Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c56f20a4 |
No files matched your search
+1
-7
@@ -20,14 +20,8 @@
|
||||
*.sql text
|
||||
|
||||
# Files that must keep LF regardless of platform — shell scripts run in Linux
|
||||
# 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.
|
||||
# containers, and Dockerfiles are read by a Linux daemon.
|
||||
*.sh text eol=lf
|
||||
*.spec text eol=lf
|
||||
*.rules text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.Dockerfile text eol=lf
|
||||
Caddyfile text eol=lf
|
||||
|
||||
+1
-142
@@ -462,55 +462,6 @@ 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
|
||||
@@ -1097,45 +1048,6 @@ 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
|
||||
@@ -1233,60 +1145,7 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
# ◆ 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
|
||||
if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$name\""; 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"
|
||||
echo "Published nightly desktop $VERSION as $TAG"
|
||||
|
||||
image:
|
||||
name: api image
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<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,12 +88,8 @@
|
||||
-->
|
||||
<PackageVersion Include="NSec.Cryptography" Version="26.4.0" />
|
||||
<PackageVersion Include="libsodium" Version="1.0.22" />
|
||||
<!--
|
||||
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" />
|
||||
<!-- Managed differential oracle for the crypto test suite only. -->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Desktop client">
|
||||
<!--
|
||||
@@ -102,14 +98,8 @@
|
||||
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="2026.0.0" />
|
||||
<PackageVersion Include="SSH.NET" Version="2025.1.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
|
||||
@@ -173,28 +163,6 @@
|
||||
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">
|
||||
<!--
|
||||
@@ -243,20 +211,5 @@
|
||||
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>
|
||||
@@ -71,7 +71,7 @@ src/
|
||||
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.Shell the view models both heads drive, the renderer's files, the palette
|
||||
DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
|
||||
DodoSSH.Client.App the desktop head: its views, and its Windows 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,9 +81,7 @@ 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.
|
||||
@@ -179,74 +177,6 @@ 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.
|
||||
@@ -259,18 +189,6 @@ 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
|
||||
@@ -1002,31 +920,10 @@ 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, 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.
|
||||
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.
|
||||
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
||||
|
||||
## Licence
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,342 +0,0 @@
|
||||
# ==================================================================================================
|
||||
# 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.
|
||||
@@ -107,81 +107,6 @@ 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
|
||||
@@ -213,11 +138,9 @@ would have become false under DPAPI alone. A gesture is still something the atta
|
||||
|
||||
### Operational
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
- **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,106 +316,6 @@ 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
|
||||
@@ -469,10 +369,6 @@ 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
|
||||
|
||||
@@ -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 | **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. |
|
||||
> | 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. |
|
||||
> | 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,24 +720,3 @@ 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.
|
||||
+3
-333
@@ -28,9 +28,8 @@ 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, 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.
|
||||
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.
|
||||
|
||||
**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
|
||||
@@ -171,32 +170,6 @@ bound to the same side of `IsImportOpen`. SettingsNav lighting a different row w
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Known Hosts as its own page
|
||||
@@ -427,82 +400,6 @@ 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
|
||||
@@ -1899,29 +1796,6 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Shared vaults: the operations that span two accounts
|
||||
@@ -2549,9 +2423,7 @@ 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. 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.
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours.
|
||||
|
||||
**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.
|
||||
@@ -2841,205 +2713,3 @@ 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,19 +388,6 @@ 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
|
||||
@@ -712,30 +699,6 @@ 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.
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
#!/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.
|
||||
|
||||
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 -qF "$soname("; 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 -qE '^lib.*\.so'; 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 -q '^libc\.so\.6'; 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 -qxF "$expected"; then
|
||||
echo "The package was built and $expected is not in it." >&2
|
||||
printf '%s\n' "$files" | head -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"
|
||||
@@ -436,31 +436,6 @@
|
||||
<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}" />
|
||||
|
||||
@@ -48,12 +48,7 @@
|
||||
the refusal has no continue button here either.
|
||||
-->
|
||||
|
||||
<!--
|
||||
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">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
||||
@@ -83,13 +78,7 @@
|
||||
</Grid>
|
||||
|
||||
<!-- ============ opening one ============ -->
|
||||
<!--
|
||||
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}">
|
||||
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding !IsConnected}">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
|
||||
@@ -185,48 +174,9 @@
|
||||
</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="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">
|
||||
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding IsConnected}" 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}">
|
||||
@@ -265,7 +215,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="5" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
|
||||
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -290,9 +240,9 @@
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ the listing ============ -->
|
||||
<Panel Grid.Row="6">
|
||||
<Panel Grid.Row="5">
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding ShowsRemoteInvitation}" Margin="24,12"
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
||||
VerticalAlignment="Top" Text="{Binding Status}" />
|
||||
|
||||
<!--
|
||||
@@ -309,7 +259,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 ShowsRemoteContents}"
|
||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding IsConnected}"
|
||||
Tapped="OnRemoteEntryTapped">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
@@ -326,27 +276,15 @@
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Border MinHeight="52" Padding="10,7">
|
||||
<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"
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="▸"
|
||||
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
|
||||
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="·"
|
||||
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
|
||||
|
||||
<StackPanel Grid.Column="2" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||
<StackPanel Grid.Column="1" 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">
|
||||
@@ -357,7 +295,7 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="detail" Text="{Binding Modified}"
|
||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Modified}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -366,50 +304,9 @@
|
||||
</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="8" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
||||
<Border Grid.Row="6" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
@@ -431,105 +328,6 @@
|
||||
</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
|
||||
@@ -588,44 +386,20 @@
|
||||
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 !IsAskingAboutRemoteEntry}">
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||
<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>
|
||||
|
||||
<!--
|
||||
◆ 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>
|
||||
|
||||
<!--
|
||||
◆ 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"
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||
<Button Grid.Column="0" 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"
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
<Panel Grid.Column="2" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
||||
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,15 +35,6 @@ 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)
|
||||
|
||||
@@ -1029,48 +1029,6 @@
|
||||
</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
|
||||
|
||||
@@ -320,14 +320,8 @@
|
||||
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}"
|
||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -33,94 +33,9 @@
|
||||
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>
|
||||
|
||||
<!--
|
||||
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">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- ============ the bar ============ -->
|
||||
<!--
|
||||
@@ -182,14 +97,8 @@
|
||||
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, 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"
|
||||
<!-- 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"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||
Text="{Binding Label}" />
|
||||
@@ -375,82 +284,11 @@
|
||||
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
||||
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
||||
|
||||
<!--
|
||||
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>
|
||||
<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>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -490,56 +328,7 @@
|
||||
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
|
||||
that has gone wrong rather than as two that are different things.
|
||||
-->
|
||||
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||
<!--
|
||||
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
|
||||
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.
|
||||
|
||||
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}" Height="33" Margin="0,5,0,0">
|
||||
<Border Grid.Row="2" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||
|
||||
@@ -74,9 +74,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
@@ -984,7 +984,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -1044,9 +1044,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -1140,11 +1140,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,34 +475,11 @@
|
||||
<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" />
|
||||
@@ -511,20 +488,14 @@
|
||||
<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
|
||||
@@ -623,13 +594,10 @@
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5b: THE "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ─────────────────────────────────────────────
|
||||
── v5b: THE HOST HEADER'S "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.
|
||||
|
||||
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>.
|
||||
no filled hover, only the border turning to the accent. See <c>SessionHeader.axaml</c>.
|
||||
-->
|
||||
<Style Selector="Button.headerghost">
|
||||
<Setter Property="Height" Value="32" />
|
||||
@@ -675,30 +643,6 @@
|
||||
<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.
|
||||
@@ -828,41 +772,6 @@
|
||||
<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
|
||||
@@ -948,30 +857,18 @@
|
||||
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
|
||||
rather than narrower.
|
||||
|
||||
◆ 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.
|
||||
◆ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
<Style Selector="Border.tile">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
@@ -979,7 +876,7 @@
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Width" Value="214" />
|
||||
<Setter Property="Width" Value="224" />
|
||||
<Setter Property="Margin" Value="0,0,10,10" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover Border.tile">
|
||||
@@ -1272,27 +1169,6 @@
|
||||
<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.
|
||||
|
||||
@@ -113,43 +113,14 @@ 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
|
||||
// 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
|
||||
// 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
|
||||
// (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, PinPrompt(desktop));
|
||||
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
|
||||
var updates = UpdateChannels.ForThisMachine();
|
||||
|
||||
var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections,
|
||||
browser, updates);
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <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(
|
||||
var viewModel = new MainWindowViewModel(
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
@@ -169,87 +140,18 @@ internal sealed partial class DodoSshApp : Application
|
||||
.ConfigureAwait(false),
|
||||
|
||||
copyToClipboard: ClipboardWriter(desktop),
|
||||
updates: updates,
|
||||
openInEditor: OpenInDefaultEditorAsync,
|
||||
watchFile: WatchFile);
|
||||
updates: updates);
|
||||
|
||||
/// <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 });
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
return Task.CompletedTask;
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <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 —
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 786 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1002 B |
@@ -225,37 +225,3 @@ $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,14 +151,6 @@
|
||||
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>
|
||||
|
||||
@@ -1,491 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
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 == '^';
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Runtime.Versioning;
|
||||
using DodoSSH.Client.Session;
|
||||
using Velopack;
|
||||
using Velopack.Sources;
|
||||
@@ -32,18 +31,11 @@ internal static class UpdateChannels
|
||||
/// </remarks>
|
||||
internal static IUpdateChannel ForThisMachine()
|
||||
{
|
||||
// ◆ 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();
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
|
||||
{
|
||||
return new UnavailableUpdateChannel();
|
||||
@@ -65,35 +57,6 @@ 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>
|
||||
|
||||
@@ -9,26 +9,17 @@ 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
|
||||
/// 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,
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <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.
|
||||
/// <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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
||||
@@ -42,20 +33,9 @@ namespace DodoSSH.Client.App.Platform;
|
||||
public static class DesktopDeviceKeyStores
|
||||
{
|
||||
/// <summary>The best store this machine supports.</summary>
|
||||
/// <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)
|
||||
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(askForPin);
|
||||
|
||||
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||
{
|
||||
@@ -67,11 +47,6 @@ public static class DesktopDeviceKeyStores
|
||||
return new MacDeviceKeyStore(paths);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux() && LinuxDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new LinuxDeviceKeyStore(paths, askForPin);
|
||||
}
|
||||
|
||||
return new UnavailableDeviceKeyStore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,28 +15,6 @@
|
||||
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
|
||||
@@ -46,69 +24,8 @@
|
||||
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="18" MaxWidth="460"
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14" MaxWidth="460"
|
||||
Margin="24">
|
||||
|
||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||
@@ -121,43 +38,15 @@
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="12"
|
||||
Foreground="{StaticResource Accent}" HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" TextAlignment="Center"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
|
||||
<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"
|
||||
@@ -173,36 +62,14 @@
|
||||
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.
|
||||
|
||||
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.
|
||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="GIVE UP"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
|
||||
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>
|
||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="CLOSE TAB"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
<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>
|
||||
@@ -1,203 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -584,56 +584,6 @@
|
||||
</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,18 +98,6 @@
|
||||
|
||||
<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,*">
|
||||
|
||||
<!--
|
||||
@@ -119,7 +107,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="26,26,26,16" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
|
||||
<Grid Grid.Row="0" Margin="16,20,16,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" />
|
||||
@@ -231,7 +219,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="26,0,26,16">
|
||||
<Border Grid.Row="1" Margin="16,0,16,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" />
|
||||
@@ -244,7 +232,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="26,0,26,12" Spacing="10">
|
||||
<StackPanel Grid.Row="2" Margin="16,0,16,12" Spacing="10">
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
|
||||
@@ -446,7 +434,7 @@
|
||||
HostsScreen.axaml.cs.
|
||||
-->
|
||||
<ScrollViewer Grid.Row="3" x:Name="Scroll" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="26,0,26,26" Spacing="16">
|
||||
<StackPanel Margin="16,0,16,20" Spacing="16">
|
||||
|
||||
<!--
|
||||
Named because it is where keyboard focus lands when the terminal gives it back, and because
|
||||
@@ -702,7 +690,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}" />
|
||||
|
||||
@@ -148,26 +148,12 @@
|
||||
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<!--
|
||||
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" />
|
||||
<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}" />
|
||||
<views:SessionStatusBar Grid.Row="2" />
|
||||
</Grid>
|
||||
<!--
|
||||
@@ -282,23 +268,14 @@
|
||||
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<!--
|
||||
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">
|
||||
<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" />
|
||||
|
||||
<Panel Grid.Row="0" Background="{StaticResource Pane}">
|
||||
<Panel Grid.Row="1" Background="{StaticResource Pane}">
|
||||
|
||||
<!--
|
||||
The other thing that can be in the terminal's rectangle: a tab whose session does not
|
||||
@@ -323,7 +300,6 @@
|
||||
|
||||
</Panel>
|
||||
|
||||
<views:SessionEndedBar Grid.Row="1" />
|
||||
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
|
||||
</Grid>
|
||||
<!--
|
||||
|
||||
@@ -54,21 +54,6 @@ 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>
|
||||
@@ -283,7 +268,6 @@ internal sealed partial class MainWindow : Window
|
||||
if (shell is { } previous)
|
||||
{
|
||||
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
|
||||
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||
}
|
||||
|
||||
@@ -302,7 +286,6 @@ internal sealed partial class MainWindow : Window
|
||||
wasUnlocked = viewModel.IsUnlocked;
|
||||
|
||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||
viewModel.PropertyChanged += OnShellPropertyChanged;
|
||||
}
|
||||
|
||||
@@ -315,15 +298,6 @@ 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
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
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,9 +40,8 @@
|
||||
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 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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -129,16 +128,19 @@
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
◆ 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.
|
||||
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.
|
||||
-->
|
||||
<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}"
|
||||
@@ -189,22 +191,17 @@
|
||||
</Grid>
|
||||
|
||||
<FlyoutBase.AttachedFlyout>
|
||||
<!--
|
||||
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">
|
||||
<Flyout Placement="TopEdgeAlignedLeft">
|
||||
<StackPanel Width="227" Spacing="8">
|
||||
|
||||
<!--
|
||||
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" Margin="11,4,11,6"
|
||||
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1"
|
||||
Foreground="{StaticResource TextGhost}"
|
||||
Text="{Binding Email}" TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding Email}"
|
||||
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!--
|
||||
@@ -249,7 +246,7 @@
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||
<Border Height="1" Background="{StaticResource BorderMid}" />
|
||||
|
||||
<!--
|
||||
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
|
||||
@@ -263,23 +260,16 @@
|
||||
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
◆ THE SAME TREATMENT AS SETTINGS ABOVE AND LOGOUT BELOW, which these two did not have: their
|
||||
label was TextGhost where the other two rows' was Text, so a menu of five equally live
|
||||
destinations drew two of them in the colour this window uses for something switched off. The
|
||||
icons stay one step quieter than the words — the idiom the nav rail's own rows already follow
|
||||
— but "quieter than the word beside it" and "dimmed" are not the same statement.
|
||||
-->
|
||||
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource TextGhost}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
@@ -287,11 +277,11 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource TextGhost}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||
<Border Height="1" Background="{StaticResource BorderMid}" />
|
||||
|
||||
<!--
|
||||
The existing sign-out flow, with its own confirm card — see
|
||||
@@ -301,7 +291,7 @@
|
||||
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
@@ -87,27 +87,12 @@
|
||||
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.
|
||||
-->
|
||||
<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>
|
||||
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -1,106 +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.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>
|
||||
@@ -1,15 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<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>
|
||||
@@ -0,0 +1,58 @@
|
||||
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,113 +19,70 @@
|
||||
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.
|
||||
-->
|
||||
|
||||
<Panel>
|
||||
<Border Width="300" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="6" Margin="16,20">
|
||||
|
||||
<!-- ============ 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>
|
||||
<!-- ============ 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 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">
|
||||
<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 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.
|
||||
<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>
|
||||
|
||||
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.
|
||||
<!-- ============ SNIPS (the terminal surface only) ============ -->
|
||||
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
|
||||
|
||||
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>
|
||||
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
|
||||
|
||||
<!--
|
||||
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 ItemsSource="{Binding SnippetsScreen.Visible}">
|
||||
<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.
|
||||
-->
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<Button Classes="sidebarrow"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{Binding}">
|
||||
ToolTip.Tip="{Binding Snippet.Command}">
|
||||
<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}"
|
||||
<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>
|
||||
@@ -133,54 +90,19 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
|
||||
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
|
||||
<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="Pin folder" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!-- ============ SNIPS (the terminal surface only) ============ -->
|
||||
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
|
||||
|
||||
<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>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -42,29 +42,14 @@
|
||||
<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 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}}" />
|
||||
IsVisible="{Binding !IsSessionConnected}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||
|
||||
@@ -60,31 +60,12 @@
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
|
||||
<!--
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
<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" />
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
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 SessionStatusBar and
|
||||
SessionSidebar 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 SessionHeader and
|
||||
SessionStatusBar 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
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
channel this application has for saying that a save failed, that a sync was refused, that a merge picked
|
||||
a winner. The design is a mock-up of a working afternoon and has nowhere for a sentence like that to go;
|
||||
dropping the bar would have meant dropping the sentence, or repeating it on six screens.
|
||||
|
||||
── One line, several terminals ────────────────────────────────────────────────────────────────────────
|
||||
One bar for a window that now holds any number of tabs, and connecting writes to it — so "Connected to
|
||||
prod-db." outlived the tab it was about, and closing prod-db left that sentence sitting under whichever
|
||||
terminal the user looked at next. A connection's own lines are now owned by the attempt that wrote them
|
||||
and are cleared when its tab is closed; everything else the vault says stays, because closing a terminal
|
||||
is not an answer to a failed save. See VaultViewModel.ForgetConnectionStatus and
|
||||
MainWindowViewModel.CloseTabAsync.
|
||||
-->
|
||||
|
||||
<Border Height="24" Background="{StaticResource Chrome}"
|
||||
|
||||
@@ -79,16 +79,8 @@
|
||||
-->
|
||||
<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.
|
||||
|
||||
◆ 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"
|
||||
<!-- 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"
|
||||
Background="{StaticResource KbdChip}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
|
||||
|
||||
@@ -33,43 +33,6 @@
|
||||
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 ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -296,18 +259,10 @@
|
||||
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" 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>
|
||||
<Grid Grid.Row="2" ColumnDefinitions="20,*,60,56" Margin="0,2,10,4">
|
||||
<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" 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="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
@@ -319,25 +274,14 @@
|
||||
SelectedItem="{Binding SelectedLocalEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||
<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}"
|
||||
<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}"
|
||||
Text="{Binding Name}" FontSize="13.5"
|
||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<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"
|
||||
<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"
|
||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
@@ -383,12 +327,10 @@
|
||||
|
||||
<!-- ==== The host ==== -->
|
||||
<!--
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*"
|
||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
|
||||
DragDrop.AllowDrop="True">
|
||||
|
||||
<!--
|
||||
@@ -402,48 +344,25 @@
|
||||
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
||||
-->
|
||||
<!--
|
||||
◆ 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.
|
||||
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.
|
||||
-->
|
||||
<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,8,0">
|
||||
Margin="0,0,12,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>
|
||||
<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">
|
||||
<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">
|
||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
@@ -452,6 +371,10 @@
|
||||
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>
|
||||
@@ -459,9 +382,8 @@
|
||||
<!--
|
||||
◆ 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: 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,
|
||||
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,
|
||||
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
|
||||
@@ -479,167 +401,9 @@
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ 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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -650,34 +414,15 @@
|
||||
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="3" Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
<Border Grid.Row="2" 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}" />
|
||||
<!--
|
||||
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>
|
||||
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource Danger}"
|
||||
Text="{Binding PendingRemoteDeletion.FullPath}" />
|
||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||
<WrapPanel>
|
||||
@@ -689,149 +434,7 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ 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}">
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
||||
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -863,16 +466,10 @@
|
||||
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="directory" />
|
||||
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
||||
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
@@ -882,63 +479,28 @@
|
||||
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="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>
|
||||
<Grid Grid.Row="4" ColumnDefinitions="20,*,56,52,48" Margin="0,2,10,4">
|
||||
<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" 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" />
|
||||
<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" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="6" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
||||
<ListBox Grid.Row="5" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<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}"
|
||||
<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}"
|
||||
Classes.exec="{Binding IsExecutable}"
|
||||
Text="{Binding Name}" FontSize="13.5"
|
||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="3" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||
<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"
|
||||
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" />
|
||||
<TextBlock Grid.Column="5" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||||
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||||
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
@@ -950,7 +512,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="6" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
|
||||
<TextBlock Grid.Row="5" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
|
||||
Text="Nothing in this directory." IsVisible="{Binding !HasRemoteEntries}" />
|
||||
|
||||
@@ -972,14 +534,8 @@
|
||||
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.
|
||||
-->
|
||||
<!--
|
||||
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}">
|
||||
<Border Grid.Row="1" Grid.RowSpan="5" Background="{StaticResource Canvas}"
|
||||
IsVisible="{Binding !IsConnected}">
|
||||
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
|
||||
@@ -1122,11 +678,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="7" IsHitTestVisible="False"
|
||||
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
|
||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
|
||||
|
||||
<Border Grid.Row="0" Grid.RowSpan="7" IsHitTestVisible="False"
|
||||
<Border Grid.Row="0" Grid.RowSpan="6" 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,20 +79,6 @@ 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,12 +72,6 @@
|
||||
"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, )",
|
||||
@@ -411,7 +405,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -471,9 +465,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -573,11 +567,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,24 +55,6 @@ 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;
|
||||
|
||||
@@ -401,32 +383,6 @@ 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": "[2026.0.0, )"
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -81,11 +81,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,9 @@ 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 SessionSidebarOpen = true)
|
||||
bool AutomaticUpdateChecks = true)
|
||||
{
|
||||
/*
|
||||
A positional record, and the defaults live on the parameters rather than on property initializers.
|
||||
@@ -106,24 +102,6 @@ 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": "[2026.0.0, )"
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -187,9 +187,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -289,11 +289,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,9 +328,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly Func<string, Task>? copyToClipboard;
|
||||
|
||||
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
||||
private readonly Action<Action> post;
|
||||
|
||||
/// <remarks>
|
||||
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
|
||||
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
|
||||
@@ -370,26 +367,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
|
||||
|
||||
/// <summary>
|
||||
/// The tab the next connection attempt belongs to, while a reconnect is being started.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>A field rather than a parameter because the two ends of this are not the same call.</b> Dialling
|
||||
/// goes through the vault's own connect command — which is the whole point, since that is where
|
||||
/// credentials, host keys and every refusal live — and what comes back is a <c>ConnectionStarting</c>
|
||||
/// event that has never heard of tabs. This is how the attempt that event announces is recognised as
|
||||
/// belonging to a tab that already exists instead of getting a new one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It cannot be stale by the time it is read. <c>ConnectionStarting</c> is raised before the vault's
|
||||
/// first await, on this thread, so the set and the read are the same turn of the dispatcher — and
|
||||
/// <see cref="ReconnectTabAsync"/> clears it afterwards for the paths that never got that far, which is
|
||||
/// every refusal the vault makes before it dials.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private TerminalTabViewModel? reconnecting;
|
||||
|
||||
/// <summary>How often the hosts screen's ago-text is restrung while it is visible.</summary>
|
||||
/// <remarks>
|
||||
/// A minute, to match the words it is restringing into: "1 min ago" is the shortest gap the wording
|
||||
@@ -493,28 +470,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
|
||||
/// the Android head's distribution separately, so it must never acquire one by accident.
|
||||
/// </param>
|
||||
/// <param name="openInEditor">
|
||||
/// Opens a local path with whatever this machine opens that kind of file with, or null on a head that
|
||||
/// has no such notion. Forwarded straight to <see cref="TransfersViewModel"/>, which is the only thing
|
||||
/// that uses it — see its own remark for why this is a delegate and what null means.
|
||||
/// </param>
|
||||
/// <param name="watchFile">
|
||||
/// Watches one local file for changes until the handle it returns is disposed, or null where nothing
|
||||
/// can. Forwarded with <paramref name="openInEditor"/> and useless without it.
|
||||
/// </param>
|
||||
/// <param name="post">
|
||||
/// Runs an action on the thread this shell's view models are read from. Defaults to the UI thread's
|
||||
/// dispatcher, which is the answer in every real head.
|
||||
/// <para>
|
||||
/// A delegate rather than <c>Dispatcher.UIThread</c> reached directly, for exactly the reason
|
||||
/// <c>TransfersViewModel</c>'s is one — see the remark there. It is process-wide and belongs to whichever
|
||||
/// thread touched it first, so a suite that runs with no window has no way to drain it and no way to
|
||||
/// know whose it is. This one exists because connection phases are reported from the handshake's own
|
||||
/// thread, which is the first thing in this class that has to cross onto the UI thread and also has to
|
||||
/// be assertable: the three <c>Dispatcher.UIThread.Post</c> calls that predate it are the ones this
|
||||
/// suite's own comments record as out of reach, and they are left alone rather than swept in here.
|
||||
/// </para>
|
||||
/// </param>
|
||||
internal MainWindowViewModel(
|
||||
ClientPaths paths,
|
||||
ClientCacheFactory caches,
|
||||
@@ -528,13 +483,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
ResumeHandler? resume = null,
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
string? deviceName = null,
|
||||
IUpdateChannel? updates = null,
|
||||
Action<Action>? post = null,
|
||||
Func<string, Task>? openInEditor = null,
|
||||
Func<string, Action, IDisposable>? watchFile = null)
|
||||
IUpdateChannel? updates = null)
|
||||
{
|
||||
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
||||
|
||||
this.paths = paths;
|
||||
this.caches = caches;
|
||||
this.workspace = workspace;
|
||||
@@ -554,7 +504,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// The fourth argument is the route out of the S3 screen's empty state. A bucket is made on the
|
||||
// keychain screen, which is a different tab and two clicks away from somebody who has gone to S3
|
||||
// to add one — so the screen that needs a bucket is given a way to reach the screen that makes one.
|
||||
transfers = CreateFileBrowser(sftpSessions, clock, openInEditor, watchFile);
|
||||
transfers = new TransfersViewModel(sftpSessions, clock, addBucket: ShowNewBucket);
|
||||
|
||||
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
|
||||
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
|
||||
@@ -581,30 +531,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
updateScreen = CreateUpdateScreen(updates);
|
||||
|
||||
ApplyStoredPreferences();
|
||||
// Read straight away rather than at first use, so the value is right before anything can read it —
|
||||
// a phone draws its terminal buttons from this, and a size that arrived a moment later would show
|
||||
// as the interface correcting itself.
|
||||
TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
|
||||
|
||||
_ = TellRendererTheFontSizeAsync();
|
||||
|
||||
StartSessionShellTracking();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes this machine's own preferences off disk, before anything can read them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read straight away rather than at first use, and both of them for the same reason: whatever is stored
|
||||
/// is what the first window draws. A phone builds its terminal's font buttons from the size, and the
|
||||
/// session shell decides whether to give a sidebar 300 pixels — either arriving a moment later shows as
|
||||
/// the interface correcting itself in front of the user.
|
||||
/// </remarks>
|
||||
private void ApplyStoredPreferences()
|
||||
{
|
||||
var stored = settings.Read();
|
||||
|
||||
TerminalFontSize = ClientSettings.ClampTerminalFontSize(stored.TerminalFontSize);
|
||||
IsSessionSidebarOpen = stored.SessionSidebarOpen;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
|
||||
/// </summary>
|
||||
@@ -623,32 +559,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
_ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
|
||||
}
|
||||
|
||||
/// <summary>Builds the file browser, which is composed once and outlives every lock.</summary>
|
||||
/// <remarks>
|
||||
/// A method rather than four more lines in the constructor, which is already at the length the analyzers
|
||||
/// allow — the same reason <see cref="CreateUpdateScreen"/> is one.
|
||||
/// <para>
|
||||
/// It is handed this shell's own posting delegate rather than left to default to the UI dispatcher,
|
||||
/// which is what it did while nothing it posted was worth asserting on. A connection lost underneath the
|
||||
/// file browser is raised on SSH.NET's message-listener thread and has to cross onto the thread these
|
||||
/// view models are read from — the same crossing connection phases already make — and a suite with no
|
||||
/// window has no way to drain a dispatcher it does not own. Both defaults are the UI dispatcher, so it
|
||||
/// changes nothing in either head.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private TransfersViewModel CreateFileBrowser(
|
||||
ISftpSessionFactory sftpSessions,
|
||||
TimeProvider clock,
|
||||
Func<string, Task>? openInEditor,
|
||||
Func<string, Action, IDisposable>? watchFile) =>
|
||||
new(
|
||||
sftpSessions,
|
||||
clock,
|
||||
post: post,
|
||||
addBucket: ShowNewBucket,
|
||||
openInEditor: openInEditor,
|
||||
watchFile: watchFile);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the updater, kept for the life of the process like the workspace and the transfer queue.
|
||||
/// </summary>
|
||||
@@ -1074,20 +984,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
internal event EventHandler? TerminalSessionOpened;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when something this shell did belongs in the terminal the user is already looking at, so the
|
||||
/// view can put the keyboard back there.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="TerminalSessionOpened"/> because no session opened: the sidebar's SNIPS row
|
||||
/// typed into one that was already running, and the click that did it moved Win32 focus onto an Avalonia
|
||||
/// button. The page cannot fix that from its side — see the <c>term.focus()</c> at the end of
|
||||
/// <c>terminal.js</c>'s paste handler, which only ever reaches <c>document.activeElement</c> — so the
|
||||
/// half that can only be done by the host is asked for here. The view re-checks that a terminal is
|
||||
/// actually showing before it acts; see <c>MainWindow.FocusTerminalWhenLaidOut</c>.
|
||||
/// </remarks>
|
||||
internal event EventHandler? TerminalFocusRequested;
|
||||
|
||||
internal bool IsStarting => State == ShellState.Starting;
|
||||
|
||||
internal bool IsNeedingServer => State == ShellState.NeedsServer;
|
||||
@@ -2028,9 +1924,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// Closes one terminal, ending its shell.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
|
||||
/// close button and not a menu item: closing the window somebody's job is running in should take exactly
|
||||
/// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It takes the status line with it, when that line is this tab's.</b> The strip along the bottom is
|
||||
/// one line for the whole window — see <c>StatusBar.axaml</c> — and connecting writes to it, so closing
|
||||
/// prod-db used to leave "Connected to prod-db." reporting on a session that is gone, over whichever
|
||||
/// terminal the user looked at next. Only the sentence this attempt wrote goes; anything the vault has
|
||||
/// said since stays, because a failed save is not something closing a terminal answers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task CloseTabAsync(TerminalTabViewModel tab)
|
||||
@@ -2064,6 +1969,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
RaiseTabState();
|
||||
|
||||
// Only this tab's own sentence, and only if that is what the bar currently holds — see the remark.
|
||||
Vault?.ForgetConnectionStatus(tab.AttemptId);
|
||||
|
||||
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
|
||||
// changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
|
||||
// terminal just went would keep a lit dot until something else happened to move the selection.
|
||||
@@ -2198,9 +2106,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
/// <remarks>
|
||||
/// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one
|
||||
/// that merely contains it, both come before a match found only in the address, and all three come
|
||||
/// before one found only in the vault's name. Typing three characters of a name people use daily should
|
||||
/// not put that host third. See <see cref="Rank"/>.
|
||||
/// that merely contains it, and both come before a match found only in the address. Typing three
|
||||
/// characters of a name people use daily should not put that host third.
|
||||
/// </remarks>
|
||||
private void RefreshSearchResults()
|
||||
{
|
||||
@@ -2233,21 +2140,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(HasSearchResults));
|
||||
}
|
||||
|
||||
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The vault comes last, and only when the row prints it.</b> Its name is the widest of the three
|
||||
/// readings — one word can match every host in a team's vault at once — so a host whose own name or
|
||||
/// address answers the query has to come first, or typing a machine's name would bury it under everybody
|
||||
/// else's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="HostRowViewModel.HasVaultBadge"/> rather than the name itself, so this matches exactly what
|
||||
/// the row shows: a session holding one vault prints no vault on any row, and matching a name nothing
|
||||
/// displays would answer a query with a list whose reason for existing is invisible — type "personal"
|
||||
/// there and every host comes back for no reason anybody can see.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static int Rank(HostRowViewModel host, string query)
|
||||
{
|
||||
if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
|
||||
@@ -2260,14 +2152,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return host.HasVaultBadge
|
||||
&& host.VaultName.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|
||||
? 3
|
||||
return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|
||||
? 2
|
||||
: int.MaxValue;
|
||||
}
|
||||
|
||||
@@ -3369,7 +3255,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
oldValue.PropertyChanged -= OnVaultPropertyChanged;
|
||||
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
|
||||
|
||||
// The four connection events are kept while an attempt is still in flight, and that is not an
|
||||
// The three connection events are kept while an attempt is still in flight, and that is not an
|
||||
// oversight. Locking does not end a handshake any more than it ends a shell — the workspace is
|
||||
// what holds both, and it outlives every vault — so a connection started just before a lock still
|
||||
// has an answer coming, and the tab standing in for it is still in the strip afterwards, because
|
||||
@@ -3383,7 +3269,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (attempts.Count == 0)
|
||||
{
|
||||
oldValue.ConnectionStarting -= OnVaultConnectionStarting;
|
||||
oldValue.ConnectionProgress -= OnVaultConnectionProgress;
|
||||
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
|
||||
oldValue.SessionOpened -= OnVaultSessionOpened;
|
||||
}
|
||||
@@ -3392,7 +3277,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (newValue is not null)
|
||||
{
|
||||
newValue.ConnectionStarting += OnVaultConnectionStarting;
|
||||
newValue.ConnectionProgress += OnVaultConnectionProgress;
|
||||
newValue.ConnectionFailed += OnVaultConnectionFailed;
|
||||
newValue.SessionOpened += OnVaultSessionOpened;
|
||||
newValue.PropertyChanged += OnVaultPropertyChanged;
|
||||
@@ -3528,64 +3412,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
|
||||
{
|
||||
if (reconnecting is { } existing)
|
||||
{
|
||||
// A reconnect. The tab is already in the strip, already selected, and the user is looking at it —
|
||||
// so it goes back to connecting in place rather than a second tab for the same machine appearing
|
||||
// beside the one they pressed the button on. See ReconnectTabAsync.
|
||||
reconnecting = null;
|
||||
|
||||
existing.Reconnecting();
|
||||
attempts[e.AttemptId] = existing;
|
||||
|
||||
// The card replaces the pane the moment the session id is dropped, and both flags turn on that.
|
||||
RaiseTerminalState();
|
||||
RaiseSessionState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
||||
var tab = new TerminalTabViewModel(e.Label, e.Address) { AttemptId = e.AttemptId };
|
||||
attempts[e.AttemptId] = tab;
|
||||
|
||||
AdoptTab(tab);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a connecting tab's step list on, from the handshake's own report.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one place the phases raised by <c>VaultViewModel.ConnectionProgress</c> are marshalled, and the
|
||||
/// reason that event does not marshal for itself: doing it here means it happens once, visibly, at the
|
||||
/// only boundary that cares — everything this touches is a view model an Avalonia binding is attached to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Posted unconditionally rather than applied inline when it looks safe. Some phases really do arrive
|
||||
/// on this thread — the first is reported before the handshake has yielded at all — and a
|
||||
/// <c>CheckAccess</c> fast path for them would buy one dispatcher turn on a card that is up for seconds,
|
||||
/// at the price of the two orderings existing at once and only one of them being the one a test runs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A step that arrives after the attempt has settled is harmless and needs no guard here:
|
||||
/// <see cref="TerminalTabViewModel.Advance"/> ignores anything reported to a tab that is no longer
|
||||
/// connecting, which is what a posted phase landing behind its own <see cref="OnVaultSessionOpened"/>
|
||||
/// looks like.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A report for an attempt with no tab is dropped, exactly as the other two handlers drop one: the user
|
||||
/// closed the connecting tab and there is nothing left to draw a step on. The handshake is not affected
|
||||
/// and its session is still adopted if it opens.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnVaultConnectionProgress(object? sender, ConnectionProgressEventArgs e) => post(() =>
|
||||
{
|
||||
if (attempts.TryGetValue(e.AttemptId, out var tab))
|
||||
{
|
||||
tab.Advance(e.Step);
|
||||
}
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Redraws the vault menu after a synchronisation pass found a vault this account had not seen.
|
||||
/// </summary>
|
||||
@@ -3609,6 +3441,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// dropping it would leave a shell running with nothing in the window naming it.
|
||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||
{
|
||||
AttemptId = e.AttemptId,
|
||||
StartedAt = clock.GetUtcNow(),
|
||||
Cipher = NullIfEmpty(e.Cipher),
|
||||
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||
@@ -3819,313 +3652,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Surface = ShellSurface.Terminal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The host this tab was opened against, or null when the keychain has nothing under that name.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Matched on the label, which is the same handle <see cref="RefreshConnectedHosts"/> matches on and for
|
||||
/// the same reason: a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
||||
/// still mean anything after a lock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
|
||||
/// able to say no: a machine reached by typing an address rather than from the keychain, which has no row
|
||||
/// to dial again, and a tab whose host was deleted or whose vault is locked.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
|
||||
tab is null || Vault is not { } vault
|
||||
? null
|
||||
: vault.Hosts.FirstOrDefault(
|
||||
row => string.Equals(row.Label, tab.Label, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected tab has stopped and there is something to dial again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both halves are needed and neither implies the other. The tab has to be finished — offering a
|
||||
/// reconnect beside a live shell is offering to throw it away — and the machine has to still be in the
|
||||
/// keychain, because that is the only place the credentials to redial it exist. A tab typed in by hand
|
||||
/// fails the second half, which is honest: this client cannot dial it again without the password, and a
|
||||
/// button that raised a password box under the word RECONNECT would be a different promise.
|
||||
/// </remarks>
|
||||
internal bool CanReconnectSelectedTab =>
|
||||
IsTerminalSurface
|
||||
&& SelectedTab is { CanOfferReconnect: true }
|
||||
&& HostFor(SelectedTab) is not null;
|
||||
|
||||
/// <summary>
|
||||
/// The word the status bar shows in place of CONNECTED once the selected tab's shell has stopped, or
|
||||
/// null while it has not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both surfaces, and only one word on the file side. A shell has two endings worth telling apart —
|
||||
/// somebody typed <c>exit</c>, or the machine stopped answering — and a file session has one: this
|
||||
/// screen closing it is a disconnect nobody needs told about, so the only ending left to word is the one
|
||||
/// that happened on its own.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A bucket never reaches this. It is HTTP per request with no connection to lose, so
|
||||
/// <see cref="TransfersViewModel.IsConnectionLost"/> is false for one however badly a request goes —
|
||||
/// which is honest rather than a gap: a failed request against a bucket is a failed request, and the
|
||||
/// screen already reports it as one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string? SessionEndedText => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => SelectedTab?.EndedSummary,
|
||||
_ when IsTransfersShowing => Transfers.IsConnectionLost ? "DISCONNECTED" : null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>Whether the selected tab's session ended because the connection broke.</summary>
|
||||
/// <remarks>
|
||||
/// Drawn in the palette's red where a shell that merely exited is drawn in grey, which is the whole
|
||||
/// reason this is a second question rather than "did it end". One of the two is somebody having finished
|
||||
/// what they were doing and the other is a machine that stopped answering.
|
||||
/// </remarks>
|
||||
internal bool IsSessionDropped => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => SelectedTab?.EndReason is SshSessionEnd.ConnectionLost,
|
||||
|
||||
// The file side's one ending is the lost one, so where the terminal has to ask which it was, this
|
||||
// only has to ask whether there was one.
|
||||
_ when IsTransfersShowing => Transfers.IsConnectionLost,
|
||||
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether the status bar should say NOT CONNECTED rather than name an ending.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The third of three, and it exists so that no two of them are ever drawn at once. A tab still dialling
|
||||
/// is this; a tab whose shell has stopped has a word of its own, and printing NOT CONNECTED beside
|
||||
/// DISCONNECTED would answer the same question twice in two vocabularies.
|
||||
/// </remarks>
|
||||
internal bool IsSessionIdle => !IsSessionConnected && SessionEndedText is null;
|
||||
|
||||
/// <summary>
|
||||
/// The sentence under that word — what the remote or the transport actually said, or why a reconnect was
|
||||
/// just refused.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Usually the same string the renderer has already written into the pane. Drawn outside it as well
|
||||
/// because the pane scrolls: a shell that printed a page of shutdown messages before it went has pushed
|
||||
/// the line explaining itself off the screen, and this is the copy that stays put. See
|
||||
/// <see cref="TerminalTabViewModel.EndedDetail"/> for the case where it is not that string.
|
||||
/// </remarks>
|
||||
internal string? SessionEndedDetail => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => SelectedTab?.EndedDetail,
|
||||
_ when IsTransfersShowing => Transfers.LostConnection,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether the strip under a stopped session may offer to dial again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One question resolved by surface, for the reason <see cref="SessionAddress"/> is: the strip is the
|
||||
/// same markup on both, and a view branching on which one it is drawn over would be the shell's own job
|
||||
/// done in XAML. The two halves are genuinely different questions — a terminal needs a keychain entry to
|
||||
/// redial, and a file session already captured what it would dial when it connected.
|
||||
/// </remarks>
|
||||
internal bool CanReconnectSession => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => CanReconnectSelectedTab,
|
||||
_ when IsTransfersShowing => Transfers.IsConnectionLost,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>What the strip's second button says, which is not the same act on the two surfaces.</summary>
|
||||
/// <remarks>
|
||||
/// A terminal's tab is a thing that can be taken away and a file session's pane is not — there is one of
|
||||
/// it, and what "dismiss" means there is putting the screen back to its invitation. Two labels rather
|
||||
/// than one vague one, because a button that said CLOSE on both would be doing noticeably different
|
||||
/// things.
|
||||
/// </remarks>
|
||||
internal string SessionEndedDismissLabel => IsTerminalSurface ? "CLOSE TAB" : "DISCONNECT";
|
||||
|
||||
/// <summary>Dials again, whichever kind of session stopped.</summary>
|
||||
/// <remarks>
|
||||
/// Each half is the surface's own reconnect rather than a third path: the terminal's goes through the
|
||||
/// vault's connect command into the tab that stopped, and the file side's re-opens on the remote it
|
||||
/// captured and walks back to the directory it was showing.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ReconnectSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsTerminalSurface)
|
||||
{
|
||||
await ReconnectTabAsync(SelectedTab).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsTransfersShowing)
|
||||
{
|
||||
await Transfers.ReconnectCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Takes the stopped session away, whichever kind it is.</summary>
|
||||
/// <inheritdoc cref="SessionEndedDismissLabel" path="/remarks" />
|
||||
[RelayCommand]
|
||||
private async Task DismissEndedSessionAsync()
|
||||
{
|
||||
if (IsTerminalSurface)
|
||||
{
|
||||
if (SelectedTab is { } tab)
|
||||
{
|
||||
await CloseTabAsync(tab).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsTransfersShowing)
|
||||
{
|
||||
await Transfers.DisconnectCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a new shell on the machine a stopped tab was pointed at, in that tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>It dials through the vault's ordinary connect path, and that is the whole design.</b> Every
|
||||
/// refusal a connection can meet — a key that will not resolve, an unknown host key, a mismatch, a group
|
||||
/// default that has changed since the first attempt — is decided in one place, and a reconnect that
|
||||
/// reached past it into <c>TerminalWorkspace</c> would be a second connect path that got all of those
|
||||
/// subtly wrong. What this method adds to that path is one thing: which tab the attempt belongs to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The old session is closed before the new one is asked for.</b> A dead shell still holds an
|
||||
/// authenticated connection this process has stopped reading, and its pane still holds an xterm, a
|
||||
/// scrollback and a WebGL context — of which a browser hands out about sixteen. Reconnecting without
|
||||
/// closing it would leak one of each per reconnect, invisibly, inside the WebView.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A host that asks for a typed password is sent to the keychain rather than refused.</b> The vault
|
||||
/// raises its password sheet for one, and that sheet is drawn on the hosts screen — so this navigates
|
||||
/// there instead of arming the tab, and the session that results is a new tab. That is a smaller wrong
|
||||
/// than a button that appears to do nothing because the thing it raised is on a screen nobody is looking
|
||||
/// at.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ReconnectTabAsync(TerminalTabViewModel? tab)
|
||||
{
|
||||
if (tab is null || Vault is not { } vault || !tab.CanOfferReconnect)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (HostFor(tab) is not { } host)
|
||||
{
|
||||
Refuse(tab, $"{tab.Label} is not in this keychain any more, so there is nothing here to dial again.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Selected first, so that a reconnect started from the tab strip while another tab was showing puts
|
||||
// the user in front of the attempt they just asked for.
|
||||
SelectTab(tab);
|
||||
|
||||
// Before the question below, because the question is about the selection: SelectedHostAsksForAPassword
|
||||
// reads whichever row is lit, and asking it about a host it has not been pointed at yet would answer
|
||||
// for the previous one. The connect command assigns the same row again, so this is not a second
|
||||
// selection — it is the same one, made early enough to be asked about.
|
||||
vault.SelectedHost = host;
|
||||
|
||||
// The same pair of conditions ConnectToRowAsync itself branches on: a machine that needs a secret and
|
||||
// has not been given one is a sheet being raised rather than a connection being made, and a sheet is
|
||||
// drawn on a screen this surface is not.
|
||||
if (vault.SelectedHostAsksForAPassword && vault.ConnectPassword.Length == 0)
|
||||
{
|
||||
ShowScreen(ShellScreen.Hosts);
|
||||
|
||||
// The command's own second half raises the sheet and says which machine it is for; running it
|
||||
// rather than setting the flag here keeps that wording in one place.
|
||||
await vault.ConnectToRowCommand.ExecuteAsync(host).ConfigureAwait(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await RedialIntoAsync(vault, host, tab).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Dials <paramref name="host"/> with the attempt bound to <paramref name="tab"/>.</summary>
|
||||
/// <remarks>
|
||||
/// Split from <see cref="ReconnectTabAsync"/> so that the arming, the dial and the old session's release
|
||||
/// read as the one sequence they are — the refusals above it are a separate concern and each of them
|
||||
/// returns before reaching here.
|
||||
/// </remarks>
|
||||
private async Task RedialIntoAsync(VaultViewModel vault, HostRowViewModel host, TerminalTabViewModel tab)
|
||||
{
|
||||
var previous = tab.SessionId;
|
||||
|
||||
reconnecting = tab;
|
||||
|
||||
bool dialled;
|
||||
|
||||
try
|
||||
{
|
||||
await vault.ConnectToRowCommand.ExecuteAsync(host).ConfigureAwait(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The field being gone means OnVaultConnectionStarting took it, which is the one and only proof
|
||||
// that an attempt actually began. Asking the attempts map instead does not work: a handshake that
|
||||
// has already finished — succeeded or been refused — has been taken out of it again, and in this
|
||||
// suite's fakes that happens before this line is reached.
|
||||
dialled = reconnecting is null;
|
||||
|
||||
// Cleared however it went, so a refusal the vault made before dialling cannot leave the field
|
||||
// armed to capture somebody else's next connection into this tab.
|
||||
reconnecting = null;
|
||||
}
|
||||
|
||||
if (!dialled)
|
||||
{
|
||||
// The vault turned it down before starting one — a key that has left the keychain, a group
|
||||
// binding that no longer resolves. Its own Status carries the reason and is drawn on a screen
|
||||
// this surface is not, so it is repeated where the button was pressed. The old session is left
|
||||
// alone: there is nothing to replace it with.
|
||||
Refuse(
|
||||
tab,
|
||||
vault.Status is { Length: > 0 } refusal
|
||||
? refusal
|
||||
: $"{tab.Label} could not be dialled again.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (previous != 0)
|
||||
{
|
||||
// After the attempt has started, not before: the pane the user was reading stays up for as long
|
||||
// as there is nothing to replace it with, and closing it first would blank the rectangle for the
|
||||
// length of a handshake.
|
||||
await workspace.CloseSessionAsync(previous).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Says why a reconnect is not happening, where the strip that offered it is looking.</summary>
|
||||
/// <remarks>
|
||||
/// On the tab rather than in a field of this shell's own, because it is a fact about that tab: switching
|
||||
/// to another terminal and back should not lose it, and switching to a different one should not show it.
|
||||
/// See <see cref="TerminalTabViewModel.EndedDetail"/>.
|
||||
/// </remarks>
|
||||
private void Refuse(TerminalTabViewModel tab, string reason)
|
||||
{
|
||||
tab.Status = reason;
|
||||
|
||||
RaiseSessionState();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a tab dead when its shell ends on its own.
|
||||
/// </summary>
|
||||
@@ -4145,21 +3671,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// gap rather than worth blocking this handler on the recorder's queue.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => post(() =>
|
||||
{
|
||||
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
||||
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
tab.Ended(e.Outcome.End, e.Outcome.Description);
|
||||
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
||||
{
|
||||
tab.IsLive = false;
|
||||
}
|
||||
|
||||
// Only for the tab being looked at, which is what these three already ask. The rectangle does not
|
||||
// change — a dead tab keeps its pane and its scrollback — but the status bar under it does, and
|
||||
// the reconnect beside CONNECTED appears from here.
|
||||
RaiseSessionState();
|
||||
}
|
||||
|
||||
RefreshConnectedHosts();
|
||||
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
|
||||
});
|
||||
RefreshConnectedHosts();
|
||||
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
|
||||
@@ -4259,72 +3781,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
(IsTerminalSurface && SelectedTab is not null)
|
||||
|| (IsTransfersShowing && Transfers.IsConnected);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the sidebar is drawn in full, as opposed to collapsed to the rail that brings it back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A separate question from <see cref="ShowsQuickAccessSidebar"/>, and the two are not interchangeable:
|
||||
/// that one is "is there a session for this to be about", which the shell answers, and this one is "does
|
||||
/// the person want to see it", which only they can. Closed still draws something — a 34-pixel rail with
|
||||
/// the way back on it; see <c>SessionSidebar.axaml</c> — because a panel that vanishes with no trace of
|
||||
/// how to get it back is one people report as lost rather than as closed. Remembered between launches;
|
||||
/// see <see cref="ToggleSessionSidebar"/> and <c>ClientSettings.SessionSidebarOpen</c>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isSessionSidebarOpen = true;
|
||||
|
||||
/// <summary>Opens the session sidebar, or closes it to its rail.</summary>
|
||||
/// <remarks>
|
||||
/// Written through on every toggle rather than on shutdown: this shell is disposed on paths that do not
|
||||
/// all run to completion — a killed process, a phone's activity going away — and a preference that
|
||||
/// survives only a clean exit is one that will sometimes be forgotten for no reason the user can see.
|
||||
/// The store swallows its own failures and says whether it wrote; nothing here can do anything useful
|
||||
/// with the answer, so the toggle stands whether or not the disk took it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ToggleSessionSidebar()
|
||||
{
|
||||
IsSessionSidebarOpen = !IsSessionSidebarOpen;
|
||||
|
||||
_ = settings.Write(settings.Read() with { SessionSidebarOpen = IsSessionSidebarOpen });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The label on the sidebar's cross-surface row: where the other half of this host is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// v5c-4 moved this button off the session shell's own 60-pixel header row and into the sidebar, and the
|
||||
/// header went with it — see <c>SessionSidebar.axaml</c>. What the two surfaces hand in separately used
|
||||
/// to be a pair of properties on the header control; it is resolved here now, for the same reason
|
||||
/// <see cref="SessionAddress"/> is: the sidebar is one control drawn on both surfaces, and a view that
|
||||
/// branched on which one it was would be asking a question the shell has already answered.
|
||||
/// </remarks>
|
||||
internal string SessionCrossSurfaceLabel => IsTerminalSurface ? "Open SFTP" : "Open terminal";
|
||||
|
||||
/// <summary>Goes to the other half of the session the sidebar is about.</summary>
|
||||
/// <remarks>
|
||||
/// The two directions were two commands bound from two usages of the header control, and they still are
|
||||
/// two methods — <see cref="SelectFilesHostAsync"/> takes a tab and opens an SFTP connection to its host;
|
||||
/// <see cref="OpenTerminalForFilesHostAsync"/> dials a fresh terminal at whatever SFTP has open, because
|
||||
/// there is no terminal session to reuse. What is new is only that one control now asks for both, so the
|
||||
/// branch lives here beside <see cref="SessionCrossSurfaceLabel"/>, which has to agree with it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task OpenOtherSurfaceAsync()
|
||||
{
|
||||
if (IsTerminalSurface)
|
||||
{
|
||||
if (SelectedTab is { } tab)
|
||||
{
|
||||
await SelectFilesHostAsync(tab).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await OpenTerminalForFilesHostAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
|
||||
/// </summary>
|
||||
@@ -4397,12 +3853,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (screen.CanInsert)
|
||||
{
|
||||
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
|
||||
// The click that got here took the keyboard off the terminal and gave it to the sidebar row, so
|
||||
// the command lands at a prompt that cannot be typed at until somebody clicks the pane. Asked
|
||||
// for after the insert rather than before it, so the caret arrives to find the text already
|
||||
// there. See TerminalFocusRequested.
|
||||
TerminalFocusRequested?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4496,27 +3946,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account and endpoint the session shell's sidebar and status bar are about right now, or null when
|
||||
/// The account and endpoint the session shell's header and status bar are about right now, or null when
|
||||
/// neither surface has one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One property reading whichever surface is showing, rather than one binding per surface reading its own
|
||||
/// source directly — <c>SessionSidebar.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
|
||||
/// source directly — <c>SessionHeader.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
|
||||
/// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view
|
||||
/// to. It was the retired header row that printed this first; v5c-4 moved the line into the sidebar's own
|
||||
/// session block and left this property exactly as it was. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
|
||||
/// to. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
|
||||
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
|
||||
/// </remarks>
|
||||
internal string? SessionAddress => Surface switch
|
||||
{
|
||||
ShellSurface.Terminal => SelectedTab?.Address,
|
||||
|
||||
// Connected, or connected until the link went. A session that has just been lost still has a host
|
||||
// worth naming — it is the one the strip is offering to dial again — and requiring IsConnected here
|
||||
// collapsed the entire left half of the status bar at exactly the moment it had the most to say.
|
||||
_ when IsTransfersShowing =>
|
||||
Transfers.IsConnected || Transfers.IsConnectionLost ? Transfers.ConnectedTo : null,
|
||||
|
||||
_ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -4649,23 +4092,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
OnPropertyChanged(nameof(SessionAddress));
|
||||
OnPropertyChanged(nameof(IsSessionConnected));
|
||||
OnPropertyChanged(nameof(SessionEndedText));
|
||||
OnPropertyChanged(nameof(SessionEndedDetail));
|
||||
OnPropertyChanged(nameof(IsSessionDropped));
|
||||
OnPropertyChanged(nameof(IsSessionIdle));
|
||||
OnPropertyChanged(nameof(CanReconnectSelectedTab));
|
||||
OnPropertyChanged(nameof(CanReconnectSession));
|
||||
OnPropertyChanged(nameof(SessionEndedDismissLabel));
|
||||
OnPropertyChanged(nameof(SessionElapsedText));
|
||||
OnPropertyChanged(nameof(SessionCipher));
|
||||
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
|
||||
OnPropertyChanged(nameof(SessionIdentityLabel));
|
||||
OnPropertyChanged(nameof(SessionIdentityText));
|
||||
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
||||
|
||||
// v5c-4: the sidebar's cross-surface row says where the other half of this session is, so it turns
|
||||
// over with the surface exactly as the facts above do.
|
||||
OnPropertyChanged(nameof(SessionCrossSurfaceLabel));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -4679,8 +4111,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
|
||||
or nameof(TransfersViewModel.IsConnected)
|
||||
or nameof(TransfersViewModel.IsConnectionLost)
|
||||
or nameof(TransfersViewModel.LostConnection)
|
||||
or nameof(TransfersViewModel.Remote)
|
||||
or nameof(TransfersViewModel.ConnectedTo)
|
||||
or nameof(TransfersViewModel.ConnectedCipher)
|
||||
|
||||
@@ -1,129 +1,7 @@
|
||||
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>
|
||||
@@ -179,23 +57,8 @@ 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>
|
||||
@@ -209,12 +72,6 @@ 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>
|
||||
@@ -227,6 +84,18 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
/// </remarks>
|
||||
internal uint SessionId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The connection attempt this tab was opened for, or <see cref="Guid.Empty"/> for a tab nothing dialled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Carried only so that closing this tab can take the vault's status line with it when the line is about
|
||||
/// this connection — see <c>VaultViewModel.ForgetConnectionStatus</c>. It is the attempt id and not the
|
||||
/// session id because the message can be written before a session exists ("Connecting to prod-db…") and
|
||||
/// after one has stopped existing, and because the two failure sentences a tab can end on never have a
|
||||
/// session id at all.
|
||||
/// </remarks>
|
||||
internal Guid AttemptId { get; init; }
|
||||
|
||||
internal string Label { get; }
|
||||
|
||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||
@@ -271,36 +140,6 @@ 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;
|
||||
@@ -351,95 +190,6 @@ 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;
|
||||
|
||||
@@ -449,70 +199,13 @@ 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>
|
||||
@@ -527,152 +220,13 @@ 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,6 +74,16 @@ 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>
|
||||
/// <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.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly IUpdateChannel updates;
|
||||
private readonly ClientSettingsStore settings;
|
||||
private readonly TimeProvider clock;
|
||||
@@ -232,40 +242,16 @@ 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.Yield();
|
||||
await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
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));
|
||||
|
||||
@@ -943,31 +943,6 @@ internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, s
|
||||
internal string Address { get; } = address;
|
||||
}
|
||||
|
||||
/// <summary>A connection that has got as far as a named step.</summary>
|
||||
/// <param name="attemptId">The attempt this is about.</param>
|
||||
/// <param name="step">The step that has just begun.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The fourth of the attempt events, and the only one that can be raised more than once for an attempt. It
|
||||
/// exists because the other three say a connection started and then, seconds later, whether it worked — and
|
||||
/// the seconds in between are the whole of what a user staring at a connecting card is trying to find out.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Raised on whichever thread the handshake is on.</b> SSH.NET reports the interior of a connection from
|
||||
/// its own thread, and this event is that report forwarded rather than a copy made on a timer, so a
|
||||
/// subscriber that touches a view model must marshal for itself. <c>MainWindowViewModel</c> does; see the
|
||||
/// handler.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ConnectionProgressEventArgs(Guid attemptId, ConnectionStep step) : EventArgs
|
||||
{
|
||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" />
|
||||
internal Guid AttemptId { get; } = attemptId;
|
||||
|
||||
/// <inheritdoc cref="ConnectionProgressEventArgs" path="/param[@name='step']" />
|
||||
internal ConnectionStep Step { get; } = step;
|
||||
}
|
||||
|
||||
/// <summary>A connection that was asked for and did not happen.</summary>
|
||||
/// <param name="attemptId">The attempt that has just ended.</param>
|
||||
/// <param name="reason">What to say about it, in the tab.</param>
|
||||
@@ -2155,6 +2130,60 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The connection <see cref="Status"/> is currently about, or <see cref="Guid.Empty"/> when it is about
|
||||
/// something else — a save, a sync, a refusal that has nothing to do with a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The status line is one line for the whole window, and most of what goes through it is about the vault,
|
||||
/// which is one thing however many terminals are open. A connection is not: "Connected to prod-db." is
|
||||
/// about one tab, and it used to outlive that tab — close prod-db, look at the terminal beside it, and the
|
||||
/// strip along the bottom was still reporting on a session that is no longer there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Ownership rather than a blanket clear on every close, because the alternative loses the sentences this
|
||||
/// bar exists for: a failed save or a refused sync is what the design deleted the bar's other four fields
|
||||
/// to make room for, and closing a terminal is no reason to take one away.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Reset by <see cref="OnStatusChanged"/> on every write, so the only messages that carry an owner are the
|
||||
/// ones <see cref="SayAbout"/> writes. Anything assigning <see cref="Status"/> in the ordinary way says,
|
||||
/// by doing so, that its message belongs to no tab.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private Guid statusAttemptId;
|
||||
|
||||
partial void OnStatusChanged(string value) => statusAttemptId = Guid.Empty;
|
||||
|
||||
/// <summary>Writes a status line that belongs to one connection attempt.</summary>
|
||||
/// <remarks>
|
||||
/// After the assignment, not before: <see cref="OnStatusChanged"/> clears the owner on every write, which
|
||||
/// is what makes "belongs to nobody" the default rather than something each of the other status writes
|
||||
/// would have to remember to say.
|
||||
/// </remarks>
|
||||
private void SayAbout(ConnectionAttemptEventArgs attempt, string message)
|
||||
{
|
||||
Status = message;
|
||||
statusAttemptId = attempt.AttemptId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the status line if what it is saying is about this connection, and leaves it alone otherwise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called by the shell when a tab is closed — see <c>MainWindowViewModel.CloseTabAsync</c>. Cleared to
|
||||
/// empty rather than replaced with "Closed prod-db.": the tab going is the report, it is the report the
|
||||
/// user just made happen, and a bar that answers every close with a sentence is a bar people stop reading.
|
||||
/// </remarks>
|
||||
internal void ForgetConnectionStatus(Guid attemptId)
|
||||
{
|
||||
if (attemptId != Guid.Empty && statusAttemptId == attemptId)
|
||||
{
|
||||
Status = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private int pendingChanges;
|
||||
|
||||
@@ -3111,37 +3140,6 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private AuthenticationChoice? editorSelectedAuthentication;
|
||||
|
||||
// ---- Making a credential from inside the host editor ----
|
||||
// A fifth set of editor fields, and deliberately not the keychain screen's four. Sharing them would put
|
||||
// IsEditingCredential — which AVaultEditorIsInTheWay asks about — true while the user is on the Hosts
|
||||
// screen, and the whole Vault screen would refuse to open an editor with a sentence naming a form on
|
||||
// another screen. That is the exact failure AHostEditorIsInTheWay was split out to end; see its remarks.
|
||||
|
||||
/// <summary>Whether the host editor is showing its own new-credential form.</summary>
|
||||
[ObservableProperty]
|
||||
private bool isAddingEditorCredential;
|
||||
|
||||
/// <summary>The name in the host editor's new-credential form.</summary>
|
||||
[ObservableProperty]
|
||||
private string editorNewCredentialLabel = string.Empty;
|
||||
|
||||
/// <inheritdoc cref="CredentialEditorUsername" path="/remarks" />
|
||||
[ObservableProperty]
|
||||
private string editorNewCredentialUsername = string.Empty;
|
||||
|
||||
/// <remarks>
|
||||
/// Holds a password for as long as the form is open, on the same terms the keychain's box does — see
|
||||
/// <see cref="CredentialEditorPassword"/>. Cleared by every path that closes this form, including the
|
||||
/// ones that close the host editor around it, so a password typed here cannot outlive the form and
|
||||
/// reappear behind the next host somebody edits.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string editorNewCredentialPassword = string.Empty;
|
||||
|
||||
/// <summary>Free text, as the keychain's own editor takes.</summary>
|
||||
[ObservableProperty]
|
||||
private string editorNewCredentialNotes = string.Empty;
|
||||
|
||||
/// <summary>What the group picker offers: "no group", then every group of the chosen vault.</summary>
|
||||
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
||||
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
|
||||
@@ -3413,121 +3411,6 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(HasTagChoices));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the host editor's own new-credential form.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A button beside the picker rather than an entry inside it. Every row of that list is a binding the
|
||||
/// host can have — see <see cref="AuthenticationChoice"/> — and "make a new one" is an action, not a
|
||||
/// binding: as an entry it would sit in the box afterwards describing a state no host can be in, and
|
||||
/// cancelling the form would leave the picker showing it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void BeginEditorCredential()
|
||||
{
|
||||
ClearEditorCredentialForm();
|
||||
IsAddingEditorCredential = true;
|
||||
Status = "Adding a credential for this host.";
|
||||
}
|
||||
|
||||
/// <summary>Abandons the form, clearing the password out of it.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelEditorCredential()
|
||||
{
|
||||
ClearEditorCredentialForm();
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Closes the form and drops what was typed into it, the password included.</summary>
|
||||
private void ClearEditorCredentialForm()
|
||||
{
|
||||
IsAddingEditorCredential = false;
|
||||
EditorNewCredentialLabel = string.Empty;
|
||||
EditorNewCredentialUsername = string.Empty;
|
||||
EditorNewCredentialPassword = string.Empty;
|
||||
EditorNewCredentialNotes = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a credential from the host editor's form and binds the host being edited to it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The same reasoning <see cref="AddEditorTagAsync"/> gives, and for the same moment: somebody is
|
||||
/// choosing how a host authenticates and finds the password they want is not in the keychain yet.
|
||||
/// Sending them to the other screen to make one would lose the half-typed host they were standing in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It writes to the keychain immediately, unlike every other field in this editor.</b> A credential
|
||||
/// is a shared item with an id and a host can only name an id that exists, so there is nothing to defer.
|
||||
/// Cancelling the host edit therefore leaves the credential behind — honest rather than hidden, and the
|
||||
/// bargain a tag already makes here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A name that already exists is duplicated rather than reused, which is where this deliberately
|
||||
/// parts from the tag path.</b> Two tags called "staging" are the same intention spelled twice; two
|
||||
/// credentials called "root" are two different passwords, and quietly binding the host to the one that
|
||||
/// happened to be there already would authenticate it as an account the user never chose. A duplicate
|
||||
/// label in the picker is a smaller problem than a silent wrong password.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Into <see cref="editingHostVaultId"/>, not the standing target: the credential belongs wherever the
|
||||
/// host is being sealed, so everybody who can read the host can read what it authenticates with. That is
|
||||
/// stricter than the tag path — which files into the active vault and is recorded as a gap — and it can
|
||||
/// be, because the picker here lists credentials from every readable vault rather than one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task AddEditorCredentialAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var credential = new CredentialSecret
|
||||
{
|
||||
Label = EditorNewCredentialLabel.Trim(),
|
||||
|
||||
// Not trimmed. A password of spaces is a password — CredentialSecret.TryValidate says so — and
|
||||
// trimming one here would lock somebody out of a host over a tidiness opinion.
|
||||
Password = EditorNewCredentialPassword,
|
||||
Username = string.IsNullOrWhiteSpace(EditorNewCredentialUsername)
|
||||
? null
|
||||
: EditorNewCredentialUsername.Trim(),
|
||||
|
||||
// Untrimmed and unnormalised past blank-is-absent, as the keychain's editor writes it: free text
|
||||
// is the user's to lay out, and its leading indent is theirs rather than this form's to correct.
|
||||
Notes = string.IsNullOrWhiteSpace(EditorNewCredentialNotes) ? null : EditorNewCredentialNotes,
|
||||
};
|
||||
|
||||
if (!credential.TryValidate(out var reason))
|
||||
{
|
||||
Status = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Saving…",
|
||||
async () =>
|
||||
{
|
||||
var entityId = await session.Credentials
|
||||
.CreateAsync(editingHostVaultId, credential, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// Before the reload, not after it. RefreshOpenEditors rebuilds this picker and then restores
|
||||
// it from whatever this property says, so writing the binding here is what survives the pass
|
||||
// — and by the time it is read, ReloadCredentialsAsync has put the matching entry in the
|
||||
// list for it to land on.
|
||||
EditorSelectedAuthentication =
|
||||
AuthenticationChoice.ForCredential(entityId, credential.Label);
|
||||
|
||||
ClearEditorCredentialForm();
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Added '{credential.Label}' and bound this host to it. "
|
||||
+ "Save the host to keep the binding.";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The paths pinned on the host being edited, in the order QUICK ACCESS draws them.
|
||||
/// </summary>
|
||||
@@ -4152,15 +4035,6 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal event EventHandler<ConnectionAttemptEventArgs>? ConnectionStarting;
|
||||
|
||||
/// <summary>Raised as a connection this vault announced gets from one step to the next.</summary>
|
||||
/// <remarks>
|
||||
/// Between <see cref="ConnectionStarting"/> and whichever of the other two ends the attempt, any number
|
||||
/// of times including none — a handshake fast enough to finish inside one turn reports nothing, which is
|
||||
/// the honest account of it. See <see cref="ConnectionProgressEventArgs"/> for the threading, which is
|
||||
/// the one way this event differs from its three neighbours.
|
||||
/// </remarks>
|
||||
internal event EventHandler<ConnectionProgressEventArgs>? ConnectionProgress;
|
||||
|
||||
/// <summary>Raised when a connection this vault announced does not become a session.</summary>
|
||||
/// <inheritdoc cref="ConnectionStarting" path="/remarks" />
|
||||
internal event EventHandler<ConnectionFailedEventArgs>? ConnectionFailed;
|
||||
@@ -7272,9 +7146,6 @@ internal sealed partial class VaultViewModel(
|
||||
EditorPinnedPaths.Clear();
|
||||
EditorNewPin = string.Empty;
|
||||
|
||||
// Closed rather than carried over, and it holds a password — see EditorNewCredentialPassword.
|
||||
ClearEditorCredentialForm();
|
||||
|
||||
// Before the group picker, because a group belongs to one vault and the picker is that vault's.
|
||||
BuildEditorVaultChoices(editingHostVaultId);
|
||||
|
||||
@@ -7360,9 +7231,6 @@ internal sealed partial class VaultViewModel(
|
||||
EditorNewTag = string.Empty;
|
||||
BuildTagChoices();
|
||||
|
||||
// As in NewHost, and for the password it can be holding.
|
||||
ClearEditorCredentialForm();
|
||||
|
||||
LoadEditorPinnedPaths(row.Host.PinnedPaths);
|
||||
|
||||
BuildEditorVaultChoices(editingHostVaultId);
|
||||
@@ -8223,10 +8091,6 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
IsEditing = false;
|
||||
editingEntityId = null;
|
||||
|
||||
// The form goes with the editor it lives in, password and all. A credential already added through it
|
||||
// stays in the keychain — see AddEditorCredentialAsync — but what was still being typed does not.
|
||||
ClearEditorCredentialForm();
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
@@ -8260,10 +8124,6 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
IsEditing = false;
|
||||
|
||||
// As CancelEdit does, for the same password.
|
||||
ClearEditorCredentialForm();
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
|
||||
@@ -10923,53 +10783,6 @@ internal sealed partial class VaultViewModel(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Turns the handshake's phases into this attempt's progress events.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Forwarded rather than accumulated, because the tab is the thing that knows what has already happened
|
||||
/// and this object deliberately does not: a connection here is one straight line from renderer to
|
||||
/// session, and a running total of where it had got to would be a second copy of the state the card
|
||||
/// already draws from the first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately not <c>System.Progress<T></c></b>, which captures whatever synchronisation
|
||||
/// context it happens to be constructed on and posts to it. That reads like a convenience and is really
|
||||
/// a second place the marshalling decision gets made: silently, differently under a test with no
|
||||
/// context, and — because a post is a later turn — out of order with respect to the failure or the
|
||||
/// session that follows the phase. Raised inline instead, and the shell marshals once where it can be
|
||||
/// seen. See <c>MainWindowViewModel.OnVaultConnectionProgress</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private PhaseReporter ReporterFor(ConnectionAttemptEventArgs attempt) => new(phase =>
|
||||
ConnectionProgress?.Invoke(this, new ConnectionProgressEventArgs(attempt.AttemptId, StepFor(phase))));
|
||||
|
||||
/// <summary>The step a handshake phase is reported to the shell as.</summary>
|
||||
/// <remarks>
|
||||
/// The whole of the mapping between the SSH assembly's four phases and the card's five steps, in one
|
||||
/// place. <see cref="ConnectionStep.PreparingTerminal"/> is not here because nothing reports it: the tab
|
||||
/// starts on it, and the first phase to arrive is what closes it.
|
||||
/// </remarks>
|
||||
private static ConnectionStep StepFor(SshConnectionPhase phase) => phase switch
|
||||
{
|
||||
SshConnectionPhase.Reaching => ConnectionStep.Reaching,
|
||||
SshConnectionPhase.CheckingHostKey => ConnectionStep.CheckingHostKey,
|
||||
SshConnectionPhase.Authenticating => ConnectionStep.Authenticating,
|
||||
SshConnectionPhase.OpeningShell => ConnectionStep.OpeningShell,
|
||||
_ => ConnectionStep.Reaching,
|
||||
};
|
||||
|
||||
/// <summary>Hands each phase straight to a delegate, on the thread that reported it.</summary>
|
||||
/// <remarks>
|
||||
/// The whole type, and it exists to be the thing <c>System.Progress<T></c> is not — see the remark
|
||||
/// at its one use. A lambda cannot implement an interface, and the alternative was widening the
|
||||
/// workspace's parameter to <c>Action<T></c>, which would have put a non-standard progress
|
||||
/// contract into three assemblies to save one class here.
|
||||
/// </remarks>
|
||||
private sealed class PhaseReporter(Action<SshConnectionPhase> report) : IProgress<SshConnectionPhase>
|
||||
{
|
||||
public void Report(SshConnectionPhase value) => report(value);
|
||||
}
|
||||
|
||||
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="ConnectionTarget"/> because a keychain host has no username of its own at
|
||||
@@ -11008,7 +10821,7 @@ internal sealed partial class VaultViewModel(
|
||||
Dialled(target, authentication));
|
||||
|
||||
ConnectionStarting?.Invoke(this, attempt);
|
||||
Status = $"Connecting to {target.Label}…";
|
||||
SayAbout(attempt, $"Connecting to {target.Label}…");
|
||||
|
||||
await OpenSessionAsync(attempt, target, authentication, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
@@ -11247,7 +11060,10 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Abandon(attempt, RendererNeverStarted);
|
||||
Abandon(
|
||||
attempt,
|
||||
"The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
|
||||
+ "runtime is probably missing or blocked; install it and try again.");
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
@@ -11272,29 +11088,6 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What a renderer that never attached is reported as.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The wait is translated rather than reported for the reason <see cref="OpenSessionAsync"/> gives —
|
||||
/// <see cref="TimeoutException"/> says only "The operation has timed out" — and the whole value of the
|
||||
/// translation is naming where to look. Which is why it cannot be one sentence: the desktop's answer is
|
||||
/// a runtime this application does not install, and the phone has no such runtime and no such answer.
|
||||
/// Telling somebody on a handset to install Microsoft Edge WebView2 is worse than saying nothing, at the
|
||||
/// one moment they are trying to work out what went wrong.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A runtime check rather than a constructor parameter, for the reason
|
||||
/// <c>MainWindowViewModel.GestureWait</c> records at length: which renderer is behind the terminal is a
|
||||
/// fact about the platform this assembly is running on, not about one installation of it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static string RendererNeverStarted =>
|
||||
OperatingSystem.IsAndroid()
|
||||
? "The terminal did not start, so nothing was connected. Android's WebView is probably "
|
||||
+ "disabled or updating; check it in Settings and try again."
|
||||
: "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
|
||||
+ "runtime is probably missing or blocked; install it and try again.";
|
||||
|
||||
/// <summary>Says, in one place, that an attempt ended without a session and why.</summary>
|
||||
/// <remarks>
|
||||
/// The reason goes to two places on purpose. The status line is where somebody watching this screen is
|
||||
@@ -11303,7 +11096,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
|
||||
{
|
||||
Status = reason;
|
||||
// Owned by the attempt, unlike Answer's own line below: this is the case where the tab stays, carrying
|
||||
// the same reason, so closing that tab is exactly the moment the line stops being about anything.
|
||||
SayAbout(attempt, reason);
|
||||
|
||||
ConnectionFailed?.Invoke(
|
||||
this,
|
||||
@@ -11339,8 +11134,6 @@ internal sealed partial class VaultViewModel(
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Not reported before the await: the tab is constructed with this step already running — see
|
||||
// TerminalTabViewModel — because there is no moment between the two worth telling anybody about.
|
||||
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
var request = new SshConnectionRequest(
|
||||
@@ -11350,7 +11143,7 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Credential);
|
||||
|
||||
var sessionId = await workspace
|
||||
.OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
|
||||
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||
@@ -11360,7 +11153,7 @@ internal sealed partial class VaultViewModel(
|
||||
// only record that machine was reached at all.
|
||||
connectionLog?.Identify(sessionId, target.Label, target.HostId);
|
||||
|
||||
Status = $"Connected to {target.Label}.";
|
||||
SayAbout(attempt, $"Connected to {target.Label}.");
|
||||
|
||||
// The session the workspace just opened is the only place the negotiated cipher and host-key
|
||||
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
|
||||
@@ -11391,7 +11184,8 @@ internal sealed partial class VaultViewModel(
|
||||
// screen it is typed on says so.
|
||||
if (target.Row is { } row)
|
||||
{
|
||||
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
||||
await RememberTypedPasswordAsync(attempt, row, authentication, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11423,6 +11217,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RememberTypedPasswordAsync(
|
||||
ConnectionAttemptEventArgs attempt,
|
||||
HostRowViewModel row,
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -11438,8 +11233,10 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
|
||||
+ "newer version of DodoSSH, and binding a credential would re-encode it.";
|
||||
SayAbout(
|
||||
attempt,
|
||||
$"Connected to {row.Label}. Its password was not saved: this host was written by a newer "
|
||||
+ "version of DodoSSH, and binding a credential would re-encode it.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11463,7 +11260,9 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
|
||||
SayAbout(
|
||||
attempt,
|
||||
$"Connected to {row.Label}, but its password could not be saved: {exception.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11474,8 +11273,10 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
|
||||
+ "will not be asked for again.";
|
||||
SayAbout(
|
||||
attempt,
|
||||
$"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it will "
|
||||
+ "not be asked for again.");
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -78,50 +78,6 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
── THE BLACK STRIP UNDER THE TERMINAL ───────────────────────────────────────────────────────────────
|
||||
xterm.css paints its scrolling viewport #000 — literally black, and its own comment says why: on macOS
|
||||
the overlay scrollbar is only fully opaque over an opaque backdrop. Everywhere else that black is a
|
||||
surface nobody sees, because the rows cover it — except along the bottom, where they do not. The fit
|
||||
addon floors the row count, so whatever is left of the pane below the last whole row is viewport with
|
||||
nothing drawn on it: a full-width black bar under the terminal, up to one line tall, against this
|
||||
page's own #171a26. On Windows it is also where the classic scrollbar's bottom corner lands, which is
|
||||
the light square at its right-hand end.
|
||||
|
||||
Repainting it in the page's own background is the whole fix. The remainder is still there — it is the
|
||||
cost of a grid that has to divide evenly — but it now reads as the terminal's own margin rather than
|
||||
as a strip of chrome that belongs to something else.
|
||||
*/
|
||||
.xterm .xterm-viewport {
|
||||
background-color: var(--dodo-background);
|
||||
|
||||
/*
|
||||
And the scrollbar itself, which WebView2 draws in the classic Windows style: a 15-pixel light-grey
|
||||
channel with arrow buttons, down the right of a near-black terminal. Thin and in this page's own
|
||||
colours instead — kept rather than hidden, because the scrollback is real and a surface that scrolls
|
||||
with no sign that it does is worse than a quiet bar saying where you are.
|
||||
|
||||
Both spellings. scrollbar-width/-color is the standard one and is what current WebView2 and WebKitGTK
|
||||
honour; ::-webkit-scrollbar is what older Chromium builds and WKWebView answer to. Neither is
|
||||
load-bearing on its own and the two do not conflict — whichever the host understands wins.
|
||||
*/
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--dodo-muted) 45%, transparent) transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 9px;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--dodo-muted) 45%, transparent);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#status {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
|
||||
conditions that are hardest to reproduce.
|
||||
|
||||
3. The socket reconnects itself, forever, with backoff — and on being looked at again, which is not
|
||||
the same thing and is the half a timer cannot cover. This page's WebView is routinely killed
|
||||
3. The socket reconnects itself, forever, with backoff. This page's WebView is routinely killed
|
||||
and reloaded by Android under memory pressure or simply for being backgrounded, so "the
|
||||
socket closed" is an ordinary event here, not the end of the terminal's life — see connect().
|
||||
A reloaded page starts with an empty session map, so createSession is idempotent (a session
|
||||
@@ -53,22 +52,6 @@ const SCROLLBACK_LINES = 5000;
|
||||
const RECONNECT_INITIAL_DELAY_MS = 1000;
|
||||
const RECONNECT_MAX_DELAY_MS = 5000;
|
||||
|
||||
/*
|
||||
How long a socket is given to finish its handshake before it is treated as a failure.
|
||||
|
||||
A socket that cannot connect normally says so and says it quickly — 'error' then 'close', within a
|
||||
millisecond or two of a loopback refusal. The case this covers is the one that says nothing: an attempt
|
||||
parked in CONNECTING with no event ever coming, which is what a WebSocket opened by a renderer that is
|
||||
then suspended, or one whose handshake the host never answers, leaves this page holding.
|
||||
|
||||
Without the watchdog that state is terminal, and quietly so. Every retry in this file is scheduled by a
|
||||
close or an error, so an attempt that produces neither schedules nothing: the banner says the view is
|
||||
reconnecting for the rest of the page's life while nothing whatever is reconnecting. Five seconds is
|
||||
generous against a handshake that ordinarily takes about a millisecond, and short against somebody
|
||||
waiting on their terminal to come back.
|
||||
*/
|
||||
const HANDSHAKE_TIMEOUT_MS = 5000;
|
||||
|
||||
/*
|
||||
Styled like the SESSION_CLOSED banner (matching \x1b[38;5;244, the same dim grey), but written by
|
||||
createSession's caller rather than by createSession itself: only a *replay* landing on a pane that
|
||||
@@ -101,57 +84,14 @@ const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
|
||||
const root = document.getElementById('root');
|
||||
const statusBanner = document.getElementById('status');
|
||||
|
||||
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement, notice: string}>} */
|
||||
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement}>} */
|
||||
const sessions = new Map();
|
||||
|
||||
/** @type {WebSocket | null} */
|
||||
let socket = null;
|
||||
|
||||
/** Whose pane is showing, or null before there is one — see activate(). */
|
||||
let activeSessionId = null;
|
||||
|
||||
/*
|
||||
── THE BANNER BELONGS TO ONE PANE AT A TIME ─────────────────────────────────────────────────────────
|
||||
There is one #status element for the whole page, because there is one page for every terminal: the
|
||||
panes are stacked in the same box and all but the active one are hidden. What goes in it comes from
|
||||
two sources that are not the same size, and the difference is the whole of this.
|
||||
|
||||
The socket's troubles are the page's. There is a single socket behind every pane, so "the view is
|
||||
reconnecting" is true of whatever is on screen and true of the panes behind it.
|
||||
|
||||
A session's last words are not. "The remote closed the session." is a fact about one terminal and says
|
||||
nothing whatever about the others — so it is held on the session and drawn only while that session's
|
||||
pane is the one showing. Written straight into the shared element, which is what this used to do, it
|
||||
outlived the tab it described: switching to a live terminal left the dead one's epitaph sitting under
|
||||
it, and opening or closing any other tab wiped the message whether or not it belonged to that tab.
|
||||
|
||||
The socket's half wins when both have something to say: a page whose socket is down is not showing
|
||||
live output on any pane, which makes what became of one session the less urgent of the two.
|
||||
*/
|
||||
let transportStatus = statusBanner.textContent ?? '';
|
||||
|
||||
function renderStatus() {
|
||||
const notice = activeSessionId === null ? '' : sessions.get(activeSessionId)?.notice ?? '';
|
||||
|
||||
statusBanner.textContent = transportStatus || notice;
|
||||
}
|
||||
|
||||
/** Says something about the socket, which every pane shares. */
|
||||
function setTransportStatus(text) {
|
||||
transportStatus = text ?? '';
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
/** Records what became of one session, to be drawn only while that session's pane is showing. */
|
||||
function setSessionNotice(sessionId, text) {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.notice = text ?? '';
|
||||
renderStatus();
|
||||
function setStatus(text) {
|
||||
statusBanner.textContent = text ?? '';
|
||||
}
|
||||
|
||||
/** Builds a frame: opcode, big-endian session id, then payload. */
|
||||
@@ -315,31 +255,8 @@ function createSession(sessionId) {
|
||||
// WebGL where it is available. Falling back rather than failing matters because a software
|
||||
// renderer is slow but usable, whereas a blank pane is not — and remote desktops and VMs
|
||||
// routinely have no usable GPU context.
|
||||
//
|
||||
// ◆ THE CONTEXT-LOSS HANDLER IS THE HALF THAT WAS MISSING, AND ON A PHONE IT IS THE WHOLE THING.
|
||||
//
|
||||
// The addon does not recover from a lost GPU context by itself, and it does not fail loudly either:
|
||||
// it stays loaded over a dead context and draws nothing at all. What that looks like from outside is
|
||||
// a terminal that is connected, still accepting keystrokes, still acknowledging output — and blank.
|
||||
// xterm's own guidance is to dispose the addon and let the DOM renderer take over, which is what this
|
||||
// does; the addon is not reloaded afterwards, because a pane that lost the context once is on a
|
||||
// surface that will do it again and thrashing between renderers is worse than being slow.
|
||||
//
|
||||
// Losing it is ordinary on Android and nearly unheard of on Windows, which is why this went unnoticed
|
||||
// for so long. Collapsing the renderer sets the native view to GONE — see
|
||||
// AndroidNativeControlHostImpl.HideWithSize — and a WebView with no surface has no GL context. The
|
||||
// shell collapses it every time a tab starts connecting, every time the connect sheet opens and every
|
||||
// time the app is backgrounded, so on a phone the first loss arrives within seconds of the first
|
||||
// session. WebView2 hides a child HWND instead and keeps rendering throughout; see
|
||||
// docs/platform-flags.md.
|
||||
try {
|
||||
const webgl = new WebglAddon.WebglAddon();
|
||||
|
||||
// Subscribed before loadAddon, because loadAddon is what activates the addon and a context that is
|
||||
// already gone can be reported from inside that call.
|
||||
webgl.onContextLoss(() => webgl.dispose());
|
||||
|
||||
term.loadAddon(webgl);
|
||||
term.loadAddon(new WebglAddon.WebglAddon());
|
||||
} catch (error) {
|
||||
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
|
||||
}
|
||||
@@ -352,7 +269,7 @@ function createSession(sessionId) {
|
||||
|
||||
term.onResize(() => sendResize(sessionId, term, pane));
|
||||
|
||||
const session = { term, fit, pane, notice: '' };
|
||||
const session = { term, fit, pane };
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
activate(sessionId);
|
||||
@@ -366,11 +283,6 @@ function activate(sessionId) {
|
||||
session.pane.dataset.active = String(id === sessionId);
|
||||
}
|
||||
|
||||
// The banner follows the pane. Whatever this session has to say for itself replaces whatever the
|
||||
// session that was showing had to say for its own, which is the point of holding it per session.
|
||||
activeSessionId = sessionId;
|
||||
renderStatus();
|
||||
|
||||
const active = sessions.get(sessionId);
|
||||
if (active) {
|
||||
active.term.focus();
|
||||
@@ -384,17 +296,10 @@ function activate(sessionId) {
|
||||
// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
|
||||
// once splits land.
|
||||
//
|
||||
// It is *not* what protects the vault's lock screen on the desktop, which an earlier version of this
|
||||
// comment claimed. Collapsing WebView2 hides a native child window without resizing it, so this page's
|
||||
// viewport does not change, no observer fires and this function is never called — measured with a live
|
||||
// shell, and confirmed by removing the guard and finding the lock cycle equally clean. See
|
||||
// docs/platform-flags.md.
|
||||
//
|
||||
// On the phone it *is* load-bearing, and that is the one place the two heads differ here. Android hides a
|
||||
// native child by setting it GONE, and a GONE view is skipped by its parent's layout — so collapsing the
|
||||
// renderer really does take this page's viewport to nothing, the observer really does fire, and without
|
||||
// the guard every lock, every connect sheet and every trip to the background would reflow the remote pty
|
||||
// to 2x1 and mangle the scrollback it wrapped.
|
||||
// It is *not* what protects the vault's lock screen, which an earlier version of this comment claimed.
|
||||
// Collapsing the host's WebView hides a native child window without resizing it, so this page's viewport
|
||||
// does not change, no observer fires and this function is never called — measured with a live shell, and
|
||||
// confirmed by removing the guard and finding the lock cycle equally clean. See docs/platform-flags.md.
|
||||
const MINIMUM_FITTABLE_PIXELS = 40;
|
||||
|
||||
function resize(session, sessionId) {
|
||||
@@ -438,10 +343,7 @@ function handleFrame(buffer) {
|
||||
session.term.write(REPLAY_BANNER);
|
||||
}
|
||||
|
||||
// This session's own line, and only this one's: a session that is open has nothing to say about
|
||||
// how it ended. The page's own "Connecting…" is cleared by the socket opening, which happens
|
||||
// before any frame can arrive.
|
||||
setSessionNotice(sessionId, '');
|
||||
setStatus('');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -494,14 +396,7 @@ function handleFrame(buffer) {
|
||||
session.pane.remove();
|
||||
sessions.delete(sessionId);
|
||||
|
||||
// The notice went with the session record it was held on, but the page can still be pointing at
|
||||
// the pane that is now gone. Cleared rather than left dangling, so the banner stops describing a
|
||||
// closed tab while the host decides which pane to show next.
|
||||
if (activeSessionId === sessionId) {
|
||||
activeSessionId = null;
|
||||
}
|
||||
|
||||
renderStatus();
|
||||
setStatus('');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -522,23 +417,8 @@ function handleFrame(buffer) {
|
||||
is something only this page sees — and a shell that receives a multi-line command inside those
|
||||
markers treats every newline as text. Without them it treats each one as "run this", so a
|
||||
three-line snippet runs three commands the moment it is inserted.
|
||||
|
||||
◆ ONE LINE IS TYPED INSTEAD, and this is not an optimisation. Bracketed paste is what readline
|
||||
uses to decide it has been pasted into, and bash marks the result as an active region: the
|
||||
inserted command sits at the prompt in reverse video, looking selected, until the next
|
||||
keystroke clears it. That is right for a paste somebody made with the clipboard and wrong for a
|
||||
snippet they picked off the sidebar, which should read as though they had typed it.
|
||||
|
||||
The markers are only load-bearing for text carrying a newline — that is the whole of what the
|
||||
paragraph above protects against — so a single-line snippet does not need them and is written
|
||||
as keystrokes. Multi-line still pastes, highlight and all, because "runs three commands
|
||||
unasked" is the worse of the two.
|
||||
*/
|
||||
if (text.includes('\n') || text.includes('\r')) {
|
||||
session.term.paste(text);
|
||||
} else {
|
||||
session.term.input(text);
|
||||
}
|
||||
session.term.paste(text);
|
||||
|
||||
/*
|
||||
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
|
||||
@@ -550,15 +430,6 @@ function handleFrame(buffer) {
|
||||
session.term.input('\r');
|
||||
}
|
||||
|
||||
/*
|
||||
The caret goes back where the text landed. Half of it, anyway: this reaches
|
||||
document.activeElement and nothing further, so it is what makes the pane the page's own focused
|
||||
element and what stops a hidden textarea from keeping the caret. The other half is Win32
|
||||
focus — the sidebar row that sent this frame took it — and only the host can give that back;
|
||||
see MainWindowViewModel.TerminalFocusRequested and MainWindow's own FocusTerminalWhenLaidOut.
|
||||
*/
|
||||
session.term.focus();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -578,19 +449,14 @@ function handleFrame(buffer) {
|
||||
const session = sessions.get(sessionId);
|
||||
const reason = new TextDecoder().decode(payload);
|
||||
|
||||
if (!session) {
|
||||
// No pane, so there is nothing this page can honestly hang the reason on. It used to go into
|
||||
// the banner anyway, which printed one session's ending underneath whichever pane happened to
|
||||
// be showing at the time.
|
||||
break;
|
||||
if (session) {
|
||||
// The pane and its scrollback stay. The user was probably reading the last thing the
|
||||
// remote said, and that is usually why the session ended.
|
||||
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
|
||||
session.term.options.cursorBlink = false;
|
||||
}
|
||||
|
||||
// The pane and its scrollback stay. The user was probably reading the last thing the
|
||||
// remote said, and that is usually why the session ended.
|
||||
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
|
||||
session.term.options.cursorBlink = false;
|
||||
|
||||
setSessionNotice(sessionId, reason);
|
||||
setStatus(reason);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -600,47 +466,8 @@ function handleFrame(buffer) {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
── THE SOCKET COMES BACK BY ITSELF, INCLUDING FOR A PAGE NOBODY HAS LOOKED AT IN AN HOUR ────────────
|
||||
|
||||
Retrying on a timer is the easy half and was the whole of this. The three rules below are what make the
|
||||
retry actually reach a page that has been sitting collapsed — each of them a way this page was found
|
||||
showing "Reconnecting the terminal view…" over a socket that nothing was reconnecting.
|
||||
|
||||
1. ONLY THE CURRENT ATTEMPT'S EVENTS COUNT. connect() abandons whatever socket was attached before it,
|
||||
and an abandoned socket still reports its end: the host aborts it the moment the newcomer upgrades,
|
||||
which is TerminalDataPlane.UpgradeAsync's takeover doing exactly what it is meant to. Counting that
|
||||
close as a fresh failure schedules a retry against a socket that has just succeeded, and the
|
||||
takeover then aborts *that* one, whose close schedules the next — a loop with no fixed point, in
|
||||
which the terminal reconnects every second or so forever and the banner is up for most of it. Every
|
||||
handler below asks whether it is still the page's socket before it does anything.
|
||||
|
||||
2. AN ATTEMPT THAT NEVER FINISHES IS A FAILURE TOO. See HANDSHAKE_TIMEOUT_MS.
|
||||
|
||||
3. COMING BACK IS A REASON TO TRY, NOT ONLY THE CLOCK. The retry is a setTimeout chain, and a chain is
|
||||
precisely what a WebView is entitled to stop running. Chromium throttles timers in a page nobody is
|
||||
looking at — down to once a minute once it has been hidden five minutes — and a renderer that is
|
||||
frozen, or reclaimed and not yet reloaded, runs none of them at all. So the socket drops while nobody
|
||||
is watching, the banner goes up, the retry is scheduled, and the retry is then the one thing not
|
||||
running: coming back shows a terminal that says it is reconnecting and, for as long as that lasts,
|
||||
is not.
|
||||
|
||||
The phone is where this is easiest to reach, because leaving the terminal for another screen
|
||||
collapses its WebView to GONE — see createSession's note on the GPU context — and a WebView with no
|
||||
surface is a page the platform may treat as hidden. Which of the two mechanisms actually bit is not
|
||||
established here, and does not need to be: both end with a timer that will not run, and the fix is
|
||||
not to guess at either but to stop depending on the timer alone.
|
||||
|
||||
Hence the wake-ups at the bottom of this file. What they add is not a faster retry; it is a retry
|
||||
driven by the one thing that cannot be throttled, which is the user arriving.
|
||||
*/
|
||||
|
||||
/** @type {number | null} */
|
||||
let reconnectTimer = null;
|
||||
|
||||
/** @type {number | null} */
|
||||
let handshakeTimer = null;
|
||||
|
||||
let reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
||||
|
||||
/**
|
||||
@@ -654,7 +481,7 @@ function scheduleReconnect() {
|
||||
return;
|
||||
}
|
||||
|
||||
setTransportStatus('Reconnecting the terminal view…');
|
||||
setStatus('Reconnecting the terminal view…');
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
@@ -664,112 +491,31 @@ function scheduleReconnect() {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries the socket again now, if it is down.
|
||||
*
|
||||
* The entry point for "somebody is looking at this page again", and it has to be safe to call as often
|
||||
* as that happens — which on the desktop is every time the window is clicked. A socket that is up makes
|
||||
* this nothing at all.
|
||||
*
|
||||
* An attempt still in CONNECTING is left alone rather than restarted: it may be about to succeed, and
|
||||
* the one that is not is already the handshake watchdog's to give up on.
|
||||
*/
|
||||
function reconnectIfDown() {
|
||||
if (socket !== null
|
||||
&& (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Whatever the timer was going to do, this is doing now. Left pending it would land on top of the
|
||||
// socket this call is about to open, and the takeover that followed is the loop rule 1 describes.
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
// Back to the quick attempt. The wait grew to space out retries during an outage nobody was watching,
|
||||
// and being called at all means somebody is watching now.
|
||||
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const token = root.dataset.token;
|
||||
const url = root.dataset.socket;
|
||||
|
||||
/*
|
||||
Whatever was attached is abandoned here, and closed rather than dropped: the host takes the socket
|
||||
over regardless, but one left open is a connection it has to abort and an event this page then has to
|
||||
ignore. Cleared out of `socket` before the close, so that every handler — including that close,
|
||||
whenever it lands — can already tell the attempt is no longer the page's.
|
||||
*/
|
||||
const abandoned = socket;
|
||||
socket = null;
|
||||
abandoned?.close();
|
||||
|
||||
// The token travels as a subprotocol rather than a query parameter, which keeps it out of
|
||||
// anything that logs URLs.
|
||||
const pending = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
||||
pending.binaryType = 'arraybuffer';
|
||||
socket = pending;
|
||||
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
|
||||
/** Whether this attempt is still the page's, rather than one a later connect() has replaced. */
|
||||
const isCurrent = () => socket === pending;
|
||||
|
||||
const forgetHandshakeTimer = () => {
|
||||
if (handshakeTimer !== null) {
|
||||
clearTimeout(handshakeTimer);
|
||||
handshakeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
forgetHandshakeTimer();
|
||||
|
||||
handshakeTimer = setTimeout(() => {
|
||||
handshakeTimer = null;
|
||||
|
||||
// Still CONNECTING with nothing on its way. Only the retry is arranged here; abandoning the attempt
|
||||
// is left to the connect() that retry runs, which is the one place a socket is replaced.
|
||||
if (isCurrent() && pending.readyState === WebSocket.CONNECTING) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}, HANDSHAKE_TIMEOUT_MS);
|
||||
|
||||
pending.addEventListener('open', () => {
|
||||
// A replaced attempt cannot reach here — connect() closes what it abandons, and a socket closed
|
||||
// while connecting never opens — so this is a guard against the ordering rather than a live case.
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
forgetHandshakeTimer();
|
||||
setTransportStatus('');
|
||||
socket.addEventListener('open', () => {
|
||||
setStatus('');
|
||||
|
||||
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
|
||||
// attempts within one outage, reset once the outage is actually over.
|
||||
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
||||
});
|
||||
|
||||
pending.addEventListener('message', (event) => {
|
||||
if (isCurrent()) {
|
||||
handleFrame(event.data);
|
||||
}
|
||||
});
|
||||
socket.addEventListener('message', (event) => handleFrame(event.data));
|
||||
|
||||
// Both close and error retry. They are not the same event on every failure — a socket that never
|
||||
// opens can fire only 'error', one that opens and later drops fires only 'close' — and the host
|
||||
// side of this same problem (TerminalDataPlane.UpgradeAsync's takeover) is exactly why retrying is
|
||||
// safe: whichever attempt eventually reaches the host, a fresh valid upgrade always wins the socket.
|
||||
const retry = () => {
|
||||
if (isCurrent()) {
|
||||
forgetHandshakeTimer();
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
pending.addEventListener('close', retry);
|
||||
pending.addEventListener('error', retry);
|
||||
socket.addEventListener('close', scheduleReconnect);
|
||||
socket.addEventListener('error', scheduleReconnect);
|
||||
}
|
||||
|
||||
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
|
||||
@@ -782,30 +528,4 @@ new ResizeObserver(() => {
|
||||
|
||||
window.addEventListener('beforeunload', () => socket?.close());
|
||||
|
||||
/*
|
||||
The ways this page finds out somebody is looking at it again — rule 3 at the top of the transport
|
||||
section. All three end in the same check, and that check is what makes them safe to be as noisy as they
|
||||
are: with a healthy socket every one of them does nothing.
|
||||
|
||||
'visibilitychange' is the phone's. A WebView collapsed to GONE is a hidden page, so coming back to the
|
||||
terminal screen is the event that says so, and it is the same event Chromium lifts its own throttling
|
||||
on — this page simply does not wait to be asked twice.
|
||||
|
||||
'focus' is the desktop's, where the page is *not* marked hidden while the WebView is collapsed
|
||||
(measured; see docs/platform-flags.md) but a window left in the background for hours has had its timers
|
||||
throttled all the same. It fires when the terminal takes the keyboard back, which is the same gesture.
|
||||
|
||||
'online' is neither head's ordinary case, because the socket is loopback and has nothing to do with the
|
||||
network — but the event does follow a machine coming back from sleep, and a page with a dead socket has
|
||||
no reason to ignore any hint that the world has moved.
|
||||
*/
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
reconnectIfDown();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('focus', reconnectIfDown);
|
||||
window.addEventListener('online', reconnectIfDown);
|
||||
|
||||
connect();
|
||||
@@ -207,7 +207,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -267,9 +267,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -369,11 +369,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,54 +140,6 @@ public static class PosixMode
|
||||
/// <summary>Whether the others triple carries the write bit.</summary>
|
||||
public static bool IsWorldWritable(string mode) => At(mode, OthersWrite) == 'w';
|
||||
|
||||
/// <summary>
|
||||
/// Reads the nine permission bits back out of a formatted mode, as an octal number.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The inverse of <see cref="Format"/>, and it exists because the editor that changes a mode has to
|
||||
/// start from the one that is there. Anything that is not a mode this type wrote answers zero — the same
|
||||
/// forgiving reading <see cref="At"/> already takes, and for the same reason: this feeds a form, and a
|
||||
/// listing is not worth failing over one unrecognised string.
|
||||
/// </remarks>
|
||||
public static int ToOctal(string mode)
|
||||
{
|
||||
if (mode is not { Length: 10 })
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var bits = 0;
|
||||
|
||||
for (var index = 1; index < 10; index++)
|
||||
{
|
||||
bits <<= 1;
|
||||
|
||||
if (mode[index] != '-')
|
||||
{
|
||||
bits |= 1;
|
||||
}
|
||||
}
|
||||
|
||||
return bits;
|
||||
}
|
||||
|
||||
/// <summary>Formats nine permission bits the way <c>chmod</c> takes them, e.g. <c>0755</c>.</summary>
|
||||
/// <remarks>
|
||||
/// Four digits with a leading zero, which is how every <c>chmod</c> example on earth writes one — and
|
||||
/// the leading zero is the part that says out loud that the three bits this client does not write are
|
||||
/// being sent as clear. See the type's own remark.
|
||||
/// </remarks>
|
||||
public static string FormatOctal(int mode) =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"0{(mode >> 6) & 7}{(mode >> 3) & 7}{mode & 7}");
|
||||
|
||||
/// <summary>The nine bits, and nothing above them.</summary>
|
||||
/// <remarks>
|
||||
/// Applied to everything on its way to a server. It is what keeps a caller from setting setuid through
|
||||
/// an interface that does not admit to offering it — see the type's own remark on why those three bits
|
||||
/// are neither rendered nor written.
|
||||
/// </remarks>
|
||||
public static int Sanitise(int mode) => mode & 0b111_111_111;
|
||||
|
||||
private const int OwnerExecute = 3;
|
||||
private const int GroupExecute = 6;
|
||||
private const int OthersWrite = 8;
|
||||
@@ -282,93 +234,6 @@ public static class SftpPath
|
||||
|
||||
return trail;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns something a person typed into an absolute path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The file browser's path box is the reason this exists. A breadcrumb can only offer the path it is
|
||||
/// already standing on, so reaching <c>/var/log/nginx</c> from a home directory is a click and a round
|
||||
/// trip per level — and somebody who already knows where they are going types it instead. What they
|
||||
/// type is not always absolute: <c>..</c>, <c>logs</c> and <c>~/.ssh</c> are all ordinary answers to
|
||||
/// "where to", and a box that took only a leading slash would refuse three of the four shapes every
|
||||
/// shell accepts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Resolved here rather than left to the server.</b> An SFTP server does resolve a relative path —
|
||||
/// against the session's own working directory, which is not necessarily the directory this pane is
|
||||
/// showing — so handing one over would navigate somewhere the breadcrumb above it did not predict. What
|
||||
/// this returns is absolute, which is the only kind every other member here takes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>..</c> is collapsed textually, which is one thing a shell's own <c>cd</c> does not do: through a
|
||||
/// symbolic link, <c>..</c> leads to the link's parent here and to the target's parent on the server.
|
||||
/// The difference shows only on a path that walks into a link and back out of it, and the alternative is
|
||||
/// a <c>realpath</c> round trip before anything is listed at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="current">The directory a relative path is taken to be relative to.</param>
|
||||
/// <param name="typed">What was typed. Blank answers <paramref name="current"/>.</param>
|
||||
/// <param name="home">
|
||||
/// What <c>~</c> means, or null in a session that has not said. A leading <c>~</c> with no home to
|
||||
/// expand stays an ordinary segment rather than being guessed at — a directory really can be called
|
||||
/// that, and inventing a home directory to point at would be worse than listing the one that is there.
|
||||
/// </param>
|
||||
public static string Resolve(string current, string typed, string? home = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(current);
|
||||
ArgumentNullException.ThrowIfNull(typed);
|
||||
|
||||
var wanted = typed.Trim();
|
||||
|
||||
if (wanted.Length == 0)
|
||||
{
|
||||
return Normalize(current);
|
||||
}
|
||||
|
||||
if (home is { Length: > 0 }
|
||||
&& (string.Equals(wanted, "~", StringComparison.Ordinal)
|
||||
|| wanted.StartsWith("~/", StringComparison.Ordinal)))
|
||||
{
|
||||
wanted = wanted.Length == 1 ? home : Combine(home, wanted[2..]);
|
||||
}
|
||||
|
||||
return Normalize(IsAbsolute(wanted) ? wanted : Combine(current, wanted));
|
||||
}
|
||||
|
||||
/// <summary>Collapses <c>.</c>, <c>..</c> and repeated separators, anchored at the root.</summary>
|
||||
/// <remarks>
|
||||
/// <c>..</c> above the root is the root, as it is in every shell: <c>/../..</c> is <c>/</c> rather than
|
||||
/// a failure, because there is nowhere else for it to be.
|
||||
/// </remarks>
|
||||
private static string Normalize(string path)
|
||||
{
|
||||
var walked = new List<string>();
|
||||
|
||||
foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
switch (segment)
|
||||
{
|
||||
case ".":
|
||||
break;
|
||||
|
||||
case "..":
|
||||
if (walked.Count > 0)
|
||||
{
|
||||
walked.RemoveAt(walked.Count - 1);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
walked.Add(segment);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return walked.Count == 0 ? Root : Root + string.Join('/', walked);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -441,43 +306,6 @@ public interface IRemoteFileStore : IAsyncDisposable
|
||||
/// <summary>Whether the transport is still up.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Why the connection under this store went away, or null if it did not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The same fact, latched the same way and for the same reason, as
|
||||
/// <see cref="ISshShellSession.TransportFailure"/> — read that member for the argument. What differs is
|
||||
/// what it is read <em>for</em>: a shell has a blocked read this releases, and a file browser has no
|
||||
/// loop at all. Here it is the sentence a screen prints beside the word DISCONNECTED, and the thing that
|
||||
/// tells a failed listing about a path apart from a failed listing about a connection.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Null forever on a store with no connection to lose. A bucket is HTTP, request per operation, and has
|
||||
/// nothing that can be up between two of them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
string? TransportFailure { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised once, when the connection goes away without this client having closed it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Because otherwise nobody finds out until they click.</b> A file browser is idle between
|
||||
/// operations, so the alternative to an event is a screen that goes on saying CONNECTED over a host that
|
||||
/// is gone until somebody happens to ask it for a listing — which is the same dishonesty a terminal
|
||||
/// showed and the same one this is here to remove. <see cref="TransportFailure"/> is already set by the
|
||||
/// time this is raised.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Raised on whichever thread the failure was noticed on, which is SSH.NET's message-listener thread; a
|
||||
/// handler that touches a view model has to marshal. Never raised by a store with no connection to lose,
|
||||
/// and never raised for a session this client disposed itself.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
event EventHandler? ConnectionLost;
|
||||
|
||||
/// <summary>
|
||||
/// Where the session starts, which is the account's home directory.
|
||||
/// </summary>
|
||||
@@ -538,42 +366,6 @@ public interface IRemoteFileStore : IAsyncDisposable
|
||||
|
||||
/// <summary>Renames or moves a path within the same host.</summary>
|
||||
Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sets a path's POSIX mode.
|
||||
/// </summary>
|
||||
/// <param name="path">What to change.</param>
|
||||
/// <param name="mode">
|
||||
/// The nine permission bits, as an octal number reads them — <c>0o755</c> is <c>rwxr-xr-x</c>. Only the
|
||||
/// low nine bits are sent; see <see cref="PosixMode"/> for why setuid, setgid and sticky are not this
|
||||
/// client's to write.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <remarks>
|
||||
/// ◆ <b>The one operation on this interface that writes something the listing already shows.</b> The
|
||||
/// <c>PERMS</c> column has been a read-only fact since it arrived, and the commonest thing anybody does
|
||||
/// straight after using this screen is open a terminal to make the file they just uploaded executable.
|
||||
/// <para>
|
||||
/// Refused by a store with no such thing. An object has an ACL rather than a mode, and mapping one onto
|
||||
/// the other would be inventing a fact about a bucket.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="SftpPathException">The path is not there, is not writable, or has no mode to set.</exception>
|
||||
Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Sets a path's last-write time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What makes an upload land carrying the timestamp its source had, which is the half of "preserve" that
|
||||
/// every platform can answer — a mode is not a fact about a file on Windows and a modification time is.
|
||||
/// Refused by a store that has no way to say when an object was written other than when it was put.
|
||||
/// </remarks>
|
||||
/// <exception cref="SftpPathException">The path is not there, or its time cannot be set.</exception>
|
||||
Task SetLastWriteTimeAsync(
|
||||
string path,
|
||||
DateTimeOffset lastWriteTimeUtc,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -66,76 +66,12 @@ public sealed record SshConnectionRequest(
|
||||
TimeSpan? ConnectTimeout = null,
|
||||
SshLoopbackProxy? Proxy = null);
|
||||
|
||||
/// <summary>
|
||||
/// How a shell session came to an end.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Three, because they are the three a user would answer differently. A shell that exited is over and there
|
||||
/// is nothing wrong; a connection that was lost is a machine that may well still be there, and is the one
|
||||
/// worth offering to dial again; a session this client ended is a tab being closed, which nobody needs told.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Deliberately not a fourth for "the remote rebooted".</b> Nothing on the wire distinguishes a reboot
|
||||
/// from a cable being pulled — both are a transport that stops answering — and a name this client cannot
|
||||
/// check would be a guess dressed as a fact. <see cref="ConnectionLost"/> is what is actually known.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum SshSessionEnd
|
||||
{
|
||||
/// <summary>
|
||||
/// The remote ended it in an orderly way: the shell exited, or the server hung up cleanly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What <c>exit</c> and <c>logout</c> produce, and what a login shell being killed produces. The channel
|
||||
/// closed while the transport under it was still healthy.
|
||||
/// </remarks>
|
||||
RemoteClosed = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The transport failed underneath it, with no channel close first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A reboot, a network that went away, an <c>sshd</c> that was killed, a laptop lid. See
|
||||
/// <see cref="ISshShellSession.TransportFailure"/> for how this is told apart from
|
||||
/// <see cref="RemoteClosed"/> without a race.
|
||||
/// </remarks>
|
||||
ConnectionLost = 1,
|
||||
|
||||
/// <summary>This client ended it: a tab was closed, a reconnect began, or the process is shutting down.</summary>
|
||||
ClosedLocally = 2,
|
||||
}
|
||||
|
||||
/// <summary>An interactive shell over a pseudo-terminal.</summary>
|
||||
public interface ISshShellSession : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Whether the channel is still usable.</summary>
|
||||
bool IsOpen { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Why the transport under this shell went away, or null if it did not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>A latched value rather than a question asked afterwards, and that is the whole point of it.</b>
|
||||
/// The obvious way to tell <c>exit</c> from a dropped connection is to wait for the read to report the end
|
||||
/// of the shell and then ask whether the transport is still up — and it is wrong about half the time,
|
||||
/// because a server whose only channel just closed hangs the connection up a millisecond later. The
|
||||
/// answer to that question races the teardown; this does not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What makes it safe is an ordering SSH.NET guarantees: a transport failure is raised on its
|
||||
/// message-listener thread <em>before</em> that thread goes on to disconnect the session, and it is the
|
||||
/// disconnect that wakes a blocked read. So a read that has just returned zero can consult this and get a
|
||||
/// settled answer — set if the transport failed, still null if the remote merely closed the channel.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The message rather than the exception: this is read to be shown to somebody, and nothing above this
|
||||
/// interface branches on a type.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
string? TransportFailure { get; }
|
||||
|
||||
/// <summary>Reads whatever output is available, blocking until at least one byte arrives.</summary>
|
||||
/// <returns>Bytes read, or 0 once the remote closes the channel.</returns>
|
||||
ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken);
|
||||
@@ -185,53 +121,12 @@ public interface ISshConnection : IAsyncDisposable
|
||||
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How far a connection being made has got.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These are the boundaries a client can actually observe, and there are deliberately no others. SSH.NET
|
||||
/// runs the whole handshake inside one <c>ConnectAsync</c> and raises exactly one event from the middle of
|
||||
/// it — <c>HostKeyReceived</c>, once the key exchange has produced a key to show. That event is the only
|
||||
/// interior moment there is, so it is the only interior phase named here: everything before it is
|
||||
/// <see cref="Reaching"/> and everything after it is <see cref="Authenticating"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Nothing here is a guess about elapsed time or a fraction of the way through.</b> Each value is
|
||||
/// reported at the instant the thing it names actually starts, which is what makes it safe for a screen to
|
||||
/// draw as fact. A phase that took no measurable time is reported anyway and simply passes at once — that
|
||||
/// is a true account of a fast handshake, not a step that was skipped. See the transfer strip's own remark
|
||||
/// in TransfersScreen.axaml for why this design does not invent furniture for states it cannot measure.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum SshConnectionPhase
|
||||
{
|
||||
/// <summary>Resolving the name, opening the socket, and exchanging keys. Before any key is known.</summary>
|
||||
Reaching = 0,
|
||||
|
||||
/// <summary>The server has offered a host key, and its trust is being decided.</summary>
|
||||
CheckingHostKey = 1,
|
||||
|
||||
/// <summary>The key was accepted. The credential is being offered.</summary>
|
||||
Authenticating = 2,
|
||||
|
||||
/// <summary>Authenticated. A pseudo-terminal and a shell channel are being opened.</summary>
|
||||
OpeningShell = 3,
|
||||
}
|
||||
|
||||
/// <summary>Opens connections, enforcing host key trust before authenticating.</summary>
|
||||
public interface ISshConnectionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Connects and authenticates.
|
||||
/// </summary>
|
||||
/// <param name="request">What to connect to, as whom, and with what.</param>
|
||||
/// <param name="progress">
|
||||
/// Told each phase as it begins, or null to report nothing. Called from whichever thread the handshake
|
||||
/// is on — SSH.NET raises host key verification on its own — so an implementation that touches a UI must
|
||||
/// marshal for itself.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Abandons the attempt.</param>
|
||||
/// <exception cref="SshHostKeyUnknownException">
|
||||
/// The host has no pinned key. The caller must show the fingerprint, and only on explicit
|
||||
/// confirmation record it via <see cref="IKnownHostStore.TrustAsync"/> and retry.
|
||||
@@ -239,8 +134,5 @@ public interface ISshConnectionFactory
|
||||
/// <exception cref="SshHostKeyMismatchException">
|
||||
/// The presented key differs from the pin. There is no retry path: this is a hard block.
|
||||
/// </exception>
|
||||
Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken);
|
||||
Task<ISshConnection> ConnectAsync(SshConnectionRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -18,34 +18,6 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
{
|
||||
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// How often to put a byte on the wire for a connection that is otherwise idle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Without this a rebooted machine is never noticed, and the tab claims to be connected for ever.</b>
|
||||
/// An idle SSH session is a socket nobody is writing to, and a TCP connection that is not written to has
|
||||
/// no way to discover that the far end has gone: the read simply never returns. That is the whole of the
|
||||
/// "reboot leaves it hanging" failure — nothing was broken, nothing had been asked, so nothing could
|
||||
/// fail. This is what makes the question get asked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It is the write that matters, not the reply.</b> SSH.NET's keep-alive is <c>SSH_MSG_IGNORE</c>,
|
||||
/// which a server is not obliged to answer, so this is not a heartbeat with a deadline — it is traffic.
|
||||
/// A machine that has rebooted answers the first probe with a RST, and the session is torn down at once;
|
||||
/// a network that has vanished is discovered when the OS gives up retransmitting. Neither of those is a
|
||||
/// timeout this client sets, which is why there is no "no reply within N seconds" rule here to be wrong
|
||||
/// about a slow link.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Thirty seconds, which is what OpenSSH's own <c>ServerAliveInterval</c> examples use. It costs a packet
|
||||
/// a minute per idle session in each direction and it bounds how long a dead session can go on looking
|
||||
/// alive; shorter would not make the RST arrive sooner, because what is being waited for is the first
|
||||
/// probe after the machine came back.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// How much of a file SSH.NET reads or writes per SFTP request.
|
||||
/// </summary>
|
||||
@@ -70,14 +42,13 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
/// <inheritdoc />
|
||||
public async Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var client = new SshClient(BuildConnectionInfo(request)) { KeepAliveInterval = KeepAliveInterval };
|
||||
var client = new SshClient(BuildConnectionInfo(request));
|
||||
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SshNetConnection(client, gate.Presented!);
|
||||
@@ -95,19 +66,9 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
|
||||
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
|
||||
// since rebooted would otherwise wait on a socket nothing will ever answer.
|
||||
var client = new SftpClient(BuildConnectionInfo(request))
|
||||
{
|
||||
BufferSize = SftpBufferSize,
|
||||
KeepAliveInterval = KeepAliveInterval,
|
||||
};
|
||||
var client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize };
|
||||
|
||||
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
|
||||
// reports itself, and a second connection opened behind an already-open shell has nothing the user
|
||||
// is watching a step list for.
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
||||
@@ -140,18 +101,12 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
private async Task<HostKeyGate> ConnectThroughHostKeyGateAsync(
|
||||
BaseClient client,
|
||||
SshConnectionRequest request,
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var gate = new HostKeyGate(knownHosts, request, progress, cancellationToken);
|
||||
var gate = new HostKeyGate(knownHosts, request, cancellationToken);
|
||||
|
||||
client.HostKeyReceived += gate.OnHostKeyReceived;
|
||||
|
||||
// Before the await rather than inside the gate, because this phase is the part of the handshake
|
||||
// that happens before there is anything to raise an event about: the lookup, the socket and the key
|
||||
// exchange. Nothing else can report the start of it.
|
||||
progress?.Report(SshConnectionPhase.Reaching);
|
||||
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -184,7 +139,6 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
private sealed class HostKeyGate(
|
||||
IKnownHostStore knownHosts,
|
||||
SshConnectionRequest request,
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
/// <summary>What the server offered, once the handshake has reached that point.</summary>
|
||||
@@ -203,11 +157,6 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
|
||||
Presented = presentation;
|
||||
|
||||
// Reported before the lookup rather than after it, because the lookup is the wait: this is a
|
||||
// vault-backed store on the handshake thread, and on a locked or cold vault it is the part of
|
||||
// "checking the host key" long enough to be worth naming.
|
||||
progress?.Report(SshConnectionPhase.CheckingHostKey);
|
||||
|
||||
// Looked up here rather than before connecting, because the negotiated algorithm is only
|
||||
// known now and a server may choose a different one than it did last time.
|
||||
//
|
||||
@@ -230,15 +179,6 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
|
||||
mismatch = !matches;
|
||||
e.CanTrust = matches;
|
||||
|
||||
// Only on acceptance, and here rather than after the await above, because this is the last
|
||||
// moment SSH.NET gives anyone: returning true from this handler is what lets the handshake go on
|
||||
// to offer the credential, and it does not come back until it has an answer either way. A
|
||||
// refusal reports nothing — there is no authentication about to happen for it to be true of.
|
||||
if (matches)
|
||||
{
|
||||
progress?.Report(SshConnectionPhase.Authenticating);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The specific exception for a refusal this gate caused, or null if it did not.</summary>
|
||||
@@ -339,11 +279,10 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
|
||||
/// Read at construction rather than lazily: by the time an <see cref="SshNetConnection"/> exists,
|
||||
/// <see cref="SshNetConnectionFactory.ConnectAsync"/> has already awaited <c>client.ConnectAsync</c>, so
|
||||
/// <c>ConnectionInfo</c> is already populated and there is no earlier moment reading it would race. SSH.NET
|
||||
/// types the property as nullable all the same — it is null before the key exchange completes — so the
|
||||
/// coalesce is here to satisfy that annotation, not because this call site expects to see it. Empty is the
|
||||
/// value this codebase already uses for a cipher that could not be read; see <c>MainWindowViewModel.NullIfEmpty</c>.
|
||||
/// types the property as a non-nullable <c>string</c>, so this reads straight through rather than coalescing
|
||||
/// a null that the library's own contract says cannot occur.
|
||||
/// </remarks>
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||
@@ -362,7 +301,7 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
|
||||
effective.PixelHeight,
|
||||
4096);
|
||||
|
||||
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client));
|
||||
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -374,96 +313,10 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
|
||||
}
|
||||
|
||||
/// <summary>An SSH.NET-backed shell session.</summary>
|
||||
internal sealed class SshNetShellSession : ISshShellSession
|
||||
internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession
|
||||
{
|
||||
private readonly ShellStream shell;
|
||||
private readonly SshClient client;
|
||||
|
||||
private string? transportFailure;
|
||||
|
||||
/// <param name="shell">The channel this shell reads and writes.</param>
|
||||
/// <param name="client">
|
||||
/// The connection underneath it, subscribed to for the one thing the stream cannot report on its own —
|
||||
/// see <see cref="TransportFailure"/>.
|
||||
/// </param>
|
||||
internal SshNetShellSession(ShellStream shell, SshClient client)
|
||||
{
|
||||
this.shell = shell;
|
||||
this.client = client;
|
||||
|
||||
client.ErrorOccurred += OnTransportError;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <c>CanWrite</c> rather than <c>CanRead</c>, which is what this used to read and which is a constant:
|
||||
/// <c>ShellStream.CanRead</c> is documented as always true — it stays readable after disposal so that
|
||||
/// buffered output can still be drained — so this property answered "yes" for every shell that had ever
|
||||
/// existed. <c>CanWrite</c> is the one that goes false when the stream is disposed, which is what closing
|
||||
/// the channel and losing the session both do.
|
||||
/// </remarks>
|
||||
public bool IsOpen => shell.CanWrite;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Written on SSH.NET's message-listener thread and read on the pump's, which is why it is a
|
||||
/// <see cref="Volatile"/> pair rather than a plain field: the two threads are ordered by the wake-up the
|
||||
/// interface's own remark describes, but nothing in the language says so, and a torn or cached read here
|
||||
/// would report a clean exit for a connection that had just died.
|
||||
/// </remarks>
|
||||
public string? TransportFailure => Volatile.Read(ref transportFailure);
|
||||
|
||||
/// <summary>
|
||||
/// Latches the first transport failure and ends the read that is waiting on a connection which is
|
||||
/// no longer there.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>THE SECOND HALF OF THIS IS WHY A REBOOTED MACHINE USED TO HANG FOR EVER, AND IT IS A DEFECT IN
|
||||
/// SSH.NET RATHER THAN IN ANYTHING ABOVE IT.</b> <c>ShellStream</c> ends a blocked read from exactly two
|
||||
/// places: its channel being closed, and the session raising <c>Disconnected</c>. A shell that exits
|
||||
/// gives the first. But <c>Session</c> raises <c>Disconnected</c> from one method only —
|
||||
/// <c>OnDisconnectReceived</c>, for a server that politely says goodbye — and a socket that simply fails
|
||||
/// goes down a different path: it raises the error event, sets its disconnecting flag and disposes the
|
||||
/// socket, without ever raising <c>Disconnected</c>. So the stream was never disposed, its
|
||||
/// <c>Monitor.Wait</c> was never pulsed, and the read blocked until the process ended. The transport was
|
||||
/// gone, the client knew the transport was gone, and the terminal went on looking connected.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Keep-alive is what makes the failure <em>happen</em> on an idle connection; this is what makes it
|
||||
/// <em>arrive</em>. Neither is sufficient alone, which is why both are in this file.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Queued rather than disposed inline, and that is not tidiness.</b> This runs on SSH.NET's own
|
||||
/// message-listener thread. Closing a channel waits for the server's close to come back — which is
|
||||
/// processed by that same thread — so disposing here would be the listener thread waiting on itself, and
|
||||
/// the best case is that it unwinds when the wait times out.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The latch is set before the queue, so <see cref="TransportFailure"/> is already true by the time the
|
||||
/// read it releases can look at it. That ordering is the whole of the promise this property makes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnTransportError(object? sender, ExceptionEventArgs e)
|
||||
{
|
||||
Interlocked.CompareExchange(ref transportFailure, e.Exception.Message, comparand: null);
|
||||
|
||||
ThreadPool.UnsafeQueueUserWorkItem(
|
||||
static state =>
|
||||
{
|
||||
try
|
||||
{
|
||||
state.Dispose();
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Best-effort. The connection is already gone; what matters is that the read was
|
||||
// released, and a fault tearing down a dead channel has no caller to report it to.
|
||||
}
|
||||
},
|
||||
shell,
|
||||
preferLocal: false);
|
||||
}
|
||||
public bool IsOpen => shell.CanRead;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
@@ -509,10 +362,6 @@ internal sealed class SshNetShellSession : ISshShellSession
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Unsubscribed before the stream goes, so a connection that outlives this shell — the workspace
|
||||
// disposes the two in that order — is not still holding a reference to it through its own event.
|
||||
client.ErrorOccurred -= OnTransportError;
|
||||
|
||||
await shell.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,73 +11,25 @@ namespace DodoSSH.Client.Ssh;
|
||||
/// this type does beyond forwarding calls is translate SSH.NET's several path failures into the single one a
|
||||
/// caller can act on.
|
||||
/// </remarks>
|
||||
internal sealed class SshNetSftpSession : ISftpSession
|
||||
internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
|
||||
: ISftpSession
|
||||
{
|
||||
private readonly SftpClient client;
|
||||
|
||||
private string? transportFailure;
|
||||
private int announced;
|
||||
|
||||
internal SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
|
||||
{
|
||||
this.client = client;
|
||||
|
||||
HostKey = hostKey;
|
||||
HomeDirectory = homeDirectory;
|
||||
Cipher = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
||||
|
||||
client.ErrorOccurred += OnTransportError;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => client.IsConnected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TransportFailure => Volatile.Read(ref transportFailure);
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler? ConnectionLost;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; }
|
||||
public HostKeyPresentation HostKey { get; } = hostKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Read at construction, the same way and for the same reason as <c>SshNetConnection.Cipher</c>: this type
|
||||
/// is only ever built after <c>SshNetConnectionFactory.OpenSftpAsync</c> has awaited <c>client.ConnectAsync</c>,
|
||||
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from. The coalesce
|
||||
/// is there for SSH.NET's nullable annotation, which covers the pre-key-exchange window this never sees.
|
||||
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from.
|
||||
/// </remarks>
|
||||
public string Cipher { get; }
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Latches the failure and says so once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// No blocked read to release, unlike the shell's own handler: every SFTP call is a request and a reply,
|
||||
/// so a connection that has gone is discovered by the next one throwing. What this adds is that somebody
|
||||
/// finds out <em>without</em> making that next call — a file browser sits idle between operations, and
|
||||
/// waiting for a click to reveal a host that went away half an hour ago is exactly the stale CONNECTED
|
||||
/// this exists to remove.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Announced once. SSH.NET can raise several errors as one connection comes apart, and a screen told
|
||||
/// three times would report the second and third against a session it had already replaced.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnTransportError(object? sender, ExceptionEventArgs e)
|
||||
{
|
||||
Interlocked.CompareExchange(ref transportFailure, e.Exception.Message, comparand: null);
|
||||
|
||||
if (Interlocked.Exchange(ref announced, 1) == 0)
|
||||
{
|
||||
ConnectionLost?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
public string HomeDirectory { get; } = homeDirectory;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||
@@ -188,61 +140,10 @@ internal sealed class SshNetSftpSession : ISftpSession
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// ◆ <b>On a thread-pool thread, because SSH.NET offers no asynchronous <c>SETSTAT</c>.</b>
|
||||
/// <c>ChangePermissions</c> is a blocking round trip and every caller here is on the thread the
|
||||
/// interface is drawn from — so calling it directly would freeze the window for a link's latency, which
|
||||
/// on the connections this product exists for is the difference between instant and noticeably not.
|
||||
/// Every other operation on this type has an async form and uses it.
|
||||
/// </remarks>
|
||||
public async Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
|
||||
{
|
||||
// Sanitised here rather than trusted from the caller, so that the promise the interface makes about
|
||||
// the three bits it does not write holds at the last point before the wire.
|
||||
var bits = (short)PosixMode.Sanitise(mode);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(() => client.ChangePermissions(path, bits), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>See <see cref="SetPermissionsAsync"/>; the same blocking round trip, moved off the caller's thread for the same reason.</remarks>
|
||||
public async Task SetLastWriteTimeAsync(
|
||||
string path,
|
||||
DateTimeOffset lastWriteTimeUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var when = lastWriteTimeUtc.UtcDateTime;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(() => client.SetLastWriteTimeUtc(path, when), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// Before the client goes, and it also settles the flag: a disposal that trips SSH.NET's own error
|
||||
// path must not reach a screen as a connection this client did not mean to lose.
|
||||
Interlocked.Exchange(ref announced, 1);
|
||||
client.ErrorOccurred -= OnTransportError;
|
||||
|
||||
client.Dispose();
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "Direct",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
@@ -54,9 +54,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
|
||||
@@ -16,21 +16,6 @@ public interface ITerminalTransport
|
||||
ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How one session ended, and the sentence that says so.
|
||||
/// </summary>
|
||||
/// <param name="End">Which of the three ways it was.</param>
|
||||
/// <param name="Description">
|
||||
/// What to show a person. Written into the pane by the renderer, and available to the shell beside the
|
||||
/// <paramref name="End"/> it belongs to.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// The pair travels together because either alone is not enough. The enum is what code branches on — whether
|
||||
/// to offer a reconnect, whether a tab is worth keeping — and the sentence is the detail underneath it, which
|
||||
/// for a lost connection is the only place the actual cause appears.
|
||||
/// </remarks>
|
||||
public sealed record TerminalSessionOutcome(SshSessionEnd End, string Description);
|
||||
|
||||
/// <summary>Tuning for one session's output path.</summary>
|
||||
public sealed class TerminalPumpOptions
|
||||
{
|
||||
@@ -115,24 +100,6 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
||||
public long FramesSent { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// How this session ended and what to say about it, once <see cref="RunAsync"/> has returned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Meaningless before the run completes, and deliberately not nullable for it: a session that has not
|
||||
/// ended is not a session that ended in an unknown way, and every caller here already knows whether the
|
||||
/// run is over — the workspace awaits it before reading this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The same description that goes to the renderer in the <see cref="TerminalServerOpcode.SessionClosed"/>
|
||||
/// frame, so the line written into the pane and whatever the shell draws around the tab are the one
|
||||
/// sentence rather than two that can disagree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public TerminalSessionOutcome Outcome { get; private set; } =
|
||||
new(SshSessionEnd.ClosedLocally, "The session was closed.");
|
||||
|
||||
/// <summary>
|
||||
/// Runs until the remote closes the channel or the token is cancelled.
|
||||
/// </summary>
|
||||
@@ -152,36 +119,21 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
var reader = ReadLoopAsync(linked.Token);
|
||||
var flusher = FlushLoopAsync(linked.Token);
|
||||
|
||||
TerminalSessionOutcome outcome;
|
||||
string reason;
|
||||
try
|
||||
{
|
||||
await reader.ConfigureAwait(false);
|
||||
|
||||
// The read loop finished of its own accord, which means the shell is over. Which of the two ways
|
||||
// it is over is not a question asked of the transport now — by now the server has very likely hung
|
||||
// up behind a clean exit, and asking would call `exit` a dropped connection — but of what the shell
|
||||
// latched at the moment it happened. See ISshShellSession.TransportFailure.
|
||||
outcome = session.TransportFailure is { Length: > 0 } failure
|
||||
? new TerminalSessionOutcome(SshSessionEnd.ConnectionLost, failure)
|
||||
: new TerminalSessionOutcome(SshSessionEnd.RemoteClosed, "The remote closed the session.");
|
||||
reason = "The remote closed the session.";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
outcome = new TerminalSessionOutcome(SshSessionEnd.ClosedLocally, "The session was closed.");
|
||||
reason = "The session was closed.";
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A read that threw rather than returning zero. Whatever it was, it was not this client asking —
|
||||
// that arrives as the cancellation above — so it is the connection going out from under the read.
|
||||
outcome = new TerminalSessionOutcome(
|
||||
SshSessionEnd.ConnectionLost,
|
||||
session.TransportFailure is { Length: > 0 } failure ? failure : exception.Message);
|
||||
reason = exception.Message;
|
||||
}
|
||||
|
||||
// Before the drain and the last frame, so that anything reading this off a completed run — the
|
||||
// workspace's own announcement — cannot see the default.
|
||||
Outcome = outcome;
|
||||
|
||||
// Stop the flusher, but only after draining what the reader already produced — the last thing
|
||||
// a remote writes is often the most important, and dropping it makes a clean exit look like a
|
||||
// crash.
|
||||
@@ -198,7 +150,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
|
||||
await SendAsync(
|
||||
TerminalServerOpcode.SessionClosed,
|
||||
Encoding.UTF8.GetBytes(outcome.Description),
|
||||
Encoding.UTF8.GetBytes(reason),
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -31,21 +31,10 @@ public sealed class TerminalWorkspaceOptions
|
||||
|
||||
/// <summary>A session whose shell has ended.</summary>
|
||||
/// <param name="sessionId">The session, as the renderer and the workspace know it.</param>
|
||||
/// <param name="outcome">How it ended, and the sentence that says so.</param>
|
||||
/// <remarks>
|
||||
/// ◆ <b>The outcome is here because a subscriber cannot work it out afterwards.</b> By the time this event
|
||||
/// is raised the session is over: there is no transport left to interrogate, and the difference between a
|
||||
/// shell that exited and a connection that broke is exactly the difference between a tab that is finished
|
||||
/// and a tab worth offering to dial again. It used to carry the id alone, so the only place that difference
|
||||
/// existed was a line of grey text inside the WebView.
|
||||
/// </remarks>
|
||||
public sealed class TerminalSessionEndedEventArgs(uint sessionId, TerminalSessionOutcome outcome) : EventArgs
|
||||
public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
|
||||
{
|
||||
/// <summary>The session that ended.</summary>
|
||||
public uint SessionId { get; } = sessionId;
|
||||
|
||||
/// <inheritdoc cref="TerminalSessionOutcome" />
|
||||
public TerminalSessionOutcome Outcome { get; } = outcome;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -119,10 +108,6 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// </summary>
|
||||
private static readonly byte[] ReplayMarker = [1];
|
||||
|
||||
/// <summary>What <see cref="CloseSessionAsync"/> announces, whatever the pump made of the same end.</summary>
|
||||
private static readonly TerminalSessionOutcome ClosedByThisClient =
|
||||
new(SshSessionEnd.ClosedLocally, "The session was closed.");
|
||||
|
||||
private uint nextSessionId = 1;
|
||||
private Task? server;
|
||||
private int disposed;
|
||||
@@ -384,31 +369,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
dataPlane.RendererAttached.WaitAsync(options.RendererTimeout, cancellationToken);
|
||||
|
||||
/// <summary>Connects to a host and starts a terminal for it.</summary>
|
||||
/// <param name="request">What to connect to, as whom, and with what.</param>
|
||||
/// <param name="size">The pseudo-terminal's initial size.</param>
|
||||
/// <param name="progress">
|
||||
/// Told each phase as it begins, or null to report nothing. Reported from the handshake's own thread;
|
||||
/// see <see cref="SshConnectionPhase"/>. Optional because a session opened by anything other than the
|
||||
/// connecting card has nobody watching a step list for it, which is every caller but one.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Abandons the attempt.</param>
|
||||
/// <returns>The session id, which identifies this terminal in the renderer.</returns>
|
||||
/// <remarks>
|
||||
/// <see cref="SshConnectionPhase.OpeningShell"/> is reported here rather than by the factory because
|
||||
/// this is where it happens: the factory's work ends with an authenticated connection, and asking for a
|
||||
/// pseudo-terminal on it is a separate round trip this method makes.
|
||||
/// </remarks>
|
||||
public async Task<uint> OpenSessionAsync(
|
||||
SshConnectionRequest request,
|
||||
TerminalSize size,
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await connections
|
||||
.ConnectAsync(request, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
progress?.Report(SshConnectionPhase.OpeningShell);
|
||||
var connection = await connections.ConnectAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ISshShellSession shell;
|
||||
try
|
||||
@@ -449,7 +416,7 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
|
||||
// The announcement's own continuation — see AnnounceEndedAsync. Started after the entry is stored,
|
||||
// so the containment check inside it can never run against a dictionary the session had not reached.
|
||||
_ = AnnounceEndedAsync(sessionId, pump, run);
|
||||
_ = AnnounceEndedAsync(sessionId, run);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
@@ -626,13 +593,9 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
// After the drain, so a handler reading LiveSessionCount sees this session already gone — the
|
||||
// event's own remark carries why a deliberate close is announced at all, and why shutdown is not:
|
||||
// DisposeAsync sets the flag before its closing loop, and is dismantling every subscriber anyway.
|
||||
//
|
||||
// Reported as this client's own doing whatever the pump made of it. A shell that had already ended on
|
||||
// its own before the tab was closed still carries that older reason, and re-announcing it here as
|
||||
// though it had just happened is how closing a tab comes to look like a dropped connection.
|
||||
if (Volatile.Read(ref disposed) == 0)
|
||||
{
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, ClosedByThisClient));
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,13 +725,6 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
||||
/// <param name="sessionId">The session, as the renderer knows it.</param>
|
||||
/// <param name="pump">
|
||||
/// The pump, for its <see cref="TerminalSessionPump.Outcome"/> — which is only settled once
|
||||
/// <paramref name="run"/> has completed, and is therefore read after the await below rather than captured
|
||||
/// alongside it.
|
||||
/// </param>
|
||||
/// <param name="run">The session's run task.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A continuation rather than a line in <see cref="RunSessionAsync"/>'s finally, and the difference is
|
||||
@@ -784,7 +740,7 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// removes the entry before it disposes the pump, and makes its own announcement after its own drain.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task AnnounceEndedAsync(uint sessionId, TerminalSessionPump pump, Task run)
|
||||
private async Task AnnounceEndedAsync(uint sessionId, Task run)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -805,9 +761,7 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
|
||||
if (announce)
|
||||
{
|
||||
// Read after the await, which is the only point at which the pump has settled it — see
|
||||
// TerminalSessionPump.Outcome.
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, pump.Outcome));
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,14 +37,14 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -63,11 +63,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,41 +87,6 @@ public sealed record TransferSnapshot(
|
||||
State is TransferState.Failed or TransferState.Cancelled && Transferred > 0 && Transferred < Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a transfer should carry across with the bytes.
|
||||
/// </summary>
|
||||
/// <param name="Mode">
|
||||
/// The nine POSIX permission bits to set on the destination, or null to leave whatever it lands with.
|
||||
/// </param>
|
||||
/// <param name="LastWriteTimeUtc">
|
||||
/// The timestamp to set on the destination, or null to leave the one the write produced.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Both nullable, because both are facts the source may not have.</b> A local file on Windows has no
|
||||
/// POSIX mode — this codebase already says so, in <c>LocalEntryRowViewModel</c>, and prints an empty
|
||||
/// <c>PERMS</c> column rather than a plausible <c>-rw-r--r--</c>. So the caller passes what it actually
|
||||
/// knows and null for the rest, and nothing here invents a default: a file that arrives with the server's
|
||||
/// umask on it is the honest outcome of an upload from a machine with no mode to send.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Applied after the rename into place, never before.</b> The part file is what the transfer writes and
|
||||
/// it is not the file anybody asked for; setting a mode on it would put the right bits on the wrong name
|
||||
/// and leave the destination with whatever the rename gave it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failure here does not fail the transfer.</b> The bytes are the transfer and they arrived; a server
|
||||
/// that refuses <c>SETSTAT</c> — some do, and every object store does — must not turn a completed copy into
|
||||
/// a failed one. It is reported through <see cref="TransferSnapshot.Failure"/> on a row that still says
|
||||
/// <see cref="TransferState.Completed"/>, which is the one shape that says both true things at once.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record TransferAttributes(int? Mode, DateTimeOffset? LastWriteTimeUtc)
|
||||
{
|
||||
/// <summary>Whether there is anything here worth a round trip.</summary>
|
||||
public bool IsEmpty => Mode is null && LastWriteTimeUtc is null;
|
||||
}
|
||||
|
||||
/// <summary>A transfer whose state or progress has moved.</summary>
|
||||
/// <param name="transfer">The transfer, as it now is.</param>
|
||||
public sealed class TransferChangedEventArgs(TransferSnapshot transfer) : EventArgs
|
||||
@@ -251,23 +216,18 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
||||
/// rather than measured here, because the pane the user dragged from has just read it and asking again
|
||||
/// would be a round trip to learn something already on screen.
|
||||
/// </param>
|
||||
/// <param name="preserve">
|
||||
/// What to carry across with the bytes, or null to carry nothing. See <see cref="TransferAttributes"/>.
|
||||
/// </param>
|
||||
/// <returns>The transfer's id.</returns>
|
||||
public Guid Enqueue(
|
||||
TransferDirection direction,
|
||||
string localPath,
|
||||
string remotePath,
|
||||
long length,
|
||||
TransferAttributes? preserve = null)
|
||||
long length)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(localPath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(remotePath);
|
||||
|
||||
var entry = new Entry
|
||||
{
|
||||
Preserve = preserve is { IsEmpty: false } wanted ? wanted : null,
|
||||
Id = Guid.CreateVersion7(),
|
||||
Direction = direction,
|
||||
LocalPath = localPath,
|
||||
@@ -589,75 +549,6 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
||||
// block here. On Windows a move of a file still open for writing fails, and it is the one step whose
|
||||
// failure would leave a complete transfer looking like an incomplete one.
|
||||
File.Move(part, destination);
|
||||
|
||||
ApplyLocally(entry, destination);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts the remote file's mode and timestamp on the copy that has just landed here.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The mode is only set where the platform has one.</b> <c>File.SetUnixFileMode</c> throws on
|
||||
/// Windows, where a POSIX mode is not a fact about a file at all — so it is asked for rather than tried,
|
||||
/// and a download onto Windows quietly carries the timestamp alone. That is not a gap being papered
|
||||
/// over: there is nothing on that filesystem for those nine bits to mean.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Best-effort, and its failures are recorded on a row that still says the transfer completed. See
|
||||
/// <see cref="TransferAttributes"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void ApplyLocally(Entry entry, string destination)
|
||||
{
|
||||
if (entry.Preserve is not { } preserve)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (preserve.LastWriteTimeUtc is { } when)
|
||||
{
|
||||
File.SetLastWriteTimeUtc(destination, when.UtcDateTime);
|
||||
}
|
||||
|
||||
if (preserve.Mode is { } mode && !OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(destination, (UnixFileMode)PosixModeBits(mode));
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException
|
||||
or PlatformNotSupportedException)
|
||||
{
|
||||
entry.Failure = $"The file arrived, but its permissions could not be set: {exception.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The nine permission bits, as <see cref="UnixFileMode"/> numbers them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A translation rather than a cast, and it is load-bearing: <see cref="UnixFileMode"/> is a flags enum
|
||||
/// whose values happen to line up with the octal mode for the low nine bits, which is true today and is
|
||||
/// not something the BCL promises. Writing the nine out means a change there is a compile error rather
|
||||
/// than a file that lands with the wrong bits on it.
|
||||
/// </remarks>
|
||||
private static UnixFileMode PosixModeBits(int mode)
|
||||
{
|
||||
var bits = UnixFileMode.None;
|
||||
|
||||
if ((mode & 0b100_000_000) != 0) { bits |= UnixFileMode.UserRead; }
|
||||
if ((mode & 0b010_000_000) != 0) { bits |= UnixFileMode.UserWrite; }
|
||||
if ((mode & 0b001_000_000) != 0) { bits |= UnixFileMode.UserExecute; }
|
||||
if ((mode & 0b000_100_000) != 0) { bits |= UnixFileMode.GroupRead; }
|
||||
if ((mode & 0b000_010_000) != 0) { bits |= UnixFileMode.GroupWrite; }
|
||||
if ((mode & 0b000_001_000) != 0) { bits |= UnixFileMode.GroupExecute; }
|
||||
if ((mode & 0b000_000_100) != 0) { bits |= UnixFileMode.OtherRead; }
|
||||
if ((mode & 0b000_000_010) != 0) { bits |= UnixFileMode.OtherWrite; }
|
||||
if ((mode & 0b000_000_001) != 0) { bits |= UnixFileMode.OtherExecute; }
|
||||
|
||||
return bits;
|
||||
}
|
||||
|
||||
private async Task ReadIntoPartAsync(
|
||||
@@ -711,50 +602,6 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
||||
await WriteIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await session.RenameAsync(part, destination, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await ApplyRemotelyAsync(entry, session, destination, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Puts the source file's mode and timestamp on the copy that has just landed on the host.</summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>This is the one that makes an uploaded <c>deploy.sh</c> arrive executable.</b> Without it a file
|
||||
/// lands with whatever the server's umask gives it, which is the reason people reach for a terminal
|
||||
/// straight after using this screen.
|
||||
/// <para>
|
||||
/// A refusal is recorded and does not fail the transfer — a store that has no mode to set says so, and
|
||||
/// the bytes are still there. See <see cref="TransferAttributes"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task ApplyRemotelyAsync(
|
||||
Entry entry,
|
||||
IRemoteFileStore session,
|
||||
string destination,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entry.Preserve is not { } preserve)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (preserve.Mode is { } mode)
|
||||
{
|
||||
await session.SetPermissionsAsync(destination, mode, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (preserve.LastWriteTimeUtc is { } when)
|
||||
{
|
||||
await session.SetLastWriteTimeAsync(destination, when, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||
and not OperationCanceledException)
|
||||
{
|
||||
entry.Failure = $"The file arrived, but its permissions could not be set: {exception.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteIntoPartAsync(
|
||||
@@ -1018,9 +865,6 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
||||
/// <summary>Whether the next run may carry on from a part file rather than starting again.</summary>
|
||||
public bool Resume { get; set; }
|
||||
|
||||
/// <inheritdoc cref="TransferAttributes" />
|
||||
public TransferAttributes? Preserve { get; init; }
|
||||
|
||||
public CancellationTokenSource? Cancellation { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -37,14 +37,14 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -63,11 +63,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
|
||||
@@ -1744,9 +1744,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -1847,11 +1847,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -122,14 +122,8 @@ internal static class LayoutHarness
|
||||
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
|
||||
internal const double SessionSidebarWidth = 300;
|
||||
|
||||
/*
|
||||
A third session-shell constant stood here through wave B and C: SessionHeaderHeight, 60 pixels, for
|
||||
the host header that sat above the pane on both surfaces. v5c-4 retires that row — its address and
|
||||
its cross-surface button both live in the sidebar now; see SessionSidebar.axaml — so the pane between
|
||||
the tab row and the status bar is 60 pixels taller and this budget no longer subtracts anything for
|
||||
it. The same treatment the retired window-wide tab strip got above, and for the same reason: a
|
||||
constant for chrome that is not drawn is a budget that quietly under-measures every screen.
|
||||
*/
|
||||
/// <summary>The v5b session shell's own host header, from <c>SessionHeader.axaml</c>.</summary>
|
||||
internal const double SessionHeaderHeight = 60;
|
||||
|
||||
/// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary>
|
||||
internal const double SessionStatusBarHeight = 37;
|
||||
@@ -152,12 +146,12 @@ internal static class LayoutHarness
|
||||
/// The arithmetic, top to bottom: <see cref="ScreenHeight"/> less <see cref="SessionShellPadding"/> on
|
||||
/// both the top and the bottom of the outer padded column, less <see cref="SessionTabRowHeight"/> for the
|
||||
/// tab row that sits above the bordered container, less <see cref="SessionShellBorderThickness"/> on both
|
||||
/// the top and the bottom of that border, less <see cref="SessionStatusBarHeight"/> for the one fixed
|
||||
/// strip left below the pane — v5c-4 retired the header above it; see the note where its constant was.
|
||||
/// the top and the bottom of that border, less <see cref="SessionHeaderHeight"/> and
|
||||
/// <see cref="SessionStatusBarHeight"/> for the two fixed strips the pane sits between.
|
||||
/// </remarks>
|
||||
internal static double SessionScreenHeight =>
|
||||
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
|
||||
- SessionStatusBarHeight;
|
||||
- SessionHeaderHeight - SessionStatusBarHeight;
|
||||
|
||||
/// <summary>
|
||||
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Presenters;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
@@ -274,49 +272,6 @@ public sealed class NavRailTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every row in the popover rests flat, and the pointer is what fills one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>Button.poprow</c> set a radius and a padding and left the Background alone, so each row wore the
|
||||
/// Fluent theme's own button fill: the account menu drew as six raised pills where the design draws six
|
||||
/// lines of text. Read as a colour off the templated presenter rather than off the Button, because that
|
||||
/// is where the theme puts its brush and therefore the only place the absence of one can be proven.
|
||||
///
|
||||
/// The hover half is asserted too, and it is what stops "flat" being fixed by making the rows
|
||||
/// permanently invisible to the pointer: a menu row that does not answer a pointer at all is a worse
|
||||
/// answer than one that answers wrongly.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task PopoverRowsAreFlatUntilThePointerFindsThem()
|
||||
{
|
||||
await OnTheRailAsync((rail, window) =>
|
||||
{
|
||||
Click(UserChip(rail), window);
|
||||
|
||||
var row = PopoverRow(window, "Settings");
|
||||
var presenter = row.GetVisualDescendants()
|
||||
.OfType<ContentPresenter>()
|
||||
.First(candidate => candidate.Name is "PART_ContentPresenter");
|
||||
|
||||
var resting = presenter.Background as ISolidColorBrush;
|
||||
|
||||
(resting is null || resting.Color.A == 0).ShouldBeTrue(
|
||||
$"a popover row rests flat, and this one is filled with {resting?.Color}");
|
||||
|
||||
var centre = row.TranslatePoint(new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
|
||||
?? throw new InvalidOperationException("the row is not in this window's tree");
|
||||
|
||||
window.MouseMove(centre);
|
||||
LayoutHarness.Settle(window, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
row.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
|
||||
(presenter.Background as ISolidColorBrush).ShouldNotBeNull().Color.A.ShouldNotBe(
|
||||
(byte)0,
|
||||
"a row that does not change under the pointer is one nobody can tell is clickable");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private Task OnTheRailAsync(Action<NavRail, Window> body) =>
|
||||
|
||||
@@ -236,51 +236,6 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette searches every vault the session holds a key for, so a row has to say which one it came
|
||||
/// out of. Two machines a team and a person both call <c>prod-db</c> are otherwise two identical rows,
|
||||
/// and Enter takes whichever the ranking happened to put first.
|
||||
/// <para>
|
||||
/// Typed into rather than read off the unfiltered list, because the shared vault's host sorts last — the
|
||||
/// active vault's rows come first — and the list virtualises, so the row this is about might never be
|
||||
/// realised. Narrowing to it also proves the search reaches past the active vault at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AResultSaysWhichVaultItCameOutOf()
|
||||
{
|
||||
await SeedSharedHostAsync();
|
||||
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "prod-db";
|
||||
Relayout(window);
|
||||
|
||||
var found = shell.SearchResults.ShouldHaveSingleItem();
|
||||
found.VaultId.ShouldNotBe(session.ActiveVaultId, "the palette reaches past the active vault");
|
||||
|
||||
VisibleTexts(RowFor(palette, found)).ShouldContain("PLATFORM SECRETS", StringComparer.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the rule, and the reason the name is a badge rather than a column: a vault named on
|
||||
/// every row of a session that has only one is the same fact repeated, which is noise rather than a
|
||||
/// reading. <c>HostRowViewModel.VaultBadge</c> is empty there, and an empty line has to collapse rather
|
||||
/// than leave a gap above the kind word.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AResultNamesNoVaultWhenThereIsOnlyOneToBeIn()
|
||||
{
|
||||
await OnThePaletteAsync((palette, _) =>
|
||||
{
|
||||
var first = shell.SearchResults[0];
|
||||
|
||||
first.HasVaultBadge.ShouldBeFalse("this fixture's session holds the personal vault alone");
|
||||
VisibleTexts(RowFor(palette, first)).ShouldNotContain("PERSONAL", StringComparer.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette is a box somebody is expected to start typing into, and for a while it was not: the window
|
||||
/// focused it from the view model's <c>PropertyChanged</c>, which runs before the binding that reveals the
|
||||
@@ -352,25 +307,6 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
.OfType<ListBoxItem>()
|
||||
.First(item => ReferenceEquals(item.DataContext, host));
|
||||
|
||||
/// <summary>What one row actually draws, in order, ignoring the lines that collapsed.</summary>
|
||||
private static List<string> VisibleTexts(Visual row) =>
|
||||
row.GetVisualDescendants()
|
||||
.OfType<TextBlock>()
|
||||
.Where(text => text.IsEffectivelyVisible)
|
||||
.Select(text => text.Text ?? string.Empty)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Runs the layout pass the application's dispatcher would run after the list changed.</summary>
|
||||
/// <remarks>
|
||||
/// Without it the new rows are in the collection but not in the visual tree, so <see cref="RowFor"/>
|
||||
/// finds nothing to look at.
|
||||
/// </remarks>
|
||||
private static void Relayout(Window window)
|
||||
{
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
}
|
||||
|
||||
private static Point Centre(Visual control, Visual window) =>
|
||||
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||
@@ -389,37 +325,4 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a second, shared vault to the fixture's session and files one host into it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Straight into the session rather than through <c>VaultsViewModel</c>, which is the technique the
|
||||
/// settings pages' suite uses and for the same reason: creating a vault needs a connection, and this
|
||||
/// shell has none. The key is generated on this machine either way, so what the session ends up holding
|
||||
/// is the same thing a real creation leaves behind — see <see cref="StubTeamServer"/>.
|
||||
/// </remarks>
|
||||
private async Task SeedSharedHostAsync()
|
||||
{
|
||||
using var teamServer = new StubTeamServer();
|
||||
|
||||
var shared = await session.CreateTeamVaultAsync(
|
||||
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedTargetVault =
|
||||
vault.TargetVaults.Single(choice => choice.VaultId == shared.VaultId);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditing.ShouldBeFalse(vault.Status);
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
@@ -190,25 +189,6 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The editor with its new-credential card showing, which the test above never draws: the card is
|
||||
/// collapsed until somebody presses + NEW CREDENTIAL, so nothing else in this suite measures the three
|
||||
/// boxes, the paragraph of hint text and the two buttons it adds inside the section that already holds
|
||||
/// the authentication picker. A card that only appears on a click is exactly the shape that escapes a
|
||||
/// harness driven by the default state.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostDrawerFitsWithTheNewCredentialFormOpen()
|
||||
{
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.BeginEditorCredentialCommand.Execute(null);
|
||||
vault.IsAddingEditorCredential.ShouldBeTrue("there is nothing to measure otherwise");
|
||||
|
||||
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other editor, and it is in this control for the first time: the desktop's group editor used to be
|
||||
/// a bar across the foot of the hosts screen, where it competed with the grid for the same column. Its
|
||||
@@ -1275,7 +1255,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the
|
||||
/// 472-pixel budget <see cref="LayoutHarness.SessionScreenWidth"/> computes, 204 pixels a side. DISCONNECT
|
||||
/// is what is left in the remote pane's own connected strip now; the account-at-host chip that used to
|
||||
/// share the row with it moved out, because the session shell already prints the same address beside
|
||||
/// share the row with it moved out, because <c>SessionHeader</c> already prints the same address above
|
||||
/// this screen — see <c>TransfersScreen.axaml</c>'s own remark on the strip for why keeping both was the
|
||||
/// thing squeezing DISCONNECT off the edge at this width.
|
||||
/// </para>
|
||||
@@ -1312,184 +1292,18 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// The question in front of deleting something on the host, which takes a row out of the remote pane's
|
||||
/// column while the listing under it is still showing. A directory, because that is the longer of the
|
||||
/// two warnings, and a path deep enough to wrap in a pane a third of the window wide.
|
||||
/// <para>
|
||||
/// Connected, because that is the only state this question can be asked in — and it is the state that
|
||||
/// asks it at the narrowest budget, since <c>IsConnected</c> is what pulls QUICK ACCESS's own sidebar
|
||||
/// in beside this screen and takes the pane down to 204 pixels. Measured disconnected until the row of
|
||||
/// item actions arrived beside DELETE, which is a shape this card has to share the pane's height with.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithADeletionInQuestion()
|
||||
{
|
||||
transfers.IsConnected = true;
|
||||
transfers.RemotePath = "/srv/releases/site/backups/nightly";
|
||||
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
||||
[
|
||||
new RemoteDeletionTarget(
|
||||
"2026-07-30",
|
||||
"/srv/releases/site/backups/nightly/2026-07-30",
|
||||
IsDirectory: true),
|
||||
]);
|
||||
"2026-07-30",
|
||||
"/srv/releases/site/backups/nightly/2026-07-30",
|
||||
IsDirectory: true);
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The edit strip, which shares the questions' Grid row and is the tallest thing that can be in it: a
|
||||
/// heading, a path, a scrolling log of saves, the conflict card and a button. Its log is bounded for the
|
||||
/// same reason the deletion card's path list is — see TransfersScreen.axaml — and this is what says that
|
||||
/// bound holds with the conflict card open underneath it, which is the worst case.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithAFileBeingEdited()
|
||||
{
|
||||
transfers.IsConnected = true;
|
||||
transfers.RemotePath = "/etc/nginx/conf.d";
|
||||
transfers.RemoteEdit = new RemoteEditSession(
|
||||
"site.conf",
|
||||
"/etc/nginx/conf.d/site.conf",
|
||||
Path.Combine(Path.GetTempPath(), "dodossh-edits", "abc", "site.conf"),
|
||||
0b110_100_100);
|
||||
|
||||
transfers.RemoteEditConflict = "The server's copy has changed since you opened it.";
|
||||
|
||||
for (var save = 0; save < 6; save++)
|
||||
{
|
||||
transfers.RemoteEditLog.Add($"14:2{save}:07 saved → uploaded (2.4 KB)");
|
||||
}
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The same card with a whole ticked set in it, which is the shape that can grow without limit. Its
|
||||
/// path list scrolls inside a bounded height rather than pushing the buttons under it off the pane —
|
||||
/// see TransfersScreen.axaml — and this is what says that bound actually holds. Five is past the point
|
||||
/// where an unbounded list would have overflowed the 204-pixel pane inside the session shell.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithSeveralDeletionsInQuestion()
|
||||
{
|
||||
transfers.IsConnected = true;
|
||||
transfers.RemotePath = "/srv/releases/site/backups/nightly";
|
||||
transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
|
||||
[
|
||||
.. Enumerable.Range(24, 5).Select(day => new RemoteDeletionTarget(
|
||||
$"2026-07-{day}",
|
||||
$"/srv/releases/site/backups/nightly/2026-07-{day}",
|
||||
IsDirectory: true)),
|
||||
]);
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The rename form, which takes a row out of the remote pane's column the way the deletion question
|
||||
/// does but is a taller shape: a heading, the path, a label, a box and a sentence, over a listing that
|
||||
/// is still showing. Measured connected, so it is asked at the 204-pixel width the session shell's own
|
||||
/// sidebar leaves this pane.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithARenameInQuestion()
|
||||
{
|
||||
transfers.IsConnected = true;
|
||||
transfers.RemotePath = "/srv/releases/site/shared";
|
||||
transfers.PendingRemoteMove = new RemoteMoveRequest(
|
||||
"site-backup-2026-07-30.sql.gz",
|
||||
"/srv/releases/site/shared/site-backup-2026-07-30.sql.gz",
|
||||
IsDirectory: false,
|
||||
RemoteMoveKind.Rename);
|
||||
transfers.RemoteMoveTarget = "site-backup-2026-07-30.sql.gz";
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a rename being filled in"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The move form is the same shape with longer words in it — see <c>RemoteMoveRequest.Note</c>, whose
|
||||
/// move half is two sentences where the rename half is one — and a directory, which is the kind whose
|
||||
/// destination is most likely to be a deep path typed in full.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithAMoveInQuestion()
|
||||
{
|
||||
transfers.IsConnected = true;
|
||||
transfers.RemotePath = "/srv/releases/site/shared";
|
||||
transfers.PendingRemoteMove = new RemoteMoveRequest(
|
||||
"uploads",
|
||||
"/srv/releases/site/shared/uploads",
|
||||
IsDirectory: true,
|
||||
RemoteMoveKind.Move);
|
||||
transfers.RemoteMoveTarget = "/srv/releases/site/shared/archive/2026/production";
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a move being filled in"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The pane header's path is a box that can be typed into, and this is what says so: the regression it
|
||||
/// guards against is the one this replaced, a <c>TextBlock</c> that printed where the pane was and gave
|
||||
/// no way to say where to go. It is bound to <see cref="TransfersViewModel.RemotePathDraft"/> rather
|
||||
/// than to <c>RemotePath</c>, which is the difference between a box somebody can finish typing into and
|
||||
/// one that navigates on every keystroke.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The three item buttons are asserted in the same pass because they and the box are the same change:
|
||||
/// DELETE left the header to join RENAME and MOVE below it, and the room the box has is exactly the
|
||||
/// room those two buttons no longer take up there.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheRemotePanesPathIsTypedAndItsRowActionsAreAllThere()
|
||||
{
|
||||
transfers.IsConnected = true;
|
||||
transfers.RemotePath = "/srv/releases/site/shared/uploads/production/2026/08/nightly";
|
||||
transfers.RemotePathDraft = transfers.RemotePath;
|
||||
|
||||
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
|
||||
"docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
|
||||
3_400, TimeProvider.System.GetUtcNow(), "-rw-r--r--")));
|
||||
|
||||
transfers.SelectedRemoteEntry = transfers.RemoteEntries[0];
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new TransfersScreen { DataContext = transfers };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
var box = screen.GetVisualDescendants()
|
||||
.OfType<TextBox>()
|
||||
.Single(text => text.Classes.Contains("pathbox"));
|
||||
|
||||
box.Text.ShouldBe(transfers.RemotePath);
|
||||
box.IsEffectivelyEnabled.ShouldBeTrue("nothing may type into a disconnected pane");
|
||||
box.IsReadOnly.ShouldBeFalse();
|
||||
|
||||
var tips = screen.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Select(button => ToolTip.GetTip(button) as string)
|
||||
.Where(tip => tip is not null)
|
||||
.ToList();
|
||||
|
||||
tips.ShouldContain("Rename on the host");
|
||||
tips.ShouldContain("Move to another directory");
|
||||
tips.ShouldContain("Delete on the host");
|
||||
|
||||
LayoutHarness.Unreachable(window).ShouldBeEmpty("with a row chosen and its actions showing");
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
// ---- ◆ wave C: the restyled panes and the TRANSFERS strip ----
|
||||
|
||||
/// <remarks>
|
||||
@@ -1763,7 +1577,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// v5b's redraw changes what this test has to hold. Three button shapes live in the rail now rather
|
||||
/// than one: the switcher's three segments, each a third of the rail's own content width; the five item
|
||||
/// than one: the switcher's three segments, each a third of the rail's own content width; the six item
|
||||
/// rows below it and the user chip at the foot, both the rail's full content width. A single
|
||||
/// across-the-board width assertion the way the v3 version of this test made one would either be wrong
|
||||
/// for the segments or have to loosen until it caught nothing, so each shape gets its own count and its
|
||||
@@ -1771,18 +1585,13 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The rail runs vertically, so what runs out at the window's minimum is still height — a switcher plus
|
||||
/// five rows plus a user chip have to leave room for each other in the same space the v3 rail's seven
|
||||
/// six rows plus a user chip have to leave room for each other in the same space the v3 rail's seven
|
||||
/// plain rows did. Both counts are asserted in both directions for the reason the old test's was: an
|
||||
/// entry silently dropping off the bottom would still pass every other assertion here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Five and not six since Pins left the rail: the pins screen is reached from "Host keys" on the Keys
|
||||
/// screen, which was always the other way in. Exact rather than a bound, so putting a row back is a
|
||||
/// decision somebody makes here rather than something that slips in.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheNavRailHoldsItsSwitcherFiveDestinationsAndTheUserChipAtTheWindowsMinimum()
|
||||
public async Task TheNavRailHoldsItsSwitcherSixDestinationsAndTheUserChipAtTheWindowsMinimum()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
@@ -1802,7 +1611,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
|
||||
segments.Count.ShouldBe(3, "SSH, SFTP and S3");
|
||||
rows.Count.ShouldBe(
|
||||
5, "the mode-dependent first row, then Hosts, Keys, Snips and Logs");
|
||||
6, "the mode-dependent first row, then Hosts, Keys, Pins, Snips and Logs");
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
@@ -2224,262 +2033,6 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
|
||||
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
|
||||
/// </remarks>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ---- ◆ The listings' columns ----
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Every cell shows what it holds, at a width this screen actually gets.</b> The columns were fixed
|
||||
/// pixel widths until they were measured against their own content: MODIFIED prints a sixteen-character
|
||||
/// timestamp needing 116 pixels and had 52, so it was truncated at every width the application has ever
|
||||
/// been drawn at — on a maximised window it read "2026-08" beside a NAME column with four hundred empty
|
||||
/// pixels in it. PERMS and SIZE were short by the same kind of margin.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted by measuring each cell's own text unconstrained and holding the rendered width against it,
|
||||
/// rather than against numbers written down here: a column that fits is a column at least as wide as
|
||||
/// what it has to draw, and any other formulation is this test agreeing with the markup about an
|
||||
/// arithmetic they both got from the same place.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheListingColumnsShowTheirContentInFullWhenThereIsRoom()
|
||||
{
|
||||
SeedBothListings();
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new TransfersScreen { DataContext = transfers };
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, MaximisedSessionScreenWidth, LayoutHarness.SessionScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var text in new[] { "2.1 KB", "2026-08-21 11:36", "-rw-r--r--", "2026-07-04 09:00" })
|
||||
{
|
||||
var cell = screen.GetVisualDescendants()
|
||||
.OfType<TextBlock>()
|
||||
.First(block => string.Equals(block.Text, text, StringComparison.Ordinal));
|
||||
|
||||
cell.Bounds.Width.ShouldBeGreaterThanOrEqualTo(
|
||||
Unconstrained(cell) - 0.5, $"'{text}' has to fit the column it is drawn in");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The headings are right-aligned in their own columns, so two of them whose columns are exactly the
|
||||
/// width of their text run together: SIZE ended on the pixel MODIFIED began at, and the row read
|
||||
/// "SIZEMODIFIED". This holds the gap that fixes it — a left margin on each of the three, which does not
|
||||
/// move a right-aligned label but does stop the column before it reaching this one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheListingHeadingsDoNotRunIntoEachOther()
|
||||
{
|
||||
SeedBothListings();
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new TransfersScreen { DataContext = transfers };
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, MaximisedSessionScreenWidth, LayoutHarness.SessionScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
// Per pane, not across both. Each has its own NAME at its own left edge, and a list of
|
||||
// all seven sorted by position pairs the local pane's last heading with the remote
|
||||
// pane's first — two labels in different panes, which no amount of margin separates.
|
||||
var rows = screen.GetVisualDescendants()
|
||||
.OfType<Grid>()
|
||||
.Where(grid => grid.Children.OfType<TextBlock>().Any(
|
||||
block => block.Classes.Contains("label")
|
||||
&& string.Equals(block.Text, "NAME", StringComparison.Ordinal)))
|
||||
.ToList();
|
||||
|
||||
rows.Count.ShouldBe(2, "one heading row per pane");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var headings = row.Children
|
||||
.OfType<TextBlock>()
|
||||
.Where(block => block.Classes.Contains("label"))
|
||||
.Select(block => (block.Text, Left: block.Bounds.X, Right: block.Bounds.Right))
|
||||
.OrderBy(heading => heading.Left)
|
||||
.ToList();
|
||||
|
||||
foreach (var (before, after) in headings.Zip(headings.Skip(1)))
|
||||
{
|
||||
after.Left.ShouldBeGreaterThan(
|
||||
before.Right, $"'{before.Text}' must not touch '{after.Text}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The other end of the same fault. At the 204 pixels the session shell's own sidebar leaves a pane, the
|
||||
/// fixed columns took 176 of a row's 180 and the remote pane's NAME cell measured <b>zero</b> — a file
|
||||
/// browser drawing sizes, timestamps and modes for rows whose names were not on screen at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No column carries a MinWidth, which is what keeps this from being a fight the row can lose: at any
|
||||
/// width all four give ground together, and NAME — weighted heaviest — is the last to run out. What is
|
||||
/// asserted is only that it has not run out, because how much of a name fits in 204 pixels is a question
|
||||
/// with no good answer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheNameColumnSurvivesTheNarrowestBudget()
|
||||
{
|
||||
SeedBothListings();
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new TransfersScreen { DataContext = transfers };
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var name in new[] { ".bash_history", ".azure-devops" })
|
||||
{
|
||||
var cell = screen.GetVisualDescendants()
|
||||
.OfType<TextBlock>()
|
||||
.First(block => string.Equals(block.Text, name, StringComparison.Ordinal));
|
||||
|
||||
cell.Bounds.Width.ShouldBeGreaterThan(20, $"'{name}' must be readable at all");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A placeholder cut off mid-word reads as a rendering fault rather than as a hint — MKDIR's box drew
|
||||
/// "new directo" and the pane header's drew a path ending mid-segment. Both are boxes whose width is
|
||||
/// fixed by the pane's own narrowest budget, so the text is what had to give.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePaneBoxesPlaceholdersFitTheBoxesTheyAreIn()
|
||||
{
|
||||
transfers.IsConnected = true;
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new TransfersScreen { DataContext = transfers };
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var box in screen.GetVisualDescendants().OfType<TextBox>())
|
||||
{
|
||||
// The picker's own password box is in the collapsed half of the invitation, so it
|
||||
// lays out at zero width and has no room for anything. Only what is on screen.
|
||||
if (box.Watermark is not { Length: > 0 } mark || !box.IsEffectivelyVisible)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var room = box.Bounds.Width - box.Padding.Left - box.Padding.Right;
|
||||
|
||||
room.ShouldBeGreaterThanOrEqualTo(
|
||||
Measured(mark, box.FontSize, box.FontFamily),
|
||||
$"the placeholder '{mark}' has to fit its own box");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <summary>Puts one row of each pane's own shape in, with content of a real length.</summary>
|
||||
/// <remarks>
|
||||
/// The local pane is cleared first for the reason <see cref="TheRowTemplateShowsSizeAndDateColumns"/>
|
||||
/// gives: it lists this fixture's real home directory, and a virtualizing ListBox realises only the rows
|
||||
/// that fit — a row appended after those would never be built, and the assertion would find nothing to
|
||||
/// have missed.
|
||||
/// </remarks>
|
||||
private void SeedBothListings()
|
||||
{
|
||||
transfers.IsConnected = true;
|
||||
transfers.RemotePath = "/root";
|
||||
|
||||
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
|
||||
".bash_history",
|
||||
"/root/.bash_history",
|
||||
SftpEntryKind.File,
|
||||
2_150,
|
||||
new DateTimeOffset(2026, 8, 21, 11, 36, 0, TimeSpan.Zero),
|
||||
"-rw-r--r--")));
|
||||
|
||||
transfers.LocalEntries.Clear();
|
||||
transfers.LocalEntries.Add(new LocalEntryRowViewModel(new LocalEntry(
|
||||
".azure-devops",
|
||||
Path.Combine(Path.GetTempPath(), ".azure-devops"),
|
||||
IsDirectory: true,
|
||||
Length: 0,
|
||||
new DateTimeOffset(2026, 7, 4, 9, 0, 0, TimeSpan.Zero))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a session-shell screen gets on a maximised window, which is where the columns were reported
|
||||
/// wrong.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A 1843-pixel window less the shell's own 26-pixel padding a side, its 1-pixel border a side, and
|
||||
/// QUICK ACCESS's 300 — the arithmetic <see cref="LayoutHarness.SessionScreenWidth"/> does, at a width
|
||||
/// that constant cannot express because it is pinned to the window's <em>minimum</em>. Both belong in
|
||||
/// this file: the minimum is what everything else here measures against, and this is the width at which
|
||||
/// the numeric columns reach their caps and the question "does the content fit" has a yes to give.
|
||||
/// Below it they share the width instead, which is what
|
||||
/// <see cref="TheNameColumnSurvivesTheNarrowestBudget"/> holds the other end of.
|
||||
/// </remarks>
|
||||
private const double MaximisedSessionScreenWidth = 1843 - (2 * 26) - (2 * 1) - 300;
|
||||
|
||||
/// <summary>What a rendered block would want if nothing bounded it.</summary>
|
||||
private static double Unconstrained(TextBlock block) =>
|
||||
Measured(block.Text ?? string.Empty, block.FontSize, block.FontFamily);
|
||||
|
||||
/// <inheritdoc cref="Unconstrained" />
|
||||
private static double Measured(string text, double fontSize, Avalonia.Media.FontFamily family)
|
||||
{
|
||||
var block = new TextBlock { Text = text, FontSize = fontSize, FontFamily = family };
|
||||
|
||||
block.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||
|
||||
return block.DesiredSize.Width;
|
||||
}
|
||||
|
||||
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The session shell's right-hand column: its two widths, and what a long address does to the row it shares.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Worth a suite of its own since v5c-4, which gave this control two things it did not have: a session block
|
||||
/// at its head — the address, and the cross-surface button, both inherited from the 60-pixel header row that
|
||||
/// pass retired — and a closed state. The first is exactly the shape this harness exists for, a fixed-width
|
||||
/// column holding a string of unbounded length beside a button that must stay clickable; the second is a
|
||||
/// width the rest of the window has to cope with, and <c>MainWindow.axaml</c> copes with it by asking this
|
||||
/// control how wide it is rather than by knowing.
|
||||
/// </remarks>
|
||||
public sealed class SessionSidebarTests : IAsyncLifetime
|
||||
{
|
||||
/// <summary>The widths <c>SessionSidebar.axaml</c> declares for its two states.</summary>
|
||||
private const double OpenWidth = 300;
|
||||
|
||||
/// <inheritdoc cref="OpenWidth" />
|
||||
private const double RailWidth = 34;
|
||||
|
||||
/// <remarks>
|
||||
/// Long on purpose, and longer than the column is wide at this font: the address is the one string here
|
||||
/// whose length nobody controls, and it shares its row with the button that closes the column.
|
||||
/// </remarks>
|
||||
private const string LongAddress = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022";
|
||||
|
||||
private string directory = null!;
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask InitializeAsync()
|
||||
{
|
||||
// A profile of this test's own rather than ClientPaths.Default: closing the sidebar is written
|
||||
// through to disk — see ClientSettings.SessionSidebarOpen — and a suite that used the default paths
|
||||
// would be editing the preferences of whoever ran it.
|
||||
directory = Path.Combine(Path.GetTempPath(), $"dodossh-sidebar-{Guid.CreateVersion7():N}");
|
||||
caches = ClientCacheFactory.ForMemory($"session-sidebar-{Guid.CreateVersion7():N}");
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
new ClientPaths(directory),
|
||||
caches,
|
||||
workspace,
|
||||
new VaultKnownHostStore(),
|
||||
Substitute.For<IDeviceKeyStore>(),
|
||||
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
||||
TimeProvider.System,
|
||||
Substitute.For<ISftpSessionFactory>())
|
||||
{
|
||||
State = ShellState.Unlocked,
|
||||
};
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
await workspace.DisposeAsync();
|
||||
caches.Dispose();
|
||||
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The address trims and the close button stays where it is; that is the whole claim. Asserted through
|
||||
/// <see cref="LayoutHarness.Unreachable"/> rather than by reading the address's own width, because what
|
||||
/// matters is not how much of the string is shown — an ellipsis is an honest answer — but that nothing
|
||||
/// beside it was pushed out of the column to make room.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheColumnIsThreeHundredWide_AndALongAddressPushesNothingOutOfIt()
|
||||
{
|
||||
await OnTheSidebarAsync((sidebar, window) =>
|
||||
{
|
||||
// DesiredSize rather than Bounds, and that distinction is the control's own: the width lives on
|
||||
// the Border inside it, so what the surrounding "Auto" column is given — and what this asks for
|
||||
// — is what the control asks for, not how wide a host window happened to stretch it.
|
||||
sidebar.DesiredSize.Width.ShouldBe(OpenWidth);
|
||||
|
||||
shell.SessionAddress.ShouldBe(LongAddress, "the fixture selected a tab with one");
|
||||
|
||||
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The cross-surface button that used to live in the header row. Read off the control rather than off the
|
||||
/// view model, so a row bound to the wrong property — or to nothing, which a compiled binding would still
|
||||
/// draw as an empty button — fails this.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheCrossSurfaceButtonNamesTheOtherSurface()
|
||||
{
|
||||
await OnTheSidebarAsync((sidebar, _) =>
|
||||
{
|
||||
var button = sidebar.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.First(candidate => candidate.Classes.Contains("headerghost"));
|
||||
|
||||
button.Content.ShouldBe("Open SFTP");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The address is the one string in this column somebody has to be able to take away with them, and the
|
||||
/// claim under test is the half of that which the trimming could quietly break: an ellipsis is a drawing,
|
||||
/// not a shorter string, so select-all reaches the whole address and not the part that happened to fit.
|
||||
/// Asserted on the longest address this suite has — the case where the two could disagree — rather than
|
||||
/// on one that fits, which would pass whether the trimming were render-only or not.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheAddressSelectsAndCopiesWholeEvenWhereItIsTrimmed()
|
||||
{
|
||||
await OnTheSidebarAsync((sidebar, _) =>
|
||||
{
|
||||
// Single rather than First: this column prints exactly one string that is there to be copied, and
|
||||
// a second selectable one appearing beside it is a question this test should ask rather than skip.
|
||||
var address = sidebar.GetVisualDescendants().OfType<SelectableTextBlock>().Single();
|
||||
|
||||
address.Text.ShouldBe(LongAddress);
|
||||
|
||||
address.SelectAll();
|
||||
|
||||
address.SelectedText.ShouldBe(LongAddress);
|
||||
address.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Closed, the control is still drawn and is still the thing the window asks for a width — see
|
||||
/// <c>MainWindow.axaml</c>'s own "Auto" column. What it must not be is nothing: a rail with the way back
|
||||
/// on it is the difference between a panel somebody closed and a panel somebody lost.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClosingTheColumnLeavesTheRailThatBringsItBack()
|
||||
{
|
||||
await OnTheSidebarAsync((sidebar, window) =>
|
||||
{
|
||||
shell.IsSessionSidebarOpen = false;
|
||||
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||
|
||||
sidebar.DesiredSize.Width.ShouldBe(RailWidth);
|
||||
|
||||
var grip = sidebar.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.Single(candidate => candidate.Classes.Contains("sidebargrip") && candidate.IsEffectivelyVisible);
|
||||
|
||||
var centre = grip.TranslatePoint(new Point(grip.Bounds.Width / 2, grip.Bounds.Height / 2), window)
|
||||
?? throw new InvalidOperationException("the grip is not in this window's tree");
|
||||
|
||||
window.MouseDown(centre, MouseButton.Left);
|
||||
window.MouseUp(centre, MouseButton.Left);
|
||||
|
||||
shell.IsSessionSidebarOpen.ShouldBeTrue("the rail's own button is what reopens the column");
|
||||
|
||||
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||
sidebar.DesiredSize.Width.ShouldBe(OpenWidth);
|
||||
});
|
||||
}
|
||||
|
||||
private Task OnTheSidebarAsync(Action<SessionSidebar, Window> body) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
shell.Tabs.Add(new TerminalTabViewModel(1, "db-primary", LongAddress));
|
||||
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
||||
|
||||
var sidebar = new SessionSidebar { DataContext = shell, ShowsSnips = true };
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
sidebar, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||
|
||||
try
|
||||
{
|
||||
body(sidebar, window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
@@ -101,59 +101,4 @@ public sealed class TitleBarTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The chip that says which chord opens the pill beside it. The design's own is 34 pixels wide because
|
||||
/// the design's own label is ⌘K — two glyphs — and this build substitutes "CTRL K", which at 10.5 mono is
|
||||
/// wider than that. It shipped clipped: the chip drew "CTRL" and half of the K, which reads as a rendering
|
||||
/// glitch rather than as a keyboard shortcut.
|
||||
///
|
||||
/// Asserted as "the chip is at least as wide as its own text", not against a number. A pixel count would
|
||||
/// have to be re-derived by hand every time the font, the size or the wording moved, and the thing that
|
||||
/// actually matters is the relationship between the two.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheKeyboardChipIsWideEnoughForTheChordItNames()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var bar = new TitleBar { DataContext = shell };
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
|
||||
|
||||
try
|
||||
{
|
||||
var label = bar.GetVisualDescendants()
|
||||
.OfType<TextBlock>()
|
||||
.Single(text => string.Equals(text.Text, "CTRL K", StringComparison.Ordinal));
|
||||
|
||||
var chip = label.GetVisualAncestors().OfType<Border>().First();
|
||||
|
||||
// Measured on a copy under an unbounded constraint, not read off the label in the tree.
|
||||
// A TextBlock's own DesiredSize is already clipped to what it was given, so the laid-out
|
||||
// one reports 34 inside a 34-pixel chip whether or not the text fits — which is exactly
|
||||
// the state this test exists to fail on.
|
||||
var natural = new TextBlock
|
||||
{
|
||||
Text = label.Text,
|
||||
FontFamily = label.FontFamily,
|
||||
FontSize = label.FontSize,
|
||||
FontWeight = label.FontWeight,
|
||||
};
|
||||
|
||||
natural.Measure(Size.Infinity);
|
||||
|
||||
natural.DesiredSize.Width.ShouldBeGreaterThan(0, "the chord is a real run of text");
|
||||
chip.Bounds.Width.ShouldBeGreaterThanOrEqualTo(
|
||||
natural.DesiredSize.Width,
|
||||
"a chip narrower than its own label draws part of the chord and cuts the rest");
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
}
|
||||
@@ -490,7 +490,6 @@
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
||||
"Microsoft.TSS": "[2.1.1, )",
|
||||
"Velopack": "[1.2.0, )"
|
||||
}
|
||||
},
|
||||
@@ -552,7 +551,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -664,9 +663,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -730,12 +729,6 @@
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"Microsoft.TSS": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.1, )",
|
||||
"resolved": "2.1.1",
|
||||
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[26.4.0, )",
|
||||
@@ -778,11 +771,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -29,14 +29,6 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
/// <summary>Requests this factory was asked for, in order.</summary>
|
||||
internal List<SshConnectionRequest> Requests { get; } = [];
|
||||
|
||||
/// <summary>Every shell this factory has opened, in order.</summary>
|
||||
/// <remarks>
|
||||
/// Exists so a test can reach one and drop it — see <see cref="FakeSshShellSession.Drop"/>. There was no
|
||||
/// reason to hold them while the only ending a session had was the tab being closed, which the shell can
|
||||
/// ask for directly.
|
||||
/// </remarks>
|
||||
internal List<FakeSshShellSession> Shells { get; } = [];
|
||||
|
||||
/// <summary>Requests for a file-transfer session, in order.</summary>
|
||||
/// <remarks>
|
||||
/// Kept apart from <see cref="Requests"/> deliberately: file transfer is a separate connection, and a
|
||||
@@ -48,43 +40,20 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
/// <inheritdoc />
|
||||
public async Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request);
|
||||
|
||||
// Before the gate rather than after it, which is what makes this fake useful for the connecting
|
||||
// card: a test that holds Gate open is a connection stuck partway through, and the step list has to
|
||||
// show it stuck on a named step rather than on none.
|
||||
progress?.Report(SshConnectionPhase.Reaching);
|
||||
|
||||
if (Gate is { } gate)
|
||||
{
|
||||
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (Failure is { } failure)
|
||||
{
|
||||
throw failure;
|
||||
}
|
||||
|
||||
// Only on the way to succeeding. A failure reported as having authenticated would let a test pass
|
||||
// while the card showed a refused connection getting one step further than it did.
|
||||
progress?.Report(SshConnectionPhase.CheckingHostKey);
|
||||
progress?.Report(SshConnectionPhase.Authenticating);
|
||||
|
||||
return new FakeSshConnection(request, Shells);
|
||||
return Failure is { } failure
|
||||
? throw failure
|
||||
: new FakeSshConnection(request);
|
||||
}
|
||||
|
||||
/// <summary>The file-transfer session this factory last handed out, or null when it never has.</summary>
|
||||
/// <remarks>
|
||||
/// Kept so a test can read back what the screen actually asked the host to do — which path was renamed,
|
||||
/// which was deleted, which directories were listed. The screen reports all three in its own status
|
||||
/// line, and a suite that asserted only on that would be checking a sentence this code wrote about
|
||||
/// itself rather than the operation it claims to have performed.
|
||||
/// </remarks>
|
||||
internal FakeSftpSession? OpenedSftp { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISftpSession> OpenSftpAsync(
|
||||
SshConnectionRequest request,
|
||||
@@ -92,14 +61,9 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
{
|
||||
SftpRequests.Add(request);
|
||||
|
||||
if (Failure is { } failure)
|
||||
{
|
||||
return Task.FromException<ISftpSession>(failure);
|
||||
}
|
||||
|
||||
OpenedSftp = new FakeSftpSession(request);
|
||||
|
||||
return Task.FromResult<ISftpSession>(OpenedSftp);
|
||||
return Failure is { } failure
|
||||
? Task.FromException<ISftpSession>(failure)
|
||||
: Task.FromResult<ISftpSession>(new FakeSftpSession(request));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,27 +79,6 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected { get; private set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TransportFailure { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler? ConnectionLost;
|
||||
|
||||
/// <summary>
|
||||
/// Ends this session the way a rebooted host does: the connection is gone and it says so.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Latched before the announcement, which is the ordering the real session promises — a screen reading
|
||||
/// the reason out of the event would see null if a fake did it the other way round.
|
||||
/// </remarks>
|
||||
internal void Drop(string reason)
|
||||
{
|
||||
TransportFailure = reason;
|
||||
IsConnected = false;
|
||||
|
||||
ConnectionLost?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } =
|
||||
new(request.Host, request.Port, "ssh-ed25519", "SHA256:fake");
|
||||
@@ -146,55 +89,9 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory => $"/home/{request.Username}";
|
||||
|
||||
/// <summary>Every directory this session has been asked to list, in order.</summary>
|
||||
/// <remarks>
|
||||
/// The evidence that a refresh happened at all. A listing whose contents never change — this one always
|
||||
/// answers the same single row — leaves no other trace of having been re-read, which is exactly the
|
||||
/// thing the automatic refresh after a transfer has to be proved to do.
|
||||
/// </remarks>
|
||||
internal List<string> Listed { get; } = [];
|
||||
|
||||
/// <summary>Every rename this session has been asked for, as it was asked.</summary>
|
||||
internal List<(string From, string To)> Renamed { get; } = [];
|
||||
|
||||
/// <summary>Every path this session has been asked to delete.</summary>
|
||||
internal List<string> Deleted { get; } = [];
|
||||
|
||||
/// <summary>Every mode this session has been asked to set, as it was asked.</summary>
|
||||
/// <remarks>
|
||||
/// The evidence the screen's own status line cannot be: that sentence is code describing itself, and
|
||||
/// what matters is which nine bits actually went to the host.
|
||||
/// </remarks>
|
||||
internal List<(string Path, int Mode)> PermissionsSet { get; } = [];
|
||||
|
||||
/// <summary>Every timestamp this session has been asked to set.</summary>
|
||||
internal List<(string Path, DateTimeOffset When)> TimesSet { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetPermissionsAsync(string path, int mode, CancellationToken cancellationToken)
|
||||
{
|
||||
PermissionsSet.Add((path, mode));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetLastWriteTimeAsync(
|
||||
string path,
|
||||
DateTimeOffset lastWriteTimeUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
TimesSet.Add((path, lastWriteTimeUtc));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
Listed.Add(path);
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SftpEntry>>(
|
||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<SftpEntry>>(
|
||||
[
|
||||
new SftpEntry(
|
||||
"notes.txt",
|
||||
@@ -204,85 +101,29 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"-rw-r--r--"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paths this session admits to already having, and what it says about each.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ Empty by default, and that is load-bearing rather than lazy: the transfer queue asks
|
||||
/// <see cref="StatAsync"/> whether a destination is taken and refuses to overwrite one that is, so a
|
||||
/// fake answering yes for everything would make every upload in this suite fail. What needs a real
|
||||
/// answer is editing, which stats the file before each write-back to see whether it moved — so a test
|
||||
/// about that puts the one path it is about in here.
|
||||
/// </remarks>
|
||||
internal Dictionary<string, SftpEntry> Existing { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>What the last write-back actually sent, so an edit's bytes are observable.</summary>
|
||||
internal byte[]? Written { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(Existing.TryGetValue(path, out var entry) ? entry : null);
|
||||
Task.FromResult<SftpEntry?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<Stream>(new MemoryStream("hello there\n"u8.ToArray(), writable: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// ◆ A write updates <see cref="Existing"/> only for a path already in it, which is the difference
|
||||
/// between modelling a server and breaking every other test in the suite: the transfer queue writes to
|
||||
/// part files and stats them to decide whether there is anything to resume from, so a fake that
|
||||
/// remembered every write would start answering yes to questions it currently answers no to. What is in
|
||||
/// <see cref="Existing"/> is what a test deliberately put there.
|
||||
/// </remarks>
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<Stream>(new RecordingStream(bytes =>
|
||||
{
|
||||
Written = bytes;
|
||||
|
||||
if (Existing.TryGetValue(path, out var before))
|
||||
{
|
||||
Existing[path] = before with
|
||||
{
|
||||
Length = bytes.Length,
|
||||
LastWriteTimeUtc = before.LastWriteTimeUtc.AddSeconds(1),
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
/// <summary>Every directory this session was asked to make, in the order it was asked.</summary>
|
||||
/// <remarks>
|
||||
/// The evidence a folder upload actually built the tree before it queued the files. Order matters and is
|
||||
/// asserted on: a parent has to exist before its child is asked for, and shortest-first is what
|
||||
/// guarantees it.
|
||||
/// </remarks>
|
||||
internal List<string> Created { get; } = [];
|
||||
Task.FromResult<Stream>(new MemoryStream());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
Created.Add(path);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
Deleted.Add(path);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task DeleteAsync(string path, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
||||
{
|
||||
Renamed.Add((fromPath, toPath));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
@@ -292,8 +133,7 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeSshConnection(SshConnectionRequest request, List<FakeSshShellSession> shells)
|
||||
: ISshConnection
|
||||
internal sealed class FakeSshConnection(SshConnectionRequest request) : ISshConnection
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected { get; private set; } = true;
|
||||
@@ -308,13 +148,8 @@ internal sealed class FakeSshConnection(SshConnectionRequest request, List<FakeS
|
||||
/// <inheritdoc />
|
||||
public Task<ISshShellSession> OpenShellAsync(
|
||||
TerminalSize size,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var shell = new FakeSshShellSession();
|
||||
shells.Add(shell);
|
||||
|
||||
return Task.FromResult<ISshShellSession>(shell);
|
||||
}
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult<ISshShellSession>(new FakeSshShellSession());
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
@@ -324,33 +159,11 @@ internal sealed class FakeSshConnection(SshConnectionRequest request, List<FakeS
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A write stream that hands its contents over when it is closed.</summary>
|
||||
/// <remarks>
|
||||
/// A <see cref="MemoryStream"/> cannot be read after disposal, and disposal is exactly when a write is
|
||||
/// finished — so a fake that handed one back could observe a transfer starting and never what it sent.
|
||||
/// </remarks>
|
||||
internal sealed class RecordingStream(Action<byte[]> onClosed) : MemoryStream
|
||||
{
|
||||
private bool handed;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !handed)
|
||||
{
|
||||
handed = true;
|
||||
onClosed(ToArray());
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A shell that is open, silent and closes only when it is told to.</summary>
|
||||
/// <summary>A shell that is open, silent and never closes on its own.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ReadAsync"/> blocks rather than returning 0. Returning 0 means the remote closed the
|
||||
/// channel, which would end the session the moment it was opened and make the test assert against a
|
||||
/// connection that had already gone. <see cref="Drop"/> and <see cref="Exit"/> are the two ways out.
|
||||
/// connection that had already gone.
|
||||
/// </remarks>
|
||||
internal sealed class FakeSshShellSession : ISshShellSession
|
||||
{
|
||||
@@ -359,43 +172,13 @@ internal sealed class FakeSshShellSession : ISshShellSession
|
||||
/// <inheritdoc />
|
||||
public bool IsOpen { get; private set; } = true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? TransportFailure { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ends this shell the way a rebooted machine does: the read stops, with a transport failure latched.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Latched before the read is released, which is the ordering the real session promises and the whole
|
||||
/// reason the distinction is race-free — see <see cref="ISshShellSession.TransportFailure"/>. A fake
|
||||
/// that set it afterwards would be testing a guarantee the product does not make.
|
||||
/// </remarks>
|
||||
internal void Drop(string reason)
|
||||
{
|
||||
TransportFailure = reason;
|
||||
closed.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>Ends this shell the way <c>exit</c> does: the read stops, with nothing wrong underneath.</summary>
|
||||
internal void Exit() => closed.Cancel();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, closed.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// This shell ending, not the pump being asked to stop. Reported as end of stream rather than
|
||||
// rethrown, because that is what a real read does for both of the endings above: the channel
|
||||
// stops producing bytes, and what happened to the transport is told by the latch, not by an
|
||||
// exception coming out of here.
|
||||
return 0;
|
||||
}
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken, closed.Token);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, linked.Token).ConfigureAwait(false);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
using Tpm2Lib;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The TPM-backed store, driven end to end against a software TPM.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is the only device key store in the repository with real coverage, and the reason is worth
|
||||
/// knowing.</b> <c>WindowsDeviceKeyStoreTests</c> records that the Windows store cannot be exercised at
|
||||
/// all: the consent policy prompts at creation as well as at use, so anything calling <c>SaveAsync</c>
|
||||
/// blocks the suite forever waiting for a person. macOS is the same shape with a Touch ID sheet. Here the
|
||||
/// PIN arrives through a delegate and the TPM through a device, so both can be supplied and the whole path
|
||||
/// — seal, unseal, wrong PIN, lockout — runs unattended.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Skipped unless a software TPM is running, which CI does not start.</b> The same arrangement the
|
||||
/// Windows tests have on a machine with no TPM, and for the same reason: a suite that fails because the
|
||||
/// hardware is absent is a suite people learn to ignore. Start one and they run:
|
||||
/// </para>
|
||||
/// <code>
|
||||
/// docker run --rm -p 2321:2321 debian:bookworm-slim sh -c \
|
||||
/// 'apt-get update -qq && apt-get install -y -qq swtpm >/dev/null && \
|
||||
/// swtpm socket --tpm2 --tpmstate dir=/tmp --server type=tcp,port=2321,bindaddr=0.0.0.0 \
|
||||
/// --ctrl type=tcp,port=2322 --flags not-need-init,startup-clear'
|
||||
/// DODOSSH_TPM_TCP=127.0.0.1 dotnet test tests/DodoSSH.Client.App.Tests
|
||||
/// </code>
|
||||
/// <para>
|
||||
/// ◆ <b>swtpm serves exactly one client, and anything else holding the port makes this suite fail in a
|
||||
/// way that reads like a bug in the store.</b> A second connection is accepted and then closed
|
||||
/// immediately, so five tests fail at once with <c>IOException: The TPM closed the connection</c> and
|
||||
/// nothing points at the cause. It was a shell readiness probe — <c>exec 3<>/dev/tcp/…</c> — sitting
|
||||
/// on the socket. Wait for the container by looking at the container, not by opening the port.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What this cannot cover is the transport.</b> Production opens <c>/dev/tpmrm0</c> through TSS.Net's
|
||||
/// <c>LinuxTpmDevice</c> and these tests open a socket, so everything above the device is proved here and
|
||||
/// the device itself is proved by Phase 19 of <c>docs/manual-checks.md</c> on a machine with a real chip.
|
||||
/// The split is deliberate: the commands, the templates and the attributes are where the security argument
|
||||
/// lives, and they are identical either way.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LinuxDeviceKeyStoreTests : IDisposable
|
||||
{
|
||||
private readonly string directory =
|
||||
Path.Combine(Path.GetTempPath(), $"dodossh-tpm-{Guid.CreateVersion7():N}");
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>Where a software TPM is listening, if the person running this started one.</summary>
|
||||
private static string? Simulator => Environment.GetEnvironmentVariable("DODOSSH_TPM_TCP");
|
||||
|
||||
/// <summary>
|
||||
/// Clears the profile directory, and clears the TPM's lockout counter behind it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>The second half is not tidiness, and leaving it out made this suite lie.</b> A software TPM is
|
||||
/// one piece of shared mutable state and its dictionary-attack counter is the most global thing in it:
|
||||
/// <see cref="WrongPinsEventuallyLockTheTpmOut"/> deliberately trips it, and every test that ran
|
||||
/// afterwards then got null from a correct PIN. Six of them assert null and passed for entirely the
|
||||
/// wrong reason; the one that asserts a successful unseal failed, which is the only reason this was
|
||||
/// noticed at all.
|
||||
/// </remarks>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(Simulator))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var device = new SoftwareTpm(Simulator, 2321);
|
||||
|
||||
device.Connect();
|
||||
|
||||
// The lockout hierarchy has no auth value on a fresh simulator, which is exactly the state a
|
||||
// real machine is not in — there, recovery is a timer measured in hours and an administrator.
|
||||
// That difference is the point of Phase 19 rather than of this file.
|
||||
new Tpm2(device).DictionaryAttackLockReset(TpmRh.Lockout);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// A simulator that has gone away between the test and here. The next test's own connect will
|
||||
// report it far more clearly than a throw out of Dispose would.
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithNothingSaved_LoadingReturnsNullWithoutAsking()
|
||||
{
|
||||
// No TPM needed and no prompt allowed: a machine that has never registered must not raise a PIN
|
||||
// box on the unlock screen. The delegate fails the test rather than answering, which is the only
|
||||
// way to assert "was not asked" — see the ordering this depends on in TryLoadAsync.
|
||||
var store = new LinuxDeviceKeyStore(
|
||||
new ClientPaths(directory),
|
||||
(_, _) => throw new InvalidOperationException("A PIN was asked for with nothing stored."),
|
||||
() => throw new InvalidOperationException("The TPM was opened with nothing stored."));
|
||||
|
||||
(await store.TryLoadAsync(Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARoundTripReturnsTheSameKey()
|
||||
{
|
||||
SkipUnlessSimulated();
|
||||
|
||||
var key = RandomNumberGenerator.GetBytes(32);
|
||||
var store = Store("1234");
|
||||
|
||||
await store.SaveAsync(key, Token);
|
||||
|
||||
// A second store over the same directory, because the point is that nothing survives in memory:
|
||||
// the blob on disk and the TPM are between these two lines, and nothing else.
|
||||
var loaded = await Store("1234").TryLoadAsync(Token);
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.ShouldBe(key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheWrongPinGetsNothing()
|
||||
{
|
||||
SkipUnlessSimulated();
|
||||
|
||||
await Store("1234").SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
|
||||
|
||||
// Null rather than an exception, and rather than the key. This is the assertion the whole design
|
||||
// exists for: the blob is readable by anyone who has the file, and it opens for nobody without the
|
||||
// PIN, because the TPM and not this process is what checks it.
|
||||
(await Store("9999").TryLoadAsync(Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecliningThePromptFallsBackWithoutTouchingTheTpm()
|
||||
{
|
||||
SkipUnlessSimulated();
|
||||
|
||||
await Store("1234").SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
|
||||
|
||||
// Cancelling is ordinary — IDeviceKeyStore says so — and it must not spend one of the TPM's
|
||||
// lockout attempts. The device factory throwing is what proves the TPM was never opened.
|
||||
var declined = new LinuxDeviceKeyStore(
|
||||
new ClientPaths(directory),
|
||||
(_, _) => ValueTask.FromResult<string?>(null),
|
||||
() => throw new InvalidOperationException("The TPM was opened after the prompt was declined."));
|
||||
|
||||
(await declined.TryLoadAsync(Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongPinsEventuallyLockTheTpmOut()
|
||||
{
|
||||
SkipUnlessSimulated();
|
||||
|
||||
var key = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
await Store("1234").SaveAsync(key, Token);
|
||||
|
||||
// ◆ The property that makes a four-digit PIN safe here, asserted rather than assumed.
|
||||
//
|
||||
// The sealed object deliberately carries no NoDA attribute, so every wrong answer advances the
|
||||
// TPM's dictionary-attack counter. Remove that one attribute and this test still passes every
|
||||
// other assertion in this file while the PIN becomes guessable at full speed by anything holding
|
||||
// the blob — which is why the lockout is asserted by observing the *correct* PIN stop working.
|
||||
for (var attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
(await Store("9999").TryLoadAsync(Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
(await Store("1234").TryLoadAsync(Token)).ShouldBeNull(
|
||||
"the TPM should have stopped answering, so even the right PIN gets nothing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForgettingLeavesNothingThatCanBeOpened()
|
||||
{
|
||||
SkipUnlessSimulated();
|
||||
|
||||
var store = Store("1234");
|
||||
|
||||
await store.SaveAsync(RandomNumberGenerator.GetBytes(32), Token);
|
||||
await store.ForgetAsync(Token);
|
||||
|
||||
// Deleting the blob is the whole of it: the sealed object was never made persistent inside the
|
||||
// TPM, so there is no chip state left to clean up and nothing for a later install to trip over.
|
||||
File.Exists(new ClientPaths(directory).DeviceKeyFile).ShouldBeFalse();
|
||||
(await Store("1234").TryLoadAsync(Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ABlobFromSomewhereElseIsRefusedRatherThanParsed()
|
||||
{
|
||||
var paths = new ClientPaths(directory);
|
||||
|
||||
paths.EnsureCreated();
|
||||
|
||||
// What the Windows store writes is a bare RSA ciphertext with no header, and a dual-boot machine
|
||||
// or a copied profile directory can put one here. Feeding it to the TPM as a marshalled structure
|
||||
// is the failure this refuses; answering null lands on the passphrase, which works.
|
||||
await File.WriteAllBytesAsync(paths.DeviceKeyFile, RandomNumberGenerator.GetBytes(256), Token);
|
||||
|
||||
var store = new LinuxDeviceKeyStore(
|
||||
paths,
|
||||
(_, _) => throw new InvalidOperationException("A PIN was asked for over an unusable blob."),
|
||||
() => throw new InvalidOperationException("The TPM was opened for an unusable blob."));
|
||||
|
||||
(await store.TryLoadAsync(Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
private LinuxDeviceKeyStore Store(string pin) =>
|
||||
new(
|
||||
new ClientPaths(directory),
|
||||
(_, _) => ValueTask.FromResult<string?>(pin),
|
||||
() => new SoftwareTpm(Simulator ?? "127.0.0.1", 2321));
|
||||
|
||||
private static void SkipUnlessSimulated() =>
|
||||
Assert.SkipWhen(
|
||||
string.IsNullOrEmpty(Simulator),
|
||||
"No software TPM. See the remarks on this class for the one command that starts one.");
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The RPM update channel: what it offers, what it refuses, and what it hands on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Driven against a stubbed <c>HttpMessageHandler</c> rather than a server, because every question here is
|
||||
/// about what this class decides from what the forge said — and a real socket would add a port to bind and
|
||||
/// nothing to the assertions.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The two that matter are the version comparison and the digest.</b> The first is what keeps a rolling
|
||||
/// nightly from offering a downgrade PackageKit would decline, forever, every six hours. The second is the
|
||||
/// only thing standing between a truncated download and a file handed to the machine's package manager —
|
||||
/// Velopack verifies its own downloads and Android checks a signature, and an RPM over HTTPS has neither.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LinuxUpdateChannelTests : IDisposable
|
||||
{
|
||||
private const string ReleaseUrl =
|
||||
"https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH/releases/tags/nightly-desktop";
|
||||
|
||||
private const string ManifestUrl = "https://forge.invalid/linux-nightly.json";
|
||||
private const string RpmUrl = "https://forge.invalid/dodossh-nightly.rpm";
|
||||
|
||||
private readonly string directory =
|
||||
Path.Combine(Path.GetTempPath(), $"dodossh-updates-{Guid.CreateVersion7():N}");
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANewerPublishedVersionIsOffered()
|
||||
{
|
||||
var package = RandomNumberGenerator.GetBytes(2048);
|
||||
var channel = Channel(package, published: "0.0.1~alpha.0.200-1", installed: "0.0.1~alpha.0.143-1");
|
||||
|
||||
var update = await channel.CheckAsync(Token);
|
||||
|
||||
update.ShouldNotBeNull();
|
||||
update.Version.ShouldBe("0.0.1-alpha.0.200");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheSameVersionIsNotOffered()
|
||||
{
|
||||
var channel = Channel(
|
||||
RandomNumberGenerator.GetBytes(16),
|
||||
published: "0.0.1~alpha.0.143-1",
|
||||
installed: "0.0.1~alpha.0.143-1");
|
||||
|
||||
(await channel.CheckAsync(Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An older published build is not offered, which is the failure this channel is most exposed to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The nightly release is rolling and CI replaces it on every push, so a revert or a re-run of an older
|
||||
/// commit publishes a lower version. Comparing "is it different from mine" would offer it, PackageKit
|
||||
/// would decline the downgrade, and the user would see a failing update every six hours until somebody
|
||||
/// pushed again.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnOlderPublishedVersionIsNotOffered()
|
||||
{
|
||||
var channel = Channel(
|
||||
RandomNumberGenerator.GetBytes(16),
|
||||
published: "0.0.1~alpha.0.99-1",
|
||||
installed: "0.0.1~alpha.0.143-1");
|
||||
|
||||
(await channel.CheckAsync(Token)).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A machine that cannot say what it has treats everything as newer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The wrong way to fail, chosen deliberately and for the reason the android channel gives: the cost is
|
||||
/// an install the package manager declines, and the cost of the other direction is a security fix that
|
||||
/// never arrives.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnUnknownInstalledVersionTreatsEverythingAsNewer()
|
||||
{
|
||||
var channel = Channel(RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.1-1", installed: null);
|
||||
|
||||
(await channel.CheckAsync(Token)).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGoodDownloadIsKeptAndHandedToTheInstaller()
|
||||
{
|
||||
var package = RandomNumberGenerator.GetBytes(4096);
|
||||
var handed = new List<string>();
|
||||
|
||||
var channel = Channel(
|
||||
package,
|
||||
published: "0.0.1~alpha.0.200-1",
|
||||
installed: "0.0.1~alpha.0.143-1",
|
||||
handToInstaller: handed.Add);
|
||||
|
||||
var update = await channel.CheckAsync(Token);
|
||||
|
||||
await channel.DownloadAsync(update!, new Progress<int>(), Token);
|
||||
|
||||
channel.ApplyAndRestart(update!);
|
||||
|
||||
handed.Count.ShouldBe(1);
|
||||
(await File.ReadAllBytesAsync(handed[0], Token)).ShouldBe(package);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A package whose digest does not match is refused, deleted, and never named to the installer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deleted rather than merely refused, because the alternative is a file called
|
||||
/// <c>dodossh-nightly-update.rpm</c> sitting in the profile directory that somebody could reasonably
|
||||
/// install by hand.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ADownloadThatDoesNotMatchIsRefusedAndRemoved()
|
||||
{
|
||||
var handed = new List<string>();
|
||||
|
||||
var channel = Channel(
|
||||
RandomNumberGenerator.GetBytes(4096),
|
||||
published: "0.0.1~alpha.0.200-1",
|
||||
installed: "0.0.1~alpha.0.143-1",
|
||||
digest: Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32)),
|
||||
handToInstaller: handed.Add);
|
||||
|
||||
var update = await channel.CheckAsync(Token);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await channel.DownloadAsync(update!, new Progress<int>(), Token));
|
||||
|
||||
handed.ShouldBeEmpty();
|
||||
Directory.Exists(Path.Combine(directory, "updates")).ShouldBeTrue();
|
||||
Directory.GetFiles(Path.Combine(directory, "updates")).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUpdateFromNoCheckIsRefused()
|
||||
{
|
||||
var channel = Channel(RandomNumberGenerator.GetBytes(16), "0.0.1~alpha.0.2-1", "0.0.1~alpha.0.1-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await channel.DownloadAsync(
|
||||
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.2"),
|
||||
new Progress<int>(),
|
||||
Token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A release that is reachable and carries no manifest throws rather than answering "up to date".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Half a published release is a fact worth an answer — <c>IUpdateChannel.CheckAsync</c> spends several
|
||||
/// paragraphs on why null must mean exactly one thing, after a private repository turned a total outage
|
||||
/// into a reassuring sentence on every client for weeks.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AReleaseWithNoManifestThrows()
|
||||
{
|
||||
var channel = new LinuxUpdateChannel(
|
||||
"nightly",
|
||||
directory,
|
||||
new HttpClient(new StubForge(new Dictionary<string, byte[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[ReleaseUrl] = Encoding.UTF8.GetBytes("""{"assets":[]}"""),
|
||||
})),
|
||||
_ => "0.0.1~alpha.0.1-1",
|
||||
_ => { });
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
|
||||
}
|
||||
|
||||
private LinuxUpdateChannel Channel(
|
||||
byte[] package,
|
||||
string published,
|
||||
string? installed,
|
||||
string? digest = null,
|
||||
Action<string>? handToInstaller = null)
|
||||
{
|
||||
// The SemVer the banner shows is derived from the rpm version the comparison uses, so the two
|
||||
// cannot disagree in a way this suite would miss.
|
||||
var semver = published[..published.LastIndexOf('-')].Replace('~', '-');
|
||||
|
||||
var manifest = $$"""
|
||||
{
|
||||
"rpmVersion": "{{published}}",
|
||||
"version": "{{semver}}",
|
||||
"rpm": "dodossh-nightly.rpm",
|
||||
"sha256": "{{digest ?? Convert.ToHexStringLower(SHA256.HashData(package))}}"
|
||||
}
|
||||
""";
|
||||
|
||||
var release = $$"""
|
||||
{"assets":[
|
||||
{"name":"linux-nightly.json","browser_download_url":"{{ManifestUrl}}"},
|
||||
{"name":"dodossh-nightly.rpm","browser_download_url":"{{RpmUrl}}"}
|
||||
]}
|
||||
""";
|
||||
|
||||
return new LinuxUpdateChannel(
|
||||
"nightly",
|
||||
directory,
|
||||
new HttpClient(new StubForge(new Dictionary<string, byte[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[ReleaseUrl] = Encoding.UTF8.GetBytes(release),
|
||||
[ManifestUrl] = Encoding.UTF8.GetBytes(manifest),
|
||||
[RpmUrl] = package,
|
||||
})),
|
||||
_ => installed,
|
||||
handToInstaller ?? (_ => { }));
|
||||
}
|
||||
|
||||
/// <summary>Answers the three URLs this channel asks for, and 404s everything else.</summary>
|
||||
private sealed class StubForge(IReadOnlyDictionary<string, byte[]> content) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(
|
||||
content.TryGetValue(request.RequestUri!.AbsoluteUri, out var bytes)
|
||||
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) }
|
||||
: new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using DodoSSH.Client.App.Platform;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <c>RpmVersion.Compare</c> against the answers <c>rpm</c> itself gives.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Every expected value here was produced by rpm and not by reading the algorithm.</b> They came out of
|
||||
/// <c>rpm --eval "%{lua:print(rpm.vercmp(a, b))}"</c> in a Fedora-era container, which is the same
|
||||
/// comparison PackageKit will make when it decides whether to accept the update this class caused to be
|
||||
/// offered. A test written from the specification would agree with a misreading of the specification.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The awkward cases are the point of the list. <c>1a</c> equals <c>1.a</c> because separators are skipped
|
||||
/// on each side independently; <c>1.01</c> equals <c>1.1</c> because leading zeros are stripped;
|
||||
/// <c>1.0_1</c> equals <c>1.0.1</c> for the first reason again; and <c>a</c> is older than <c>1</c> because
|
||||
/// rpm calls a numeric segment newer than an alphabetic one whatever the letters say.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The four rows that carry the feature are the <c>~</c> ones. <c>0.0.1~alpha.0.143 < 0.0.1</c> is what
|
||||
/// makes every nightly older than the first real release, and <c>~alpha.0.9 < ~alpha.0.10</c> is what
|
||||
/// keeps them in order among themselves — a string comparison would get that backwards and a client would
|
||||
/// stop offering updates after the ninth nightly of a version.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RpmVersionTests
|
||||
{
|
||||
[Theory]
|
||||
|
||||
// Ordinary
|
||||
[InlineData("1.2.3", "1.2.3", 0)]
|
||||
[InlineData("1.2.3", "1.2.4", -1)]
|
||||
[InlineData("1.2.3", "1.2.3.1", -1)]
|
||||
[InlineData("1.2.10", "1.2.9", 1)]
|
||||
[InlineData("2.0", "10.0", -1)]
|
||||
[InlineData("1.0.0", "1.0", 1)]
|
||||
[InlineData("1.0.0.1", "1.0.0", 1)]
|
||||
[InlineData("20101121", "20101122", -1)]
|
||||
|
||||
// Tilde: the prerelease ordering this feature depends on
|
||||
[InlineData("0.0.1~alpha.0.143", "0.0.1", -1)]
|
||||
[InlineData("0.0.1~alpha.0.143", "0.0.1~alpha.0.144", -1)]
|
||||
[InlineData("0.0.1~alpha.0.143", "0.0.1~alpha.0.99", 1)]
|
||||
[InlineData("0.0.1~alpha.0.9", "0.0.1~alpha.0.10", -1)]
|
||||
[InlineData("1.0", "1.0~rc1", 1)]
|
||||
[InlineData("1~~", "1~", -1)]
|
||||
[InlineData("1.0~beta", "1.0~alpha", 1)]
|
||||
|
||||
// Caret: after everything except the end of the string
|
||||
[InlineData("1.0^post", "1.0", 1)]
|
||||
[InlineData("1.0^post", "1.0^post2", -1)]
|
||||
[InlineData("1.2.3", "1.2.3^", -1)]
|
||||
|
||||
// Separators and leading zeros, which are the surprising ones
|
||||
[InlineData("1a", "1.a", 0)]
|
||||
[InlineData("1.01", "1.1", 0)]
|
||||
[InlineData("1.0_1", "1.0.1", 0)]
|
||||
[InlineData("1.0-1", "1.0-2", -1)]
|
||||
|
||||
// Segments of different kinds
|
||||
[InlineData("1.a", "1.b", -1)]
|
||||
[InlineData("a", "1", -1)]
|
||||
[InlineData("1", "a", 1)]
|
||||
[InlineData("5.5p1", "5.5p2", -1)]
|
||||
[InlineData("xyz10", "xyz10.1", -1)]
|
||||
[InlineData("MDK", "MDK1", -1)]
|
||||
public void ItAgreesWithRpm(string left, string right, int expected) =>
|
||||
RpmVersion.Compare(left, right).ShouldBe(expected);
|
||||
|
||||
/// <summary>
|
||||
/// The reverse of every case above, which rpm does not need asserting and this does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The marker handling is the half of the algorithm with a left and a right branch for each case, so an
|
||||
/// asymmetry there would pass every row above and reverse the answer for a client whose installed
|
||||
/// version happened to be the newer one — which is the direction that offers a downgrade.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("0.0.1~alpha.0.143", "0.0.1")]
|
||||
[InlineData("1.0^post", "1.0")]
|
||||
[InlineData("1.2.3", "1.2.3^")]
|
||||
[InlineData("a", "1")]
|
||||
[InlineData("1.2.3", "1.2.4")]
|
||||
public void ItIsAntisymmetric(string left, string right) =>
|
||||
RpmVersion.Compare(right, left).ShouldBe(-RpmVersion.Compare(left, right));
|
||||
|
||||
/// <summary>
|
||||
/// Nothing and an empty string are older than anything, and equal to each other.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// rpm's own binding refuses these outright. Here they arrive off a feed rather than out of a spec
|
||||
/// file, and the answer that leaves a client on the build it already has is better than an exception
|
||||
/// thrown on a six-hourly timer.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(null, "1.0", -1)]
|
||||
[InlineData("", "1.0", -1)]
|
||||
[InlineData("1.0", null, 1)]
|
||||
[InlineData(null, null, 0)]
|
||||
[InlineData("", null, 0)]
|
||||
public void ItTreatsNothingAsOldest(string? left, string? right, int expected) =>
|
||||
RpmVersion.Compare(left, right).ShouldBe(expected);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,115 +0,0 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Sockets;
|
||||
using Tpm2Lib;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Talks to a software TPM over its TCP data channel, so the real store can be driven without hardware.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>TSS.Net ships a TcpTpmDevice and it cannot be used here.</b> It also opens the simulator's
|
||||
/// <em>platform</em> socket and exchanges a handshake that the Microsoft reference simulator answers and
|
||||
/// swtpm does not, so <c>Connect()</c> blocks forever — with no error and no timeout. That was found by
|
||||
/// watching it hang, and it is the whole reason this file exists.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The data channel is the framing <c>tcti-mssim</c> uses and swtpm does implement: a command word, a
|
||||
/// locality byte, a length and the bytes, answered by a length, the bytes and an acknowledgement word.
|
||||
/// Power management is unnecessary when swtpm is started with <c>--flags not-need-init,startup-clear</c>,
|
||||
/// which is the invocation <see cref="LinuxDeviceKeyStoreTests"/> documents.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class SoftwareTpm : Tpm2Device
|
||||
{
|
||||
/// <summary>tcti-mssim's "here is a command for the TPM".</summary>
|
||||
private const uint SendCommand = 8;
|
||||
|
||||
private readonly string host;
|
||||
private readonly int port;
|
||||
|
||||
private TcpClient? client;
|
||||
private NetworkStream? stream;
|
||||
|
||||
internal SoftwareTpm(string host, int port)
|
||||
{
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
public override void Connect()
|
||||
{
|
||||
client = new TcpClient();
|
||||
client.Connect(host, port);
|
||||
stream = client.GetStream();
|
||||
}
|
||||
|
||||
public override void Close()
|
||||
{
|
||||
stream?.Dispose();
|
||||
client?.Dispose();
|
||||
|
||||
stream = null;
|
||||
client = null;
|
||||
}
|
||||
|
||||
public override void DispatchCommand(CommandModifier active, byte[] inBuf, out byte[] outBuf)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(active);
|
||||
ArgumentNullException.ThrowIfNull(inBuf);
|
||||
|
||||
if (stream is null)
|
||||
{
|
||||
throw new InvalidOperationException("Not connected.");
|
||||
}
|
||||
|
||||
Span<byte> header = stackalloc byte[9];
|
||||
|
||||
BinaryPrimitives.WriteUInt32BigEndian(header, SendCommand);
|
||||
header[4] = active.ActiveLocality;
|
||||
BinaryPrimitives.WriteUInt32BigEndian(header[5..], (uint)inBuf.Length);
|
||||
|
||||
stream.Write(header);
|
||||
stream.Write(inBuf, 0, inBuf.Length);
|
||||
stream.Flush();
|
||||
|
||||
outBuf = ReadExactly((int)ReadUInt32());
|
||||
|
||||
// The trailing acknowledgement word. It has to be consumed even though nothing reads it, or every
|
||||
// response after this one is misframed by four bytes.
|
||||
_ = ReadUInt32();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private uint ReadUInt32() => BinaryPrimitives.ReadUInt32BigEndian(ReadExactly(4));
|
||||
|
||||
private byte[] ReadExactly(int count)
|
||||
{
|
||||
var buffer = new byte[count];
|
||||
var read = 0;
|
||||
|
||||
while (read < count)
|
||||
{
|
||||
var got = stream!.Read(buffer, read, count - read);
|
||||
|
||||
if (got <= 0)
|
||||
{
|
||||
throw new IOException("The TPM closed the connection.");
|
||||
}
|
||||
|
||||
read += got;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Transfer;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
@@ -91,6 +90,25 @@ public sealed class TransferQueueingTests : IDisposable
|
||||
transfers.Status.ShouldContain("/srv/app");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The queue moves files. There is no recursive upload, and a folder dragged in and silently ignored
|
||||
/// looks exactly like a transfer that failed to start — so it is counted and reported.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void DroppingAFolderAmongFiles_SkipsItAndSaysSo()
|
||||
{
|
||||
Connected();
|
||||
|
||||
var folder = Path.Combine(directory, "a-folder");
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
transfers.QueueUploads([File("one.txt"), folder]);
|
||||
|
||||
Queued().ShouldHaveSingleItem();
|
||||
transfers.Status.ShouldContain("1 file");
|
||||
transfers.Status.ShouldContain("1 folder was skipped");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The paths in an operating-system drop come from another process and are not obliged to still be
|
||||
/// right by the time the drop lands.
|
||||
@@ -106,6 +124,21 @@ public sealed class TransferQueueingTests : IDisposable
|
||||
transfers.Status.ShouldContain("1 item was no longer there");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DroppingOnlyFolders_QueuesNothingAndDoesNotClaimOtherwise()
|
||||
{
|
||||
Connected();
|
||||
|
||||
var folder = Path.Combine(directory, "a-folder");
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
transfers.QueueUploads([folder]);
|
||||
|
||||
Queued().ShouldBeEmpty();
|
||||
transfers.Status.ShouldContain("Nothing was queued");
|
||||
transfers.Status.ShouldContain("1 folder was skipped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DroppingRemoteRowsOnTheLocalPane_QueuesDownloads()
|
||||
{
|
||||
@@ -118,18 +151,21 @@ public sealed class TransferQueueingTests : IDisposable
|
||||
transfers.Status.ShouldContain("download into");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DroppingARemoteDirectory_SkipsItAndSaysSo()
|
||||
{
|
||||
Connected();
|
||||
|
||||
transfers.QueueDownloads([RemoteFile("one.log"), RemoteDirectory("logs")]);
|
||||
|
||||
Queued().ShouldHaveSingleItem();
|
||||
transfers.Status.ShouldContain("1 folder was skipped");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The buttons were the only way to queue anything before drag and drop, and they now go through the
|
||||
/// same two methods — so there is one set of rules rather than two that have to agree. This is what
|
||||
/// says they still do.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Files only, since folders stopped being skipped: walking one needs a session to list and to make
|
||||
/// directories on, and this fixture deliberately has none — everything here is about what may be
|
||||
/// queued, which needs no server. The recursive half is exercised in <c>ShellFlowTests</c>, against the
|
||||
/// SFTP fake that a real connect produces.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheDownloadButton_GoesThroughTheSamePathAsADrop()
|
||||
@@ -140,7 +176,13 @@ public sealed class TransferQueueingTests : IDisposable
|
||||
transfers.DownloadCommand.Execute(null);
|
||||
|
||||
Queued().ShouldHaveSingleItem();
|
||||
transfers.Status.ShouldContain("download into");
|
||||
|
||||
// And refuses a directory in the same words, rather than with the button's own message.
|
||||
transfers.SelectedRemoteEntry = RemoteDirectory("logs");
|
||||
transfers.DownloadCommand.Execute(null);
|
||||
|
||||
Queued().Count.ShouldBe(1);
|
||||
transfers.Status.ShouldContain("1 folder was skipped");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -186,11 +228,14 @@ public sealed class TransferQueueingTests : IDisposable
|
||||
{
|
||||
Connected();
|
||||
|
||||
transfers.QueueStagedUploads([File("picked.txt"), Path.Combine(directory, "never-existed.txt")]);
|
||||
var folder = Path.Combine(directory, "a-folder");
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
transfers.QueueStagedUploads([File("picked.txt"), folder]);
|
||||
|
||||
Queued().ShouldHaveSingleItem();
|
||||
transfers.Status.ShouldContain("1 file");
|
||||
transfers.Status.ShouldContain("1 item was no longer there");
|
||||
transfers.Status.ShouldContain("1 folder was skipped");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -236,71 +281,6 @@ public sealed class TransferQueueingTests : IDisposable
|
||||
delivered.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The local half of the automatic refresh. A download that finished left this pane showing the folder
|
||||
/// as it was before the file arrived, so the file this application had just fetched was not in the
|
||||
/// listing it was fetched into — and the way to see it was to press REFRESH.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Synchronous, unlike its remote counterpart, and that is the whole reason it is nothing more than a
|
||||
/// re-read where it stands: a local listing is a filesystem call, so there is no round trip to defer and
|
||||
/// no busy state to wait out. See <c>TransfersViewModel.SettleRemoteListingAsync</c> for the other side.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void ADownloadThatLandsInTheFolderShowing_ReReadsItWithoutBeingAsked()
|
||||
{
|
||||
Connected();
|
||||
|
||||
transfers.RefreshLocalCommand.Execute(null);
|
||||
transfers.LocalEntries.ShouldBeEmpty();
|
||||
|
||||
var landed = File("fetched.bin");
|
||||
|
||||
transfers.NoteTransferLanded(new TransferSnapshot(
|
||||
Guid.CreateVersion7(),
|
||||
TransferDirection.Download,
|
||||
"fetched.bin",
|
||||
landed,
|
||||
"/srv/app/fetched.bin",
|
||||
128,
|
||||
128,
|
||||
TransferState.Completed,
|
||||
BytesPerSecond: 0,
|
||||
Failure: null));
|
||||
|
||||
transfers.LocalEntries.Select(row => row.Name).ShouldBe(["fetched.bin"]);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Only the folder on screen, for the reason its remote counterpart gives: a queue writing into
|
||||
/// somewhere nobody is looking at would otherwise re-read a directory per completed transfer.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void ADownloadThatLandsSomewhereElse_ChangesNothingOnScreen()
|
||||
{
|
||||
Connected();
|
||||
|
||||
transfers.RefreshLocalCommand.Execute(null);
|
||||
|
||||
File("elsewhere.bin");
|
||||
|
||||
transfers.NoteTransferLanded(new TransferSnapshot(
|
||||
Guid.CreateVersion7(),
|
||||
TransferDirection.Download,
|
||||
"elsewhere.bin",
|
||||
Path.Combine(Path.GetTempPath(), "somewhere-else", "elsewhere.bin"),
|
||||
"/srv/app/elsewhere.bin",
|
||||
128,
|
||||
128,
|
||||
TransferState.Completed,
|
||||
BytesPerSecond: 0,
|
||||
Failure: null));
|
||||
|
||||
transfers.LocalEntries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>The queue's rows, once the posts that create them have been let run.</summary>
|
||||
/// <remarks>
|
||||
/// <c>TransfersViewModel</c> adds a row from the transfer queue's own <c>Changed</c> event, which it
|
||||
|
||||
@@ -403,46 +403,6 @@ public sealed class UpdateFlowTests : IDisposable
|
||||
channel.Checks.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The loop rather than <c>CheckOnceAsync</c>, which is the one thing the rest of this file avoids
|
||||
/// driving — and here it is the whole point, because the claim is about when the first pass happens
|
||||
/// rather than about what it does. The first pass used to wait two minutes, which meant a client opened
|
||||
/// to reach one host and closed again never asked at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It waits on the pass and not on a clock, so there is nothing here to be flaky about: a regression
|
||||
/// that puts a delay back in front of the loop does not fail on a margin, it spins until the suite's own
|
||||
/// cancellation ends it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheFirstPassRunsAtStart_RatherThanOnADelay()
|
||||
{
|
||||
channel.Available = new AvailableUpdate("1.3.0");
|
||||
|
||||
var updates = Build();
|
||||
await using var _ = updates.ConfigureAwait(false);
|
||||
|
||||
updates.Start();
|
||||
|
||||
while (updates.State is not UpdateState.Ready)
|
||||
{
|
||||
Token.ThrowIfCancellationRequested();
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
channel.Checks.ShouldBe(1);
|
||||
updates.ReadyVersion.ShouldBe("1.3.0");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Started and disposed with nothing in between, which since the first pass stopped waiting two minutes
|
||||
/// is a race rather than a formality: the loop may be anywhere between its yield and a finished check
|
||||
/// when the cancellation lands. What is asserted is what matters either way — that disposing returns,
|
||||
/// rather than waiting on a pass that will never be allowed to finish.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DisposingStopsTheLoop()
|
||||
{
|
||||
|
||||
@@ -766,59 +766,6 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vault.Status.ShouldContain("left alone", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The palette reaches every vault the session holds a key for, so the vault's name is one of the things
|
||||
/// somebody can reasonably type into it: "show me the team's machines" is a question a list spanning two
|
||||
/// vaults invites, and until now the only answer was to know one of the machines by name already.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The order is the assertion that matters. A vault name is the widest of the three readings — one word
|
||||
/// matches every host in that vault at once — so the host whose own name carries the word has to come
|
||||
/// first, or typing a machine's name would bury it under everybody else's.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePalette_FindsAHostByItsVaultsName_BehindOneCalledThatItself()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "platform-gateway";
|
||||
vault.EditorHostname = "gateway.internal";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorSelectedVault =
|
||||
vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditing.ShouldBeFalse(vault.Status);
|
||||
|
||||
shell.ToggleSearchCommand.Execute(null);
|
||||
shell.SearchText = "platform";
|
||||
|
||||
shell.SearchResults
|
||||
.Select(row => row.Label)
|
||||
.ShouldBe(["platform-gateway", "prod-db"], "a host's own name outranks its vault's");
|
||||
|
||||
shell.SearchResults[1].VaultBadge.ShouldBe(
|
||||
"PLATFORM SECRETS", "and the row says what it was found by");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two hosts for the run above: one in the personal vault under a group, one already in the destination.
|
||||
/// </summary>
|
||||
|
||||
@@ -31,18 +31,6 @@ public sealed class WindowsDeviceKeyStoreTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>
|
||||
/// The PIN prompt this platform never reaches, because Windows raises its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ForThisMachine</c> takes one so that the Linux store has somewhere to ask from — see
|
||||
/// <c>LinuxDeviceKeyStore</c>. On Windows nothing calls it, and answering null rather than throwing is
|
||||
/// the honest stand-in: if the selection ever did reach for it here, the result would be a store that
|
||||
/// declines rather than a test that fails somewhere unrelated.
|
||||
/// </remarks>
|
||||
private static ValueTask<string?> NoPin(DevicePinPurpose purpose, CancellationToken cancellationToken) =>
|
||||
ValueTask.FromResult<string?>(null);
|
||||
|
||||
[Fact]
|
||||
public async Task OnAMachineWithATpm_TheStoreOffersItself()
|
||||
{
|
||||
@@ -51,7 +39,7 @@ public sealed class WindowsDeviceKeyStoreTests
|
||||
// provider reports itself present on machines where creating a key then fails.
|
||||
SkipUnlessSupported();
|
||||
|
||||
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin);
|
||||
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()));
|
||||
|
||||
store.ShouldBeOfType<WindowsDeviceKeyStore>();
|
||||
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
|
||||
@@ -78,7 +66,7 @@ public sealed class WindowsDeviceKeyStoreTests
|
||||
private static void SkipUnlessSupported()
|
||||
{
|
||||
var supported = OperatingSystem.IsWindows()
|
||||
&& DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin)
|
||||
&& DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()))
|
||||
is WindowsDeviceKeyStore;
|
||||
|
||||
if (!supported)
|
||||
|
||||
@@ -479,7 +479,6 @@
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
||||
"Microsoft.TSS": "[2.1.1, )",
|
||||
"Velopack": "[1.2.0, )"
|
||||
}
|
||||
},
|
||||
@@ -541,7 +540,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -653,9 +652,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -719,12 +718,6 @@
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"Microsoft.TSS": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.1.1, )",
|
||||
"resolved": "2.1.1",
|
||||
"contentHash": "87DBN+4ANSFO8TDRmFjjTBl6PL7tmrfofmxki4LUnE34gOdyQiBfJK/X1QSu0UfCGUtpoBVv/tD985s4FDpKiA=="
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[26.4.0, )",
|
||||
@@ -767,11 +760,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"AWSSDK.Core": {
|
||||
@@ -249,9 +249,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -270,11 +270,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user