Public Access
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2f93a172b | ||
|
|
882f01c515 | ||
|
|
2f8daacb60 | ||
|
|
ef12e8cc99 | ||
|
|
963cb7f670 | ||
|
|
68964d8a34 | ||
|
|
6934efb1e6 | ||
|
|
e252d337d1 | ||
|
|
fdea3911c1 | ||
|
|
10f80bded1 | ||
|
|
281f849086 | ||
|
|
25407756c3 | ||
|
|
a763f4b113 | ||
|
|
881562e81b | ||
|
|
93e35a0095 | ||
|
|
b80bf23341 | ||
|
|
8c58e5a558 | ||
|
|
cec73010d3 | ||
|
|
53ff15ba86 | ||
|
|
766fe6aebe | ||
|
|
9f73893e14 | ||
|
|
ccaf7a8e72 | ||
|
|
6fc1e3a7c5 | ||
|
|
8a77b7ca68 | ||
|
|
9755b5f6ae | ||
|
|
afc6a042f1 | ||
|
|
e41eca01a8 | ||
|
|
e96d01aab9 | ||
|
|
7f77539ba6 | ||
|
|
ca081af209 | ||
|
|
890a5f2246 | ||
|
|
8c67fce32c | ||
|
|
0ffd259ccd | ||
|
|
9bc9069425 | ||
|
|
e936ab4646 | ||
|
|
506d2803a2 |
No files matched your search
@@ -291,6 +291,7 @@ jobs:
|
||||
# so it is not done either. What reaches users is built, installed and walked through Phase 16
|
||||
# of docs/manual-checks.md by a person first.
|
||||
- name: package the windows desktop client
|
||||
id: winpack
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -374,6 +375,93 @@ jobs:
|
||||
ls -la "$releases"
|
||||
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
|
||||
|
||||
# Handed to the macOS step below rather than worked out again there. The floor logic above
|
||||
# is thirty lines of reasoning about MinVer's pre-first-tag answer, and a second copy of it
|
||||
# is a second thing to keep in step — while two desktop packages built from one commit
|
||||
# carrying different version numbers is precisely the confusion this file spends that
|
||||
# reasoning to avoid.
|
||||
echo "packVersion=$packVersion" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ◆ AND THE macOS BUNDLE IS BUILT HERE, ON LINUX, AND IS ALSO THROWN AWAY.
|
||||
#
|
||||
# Same argument as the Windows step above, one platform along: the failures a release is most
|
||||
# exposed to are the ones only the packager finds, and the person who would otherwise find them
|
||||
# is the one midway through a release on the one Mac that can cut one.
|
||||
#
|
||||
# What this catches that the Windows step cannot: the osx-arm64 restore graph. A native package
|
||||
# that resolves for win-x64 and has no osx-arm64 asset — libsodium and SkiaSharp both ship per
|
||||
# RID — fails here, on every main build, rather than at the first `dotnet publish` of a release
|
||||
# nobody can retry without a Mac.
|
||||
#
|
||||
# ◆ bundle, NOT pack, AND THE DIFFERENCE IS NOT A CHOICE.
|
||||
#
|
||||
# `vpk [osx]` cross-compiling from a non-Mac offers exactly one packaging verb: bundle, which
|
||||
# builds the .app. There is no `[osx] pack` off a Mac, and that is correct rather than a gap —
|
||||
# pack signs with codesign, submits to Apple with notarytool and staples the ticket, all of
|
||||
# which is Apple tooling that exists on no other platform. So this proves the bundle and stops
|
||||
# where the platform does.
|
||||
#
|
||||
# No --plist and no --icon either, deliberately. Both are proved by scripts/release-macos.sh on
|
||||
# the machine that can also check the result; passing a rendered plist here would mean copying
|
||||
# the substitution out of that script to no end, since nothing looks at what this produces.
|
||||
#
|
||||
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
|
||||
#
|
||||
# RUNNER_TEMP, dying with the job. ADR 0013 rule 3 puts the capability to ship somebody a build
|
||||
# on a machine which is not a runner, and an unsigned .app is additionally something no Mac
|
||||
# would open — so publishing it would be handing out a file whose only possible use is confusion.
|
||||
- name: publish and bundle the macos desktop client
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# RestoreLockedMode=false for the RID, exactly as the win-x64 publish above does — see the
|
||||
# long note there for why the committed lock files are deliberately RID-free. This runner's
|
||||
# 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 osx-arm64 --self-contained true \
|
||||
-p:RestoreLockedMode=false \
|
||||
--output "$RUNNER_TEMP/osx-arm64"
|
||||
|
||||
# The apphost has no extension on macOS, so this is `DodoSSH` and not `DodoSSH.exe`. Named
|
||||
# rather than globbed, because a publish that produced no apphost at all would otherwise
|
||||
# bundle happily and produce an .app that launches nothing.
|
||||
if [ ! -s "$RUNNER_TEMP/osx-arm64/DodoSSH" ]; then
|
||||
echo "The osx-arm64 publish produced no apphost." >&2
|
||||
ls -la "$RUNNER_TEMP/osx-arm64" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bundles="$RUNNER_TEMP/osx-bundle"
|
||||
|
||||
# The quotes around [osx] are load-bearing, exactly as they are on '[win]' above: unquoted,
|
||||
# the shell reads it as a glob matching any one of o, s and x.
|
||||
dotnet vpk '[osx]' bundle \
|
||||
--skip-updates \
|
||||
--packId DodoSSH.Desktop \
|
||||
--packVersion '${{ steps.winpack.outputs.packVersion }}' \
|
||||
--packDir "$RUNNER_TEMP/osx-arm64" \
|
||||
--packTitle DodoSSH \
|
||||
--packAuthors DodoTech \
|
||||
--mainExe DodoSSH \
|
||||
--bundleId dev.dodotech.dodossh \
|
||||
--runtime osx-arm64 \
|
||||
--channel osx \
|
||||
--outputDir "$bundles"
|
||||
|
||||
# Asked for rather than inferred from an exit code, for the reason the Windows step gives.
|
||||
# The Info.plist is the specific thing worth naming: a bundle missing it is a directory
|
||||
# macOS will not treat as an application at all, and it is the one part of the .app that
|
||||
# vpk composes rather than copies.
|
||||
app="$bundles/DodoSSH.Desktop.app"
|
||||
if [ ! -s "$app/Contents/Info.plist" ]; then
|
||||
echo "vpk reported success and there is no Info.plist at $app/Contents/Info.plist." >&2
|
||||
find "$bundles" -maxdepth 3 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<Project>
|
||||
|
||||
<!--
|
||||
◆ THE TRIMMER'S VERSION IS PINNED HERE BECAUSE OTHERWISE THE LOCK FILES ARE NOT LOCKED.
|
||||
|
||||
Microsoft.NET.ILLink.Tasks is not referenced by anything in this repository. The SDK adds it
|
||||
on its own to any project that sets IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
|
||||
DodoSSH.Crypto do, and the Android head gets it from trimming being on by default there — and
|
||||
the version it asks for is whatever the running SDK happens to bundle. That version lives in
|
||||
the SDK's own Microsoft.NETCoreSdk.BundledVersions.props, as a KnownILLinkPack item.
|
||||
|
||||
Which makes it a dependency whose version is a property of the toolchain rather than of this
|
||||
repository, and that is the whole problem: packages.lock.json records it as a Direct reference
|
||||
with a requested range, so the lock file silently means "whichever SDK last ran a restore".
|
||||
global.json says rollForward: latestMinor, so CI's setup-dotnet installs the newest 10.x SDK
|
||||
that exists on the day it runs. The moment .NET ships a servicing release, CI's SDK asks for a
|
||||
version the committed lock files do not have, and the locked-mode restore in ci.yml fails with
|
||||
NU1004 before a single file is compiled.
|
||||
|
||||
That is not hypothetical. It closed the whole pipeline: main's run 125 and every open pull
|
||||
request went red together, on
|
||||
|
||||
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||
from [10.0.10, ) to [10.0.11, ).
|
||||
|
||||
with nothing in any of those commits touching a package. .NET had shipped SDK 10.0.400, which
|
||||
bundles ILLink 10.0.11 where 10.0.302 bundled 10.0.10, and setup-dotnet installed it the next
|
||||
time anything ran.
|
||||
|
||||
Worse than the outage is the shape of the repair without this pin. Regenerating the lock files
|
||||
holds only until the next servicing release, and it cannot be done from a machine whose newest
|
||||
SDK is older than the runner's: a restore on 10.0.302 writes 10.0.10 straight back and re-breaks
|
||||
CI, so the recorded version becomes a fact about whoever ran restore last rather than about this
|
||||
repository. That is exactly the state locking exists to prevent, and it is not a hypothetical
|
||||
either — every SDK installed on the machine this pin was written on tops out at 10.0.302.
|
||||
|
||||
Pinning it makes the recorded version a decision this repository made, reviewable in a diff
|
||||
like every other version in Directory.Packages.props, and identical on every machine whatever
|
||||
SDK it has. Moving it is then a deliberate edit here plus a regenerated lock file, which is the
|
||||
same ceremony any other dependency bump gets.
|
||||
|
||||
It is an Update on the SDK's item rather than a PackageVersion in Directory.Packages.props, and
|
||||
it has to be: the reference is implicit, so the SDK supplies the version itself and central
|
||||
package management never gets asked. ProcessFrameworkReferences reads @(KnownILLinkPack) when
|
||||
it runs, which is why this lives in Directory.Build.targets — the item does not exist yet while
|
||||
Directory.Build.props is being evaluated.
|
||||
|
||||
Keep this within a patch or two of the runtime the SDK ships. It is the trimming analyzer and
|
||||
the ILLink task, so a small skew is harmless, but a version far behind the framework being
|
||||
analysed is a real way to miss a trim warning.
|
||||
-->
|
||||
<Target Name="PinTheILLinkPackVersion" BeforeTargets="ProcessFrameworkReferences">
|
||||
<!--
|
||||
Inside a target, and not for tidiness. The SDK ships one KnownILLinkPack per target framework
|
||||
and they all share the identity "Microsoft.NET.ILLink.Tasks", so the TargetFramework metadata
|
||||
is the only thing telling net10.0's entry from net8.0's. A condition on %(...) is item
|
||||
batching, which MSBuild permits in a target and rejects during evaluation with MSB4191 — so
|
||||
an ItemGroup at the top of this file cannot express "only the net10.0 one" at all, and the
|
||||
unconditioned Update it would have to become rewrites every framework's entry.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<KnownILLinkPack Update="Microsoft.NET.ILLink.Tasks"
|
||||
Condition="'%(TargetFramework)' == 'net10.0'"
|
||||
ILLinkPackVersion="10.0.11" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -88,8 +88,12 @@
|
||||
-->
|
||||
<PackageVersion Include="NSec.Cryptography" Version="26.4.0" />
|
||||
<PackageVersion Include="libsodium" Version="1.0.22" />
|
||||
<!-- Managed differential oracle for the crypto test suite only. -->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<!--
|
||||
Managed differential oracle for the crypto test suite only. Also what SSH.NET pulls in,
|
||||
so central transitive pinning makes this pin its floor too: 2.7.0 is what SSH.NET
|
||||
2026.0.0 asks for, and pinning below that is a downgrade error rather than a preference.
|
||||
-->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Desktop client">
|
||||
<!--
|
||||
@@ -98,8 +102,14 @@
|
||||
agent forwarding (needs an upstream change; de-scoped from v1) and being handed a
|
||||
pre-connected Stream — it performs its own socket connect, which is why the relay and
|
||||
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
|
||||
|
||||
2026.0.0 is the fix for GHSA-q939-rpr3-3284, a path traversal in ScpClient.Download's
|
||||
recursive mode that trusts server-supplied names (everything up to and including
|
||||
2025.1.0 is affected). Nothing here uses ScpClient — transfers go through SftpClient —
|
||||
so this was an upgrade on principle rather than an exposure, and the release notes
|
||||
list no breaking changes over 2025.1.0.
|
||||
-->
|
||||
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
|
||||
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
|
||||
<!--
|
||||
The S3 client, for buckets as a remote in the file browser. First-party, Apache-2.0, and
|
||||
managed only — no native assets — which is the bar this file sets for anything that gets
|
||||
@@ -211,5 +221,20 @@
|
||||
mistakes that cause real authorization holes.
|
||||
-->
|
||||
<PackageVersion Include="WireMock.Net" Version="2.13.0" />
|
||||
<!--
|
||||
A JavaScript interpreter, in managed code, so that terminal.js can be tested as the file that
|
||||
ships rather than as a transcription of it into C#. Test-only and referenced by exactly one
|
||||
project; nothing in src depends on it.
|
||||
|
||||
Chosen over shelling out to node, which is the obvious alternative and needs node present
|
||||
wherever the suite runs — CI installs one already, but a test that only runs when a separate
|
||||
command is remembered is a test that stops being run. Chosen over a headless browser for the
|
||||
same reason several times over.
|
||||
|
||||
What it does not buy: Jint is not Chromium, so this proves the page's own logic and nothing
|
||||
about how WebView2 or Android's WebView behave. That line is drawn in RendererPage's remark and
|
||||
picked up by docs/manual-checks.md 1.10 and 11.12a.
|
||||
-->
|
||||
<PackageVersion Include="Jint" Version="4.16.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -80,6 +80,8 @@ docs/platform-flags.md what differs off Windows, and the gotchas that have c
|
||||
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
||||
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
|
||||
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
|
||||
```
|
||||
|
||||
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
||||
@@ -152,8 +154,32 @@ reinstalling asks for your passphrase rather than starting over. Use **Sign out*
|
||||
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
|
||||
this machine's device key from your account.
|
||||
|
||||
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
|
||||
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
|
||||
## Installing on macOS
|
||||
|
||||
A `.pkg` on the same release page, for Apple Silicon. Everything above about where a client may come from,
|
||||
about the update check and about uninstalling applies unchanged; what differs is worth three short
|
||||
paragraphs.
|
||||
|
||||
**It is signed and notarized, so there is no warning to click past.** That is not generosity — macOS refuses
|
||||
to open an un-notarized download outright rather than warning about it, so unlike the Windows build there
|
||||
was never an unsigned option. If you *do* see "cannot be opened because Apple cannot check it for malicious
|
||||
software", the file did not come from the project's release page, and that is worth taking literally.
|
||||
|
||||
**Apple Silicon only for now.** An Intel package is a small amount of work and no one here has an Intel Mac
|
||||
to check it on, and this project does not ship desktop builds nobody has run — see
|
||||
[docs/manual-checks.md](docs/manual-checks.md). Under Rosetta the arm64 build will not run; there is no
|
||||
graceful version of that, and the honest answer is that the platform is not covered yet.
|
||||
|
||||
**Touch ID can stand in for your passphrase**, on a Mac with a Secure Enclave. The key that unwraps your
|
||||
device key is generated inside the enclave and never leaves it, and the enclave — not DodoSSH — is what
|
||||
requires your fingerprint or login password before it will use it. Cancel the prompt and you get the
|
||||
passphrase screen, always. The application lives at `/Applications/DodoSSH.Desktop.app` and your vault cache
|
||||
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
|
||||
first never touches the second.
|
||||
|
||||
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.
|
||||
|
||||
### The nightly desktop build
|
||||
|
||||
@@ -886,8 +912,18 @@ keychain plus a terminal — and the spike that gates all of it.
|
||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
||||
[Installing on Windows](#installing-on-windows) for what a user sees.
|
||||
|
||||
Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the
|
||||
first release aimed at strangers), and macOS and Linux packaging.
|
||||
**The macOS half is built on the same machinery**, and signed from the start because Gatekeeper leaves no
|
||||
choice: `scripts/release-macos.sh` publishes, signs every native library, notarizes with Apple and staples
|
||||
the ticket before it will hand anything over, and refuses to upload until a person has installed it. The
|
||||
device key is held in the Secure Enclave behind Touch ID. CI publishes `osx-arm64` and builds the `.app`
|
||||
on every main build to prove it still packages, and uploads nothing. See
|
||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
|
||||
[Installing on macOS](#installing-on-macos).
|
||||
|
||||
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
|
||||
certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the
|
||||
manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
|
||||
everything above is verified only as far as the bundle.
|
||||
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
||||
|
||||
## Licence
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
What the hardened runtime has to be asked to relax before a .NET application will run under it.
|
||||
|
||||
The hardened runtime is not optional: notarization refuses a Developer ID submission without it,
|
||||
and Gatekeeper refuses an un-notarized download. So every entitlement below is the price of being
|
||||
distributable at all, and each one is a hole in a wall that is otherwise worth having. They are
|
||||
listed one at a time, with what breaks without each, because the temptation when notarization
|
||||
fails at eleven at night is to paste in a longer list from somewhere and stop thinking.
|
||||
|
||||
◆ WHAT IS DELIBERATELY NOT HERE.
|
||||
|
||||
com.apple.security.app-sandbox. Developer ID distribution outside the App Store does not require
|
||||
the sandbox, and turning it on would break the product outright: the terminal's data plane is a
|
||||
loopback WebSocket (see DodoSSH.Client.Terminal/TerminalDataPlane.cs), and a sandboxed process
|
||||
needs com.apple.security.network.server to listen at all, plus network.client to reach any host
|
||||
the user asks for. This is the same shape of decision as ruling out MSIX on Windows, which was
|
||||
ruled out for the same loopback reason — docs/platform-flags.md.
|
||||
|
||||
com.apple.security.cs.debugger. Would let this process attach to others. Nothing here debugs
|
||||
anything, and it is the entitlement most worth not having.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
CoreCLR compiles IL to machine code at runtime and then executes the pages it just wrote. The
|
||||
hardened runtime's default is that no page is both writable and executable, so without this the
|
||||
process does not start — it dies during runtime initialisation, before any of this application's
|
||||
code runs, which means before anything exists that could report it.
|
||||
-->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
|
||||
<!--
|
||||
The broader form of the same permission, and it is needed as well as allow-jit rather than
|
||||
instead of it. allow-jit covers pages mapped through the MAP_JIT convention; CoreCLR also
|
||||
allocates executable memory outside that path — stubs, precode, and the write-xor-execute
|
||||
fallback it uses when MAP_JIT is unavailable. With only the first, startup gets further and
|
||||
still fails.
|
||||
-->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
|
||||
<!--
|
||||
Library validation requires every loaded dylib to be signed by the same team as the main
|
||||
binary. This bundle carries native libraries built by other people — libsodium, libSkiaSharp,
|
||||
libHarfBuzzSharp, libe_sqlite3, libAvaloniaNative — and the release script signs each of them
|
||||
with this Developer ID, which would in principle satisfy validation.
|
||||
|
||||
It is disabled anyway, and the reason is the updater. Velopack replaces the bundle in place and
|
||||
relaunches it, and the process doing the replacing is not always signed by the same team as the
|
||||
process being replaced during the changeover. Leaving validation on makes the failure mode of a
|
||||
bad update "the application will not start", with no way to recover except a reinstall the user
|
||||
would have to be told about through some other channel.
|
||||
-->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
|
||||
<!--
|
||||
The runtime reads DYLD_ variables while resolving its own native dependencies, and Velopack's
|
||||
update path sets them. Without this the hardened runtime strips them silently and the failure
|
||||
surfaces later as a library that cannot be found, naming a file that is plainly present.
|
||||
-->
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
The Info.plist for the macOS bundle, with the version left as a placeholder.
|
||||
|
||||
◆ A TEMPLATE RATHER THAN A FILE, BECAUSE vpk COPIES A CUSTOM PLIST VERBATIM.
|
||||
|
||||
Measured, not assumed: `vpk [osx] bundle --plist` performs no substitution of any kind. It logs
|
||||
"Bundle using provided Info.plist" and copies the bytes. That is also why it refuses --plist and
|
||||
--bundleId together — with a plist supplied, every key is the caller's problem.
|
||||
|
||||
So a committed Info.plist would carry whatever version it was written with into every release
|
||||
afterwards, and the failure is quiet in the worst way: Velopack's own release index would carry the
|
||||
right version, the updater would compare correctly and update correctly, and only the About window,
|
||||
Finder's Get Info panel and any crash report would claim the build was something else. Nobody
|
||||
reads those on the day of a release. scripts/release-macos.sh substitutes @VERSION@ into a copy
|
||||
and passes that.
|
||||
|
||||
◆ WHY A CUSTOM PLIST AT ALL, WHEN vpk WRITES A PERFECTLY GOOD ONE.
|
||||
|
||||
Three keys it does not write, each of which is a real defect without it:
|
||||
|
||||
CFBundleDisplayName The bundle on disk is DodoSSH.Desktop.app, because the pack id must not
|
||||
be DodoSSH — see scripts/release-macos.sh for the directory collision
|
||||
that rule prevents. On Windows the pack id is invisible; on macOS it
|
||||
names the thing in /Applications and in the Dock. This key is what puts
|
||||
"DodoSSH" back in front of a person while the bundle keeps the id.
|
||||
|
||||
LSMinimumSystemVersion Without it macOS will happily launch this on a release the runtime was
|
||||
never built for, and the user gets a dyld crash rather than a sentence.
|
||||
|
||||
NSHumanReadableCopyright Shown in the About panel. Absent, the panel shows a blank line.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
CFBundleName is what the menu bar shows and is capped at 15 characters by convention;
|
||||
CFBundleDisplayName is what Finder and the Dock show. Both say DodoSSH, and the bundle
|
||||
directory does not. See the note above.
|
||||
-->
|
||||
<key>CFBundleName</key>
|
||||
<string>DodoSSH</string>
|
||||
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DodoSSH</string>
|
||||
|
||||
<!--
|
||||
Reverse-DNS under the domain this project actually controls. It is the identity Gatekeeper,
|
||||
the notary service and the keychain all key off, so it is as irreversible as the Windows pack
|
||||
id: changing it makes an update a different application, and it orphans anything the previous
|
||||
identifier stored — including the Secure Enclave key MacDeviceKeyStore holds, which is scoped
|
||||
to this identifier and cannot be migrated because its whole point is that it never leaves the
|
||||
enclave.
|
||||
-->
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>dev.dodotech.dodossh</string>
|
||||
|
||||
<!--
|
||||
The apphost the publish produced, named for the product by <AssemblyName> in the csproj rather
|
||||
than for the project. Must match --mainExe or the bundle launches nothing.
|
||||
-->
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>DodoSSH</string>
|
||||
|
||||
<!--
|
||||
Both version keys take the numeric core only — 1.2.3 and never 1.2.3-rc.1 — because Apple
|
||||
defines them as one to three dot-separated integers and notarization rejects what it cannot
|
||||
parse. The full version, prerelease suffix and all, is in Velopack's release index, and that
|
||||
is the one the updater compares. These two are for Finder and for Gatekeeper.
|
||||
|
||||
They are the same value rather than the usual marketing/build split, because there is no build
|
||||
counter here that a release does not already bump.
|
||||
-->
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>@VERSION@</string>
|
||||
|
||||
<key>CFBundleVersion</key>
|
||||
<string>@VERSION@</string>
|
||||
|
||||
<!--
|
||||
The file name inside Contents/Resources, which is where --icon puts it. With a custom plist
|
||||
nothing rewrites this key, so a rename of the asset that forgets this line produces a bundle
|
||||
showing the generic application icon and no error anywhere.
|
||||
-->
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>dodossh.icns</string>
|
||||
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
|
||||
<!--
|
||||
12.0, and it is read off the binaries rather than off a support matrix. The apphost and
|
||||
libcoreclr.dylib in a net10.0 osx-arm64 publish both carry LC_BUILD_VERSION with minos 12.0.0,
|
||||
so 12.0 is the oldest release these bytes are built to load on.
|
||||
|
||||
Microsoft's *support* statement for .NET 10 is higher than this, and that difference is
|
||||
deliberate rather than overlooked: this key decides whether macOS refuses to launch the app at
|
||||
all, and refusing on a release where it would in fact have run is the worse of the two errors.
|
||||
A user on an unsupported-but-working macOS gets the application; the support matrix governs
|
||||
what gets fixed if it misbehaves there, which is a different question.
|
||||
-->
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
|
||||
<!--
|
||||
Without this the window is drawn at 1x and scaled up, which on a Retina display turns the
|
||||
terminal — the one surface in this application that is nothing but small text — into a blur.
|
||||
-->
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
|
||||
<!--
|
||||
False, and stated rather than left out. An agent application has no Dock icon and no menu bar;
|
||||
this one is an ordinary windowed application and the default is already false, but the key
|
||||
being absent is indistinguishable from somebody having removed it.
|
||||
-->
|
||||
<key>LSUIElement</key>
|
||||
<false/>
|
||||
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© DodoTech. MIT licensed.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR 0007 — What protects the device key on Windows
|
||||
# ADR 0007 — What protects the device key on the desktop
|
||||
|
||||
**Status:** accepted, 2026-07-30
|
||||
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
|
||||
@@ -141,6 +141,14 @@ would have become false under DPAPI alone. A gesture is still something the atta
|
||||
- **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
|
||||
platform's rather than the process's — which is the entire point of the 2026-07-30 amendment above, and
|
||||
the thing a self-drawn prompt over a protected file would fail to be. The mechanical differences are
|
||||
incidental: P-256 with ECIES because the enclave holds no other kind of key, and no prompt when sealing
|
||||
because the public half needs no consent. See docs/platform-flags.md for the three ordinary Macs where the
|
||||
probe answers no, one of which is every unsigned development build.
|
||||
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
|
||||
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
|
||||
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
|
||||
|
||||
@@ -228,6 +228,44 @@ changes.
|
||||
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
||||
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
||||
|
||||
**This rule is Windows-only, and macOS gets the opposite one.** See decision 10: there is no "unsigned for
|
||||
now" available on that platform at any price, because Gatekeeper refuses rather than warns.
|
||||
|
||||
### 10. macOS is a second desktop platform on the same machinery, signed from the start
|
||||
|
||||
The macOS head is the same application, the same Velopack, and the same two-phase person-run release. Four
|
||||
things differ, and each is forced rather than chosen.
|
||||
|
||||
**Signing is a precondition, not an improvement.** Decision 8's whole argument — one dialog per user per
|
||||
lifetime, buy a certificate when a stranger is invited to install — has no macOS equivalent. An
|
||||
un-notarized download is refused outright, so the Developer ID certificate and the notarization round trip
|
||||
are the price of the package existing. `scripts/release-macos.sh` therefore refuses to run without the
|
||||
signing identities, where the Windows script refuses nothing.
|
||||
|
||||
**The channels are `osx` and `osx-nightly`, and they are separate for decision 9's reason.** Four channels
|
||||
now publish to one repository, and the only thing keeping a Mac from being offered a Windows package is
|
||||
that it never reads that index. The macOS nightly channel is named and has no publisher: CI builds and
|
||||
bundles the macOS head to prove it still builds, and uploads nothing, exactly as it does for the Windows
|
||||
release channel.
|
||||
|
||||
**The pack id is shared with Windows, and on macOS it is visible.** vpk names the bundle after the pack id,
|
||||
so `/Applications` holds `DodoSSH.Desktop.app`. Decision 2's reasoning applies with more force here rather
|
||||
than less: a pack id of `DodoSSH` would put Velopack's install root on `~/Library/Application
|
||||
Support/DodoSSH`, which is `ClientPaths.DataDirectory`, and an uninstall would take the user's un-synced
|
||||
outbox with it. `CFBundleDisplayName` puts the product name back in front of a person; the directory keeps
|
||||
the id.
|
||||
|
||||
**arm64 only, because the check is the scarce thing.** Velopack keys a channel to one architecture, and an
|
||||
Intel package would be the only artefact in this repository reaching users without somebody having walked
|
||||
Phase 18 against it. The engineering for a second channel is small and is described in the release script;
|
||||
what is missing is an Intel Mac to verify on, and shipping blind is the thing this project's manual-check
|
||||
discipline exists to refuse.
|
||||
|
||||
**And one thing that does not differ, which is worth saying because it is the expensive half.** The
|
||||
capability to publish still lives on a person's machine and never in CI. Notarization does not change that:
|
||||
Apple's ticket says this build came from this developer account, and says nothing about whether the build
|
||||
should have been made. Velopack clients still apply what their feed serves. Rule 3 is untouched.
|
||||
|
||||
### 9. There is a second desktop channel, published by CI, and it is a second application
|
||||
|
||||
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
|
||||
|
||||
@@ -348,6 +348,16 @@ runtime or the receipt is silently invisible — the service still runs, but not
|
||||
to show, at most once, with no result read back: a refusal costs the notification and nothing else, which is
|
||||
what the manifest's own comment on the permission says.
|
||||
|
||||
**And a fourth correction, found by the notification refusing to come down.** "1 shell connected" outlived
|
||||
the shell, both ways a shell can close. A shell exiting on its own announced `SessionEnded` from inside its
|
||||
run's own finally block — where the run task is by definition not yet complete, so the
|
||||
`LiveSessionCount` the keep-alive reads from that event still counted the session that had just ended, and
|
||||
nothing fired afterwards to correct it. A tab closed by hand announced nothing at all, by a recorded
|
||||
decision that assumed every subscriber was the closer. Both reversed in `TerminalWorkspace`: the end is now
|
||||
announced from a continuation after the run has actually completed, and `CloseSessionAsync` announces too,
|
||||
after its own drain — the event's remark carries the reversal, and `SessionEnded`'s subscribers were all
|
||||
already "reconcile to reality" handlers for which a second announcement is harmless.
|
||||
|
||||
### Phone first
|
||||
|
||||
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
|
||||
|
||||
@@ -300,7 +300,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
|
||||
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
|
||||
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
|
||||
> | No pins destination in the design at all | **Kept anyway.** The rail still carries Pins — `KnownHostsScreen` — because the mock has no screen for approved host keys and this application's has to stay reachable. |
|
||||
> | No pins destination in the design at all | **The rail agrees with the design now.** `KnownHostsScreen` is still built and still reachable — from **Host keys** on the Keys screen's own header, which was always the second way in — but the rail's Pins row is gone. It was kept through v5b on the grounds that the mock has no screen for approved host keys, which is a reason for the screen to exist and was never a reason for a rail entry once the keychain had a door to the same place. Two rail rows landing on one screen is a rail that has to be read twice. |
|
||||
> | The popover's Settings and Preferences rows, and the design's own Settings-* family of screens | **Landed in v5c.** What was two doors to one room in v5b — Settings and Preferences both opening the same bare `Preferences` screen — is now two of three doors onto their own settings pages: Settings opens General, Preferences opens Preferences, and a third row, Vaults, opens Vaults. All three are real, distinct pages inside one settings mode; see the v5c section, below. |
|
||||
> | `· Org` after the user chip's name, and a `Primary` tag on a vault row in the popover | Neither. There is no organisation concept behind a vault — only the vault itself — and no vault is distinguished as primary; the popover's vault rows are the existing shown-vaults toggles, restyled. |
|
||||
> | The design's titlebar, which has nowhere for a sync indicator | `SYNCED` stays, on the titlebar's right side, ahead of the window's own minimise/maximise/close buttons — the one thing this titlebar keeps that the design's own does not draw at all. |
|
||||
@@ -720,3 +720,24 @@ 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.
|
||||
+274
-5
@@ -28,8 +28,9 @@ a phase had nothing left for a person to do, which is the good outcome rather th
|
||||
|
||||
### 1.1 No screen is sliced at the WebView's left edge · **the important one**
|
||||
|
||||
Open two terminals, then visit every nav rail entry in turn — Hosts, Keys, Pins, Snips, Logs — and both of
|
||||
the switcher's other two segments, SFTP and S3, at the rail's own head.
|
||||
Open two terminals, then visit every nav rail entry in turn — Hosts, Keys, Snips, Logs — and both of
|
||||
the switcher's other two segments, SFTP and S3, at the rail's own head. The pins screen is no longer a rail
|
||||
entry; reach it from **Host keys** on the Keys screen's header and check it the same way.
|
||||
|
||||
**Pass:** each screen draws whole, its buttons all clickable, and the nav rail stays up the left edge for
|
||||
every one of them. Since v5b's chrome pass the rail is permanent furniture — it no longer collapses for
|
||||
@@ -170,6 +171,32 @@ 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
|
||||
@@ -400,6 +427,82 @@ surface. Use the ← button, or drag the file to the local pane first.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2b — The path box, and what can be done to a remote row
|
||||
|
||||
The rules are automated — `SftpPath.Resolve` in `RemotePathTests`, the three questions and the automatic
|
||||
refresh in `ShellFlowTests`, both new shapes measured in `ScreenLayoutTests`. What is left here is what
|
||||
those cannot reach: a real server's own refusals, and a soft keyboard.
|
||||
|
||||
Connect the SFTP page to a host first.
|
||||
|
||||
### 2.20 Typing a path
|
||||
|
||||
Click into the path in the remote pane's header, type `/var/log`, press Enter. Then type `..`, Enter. Then
|
||||
`~`, Enter.
|
||||
|
||||
**Pass:** each one lists, the breadcrumb underneath follows, and the box ends up showing the absolute path
|
||||
that was actually reached rather than what was typed.
|
||||
|
||||
### 2.21 Escape puts the path back
|
||||
|
||||
Type nonsense into the box without pressing Enter, then press Escape.
|
||||
|
||||
**Pass:** the box goes back to the directory the pane is showing.
|
||||
|
||||
### 2.22 A path the server refuses
|
||||
|
||||
Type a path that does not exist, and one that exists but is not readable by this account.
|
||||
|
||||
**Pass:** the pane stays where it was, the status line at the foot of the screen carries the server's own
|
||||
message, and the box still holds what was typed so it can be corrected.
|
||||
|
||||
### 2.23 Rename
|
||||
|
||||
Select a file, press the rename button, change the name, press RENAME.
|
||||
|
||||
**Pass:** the listing comes back with the new name and the row still selected. Then try a name that another
|
||||
file in the directory already has: **the server refuses it and says so, and nothing is overwritten** — this
|
||||
is the one that matters, because the whole queue's "nothing lands at its final name until it is complete"
|
||||
promise rests on the same server behaviour.
|
||||
|
||||
### 2.24 Move
|
||||
|
||||
Select a file, press the move button, type a directory — try an absolute one and a relative one — and press
|
||||
MOVE.
|
||||
|
||||
**Pass:** the file leaves the listing and is in the directory that was typed.
|
||||
|
||||
Then try one across a filesystem boundary on the same host — `/tmp` to a path on a separate mount is the
|
||||
usual pair. **Pass:** the server refuses with its own message about a cross-device link. This cannot be
|
||||
faked in a test and is the refusal somebody will eventually meet.
|
||||
|
||||
### 2.25 The listing refreshes itself after an upload
|
||||
|
||||
Upload a file into the directory showing.
|
||||
|
||||
**Pass:** it appears in the remote listing without REFRESH being pressed. Then upload into a *different*
|
||||
directory: nothing on screen changes. Then download a file into the folder the local pane is showing, and
|
||||
that one appears on the left the same way.
|
||||
|
||||
### 2.26 A transfer that lands while a question is open
|
||||
|
||||
Start an upload into the directory showing, and while it runs select a row and press DELETE so the question
|
||||
is up when the transfer finishes.
|
||||
|
||||
**Pass:** the listing does not move while the question is on screen, and refreshes the moment the question
|
||||
is answered or cancelled.
|
||||
|
||||
**Failure means:** the row the question was asked about is not necessarily the row that gets acted on.
|
||||
|
||||
### 2.27 The phone's GO button
|
||||
|
||||
On Android, connect on the Files screen, type a path into the box above the breadcrumb and press GO.
|
||||
|
||||
**Pass:** it lists. The keyboard's own return key may or may not commit — that is the IME's choice, which is
|
||||
why GO exists — but GO always must.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Host groups and snippets
|
||||
|
||||
Two synced item kinds, two ways of drawing a group — the desktop's grid of cards, one level at a time, and
|
||||
@@ -1745,6 +1848,24 @@ is a terminal that answers the buttons and ignores the keyboard: it reads as the
|
||||
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
|
||||
tap of an arrow key.
|
||||
|
||||
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
|
||||
|
||||
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
|
||||
keep typing on the software keyboard.
|
||||
|
||||
**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
|
||||
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
|
||||
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
|
||||
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
|
||||
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
|
||||
|
||||
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
|
||||
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
|
||||
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
|
||||
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
|
||||
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
|
||||
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
|
||||
|
||||
### 11.11 Closing a connection and opening a new one both take you somewhere real
|
||||
|
||||
Open a shell, close its tab, then open a different one from HOSTS.
|
||||
@@ -1778,6 +1899,29 @@ 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
|
||||
@@ -2115,13 +2259,19 @@ foreground service is protecting), wait thirty seconds with the shell doing noth
|
||||
**Pass:** the notification stayed up the whole time, and the shell is exactly where it was — same scrollback,
|
||||
same prompt — with typing reaching the host immediately. Exit the shell.
|
||||
|
||||
**Pass:** the notification goes with it, once nothing else is open.
|
||||
**Pass:** the notification goes with it, once nothing else is open. Open another shell and close it from the
|
||||
shells strip's ✕ instead of exiting — the notification comes down for that route too, which is the route
|
||||
that used to leave it up: a deliberate close announced nothing to the keep-alive at all, and a shell exiting
|
||||
on its own was announced while the count still included it.
|
||||
|
||||
**Failure means:** an upload that stalls with the screen off is the count not reaching
|
||||
`SessionForegroundService`, and Android has stopped the process mid-transfer. A notification left up
|
||||
afterwards is `ActivityChanged` not being subscribed — the other end of the same wire. A shell that has
|
||||
disconnected on return is `MainWindowViewModel.TerminalSessionOpened` never reaching `SessionKeepAlive` — the
|
||||
service only ever heard about a shell *ending*, so it never came up for one in the first place.
|
||||
service only ever heard about a shell *ending*, so it never came up for one in the first place. A
|
||||
notification still saying "1 shell connected" after the shell is gone — by either route — is
|
||||
`TerminalWorkspace.SessionEnded` firing before the run completed, or a close not announcing; see
|
||||
`AnnounceEndedAsync` and the event's own remark.
|
||||
|
||||
### 14.6a A Files connection with nothing moving still survives backgrounding
|
||||
|
||||
@@ -2399,7 +2549,9 @@ script warns rather than failing when that is legitimate, which is the first rel
|
||||
### 16.7 The update arrives, and the restart lands in it · **the whole point of the work**
|
||||
|
||||
With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours.
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours. Closing and
|
||||
reopening the application does the same thing without the button: the first pass of the loop runs at launch,
|
||||
so a client started after a release finds it without anybody asking.
|
||||
|
||||
**Pass:** the progress bar moves, the banner appears above the status bar, and — the part to actually watch
|
||||
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
|
||||
@@ -2572,3 +2724,120 @@ package manager will not offer to.
|
||||
|
||||
**Failure means:** the channels are not separate, and a public key is signing the application people keep
|
||||
their credentials in.
|
||||
|
||||
## Phase 18 — Installing the macOS client, and being updated by it
|
||||
|
||||
The macOS counterpart of phase 16, and it needs a Mac with a Secure Enclave — an Apple Silicon machine or
|
||||
an Intel one with a T2. Every check here is structurally unreachable by a test for the reasons phase 16
|
||||
gives, plus one this platform adds: **CI has no macOS runner at all**, so this phase is the only place the
|
||||
suite and the application ever run on macOS. Anything `docs/platform-flags.md` marks as unverified on macOS
|
||||
is verified here or nowhere.
|
||||
|
||||
Run `bash scripts/release-macos.sh` first. It stops after packing and notarizing, on purpose, so that
|
||||
everything below happens before anything reaches a user. Phase 16.0 — the feed being readable without
|
||||
credentials — applies unchanged and is not repeated.
|
||||
|
||||
### 18.1 Gatekeeper accepts it on a machine that did not build it · **do this one first**
|
||||
|
||||
The Mac that signed a package trusts it locally whatever happened, so the build machine cannot answer this
|
||||
question about itself. Copy the `.pkg` to a second Mac — or at minimum download it through a browser, which
|
||||
is what applies the quarantine attribute — and open it.
|
||||
|
||||
**Pass:** it installs with no warning beyond the ordinary installer prompts.
|
||||
|
||||
**Failure means:** "cannot be opened because Apple cannot check it for malicious software" is notarization
|
||||
that did not happen or a ticket that did not staple. The script's `spctl --assess` and `xcrun stapler
|
||||
validate` should have caught it before this point, so reaching here means one of those two checks was
|
||||
removed or skipped. Do not distribute the package.
|
||||
|
||||
### 18.2 The Dock shows the product and not the pack id
|
||||
|
||||
Look at the installed application in `/Applications`, in the Dock, and in the menu bar while it runs.
|
||||
|
||||
**Pass:** the menu bar says **DodoSSH**. Finder shows **DodoSSH**. The bundle on disk is
|
||||
`DodoSSH.Desktop.app` and that is expected — see the pack id note in `scripts/release-macos.sh`.
|
||||
|
||||
**Failure means:** "DodoSSH.Desktop" in the menu bar is `CFBundleName` not reaching the bundle, which means
|
||||
the rendered `Info.plist` did not get used. Since vpk copies a custom plist verbatim and substitutes
|
||||
nothing, check the same bundle's `CFBundleShortVersionString` — if it reads `@VERSION@`, the template was
|
||||
passed through unrendered.
|
||||
|
||||
### 18.3 The icon is the mark, at every size
|
||||
|
||||
Look at it in the Dock, in Finder's icon view at a large size, and in `⌘I` Get Info.
|
||||
|
||||
**Pass:** the accent tile and the `>_` mark, crisp at 1024, with the same air around it that Finder and
|
||||
Safari have.
|
||||
|
||||
**Failure means:** a generic application icon is `CFBundleIconFile` naming a file that is not in
|
||||
`Contents/Resources`. An icon that fills its square edge to edge, larger than its neighbours, is
|
||||
`New-MarkPng` having been called with the Windows tile fraction — see `dodossh-icon.ps1`.
|
||||
|
||||
### 18.4 Touch ID guards the device key, and the enclave enforces it
|
||||
|
||||
Register a device key from the security settings page, then lock the vault and unlock it again.
|
||||
|
||||
**Pass:** registering shows **no** prompt at all — sealing uses only the public half — and unlocking raises
|
||||
the system Touch ID sheet saying DodoSSH is trying to *unlock your DodoSSH vault*. The vault opens on a
|
||||
successful touch.
|
||||
|
||||
**Failure means:** a prompt at registration is not a failure of correctness but says the key was not created
|
||||
in the enclave; check that `kSecAttrTokenID` reached the attributes. **No prompt at unlock, with the vault
|
||||
opening anyway, is the serious one** — it means the key is a software key and the access control did nothing,
|
||||
which is precisely the "a gate inside the process is not a gate" mistake `WindowsDeviceKeyStore` documents.
|
||||
|
||||
### 18.5 Declining the fingerprint falls back to the passphrase
|
||||
|
||||
Repeat 18.4 and cancel the Touch ID sheet.
|
||||
|
||||
**Pass:** the unlock screen asks for the passphrase, and it works.
|
||||
|
||||
**Failure means:** an error dialog, or a stuck screen, is `TryLoadAsync` throwing rather than answering
|
||||
null. Every failure it can meet — cancelled, timed out, key invalidated by a password reset — is meant to
|
||||
be indistinguishable and to land on the passphrase.
|
||||
|
||||
### 18.6 A development build offers no device key at all
|
||||
|
||||
Run the application with `dotnet run` rather than from the installed bundle, and open the security settings
|
||||
page.
|
||||
|
||||
**Pass:** registering a device key is not offered.
|
||||
|
||||
**Failure means:** being offered it is `IsSupported` having inferred availability from the OS rather than
|
||||
probing. An unsigned build cannot create an enclave key, so accepting the offer would put a wrap on the
|
||||
server that nothing can ever open and list a capability this machine does not have.
|
||||
|
||||
### 18.7 The terminal works, which is the WKWebView 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 that reports a renderer timeout after fifteen seconds is the loopback
|
||||
WebSocket not reaching WKWebView. This is the check that most needs walking, because the data plane has
|
||||
never run against this backend — see `TerminalDataPlane`. If it fails, the App Sandbox is the first thing to
|
||||
rule out: the entitlements deliberately do not enable it, and a sandboxed process cannot listen on loopback
|
||||
without `com.apple.security.network.server`.
|
||||
|
||||
### 18.8 An update is offered, downloaded and applied
|
||||
|
||||
With the release installed, cut a second release with a higher version and publish it, then leave the first
|
||||
running.
|
||||
|
||||
**Pass:** the banner appears, downloads, and on applying the application closes and reopens on the new
|
||||
version. The vault's contents and the known hosts survive.
|
||||
|
||||
**Failure means:** an update that never arrives is usually the channel — `osx` here and `osx` in
|
||||
`VelopackUpdateChannel.MacReleaseChannel`, with no error anywhere when they disagree. An update that
|
||||
downloads and fails to apply, leaving the application unable to restart, is library validation: check that
|
||||
`com.apple.security.cs.disable-library-validation` survived into the entitlements.
|
||||
|
||||
### 18.9 Uninstalling does not take the vault with it
|
||||
|
||||
Register a device, sync something, then remove the application.
|
||||
|
||||
**Pass:** `~/Library/Application Support/DodoSSH` still holds the cache and the outbox afterwards.
|
||||
|
||||
**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`.
|
||||
+98
-3
@@ -3,8 +3,18 @@
|
||||
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
|
||||
have already cost time once. Development is Windows-first, but **the full test suite now runs on
|
||||
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
|
||||
suspicion. **macOS is still untested**, and anything marked *unverified* has not run on the platform
|
||||
in question and must not be assumed to work.
|
||||
suspicion. Anything marked *unverified* has not run on the platform in question and must not be
|
||||
assumed to work.
|
||||
|
||||
**macOS now builds and packages, and has still never run.** The distinction matters more here than
|
||||
anywhere else on this page, because the two halves are verified in completely different places. The
|
||||
build is measured on every main and tag build: CI publishes `osx-arm64` and runs `vpk [osx] bundle`
|
||||
on a Linux runner, which is enough to catch a restore graph with no macOS native asset and an `.app`
|
||||
that will not compose. Everything past that — whether the window draws, whether the terminal's
|
||||
loopback WebSocket reaches WKWebView, whether the Secure Enclave holds a device key — is verified
|
||||
only by a person walking Phase 18 of [manual-checks.md](manual-checks.md) on a Mac, because **there
|
||||
is no macOS runner in CI**. Treat every macOS runtime claim below as unverified unless it says
|
||||
otherwise.
|
||||
|
||||
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
|
||||
has been verified or made moot — not when it merely stops being convenient.
|
||||
@@ -17,6 +27,19 @@ docs/crypto.md §1. *Already mitigated* — but if a BCL AEAD path is ever added
|
||||
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
|
||||
any vault on macOS.
|
||||
|
||||
**The Secure Enclave holds P-256 keys and nothing else**, which is why `MacDeviceKeyStore` wraps the
|
||||
device key with ECIES rather than with the RSA-OAEP the Windows store uses. It will not hold an RSA
|
||||
key at any size, so this is not a preference. The useful consequence is that the macOS shape is
|
||||
*better* than the Windows one: `SecKeyCopyPublicKey` works on an enclave key without prompting, so
|
||||
registering a device is silent and only unlock asks — where Windows raises a dialog at key creation
|
||||
too. *Unverified:* no enclave call in this repository has ever run.
|
||||
|
||||
**Three ordinary Macs have no usable enclave**, and `IsSupported` probes rather than infers for that
|
||||
reason: an Intel machine without a T2, a machine with no login password set, and — the one that
|
||||
surprises people — **any build that is not code signed**, because enclave key creation needs a
|
||||
signing identity. So `dotnet run` correctly offers no device key at all. Do not "fix" this by
|
||||
checking the OS instead; the offer would then put a wrap on the server that nothing can ever open.
|
||||
|
||||
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
|
||||
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
|
||||
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
|
||||
@@ -26,7 +49,11 @@ and the parameters are stored per user at enrollment, so a bad default is a per-
|
||||
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
|
||||
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
||||
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
|
||||
or notarization fails with an error that does not name the offending file.
|
||||
or notarization fails with an error that does not name the offending file. *Mitigated* in
|
||||
`scripts/release-macos.sh`, which signs every `.dylib` and `createdump` in a loop before vpk touches
|
||||
anything — vpk's own pass uses `codesign --deep`, which is the shape Apple documents as wrong for
|
||||
nested code and is the likeliest source of that unnamed rejection. The loop looks redundant next to
|
||||
`--deep` and is not; do not delete it because a release once succeeded without it.
|
||||
|
||||
## Desktop client
|
||||
|
||||
@@ -361,11 +388,55 @@ 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
|
||||
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
||||
|
||||
**The App Sandbox is ruled out on macOS for the same reason, and the entitlements say so.** A
|
||||
sandboxed process cannot listen on loopback without `com.apple.security.network.server`, and the
|
||||
terminal is that listener. Developer ID distribution outside the App Store does not require the
|
||||
sandbox, so this costs nothing today — but it does mean the Mac App Store is closed to this
|
||||
application without solving the data plane differently first. See
|
||||
`build/macos/DodoSSH.entitlements`.
|
||||
|
||||
**The hardened runtime is not optional and .NET needs four holes punched in it.** Notarization
|
||||
refuses a Developer ID submission without it, and CoreCLR will not start under it without
|
||||
`allow-jit` and `allow-unsigned-executable-memory` — both, not either, because the runtime allocates
|
||||
executable memory outside the `MAP_JIT` path as well. `disable-library-validation` and
|
||||
`allow-dyld-environment-variables` are needed for Velopack's updater rather than for the runtime.
|
||||
Each is argued individually in the entitlements file; the failure mode for a missing one is a
|
||||
process that dies during runtime initialisation, before anything exists that could report it.
|
||||
|
||||
**`vpk` cross-compiles to macOS only as far as the bundle.** `vpk [osx] bundle` runs anywhere and
|
||||
produces a real `.app`; there is no `[osx] pack` off a Mac, because pack drives `codesign`,
|
||||
`notarytool` and `stapler`. So CI can prove the bundle builds and only a Mac can produce something
|
||||
installable. Note this is the *opposite* of the Windows story, where `vpk [win] pack` builds the
|
||||
whole installer on Linux — the asymmetry is Apple tooling, not a Velopack limitation.
|
||||
|
||||
**A custom `Info.plist` is copied verbatim by vpk, with no substitution whatsoever.** That is why
|
||||
`--plist` and `--bundleId` are mutually exclusive, and why `build/macos/Info.plist.template` is a
|
||||
template the release script renders rather than a committed file. A committed plist would carry one
|
||||
version into every release afterwards, and the symptom is silent: Velopack's index would still be
|
||||
right, the updater would still work, and only Get Info and any crash report would disagree.
|
||||
|
||||
**macOS app icons live on an 824-in-1024 grid.** An icon that bleeds to the edge of its canvas is
|
||||
not bolder, it is the one icon in the Dock that is too big. `dodossh-icon.ps1` draws the `.icns` at
|
||||
that fraction and the `.ico` at full bleed, from one geometry.
|
||||
|
||||
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
|
||||
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
||||
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
||||
@@ -641,6 +712,30 @@ The lasting hazard is the first paragraph and not the fix. Any change to a share
|
||||
to a lock file this repository cannot verify from a machine without the Android workload, and it will go
|
||||
on being noticed later than every other one.
|
||||
|
||||
**A lock file can go stale with nothing in this repository changing, because `Microsoft.NET.ILLink.Tasks`
|
||||
is versioned by the SDK and `global.json` lets the SDK float.** The reference is implicit — nothing in any
|
||||
`.csproj` asks for it — and its version tracks the runtime patch band, while `global.json` pins only
|
||||
`10.0.100` with `rollForward: latestMinor`. So `setup-dotnet` installs whatever the newest 10.x SDK is on
|
||||
the day, and the moment that SDK's band moves, locked-mode restore stops:
|
||||
|
||||
```
|
||||
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||
from [10.0.10, ) to [10.0.11, ).
|
||||
```
|
||||
|
||||
It named `DodoSSH.Client.Android`, `DodoSSH.Contracts` and `DodoSSH.Crypto` — the three lock files that
|
||||
carry the entry — on a commit that touched none of them and no dependency at all.
|
||||
|
||||
The fix is `--force-evaluate` on those three, **from a machine whose SDK is at least as new as the
|
||||
runner's**, which is the part that is easy to get wrong: a `--force-evaluate` from an older SDK rewrites
|
||||
the lock at the older version, changes nothing, and looks like it worked. Check `dotnet --version` against
|
||||
the version in the error before believing a regeneration.
|
||||
|
||||
This will recur on every SDK patch that moves the band. It is the accepted cost of letting the SDK float:
|
||||
the alternative is pinning an exact SDK in `global.json`, which trades a recurring lock-file bump for a
|
||||
recurring toolchain bump and makes every contributor install one specific SDK. Neither is free, and this
|
||||
repository has chosen the floating side deliberately.
|
||||
|
||||
**.NET for Android cannot be built on a musl host, and this project's runner is Alpine. Every message the
|
||||
toolchain produces on the way to saying so names a missing file that is present.** Three CI rounds went
|
||||
into this and the first two fixed symptoms, so the messages are worth reading in the order they arrive.
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Builds, packages and publishes the macOS desktop client.
|
||||
#
|
||||
# The counterpart of scripts/release-windows.ps1, and deliberately the same shape: run by a person, on a
|
||||
# Mac that is not a CI runner, in two phases with the upload withheld until somebody has installed what
|
||||
# phase one built and walked the manual checks. docs/adr/0011-android-distribution.md rule 1 puts the
|
||||
# capability to ship somebody a build on a machine which is not a runner, and
|
||||
# docs/adr/0013-desktop-distribution-and-updates.md explains why the token that writes a Gitea release is
|
||||
# that capability: Velopack clients trust their feed and do not verify a package signature when they apply
|
||||
# it, so whoever can write a release can ship an update every install runs.
|
||||
#
|
||||
# 1. Without --upload: builds, signs, notarizes, packs, and stops. Nothing has left this machine
|
||||
# except the notarization submission, which Apple sees and users do not.
|
||||
# 2. With --upload: asks for the forge token and publishes what phase one produced. It does not
|
||||
# rebuild, so the bytes that reach users are the bytes that were installed and checked.
|
||||
#
|
||||
# ◆ WHAT IS DIFFERENT FROM THE WINDOWS SCRIPT, AND WHY.
|
||||
#
|
||||
# Signing is not optional here. On Windows an unsigned installer costs a SmartScreen dialog once per
|
||||
# user, which is why that script has no --signParams and says so. On macOS an un-notarized download is
|
||||
# refused outright by Gatekeeper — not warned about, refused — so the Developer ID certificate and the
|
||||
# notarization round trip are the price of the package being installable at all, not an improvement to
|
||||
# be bought later.
|
||||
#
|
||||
# ◆ CREDENTIALS COME FROM THE KEYCHAIN AND THE ENVIRONMENT, NOT FROM THIS FILE.
|
||||
#
|
||||
# Three values are read from the environment, and none of them is itself a secret — they name things the
|
||||
# keychain holds, and the keychain is what guards the private key and the App Store Connect credentials:
|
||||
#
|
||||
# DODOSSH_SIGN_APP_IDENTITY e.g. "Developer ID Application: DodoTech (TEAMID)"
|
||||
# DODOSSH_SIGN_INSTALL_IDENTITY e.g. "Developer ID Installer: DodoTech (TEAMID)"
|
||||
# DODOSSH_NOTARY_PROFILE the profile name given to `xcrun notarytool store-credentials`
|
||||
#
|
||||
# `security find-identity -v -p codesigning` lists the first two exactly as codesign wants them. The
|
||||
# third is created once per machine:
|
||||
#
|
||||
# xcrun notarytool store-credentials DodoSSH \
|
||||
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
|
||||
#
|
||||
# The forge token is the one real secret, and it is prompted for rather than read from a file or the
|
||||
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
|
||||
# minutes a credential that can publish an update spends in a shell's memory the better.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/release-macos.sh
|
||||
# bash scripts/release-macos.sh --upload
|
||||
# bash scripts/release-macos.sh --skip-tests
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
UPLOAD=0
|
||||
SKIP_TESTS=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--upload) UPLOAD=1 ;;
|
||||
--skip-tests) SKIP_TESTS=1 ;;
|
||||
*)
|
||||
echo "Unknown argument: $arg" >&2
|
||||
echo "Usage: bash scripts/release-macos.sh [--upload] [--skip-tests]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- The contract with every installed client ---------------------------------------------------------
|
||||
|
||||
# Velopack's identity for this application, and it is effectively irreversible for the reasons the Windows
|
||||
# script states — it is what an installed client matches an update against.
|
||||
#
|
||||
# ◆ THE SAME PACK ID AS WINDOWS, AND ON THIS PLATFORM IT IS VISIBLE.
|
||||
#
|
||||
# vpk names the bundle after the pack id, so this produces DodoSSH.Desktop.app rather than DodoSSH.app,
|
||||
# and that is what somebody sees in /Applications. It is kept anyway, because the alternative is worse:
|
||||
# a pack id of DodoSSH would put Velopack's install and its uninstall on ~/Library/Application Support/
|
||||
# DodoSSH, which is exactly where ClientPaths keeps the encrypted cache, the outbox of changes not yet
|
||||
# pushed and the device key. Sharing that directory would mean an uninstall silently taking a user's
|
||||
# un-synced work with it. The same reasoning, and the same conclusion, as the Windows script.
|
||||
#
|
||||
# What a person actually reads is CFBundleDisplayName, which build/macos/Info.plist.template sets to
|
||||
# DodoSSH. So the bundle keeps the id and the Dock shows the product.
|
||||
PACK_ID='DodoSSH.Desktop'
|
||||
PACK_TITLE='DodoSSH'
|
||||
PACK_AUTHORS='DodoTech'
|
||||
|
||||
# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
|
||||
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
|
||||
# The owner is part of it: Gitea left a 301 at the old organisation's path, which a GET follows and an
|
||||
# upload does not.
|
||||
REPO_URL='https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
|
||||
|
||||
# A contract with VelopackUpdateChannel.MacReleaseChannel. Velopack's macOS default is also "osx", so
|
||||
# leaving it unsaid on both sides would work — but unsaid here and stated there is how a feed goes quiet
|
||||
# with no error at all: the client checks, finds nothing, and reports itself up to date forever.
|
||||
CHANNEL='osx'
|
||||
|
||||
# ◆ ARM64 ONLY, AND THAT IS A DECISION RATHER THAN AN OVERSIGHT.
|
||||
#
|
||||
# Velopack keys a channel to one architecture, so shipping Intel too means a second channel, a second
|
||||
# publish, a second set of deltas and a second thing to keep in step with the client's channel picker.
|
||||
# That is all affordable. What is not currently affordable is testing it: nobody here has an Intel Mac,
|
||||
# and docs/manual-checks.md exists because this project does not ship desktop builds no one has run.
|
||||
# An x64 package built blind and published beside a checked arm64 one would be the only artefact in this
|
||||
# repository that reached users unverified.
|
||||
#
|
||||
# Adding it later is this constant, a second channel name in VelopackUpdateChannel, and a picker keyed on
|
||||
# RuntimeInformation.ProcessArchitecture — which reports X64 for a build running under Rosetta, so an
|
||||
# Intel build correctly stays on the Intel feed. The work is small; the check is the part that is missing.
|
||||
RUNTIME='osx-arm64'
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROJECT="$REPO_ROOT/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj"
|
||||
SOLUTION="$REPO_ROOT/DodoSSH.slnx"
|
||||
PUBLISH_DIR="$REPO_ROOT/publish/$RUNTIME"
|
||||
RELEASES_DIR="$REPO_ROOT/Releases"
|
||||
ICON="$REPO_ROOT/src/DodoSSH.Client.App/Assets/dodossh.icns"
|
||||
ENTITLEMENTS="$REPO_ROOT/build/macos/DodoSSH.entitlements"
|
||||
PLIST_TEMPLATE="$REPO_ROOT/build/macos/Info.plist.template"
|
||||
|
||||
write_step() { printf '\n\033[36m==> %s\033[0m\n' "$1"; }
|
||||
stop_with() { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; }
|
||||
|
||||
# ---- Is this machine able to do the job at all? -------------------------------------------------------
|
||||
|
||||
if [ "$(uname -s)" != 'Darwin' ]; then
|
||||
# codesign, notarytool and stapler are Apple tooling and exist nowhere else. The build and even the
|
||||
# .app bundle cross-compile fine from Windows or Linux — `vpk [osx] bundle` does exactly that, and
|
||||
# ci.yml uses it to prove the bundle still builds — but a signed, notarized, installable package
|
||||
# cannot be produced anywhere but here.
|
||||
stop_with 'This builds a signed macOS package and has to run on macOS.'
|
||||
fi
|
||||
|
||||
for tool in dotnet git xcrun codesign; do
|
||||
command -v "$tool" >/dev/null 2>&1 || stop_with "$tool is not on PATH."
|
||||
done
|
||||
|
||||
# Checked before anything is built rather than at the step that uses them. Notarization is the last thing
|
||||
# this script does and the slowest, and discovering there that a profile name was never exported means
|
||||
# throwing away a full build and test run.
|
||||
for required in DODOSSH_SIGN_APP_IDENTITY DODOSSH_SIGN_INSTALL_IDENTITY DODOSSH_NOTARY_PROFILE; do
|
||||
if [ -z "${!required-}" ]; then
|
||||
stop_with "$required is not set. See the header of this script for what the three are and how to make them."
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# ---- What is being released ---------------------------------------------------------------------------
|
||||
|
||||
# Restored before the version is read, and both halves are load-bearing — the same two traps the Windows
|
||||
# script documents. -t:MinVer, because -getProperty alone evaluates the project and runs no targets, while
|
||||
# MinVer sets Version from inside one, so the read would answer the SDK's default 1.0.0 regardless of the
|
||||
# tag. And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
|
||||
# clone where obj/ has no MinVer targets to import yet.
|
||||
write_step 'Restoring the desktop head, so the version can be read'
|
||||
dotnet restore "$PROJECT" --locked-mode || stop_with 'Restore failed.'
|
||||
|
||||
VERSION="$(dotnet msbuild "$PROJECT" -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
|
||||
[ -n "$VERSION" ] || stop_with 'Could not read the version from MSBuild.'
|
||||
|
||||
TAG="v$VERSION"
|
||||
|
||||
# Apple's two version keys take one to three dot-separated integers and nothing else, so a prerelease
|
||||
# version has to have its suffix removed before it reaches the plist. 1.2.3-rc.1 becomes 1.2.3.
|
||||
#
|
||||
# The full version, suffix and all, is what vpk packs and what the release index carries, so the updater
|
||||
# still tells an rc from the release it precedes. These two keys are for Finder and Gatekeeper, which
|
||||
# care that the string parses and not what it says. See build/macos/Info.plist.template.
|
||||
PLIST_VERSION="${VERSION%%-*}"
|
||||
PLIST_VERSION="${PLIST_VERSION%%+*}"
|
||||
|
||||
write_step "DodoSSH $VERSION ($PACK_ID, channel $CHANNEL, $RUNTIME)"
|
||||
|
||||
# ---- Phase 2: publish what phase 1 built --------------------------------------------------------------
|
||||
|
||||
if [ "$UPLOAD" -eq 1 ]; then
|
||||
# The installer package is the artefact a person downloads, so its absence is the honest test of
|
||||
# whether phase one ever ran. A directory holding only a .nupkg is a pack that failed part way.
|
||||
if ! ls "$RELEASES_DIR"/*.pkg >/dev/null 2>&1; then
|
||||
stop_with "Nothing to upload: $RELEASES_DIR has no .pkg. Run this without --upload first."
|
||||
fi
|
||||
|
||||
echo "About to publish the contents of $RELEASES_DIR to $REPO_URL as $TAG."
|
||||
echo 'Only do this once you have installed it and walked Phase 18 of docs/manual-checks.md.'
|
||||
|
||||
# -s so the token is never echoed and never lands in the shell's history.
|
||||
printf 'Gitea token (write:repository): '
|
||||
read -r -s TOKEN
|
||||
echo
|
||||
|
||||
[ -n "$TOKEN" ] || stop_with 'No token given.'
|
||||
|
||||
# --merge because Gitea already has a release entry for the pushed tag — and on this platform it may
|
||||
# also already hold the Windows package for the same tag, which is the case --merge is really doing
|
||||
# the work for: without it the second platform to publish a given version fails on a release that
|
||||
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
|
||||
UPLOAD_ARGS=(
|
||||
upload gitea
|
||||
--repoUrl "$REPO_URL"
|
||||
--token "$TOKEN"
|
||||
--outputDir "$RELEASES_DIR"
|
||||
--channel "$CHANNEL"
|
||||
--releaseName "$TAG"
|
||||
--tag "$TAG"
|
||||
--merge
|
||||
--publish
|
||||
)
|
||||
|
||||
# Mirrors the rule the docker image job and the Windows script already apply to the same tag, so a
|
||||
# release candidate is a prerelease in every channel or in none.
|
||||
case "$VERSION" in
|
||||
*-*) UPLOAD_ARGS+=(--pre) ;;
|
||||
esac
|
||||
|
||||
write_step 'Uploading'
|
||||
dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'
|
||||
|
||||
write_step "Published $TAG."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- Phase 1: build, sign, notarize, pack -------------------------------------------------------------
|
||||
|
||||
[ -z "$(git status --porcelain)" ] || stop_with 'The working tree is not clean. A release is cut from a commit, not from a desk.'
|
||||
|
||||
HEAD_TAG="$(git describe --exact-match --tags HEAD 2>/dev/null || true)"
|
||||
[ -n "$HEAD_TAG" ] || stop_with "HEAD is not tagged. Tag it $TAG first, or change the version and tag that."
|
||||
|
||||
# Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the day
|
||||
# somebody pins a version by hand this is the guard that notices.
|
||||
[ "$HEAD_TAG" = "$TAG" ] || stop_with "HEAD is tagged $HEAD_TAG but the computed version is $VERSION."
|
||||
|
||||
write_step 'Restoring tools'
|
||||
dotnet tool restore || stop_with 'dotnet tool restore failed.'
|
||||
|
||||
write_step 'Restoring packages (locked, exactly as CI does)'
|
||||
dotnet restore "$SOLUTION" --locked-mode || stop_with 'Restore failed. A lock file that only works on Linux fails here.'
|
||||
|
||||
write_step 'Building'
|
||||
dotnet build "$SOLUTION" --no-restore --configuration Release || stop_with 'Build failed.'
|
||||
|
||||
if [ "$SKIP_TESTS" -eq 0 ]; then
|
||||
# The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
|
||||
# on trust from CI, because a tag is the one build nobody is watching — and on this platform there is
|
||||
# a second reason: CI has no macOS runner, so this is the only place the suite ever runs on a Mac at
|
||||
# all. Everything docs/platform-flags.md lists as unverified on macOS is verified here or nowhere.
|
||||
write_step 'Testing'
|
||||
dotnet test "$SOLUTION" --no-build --configuration Release || stop_with 'Tests failed.'
|
||||
fi
|
||||
|
||||
write_step "Publishing $RUNTIME"
|
||||
rm -rf "$PUBLISH_DIR"
|
||||
|
||||
# Self-contained, and not single-file, for the reasons the Windows script gives: the native libraries ship
|
||||
# per RID and a self-extracting bundle breaks delta updates.
|
||||
#
|
||||
# RestoreLockedMode=false, and the lock files put back straight afterwards. A RID-specific publish resolves
|
||||
# a graph the committed lock files do not describe, because they are deliberately kept RID-free —
|
||||
# declaring a RID on the head writes a net10.0/<rid> target into every project it references transitively,
|
||||
# including DodoSSH.Contracts and DodoSSH.Crypto, and the API's Dockerfile then restores those with no RID
|
||||
# under locked mode and fails NU1004. Packaging the desktop client would have broken the server's image
|
||||
# build. The gate that matters is the locked solution restore above, which is untouched.
|
||||
dotnet publish "$PROJECT" \
|
||||
--configuration Release \
|
||||
--runtime "$RUNTIME" \
|
||||
--self-contained true \
|
||||
--output "$PUBLISH_DIR" \
|
||||
-p:RestoreLockedMode=false \
|
||||
|| stop_with 'Publish failed.'
|
||||
|
||||
# An unlocked restore rewrites the lock files it walked. Left there, the next commit would carry exactly
|
||||
# the change that breaks the image build. Safe to do bluntly because this script refuses to run on a dirty
|
||||
# tree, so anything modified here is its own.
|
||||
git checkout -- '*packages.lock.json' || stop_with 'Could not restore the lock files after publishing.'
|
||||
|
||||
# Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for an
|
||||
# application that never checks for updates — which looks completely normal until the next release goes out
|
||||
# and nobody receives it.
|
||||
for required in DodoSSH Velopack.dll; do
|
||||
[ -e "$PUBLISH_DIR/$required" ] || stop_with "$required is missing from $PUBLISH_DIR."
|
||||
done
|
||||
|
||||
echo " $(du -sh "$PUBLISH_DIR" | cut -f1) in $(find "$PUBLISH_DIR" -type f | wc -l | tr -d ' ') files"
|
||||
|
||||
# ---- Signing the native libraries, before vpk signs anything ------------------------------------------
|
||||
|
||||
# ◆ THIS LOOP IS WHY NOTARIZATION SUCCEEDS, AND IT LOOKS REDUNDANT.
|
||||
#
|
||||
# vpk signs the finished bundle itself, with `codesign -f -v --timestamp --options runtime --entitlements
|
||||
# <file> --deep`, and --deep is documented by Apple as the wrong way to sign nested code. Apple's guidance
|
||||
# is inside-out: sign each nested binary first, then the bundle around it. --deep does the reverse in one
|
||||
# pass and applies the outer entitlements to everything it touches.
|
||||
#
|
||||
# In practice --deep alone is where the failure recorded in docs/platform-flags.md comes from — a
|
||||
# notarization rejection that does not name the offending file, on a submission that took its time getting
|
||||
# there. Signing each dylib properly first means vpk's pass has nothing left to get wrong, and re-signing
|
||||
# an already correctly signed binary with -f is a no-op in effect.
|
||||
#
|
||||
# No --entitlements here, and that is the difference that matters. Entitlements belong on the main
|
||||
# executable; a dylib carrying allow-jit is at best meaningless and at worst a rejection.
|
||||
write_step 'Signing native libraries'
|
||||
|
||||
# createdump is a Mach-O executable the runtime ships and it is signed like the libraries: a nested
|
||||
# executable that is not signed fails notarization exactly as an unsigned dylib does, and it is the one
|
||||
# people forget because it has no extension to grep for.
|
||||
NATIVE_COUNT=0
|
||||
while IFS= read -r -d '' binary; do
|
||||
codesign --force --verbose=0 --timestamp --options runtime \
|
||||
--sign "$DODOSSH_SIGN_APP_IDENTITY" "$binary" \
|
||||
|| stop_with "codesign failed on $binary"
|
||||
NATIVE_COUNT=$((NATIVE_COUNT + 1))
|
||||
done < <(find "$PUBLISH_DIR" \( -name '*.dylib' -o -name 'createdump' \) -type f -print0)
|
||||
|
||||
[ "$NATIVE_COUNT" -gt 0 ] || stop_with "No native binaries found under $PUBLISH_DIR, which cannot be right for a self-contained publish."
|
||||
echo " signed $NATIVE_COUNT native binaries"
|
||||
|
||||
# ---- The bundle's Info.plist --------------------------------------------------------------------------
|
||||
|
||||
# Rendered rather than committed, because vpk copies a custom plist verbatim and substitutes nothing —
|
||||
# so a committed one would carry whatever version it was written with into every release afterwards.
|
||||
# See the header of build/macos/Info.plist.template.
|
||||
write_step "Rendering Info.plist for $PLIST_VERSION"
|
||||
RENDERED_PLIST="$(mktemp -t dodossh-plist)"
|
||||
trap 'rm -f "$RENDERED_PLIST"' EXIT
|
||||
|
||||
sed "s/@VERSION@/$PLIST_VERSION/g" "$PLIST_TEMPLATE" > "$RENDERED_PLIST"
|
||||
|
||||
# The placeholder is the whole mechanism, so its absence is checked rather than hoped for. A template
|
||||
# somebody edited into a literal version would otherwise sail through and pin every future release to it.
|
||||
grep -q '@VERSION@' "$PLIST_TEMPLATE" || stop_with "$PLIST_TEMPLATE has no @VERSION@ placeholder left in it."
|
||||
! grep -q '@VERSION@' "$RENDERED_PLIST" || stop_with 'Substitution into the rendered Info.plist did not take.'
|
||||
|
||||
mkdir -p "$RELEASES_DIR"
|
||||
|
||||
# The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
|
||||
# macOS release has no predecessor, and a hard failure here would make cutting it impossible.
|
||||
write_step 'Fetching the previous release, for deltas'
|
||||
if ! dotnet vpk download gitea --repoUrl "$REPO_URL" --outputDir "$RELEASES_DIR" --channel "$CHANNEL"; then
|
||||
echo ' Nothing came down. This package will be full-only, which is right for a first release.'
|
||||
fi
|
||||
|
||||
# ---- Pack, sign, notarize, staple ---------------------------------------------------------------------
|
||||
|
||||
# One command does the rest, and it is worth knowing what it is doing on your behalf, because the slow
|
||||
# part is not local: it builds the .app from the published files, signs it with the Developer ID
|
||||
# certificate and the entitlements below, submits it to Apple with `xcrun notarytool submit --wait`,
|
||||
# staples the resulting ticket to the package, and then builds the .pkg installer and the release index.
|
||||
#
|
||||
# The notarization wait is the reason this step can take a quarter of an hour and occasionally much
|
||||
# longer — it is a queue at Apple, not a computation here, and vpk's own message says so.
|
||||
#
|
||||
# --signInstallIdentity is a different certificate from --signAppIdentity, and the pair is not
|
||||
# interchangeable: "Developer ID Application" signs the bundle, "Developer ID Installer" signs the .pkg.
|
||||
# Passing one where the other belongs fails with a message about an identity that cannot be found, which
|
||||
# reads like a keychain problem rather than like the wrong certificate.
|
||||
write_step 'Packing, signing and notarizing (the notarization wait is Apple queueing, not this machine)'
|
||||
|
||||
dotnet vpk pack \
|
||||
--packId "$PACK_ID" \
|
||||
--packVersion "$VERSION" \
|
||||
--packDir "$PUBLISH_DIR" \
|
||||
--packTitle "$PACK_TITLE" \
|
||||
--packAuthors "$PACK_AUTHORS" \
|
||||
--mainExe 'DodoSSH' \
|
||||
--icon "$ICON" \
|
||||
--plist "$RENDERED_PLIST" \
|
||||
--entitlements "$ENTITLEMENTS" \
|
||||
--signAppIdentity "$DODOSSH_SIGN_APP_IDENTITY" \
|
||||
--signInstallIdentity "$DODOSSH_SIGN_INSTALL_IDENTITY" \
|
||||
--notaryProfile "$DODOSSH_NOTARY_PROFILE" \
|
||||
--runtime "$RUNTIME" \
|
||||
--channel "$CHANNEL" \
|
||||
--outputDir "$RELEASES_DIR" \
|
||||
|| stop_with 'vpk pack failed.'
|
||||
|
||||
# ---- Did the notarization actually take? --------------------------------------------------------------
|
||||
|
||||
# Asked rather than assumed, and this is the check worth having above all the others. A package whose
|
||||
# ticket did not staple is indistinguishable from a good one on the machine that built it — the Mac that
|
||||
# signed something trusts it locally — and reveals itself only on somebody else's machine, as a refusal
|
||||
# to open at all. spctl assesses it the way Gatekeeper will on a machine that has never seen this
|
||||
# certificate.
|
||||
write_step 'Verifying the notarization the way another Mac will'
|
||||
|
||||
PKG="$(ls -t "$RELEASES_DIR"/*.pkg 2>/dev/null | head -n 1)"
|
||||
[ -n "$PKG" ] || stop_with 'vpk pack reported success but produced no .pkg.'
|
||||
|
||||
if ! spctl --assess --type install --verbose=4 "$PKG"; then
|
||||
stop_with "Gatekeeper rejects $PKG. It is signed but the notarization ticket is missing or stale; do not upload it."
|
||||
fi
|
||||
|
||||
xcrun stapler validate "$PKG" || stop_with "The notarization ticket is not stapled to $PKG."
|
||||
|
||||
write_step 'Built, notarized, and deliberately not uploaded'
|
||||
|
||||
ls -lh "$RELEASES_DIR" | tail -n +2
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Next:
|
||||
1. Install the .pkg above and walk Phase 18 of docs/manual-checks.md.
|
||||
2. Then: bash scripts/release-macos.sh --upload
|
||||
EOF
|
||||
@@ -46,8 +46,11 @@ internal sealed class SessionKeepAlive : IDisposable
|
||||
this.activeTransfers = activeTransfers;
|
||||
this.holdsFileSession = holdsFileSession;
|
||||
|
||||
// Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is
|
||||
// a binder call and needs no particular thread. Nothing here touches the interface.
|
||||
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
|
||||
// closer's own — which is fine: starting and stopping a service is a binder call and needs no
|
||||
// particular thread. Nothing here touches the interface. That the announcement waits for the run to
|
||||
// actually complete, and comes for deliberate closes too, is what makes reading LiveSessionCount
|
||||
// from it honest — the event's own remark carries the stuck notification that taught us both.
|
||||
workspace.SessionEnded += OnSessionEnded;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using global::Android.Views;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Hands native focus back to the terminal's WebView after Avalonia chrome took it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> the
|
||||
/// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does
|
||||
/// not own. The accessory row's keys are already <c>Focusable=false</c> — see TerminalScreen — so Avalonia's
|
||||
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>:
|
||||
/// <c>AvaloniaView.DispatchTouchEvent</c> (decompiled from Avalonia.Android 12.1.1) ends every handled
|
||||
/// touch — DOWN and UP alike — with a <c>RequestFocus()</c> for Avalonia's own view. The WebView's input
|
||||
/// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text,
|
||||
/// and the inset churn that follows can leave it sitting on top of the very row that was tapped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.</b>
|
||||
/// The first version called <c>RequestFocus()</c> from the keys' own Click handlers, which fire
|
||||
/// <em>inside</em> the UP event's dispatch — and the platform's own request runs <em>after</em> dispatch
|
||||
/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
|
||||
/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
|
||||
/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
|
||||
/// about to be made stale by the very mechanism this exists to counter.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining
|
||||
/// native focus re-establishes the same input connection and the keyboard settles back to what it was.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, because the
|
||||
/// control does not expose its platform child and this application only ever has the one WebView — the
|
||||
/// walk's first match is necessarily the terminal. Every step is allowed to be absent, exactly as
|
||||
/// <see cref="SoftKeyboard.Hide"/>'s are: no activity while backgrounded, no WebView while the terminal
|
||||
/// surface has never been shown, and nothing to do in either case.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class TerminalFocus
|
||||
{
|
||||
public static void Return()
|
||||
{
|
||||
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (FindWebView(decor) is not { } webView)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
webView.Post(() =>
|
||||
{
|
||||
if (!webView.IsFocused)
|
||||
{
|
||||
webView.RequestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static View? FindWebView(ViewGroup parent)
|
||||
{
|
||||
for (var i = 0; i < parent.ChildCount; i++)
|
||||
{
|
||||
switch (parent.GetChildAt(i))
|
||||
{
|
||||
case global::Android.Webkit.WebView webView:
|
||||
return webView;
|
||||
|
||||
case ViewGroup child when FindWebView(child) is { } found:
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -116,11 +116,27 @@
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<!-- A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse. -->
|
||||
<!--
|
||||
A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse.
|
||||
|
||||
◆ VerticalContentAlignment, because that height is the whole point of this class and Avalonia's default
|
||||
for content alignment is Stretch — so the content presenter stretched the caption to the full row and a
|
||||
TextBlock draws its line at the TOP of what it is given. Most rows here never showed it, having a
|
||||
StackPanel or a Grid of already-centred children in them, which is what made the four that did look
|
||||
like four unrelated mistakes: the breadcrumb chips and the up-one-directory button on FilesScreen, and
|
||||
TerminalScreen's CLOSE THIS TAB, each a bare TextBlock in a row 36 or 44 tall with no vertical padding.
|
||||
Measured at those numbers, the caption sat flush against the top edge with 21 to 33 pixels below it.
|
||||
|
||||
The desktop head's App.axaml carries the same setter on its own shapes for the same reason, and excludes
|
||||
two of them — see the remark on Button.ghost there. Nothing is excluded here: no row's content depends
|
||||
on being stretched, there being no full-height strip inside any of the thirty-three, and the Grids that
|
||||
stop filling hold only children that already centre themselves, so they land where they always did.
|
||||
-->
|
||||
<Style Selector="Button.row">
|
||||
<Setter Property="MinHeight" Value="54" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
@@ -420,6 +436,20 @@
|
||||
<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>
|
||||
|
||||
<!-- 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,7 +48,7 @@
|
||||
the refusal has no continue button here either.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
||||
@@ -174,8 +174,31 @@
|
||||
</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 IsConnected}">
|
||||
<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>
|
||||
|
||||
<!-- ============ where we are ============ -->
|
||||
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
<ScrollViewer Grid.Row="4" 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"
|
||||
@@ -215,7 +238,7 @@
|
||||
answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as
|
||||
"this host has nothing pinned".
|
||||
-->
|
||||
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
<ScrollViewer Grid.Row="5" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
|
||||
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -240,7 +263,7 @@
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ the listing ============ -->
|
||||
<Panel Grid.Row="5">
|
||||
<Panel Grid.Row="6">
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
||||
VerticalAlignment="Top" Text="{Binding Status}" />
|
||||
@@ -306,7 +329,7 @@
|
||||
|
||||
<!-- ============ what to do with the chosen entry ============ -->
|
||||
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
|
||||
<Border Grid.Row="6" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
||||
<Border Grid.Row="7" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
@@ -328,6 +351,36 @@
|
||||
</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 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
|
||||
@@ -386,20 +439,32 @@
|
||||
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
||||
a file is selected and something is connected — and is reused rather than restated here.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
||||
IsEnabled="{Binding CanDownload}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||
<!--
|
||||
◆ 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>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
|
||||
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
||||
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||
|
||||
@@ -1029,6 +1029,48 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
|
||||
moment one is wanted is while deciding how a host authenticates, and this head has no keychain
|
||||
editor for credentials at all — so without this a phone could bind a host to a credential but
|
||||
never make one. Writes to the keychain the instant ADD is pressed, exactly as the new-tag box
|
||||
below does and for the same reason: a host can only name an id that exists.
|
||||
-->
|
||||
<Button Classes="secondary" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
|
||||
MinHeight="40" Padding="14,0"
|
||||
IsVisible="{Binding !IsAddingEditorCredential}"
|
||||
Command="{Binding BeginEditorCredentialCommand}" />
|
||||
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
|
||||
IsVisible="{Binding IsAddingEditorCredential}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="NEW CREDENTIAL" />
|
||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialLabel}"
|
||||
PlaceholderText="name" />
|
||||
<!--
|
||||
Optional, and what makes a credential its own item: one account on twenty machines is
|
||||
rotated in one place. Left blank, this host's own username is used.
|
||||
-->
|
||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialUsername}"
|
||||
PlaceholderText="username (blank: this host's own)" />
|
||||
<TextBox Classes="field secret" Text="{Binding EditorNewCredentialPassword}"
|
||||
PlaceholderText="password" />
|
||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialNotes}"
|
||||
PlaceholderText="notes" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||
Text="Added to the keychain as soon as you press ADD, so it stays even if you leave this host without saving." />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Content="ADD" MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding AddEditorCredentialCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Content="CANCEL" MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding CancelEditorCredentialCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than
|
||||
one vault that can be written to, exactly as on the desktop — an existing host's vault is not a
|
||||
|
||||
@@ -320,8 +320,14 @@
|
||||
which was true when a tab could not exist without a session; one can now —
|
||||
connecting opens the tab first — and a dot that was green before anything had
|
||||
answered would be the one thing on this strip claiming something untrue.
|
||||
|
||||
Amber while it is being made, which is the other half of that correction. Not being
|
||||
green stopped the dot lying, but it left a tab still dialling drawn exactly like a
|
||||
tab whose shell has exited — the two states on this strip with the least in common,
|
||||
one worth waiting for and one over. See Phone.axaml.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -33,6 +33,85 @@
|
||||
gesture does the same thing the arrow does.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
|
||||
<!--
|
||||
── the connecting step list ─────────────────────────────────────────────────────────────────────
|
||||
The same five rows the desktop's ConnectingCard draws, from the same reported phases, in this head's
|
||||
own sizes. Kept here rather than in Phone.axaml because nothing else on this head has a step list —
|
||||
the theme file is for what more than one screen shares, and a rule that exists for one control is
|
||||
easier to read beside it.
|
||||
|
||||
Amber for the step in flight, green behind it, red where it stopped. That is the palette's rule
|
||||
rather than an exception to it: green is what is true and purple is what you can press, and a step
|
||||
still happening is neither. See ConnectingCard.axaml for the longer version of this argument, and
|
||||
Palette.axaml for the rule itself.
|
||||
|
||||
A phone needs this more than a desktop does, which is the same thing the connecting block below
|
||||
already says about itself: mobile links are slower and drop more often, so the stretch this describes
|
||||
is longer here and more likely to end badly.
|
||||
-->
|
||||
<!--
|
||||
Its own FontFamily rather than the row also carrying the mono class, which is this head's convention
|
||||
and not a stylistic preference: Phone.axaml's mono sets a colour and a size along with the family, so
|
||||
a caption wearing both classes would be asking two rules for one Foreground and settling it on style
|
||||
ordering. Every other text class here — body, label, title, detail — names its own family for exactly
|
||||
that reason. The desktop's mono sets the family alone, which is why ConnectingCard composes the two
|
||||
and this does not.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepcaption">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||
</Style>
|
||||
|
||||
<!-- Fixed width and centred: four different characters on a ragged edge is a list that looks broken. -->
|
||||
<Style Selector="TextBlock.stepmark">
|
||||
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Width" Value="13" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
4 rather than the desktop's 5, which is the only deliberate difference between the two heads here:
|
||||
this bar sits in a column 24 from each edge of a 360dp screen rather than under a 460-wide card, so
|
||||
the same height reads as a heavier rule across a narrower span.
|
||||
-->
|
||||
<Style Selector="ProgressBar.steptrack">
|
||||
<Setter Property="Height" Value="4" />
|
||||
<Setter Property="MinHeight" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="2" />
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="ProgressBar.steptrack.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
</UserControl.Styles>
|
||||
|
||||
<Panel>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
@@ -97,8 +176,12 @@
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
<!--
|
||||
Green only while there is a shell behind it, amber while one is being made; see
|
||||
the same dot in PhoneShell, and Phone.axaml for why amber is not a rule broken.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||
Text="{Binding Label}" />
|
||||
@@ -284,11 +367,70 @@
|
||||
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
||||
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Where a single unchanging "connecting…" used to be. The track counts steps that really finished
|
||||
against the five there are — StepsDone over StepCount, never a percentage, because the arithmetic
|
||||
that makes a percentage is the arithmetic that starts inventing one. See TerminalTabViewModel.
|
||||
|
||||
Drawn for both states rather than once per state: a refused connection has the same five rows and
|
||||
the same track, and the only differences are that one row is red and the track stops where it got
|
||||
to. Two templates kept identical for the sake of a colour is how the two drift apart.
|
||||
-->
|
||||
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="6" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9">
|
||||
<TextBlock Classes="stepmark"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Mark}" />
|
||||
<TextBlock Classes="stepcaption"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Caption}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
Only for a refusal now. While a connection is being made this used to be the whole of what this
|
||||
screen said, and it is now the step list's running row said twice — so it is shown for the one
|
||||
state the list cannot put into words: why it stopped.
|
||||
-->
|
||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}"
|
||||
Foreground="{StaticResource Danger}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
|
||||
<!--
|
||||
Two 44-high targets side by side rather than one, and the second is the logs: the step list is
|
||||
this attempt and the log is every other one, which is the question a connection that is taking too
|
||||
long on a mobile link actually raises — has this machine ever worked from here. Reached the
|
||||
ordinary way, through ShowScreenCommand, exactly as the rail and MORE reach it.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Left">
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||
</Button>
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="SHOW LOGS" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -139,48 +139,18 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
{
|
||||
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
||||
|
||||
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
|
||||
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
|
||||
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
|
||||
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
|
||||
// length of the press; and the Return is posted past the current dispatch, so its ordering against
|
||||
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
|
||||
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||
|
||||
foreach (var (label, bytes, latches) in Keys)
|
||||
{
|
||||
var key = new Button
|
||||
{
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
|
||||
// 44 wide, and that is the number that matters: the design draws these flexed across the
|
||||
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
|
||||
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
|
||||
// costs nothing a width does: the keys are a single row with the terminal above and the
|
||||
// system's gesture bar below, so there is no neighbour a short press can land on instead.
|
||||
MinWidth = 44,
|
||||
Height = 30,
|
||||
Padding = new Thickness(10, 0),
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Background = Palette("Panel"),
|
||||
BorderBrush = Palette("BorderMid"),
|
||||
BorderThickness = new Thickness(1),
|
||||
Foreground = Palette("TextDim"),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
|
||||
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
||||
//
|
||||
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
||||
// ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView
|
||||
// — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing
|
||||
// Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next
|
||||
// thing typed on a hardware keyboard went nowhere, and the row went on working because its
|
||||
// buttons are pressed rather than typed into, which is what makes it look like the terminal
|
||||
// had died instead.
|
||||
//
|
||||
// Focusable=false is what a toolbar button is, and it means the focused element never
|
||||
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
||||
Focusable = false,
|
||||
};
|
||||
var key = CreateKey(label);
|
||||
|
||||
if (latches)
|
||||
{
|
||||
@@ -196,6 +166,57 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One key of the accessory row, before its click is wired.</summary>
|
||||
/// <remarks>Split from <see cref="BuildAccessoryRow"/> for length rather than for reuse.</remarks>
|
||||
private static Button CreateKey(string label) =>
|
||||
new()
|
||||
{
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
|
||||
// 44 wide, and that is the number that matters: the design draws these flexed across the
|
||||
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
|
||||
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
|
||||
// costs nothing a width does: the keys are a single row with the terminal above and the
|
||||
// system's gesture bar below, so there is no neighbour a short press can land on instead.
|
||||
MinWidth = 44,
|
||||
Height = 30,
|
||||
Padding = new Thickness(10, 0),
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Background = Palette("Panel"),
|
||||
BorderBrush = Palette("BorderMid"),
|
||||
BorderThickness = new Thickness(1),
|
||||
Foreground = Palette("TextDim"),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
|
||||
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
||||
//
|
||||
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
||||
// ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView
|
||||
// — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing
|
||||
// Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next
|
||||
// thing typed on a hardware keyboard went nowhere, and the row went on working because its
|
||||
// buttons are pressed rather than typed into, which is what makes it look like the terminal
|
||||
// had died instead.
|
||||
//
|
||||
// Focusable=false is what a toolbar button is, and it means the focused element never
|
||||
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
||||
//
|
||||
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
|
||||
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
|
||||
// decides about its element — taking the keyboard's input connection off the terminal and
|
||||
// swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
|
||||
// that half back; see TerminalFocus for the whole story, including why the handing back has
|
||||
// to be posted rather than done inline.
|
||||
Focusable = false,
|
||||
};
|
||||
|
||||
private void ToggleControl()
|
||||
{
|
||||
controlLatched = !controlLatched;
|
||||
|
||||
@@ -74,9 +74,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
@@ -984,7 +984,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -1044,9 +1044,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -1140,11 +1140,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
@@ -463,6 +464,7 @@
|
||||
<Setter Property="Padding" Value="13,7" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
|
||||
@@ -473,26 +475,56 @@
|
||||
<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" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<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
|
||||
@@ -591,10 +623,13 @@
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5b: THE HOST HEADER'S "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ───────────────────────────────
|
||||
── v5b: THE "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ─────────────────────────────────────────────
|
||||
A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c>
|
||||
rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it
|
||||
no filled hover, only the border turning to the accent. See <c>SessionHeader.axaml</c>.
|
||||
no filled hover, only the border turning to the accent.
|
||||
|
||||
Named for the host header it was drawn for, which v5c-4 retired; the button itself moved intact to the
|
||||
head of the session sidebar and is stretched across that column there. See <c>SessionSidebar.axaml</c>.
|
||||
-->
|
||||
<Style Selector="Button.headerghost">
|
||||
<Setter Property="Height" Value="32" />
|
||||
@@ -640,6 +675,30 @@
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5c-4: THE SIDEBAR'S OWN CLOSE AND REOPEN ────────────────────────────────────────────────────────
|
||||
One class for both, because they are one control in two states — a 26-pixel square carrying a chevron,
|
||||
at the head of the column when it is open and at the head of the rail when it is not. Square rather
|
||||
than the 33-tall rows below it: it is chrome belonging to the panel, not an entry in the list the panel
|
||||
is holding, and matching the rows' shape would have offered it as one.
|
||||
-->
|
||||
<Style Selector="Button.sidebargrip">
|
||||
<Setter Property="Width" Value="26" />
|
||||
<Setter Property="Height" Value="26" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.sidebargrip /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.sidebargrip:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The "+ Pin folder" / "+ Add Snip" row at the foot of each section: 30 tall rather than 33, and its own
|
||||
quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections.
|
||||
@@ -671,6 +730,7 @@
|
||||
-->
|
||||
<Style Selector="Button.choice">
|
||||
<Setter Property="Padding" Value="10,5" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="LetterSpacing" Value="0.5" />
|
||||
@@ -768,6 +828,41 @@
|
||||
<Setter Property="BorderThickness" Value="1.5" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ THE FILE PANE'S PATH BOX, which used to be a TextBlock.
|
||||
|
||||
The SFTP pane's header printed where it was and gave no way to say where to go: reaching a directory
|
||||
the breadcrumb could not see meant walking to a common ancestor and back down, one round trip a level,
|
||||
and a path somebody already knew could not be typed at all. It is a real TextBox now, and this style
|
||||
is the whole of what keeps it looking like the label it replaced — no fill and no border until it is
|
||||
pointed at, the same mono weight and colour the TextBlock carried, and the same height, so a header
|
||||
that gained an input did not gain a control that shouts about being one.
|
||||
|
||||
Declared after the base TextBox rules and after .address for the reason this file keeps restating:
|
||||
Avalonia has no specificity, so an exception written before the rules it overrides does nothing.
|
||||
-->
|
||||
<Style Selector="TextBox.pathbox">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="13.5" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="MinHeight" Value="26" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.pathbox /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.pathbox:pointerover /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
</Style>
|
||||
<Style Selector="TextBox.pathbox:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
||||
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
||||
@@ -853,18 +948,30 @@
|
||||
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
|
||||
rather than narrower.
|
||||
|
||||
◆ 224 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
||||
column at the window's minimum is 1016 less the rail's 190 and the drawer's 320, which is 506. The
|
||||
scrolling stack inside it takes 16 of margin on each side, and the vertical scrollbar takes its own —
|
||||
call the usable width 474. A WrapPanel fits floor(474 / (Width + 10)) per row, so two columns needs
|
||||
Width no more than 227.
|
||||
◆ 214 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
||||
column at the window's minimum is 1081 less the rail's 255 and the drawer's 320, which is 506. The
|
||||
scrolling stack inside it takes 26 of margin on each side, and the vertical scrollbar takes its own —
|
||||
call the usable width 454. A WrapPanel fits floor(454 / (Width + 10)) per row, so two columns needs
|
||||
Width no more than 217, and 214 is that with the same few pixels of slack the previous number kept.
|
||||
|
||||
The first number here was 248, from the same reasoning with the two margins left out. It laid out
|
||||
cleanly and the layout harness passed it, because the harness asks whether a control is inside the
|
||||
window and not how many of them fit on a line — so the grid quietly became one column wide at exactly
|
||||
the size this application guarantees, which is the shape the cards exist to avoid. The second was 232,
|
||||
derived the same way against the drawer's own 304; v5 widened the drawer to 320 for the ADDRESS field's
|
||||
breathing room, which narrowed the budget this number is drawn from and had to move it down in step.
|
||||
Every number here has moved at least once, and always because something beside the cards did:
|
||||
|
||||
· 248, from this reasoning with the two margins left out. It laid out cleanly and the layout harness
|
||||
passed it, because the harness asks whether a control is inside the window and not how many of them
|
||||
fit on a line — so the grid quietly became one column wide at exactly the size this application
|
||||
guarantees, which is the shape the cards exist to avoid.
|
||||
· 232, derived against the drawer's own 304, which v5 widened to 320 for the ADDRESS field's breathing
|
||||
room — narrowing the budget this number is drawn from and moving it down in step.
|
||||
· 224, which is what that gave. The stated arithmetic still said 1016 and 190 by then: v5b's rail took
|
||||
190 to 255 and the window's minimum 1016 to 1081 in the same pass, so the two changes cancelled and
|
||||
the answer stayed right while the working went stale.
|
||||
· 214, now that HostsScreen's board is inset 26 a side rather than 16 — see that file's own remark on
|
||||
why every screen frames its content the same way. Twenty pixels of board is twenty pixels the cards
|
||||
no longer have, and this is where they come from.
|
||||
|
||||
◆ THE TEST THAT CATCHES THIS IS NOT THE HARNESS. See
|
||||
ScreenLayoutTests.TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen, which counts columns
|
||||
because that is the thing this number exists to buy and the thing no fit assertion can see.
|
||||
-->
|
||||
<Style Selector="Border.tile">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
@@ -872,7 +979,7 @@
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Width" Value="224" />
|
||||
<Setter Property="Width" Value="214" />
|
||||
<Setter Property="Margin" Value="0,0,10,10" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover Border.tile">
|
||||
@@ -1120,6 +1227,7 @@
|
||||
<Style Selector="Button.panechip">
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
@@ -1164,6 +1272,16 @@
|
||||
<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>
|
||||
|
||||
<!--
|
||||
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.
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
# Regenerates dodossh.ico from the same geometry the Android launcher icon draws.
|
||||
# Regenerates dodossh.ico and dodossh.icns from the same geometry the Android launcher icon draws.
|
||||
#
|
||||
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
|
||||
# reason it is a vector is that there is then one geometry to change and no set of PNG densities
|
||||
# to forget one of. Windows will not take a vector: <ApplicationIcon> wants an .ico and nothing
|
||||
# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays
|
||||
# honest: the numbers below are the ones in that XML, and regenerating is the whole edit.
|
||||
# to forget one of. Neither desktop platform will take a vector: <ApplicationIcon> wants an .ico
|
||||
# and nothing else, Window.Icon wants a bitmap, and vpk wants an .icns for the macOS bundle. So
|
||||
# the rasters exist, and this script is how they stay honest: the numbers below are the ones in
|
||||
# that XML, and regenerating is the whole edit.
|
||||
#
|
||||
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
|
||||
#
|
||||
# Both outputs are written every run, deliberately. Two scripts, or one script with a switch,
|
||||
# is how the two files come to be drawn from different geometry — which nobody would notice,
|
||||
# because no one person looks at a Windows taskbar and a macOS Dock on the same afternoon.
|
||||
#
|
||||
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
|
||||
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
|
||||
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
|
||||
@@ -30,29 +35,49 @@ $ink = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') # AccentInk
|
||||
# the gap, and its downsampler is not kind to a hairline.
|
||||
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||
|
||||
function New-MarkPng([int]$size)
|
||||
# $tileFraction is how much of the canvas the accent tile fills, and it is the one number that
|
||||
# differs between the two platforms.
|
||||
#
|
||||
# Windows passes 1.0: the tile bleeds to the edge, because Windows draws application icons at
|
||||
# whatever size they come in and every other icon on the taskbar does the same.
|
||||
#
|
||||
# macOS passes 0.8047, and that is not taste. Apple's icon grid puts a rounded-rect app icon in
|
||||
# an 824-pixel square inside a 1024-pixel canvas — 824/1024 — with the remaining hundred pixels a
|
||||
# side left as air for the Dock's shadow and its magnification. An icon that ignores the grid and
|
||||
# bleeds to the edge does not read as bold; it reads as the one icon in the Dock that is too big,
|
||||
# because it sits beside Finder and Safari which do not.
|
||||
function New-MarkPng([int]$size, [double]$tileFraction = 1.0)
|
||||
{
|
||||
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||
|
||||
# The tile, and the inset that centres it when it does not fill the canvas.
|
||||
$tile = [double]$size * $tileFraction
|
||||
$inset = ([double]$size - $tile) / 2.0
|
||||
|
||||
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
|
||||
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
|
||||
$radius = [double]$size * 0.22
|
||||
#
|
||||
# 0.22 of the tile rather than of the canvas, so the corner keeps its proportion to the shape
|
||||
# it is rounding instead of growing as the air around it does. It is also within a whisker of
|
||||
# the 185/824 Apple's own grid specifies, which is why one radius serves both files.
|
||||
$radius = $tile * 0.22
|
||||
$d = $radius * 2.0
|
||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$path.AddArc(0.0, 0.0, $d, $d, 180, 90)
|
||||
$path.AddArc($size - $d, 0.0, $d, $d, 270, 90)
|
||||
$path.AddArc($size - $d, $size - $d, $d, $d, 0, 90)
|
||||
$path.AddArc(0.0, $size - $d, $d, $d, 90, 90)
|
||||
$path.AddArc($inset, $inset, $d, $d, 180, 90)
|
||||
$path.AddArc($inset + $tile - $d, $inset, $d, $d, 270, 90)
|
||||
$path.AddArc($inset + $tile - $d, $inset + $tile - $d, $d, $d, 0, 90)
|
||||
$path.AddArc($inset, $inset + $tile - $d, $d, $d, 90, 90)
|
||||
$path.CloseFigure()
|
||||
$brush = New-Object System.Drawing.SolidBrush($accent)
|
||||
$g.FillPath($brush, $path)
|
||||
|
||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge.
|
||||
$scale = [double]$size / 72.0
|
||||
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) }
|
||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge. Scaled to the tile and
|
||||
# offset by the inset, so the glyph keeps its place within the tile at either fraction.
|
||||
$scale = $tile / 72.0
|
||||
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale + $inset), (($y - 18.0) * $scale + $inset)) }
|
||||
|
||||
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
|
||||
# difference between a mark and a smudge. The phone's file already bumps this width for the
|
||||
@@ -117,3 +142,86 @@ $target = Join-Path $PSScriptRoot 'dodossh.ico'
|
||||
$w.Dispose(); $out.Dispose()
|
||||
|
||||
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
|
||||
|
||||
# ---- dodossh.icns, for the macOS bundle ----------------------------------------------------------
|
||||
#
|
||||
# Written here rather than by `iconutil` on a Mac, and that is the point of doing it the long way.
|
||||
# iconutil is the documented tool and it exists only on macOS, so an icon that needed it could not
|
||||
# be regenerated on the machine this project is developed on — the geometry above would change and
|
||||
# the .icns would quietly keep the old mark until somebody next opened a Mac. The container format
|
||||
# is a magic word, a length and a run of typed PNG chunks, which is little enough to own.
|
||||
#
|
||||
# ◆ EVERY LENGTH IN THIS FILE IS BIG-ENDIAN, AND BinaryWriter IS NOT.
|
||||
#
|
||||
# The one thing that will catch anybody editing this. A .icns written little-endian is not rejected
|
||||
# with an error — Finder and vpk both just show the placeholder icon, because the first chunk claims
|
||||
# a length of about two billion and the parser walks off the end and gives up. Hence Write-BE32.
|
||||
#
|
||||
# Type codes are Apple's, and the pairs are not redundant. ic08 and ic13 are both 256 pixels because
|
||||
# one is "256 at 1x" and the other is "128 at 2x", and a Retina display asked for the second will not
|
||||
# accept the first. Same for ic09/ic14 at 512. iconutil emits both from an .iconset for this reason,
|
||||
# so this does too.
|
||||
$icnsTypes = @(
|
||||
@{ Type = 'ic11'; Size = 32 } # 16@2x
|
||||
@{ Type = 'ic12'; Size = 64 } # 32@2x
|
||||
@{ Type = 'ic07'; Size = 128 } # 128@1x
|
||||
@{ Type = 'ic13'; Size = 256 } # 128@2x
|
||||
@{ Type = 'ic08'; Size = 256 } # 256@1x
|
||||
@{ Type = 'ic14'; Size = 512 } # 256@2x
|
||||
@{ Type = 'ic09'; Size = 512 } # 512@1x
|
||||
@{ Type = 'ic10'; Size = 1024 } # 512@2x
|
||||
)
|
||||
|
||||
# Apple's icon grid: an 824-pixel shape centred in a 1024-pixel canvas. See New-MarkPng.
|
||||
$macTileFraction = 824.0 / 1024.0
|
||||
|
||||
# Rendered once per distinct pixel size rather than once per type code, so the two 256s and the two
|
||||
# 512s are byte-identical and the file does not carry the same image twice over at different
|
||||
# compression. It also halves the drawing, which at 1024 is not nothing.
|
||||
$rendered = @{}
|
||||
foreach ($size in ($icnsTypes.Size | Sort-Object -Unique))
|
||||
{
|
||||
[byte[]]$png = New-MarkPng $size $macTileFraction
|
||||
$rendered[$size] = $png
|
||||
}
|
||||
|
||||
$icns = New-Object System.IO.MemoryStream
|
||||
|
||||
function Write-BE32([System.IO.Stream]$stream, [uint32]$value)
|
||||
{
|
||||
$bytes = [System.BitConverter]::GetBytes($value)
|
||||
if ([System.BitConverter]::IsLittleEndian) { [array]::Reverse($bytes) }
|
||||
$stream.Write($bytes, 0, 4)
|
||||
}
|
||||
|
||||
function Write-Ascii([System.IO.Stream]$stream, [string]$text)
|
||||
{
|
||||
$bytes = [System.Text.Encoding]::ASCII.GetBytes($text)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
}
|
||||
|
||||
# The header's length field covers the whole file including the header, so it is written last —
|
||||
# eight bytes of nothing now, seeked back to and filled in once the total is known.
|
||||
Write-Ascii $icns 'icns'
|
||||
Write-BE32 $icns 0
|
||||
|
||||
foreach ($entry in $icnsTypes)
|
||||
{
|
||||
$payload = $rendered[$entry.Size]
|
||||
Write-Ascii $icns $entry.Type
|
||||
|
||||
# Length includes this chunk's own eight-byte header, which is the off-by-eight everybody
|
||||
# writes once.
|
||||
Write-BE32 $icns ([uint32]($payload.Length + 8))
|
||||
$icns.Write($payload, 0, $payload.Length)
|
||||
}
|
||||
|
||||
$total = [uint32]$icns.Length
|
||||
$icns.Position = 4
|
||||
Write-BE32 $icns $total
|
||||
|
||||
$icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
|
||||
[System.IO.File]::WriteAllBytes($icnsTarget, $icns.ToArray())
|
||||
$icns.Dispose()
|
||||
|
||||
Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)"
|
||||
Binary file not shown.
@@ -66,6 +66,21 @@
|
||||
-->
|
||||
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
|
||||
|
||||
<!--
|
||||
For the macOS keychain interop in Platform/, and for nothing else.
|
||||
|
||||
Set on this project rather than in Directory.Build.props deliberately. The frameworks that hold a
|
||||
Secure Enclave key take CFDictionaries of raw pointers, so building one means pinning arrays and
|
||||
taking their addresses — see MacDeviceKeyStore. Every other project here is managed code with no
|
||||
business doing that, and a solution-wide flag would quietly permit it everywhere, including in the
|
||||
crypto project where a stray pointer is the last thing anybody wants to have been allowed.
|
||||
|
||||
The alternative — GCHandle.Alloc with GCHandleType.Pinned — needs no flag and was considered. It
|
||||
would replace each `fixed` with an allocate/free pair that has to be balanced by hand across the
|
||||
early returns those methods are full of, which trades a compiler-checked scope for a manual one.
|
||||
-->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
|
||||
<!--
|
||||
False here, unlike every server project. The root Directory.Build.props sets it true because
|
||||
the API is container-hosted, UTC-only and has no business formatting anything for a human.
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Session;
|
||||
using static DodoSSH.Client.App.Platform.MacSecurity;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the device key encrypted to a Secure Enclave key whose use requires the user's presence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The macOS counterpart of <see cref="WindowsDeviceKeyStore"/>, and the same argument holds it up:
|
||||
/// <b>the consent is enforced by the platform, not by this class</b>. The unwrapping key is generated
|
||||
/// inside the Secure Enclave and never leaves it — there is no code path, privileged or otherwise, that
|
||||
/// turns it into bytes — and it is created under an access control requiring
|
||||
/// <see cref="AccessControlFlags.UserPresence"/>, so Touch ID or the login password is a condition of
|
||||
/// <em>using</em> it. Malware running as the user can ask for a decryption; it cannot answer the prompt,
|
||||
/// and the attempt is visible.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A store that showed its own prompt and then read a protected file would be trivially bypassed, which
|
||||
/// is the mistake ADR 0007 originally described and the Windows store's comment corrects. The correction
|
||||
/// applies here unchanged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>P-256 and ECIES, where Windows uses RSA-OAEP, and the difference is not a preference.</b> The
|
||||
/// Secure Enclave holds exactly one kind of key: a 256-bit key on the NIST P-256 curve. It will not hold
|
||||
/// an RSA key at any size. So the wrap is <c>eciesEncryptionCofactorX963SHA256AESGCM</c> — an ephemeral
|
||||
/// agreement against the enclave's public half, X9.63-KDF to an AES-GCM key, and the ephemeral public
|
||||
/// key carried in the output. The framework does all of that; what matters here is that the input is 32
|
||||
/// bytes and there is no size limit worth worrying about.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Sealing is silent and unsealing prompts, which is better than the Windows shape rather than merely
|
||||
/// different.</b> On Windows, <c>CngKey.Create</c> with <c>ProtectKey</c> raises a dialog at creation as
|
||||
/// well, because the policy means "protect this key with a PIN" and Windows sets that up there and then.
|
||||
/// Here <see cref="SecKeyCopyPublicKey"/> works on an enclave key without any prompt, so registering a
|
||||
/// device shows nothing and only unlock asks. <see cref="SaveAsync"/> is therefore not user-facing on
|
||||
/// this platform — but it is still called from where the Windows one has to be, and relying on that
|
||||
/// difference would make the shared caller platform-specific for no gain.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What this cannot be tested against, and what follows from that.</b> Every method except
|
||||
/// <see cref="IsAvailableAsync"/> and the empty case of <see cref="TryLoadAsync"/> needs an interactive
|
||||
/// login session and real enclave hardware, so none can be exercised by an automated test — the same
|
||||
/// line the Windows store draws. It also means <see cref="IsSupported"/> must probe rather than infer:
|
||||
/// see its remarks for the three ordinary machines that have no usable enclave and must degrade to the
|
||||
/// passphrase rather than fail at unlock.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("macos")]
|
||||
public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
{
|
||||
/// <summary>
|
||||
/// The keychain tag this application's enclave key is filed under.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Versioned for the reason the Windows key name is: a future change of curve or wrap algorithm can
|
||||
/// create a new key beside the old one rather than failing to open blobs written by a previous
|
||||
/// build. A device that cannot be opened falls back to the passphrase, which is survivable — but
|
||||
/// silently, and a user would only notice their fingerprint had stopped working.
|
||||
///
|
||||
/// Prefixed with the bundle identifier because the keychain is shared across every application the
|
||||
/// user runs, unlike a CNG key name, which is scoped to the user's key store already.
|
||||
/// </remarks>
|
||||
private const string KeyTag = "dev.dodotech.dodossh.devicekey.v1";
|
||||
|
||||
/// <summary>
|
||||
/// Shown in the Touch ID prompt, so it has to read as a sentence to a person.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// macOS composes it into "DodoSSH is trying to ...", so this is a verb phrase and not a sentence of
|
||||
/// its own. The same words the Windows consent dialog uses.
|
||||
/// </remarks>
|
||||
private const string ConsentPrompt = "unlock your DodoSSH vault";
|
||||
|
||||
private readonly ClientPaths paths;
|
||||
|
||||
/// <summary>Creates the store.</summary>
|
||||
public MacDeviceKeyStore(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
this.paths = paths;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this Mac has a Secure Enclave that will hold a key for this build.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Probed by creating a throwaway key and deleting it, rather than by asking whether the hardware
|
||||
/// exists. Three ordinary situations answer "no" here and would otherwise only be discovered at the
|
||||
/// moment somebody tried to unlock:
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An Intel Mac with no T2.</b> Apple Silicon and T2 machines have an enclave; earlier Intel
|
||||
/// models do not, and there is no single attribute that says so.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A build that is not code signed.</b> Enclave key creation requires a signing identity, so
|
||||
/// every <c>dotnet run</c> and every build from an IDE fails here with a missing-entitlement error.
|
||||
/// That is the correct answer rather than a nuisance: a development build should keep asking for the
|
||||
/// passphrase, and this is what makes it do so without a platform check somewhere else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A machine with no login password set.</b> <see cref="AccessControlFlags.UserPresence"/> has
|
||||
/// nothing to demand, and the framework refuses the access control object rather than silently
|
||||
/// creating a key anybody could use.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The probe uses its own tag and no UI policy, so nothing prompts and nothing collides with the
|
||||
/// real key. It is deleted immediately; a probe key left behind would accumulate one per launch.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static bool IsSupported()
|
||||
{
|
||||
try
|
||||
{
|
||||
var probe = $"{KeyTag}.probe.{Guid.CreateVersion7():N}";
|
||||
|
||||
using var scope = new CoreFoundationScope();
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
if (!symbols.Complete)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = CreateEnclaveKey(scope, symbols, probe);
|
||||
|
||||
if (key == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Discarded deliberately. The question this method answers is whether the enclave will make a
|
||||
// key, and it demonstrably just did; a failure to clean the probe up afterwards leaves one
|
||||
// stray keychain item and does not make the answer no.
|
||||
_ = DeleteKey(symbols, probe);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is DllNotFoundException
|
||||
or EntryPointNotFoundException
|
||||
or BadImageFormatException)
|
||||
{
|
||||
// A macOS without these frameworks is not a thing that exists, so this is really the guard
|
||||
// for the case that does: a future release renaming or removing one of them. The answer is
|
||||
// the same as for hardware that is absent — no device key, ask for the passphrase.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||
ValueTask.FromResult(IsSupported());
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SaveAsync(
|
||||
ReadOnlyMemory<byte> devicePrivateKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sealedKey = Seal(devicePrivateKey.Span)
|
||||
?? throw new InvalidOperationException(
|
||||
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
|
||||
|
||||
paths.EnsureCreated();
|
||||
|
||||
await File.WriteAllBytesAsync(paths.DeviceKeyFile, sealedKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(paths.DeviceKeyFile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Unseal(sealedKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (File.Exists(paths.DeviceKeyFile))
|
||||
{
|
||||
File.Delete(paths.DeviceKeyFile);
|
||||
}
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
if (symbols.Complete)
|
||||
{
|
||||
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
|
||||
// and the file deleted above is the half that decides whether unlock will try at all. A key
|
||||
// left in the enclave with no ciphertext to open is inert.
|
||||
_ = DeleteKey(symbols, KeyTag);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
|
||||
/// them is the same — do not offer a device unlock.
|
||||
/// </remarks>
|
||||
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = new CoreFoundationScope();
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
if (!symbols.Complete)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Created on first use rather than at registration, so that a device key re-registered after
|
||||
// a ForgetAsync gets a key again without anything having to notice that it had gone.
|
||||
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
{
|
||||
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
|
||||
}
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var publicKey = scope.Keep(SecKeyCopyPublicKey(privateKey));
|
||||
|
||||
if (publicKey == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var plaintext = Data(scope, devicePrivateKey);
|
||||
|
||||
if (plaintext == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ciphertext = scope.Keep(
|
||||
SecKeyCreateEncryptedData(publicKey, symbols.EciesAlgorithm, plaintext, out var error));
|
||||
|
||||
scope.Keep(error);
|
||||
|
||||
return ciphertext == IntPtr.Zero ? null : ToArray(ciphertext);
|
||||
}
|
||||
catch (Exception exception) when (exception is DllNotFoundException
|
||||
or EntryPointNotFoundException
|
||||
or BadImageFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the call that prompts. Every failure becomes null, and the set is wider than it looks:
|
||||
/// the key may be gone, the user may have cancelled or let the prompt time out, the enclave may have
|
||||
/// invalidated it after the login password was reset, or the blob may predate a key that has since
|
||||
/// been replaced. None of them are distinguishable to a user and all have the same remedy, so none
|
||||
/// are worth telling apart here — see <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Blocking, and it blocks on a person. The prompt is modal to the application, so this must not run
|
||||
/// on a thread that is also expected to draw the window behind it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static byte[]? Unseal(byte[] sealedKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = new CoreFoundationScope();
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
if (!symbols.Complete)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ciphertext = Data(scope, sealedKey);
|
||||
|
||||
if (ciphertext == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var plaintext = scope.Keep(
|
||||
SecKeyCreateDecryptedData(privateKey, symbols.EciesAlgorithm, ciphertext, out var error));
|
||||
|
||||
scope.Keep(error);
|
||||
|
||||
return plaintext == IntPtr.Zero ? null : ToArray(plaintext);
|
||||
}
|
||||
catch (Exception exception) when (exception is DllNotFoundException
|
||||
or EntryPointNotFoundException
|
||||
or BadImageFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a key inside the Secure Enclave, filed under <paramref name="tag"/>. Owned by the scope.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The attribute dictionary is the whole security decision, so it is worth reading rather than
|
||||
/// pattern-matching. <c>TokenID = SecureEnclave</c> is what puts the private half in hardware;
|
||||
/// without it this silently generates an ordinary software key that behaves identically in every
|
||||
/// visible way and protects nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>AccessibleWhenUnlockedThisDeviceOnly</c> rather than any of the migratable classes, because a
|
||||
/// device key that could be restored onto another machine from a backup would no longer mean "this
|
||||
/// machine". The enclave already makes that impossible; saying it as well means the intent survives
|
||||
/// a future change of storage.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>UseDataProtectionKeychain</c> is the macOS-specific one and the easiest to omit. Without it,
|
||||
/// macOS routes this to the older file-based keychain, which does not understand access control
|
||||
/// objects or the enclave, and the call fails with a parameter error that says nothing about the
|
||||
/// missing key.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static IntPtr CreateEnclaveKey(CoreFoundationScope scope, MacSymbols symbols, string tag)
|
||||
{
|
||||
var access = scope.Keep(SecAccessControlCreateWithFlags(
|
||||
IntPtr.Zero,
|
||||
symbols.AccessibleWhenUnlockedThisDeviceOnly,
|
||||
AccessControlFlags.PrivateKeyUsage | AccessControlFlags.UserPresence,
|
||||
out var accessError));
|
||||
|
||||
scope.Keep(accessError);
|
||||
|
||||
if (access == IntPtr.Zero)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var privateAttrs = Dictionary(
|
||||
scope,
|
||||
[symbols.AttrIsPermanent, symbols.AttrApplicationTag, symbols.AttrAccessControl],
|
||||
[symbols.True, TagData(scope, tag), access]);
|
||||
|
||||
if (privateAttrs == IntPtr.Zero)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var keySize = Number(scope, 256);
|
||||
|
||||
var parameters = Dictionary(
|
||||
scope,
|
||||
[
|
||||
symbols.AttrKeyType,
|
||||
symbols.AttrKeySizeInBits,
|
||||
symbols.AttrTokenId,
|
||||
symbols.UseDataProtectionKeychain,
|
||||
symbols.PrivateKeyAttrs,
|
||||
],
|
||||
[
|
||||
symbols.KeyTypeEcSecPrimeRandom,
|
||||
keySize,
|
||||
symbols.TokenIdSecureEnclave,
|
||||
symbols.True,
|
||||
privateAttrs,
|
||||
]);
|
||||
|
||||
if (parameters == IntPtr.Zero)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var key = scope.Keep(SecKeyCreateRandomKey(parameters, out var error));
|
||||
|
||||
scope.Keep(error);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks the enclave key up by tag. Owned by the scope; zero when there is none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="prompt"/> is attached here and consumed later: the lookup itself does not raise
|
||||
/// anything, because a handle to an enclave key is not a use of it. The words reach the user at the
|
||||
/// decrypt, which is the operation the access control actually guards.
|
||||
///
|
||||
/// <c>UseOperationPrompt</c> is deprecated in favour of an <c>LAContext</c>, and is used anyway. An
|
||||
/// LAContext would mean binding LocalAuthentication as well for one string, and the deprecated key
|
||||
/// still works; the day it stops, this call fails and the store degrades to the passphrase, which is
|
||||
/// the failure this whole class is built to degrade into.
|
||||
/// </remarks>
|
||||
private static IntPtr FindKey(CoreFoundationScope scope, MacSymbols symbols, string tag, string? prompt)
|
||||
{
|
||||
List<IntPtr> keys =
|
||||
[
|
||||
symbols.Class,
|
||||
symbols.AttrApplicationTag,
|
||||
symbols.AttrKeyType,
|
||||
symbols.UseDataProtectionKeychain,
|
||||
symbols.ReturnRef,
|
||||
];
|
||||
|
||||
List<IntPtr> values =
|
||||
[
|
||||
symbols.ClassKey,
|
||||
TagData(scope, tag),
|
||||
symbols.KeyTypeEcSecPrimeRandom,
|
||||
symbols.True,
|
||||
symbols.True,
|
||||
];
|
||||
|
||||
if (prompt is not null)
|
||||
{
|
||||
keys.Add(symbols.UseOperationPrompt);
|
||||
values.Add(scope.Keep(CFString(prompt)));
|
||||
}
|
||||
|
||||
var query = Dictionary(scope, [.. keys], [.. values]);
|
||||
|
||||
if (query == IntPtr.Zero)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var status = SecItemCopyMatching(query, out var result);
|
||||
|
||||
// errSecItemNotFound is the ordinary answer on a machine that has never registered a device, and
|
||||
// it is not distinguished from any other failure for the reason the class remarks give.
|
||||
return status == Success ? scope.Keep(result) : IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>Removes the key with this tag from the keychain.</summary>
|
||||
/// <returns>Whether the keychain now has no key under this tag.</returns>
|
||||
/// <remarks>
|
||||
/// <c>ItemNotFound</c> counts as success, and that is the common case rather than an edge: it is
|
||||
/// what a machine that never registered a device answers, and what the second of two
|
||||
/// <see cref="ForgetAsync"/> calls answers. Treating it as a failure would make forgetting a device
|
||||
/// twice report a problem that does not exist.
|
||||
/// </remarks>
|
||||
private static bool DeleteKey(MacSymbols symbols, string tag)
|
||||
{
|
||||
using var scope = new CoreFoundationScope();
|
||||
|
||||
var query = Dictionary(
|
||||
scope,
|
||||
[symbols.Class, symbols.AttrApplicationTag, symbols.UseDataProtectionKeychain],
|
||||
[symbols.ClassKey, TagData(scope, tag), symbols.True]);
|
||||
|
||||
if (query == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var status = SecItemDelete(query);
|
||||
|
||||
return status is Success or ItemNotFound;
|
||||
}
|
||||
|
||||
// ---- Small CoreFoundation conveniences ---------------------------------------------------------
|
||||
|
||||
/// <remarks>
|
||||
/// The arrays are pinned for the duration of the call and not beyond it, which is correct because
|
||||
/// <c>CFDictionaryCreate</c> copies them: the dictionary retains each key and value, and never reads
|
||||
/// the arrays again.
|
||||
/// </remarks>
|
||||
private static IntPtr Dictionary(CoreFoundationScope scope, IntPtr[] keys, IntPtr[] values)
|
||||
{
|
||||
// A zero anywhere means one of the constants did not resolve or an earlier allocation failed.
|
||||
// Passing it on produces a dictionary with a null key, which CFDictionaryCreate does not reject
|
||||
// — it crashes inside the callback table instead.
|
||||
if (Array.IndexOf(keys, IntPtr.Zero) >= 0 || Array.IndexOf(values, IntPtr.Zero) >= 0)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
unsafe
|
||||
{
|
||||
fixed (IntPtr* keyPtr = keys)
|
||||
fixed (IntPtr* valuePtr = values)
|
||||
{
|
||||
return scope.Keep(CFDictionaryCreate(
|
||||
IntPtr.Zero,
|
||||
(IntPtr)keyPtr,
|
||||
(IntPtr)valuePtr,
|
||||
keys.Length,
|
||||
symbols.TypeDictionaryKeyCallBacks,
|
||||
symbols.TypeDictionaryValueCallBacks));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Copies bytes into a CFData. Owned by the scope.</summary>
|
||||
/// <remarks>
|
||||
/// The pin lasts only as long as the call, which is correct: <c>CFDataCreate</c> copies, so the
|
||||
/// CFData does not reference this memory afterwards. <c>CFDataCreateWithBytesNoCopy</c> would not,
|
||||
/// and is not used for exactly that reason — it would hand the framework a pointer into the managed
|
||||
/// heap and rely on the object staying where the collector first put it.
|
||||
/// </remarks>
|
||||
private static IntPtr Data(CoreFoundationScope scope, ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* pointer = bytes)
|
||||
{
|
||||
return scope.Keep(CFDataCreate(IntPtr.Zero, (IntPtr)pointer, bytes.Length));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// UTF-8 rather than any other encoding, and it only has to be consistent with itself: the tag is an
|
||||
/// opaque blob the keychain matches byte for byte, so what matters is that a lookup encodes it the
|
||||
/// same way the creation did. It is written once, here, for exactly that reason.
|
||||
/// </remarks>
|
||||
private static IntPtr TagData(CoreFoundationScope scope, string tag) =>
|
||||
Data(scope, Encoding.UTF8.GetBytes(tag));
|
||||
|
||||
private static IntPtr Number(CoreFoundationScope scope, int value)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
return scope.Keep(CFNumberCreate(IntPtr.Zero, (nint)CFNumberIntType, (IntPtr)(&value)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds a CFString from a managed string. Owned, so the caller tracks it.</summary>
|
||||
/// <remarks>
|
||||
/// Built explicitly rather than left to the marshaller, because these calls take a
|
||||
/// <c>CFStringRef</c> and not a C string — the runtime's default marshalling would hand over a
|
||||
/// <c>char*</c>, which CoreFoundation reads as an object pointer and follows into nothing.
|
||||
/// </remarks>
|
||||
private static IntPtr CFString(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* pointer = bytes)
|
||||
{
|
||||
// kCFStringEncodingUTF8 is 0x08000100, spelled out rather than named because it is the
|
||||
// only encoding constant this file uses.
|
||||
return CFStringCreateWithBytes(IntPtr.Zero, (IntPtr)pointer, bytes.Length, 0x08000100, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[LibraryImport(CoreFoundation)]
|
||||
private static partial IntPtr CFStringCreateWithBytes(
|
||||
IntPtr allocator,
|
||||
IntPtr bytes,
|
||||
nint numBytes,
|
||||
uint encoding,
|
||||
[MarshalAs(UnmanagedType.U1)] bool isExternalRepresentation);
|
||||
|
||||
private static byte[] ToArray(IntPtr data)
|
||||
{
|
||||
var length = (int)CFDataGetLength(data);
|
||||
var pointer = CFDataGetBytePtr(data);
|
||||
|
||||
if (length <= 0 || pointer == IntPtr.Zero)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new byte[length];
|
||||
Marshal.Copy(pointer, result, 0, length);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// The pieces of CoreFoundation and Security.framework <see cref="MacDeviceKeyStore"/> needs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Separated from the store itself because it is a different kind of code with a different kind of
|
||||
/// review: nothing here makes a decision, and everything here is a translation of a C declaration that
|
||||
/// is either right or wrong. Mixing the two would mean the security argument in
|
||||
/// <see cref="MacDeviceKeyStore"/> had to be read past two hundred lines of marshalling to find.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every Create or Copy returns an object this process owns.</b> That is CoreFoundation's Create
|
||||
/// Rule, and it is the thing here that goes wrong silently: the enclave key handle is small, so a leak
|
||||
/// shows up as nothing at all until a long-running process has done a few thousand unlocks.
|
||||
/// <see cref="CoreFoundationScope"/> exists so ownership is tracked by construction rather than by
|
||||
/// remembering, and every function below that returns a handle says whether it is owned.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The integer widths are the part worth checking against the headers rather than skimming.</b>
|
||||
/// <c>CFIndex</c>, <c>CFOptionFlags</c> and <c>CFNumberType</c> are all pointer-width on a 64-bit Mac,
|
||||
/// not 32-bit, and getting one wrong does not fail cleanly — it shifts every argument after it, so the
|
||||
/// call receives plausible rubbish and returns a parameter error that names nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("macos")]
|
||||
internal static partial class MacSecurity
|
||||
{
|
||||
internal const string SecurityFramework =
|
||||
"/System/Library/Frameworks/Security.framework/Security";
|
||||
|
||||
internal const string CoreFoundation =
|
||||
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
|
||||
|
||||
/// <summary>
|
||||
/// The access control flags <c>SecAccessControlCreateWithFlags</c> takes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ulong</c> because the parameter is a <c>CFOptionFlags</c>, which is an <c>unsigned long</c>.
|
||||
/// Only the two flags that are used are listed; the full set is large, and copying it in would
|
||||
/// invite somebody to reach for one without reading what it does to the prompt — <c>Biometry</c>
|
||||
/// alone, for instance, leaves a Mac with no Touch ID unable to unlock at all rather than falling
|
||||
/// back to the login password.
|
||||
/// </remarks>
|
||||
[Flags]
|
||||
internal enum AccessControlFlags : ulong
|
||||
{
|
||||
/// <summary>
|
||||
/// Touch ID if the machine has it, the login password if not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The forgiving one, deliberately. <c>BiometryCurrentSet</c> would additionally invalidate the
|
||||
/// key whenever a fingerprint is added or removed, which sounds stricter and here buys nothing:
|
||||
/// this key wraps a device key whose loss already means "ask for the passphrase", so the only
|
||||
/// effect would be users being sent back to their passphrase by an unrelated Settings change
|
||||
/// they would never connect to it.
|
||||
/// </remarks>
|
||||
UserPresence = 1ul << 0,
|
||||
|
||||
/// <summary>Required for any key that lives in the Secure Enclave.</summary>
|
||||
PrivateKeyUsage = 1ul << 30,
|
||||
}
|
||||
|
||||
/// <summary>The CFNumberType code for a 32-bit int, from CFNumber.h.</summary>
|
||||
internal const long CFNumberIntType = 9;
|
||||
|
||||
/// <summary>errSecSuccess.</summary>
|
||||
internal const int Success = 0;
|
||||
|
||||
/// <summary>errSecItemNotFound, which is an answer rather than a failure.</summary>
|
||||
internal const int ItemNotFound = -25300;
|
||||
|
||||
// ---- CoreFoundation ------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Releases an owned handle.</summary>
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial void CFRelease(IntPtr handle);
|
||||
|
||||
/// <summary>Copies bytes into a new CFData. Owned.</summary>
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial IntPtr CFDataCreate(IntPtr allocator, IntPtr bytes, nint length);
|
||||
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial IntPtr CFDataGetBytePtr(IntPtr data);
|
||||
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial nint CFDataGetLength(IntPtr data);
|
||||
|
||||
/// <summary>Boxes a value as a CFNumber. Owned.</summary>
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial IntPtr CFNumberCreate(IntPtr allocator, nint theType, IntPtr valuePtr);
|
||||
|
||||
/// <summary>Builds an immutable dictionary. Owned.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The key and value arrays are passed as raw pointers to memory the caller pins, rather than as
|
||||
/// managed arrays. Source-generated interop wants an explicit element count for a marshalled array,
|
||||
/// and supplying one here would mean stating the length twice — once for the marshaller and once as
|
||||
/// <paramref name="numValues"/> — which is exactly the pair that drifts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The two callback tables are what make the dictionary retain its keys and values, which is why
|
||||
/// they are passed rather than left null: with null callbacks the dictionary stores raw pointers and
|
||||
/// keeps nothing alive, and the resulting use-after-free is intermittent by nature.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial IntPtr CFDictionaryCreate(
|
||||
IntPtr allocator,
|
||||
IntPtr keys,
|
||||
IntPtr values,
|
||||
nint numValues,
|
||||
IntPtr keyCallBacks,
|
||||
IntPtr valueCallBacks);
|
||||
|
||||
// ---- Security.framework --------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Builds the access policy a Secure Enclave key is created under. Owned.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial IntPtr SecAccessControlCreateWithFlags(
|
||||
IntPtr allocator,
|
||||
IntPtr protection,
|
||||
AccessControlFlags flags,
|
||||
out IntPtr error);
|
||||
|
||||
/// <summary>Creates a key pair from an attribute dictionary. Owned.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial IntPtr SecKeyCreateRandomKey(IntPtr parameters, out IntPtr error);
|
||||
|
||||
/// <summary>The public half of a key. Owned.</summary>
|
||||
/// <remarks>
|
||||
/// Available even for an enclave key, and that asymmetry is the whole reason this design works: the
|
||||
/// public half is an ordinary key this process can hold and use, while the private half is a handle
|
||||
/// to something inside the enclave that never becomes bytes. So sealing is silent and unsealing is
|
||||
/// the thing the user is asked about.
|
||||
/// </remarks>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial IntPtr SecKeyCopyPublicKey(IntPtr key);
|
||||
|
||||
/// <summary>Encrypts with a public key. Owned.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial IntPtr SecKeyCreateEncryptedData(
|
||||
IntPtr key,
|
||||
IntPtr algorithm,
|
||||
IntPtr plaintext,
|
||||
out IntPtr error);
|
||||
|
||||
/// <summary>Decrypts with a private key, prompting for whatever guards it. Owned.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial IntPtr SecKeyCreateDecryptedData(
|
||||
IntPtr key,
|
||||
IntPtr algorithm,
|
||||
IntPtr ciphertext,
|
||||
out IntPtr error);
|
||||
|
||||
/// <summary>Finds a keychain item. The out handle is owned when the result is <see cref="Success"/>.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial int SecItemCopyMatching(IntPtr query, out IntPtr result);
|
||||
|
||||
/// <summary>Deletes every keychain item matching the query.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial int SecItemDelete(IntPtr query);
|
||||
|
||||
// ---- The framework constants ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Reads one of a framework's global CFString constants, or zero if it is not exported.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The keys these dictionaries take are not strings this code may spell for itself. They are
|
||||
/// pointer-comparable constants exported by the framework, and a CFString built here with the same
|
||||
/// characters is a different object — the lookups would miss and the call would fail with a
|
||||
/// parameter error naming nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Dereferenced once, because the exported symbol is the variable rather than its value.</b>
|
||||
/// <c>TryGetExport</c> answers the address of the global; the CFStringRef is what that address
|
||||
/// holds. Missing the indirection produces a pointer that is stable, plausible and wrong, which is
|
||||
/// the worst of the three available outcomes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Zero on a missing symbol rather than an exception, because the caller's answer to every failure
|
||||
/// is the same one — report the store unavailable and let unlock ask for the passphrase — and a
|
||||
/// constant that has been renamed by a future macOS should reach that answer rather than a crash.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static IntPtr Constant(IntPtr library, string symbol) =>
|
||||
NativeLibrary.TryGetExport(library, symbol, out var address)
|
||||
? Marshal.ReadIntPtr(address)
|
||||
: IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases every CoreFoundation handle put into it, in reverse order, exactly once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The alternative is a try/finally per handle, and the operations here need six or seven at a time — a
|
||||
/// dictionary holding a nested dictionary holding an access control object holding a CFData tag. Finallys
|
||||
/// nested that deep stop being read, and a handle released twice is a crash rather than a leak.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Keep"/> returns what it was given, so a handle can be tracked in the same expression that
|
||||
/// produces it and the call sites read as ordinary code.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("macos")]
|
||||
internal sealed class CoreFoundationScope : IDisposable
|
||||
{
|
||||
private readonly List<IntPtr> owned = [];
|
||||
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>Takes ownership of a handle and hands it straight back.</summary>
|
||||
/// <remarks>
|
||||
/// Zero is ignored rather than rejected. Every CoreFoundation call here answers zero on failure, so
|
||||
/// accepting it lets a caller track the result in the expression that produces it and check it on
|
||||
/// the next line, instead of writing the check twice.
|
||||
/// </remarks>
|
||||
internal IntPtr Keep(IntPtr handle)
|
||||
{
|
||||
if (handle != IntPtr.Zero)
|
||||
{
|
||||
owned.Add(handle);
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
// Reverse order, so a container is released before the things it retains. CoreFoundation does not
|
||||
// require it — retain counts make the order irrelevant — but it keeps the lifetimes readable in a
|
||||
// debugger, where a released container that still lists its contents is a confusing thing to meet.
|
||||
for (var i = owned.Count - 1; i >= 0; i--)
|
||||
{
|
||||
MacSecurity.CFRelease(owned[i]);
|
||||
}
|
||||
|
||||
owned.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// The framework constants <see cref="MacDeviceKeyStore"/> passes to CoreFoundation and Security.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every field here is a pointer read out of a loaded framework rather than a value this code could
|
||||
/// write down. The dictionaries these go into are matched by pointer identity, so a CFString built with
|
||||
/// the same characters is a different key and the lookup misses — see <see cref="MacSecurity.Constant"/>
|
||||
/// for the indirection that trips people.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Resolved once and cached, and the caching is what makes the failure survivable.</b> Two frameworks
|
||||
/// and nineteen symbols is a lot of things to be wrong about, and the useful property is that being
|
||||
/// wrong about any one of them shows up here — as <see cref="Complete"/> being false — rather than
|
||||
/// three calls later as a parameter error. A store that reports itself unavailable sends the user back
|
||||
/// to their passphrase; a store that half works corrupts the moment somebody registers a device.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Lazy{T}"/> rather than a static constructor, because a type initialiser that throws
|
||||
/// poisons the type for the life of the process and turns a missing symbol into a
|
||||
/// <c>TypeInitializationException</c> at every later call site. The load is done inside a try instead,
|
||||
/// and its failure is a value.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("macos")]
|
||||
internal sealed class MacSymbols
|
||||
{
|
||||
private static readonly Lazy<MacSymbols> Cached = new(Load, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
private MacSymbols()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Whether every symbol resolved.</summary>
|
||||
/// <remarks>
|
||||
/// Checked by every caller before any of the pointers are used. It is one check rather than
|
||||
/// nineteen, which is the only reason the call sites in <see cref="MacDeviceKeyStore"/> are
|
||||
/// readable.
|
||||
///
|
||||
/// Computed rather than stored, so that the instance returned when a framework will not load at all
|
||||
/// — every field left at zero — answers false without that having to be set anywhere. One rule,
|
||||
/// applied to the only state there is.
|
||||
/// </remarks>
|
||||
internal bool Complete => AllResolved();
|
||||
|
||||
// CoreFoundation.
|
||||
internal IntPtr True { get; private init; }
|
||||
|
||||
internal IntPtr TypeDictionaryKeyCallBacks { get; private init; }
|
||||
|
||||
internal IntPtr TypeDictionaryValueCallBacks { get; private init; }
|
||||
|
||||
// Security: item classes and query keys.
|
||||
internal IntPtr Class { get; private init; }
|
||||
|
||||
internal IntPtr ClassKey { get; private init; }
|
||||
|
||||
internal IntPtr ReturnRef { get; private init; }
|
||||
|
||||
internal IntPtr UseDataProtectionKeychain { get; private init; }
|
||||
|
||||
internal IntPtr UseOperationPrompt { get; private init; }
|
||||
|
||||
// Security: key attributes.
|
||||
internal IntPtr AttrKeyType { get; private init; }
|
||||
|
||||
internal IntPtr AttrKeySizeInBits { get; private init; }
|
||||
|
||||
internal IntPtr AttrTokenId { get; private init; }
|
||||
|
||||
internal IntPtr AttrIsPermanent { get; private init; }
|
||||
|
||||
internal IntPtr AttrApplicationTag { get; private init; }
|
||||
|
||||
internal IntPtr AttrAccessControl { get; private init; }
|
||||
|
||||
internal IntPtr PrivateKeyAttrs { get; private init; }
|
||||
|
||||
// Security: attribute values.
|
||||
internal IntPtr KeyTypeEcSecPrimeRandom { get; private init; }
|
||||
|
||||
internal IntPtr TokenIdSecureEnclave { get; private init; }
|
||||
|
||||
internal IntPtr AccessibleWhenUnlockedThisDeviceOnly { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The one algorithm the Secure Enclave's P-256 keys support for encryption, and the reason this
|
||||
/// store wraps rather than signs. The long name spells out the whole construction: an ephemeral
|
||||
/// key agreed against the enclave's public half with cofactor ECDH, run through the X9.63 KDF with
|
||||
/// SHA-256, used as an AES-GCM key. The ephemeral public key travels in the output, which is why the
|
||||
/// ciphertext is larger than the 32 bytes going in and why nothing else has to be stored beside it.
|
||||
/// </remarks>
|
||||
internal IntPtr EciesAlgorithm { get; private init; }
|
||||
|
||||
/// <summary>The resolved symbols, loaded once.</summary>
|
||||
internal static MacSymbols Resolve() => Cached.Value;
|
||||
|
||||
private static MacSymbols Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!NativeLibrary.TryLoad(MacSecurity.CoreFoundation, out var cf)
|
||||
|| !NativeLibrary.TryLoad(MacSecurity.SecurityFramework, out var sec))
|
||||
{
|
||||
// Every pointer left at zero, which AllResolved reads as incomplete.
|
||||
return new MacSymbols();
|
||||
}
|
||||
|
||||
// The two callback tables are structs rather than object pointers, so what is wanted is the
|
||||
// address of the export itself and not what it holds. Every other symbol here is a CFTypeRef
|
||||
// global and needs the dereference; these two do not, and mixing them up produces a
|
||||
// dictionary that does not retain its contents.
|
||||
var keyCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryKeyCallBacks", out var k)
|
||||
? k
|
||||
: IntPtr.Zero;
|
||||
|
||||
var valueCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryValueCallBacks", out var v)
|
||||
? v
|
||||
: IntPtr.Zero;
|
||||
|
||||
return new MacSymbols
|
||||
{
|
||||
True = MacSecurity.Constant(cf, "kCFBooleanTrue"),
|
||||
TypeDictionaryKeyCallBacks = keyCallBacks,
|
||||
TypeDictionaryValueCallBacks = valueCallBacks,
|
||||
|
||||
Class = MacSecurity.Constant(sec, "kSecClass"),
|
||||
ClassKey = MacSecurity.Constant(sec, "kSecClassKey"),
|
||||
ReturnRef = MacSecurity.Constant(sec, "kSecReturnRef"),
|
||||
UseDataProtectionKeychain = MacSecurity.Constant(sec, "kSecUseDataProtectionKeychain"),
|
||||
UseOperationPrompt = MacSecurity.Constant(sec, "kSecUseOperationPrompt"),
|
||||
|
||||
AttrKeyType = MacSecurity.Constant(sec, "kSecAttrKeyType"),
|
||||
AttrKeySizeInBits = MacSecurity.Constant(sec, "kSecAttrKeySizeInBits"),
|
||||
AttrTokenId = MacSecurity.Constant(sec, "kSecAttrTokenID"),
|
||||
AttrIsPermanent = MacSecurity.Constant(sec, "kSecAttrIsPermanent"),
|
||||
AttrApplicationTag = MacSecurity.Constant(sec, "kSecAttrApplicationTag"),
|
||||
AttrAccessControl = MacSecurity.Constant(sec, "kSecAttrAccessControl"),
|
||||
PrivateKeyAttrs = MacSecurity.Constant(sec, "kSecPrivateKeyAttrs"),
|
||||
|
||||
KeyTypeEcSecPrimeRandom = MacSecurity.Constant(sec, "kSecAttrKeyTypeECSECPrimeRandom"),
|
||||
TokenIdSecureEnclave = MacSecurity.Constant(sec, "kSecAttrTokenIDSecureEnclave"),
|
||||
AccessibleWhenUnlockedThisDeviceOnly =
|
||||
MacSecurity.Constant(sec, "kSecAttrAccessibleWhenUnlockedThisDeviceOnly"),
|
||||
|
||||
EciesAlgorithm = MacSecurity.Constant(
|
||||
sec,
|
||||
"kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM"),
|
||||
};
|
||||
}
|
||||
catch (Exception exception) when (exception is DllNotFoundException or BadImageFormatException)
|
||||
{
|
||||
return new MacSymbols();
|
||||
}
|
||||
}
|
||||
|
||||
private bool AllResolved() =>
|
||||
True != IntPtr.Zero
|
||||
&& TypeDictionaryKeyCallBacks != IntPtr.Zero
|
||||
&& TypeDictionaryValueCallBacks != IntPtr.Zero
|
||||
&& Class != IntPtr.Zero
|
||||
&& ClassKey != IntPtr.Zero
|
||||
&& ReturnRef != IntPtr.Zero
|
||||
&& UseDataProtectionKeychain != IntPtr.Zero
|
||||
&& UseOperationPrompt != IntPtr.Zero
|
||||
&& AttrKeyType != IntPtr.Zero
|
||||
&& AttrKeySizeInBits != IntPtr.Zero
|
||||
&& AttrTokenId != IntPtr.Zero
|
||||
&& AttrIsPermanent != IntPtr.Zero
|
||||
&& AttrApplicationTag != IntPtr.Zero
|
||||
&& AttrAccessControl != IntPtr.Zero
|
||||
&& PrivateKeyAttrs != IntPtr.Zero
|
||||
&& KeyTypeEcSecPrimeRandom != IntPtr.Zero
|
||||
&& TokenIdSecureEnclave != IntPtr.Zero
|
||||
&& AccessibleWhenUnlockedThisDeviceOnly != IntPtr.Zero
|
||||
&& EciesAlgorithm != IntPtr.Zero;
|
||||
}
|
||||
@@ -31,7 +31,12 @@ internal static class UpdateChannels
|
||||
/// </remarks>
|
||||
internal static IUpdateChannel ForThisMachine()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
// 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();
|
||||
}
|
||||
@@ -55,14 +60,21 @@ internal static class UpdateChannels
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Windows update channel, backed by Velopack against the project's own forge.
|
||||
/// The desktop update channel, backed by Velopack against the project's own forge.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one file in the repository that names Velopack. It lives beside <c>WindowsDeviceKeyStore</c>
|
||||
/// rather than in a project of its own because it is the same kind of thing — a Windows-only
|
||||
/// implementation of an interface declared in <c>DodoSSH.Client.Session</c> — and because
|
||||
/// <c>DodoSSH.Client.Shell</c> is shared with the Android head, which must never acquire an updater.
|
||||
/// The one file in the repository that names Velopack. It lives beside the platform key stores rather
|
||||
/// than in a project of its own because it is the same kind of thing — a desktop-only implementation of
|
||||
/// an interface declared in <c>DodoSSH.Client.Session</c> — and because <c>DodoSSH.Client.Shell</c> is
|
||||
/// shared with the Android head, which must never acquire an updater.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One class for both desktop platforms, where the key stores are one class each.</b> The difference
|
||||
/// is where the platform knowledge sits. A key store is platform knowledge from top to bottom: different
|
||||
/// hardware, different API, different failure modes. Velopack's <c>UpdateManager</c> has already absorbed
|
||||
/// all of that, and what is left over — check, download, apply, restart — is identical on the two. The
|
||||
/// only thing that differs is which string names the feed, and that is <see cref="ChannelFor"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
|
||||
@@ -103,7 +115,7 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// but unsaid on one side and stated on the other is how a feed goes quiet with no error anywhere:
|
||||
/// the check succeeds, finds nothing, and reports that the client is up to date forever.
|
||||
/// </remarks>
|
||||
private const string ReleaseChannel = "win";
|
||||
private const string WindowsReleaseChannel = "win";
|
||||
|
||||
/// <summary>
|
||||
/// The nightly channel, which is a different name rather than the same one on a different tag.
|
||||
@@ -122,7 +134,26 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// a download somebody watched. A channel each means neither ever sees the other's releases at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private const string NightlyChannel = "win-nightly";
|
||||
private const string WindowsNightlyChannel = "win-nightly";
|
||||
|
||||
/// <summary>The macOS release channel, and Velopack's own default there.</summary>
|
||||
/// <remarks>
|
||||
/// A contract with <c>scripts/release-macos.sh</c>, exactly as the Windows pair is one with the
|
||||
/// PowerShell script. Stated for the same reason, which applies with more force here: the four
|
||||
/// channels all publish to one repository, so the only thing keeping a Mac from being offered a
|
||||
/// <c>win</c> package is that it never reads that index.
|
||||
/// </remarks>
|
||||
private const string MacReleaseChannel = "osx";
|
||||
|
||||
/// <summary>The macOS nightly channel.</summary>
|
||||
/// <remarks>
|
||||
/// Named here and not yet published by anything. The CI job for the macOS head builds and bundles
|
||||
/// and deliberately uploads nothing — see the packaging step in <c>ci.yml</c> — so a nightly macOS
|
||||
/// build checking this feed finds an empty channel and reports itself up to date, which is the
|
||||
/// correct behaviour for a channel with no publisher. The name exists so that turning the publisher
|
||||
/// on later is one job rather than a job plus a rename that has to reach every installed client.
|
||||
/// </remarks>
|
||||
private const string MacNightlyChannel = "osx-nightly";
|
||||
|
||||
private readonly UpdateManager manager;
|
||||
|
||||
@@ -141,10 +172,13 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// <inheritdoc />
|
||||
public bool IsSupported => true;
|
||||
|
||||
/// <summary>Always, on this head.</summary>
|
||||
/// <summary>Always, on this head, on either platform.</summary>
|
||||
/// <remarks>
|
||||
/// Velopack's apply runs <c>Update.exe</c> over this installation and restarts it, so the process is
|
||||
/// gone by the time anything could have asked a question. The phone's is the other answer; see
|
||||
/// Velopack's apply hands off to a separate updater process — <c>Update.exe</c> on Windows, the
|
||||
/// <c>UpdateMac</c> helper inside the bundle on macOS — which replaces this installation and
|
||||
/// relaunches it, so the process is gone by the time anything could have asked a question. The
|
||||
/// mechanism differs and the answer does not, which is why this is a constant rather than another
|
||||
/// thing <see cref="ChannelFor"/> would have to decide. The phone's is the other answer; see
|
||||
/// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
|
||||
/// </remarks>
|
||||
public bool ApplyingEndsTheProcess => true;
|
||||
@@ -183,7 +217,36 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
|
||||
return new UpdateManager(
|
||||
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
|
||||
new UpdateOptions { ExplicitChannel = nightly ? NightlyChannel : ReleaseChannel });
|
||||
new UpdateOptions { ExplicitChannel = ChannelFor(nightly) });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one of the four channel names this build belongs to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two independent axes — which platform, and which of that platform's two channels — and they are
|
||||
/// resolved in one place so that neither can be answered differently somewhere else. The platform
|
||||
/// half is the running OS rather than anything recorded in the build, because a package can only
|
||||
/// ever be applied on the platform it was built for; the channel half comes from assembly metadata,
|
||||
/// because a release build and a nightly are the same bytes on the same OS and only the metadata
|
||||
/// tells them apart.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Windows is the fallback rather than a third branch. Only Windows and macOS reach here at all —
|
||||
/// <see cref="UpdateChannels.ForThisMachine"/> is the gate — so the alternative would be an
|
||||
/// unreachable throw, and an unreachable throw in the middle of the updater is a thing somebody
|
||||
/// later has to reason about to discover it cannot happen.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static string ChannelFor(bool nightly)
|
||||
{
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return nightly ? MacNightlyChannel : MacReleaseChannel;
|
||||
}
|
||||
|
||||
return nightly ? WindowsNightlyChannel : WindowsReleaseChannel;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -9,9 +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 TPM, or one that
|
||||
/// is not Windows, 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>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
|
||||
@@ -29,9 +37,17 @@ public static class DesktopDeviceKeyStores
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()
|
||||
? new WindowsDeviceKeyStore(paths)
|
||||
: new UnavailableDeviceKeyStore();
|
||||
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new WindowsDeviceKeyStore(paths);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS() && MacDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new MacDeviceKeyStore(paths);
|
||||
}
|
||||
|
||||
return new UnavailableDeviceKeyStore();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,28 @@
|
||||
Showing the last terminal's pane would be a lie, and showing nothing reads as the application having
|
||||
broken, so this says which machine, as whom, and how far along it is.
|
||||
|
||||
── The step list, and why it is amber ───────────────────────────────────────────────────────────────
|
||||
"How far along it is" used to be one line of prose that never changed after the tab was created, which
|
||||
made every slow connection look exactly like every hung one. It is now the five steps of actually
|
||||
getting there, each lit at the moment the handshake reports it — see SshConnectionPhase, which names
|
||||
only the boundaries a client can genuinely observe. A connection that stops therefore stops on a named
|
||||
row, and "the host key is being checked" stops being the same screen as "the host is not answering".
|
||||
|
||||
Amber for the step in flight, and that is the palette's rule rather than an exception to it. Green is
|
||||
what is true and purple is what you can press; a step still happening is neither, and it is precisely
|
||||
the caveat-worth-reading amber exists for — see the remark above Warn in Palette.axaml. Steps behind it
|
||||
go green as they become true, and the one a refusal landed on goes red. Nothing on the list is drawn in
|
||||
the accent, because there is nothing on it to press.
|
||||
|
||||
Nothing here animates, which is the argument the transfer strip makes for its own track in
|
||||
TransfersScreen.axaml, arriving at a screen with more reason to want a spinner. A spinner is furniture
|
||||
invented to fill a state nobody measured; these steps are measured, so the track fills to what has
|
||||
actually finished and then waits there. Waiting is what waiting looks like.
|
||||
|
||||
The list is drawn for both states rather than once per state. A refused connection has the same five
|
||||
rows and the same track — the difference is only that one row is red and the track stops — and drawing
|
||||
it twice would be two templates to keep identical for the sake of a colour.
|
||||
|
||||
It obeys the occlusion rule the whole window obeys: this is Avalonia-drawn content in the WebView's own
|
||||
rectangle, so the shell collapses the terminal while it is up. IsTerminalShowing and IsConnectingShowing
|
||||
are exclusive by construction — a selected tab either has a session or it does not — which is what makes
|
||||
@@ -24,8 +46,69 @@
|
||||
can be laid out by a test: WebView2's adapter refuses the headless session's thread.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
|
||||
<!--
|
||||
A rule per lit state over one quiet default, so that the pending weight is stated once and each state
|
||||
that differs from it is the one line that says how.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepcaption">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The marker beside each caption. Fixed width and centred, because four different characters on a
|
||||
ragged left edge is a list that looks broken; see ConnectionStepViewModel.Mark for which they are.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepmark">
|
||||
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Width" Value="14" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The track over the list. Amber while the attempt is alive and red once it is not, so that the bar says
|
||||
the same thing as the row it stopped on rather than staying the colour of something still being waited
|
||||
for. Chip underneath, matching the transfer strip's track.
|
||||
-->
|
||||
<Style Selector="ProgressBar.steptrack">
|
||||
<Setter Property="Height" Value="5" />
|
||||
<Setter Property="MinHeight" Value="5" />
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="ProgressBar.steptrack.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
</UserControl.Styles>
|
||||
|
||||
<Panel>
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14" MaxWidth="460"
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
||||
Margin="24">
|
||||
|
||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||
@@ -38,15 +121,43 @@
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Two states, deliberately different. Waiting is an accent line under the host's name; a refusal is
|
||||
the reason, in the palette's red, because it is the only place the reason will be after the user
|
||||
navigates away from the screen that started the connection.
|
||||
Bound to StepsDone against StepCount rather than to a percentage: five steps and a maximum of five
|
||||
means the bar is a count of things that really finished, and the arithmetic that would turn it into
|
||||
a percentage is exactly the arithmetic that would start inventing one.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="12"
|
||||
Foreground="{StaticResource Accent}" HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" TextAlignment="Center"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}" HorizontalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="7" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="stepmark"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Mark}" />
|
||||
<TextBlock Classes="stepcaption mono"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Caption}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
A refusal is the reason, in the palette's red, because it is the only place the reason will be after
|
||||
the user navigates away from the screen that started the connection. It sits under the list rather
|
||||
than replacing it: which row it stopped on is half the answer and the sentence is the other half.
|
||||
-->
|
||||
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
|
||||
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" TextAlignment="Center"
|
||||
@@ -62,14 +173,23 @@
|
||||
handshake that finishes afterwards is adopted rather than dropped — see
|
||||
MainWindowViewModel.CloseTabAsync. Two buttons rather than one with a converted label, because the
|
||||
two are different decisions and only one of them abandons something still running.
|
||||
-->
|
||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="GIVE UP"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
|
||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="CLOSE TAB"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
Beside each, the logs. The step list is this attempt and the log is every other one, which is the
|
||||
question both a connection taking too long and a connection just refused actually raise — has this
|
||||
machine ever worked. It is the ordinary rail destination reached the ordinary way rather than a
|
||||
second log grown inside this card, and leaving by it does not abandon the handshake: the tab stays
|
||||
in the strip and the card is still here on the way back.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
|
||||
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
|
||||
CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
<Button Classes="ghost" Content="CLOSE TAB" Command="{Binding CloseTabCommand}"
|
||||
CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
@@ -584,6 +584,56 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Making a credential without leaving the host. The moment one is wanted is this one: somebody
|
||||
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
||||
sending them to the other screen to add it would lose the half-typed host they are standing
|
||||
in. Same argument as the new-tag box further down, same immediate write, same honest
|
||||
consequence — the credential stays if this editor is cancelled, because a host can only name
|
||||
an id that exists.
|
||||
|
||||
A button beside the picker rather than an entry inside it. Every row of that list is a
|
||||
binding the host can have; "make a new one" is an action, and as an entry it would sit in the
|
||||
box afterwards describing a state no host can be in.
|
||||
-->
|
||||
<Button Classes="ghost" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
|
||||
FontSize="10.5" Height="28" Padding="10,0"
|
||||
IsVisible="{Binding !IsAddingEditorCredential}"
|
||||
Command="{Binding BeginEditorCredentialCommand}"
|
||||
ToolTip.Tip="Adds a credential to the keychain and binds this host to it" />
|
||||
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
|
||||
IsVisible="{Binding IsAddingEditorCredential}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="label" Text="NEW CREDENTIAL" FontSize="10" />
|
||||
<TextBox Text="{Binding EditorNewCredentialLabel}" PlaceholderText="name" Height="36" />
|
||||
<!--
|
||||
Optional, and what makes a credential worth being its own item: one account on twenty
|
||||
machines is rotated in one place. Left blank, this host's own username is used.
|
||||
-->
|
||||
<TextBox Text="{Binding EditorNewCredentialUsername}" Height="36"
|
||||
PlaceholderText="username (blank: use this host's own)" />
|
||||
<!-- Masked, on the reasoning the keychain's own password box carries. -->
|
||||
<TextBox Text="{Binding EditorNewCredentialPassword}" PlaceholderText="password"
|
||||
PasswordChar="•" Height="36">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorCredentialCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBox Text="{Binding EditorNewCredentialNotes}" PlaceholderText="notes"
|
||||
AcceptsReturn="True" Height="44" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Added to the keychain as soon as you press ADD, so it stays even if you cancel this host. Renaming and deleting are on the keychain screen." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="ADD"
|
||||
Command="{Binding AddEditorCredentialCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding CancelEditorCredentialCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
|
||||
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
||||
|
||||
@@ -98,6 +98,18 @@
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<!--
|
||||
── 26 DOWN EACH SIDE, the same inset Keychain, Snips, Logs and Pins all take. ──────────────────────
|
||||
Those four say it once, as Margin="26" on their own root; this screen repeats it on each of the four
|
||||
rows below, and it has to. The board's ScrollViewer is the last row and is deliberately full-bleed, so
|
||||
that its scrollbar rides the pane's own edge rather than floating 26 pixels inside it — a root margin
|
||||
would inset the bar with everything else. It would also inset the drawer in the second column, which
|
||||
draws its own edge and wants none.
|
||||
|
||||
It was 16 and 20 until this pass, which put the Hosts header a visible step left of and above every
|
||||
other screen's. Four numbers rather than one is the cost of the two exceptions above; changing one of
|
||||
them means changing all four.
|
||||
-->
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<!--
|
||||
@@ -107,7 +119,7 @@
|
||||
buttons over a board of forty is a pair whose subject the user has to work out. The group's own
|
||||
Edit/Move/Delete sit on its own heading's menu for the same reason.
|
||||
-->
|
||||
<Grid Grid.Row="0" Margin="16,20,16,16" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
|
||||
<Grid Grid.Row="0" Margin="26,26,26,16" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
|
||||
|
||||
<TextBlock Grid.Column="0" Text="Hosts" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
@@ -219,7 +231,7 @@
|
||||
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
|
||||
who has found this box should know about the one that also connects on Enter.
|
||||
-->
|
||||
<Border Grid.Row="1" Margin="16,0,16,16">
|
||||
<Border Grid.Row="1" Margin="26,0,26,16">
|
||||
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
|
||||
@@ -232,7 +244,7 @@
|
||||
one of them sits here, above the board, rather than laid over it: a card over the cards would hide
|
||||
the very ticks or the very group it is asking about.
|
||||
-->
|
||||
<StackPanel Grid.Row="2" Margin="16,0,16,12" Spacing="10">
|
||||
<StackPanel Grid.Row="2" Margin="26,0,26,12" Spacing="10">
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
|
||||
@@ -434,7 +446,7 @@
|
||||
HostsScreen.axaml.cs.
|
||||
-->
|
||||
<ScrollViewer Grid.Row="3" x:Name="Scroll" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="16,0,16,20" Spacing="16">
|
||||
<StackPanel Margin="26,0,26,26" Spacing="16">
|
||||
|
||||
<!--
|
||||
Named because it is where keyboard focus lands when the terminal gives it back, and because
|
||||
@@ -690,7 +702,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,13 +148,17 @@
|
||||
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
||||
<views:SessionHeader Grid.Row="0"
|
||||
OpenLabel="Open terminal"
|
||||
OpenCommand="{Binding OpenTerminalForFilesHostCommand}"
|
||||
EmptyText="{Binding Transfers.Status}" />
|
||||
<views:TransfersScreen Grid.Row="1" DataContext="{Binding Transfers}" />
|
||||
<views:SessionStatusBar Grid.Row="2" />
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<Grid Grid.Column="0" RowDefinitions="*,Auto">
|
||||
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
|
||||
<views:SessionStatusBar Grid.Row="1" />
|
||||
</Grid>
|
||||
<!--
|
||||
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
|
||||
@@ -268,14 +272,16 @@
|
||||
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
||||
<views:SessionHeader Grid.Row="0"
|
||||
OpenLabel="Open SFTP"
|
||||
OpenCommand="{Binding SelectFilesHostCommand}"
|
||||
OpenCommandParameter="{Binding SelectedTab}"
|
||||
EmptyText="no terminals open · press + or Ctrl+K, or choose a host and press Connect" />
|
||||
<!--
|
||||
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
|
||||
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
|
||||
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
|
||||
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
||||
answers for itself in the tab row's own "+" and in the connecting card below.
|
||||
-->
|
||||
<Grid Grid.Column="0" RowDefinitions="*,Auto">
|
||||
|
||||
<Panel Grid.Row="1" Background="{StaticResource Pane}">
|
||||
<Panel Grid.Row="0" Background="{StaticResource Pane}">
|
||||
|
||||
<!--
|
||||
The other thing that can be in the terminal's rectangle: a tab whose session does not
|
||||
@@ -300,7 +306,7 @@
|
||||
|
||||
</Panel>
|
||||
|
||||
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
|
||||
<views:SessionStatusBar Grid.Row="1" ShowsEncoding="True" />
|
||||
</Grid>
|
||||
<!--
|
||||
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
|
||||
|
||||
@@ -54,6 +54,21 @@ internal sealed partial class MainWindow : Window
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Colours the system-drawn frame the moment there is a handle to colour it on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>OnOpened</c> and not the constructor: the window has no platform handle until it is shown, and
|
||||
/// <see cref="NativeWindowFrame"/> does nothing without one. See that class for what the frame is and
|
||||
/// why <c>BorderOnly</c> still has one.
|
||||
/// </remarks>
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
NativeWindowFrame.MatchTo(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the Linux backend for the one mode it can actually draw inside this window.
|
||||
/// </summary>
|
||||
@@ -268,6 +283,7 @@ internal sealed partial class MainWindow : Window
|
||||
if (shell is { } previous)
|
||||
{
|
||||
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
|
||||
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||
}
|
||||
|
||||
@@ -286,6 +302,7 @@ internal sealed partial class MainWindow : Window
|
||||
wasUnlocked = viewModel.IsUnlocked;
|
||||
|
||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||
viewModel.PropertyChanged += OnShellPropertyChanged;
|
||||
}
|
||||
|
||||
@@ -298,6 +315,15 @@ internal sealed partial class MainWindow : Window
|
||||
/// </remarks>
|
||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||
|
||||
/// <remarks>
|
||||
/// The same call for a session that was already open and has just been typed into from the sidebar —
|
||||
/// see <see cref="MainWindowViewModel.TerminalFocusRequested"/>. Posted like every other path here,
|
||||
/// although nothing was revealed this turn: the post also re-checks that a terminal is still showing,
|
||||
/// which is what keeps this from stealing the keyboard if the insert landed the user on the snippets
|
||||
/// screen instead.
|
||||
/// </remarks>
|
||||
private void OnTerminalFocusRequested(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||
|
||||
/// <remarks>
|
||||
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
|
||||
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Paints the frame Windows still draws around a <c>BorderOnly</c> window in the application's own
|
||||
/// colour, so the top edge stops reading as a leftover system titlebar.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The symptom this exists for:</b> a pale strip across the very top of the window, a few pixels
|
||||
/// tall and plainly not part of the application — most obvious on a machine with "show accent colour
|
||||
/// on title bars and window borders" turned on, where it comes out blue against a near-black shell.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is not <c>TitleBar.axaml</c> leaking and it is not a margin. It is DWM, and the reason it is
|
||||
/// there is visible in Avalonia's own Win32 backend: <c>WindowImpl.UpdateWindowProperties</c> gives a
|
||||
/// <see cref="WindowDecorations.BorderOnly"/> window <c>WS_BORDER | WS_THICKFRAME</c> and then calls
|
||||
/// <c>DwmExtendFrameIntoClientArea</c> with one-pixel margins on all four sides. So the compositor
|
||||
/// owns a hairline of every edge of this window, and it fills that hairline with the system's caption
|
||||
/// and border colours — which are chosen by the user's personalisation settings and have no reason to
|
||||
/// resemble <c>CanvasColor</c>. The window is the wrong place to look for the pixels; they were never
|
||||
/// painted by anything in this tree.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The fix is to tell DWM what colour to use rather than to try to cover it. <c>DWMWA_BORDER_COLOR</c>
|
||||
/// and <c>DWMWA_CAPTION_COLOR</c> arrived in Windows 11 21H2 and are exactly that; both are set to the
|
||||
/// window's own background, so the hairline still exists — the resize grip is on it, and the drop
|
||||
/// shadow hangs off it — and simply cannot be seen. Deliberately <em>not</em> <c>DWMWA_COLOR_NONE</c>,
|
||||
/// which removes the border outright: on a dark desktop that leaves a near-black window with no edge
|
||||
/// at all, which trades one visual defect for another.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Windows 10 gets the dark-mode attribute and nothing else, and that is the whole of what is
|
||||
/// available there: the two colour attributes are unsupported, <c>DwmSetWindowAttribute</c> answers
|
||||
/// <c>E_INVALIDARG</c>, and the calls do nothing. Hence the ignored return values — every attribute
|
||||
/// here is an improvement where it lands and a no-op where it does not, so there is nothing for a
|
||||
/// caller to handle and nothing worth logging on a path that runs once at startup.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class NativeWindowFrame
|
||||
{
|
||||
/// <summary>Windows 11 21H2 and later: the colour of the frame border.</summary>
|
||||
private const int BorderColorAttribute = 34;
|
||||
|
||||
/// <summary>Windows 11 21H2 and later: the colour of the caption, including the extended frame.</summary>
|
||||
private const int CaptionColorAttribute = 35;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 10 1903 and later: draw the frame in the dark palette.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Redundant on Windows 11, where the two colour attributes above name the colours outright, and it
|
||||
/// is set anyway because it is the only one of the three that Windows 10 honours. The build before
|
||||
/// 1903 used attribute 19 for this; that is not chased here, because a border on an OS release that
|
||||
/// left support in 2020 is not worth a second interop call.
|
||||
/// </remarks>
|
||||
private const int DarkModeAttribute = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Matches <paramref name="window"/>'s system-drawn frame to the colour it paints itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Call once the window has a handle — <c>OnOpened</c> is the first such moment. Calling earlier
|
||||
/// finds no platform handle and silently does nothing, which is the defect this replaced: the strip
|
||||
/// is only visible once the window is on screen, so a call that ran too early looks like a fix that
|
||||
/// does not work rather than a fix that never ran.
|
||||
/// </remarks>
|
||||
internal static void MatchTo(Window window)
|
||||
{
|
||||
// Every attribute below is a DWM one, and DWM is Windows. Elsewhere the frame is drawn by the
|
||||
// platform's own compositor and there is nothing here to say to it.
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.TryGetPlatformHandle()?.Handle is not { } handle || handle == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Set(handle, DarkModeAttribute, 1);
|
||||
|
||||
// The window's own Background rather than a named resource, so the frame cannot drift from the
|
||||
// canvas when the palette moves. A brush that is not solid — a gradient, or nothing set at all —
|
||||
// has no single colour to match, and leaving the system's own is better than inventing one.
|
||||
if (window.Background is not ISolidColorBrush { Color: var canvas })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var reference = ColorRef(canvas);
|
||||
Set(handle, BorderColorAttribute, reference);
|
||||
Set(handle, CaptionColorAttribute, reference);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets one integer-valued DWM attribute, and discards the answer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The discard is the point of this method existing rather than being three call sites. Every
|
||||
/// attribute here is unsupported on some Windows this application runs on, and unsupported means
|
||||
/// <c>E_INVALIDARG</c> and no change — which is the intended outcome on that OS, not a failure, so
|
||||
/// there is nothing for the caller to do with the <c>HRESULT</c> and nothing worth logging once at
|
||||
/// startup. Written once, with the reasoning, rather than left implicit at each call.
|
||||
/// </remarks>
|
||||
private static void Set(IntPtr window, int attribute, int value) =>
|
||||
_ = DwmSetWindowAttribute(window, attribute, ref value, sizeof(int));
|
||||
|
||||
/// <summary>
|
||||
/// Packs <paramref name="color"/> into a Win32 <c>COLORREF</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>0x00BBGGRR</c> — blue in the high byte, not red, and the alpha byte must be zero. Getting the
|
||||
/// order wrong produces a plausible-looking wrong colour rather than an error, which is the kind of
|
||||
/// bug that survives a glance at the window.
|
||||
/// </remarks>
|
||||
private static int ColorRef(Color color) => color.R | (color.G << 8) | (color.B << 16);
|
||||
|
||||
/// <remarks>
|
||||
/// <c>DllImport</c> rather than <c>LibraryImport</c>, for the reason
|
||||
/// <see cref="NativeKeyboardFocus"/> gives at its own P/Invoke: the generated form needs
|
||||
/// <c>AllowUnsafeBlocks</c> across a project that handles key material, and this signature is
|
||||
/// blittable, so there is no marshalling for it to improve.
|
||||
/// </remarks>
|
||||
#pragma warning disable SYSLIB1054
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(IntPtr window, int attribute, ref int value, int size);
|
||||
#pragma warning restore SYSLIB1054
|
||||
}
|
||||
@@ -40,8 +40,9 @@
|
||||
Both are still one click away; see the popover below the user chip. The chip itself carries the signed-
|
||||
in identity this application actually has — a display name and, where the server sent one, an email —
|
||||
which is also new: the titlebar drew an account name and a vault chip before this pass and does not any
|
||||
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the deviations this rail keeps on
|
||||
purpose: Pins, which the mock has no screen for at all, and the S3 segment above.
|
||||
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the one deviation this rail still
|
||||
keeps on purpose: the S3 segment above. Pins was the other, and it is gone — see the remark where that
|
||||
row used to sit, between Keys and Snips.
|
||||
|
||||
Buttons rather than a TabStrip or a ListBox, still, for the reason the v3 remark gave: all three hold
|
||||
the selection themselves, so a click would move the highlight before the shell decided anything, and a
|
||||
@@ -128,19 +129,16 @@
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
KEPT — the mock has no screen for approved host keys at all; see the file-level remark. push_pin
|
||||
is the same codepoint HostsScreen.axaml already draws for a host's own pin badge, reused rather
|
||||
than picked afresh so the one concept reads as one glyph everywhere it appears.
|
||||
◆ NO Pins ROW. The pins screen is still here and still reached in one click — from "Host keys"
|
||||
on the Keys screen's own header, which is where a list of approved host keys belongs: they are
|
||||
keychain material, and that button was already the second way to reach them. Two rail rows away
|
||||
from each other, both landing on the same screen, is a rail that has to be read twice.
|
||||
|
||||
It is also the last of the rail's own deviations from the mock to go. The row was kept in v5b on
|
||||
the grounds that the design has no screen for approved host keys at all — see the file-level
|
||||
remark — which is true of the design and was never a reason for a rail entry once the keychain
|
||||
had a door to the same place.
|
||||
-->
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
||||
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Pins" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
@@ -191,17 +189,22 @@
|
||||
</Grid>
|
||||
|
||||
<FlyoutBase.AttachedFlyout>
|
||||
<Flyout Placement="TopEdgeAlignedLeft">
|
||||
<StackPanel Width="227" Spacing="8">
|
||||
<!--
|
||||
FlyoutPresenterClasses, because a Flyout's own panel is not in this markup's visual tree to be
|
||||
styled from here — see FlyoutPresenter.poppanel in App.axaml for what the class carries and why
|
||||
the shared popup rule was not simply widened to cover it.
|
||||
-->
|
||||
<Flyout Placement="TopEdgeAlignedLeft" FlyoutPresenterClasses="poppanel">
|
||||
<StackPanel Width="227" Spacing="4">
|
||||
|
||||
<!--
|
||||
The real email, when the server sent one — verified against MainWindowViewModel.Email rather
|
||||
than assumed, and simply absent from the popover when it has not. No " · Org" suffix: there
|
||||
is no organisation concept behind a vault, only the vault itself, which the rows below name.
|
||||
-->
|
||||
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1"
|
||||
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1" Margin="11,4,11,6"
|
||||
Foreground="{StaticResource TextGhost}"
|
||||
Text="{Binding Email}"
|
||||
Text="{Binding Email}" TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!--
|
||||
@@ -246,7 +249,7 @@
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderMid}" />
|
||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||
|
||||
<!--
|
||||
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
|
||||
@@ -260,16 +263,23 @@
|
||||
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource Text}" />
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
◆ 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 TextGhost}" />
|
||||
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
@@ -277,11 +287,11 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderMid}" />
|
||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||
|
||||
<!--
|
||||
The existing sign-out flow, with its own confirm card — see
|
||||
@@ -291,7 +301,7 @@
|
||||
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||
Foreground="{StaticResource Text}" />
|
||||
Foreground="{StaticResource TextGhost}" />
|
||||
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
@@ -87,12 +87,27 @@
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Two readings stacked against the two on the left, and in the same order: what this row
|
||||
IS above, how it is reached below.
|
||||
|
||||
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
|
||||
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
|
||||
The palette searches every readable vault at once, so without it two hosts a team and a
|
||||
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
|
||||
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
|
||||
vault name would otherwise take its width out of the host name beside it.
|
||||
|
||||
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
||||
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
||||
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
||||
-->
|
||||
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
|
||||
HorizontalAlignment="Right" MaxWidth="150"
|
||||
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SessionHeader"
|
||||
x:Name="Root"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
── v5b's session shell host header ──────────────────────────────────────────────────────────────────────
|
||||
60px, DeepChrome, atop the pane both the terminal and the SFTP surface hold. Per the design, minus the
|
||||
three deviations design-notes/v5b-fidelity-notes.md records: no OS label, no latency reading, no "Port
|
||||
forward" button — none of those are facts this application has.
|
||||
|
||||
◆ THE ONE FACT LEFT is the address, read off MainWindowViewModel.SessionAddress — which is already the
|
||||
surface-aware property, so this control asks no question about which screen it is on. What differs
|
||||
between the two usages is only the cross-surface button: <see cref="OpenLabel"/>, <see cref="OpenCommand"/>
|
||||
and the empty-state copy, all handed in from MainWindow.axaml rather than branched on here.
|
||||
-->
|
||||
|
||||
<Border Height="60" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="24,0,20,0">
|
||||
|
||||
<!--
|
||||
The address, only while a session/host context is active — see SessionAddress's own remark for what
|
||||
"active" means on each surface. The empty state takes its place otherwise, in the idiom every other
|
||||
screen's own "nothing yet" sentence already uses: TextFaint, sentence case, no punctuation implying a
|
||||
form to fill in.
|
||||
-->
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold" FontSize="14"
|
||||
Foreground="{StaticResource AccentText}" VerticalAlignment="Center"
|
||||
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="{Binding #Root.EmptyText}" TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNullOrEmpty}}" />
|
||||
|
||||
<Button Grid.Column="1" Classes="headerghost"
|
||||
Content="{Binding #Root.OpenLabel}"
|
||||
Command="{Binding #Root.OpenCommand}"
|
||||
CommandParameter="{Binding #Root.OpenCommandParameter}" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -1,58 +0,0 @@
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The v5b session shell's host header: the address, and a ghost button that crosses to the other surface.
|
||||
/// See the remark at the top of SessionHeader.axaml.
|
||||
/// </summary>
|
||||
internal sealed partial class SessionHeader : UserControl
|
||||
{
|
||||
/// <summary>What the cross-surface ghost button says — "Open SFTP" or "Open terminal".</summary>
|
||||
internal static readonly StyledProperty<string?> OpenLabelProperty =
|
||||
AvaloniaProperty.Register<SessionHeader, string?>(nameof(OpenLabel));
|
||||
|
||||
/// <summary>What the cross-surface ghost button runs.</summary>
|
||||
/// <remarks>
|
||||
/// The terminal usage binds <c>SelectFilesHostCommand</c> with the selected tab as its parameter; the
|
||||
/// SFTP usage binds <c>OpenTerminalForFilesHostCommand</c>, which needs none — see the remark on both in
|
||||
/// <c>MainWindowViewModel</c> for why the two directions are not symmetrical.
|
||||
/// </remarks>
|
||||
internal static readonly StyledProperty<ICommand?> OpenCommandProperty =
|
||||
AvaloniaProperty.Register<SessionHeader, ICommand?>(nameof(OpenCommand));
|
||||
|
||||
internal static readonly StyledProperty<object?> OpenCommandParameterProperty =
|
||||
AvaloniaProperty.Register<SessionHeader, object?>(nameof(OpenCommandParameter));
|
||||
|
||||
/// <summary>What the header says instead of an address, while no session/host context is active.</summary>
|
||||
internal static readonly StyledProperty<string?> EmptyTextProperty =
|
||||
AvaloniaProperty.Register<SessionHeader, string?>(nameof(EmptyText));
|
||||
|
||||
public SessionHeader() => InitializeComponent();
|
||||
|
||||
internal string? OpenLabel
|
||||
{
|
||||
get => GetValue(OpenLabelProperty);
|
||||
set => SetValue(OpenLabelProperty, value);
|
||||
}
|
||||
|
||||
internal ICommand? OpenCommand
|
||||
{
|
||||
get => GetValue(OpenCommandProperty);
|
||||
set => SetValue(OpenCommandProperty, value);
|
||||
}
|
||||
|
||||
internal object? OpenCommandParameter
|
||||
{
|
||||
get => GetValue(OpenCommandParameterProperty);
|
||||
set => SetValue(OpenCommandParameterProperty, value);
|
||||
}
|
||||
|
||||
internal string? EmptyText
|
||||
{
|
||||
get => GetValue(EmptyTextProperty);
|
||||
set => SetValue(EmptyTextProperty, value);
|
||||
}
|
||||
}
|
||||
@@ -19,70 +19,100 @@
|
||||
Every row here is a command the shell already exposes for exactly this purpose — see
|
||||
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
|
||||
so this control carries no logic of its own beyond the list it draws and the click it forwards.
|
||||
|
||||
── v5c-4: THE SESSION BLOCK AT THE HEAD, AND THE HEADER ROW THAT IS GONE ─────────────────────────────────
|
||||
The 60-pixel host header that used to sit above the pane on both surfaces has been retired, and its two
|
||||
contents moved up here: the address it printed, and the cross-surface button — "Open SFTP" from a
|
||||
terminal, "Open terminal" from SFTP. Which of the two words it is and which command it runs are resolved
|
||||
by the shell now rather than handed in from the two usage sites; see
|
||||
MainWindowViewModel.SessionCrossSurfaceLabel and OpenOtherSurfaceCommand. The pane keeps that height.
|
||||
|
||||
The address is the fact the header row existed for, so it moves rather than disappears. It sits where the
|
||||
QUICK ACCESS heading used to print the selected tab's short label — that label said less than the address
|
||||
does and would be the same word twice beside it.
|
||||
|
||||
── AND THE COLUMN CLOSES ────────────────────────────────────────────────────────────────────────────────
|
||||
300 pixels is a lot of a 1180-pixel window to give a list that is often two rows long, so the column
|
||||
folds to a 34-pixel rail carrying the way back. A rail rather than nothing: a panel that vanishes without
|
||||
trace is one people report as lost. Both halves live in this control and swap on
|
||||
MainWindowViewModel.IsSessionSidebarOpen, so MainWindow.axaml's own "Auto" column takes whichever width
|
||||
is showing without knowing anything about the state — and the pane beside it grows into what is freed.
|
||||
-->
|
||||
|
||||
<Border Width="300" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="6" Margin="16,20">
|
||||
<Panel>
|
||||
|
||||
<!-- ============ QUICK ACCESS ============ -->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="QUICK ACCESS" FontSize="10" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="10"
|
||||
Foreground="{StaticResource TextGhost}"
|
||||
Text="{Binding SelectedTab.Label}" TextTrimming="CharacterEllipsis" MaxWidth="130" />
|
||||
</Grid>
|
||||
<!-- ============ THE RAIL, WHEN THE COLUMN IS CLOSED ============ -->
|
||||
<!--
|
||||
Painted and bordered like the open column so the closing reads as the same surface narrowing rather
|
||||
than as one piece of furniture being swapped for another.
|
||||
-->
|
||||
<Border Width="34" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
||||
IsVisible="{Binding !IsSessionSidebarOpen}">
|
||||
<Button Classes="flat sidebargrip" VerticalAlignment="Top" Margin="0,20,0,0"
|
||||
Command="{Binding ToggleSessionSidebarCommand}"
|
||||
ToolTip.Tip="Show quick access, snips and the way across to the other surface">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="18"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</Border>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<!--
|
||||
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin
|
||||
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up
|
||||
from the path's string DataContext lands on it.
|
||||
-->
|
||||
<Button Classes="sidebarrow"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
|
||||
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<!-- ============ THE COLUMN ============ -->
|
||||
<Border Width="300" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
||||
IsVisible="{Binding IsSessionSidebarOpen}">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="6" Margin="16,20">
|
||||
|
||||
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
|
||||
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!-- ============ THE SESSION ============ -->
|
||||
<!--
|
||||
The address, and the button that closes the column. Both on one row, and the address is the
|
||||
trimming one: a long account@host:port is exactly the string that would otherwise push the
|
||||
close button off the edge of a panel whose whole point is that it can be got rid of.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0,0,0">
|
||||
<TextBlock 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>
|
||||
|
||||
<!-- ============ SNIPS (the terminal surface only) ============ -->
|
||||
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
|
||||
<!--
|
||||
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}" />
|
||||
|
||||
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
|
||||
<!-- ============ QUICK ACCESS ============ -->
|
||||
<TextBlock Classes="label" Text="QUICK ACCESS" FontSize="10" Margin="8,0" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible}">
|
||||
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<!--
|
||||
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin
|
||||
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up
|
||||
from the path's string DataContext lands on it.
|
||||
-->
|
||||
<Button Classes="sidebarrow"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{Binding Snippet.Command}">
|
||||
ToolTip.Tip="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
|
||||
FontSize="11" Foreground="{StaticResource AccentText}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
|
||||
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
@@ -90,19 +120,54 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}"
|
||||
ToolTip.Tip="Opens the snippet editor.">
|
||||
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
|
||||
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
<!-- ============ SNIPS (the terminal surface only) ============ -->
|
||||
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<Button Classes="sidebarrow"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{Binding Snippet.Command}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
|
||||
FontSize="11" Foreground="{StaticResource AccentText}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}"
|
||||
ToolTip.Tip="Opens the snippet editor.">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
</UserControl>
|
||||
@@ -60,12 +60,23 @@
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
|
||||
<!--
|
||||
Two states, as the strip's own dots always were: green while the shell behind this tab is
|
||||
running, grey while it is connecting and once it has ended. The design's third, amber,
|
||||
state has no meaning here — nothing in this application checks whether a host is merely
|
||||
reachable — so it is not drawn; see design-notes/v5b-fidelity-notes.md.
|
||||
Three states now, where there were two. Green while the shell behind this tab is running
|
||||
and grey once it has ended, as the strip's dots always were — and amber while it is
|
||||
connecting, which used to be grey as well.
|
||||
|
||||
The design's amber had no meaning here while nothing in this application knew how far a
|
||||
connection had got; that changed with the step list, and the note this comment used to
|
||||
carry — that amber is for a host merely reachable, so it is not drawn — is answered
|
||||
rather than ignored. It is not being reachable that is amber, it is being underway. See
|
||||
ConnectingCard.axaml, whose track and running step are the same colour for the same
|
||||
reason, and design-notes/v5b-fidelity-notes.md for the state this is not.
|
||||
|
||||
Worth the third colour because the two it replaces were the same one: a tab still
|
||||
dialling and a tab whose shell has exited both drew grey, which are the two states in
|
||||
this strip with the least in common — one is worth waiting for and the other is over.
|
||||
-->
|
||||
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}"
|
||||
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 SessionHeader and
|
||||
SessionStatusBar are their own files: nothing here can be measured by a test that hosts the real window,
|
||||
A separate control rather than a variant of TitleBar itself, on the same reasoning SessionStatusBar and
|
||||
SessionSidebar are their own files: nothing here can be measured by a test that hosts the real window,
|
||||
and a control that is either "the wordmark bar" or "the settings bar" depending on a bound flag would be
|
||||
two controls wearing one name. The dragging, maximising and closing logic is duplicated from TitleBar's
|
||||
own code-behind rather than shared through a base class — four short handlers, and the day one of the two
|
||||
|
||||
@@ -79,8 +79,16 @@
|
||||
-->
|
||||
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||
<!-- CTRL K, not the design's ⌘K — see the remark at the top of this file. -->
|
||||
<Border Grid.Column="2" Width="34" Height="18" CornerRadius="5"
|
||||
<!--
|
||||
CTRL K, not the design's ⌘K — see the remark at the top of this file.
|
||||
|
||||
◆ PADDED RATHER THAN 34 WIDE, which is the design's own width for a chip reading ⌘K: two
|
||||
glyphs, where the substitution this bar makes is six characters and a space. At 10.5 mono
|
||||
that run is wider than 34, so the chip clipped it — "CTRL" with the K cut in half. MinWidth
|
||||
keeps the design's footprint for the day this face has a ⌘ to draw, and the padding is what
|
||||
the longer label actually needs.
|
||||
-->
|
||||
<Border Grid.Column="2" MinWidth="34" Height="18" CornerRadius="5" Padding="7,0"
|
||||
Background="{StaticResource KbdChip}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
|
||||
|
||||
@@ -33,6 +33,43 @@
|
||||
layer has not got. See docs/design-import-gaps.md.
|
||||
-->
|
||||
|
||||
<!--
|
||||
══ THE TWO LISTINGS' COLUMNS ═════════════════════════════════════════════════════════════════════════
|
||||
◆ They were fixed pixel widths, and fixed was wrong at both ends of the range this screen actually gets.
|
||||
|
||||
The numbers — 20/*/60/56 here and 20/*/56/52/48 across the arrow column — were chosen against the 204
|
||||
pixels the session shell's own QUICK ACCESS sidebar leaves a pane at the window's minimum. Measured,
|
||||
they were wrong in both directions at once:
|
||||
|
||||
· MODIFIED prints "2026-08-21 11:36", which needs 116 pixels, and never had more than 48. So the
|
||||
column was truncated at every width this screen has ever been drawn at — a maximised window showed
|
||||
"2026-08" beside a NAME column with four hundred empty pixels in it. PERMS needs 60 for a
|
||||
ten-character mode and had 48; SIZE needs 58 for "999.9 GB" and had 56.
|
||||
· At 204 the remote pane's NAME column measured **zero pixels wide**. Twenty for the glyph plus
|
||||
56+52+48 for the numbers is 176 of the 180 a row has, so the file names — the one column a file
|
||||
browser exists to show — were not drawn at all.
|
||||
|
||||
Star columns with a MaxWidth fix both, because the cap is what makes "share the width" mean the right
|
||||
thing at each end. Wide, the three numeric columns stop growing at what their own text needs and NAME
|
||||
keeps every remaining pixel. Narrow, nothing has a MinWidth to overflow with, so all four give ground
|
||||
together and NAME — weighted heaviest — keeps enough to read a name by.
|
||||
|
||||
MODIFIED carries the extra weight (1.6) so that it reaches its cap first: it is the widest content and
|
||||
the column somebody is most often actually reading. The local pane's NAME is 3* against the remote's 2*,
|
||||
which is the remote's PERMS weight folded into it — that is what makes SIZE and MODIFIED come out the
|
||||
same width on both sides of the arrow column, so the same amount of a timestamp is visible in each. See
|
||||
Timestamps.Format for why both panes write one the same way in the first place; a shared format read at
|
||||
two different widths would have made that argument for nothing.
|
||||
|
||||
◆ FOUR COPIES OF THIS, and they have to stay in step: a heading row and a row template per pane. Avalonia
|
||||
cannot share one ColumnDefinitions instance between two Grids — each mutates its own computed sizes —
|
||||
and SharedSizeGroup only applies to Auto columns. What catches a copy that drifts is measurement rather
|
||||
than discipline: ScreenLayoutTests holds every cell against the width its own text needs
|
||||
(TheListingColumnsShowTheirContentInFullWhenThereIsRoom), the headings against each other
|
||||
(TheListingHeadingsDoNotRunIntoEachOther), and NAME against zero at the narrowest budget
|
||||
(TheNameColumnSurvivesTheNarrowestBudget).
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
<!--
|
||||
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -259,10 +296,18 @@
|
||||
the row template below for why the whole column set was chosen against 204 pixels rather than the
|
||||
design's own 1920-pixel canvas.
|
||||
-->
|
||||
<Grid Grid.Row="2" ColumnDefinitions="20,*,60,56" Margin="0,2,10,4">
|
||||
<Grid Grid.Row="2" Margin="0,2,10,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="20" />
|
||||
<ColumnDefinition Width="3*" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
|
||||
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" Margin="8,0,0,0"
|
||||
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
@@ -274,13 +319,19 @@
|
||||
SelectedItem="{Binding SelectedLocalEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="20,*,60,56" Height="38" Margin="14,0,10,0">
|
||||
<Grid Height="38" Margin="14,0,10,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="20" />
|
||||
<ColumnDefinition Width="3*" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<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="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
Margin="8,0,0,0" 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>
|
||||
@@ -327,10 +378,12 @@
|
||||
|
||||
<!-- ==== The host ==== -->
|
||||
<!--
|
||||
Six rows rather than five: the connection's own strip is the new one, and it is inside this pane
|
||||
rather than above both because it is about this pane and nothing else.
|
||||
Seven rows. Two of them are furniture over a listing rather than part of it: the connection's own
|
||||
strip, and the row of things that can be done to whichever row is chosen. Both are inside this pane
|
||||
rather than above both panes because both are about this one and nothing else. The two questions —
|
||||
deleting, and renaming or moving — share a single row, since only one of the two is ever on screen.
|
||||
-->
|
||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
|
||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*"
|
||||
DragDrop.AllowDrop="True">
|
||||
|
||||
<!--
|
||||
@@ -344,25 +397,48 @@
|
||||
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
||||
-->
|
||||
<!--
|
||||
The path is alone in the star column for the reason the local pane's header remark spells out:
|
||||
TextTrimming needs the finite width only a star column gives it, and this is the pane where the
|
||||
long path is not even unusual — it is any host with a deep directory tree.
|
||||
◆ THE PATH IS TYPED HERE, and that is what the star column is now holding.
|
||||
|
||||
It was a TextBlock printing where the pane was. A breadcrumb only reaches what is under the
|
||||
directory already listed, so crossing a host's filesystem was a walk to the common ancestor and
|
||||
back down — a click and a round trip a level — and a path somebody already knew could not be
|
||||
entered at all. Enter goes; Escape puts back what the pane is really showing. See
|
||||
TransfersViewModel.RemotePathDraft for why the box holds its own value rather than RemotePath
|
||||
itself, and SftpPath.Resolve for what an entry that is not absolute means.
|
||||
|
||||
Still alone in the star column, for the reason the local pane's header remark spells out: this box
|
||||
has to be bounded, and an Auto column beside a StackPanel would measure it at its content's width
|
||||
and push the buttons past the pane's edge.
|
||||
|
||||
DELETE has left this row. It acts on the selected row rather than on the pane, and it is one of
|
||||
three that do now — see the item row below, which is where the three of them are together and
|
||||
where there is room for three rather than for one.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"
|
||||
Margin="0,0,12,0">
|
||||
Margin="0,0,8,0">
|
||||
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsHostPicker}" />
|
||||
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsBucketPicker}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left"
|
||||
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding RemotePath}" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
|
||||
<TextBox Grid.Column="1" Classes="pathbox" VerticalAlignment="Center"
|
||||
Text="{Binding RemotePathDraft}" IsEnabled="{Binding IsConnected}"
|
||||
PlaceholderText="path"
|
||||
ToolTip.Tip="Type a path and press Enter. A relative path is taken from here; ~ is the account's home directory.">
|
||||
<!--
|
||||
One word, because the only time this placeholder shows is on a pane with nothing connected,
|
||||
where the box is disabled and the invitation below is doing the explaining. "/path/to/
|
||||
somewhere" needs 146 pixels and had 91 at the session shell's narrowest, so it drew as a
|
||||
path cut off mid-segment. What the box is for is in the tooltip, where there is room for it.
|
||||
-->
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding GoToTypedRemotePathCommand}" />
|
||||
<KeyBinding Gesture="Escape" Command="{Binding RevertTypedRemotePathCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
@@ -371,10 +447,6 @@
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
|
||||
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -382,8 +454,9 @@
|
||||
<!--
|
||||
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
|
||||
|
||||
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: SessionHeader now
|
||||
prints the very same address above this whole screen — see MainWindowViewModel.SessionAddress,
|
||||
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: the session shell
|
||||
prints the very same address beside this screen — in the sidebar's own session block since v5c-4
|
||||
retired the header row that printed it above — see MainWindowViewModel.SessionAddress,
|
||||
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
|
||||
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
|
||||
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
|
||||
@@ -401,9 +474,53 @@
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The question DELETE asks. Under the button rather than over the pane, so the row it is about is
|
||||
still on screen and still selected while it is being answered — and it names the full path rather
|
||||
than the file, because a name is the half that does not identify anything.
|
||||
◆ WHAT CAN BE DONE TO THE ROW THAT IS CHOSEN. Only while something is connected.
|
||||
|
||||
Three buttons in one place, which is the point. DELETE used to sit alone in the pane header beside
|
||||
UP and REFRESH — two things that move the pane and one that destroys a file, in one row — and
|
||||
there was no rename and no move at all, so a file that had landed under the wrong name could only
|
||||
be put right by deleting it and sending it again. These three act on the selection, so they belong
|
||||
together and away from the two that act on the pane.
|
||||
|
||||
The name beside them is the row they are about, ellipsized. It says which row rather than which
|
||||
path: the listing directly underneath has that row highlighted, so a full path here would be
|
||||
repeating what the selection is already showing. The forms the buttons open do print the path,
|
||||
because that is the moment something is committed rather than merely looked at.
|
||||
|
||||
The row goes while one of the three questions is up rather than sitting there greyed out, and so
|
||||
does the breadcrumb below it — see ShowsRemoteItemActions. The form that opens is the answer to
|
||||
what these buttons asked, and inside the session shell this pane is 204 pixels wide: the height
|
||||
goes to the question or to the buttons that raised it, not to both.
|
||||
-->
|
||||
<Border Grid.Row="2" Padding="12,5" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1" IsVisible="{Binding ShowsRemoteItemActions}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="11.5" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding SelectedRemoteName}" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||
<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>
|
||||
|
||||
<!--
|
||||
The question DELETE asks. It names the full path rather than the file, because a name is the half
|
||||
that does not identify anything — and that is now the whole of how the question says which row it
|
||||
is about: at the session shell's own 204-pixel width there is no height left for a listing under
|
||||
it, so "the row is still on screen and still selected" is no longer true and the path in the card
|
||||
is what carries the identity. See the rename form below, which is measured in the same shape.
|
||||
|
||||
This is the strongest warning on any of these screens, and deliberately: everything else this
|
||||
application deletes is a tombstone against a copy the server still has, and a file on somebody's
|
||||
@@ -414,7 +531,7 @@
|
||||
CANCEL side by side want closer to 220, so at the narrow width they wrap onto their own line
|
||||
instead of one of them going off the edge, which the WrapPanel achieves for free.
|
||||
-->
|
||||
<Border Grid.Row="2" Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding IsConfirmingRemoteDeletion}">
|
||||
<StackPanel Spacing="7">
|
||||
@@ -434,7 +551,65 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
||||
<!--
|
||||
◆ RENAME AND MOVE, in the row the deletion question uses and never at the same time as it.
|
||||
|
||||
One form for both, because they differ only in what the box starts out holding and in the words
|
||||
around it — see RemoteMoveRequest, where both sets are chosen. It shares this Grid row with
|
||||
the deletion card above: the three questions are mutually exclusive by construction, each one's
|
||||
own opening clearing the others, so a row of its own would be a row that is empty whenever this
|
||||
one is not.
|
||||
|
||||
It is not painted in the deletion card's danger colours, and that is deliberate rather than an
|
||||
oversight. A rename is undone by renaming back; the deletion beside it is the one thing this
|
||||
application does that nothing can walk back, and spending the same red on both would make the red
|
||||
stop meaning anything.
|
||||
|
||||
Enter commits from inside the box, which is where the hands already are once an answer has been
|
||||
typed.
|
||||
|
||||
◆ IT TAKES THE WHOLE PANE at the session shell's own 204-pixel width, and that is measured rather
|
||||
than hoped: the listing's star row is squeezed to nothing while either question is open, exactly
|
||||
as the deletion card above already squeezed it. There is no room for a form and a listing in 336
|
||||
pixels, and the form carries the full path precisely so the row underneath does not have to be
|
||||
visible for the question to be about something identifiable. That is why the item row and the
|
||||
breadcrumb step aside instead — the height they free is what keeps the form itself un-clipped,
|
||||
which is what ScreenLayoutTests measures.
|
||||
-->
|
||||
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding IsMovingRemote}">
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteMove.Question}" />
|
||||
<SelectableTextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding PendingRemoteMove.FullPath}" />
|
||||
<TextBox Text="{Binding RemoteMoveTarget}"
|
||||
PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConfirmMoveRemoteCommand}" />
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CancelMoveRemoteCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteMove.Note}" />
|
||||
<WrapPanel>
|
||||
<Button Classes="accent" Content="{Binding PendingRemoteMove.ConfirmLabel}" Margin="0,0,8,4"
|
||||
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
|
||||
Command="{Binding CancelMoveRemoteCommand}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Away while a question is up, for the reason the item row above states: the trail is where the pane
|
||||
is and MKDIR makes a directory, and neither is what somebody answering a question about one row
|
||||
is doing.
|
||||
-->
|
||||
<Grid Grid.Row="4" ColumnDefinitions="*,Auto" Margin="12,6,12,4"
|
||||
IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -466,10 +641,16 @@
|
||||
is showing, 140 plus MKDIR plus the spacing between them ran past the pane's own edge. Still wide
|
||||
enough for a real directory name to be typed and read back, which is what this box is for; a
|
||||
name that outgrows it scrolls inside the box the way every TextBox already does.
|
||||
|
||||
◆ The placeholder says "directory" rather than "new directory", which is what it said until it
|
||||
was measured: the longer text needs 101 pixels inside a box that has 74, so it drew as "new
|
||||
directo" — a placeholder cut off mid-word, which reads as a rendering fault rather than as a
|
||||
hint. MKDIR is right beside it and carries the "new" half already. The path box in the header
|
||||
above lost the same argument and for the same reason; see its own PlaceholderText.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
||||
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="directory" />
|
||||
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
@@ -479,25 +660,42 @@
|
||||
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
|
||||
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
|
||||
-->
|
||||
<Grid Grid.Row="4" ColumnDefinitions="20,*,56,52,48" Margin="0,2,10,4">
|
||||
<Grid Grid.Row="5" Margin="0,2,10,4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="20" />
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" Margin="8,0,0,0"
|
||||
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" Margin="8,0,0,0"
|
||||
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" Margin="6,0,0,0"
|
||||
HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="5" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
||||
<ListBox Grid.Row="6" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="20,*,56,52,48" Height="38" Margin="14,0,10,0">
|
||||
<Grid Height="38" Margin="14,0,10,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="20" />
|
||||
<ColumnDefinition Width="2*" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
<ColumnDefinition Width="1.6*" MaxWidth="124" />
|
||||
<ColumnDefinition Width="*" MaxWidth="66" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="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="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
Margin="8,0,0,0" 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 perms" Classes.loose="{Binding IsWorldWritable}"
|
||||
@@ -512,7 +710,7 @@
|
||||
A directory with nothing in it. It no longer has to say anything about not being connected: that
|
||||
state is the invitation below, which covers this pane whole.
|
||||
-->
|
||||
<TextBlock Grid.Row="5" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
|
||||
<TextBlock Grid.Row="6" Classes="hint" FontSize="12" Margin="24" MaxWidth="300"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
|
||||
Text="Nothing in this directory." IsVisible="{Binding !HasRemoteEntries}" />
|
||||
|
||||
@@ -534,7 +732,7 @@
|
||||
the picker arrives, so the tall shape is the form rather than form-plus-prose, and neither shape
|
||||
reaches the floor. Anything added here has to hold that; the layout suite measures both.
|
||||
-->
|
||||
<Border Grid.Row="1" Grid.RowSpan="5" Background="{StaticResource Canvas}"
|
||||
<Border Grid.Row="1" Grid.RowSpan="6" Background="{StaticResource Canvas}"
|
||||
IsVisible="{Binding !IsConnected}">
|
||||
<StackPanel Spacing="10" Margin="20" MaxWidth="320"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
@@ -678,11 +876,11 @@
|
||||
window that has stopped answering, and the answer arriving after the drop is the answer arriving
|
||||
too late. See the local pane for why neither may hit-test.
|
||||
-->
|
||||
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
|
||||
<Border Grid.Row="0" Grid.RowSpan="7" IsHitTestVisible="False"
|
||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
|
||||
|
||||
<Border Grid.Row="0" Grid.RowSpan="6" IsHitTestVisible="False"
|
||||
<Border Grid.Row="0" Grid.RowSpan="7" IsHitTestVisible="False"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
|
||||
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="12"
|
||||
|
||||
@@ -405,7 +405,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -465,9 +465,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -567,11 +567,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,14 +55,14 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -81,11 +81,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,13 @@ namespace DodoSSH.Client.Session;
|
||||
/// <param name="AutomaticUpdateChecks">
|
||||
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
|
||||
/// </param>
|
||||
/// <param name="SessionSidebarOpen">
|
||||
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
|
||||
/// </param>
|
||||
public sealed record ClientSettings(
|
||||
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
|
||||
bool AutomaticUpdateChecks = true)
|
||||
bool AutomaticUpdateChecks = true,
|
||||
bool SessionSidebarOpen = true)
|
||||
{
|
||||
/*
|
||||
A positional record, and the defaults live on the parameters rather than on property initializers.
|
||||
@@ -102,6 +106,24 @@ public sealed record ClientSettings(
|
||||
warns against.
|
||||
*/
|
||||
|
||||
/*
|
||||
SessionSidebarOpen: why closing the sidebar is remembered, and why it is remembered here.
|
||||
|
||||
On by default, because the sidebar is where a session's pins, its snips and the way across to the
|
||||
other surface live — a first launch that hid all three would be hiding the feature rather than
|
||||
offering to.
|
||||
|
||||
Remembered at all because closing it is a choice about how much of a 1180-pixel window a terminal
|
||||
gets, and a choice that has to be made again on every launch is one the application is not really
|
||||
offering. It belongs in this file rather than in the vault for the same reason the font size does:
|
||||
it is a fact about this screen, not about this keychain, and following somebody from a 27-inch
|
||||
monitor onto a laptop would be a preference nobody asked for.
|
||||
|
||||
Not per-surface and not per-tab. The sidebar is one control drawn on two screens — see
|
||||
SessionSidebar.axaml — and a window where it is open on SFTP and closed on the terminal is a
|
||||
window that appears to lose it at random.
|
||||
*/
|
||||
|
||||
/// <summary>Brings a value inside the range this type will store.</summary>
|
||||
public static int ClampTerminalFontSize(int pixels) =>
|
||||
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -187,9 +187,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -289,11 +289,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +328,9 @@ 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
|
||||
@@ -470,6 +473,19 @@ 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="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,
|
||||
@@ -483,8 +499,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
ResumeHandler? resume = null,
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
string? deviceName = null,
|
||||
IUpdateChannel? updates = null)
|
||||
IUpdateChannel? updates = null,
|
||||
Action<Action>? post = null)
|
||||
{
|
||||
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
||||
|
||||
this.paths = paths;
|
||||
this.caches = caches;
|
||||
this.workspace = workspace;
|
||||
@@ -531,16 +550,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
updateScreen = CreateUpdateScreen(updates);
|
||||
|
||||
// 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);
|
||||
ApplyStoredPreferences();
|
||||
|
||||
_ = 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>
|
||||
@@ -984,6 +1017,20 @@ 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;
|
||||
@@ -1936,9 +1983,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return;
|
||||
}
|
||||
|
||||
// Removed first, so the workspace's SessionEnded — which fires as the pump unwinds — finds no tab to
|
||||
// mark dead and does nothing. The alternative ordering leaves a window in which a tab that is on its
|
||||
// way out is repainted as disconnected.
|
||||
// Removed first, so the workspace's SessionEnded — announced once the close below has fully drained
|
||||
// — finds no tab to mark dead and does nothing here. The alternative ordering leaves a window in
|
||||
// which a tab that is on its way out is repainted as disconnected.
|
||||
var index = Tabs.IndexOf(tab);
|
||||
Tabs.Remove(tab);
|
||||
|
||||
@@ -2094,8 +2141,9 @@ 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, 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.
|
||||
/// 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"/>.
|
||||
/// </remarks>
|
||||
private void RefreshSearchResults()
|
||||
{
|
||||
@@ -2128,6 +2176,21 @@ 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))
|
||||
@@ -2140,8 +2203,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return 1;
|
||||
}
|
||||
|
||||
return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|
||||
? 2
|
||||
if (host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return host.HasVaultBadge
|
||||
&& host.VaultName.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|
||||
? 3
|
||||
: int.MaxValue;
|
||||
}
|
||||
|
||||
@@ -3243,7 +3312,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
oldValue.PropertyChanged -= OnVaultPropertyChanged;
|
||||
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
|
||||
|
||||
// The three connection events are kept while an attempt is still in flight, and that is not an
|
||||
// The four 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
|
||||
@@ -3257,6 +3326,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (attempts.Count == 0)
|
||||
{
|
||||
oldValue.ConnectionStarting -= OnVaultConnectionStarting;
|
||||
oldValue.ConnectionProgress -= OnVaultConnectionProgress;
|
||||
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
|
||||
oldValue.SessionOpened -= OnVaultSessionOpened;
|
||||
}
|
||||
@@ -3265,6 +3335,7 @@ 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;
|
||||
@@ -3406,6 +3477,41 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
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>
|
||||
@@ -3768,6 +3874,72 @@ 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>
|
||||
@@ -3840,6 +4012,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -3933,14 +4111,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account and endpoint the session shell's header and status bar are about right now, or null when
|
||||
/// The account and endpoint the session shell's sidebar 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>SessionHeader.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
|
||||
/// source directly — <c>SessionSidebar.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. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
|
||||
/// 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"/>,
|
||||
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
|
||||
/// </remarks>
|
||||
internal string? SessionAddress => Surface switch
|
||||
@@ -4085,6 +4264,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,129 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// One named part of making a connection, in the order they happen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="SshConnectionPhase"/> with one more at the front. The SSH assembly reports four phases and
|
||||
/// knows about no others, which is correct for it — it has never heard of a renderer. But the first thing a
|
||||
/// connection here waits on is the terminal page attaching its socket, and on the first connection after a
|
||||
/// cold start that is a real wait with a real failure mode of its own: a missing WebView2 runtime. A step
|
||||
/// list that began at "reaching the host" would leave the one wait most likely to hang unnamed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Declared here rather than shared with the SSH layer for that reason, and the mapping between the two is
|
||||
/// one <c>switch</c> in <c>VaultViewModel</c>. The numbering is the order and the order is load-bearing:
|
||||
/// <see cref="TerminalTabViewModel.Advance"/> compares these values to decide what is already behind it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal enum ConnectionStep
|
||||
{
|
||||
/// <summary>Waiting for the renderer to attach, before anything is dialled.</summary>
|
||||
PreparingTerminal = 0,
|
||||
|
||||
/// <inheritdoc cref="SshConnectionPhase.Reaching" />
|
||||
Reaching = 1,
|
||||
|
||||
/// <inheritdoc cref="SshConnectionPhase.CheckingHostKey" />
|
||||
CheckingHostKey = 2,
|
||||
|
||||
/// <inheritdoc cref="SshConnectionPhase.Authenticating" />
|
||||
Authenticating = 3,
|
||||
|
||||
/// <inheritdoc cref="SshConnectionPhase.OpeningShell" />
|
||||
OpeningShell = 4,
|
||||
}
|
||||
|
||||
/// <summary>How one step of a connection is getting on.</summary>
|
||||
/// <remarks>
|
||||
/// Four states rather than a bool per row, because a step list is read as a sequence and the reader's
|
||||
/// question at each row is which of the four this is: behind us, happening, not yet, or where it stopped.
|
||||
/// <see cref="Stopped"/> exists only for the row a failure landed on — see
|
||||
/// <see cref="TerminalTabViewModel.Failed"/> — and is what turns the list from a progress bar into an
|
||||
/// account of how far the attempt got.
|
||||
/// </remarks>
|
||||
internal enum ConnectionStepState
|
||||
{
|
||||
/// <summary>Not started. Nothing is known about it yet.</summary>
|
||||
Pending = 0,
|
||||
|
||||
/// <summary>Happening now.</summary>
|
||||
Running = 1,
|
||||
|
||||
/// <summary>Finished, because something after it started.</summary>
|
||||
Done = 2,
|
||||
|
||||
/// <summary>Where the attempt stopped. There is no step after this one.</summary>
|
||||
Stopped = 3,
|
||||
}
|
||||
|
||||
/// <summary>One row of the connecting card's step list.</summary>
|
||||
/// <remarks>
|
||||
/// A view model per step rather than an index the view compares against, because each row draws its own
|
||||
/// state and an <c>ItemsControl</c> has no way to ask "am I before the current one?" — the alternative was a
|
||||
/// converter taking two bindings, which is the same comparison written somewhere it cannot be tested.
|
||||
/// </remarks>
|
||||
internal sealed partial class ConnectionStepViewModel : ObservableObject
|
||||
{
|
||||
internal ConnectionStepViewModel(ConnectionStep step, string caption)
|
||||
{
|
||||
Step = step;
|
||||
Caption = caption;
|
||||
}
|
||||
|
||||
/// <summary>Which step this is.</summary>
|
||||
internal ConnectionStep Step { get; }
|
||||
|
||||
/// <summary>What the row says, in the present tense of the thing being waited on.</summary>
|
||||
internal string Caption { get; }
|
||||
|
||||
/// <inheritdoc cref="ConnectionStepState" />
|
||||
[ObservableProperty]
|
||||
private ConnectionStepState state;
|
||||
|
||||
/// <summary>Whether this step is the one happening now.</summary>
|
||||
internal bool IsRunning => State is ConnectionStepState.Running;
|
||||
|
||||
/// <summary>Whether this step finished.</summary>
|
||||
internal bool IsDone => State is ConnectionStepState.Done;
|
||||
|
||||
/// <summary>Whether the attempt stopped on this step.</summary>
|
||||
internal bool IsStopped => State is ConnectionStepState.Stopped;
|
||||
|
||||
/// <summary>
|
||||
/// The character drawn beside the caption for whichever state this is in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Here rather than in a converter for the reason <c>TransferRowViewModel.StatusWord</c> is: the mapping
|
||||
/// is four cases with no arithmetic, and a converter would put it in a file the shell's tests cannot
|
||||
/// reach. The colours stay in the view, where the palette is.
|
||||
/// <para>
|
||||
/// Four distinguishable shapes rather than one recoloured, because the difference between a step that
|
||||
/// finished and a step still running has to survive somebody who cannot tell this design's green from
|
||||
/// its amber.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string Mark => State switch
|
||||
{
|
||||
ConnectionStepState.Done => "✓",
|
||||
ConnectionStepState.Running => "●",
|
||||
ConnectionStepState.Stopped => "✕",
|
||||
_ => "○",
|
||||
};
|
||||
|
||||
partial void OnStateChanged(ConnectionStepState value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsStopped));
|
||||
OnPropertyChanged(nameof(Mark));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How far along a tab's connection is.
|
||||
/// </summary>
|
||||
@@ -57,8 +179,23 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
{
|
||||
Label = label;
|
||||
Address = address;
|
||||
status = "connecting…";
|
||||
isLive = false;
|
||||
|
||||
Steps =
|
||||
[
|
||||
new ConnectionStepViewModel(ConnectionStep.PreparingTerminal, "Starting the terminal"),
|
||||
new ConnectionStepViewModel(ConnectionStep.Reaching, "Reaching the host"),
|
||||
new ConnectionStepViewModel(ConnectionStep.CheckingHostKey, "Checking the host key"),
|
||||
new ConnectionStepViewModel(ConnectionStep.Authenticating, "Signing in"),
|
||||
new ConnectionStepViewModel(ConnectionStep.OpeningShell, "Opening the shell"),
|
||||
];
|
||||
|
||||
// The first step is running before anything is awaited, because it is: the tab is created in the
|
||||
// same turn as the click and the renderer wait starts immediately after. A list that opened with
|
||||
// every row pending would show a connection that had not begun, which is one turn of the dispatcher
|
||||
// away from being untrue and is the turn the card is first drawn in.
|
||||
status = Steps[0].Caption;
|
||||
Steps[0].State = ConnectionStepState.Running;
|
||||
}
|
||||
|
||||
/// <summary>A tab for a session that is already open.</summary>
|
||||
@@ -72,6 +209,12 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
state = TerminalTabState.Open;
|
||||
status = string.Empty;
|
||||
isLive = true;
|
||||
|
||||
// A session that already exists got through every step by definition, even though this tab watched
|
||||
// none of them happen — an adopted session is one whose connecting tab the user closed. The list is
|
||||
// never drawn for a tab in this state; it is filled in so that nothing downstream has to treat "open"
|
||||
// as a fourth answer to "how far did it get".
|
||||
CompleteSteps();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -128,6 +271,36 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
/// </remarks>
|
||||
internal string? IdentityLabel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How far this connection got, step by step, for the card that stands in for the pane.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Fixed at construction and never added to or removed from — the steps of a connection are known before
|
||||
/// it starts, and only their state changes — so a plain array is enough and the view needs no collection
|
||||
/// change notification for it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every row here is reported, not guessed.</b> The states come from
|
||||
/// <see cref="SshConnectionPhase"/>, raised by the handshake itself at the moment each part of it begins.
|
||||
/// Nothing on this list is a timer, a fraction, or a step this view model decided had probably finished
|
||||
/// by now. That is the whole reason it is worth showing: a card that invented plausible progress would be
|
||||
/// indistinguishable from one that had stopped receiving any.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal IReadOnlyList<ConnectionStepViewModel> Steps { get; }
|
||||
|
||||
/// <summary>How many steps are behind the attempt, for the card's track.</summary>
|
||||
/// <remarks>
|
||||
/// Counted rather than stored, and it counts <see cref="ConnectionStepState.Done"/> alone: the running
|
||||
/// step is deliberately not half a step. The track fills to where the attempt has actually got to and
|
||||
/// stops there, which is the same promise the list itself makes.
|
||||
/// </remarks>
|
||||
internal int StepsDone => Steps.Count(step => step.IsDone);
|
||||
|
||||
/// <summary>How many steps there are, for the card's track.</summary>
|
||||
internal int StepCount => Steps.Count;
|
||||
|
||||
/// <inheritdoc cref="TerminalTabState" />
|
||||
[ObservableProperty]
|
||||
private TerminalTabState state;
|
||||
@@ -187,6 +360,54 @@ 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)
|
||||
{
|
||||
@@ -194,6 +415,8 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
Status = string.Empty;
|
||||
IsLive = true;
|
||||
State = TerminalTabState.Open;
|
||||
|
||||
CompleteSteps();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -208,9 +431,33 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
{
|
||||
Status = reason;
|
||||
IsLive = false;
|
||||
|
||||
// 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>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));
|
||||
|
||||
@@ -306,6 +306,83 @@ internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool
|
||||
+ "touched, and there is no undo.";
|
||||
}
|
||||
|
||||
/// <summary>Which of the two things a rename actually is, from where the user is standing.</summary>
|
||||
/// <remarks>
|
||||
/// One SFTP operation underneath — <c>ISftpSession.RenameAsync</c> is the whole of both — and two separate
|
||||
/// questions on the screen, because the answer a person types is a different kind of thing each time. A
|
||||
/// rename takes a name and must not take a path; a move takes a directory and must not take a name. Folding
|
||||
/// them into one box asking for a destination path would be fewer controls and a worse question: the two
|
||||
/// mistakes it invites — typing a directory where a name goes, or a name where a directory goes — are
|
||||
/// exactly the ones this split makes impossible to make.
|
||||
/// </remarks>
|
||||
internal enum RemoteMoveKind
|
||||
{
|
||||
/// <summary>Same directory, different name.</summary>
|
||||
Rename,
|
||||
|
||||
/// <summary>Same name, different directory.</summary>
|
||||
Move,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Something on the host that is being given a new name or a new home, and has not been committed yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The shape <see cref="RemoteDeletionRequest"/> already established, and here for the same reason: it
|
||||
/// carries the full path rather than the name, so nothing that happens between opening the form and
|
||||
/// pressing its button can point the operation at a different file. The selection can move underneath it —
|
||||
/// a refresh nulls it — and the request stays about what was asked about.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It asks rather than warns.</b> Renaming and moving on the host are not deletions: what they change is
|
||||
/// where the bytes are reached from, and both are undone by doing them again the other way. So this form
|
||||
/// carries a field and a verb instead of <see cref="RemoteDeletionRequest.Consequence"/>'s "there is no
|
||||
/// undo" — the one warning on this screen that is earned, and worth not spending twice.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Name">What the row is called now.</param>
|
||||
/// <param name="FullPath">Where it is now, which is the end of the operation nothing may re-point.</param>
|
||||
/// <param name="IsDirectory">Whether it is a directory, which changes what moving it can be asked to do.</param>
|
||||
/// <param name="Kind">Which question is being asked.</param>
|
||||
internal sealed record RemoteMoveRequest(string Name, string FullPath, bool IsDirectory, RemoteMoveKind Kind)
|
||||
{
|
||||
/// <summary>Whether this is the rename question rather than the move one.</summary>
|
||||
internal bool IsRename => Kind is RemoteMoveKind.Rename;
|
||||
|
||||
/// <summary>The heading, which names the thing rather than only the verb.</summary>
|
||||
internal string Question => IsRename
|
||||
? $"Rename '{Name}' on the host"
|
||||
: $"Move '{Name}' to another directory";
|
||||
|
||||
/// <summary>
|
||||
/// What an empty box shows, which is the shape of the answer rather than an example of one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The placeholder is the whole of what says which of the two kinds of answer the box wants — there is
|
||||
/// no label above it. A field label would be a second line saying what this line already says, and the
|
||||
/// pane this form opens in is 204 pixels wide inside the session shell: every line it does not need is
|
||||
/// a line of the listing underneath that survives.
|
||||
/// </remarks>
|
||||
internal string FieldHint => IsRename ? "new name" : "/path/to/directory";
|
||||
|
||||
/// <summary>What the button that commits it says.</summary>
|
||||
internal string ConfirmLabel => IsRename ? "RENAME" : "MOVE";
|
||||
|
||||
/// <summary>
|
||||
/// What it does, in the one sentence the form has room for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both halves state the refusal rather than the action, because the action is already in the heading
|
||||
/// and the refusal is the part nobody expects: SFTP's rename does not replace, so a name already taken
|
||||
/// is a failure rather than a silent overwrite — which is the same promise the transfer queue makes and
|
||||
/// rests on the same server behaviour.
|
||||
/// </remarks>
|
||||
internal string Note => IsRename
|
||||
? "A name already taken is refused, not overwritten."
|
||||
: "Relative to the directory showing. A name already taken is refused, not overwritten.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transfers screen: a host, two directory panes, and the queue between them.
|
||||
/// </summary>
|
||||
@@ -382,6 +459,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>Whether the remote listing on screen is known to be out of date.</summary>
|
||||
/// <remarks>
|
||||
/// Set by <see cref="NoteTransferLanded"/> and cleared by any listing of the directory it is about.
|
||||
/// Read back by <see cref="RemoteListingIsStale"/>, which exists so that the deferral this drives can be
|
||||
/// asserted on in the state where it defers — the moment a transfer lands over an open question is not
|
||||
/// one a test can otherwise see the inside of.
|
||||
/// </remarks>
|
||||
private bool remoteListingIsStale;
|
||||
|
||||
/// <param name="sftp">Opens SFTP sessions.</param>
|
||||
/// <param name="clock">Time source, for transfer rates and timestamps.</param>
|
||||
/// <param name="post">
|
||||
@@ -706,6 +792,26 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
internal ObservableCollection<CrumbViewModel> RemoteTrail { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// What is in the pane's path box, which is not where the pane is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A draft beside <see cref="RemotePath"/> rather than the path itself made editable, and the two facts
|
||||
/// really are different: <see cref="RemotePath"/> is the directory that was listed, and this is what
|
||||
/// somebody is halfway through typing. Binding the box straight to the path would navigate on every
|
||||
/// keystroke — <c>/</c>, then <c>/v</c>, then <c>/va</c> — which is a round trip and a failed listing
|
||||
/// per character.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is written back from <see cref="ListRemoteAsync"/>, so every other way of moving — a breadcrumb,
|
||||
/// UP, a pin, connecting — puts the real path back in the box. What a failed listing leaves is what was
|
||||
/// typed, on purpose: the path that did not resolve is the one worth having in front of you to correct.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string remotePathDraft = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private RemoteEntryRowViewModel? selectedRemoteEntry;
|
||||
|
||||
@@ -721,13 +827,75 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;
|
||||
|
||||
/// <summary>The rename or move being filled in, or null when none is.</summary>
|
||||
[ObservableProperty]
|
||||
private RemoteMoveRequest? pendingRemoteMove;
|
||||
|
||||
/// <summary>
|
||||
/// What has been typed into whichever of the two forms is open — a name, or a directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One box for both, because only one of the two forms is ever open: they replace each other, and a
|
||||
/// second field would be a second thing to clear at every place this one is. Seeded from the row when
|
||||
/// the form opens — the current name for a rename, the current directory for a move — so that both
|
||||
/// questions start from the answer that changes nothing, and what somebody edits is the part they came
|
||||
/// to change.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string remoteMoveTarget = string.Empty;
|
||||
|
||||
internal bool IsMovingRemote => PendingRemoteMove is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the pane is waiting on an answer about the selected row.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The three item buttons and the automatic refresh all read this rather than the two flags separately.
|
||||
/// Deleting, renaming and moving are one at a time by construction — each form's own opening clears the
|
||||
/// other — and what this names is the state where none of them may be started and nothing may pull the
|
||||
/// listing out from under the question. See <see cref="SettleRemoteListingAsync"/>.
|
||||
/// </remarks>
|
||||
internal bool IsAskingAboutRemoteEntry => IsConfirmingRemoteDeletion || IsMovingRemote;
|
||||
|
||||
/// <summary>Whether the pane's DELETE is live.</summary>
|
||||
/// <remarks>
|
||||
/// Off while its own question is up, so a second press cannot arm a second one behind the card — and
|
||||
/// disabled rather than hidden, because this button sits in a row of three and a gap where it was would
|
||||
/// move UP and REFRESH out from under the pointer.
|
||||
/// Off while any of the three questions is up, so a second press cannot arm a second one behind the
|
||||
/// form — and disabled rather than hidden, because these buttons sit in a row and a gap where one was
|
||||
/// would move the other two out from under the pointer.
|
||||
/// </remarks>
|
||||
internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion;
|
||||
internal bool CanDeleteRemote => IsConnected && !IsAskingAboutRemoteEntry;
|
||||
|
||||
/// <inheritdoc cref="CanDeleteRemote" />
|
||||
internal bool CanRenameRemote => CanDeleteRemote;
|
||||
|
||||
/// <inheritdoc cref="CanDeleteRemote" />
|
||||
internal bool CanMoveRemote => CanDeleteRemote;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the pane shows the row of things that can be done to the chosen row.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Gone while one of the three questions is up, rather than merely disabled, and the phone head does
|
||||
/// the same with its own buttons for the same reason: the form is the answer to the question those
|
||||
/// buttons ask, and a pane 204 pixels wide inside the session shell has to spend the height on one or
|
||||
/// the other. What is not on screen is not a control somebody can press by mistake either.
|
||||
/// </remarks>
|
||||
internal bool ShowsRemoteItemActions => IsConnected && !IsAskingAboutRemoteEntry;
|
||||
|
||||
/// <summary>Whether a transfer has landed in the directory showing and it has not been re-read yet.</summary>
|
||||
/// <remarks>See <see cref="SettleRemoteListingAsync"/> for when that is allowed to be true for a while.</remarks>
|
||||
internal bool RemoteListingIsStale => remoteListingIsStale;
|
||||
|
||||
/// <summary>
|
||||
/// What the item row says it is about — the chosen row's name, or the sentence that says to choose one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A name rather than a full path, unlike the questions the buttons open: this line sits directly above
|
||||
/// the listing with the row it names highlighted in it, so the disambiguation a confirmation needs is
|
||||
/// already on screen. The forms carry the path, because by then the answer is being committed rather
|
||||
/// than merely looked at.
|
||||
/// </remarks>
|
||||
internal string SelectedRemoteName => SelectedRemoteEntry?.Name ?? "Choose a row to act on it";
|
||||
|
||||
// ---- The local pane ----
|
||||
|
||||
@@ -1199,6 +1367,58 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Goes wherever the path box says.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The way out of the breadcrumb's own limit: it can only offer what is under the directory already
|
||||
/// listed, so crossing the filesystem meant walking down to the root and back up again, a round trip a
|
||||
/// level. What is typed may be absolute, relative to the directory showing, or start at <c>~</c> — see
|
||||
/// <see cref="SftpPath.Resolve"/>, which is where all three become one absolute path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A blank box is not a request to go to the root. It puts the current path back and stays where it is:
|
||||
/// somebody who cleared the box and pressed Enter has not asked for anything, and <c>/</c> is a long way
|
||||
/// from where they were.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is validated here beyond that. Whether the path exists, is a directory and may be read are
|
||||
/// three questions only the server can answer, and it answers all three at once by refusing the listing
|
||||
/// — which lands in the status line with the server's own words in it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task GoToTypedRemotePathAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (session is not { } open)
|
||||
{
|
||||
Status = "Connect to a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (RemotePathDraft.Trim().Length == 0)
|
||||
{
|
||||
RemotePathDraft = RemotePath;
|
||||
return;
|
||||
}
|
||||
|
||||
var from = RemotePath.Length > 0 ? RemotePath : open.HomeDirectory;
|
||||
|
||||
await NavigateRemoteAsync(
|
||||
SftpPath.Resolve(from, RemotePathDraft, open.HomeDirectory), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Puts the real path back in the box, for the Escape key.</summary>
|
||||
/// <remarks>
|
||||
/// Half-typed text left in a box that says where you are is a box that is lying about where you are, and
|
||||
/// the pane underneath it goes on showing the directory it always was. Escape is the way out of that
|
||||
/// without having to remember what was in the box before.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void RevertTypedRemotePath() => RemotePathDraft = RemotePath;
|
||||
|
||||
/// <summary>
|
||||
/// Opens whatever is selected in the remote pane, if it is somewhere to go.
|
||||
/// </summary>
|
||||
@@ -1631,6 +1851,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return;
|
||||
}
|
||||
|
||||
// The other question goes, so the two forms cannot be on screen at once over one row.
|
||||
PendingRemoteMove = null;
|
||||
|
||||
PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
|
||||
}
|
||||
|
||||
@@ -1666,8 +1889,173 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}
|
||||
|
||||
/// <summary>Thinks better of it.</summary>
|
||||
/// <remarks>
|
||||
/// Settles the listing on the way out. A transfer that landed while the question was up left the pane
|
||||
/// out of date and was not allowed to refresh it — see <see cref="SettleRemoteListingAsync"/> — so
|
||||
/// dismissing the question is the moment that refresh becomes safe.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void CancelDeleteRemote() => PendingRemoteDeletion = null;
|
||||
private async Task CancelDeleteRemoteAsync()
|
||||
{
|
||||
PendingRemoteDeletion = null;
|
||||
|
||||
await SettleRemoteListingAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Opens the rename form on the chosen row.</summary>
|
||||
[RelayCommand]
|
||||
private void RenameRemote() => AskAboutMoving(RemoteMoveKind.Rename);
|
||||
|
||||
/// <summary>Opens the move form on the chosen row.</summary>
|
||||
[RelayCommand]
|
||||
private void MoveRemote() => AskAboutMoving(RemoteMoveKind.Move);
|
||||
|
||||
/// <remarks>
|
||||
/// Both forms, because they differ only in what the box starts out holding and what the labels say —
|
||||
/// see <see cref="RemoteMoveRequest"/>. The rename starts from the current name and the move from the
|
||||
/// current directory, so each opens showing the answer that changes nothing and what gets edited is the
|
||||
/// half somebody actually came to change.
|
||||
/// </remarks>
|
||||
private void AskAboutMoving(RemoteMoveKind kind)
|
||||
{
|
||||
if (SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
Status = kind is RemoteMoveKind.Rename
|
||||
? "Choose something on the host to rename."
|
||||
: "Choose something on the host to move.";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The other question goes, so the two forms cannot be on screen at once over one row.
|
||||
PendingRemoteDeletion = null;
|
||||
|
||||
PendingRemoteMove = new RemoteMoveRequest(row.Name, row.FullPath, !row.IsFile, kind);
|
||||
RemoteMoveTarget = kind is RemoteMoveKind.Rename
|
||||
? row.Name
|
||||
: SftpPath.Parent(row.FullPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carries out whichever of the two was filled in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One SFTP rename underneath both, which is what the protocol offers and the whole of what a move is
|
||||
/// on one host. It follows that neither works across hosts and neither works across a filesystem
|
||||
/// boundary on the same host: the server refuses with <c>EXDEV</c>, and that refusal arrives in the
|
||||
/// status line as the server's own sentence rather than being pre-empted by a guess here about which of
|
||||
/// its mounts a path is on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It acts on the path the form named rather than on the selection, for the reason
|
||||
/// <see cref="ConfirmDeleteRemoteAsync"/> does: nothing between opening the form and pressing its button
|
||||
/// may re-point it at a different file.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmMoveRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (PendingRemoteMove is not { } request)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryResolveMoveDestination(request, out var destination, out var refusal))
|
||||
{
|
||||
// The form stays open. What was typed is what has to be corrected, and taking the form away
|
||||
// would take the box holding it as well.
|
||||
Status = refusal;
|
||||
return;
|
||||
}
|
||||
|
||||
PendingRemoteMove = null;
|
||||
|
||||
await RunAsync(
|
||||
request.IsRename ? $"Renaming {request.Name}…" : $"Moving {request.Name}…",
|
||||
async () =>
|
||||
{
|
||||
await RequireSession()
|
||||
.RenameAsync(request.FullPath, destination, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = request.IsRename
|
||||
? $"Renamed {request.Name} to {SftpPath.Name(destination)}."
|
||||
: $"Moved {request.Name} to {SftpPath.Parent(destination)}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Thinks better of it.</summary>
|
||||
/// <inheritdoc cref="CancelDeleteRemoteAsync" path="/remarks" />
|
||||
[RelayCommand]
|
||||
private async Task CancelMoveRemoteAsync()
|
||||
{
|
||||
PendingRemoteMove = null;
|
||||
RemoteMoveTarget = string.Empty;
|
||||
|
||||
await SettleRemoteListingAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Works out where the typed answer actually points, or why it points nowhere.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The three refusals here are the ones a server's own error message would not explain. A separator in
|
||||
/// a name is a move wearing a rename's clothing and the server would simply say "no such file" about a
|
||||
/// directory nobody meant to name; a destination equal to the source is a request that succeeds while
|
||||
/// doing nothing; and a directory moved inside itself is <c>EINVAL</c>, which is true and says nothing.
|
||||
/// Everything else — a path that is not there, a name already taken, a mount that cannot be crossed —
|
||||
/// is left to the server, which knows and this does not.
|
||||
/// </remarks>
|
||||
private bool TryResolveMoveDestination(
|
||||
RemoteMoveRequest request, out string destination, out string refusal)
|
||||
{
|
||||
destination = string.Empty;
|
||||
var typed = RemoteMoveTarget.Trim();
|
||||
|
||||
if (typed.Length == 0)
|
||||
{
|
||||
refusal = request.IsRename
|
||||
? "Type a new name for it."
|
||||
: "Type the directory to move it into.";
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.IsRename)
|
||||
{
|
||||
if (typed.Contains('/', StringComparison.Ordinal))
|
||||
{
|
||||
refusal = "A name cannot contain '/'. Use MOVE to put it in another directory.";
|
||||
return false;
|
||||
}
|
||||
|
||||
destination = SftpPath.Combine(SftpPath.Parent(request.FullPath), typed);
|
||||
}
|
||||
else
|
||||
{
|
||||
var into = SftpPath.Resolve(RemotePath, typed, session?.HomeDirectory);
|
||||
destination = SftpPath.Combine(into, request.Name);
|
||||
}
|
||||
|
||||
if (string.Equals(destination, request.FullPath, StringComparison.Ordinal))
|
||||
{
|
||||
refusal = $"{request.Name} is already there.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.IsDirectory
|
||||
&& destination.StartsWith(request.FullPath + "/", StringComparison.Ordinal))
|
||||
{
|
||||
refusal = "A directory cannot be moved inside itself.";
|
||||
return false;
|
||||
}
|
||||
|
||||
refusal = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
@@ -1713,13 +2101,31 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The selection survives if the row it was on does.</b> Every listing replaces every row object, so
|
||||
/// keeping the object would keep a selection pointing at something no longer in the list; it is re-found
|
||||
/// by path instead. This matters because a listing is no longer only something somebody asked for — a
|
||||
/// finished upload refreshes this pane on its own, and a refresh that cleared the selection would take
|
||||
/// the row out from under whoever was about to press RENAME on it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The path box is written back here rather than by each caller, so that every way of moving — a crumb,
|
||||
/// UP, a pin, connecting, a rename that lands — leaves the box saying where the pane actually is.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);
|
||||
var wasSelected = SelectedRemoteEntry?.FullPath;
|
||||
|
||||
RemotePath = path;
|
||||
RemotePathDraft = path;
|
||||
SelectedRemoteEntry = null;
|
||||
|
||||
// Whatever made it stale, this is a fresh read of the very directory that was stale.
|
||||
remoteListingIsStale = false;
|
||||
|
||||
RemoteEntries.Clear();
|
||||
|
||||
foreach (var entry in entries)
|
||||
@@ -1734,10 +2140,99 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
RemoteTrail.Add(new CrumbViewModel(name, crumb));
|
||||
}
|
||||
|
||||
SelectedRemoteEntry = wasSelected is null
|
||||
? null
|
||||
: RemoteEntries.FirstOrDefault(
|
||||
row => string.Equals(row.FullPath, wasSelected, StringComparison.Ordinal));
|
||||
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the remote directory because something landed in it, if now is a moment to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>An upload that finished is a listing that is wrong.</b> The pane went on showing the directory as
|
||||
/// it was before the file arrived, and the only way to see the file that had just been sent was to press
|
||||
/// REFRESH — which is the application asking somebody to tell it about something it did itself.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It waits rather than interrupting.</b> Three states are not the moment: another operation is
|
||||
/// running, which would have two listings racing for the same collection; one of the three questions is
|
||||
/// on screen, and pulling the listing out from under a question about a row is how the row somebody
|
||||
/// answered about stops being the row that was acted on; or nothing is connected. In all three the
|
||||
/// staleness is remembered and settled later — at the end of the operation, or when the question is
|
||||
/// dismissed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It says nothing.</b> Nobody asked for this listing, so it neither announces itself nor reports a
|
||||
/// failure: the status line at that moment is carrying the word about the transfer that just finished,
|
||||
/// which is the news. A refresh that failed leaves the pane as it was and the staleness set, so the next
|
||||
/// settling tries again.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task SettleRemoteListingAsync()
|
||||
{
|
||||
if (!remoteListingIsStale || !IsConnected || IsBusy || IsAskingAboutRemoteEntry
|
||||
|| RemotePath.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var said = Status;
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await ListRemoteAsync(RemotePath, CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
Status = said;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
remoteListingIsStale = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notices that a finished transfer changed a directory that is on screen.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both panes, because both go stale the same way and for the same reason — an upload lands on the host
|
||||
/// and a download lands on this machine, and whichever pane is showing that directory is now showing it
|
||||
/// as it was a moment ago. Only the directory actually on screen: a transfer into somewhere else changed
|
||||
/// nothing anybody is looking at, and re-listing for it would be a round trip per completed transfer in
|
||||
/// a queue that may be moving a hundred files elsewhere.
|
||||
/// </remarks>
|
||||
internal void NoteTransferLanded(TransferSnapshot transfer)
|
||||
{
|
||||
if (transfer.Direction is TransferDirection.Upload)
|
||||
{
|
||||
if (string.Equals(SftpPath.Parent(transfer.RemotePath), RemotePath, StringComparison.Ordinal))
|
||||
{
|
||||
remoteListingIsStale = true;
|
||||
|
||||
_ = SettleRemoteListingAsync();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The local pane is a filesystem call rather than a round trip, so there is nothing to defer: it is
|
||||
// re-read where it stands. Compared case-insensitively because this end is Windows as often as not.
|
||||
if (string.Equals(
|
||||
Path.GetDirectoryName(transfer.LocalPath), LocalPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
NavigateLocal(LocalPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
|
||||
/// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
|
||||
@@ -1748,6 +2243,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
try
|
||||
{
|
||||
var entries = LocalDirectory.List(path);
|
||||
var wasSelected = SelectedLocalEntry?.FullPath;
|
||||
|
||||
LocalPath = Path.GetFullPath(path);
|
||||
SelectedLocalEntry = null;
|
||||
@@ -1761,6 +2257,13 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
RebuildLocalTrail();
|
||||
|
||||
// Re-found by path rather than kept, for the reason ListRemoteAsync gives: a finished download
|
||||
// re-reads this pane on its own, and a refresh that cleared the selection would take the row
|
||||
// out from under whoever was about to send it somewhere.
|
||||
SelectedLocalEntry = wasSelected is null
|
||||
? null
|
||||
: LocalEntries.FirstOrDefault(
|
||||
row => string.Equals(row.FullPath, wasSelected, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
@@ -1877,10 +2380,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
ConnectedPinnedPaths.Clear();
|
||||
OnPropertyChanged(nameof(HasConnectedPins));
|
||||
RemotePath = string.Empty;
|
||||
RemotePathDraft = string.Empty;
|
||||
RemoteEntries.Clear();
|
||||
RemoteTrail.Clear();
|
||||
SelectedRemoteEntry = null;
|
||||
|
||||
// Both questions go with the session they were about, and the box either of them was filled in
|
||||
// from with them. A form left open over a pane that is no longer connected to anything would
|
||||
// commit against RequireSession's own refusal.
|
||||
PendingRemoteDeletion = null;
|
||||
PendingRemoteMove = null;
|
||||
RemoteMoveTarget = string.Empty;
|
||||
remoteListingIsStale = false;
|
||||
|
||||
// Same ordering reason as the raise at the end of MarkHostConnected: both properties this reads are
|
||||
// already null above, so the raise belongs after them rather than in OnIsConnectedChanged. This also
|
||||
// covers OpenBucketAsync, which calls this method first and never itself turns HasLiveFileSession on.
|
||||
@@ -1911,6 +2423,9 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
{
|
||||
ReleaseStaged(e.Transfer.LocalPath);
|
||||
}
|
||||
|
||||
// Whichever pane the bytes landed in is showing that directory as it was before they did.
|
||||
NoteTransferLanded(e.Transfer);
|
||||
}
|
||||
|
||||
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
||||
@@ -1961,6 +2476,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
|
||||
// The one place every command on this screen passes through on its way out, which makes it the one
|
||||
// place a listing that went stale while it was running can safely be settled.
|
||||
await SettleRemoteListingAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
||||
@@ -2002,7 +2521,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||
RaiseItemActionState();
|
||||
|
||||
// Both ways, and the disconnecting half is the one worth stating: closing a session puts the pane
|
||||
// back to its invitation rather than to the form, so what the pane shows after a disconnect is the
|
||||
@@ -2019,14 +2538,37 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(SelectedRemoteName));
|
||||
|
||||
PendingRemoteDeletion = null;
|
||||
PendingRemoteMove = null;
|
||||
}
|
||||
|
||||
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
|
||||
RaiseItemActionState();
|
||||
}
|
||||
|
||||
partial void OnPendingRemoteMoveChanged(RemoteMoveRequest? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsMovingRemote));
|
||||
RaiseItemActionState();
|
||||
}
|
||||
|
||||
/// <summary>Raises the flags the three item buttons and their two forms are drawn from.</summary>
|
||||
/// <remarks>
|
||||
/// Together, because they are one fact — is there a question on screen, and is anything connected —
|
||||
/// read five ways, and a caller that raised some of them would leave a button live beside a form that
|
||||
/// says it is not.
|
||||
/// </remarks>
|
||||
private void RaiseItemActionState()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsAskingAboutRemoteEntry));
|
||||
OnPropertyChanged(nameof(ShowsRemoteItemActions));
|
||||
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||
OnPropertyChanged(nameof(CanRenameRemote));
|
||||
OnPropertyChanged(nameof(CanMoveRemote));
|
||||
}
|
||||
|
||||
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
||||
|
||||
@@ -74,16 +74,6 @@ 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;
|
||||
@@ -242,16 +232,40 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
loop = RunCheckLoopAsync(lifetime.Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The first pass runs at launch, with no delay in front of it.</b> It used to wait two minutes, on
|
||||
/// the argument that nothing anybody does in their first two minutes depends on an update and launch is
|
||||
/// already contending for the network with a schema migration, a resumed sign-in and a first sync. What
|
||||
/// that argument leaves out is the run that is over before the two minutes are: a client opened to reach
|
||||
/// one host and closed again never checks at all, and a machine used that way is exactly the one ADR
|
||||
/// 0011 warns about — quietly a year behind, with the mechanism to fix it switched on and never reached.
|
||||
/// Every start now asks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The yield is what keeps that off the launch path.</b> <see cref="Start"/> is called from
|
||||
/// <c>MainWindowViewModel.StartAsync</c> before the migration, so running the pass inline would put
|
||||
/// whatever the channel does before its own first await — Velopack reads the install layout from disk —
|
||||
/// between the user and their window. Yielding hands the rest of the launch back and lets the check run
|
||||
/// in a later turn, which is the same moment in every sense that matters and none of the cost.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RunCheckLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true);
|
||||
await Task.Yield();
|
||||
|
||||
using var timer = new PeriodicTimer(CheckInterval, clock);
|
||||
|
||||
do
|
||||
{
|
||||
// Task.Yield takes no token, unlike the delay it replaced, so a shutdown that lands while
|
||||
// the loop is waiting to be handed back the thread has to be observed here rather than
|
||||
// only at the next tick. Otherwise an application closed during launch spends its last
|
||||
// moment asking a release channel about a build it is not going to run.
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
await CheckOnceAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true));
|
||||
|
||||
@@ -943,6 +943,31 @@ 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>
|
||||
@@ -3086,6 +3111,37 @@ 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; } = [];
|
||||
@@ -3357,6 +3413,121 @@ 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>
|
||||
@@ -3981,6 +4152,15 @@ 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;
|
||||
@@ -7092,6 +7272,9 @@ 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);
|
||||
|
||||
@@ -7177,6 +7360,9 @@ 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);
|
||||
@@ -8037,6 +8223,10 @@ 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;
|
||||
}
|
||||
|
||||
@@ -8070,6 +8260,10 @@ 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);
|
||||
@@ -10729,6 +10923,53 @@ 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
|
||||
@@ -11006,10 +11247,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
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.");
|
||||
Abandon(attempt, RendererNeverStarted);
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
@@ -11034,6 +11272,29 @@ 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
|
||||
@@ -11078,6 +11339,8 @@ 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(
|
||||
@@ -11087,7 +11350,7 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Credential);
|
||||
|
||||
var sessionId = await workspace
|
||||
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
|
||||
.OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||
|
||||
@@ -78,6 +78,50 @@ 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,7 +16,8 @@
|
||||
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. This page's WebView is routinely killed
|
||||
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
|
||||
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
|
||||
@@ -52,6 +53,22 @@ 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
|
||||
@@ -84,14 +101,57 @@ 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}>} */
|
||||
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement, notice: string}>} */
|
||||
const sessions = new Map();
|
||||
|
||||
/** @type {WebSocket | null} */
|
||||
let socket = null;
|
||||
|
||||
function setStatus(text) {
|
||||
statusBanner.textContent = text ?? '';
|
||||
/** 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();
|
||||
}
|
||||
|
||||
/** Builds a frame: opcode, big-endian session id, then payload. */
|
||||
@@ -255,8 +315,31 @@ 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 {
|
||||
term.loadAddon(new WebglAddon.WebglAddon());
|
||||
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);
|
||||
} catch (error) {
|
||||
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
|
||||
}
|
||||
@@ -269,7 +352,7 @@ function createSession(sessionId) {
|
||||
|
||||
term.onResize(() => sendResize(sessionId, term, pane));
|
||||
|
||||
const session = { term, fit, pane };
|
||||
const session = { term, fit, pane, notice: '' };
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
activate(sessionId);
|
||||
@@ -283,6 +366,11 @@ 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();
|
||||
@@ -296,10 +384,17 @@ 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, 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.
|
||||
// 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.
|
||||
const MINIMUM_FITTABLE_PIXELS = 40;
|
||||
|
||||
function resize(session, sessionId) {
|
||||
@@ -343,7 +438,10 @@ function handleFrame(buffer) {
|
||||
session.term.write(REPLAY_BANNER);
|
||||
}
|
||||
|
||||
setStatus('');
|
||||
// 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, '');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -396,7 +494,14 @@ function handleFrame(buffer) {
|
||||
session.pane.remove();
|
||||
sessions.delete(sessionId);
|
||||
|
||||
setStatus('');
|
||||
// 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();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -417,8 +522,23 @@ 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.
|
||||
*/
|
||||
session.term.paste(text);
|
||||
if (text.includes('\n') || text.includes('\r')) {
|
||||
session.term.paste(text);
|
||||
} else {
|
||||
session.term.input(text);
|
||||
}
|
||||
|
||||
/*
|
||||
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
|
||||
@@ -430,6 +550,15 @@ 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;
|
||||
}
|
||||
|
||||
@@ -449,14 +578,19 @@ function handleFrame(buffer) {
|
||||
const session = sessions.get(sessionId);
|
||||
const reason = new TextDecoder().decode(payload);
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
setStatus(reason);
|
||||
// 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);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -466,8 +600,47 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -481,7 +654,7 @@ function scheduleReconnect() {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Reconnecting the terminal view…');
|
||||
setTransportStatus('Reconnecting the terminal view…');
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
@@ -491,31 +664,112 @@ 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.
|
||||
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
const pending = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
||||
pending.binaryType = 'arraybuffer';
|
||||
socket = pending;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
setStatus('');
|
||||
/** 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('');
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => handleFrame(event.data));
|
||||
pending.addEventListener('message', (event) => {
|
||||
if (isCurrent()) {
|
||||
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.
|
||||
socket.addEventListener('close', scheduleReconnect);
|
||||
socket.addEventListener('error', scheduleReconnect);
|
||||
const retry = () => {
|
||||
if (isCurrent()) {
|
||||
forgetHandshakeTimer();
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
pending.addEventListener('close', retry);
|
||||
pending.addEventListener('error', retry);
|
||||
}
|
||||
|
||||
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
|
||||
@@ -528,4 +782,30 @@ 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": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -267,9 +267,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -369,11 +369,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,93 @@ 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>
|
||||
|
||||
@@ -121,12 +121,53 @@ 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.
|
||||
@@ -134,5 +175,8 @@ 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, CancellationToken cancellationToken);
|
||||
Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -42,13 +42,14 @@ 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));
|
||||
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SshNetConnection(client, gate.Presented!);
|
||||
@@ -68,7 +69,10 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
|
||||
var client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize };
|
||||
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||
// 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)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
||||
@@ -101,12 +105,18 @@ 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, cancellationToken);
|
||||
var gate = new HostKeyGate(knownHosts, request, progress, 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);
|
||||
@@ -139,6 +149,7 @@ 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>
|
||||
@@ -157,6 +168,11 @@ 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.
|
||||
//
|
||||
@@ -179,6 +195,15 @@ 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>
|
||||
@@ -279,10 +304,11 @@ 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 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.
|
||||
/// 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>.
|
||||
/// </remarks>
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||
|
||||
@@ -24,9 +24,10 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
|
||||
/// <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.
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory { get; } = homeDirectory;
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "Direct",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
@@ -54,9 +54,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
|
||||
@@ -257,7 +257,8 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised with the session id when a shell ends on its own.
|
||||
/// Raised with the session id once a session is over — its shell having ended on its own, or a
|
||||
/// deliberate close having fully drained.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -268,13 +269,23 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// the half of the interface Avalonia draws.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Raised on whatever thread the pump finished on</b>, which is a thread-pool thread. A handler that
|
||||
/// touches an observable collection has to marshal; this type has no toolkit to do it with, which is
|
||||
/// exactly why it does not try.
|
||||
/// <b>Raised only after the session's run task has completed, and that ordering is load-bearing.</b>
|
||||
/// It used to fire from inside the run's own finally block, where the task is by definition not yet
|
||||
/// complete — so a handler reading <see cref="LiveSessionCount"/> still counted the session that had
|
||||
/// just ended, which is how the phone's foreground notification went on saying "1 shell connected"
|
||||
/// over nothing. See <see cref="AnnounceEndedAsync"/>. Raised on a thread-pool continuation, or on the
|
||||
/// closer's own thread; a handler that touches an observable collection has to marshal either way.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not raised by <see cref="CloseSessionAsync"/>. That path already has a caller who knows the session is
|
||||
/// going, and telling it what it just asked for is how a tab close turns into a second tab close.
|
||||
/// <b>Raised by <see cref="CloseSessionAsync"/> too, which reverses a recorded decision.</b> The old
|
||||
/// reasoning — the caller asked, so telling it is an echo — assumed every subscriber was the caller.
|
||||
/// The phone's keep-alive is not: it hears this event to reconcile a notification with reality, and a
|
||||
/// close that announced nothing left that notification claiming a shell that was gone. Every subscriber
|
||||
/// treats the event as "reconcile" rather than "act" — a tab is marked dead if it is still there and
|
||||
/// skipped if it is not — so a second announcement for a session that already announced its own end
|
||||
/// (closing the tab of a shell that exited earlier) is deliberate and harmless. Shutdown is the one
|
||||
/// close that stays silent: <see cref="DisposeAsync"/> is tearing the subscribers down with the
|
||||
/// sessions, and news nobody is left to hear is not news.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;
|
||||
@@ -358,13 +369,31 @@ 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, cancellationToken).ConfigureAwait(false);
|
||||
var connection = await connections
|
||||
.ConnectAsync(request, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
progress?.Report(SshConnectionPhase.OpeningShell);
|
||||
|
||||
ISshShellSession shell;
|
||||
try
|
||||
@@ -403,6 +432,10 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
sessions[sessionId] = new LiveSession(connection, pump, run);
|
||||
}
|
||||
|
||||
// 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, run);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
@@ -574,6 +607,14 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
{
|
||||
// Expected on the ordinary path: disposing the pump cancels its run.
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (Volatile.Read(ref disposed) == 0)
|
||||
{
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -694,21 +735,51 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
|
||||
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
|
||||
// the application is closing.
|
||||
//
|
||||
// SessionEnded is deliberately NOT raised from here, and it used to be — see
|
||||
// AnnounceEndedAsync for what was wrong with that.
|
||||
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
// Only when the session is still one this workspace knows about. CloseSessionAsync removes the
|
||||
// entry before it disposes the pump, so a tab the user closed does not come back as news.
|
||||
bool announce;
|
||||
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A continuation rather than a line in <see cref="RunSessionAsync"/>'s finally, and the difference is
|
||||
/// what a handler sees. Inside that finally the run task is not yet complete — a finally is part of the
|
||||
/// task — so <see cref="LiveSessionCount"/>, which counts incomplete runs, still included the session
|
||||
/// that had just ended. The phone's keep-alive answers this event by reading exactly that count, and
|
||||
/// reconciled its foreground notification to "1 shell connected" over a shell that was gone, with
|
||||
/// nothing left to fire afterwards and correct it. By the time an await on the run resumes, the task is
|
||||
/// complete and the count is honest.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The containment check keeps the deliberate paths out of this route: <see cref="CloseSessionAsync"/>
|
||||
/// 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, Task run)
|
||||
{
|
||||
try
|
||||
{
|
||||
await run.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// The run's faults belong to whoever drains it — CloseSessionAsync, on the deliberate path.
|
||||
// This continuation cares only that the run is over, however it got there.
|
||||
}
|
||||
|
||||
lock (sessionGate)
|
||||
{
|
||||
announce = sessions.ContainsKey(sessionId);
|
||||
}
|
||||
bool announce;
|
||||
|
||||
if (announce)
|
||||
{
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
||||
}
|
||||
lock (sessionGate)
|
||||
{
|
||||
announce = sessions.ContainsKey(sessionId);
|
||||
}
|
||||
|
||||
if (announce)
|
||||
{
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,14 +37,14 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -63,11 +63,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,14 +37,14 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -63,11 +63,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
|
||||
@@ -1744,9 +1744,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -1847,11 +1847,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Presenters;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The three button shapes centre their caption inside a button taller than the caption.
|
||||
/// The button shapes that centre their caption do, and the two that deliberately do not still fill.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -41,6 +42,15 @@ namespace DodoSSH.Client.App.Layout.Tests;
|
||||
/// action — so an absolute expectation would be a font metric written down in a test file, and it would move
|
||||
/// the day the face does. "Centred" survives both.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The five shapes beyond the original three were swept in afterwards, and none of them was misbehaving
|
||||
/// when it was: every one is content-sized everywhere it is used today, so <c>Stretch</c> and <c>Center</c>
|
||||
/// agreed and the change moved nothing — 113 buttons across 29 screens measured byte-identical before and
|
||||
/// after. What the sweep buys is that the day any of them is given a height, it is already right. That is
|
||||
/// also why <see cref="AStretchingShapeStillFillsItsButton"/> matters more than it looks: the same
|
||||
/// reasoning applied to <c>flat</c> or <c>cat</c> would break a pill and a strip that are currently
|
||||
/// correct.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ButtonCaptionTests
|
||||
{
|
||||
@@ -61,6 +71,11 @@ public sealed class ButtonCaptionTests
|
||||
[InlineData("ghost")]
|
||||
[InlineData("accent")]
|
||||
[InlineData("danger")]
|
||||
[InlineData("navuser")]
|
||||
[InlineData("poprow")]
|
||||
[InlineData("panechip")]
|
||||
[InlineData("chiptoggle")]
|
||||
[InlineData("choice")]
|
||||
public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape)
|
||||
{
|
||||
await MeasureAsync(
|
||||
@@ -96,6 +111,72 @@ public sealed class ButtonCaptionTests
|
||||
"the caption should sit high, which is the defect this suite was written for"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>flat</c> and <c>cat</c> are excluded from the rule above, and must stay excluded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both stretch their content on purpose, and both would be silently broken by a later pass that
|
||||
/// "finished" the sweep the rest of these classes belong to — which is exactly why this is a test and
|
||||
/// not a comment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>flat</c> carries the titlebar's search pill, a <c>Border.searchpill</c> with no height of its own
|
||||
/// that is meant to fill all 35 pixels of the button; the usage states
|
||||
/// <c>HorizontalContentAlignment="Stretch"</c> and relies on the vertical default matching it. Centring
|
||||
/// from the style would shrink that pill to its caption's line box inside a button twice as tall.
|
||||
/// <c>cat</c> carries the keychain rail's accent strip, a <c>Border.rowmark</c> whose style sets
|
||||
/// <c>Width="2"</c> and no height at all — "at full row height", says the rule's own remark — so its
|
||||
/// height is the stretch and nothing else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted as "the content fills the button", not as "the caption is off-centre": what these two need
|
||||
/// is the fill, and a test phrased the other way would still pass if the fill broke in some new way.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("flat")]
|
||||
[InlineData("cat")]
|
||||
public async Task AStretchingShapeStillFillsItsButton(string shape)
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
// A bare Border is what both of them actually hold: no height, sized only by its parent.
|
||||
var fill = new Border();
|
||||
var button = new Button { Content = fill, Height = FixedHeight };
|
||||
button.Classes.Add(shape);
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||
|
||||
try
|
||||
{
|
||||
// The slot read off the presenter rather than recomputed from the button's Padding:
|
||||
// these shapes differ in whether their presenter also draws a border, and a hand-rolled
|
||||
// sum was two pixels out on Button.cat for exactly that reason.
|
||||
var presenter = button.GetVisualDescendants()
|
||||
.OfType<ContentPresenter>()
|
||||
.Single(p => string.Equals(p.Name, "PART_ContentPresenter", StringComparison.Ordinal));
|
||||
|
||||
var slot = presenter.Bounds.Height
|
||||
- presenter.Padding.Top - presenter.Padding.Bottom
|
||||
- presenter.BorderThickness.Top - presenter.BorderThickness.Bottom;
|
||||
|
||||
fill.Bounds.Height.ShouldBe(
|
||||
slot,
|
||||
Tolerance,
|
||||
$"Button.{shape} must stretch its content — the search pill and the rail's accent "
|
||||
+ "strip have no height of their own");
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
private static Task MeasureAsync(
|
||||
string shape, VerticalAlignment? alignment, Action<double, double> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
|
||||
@@ -122,8 +122,14 @@ 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;
|
||||
|
||||
/// <summary>The v5b session shell's own host header, from <c>SessionHeader.axaml</c>.</summary>
|
||||
internal const double SessionHeaderHeight = 60;
|
||||
/*
|
||||
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 status bar, from <c>SessionStatusBar.axaml</c>.</summary>
|
||||
internal const double SessionStatusBarHeight = 37;
|
||||
@@ -146,12 +152,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="SessionHeaderHeight"/> and
|
||||
/// <see cref="SessionStatusBarHeight"/> for the two fixed strips the pane sits between.
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal static double SessionScreenHeight =>
|
||||
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
|
||||
- SessionHeaderHeight - SessionStatusBarHeight;
|
||||
- SessionStatusBarHeight;
|
||||
|
||||
/// <summary>
|
||||
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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;
|
||||
@@ -272,6 +274,49 @@ 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,6 +236,51 @@ 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
|
||||
@@ -307,6 +352,25 @@ 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");
|
||||
@@ -325,4 +389,37 @@ 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,5 +1,6 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
@@ -189,6 +190,25 @@ 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
|
||||
@@ -1255,7 +1275,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 <c>SessionHeader</c> already prints the same address above
|
||||
/// share the row with it moved out, because the session shell already prints the same address beside
|
||||
/// 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>
|
||||
@@ -1292,10 +1312,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(
|
||||
"2026-07-30",
|
||||
"/srv/releases/site/backups/nightly/2026-07-30",
|
||||
@@ -1304,6 +1332,112 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
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>
|
||||
@@ -1577,7 +1711,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 six item
|
||||
/// than one: the switcher's three segments, each a third of the rail's own content width; the five 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
|
||||
@@ -1585,13 +1719,18 @@ 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
|
||||
/// six rows plus a user chip have to leave room for each other in the same space the v3 rail's seven
|
||||
/// five 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 TheNavRailHoldsItsSwitcherSixDestinationsAndTheUserChipAtTheWindowsMinimum()
|
||||
public async Task TheNavRailHoldsItsSwitcherFiveDestinationsAndTheUserChipAtTheWindowsMinimum()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
@@ -1611,7 +1750,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
|
||||
segments.Count.ShouldBe(3, "SSH, SFTP and S3");
|
||||
rows.Count.ShouldBe(
|
||||
6, "the mode-dependent first row, then Hosts, Keys, Pins, Snips and Logs");
|
||||
5, "the mode-dependent first row, then Hosts, Keys, Snips and Logs");
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
@@ -2033,6 +2172,262 @@ 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(
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
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>
|
||||
/// 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,4 +101,59 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -551,7 +551,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -663,9 +663,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -771,11 +771,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -40,20 +40,43 @@ 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);
|
||||
}
|
||||
|
||||
return Failure is { } failure
|
||||
? throw failure
|
||||
: new FakeSshConnection(request);
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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,
|
||||
@@ -61,9 +84,14 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
{
|
||||
SftpRequests.Add(request);
|
||||
|
||||
return Failure is { } failure
|
||||
? Task.FromException<ISftpSession>(failure)
|
||||
: Task.FromResult<ISftpSession>(new FakeSftpSession(request));
|
||||
if (Failure is { } failure)
|
||||
{
|
||||
return Task.FromException<ISftpSession>(failure);
|
||||
}
|
||||
|
||||
OpenedSftp = new FakeSftpSession(request);
|
||||
|
||||
return Task.FromResult<ISftpSession>(OpenedSftp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,9 +117,26 @@ 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; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<SftpEntry>>(
|
||||
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
Listed.Add(path);
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SftpEntry>>(
|
||||
[
|
||||
new SftpEntry(
|
||||
"notes.txt",
|
||||
@@ -101,6 +146,7 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
||||
DateTimeOffset.UnixEpoch,
|
||||
"-rw-r--r--"),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
||||
@@ -119,11 +165,20 @@ internal sealed class FakeSftpSession(SshConnectionRequest request) : ISftpSessi
|
||||
Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string path, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task DeleteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
Deleted.Add(path);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
|
||||
Task.CompletedTask;
|
||||
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
||||
{
|
||||
Renamed.Add((fromPath, toPath));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
|
||||
@@ -11,6 +11,7 @@ using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Client.Transfer;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
@@ -143,7 +144,14 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
{
|
||||
clipboard.Add(text);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
},
|
||||
|
||||
// Inline, because this suite has no window and therefore no dispatcher to drain — the same
|
||||
// answer TransferQueueingTests reached, and for the reason its own remark gives: reaching
|
||||
// Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched
|
||||
// it first. Running the action where it was raised takes the thread out of the question, and
|
||||
// every phase this suite reports is raised on the thread doing the asserting anyway.
|
||||
post: action => action());
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -1303,6 +1311,119 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.IsConnectingShowing.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What the card draws while the stretch above is going on. The tab used to carry one line of prose
|
||||
/// fixed at the moment it was created, which made a handshake stuck on a key exchange look exactly like
|
||||
/// one stuck on a dead socket — and made a connection that was progressing look exactly like one that
|
||||
/// was not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The gate is held open on the step the fake reports before it, so this asserts the state the card is
|
||||
/// actually drawn in rather than one it passes through: one step behind, one step lit, three not
|
||||
/// reached. Nothing here waits or polls, which is the other half of the claim — the report arrives on
|
||||
/// the thread that raised it and the tab is up to date in the same turn.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Connecting_LightsTheStepTheHandshakeHasActuallyReached()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
ssh.Gate = new TaskCompletionSource();
|
||||
var connecting = vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
|
||||
tab.Steps.Select(step => step.State).ShouldBe(
|
||||
[
|
||||
ConnectionStepState.Done,
|
||||
ConnectionStepState.Running,
|
||||
ConnectionStepState.Pending,
|
||||
ConnectionStepState.Pending,
|
||||
ConnectionStepState.Pending,
|
||||
],
|
||||
"the renderer attached, the host is being reached, and nothing after that has happened");
|
||||
|
||||
tab.StepsDone.ShouldBe(1, "the track fills to what finished, and the running step is not half a step");
|
||||
tab.Status.ShouldBe("Reaching the host");
|
||||
|
||||
ssh.Gate.SetResult();
|
||||
await connecting;
|
||||
|
||||
tab.Steps.ShouldAllBe(step => step.IsDone, "a session that opened got through all of them");
|
||||
tab.StepsDone.ShouldBe(tab.StepCount);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The half of the step list a progress bar could not do: where it stopped is kept, and the steps behind
|
||||
/// it stay done. That is the difference between "that host is not there" and "that host is there and
|
||||
/// would not have me", and it is the question the reason sentence alone often does not settle.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARefusedConnection_KeepsTheStepItStoppedOn()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
ssh.Failure = new InvalidOperationException("No route to host.");
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
|
||||
tab.Steps.Select(step => step.State).ShouldBe(
|
||||
[
|
||||
ConnectionStepState.Done,
|
||||
ConnectionStepState.Stopped,
|
||||
ConnectionStepState.Pending,
|
||||
ConnectionStepState.Pending,
|
||||
ConnectionStepState.Pending,
|
||||
],
|
||||
"it got as far as reaching the host and no further");
|
||||
|
||||
tab.Steps[1].Mark.ShouldBe("✕", "and says so without relying on the colour");
|
||||
|
||||
// The reason still goes where it always went. The list says how far, and this says what happened.
|
||||
tab.Status.ShouldBe("No route to host.");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A report that arrives for an attempt the shell has forgotten. Giving up on a connecting tab removes
|
||||
/// it while the handshake is still running — see <c>CloseTabAsync</c> — so every phase reported after
|
||||
/// that has no tab to land on. Dropped rather than resurrecting the tab, and above all not thrown: the
|
||||
/// handshake is still going, and its session is still adopted if it opens.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task GivingUpOnATab_LeavesLaterPhasesWithNothingToDo()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
ssh.Gate = new TaskCompletionSource();
|
||||
var connecting = vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
await shell.CloseTabCommand.ExecuteAsync(tab);
|
||||
|
||||
// Everything after the gate — the host key, the credential, the shell — is reported to a shell that
|
||||
// no longer has a tab for this attempt.
|
||||
ssh.Gate.SetResult();
|
||||
await connecting;
|
||||
|
||||
// The session opened anyway and was adopted, which is the behaviour giving up already promised.
|
||||
var adopted = shell.Tabs.ShouldHaveSingleItem();
|
||||
adopted.HasSession.ShouldBeTrue();
|
||||
adopted.ShouldNotBe(tab);
|
||||
|
||||
// And the forgotten tab was left where it was rather than being advanced from the sidelines.
|
||||
tab.Steps[1].IsRunning.ShouldBeTrue("nothing moved it on after the shell let go of it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
|
||||
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
|
||||
@@ -1645,6 +1766,28 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette searches a host's vault name, and only where the row prints it — which a session holding
|
||||
/// one vault never does. Matching it anyway would answer "personal" with the entire keychain, ranked
|
||||
/// behind nothing and explained by nothing on screen. See <c>MainWindowViewModel.Rank</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePalette_DoesNotMatchTheVaultNameWhenThereIsOnlyOneVaultToBeIn()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.VaultName.ShouldBe("Personal", "which is the word this test types");
|
||||
|
||||
shell.ToggleSearchCommand.Execute(null);
|
||||
|
||||
shell.SearchResults.ShouldHaveSingleItem()
|
||||
.HasVaultBadge.ShouldBeFalse("one vault is nothing to tell apart");
|
||||
|
||||
shell.SearchText = "personal";
|
||||
|
||||
shell.SearchResults.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The rail marks where you are, and a terminal is not one of its destinations. Lighting HOSTS while a
|
||||
/// terminal fills the window would point at a screen that is not showing — and the selected tab already
|
||||
@@ -2341,6 +2484,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await workspace.OpenSessionAsync(
|
||||
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
||||
TerminalSize.Default,
|
||||
progress: null,
|
||||
Token);
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(1);
|
||||
@@ -2980,6 +3124,161 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
vault.Hosts[0].Host.CredentialId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The moment a credential is wanted is the moment somebody is choosing how a host authenticates and
|
||||
/// finds it is not in the keychain yet, so the host editor makes one. Selecting it has to survive the
|
||||
/// reload the write triggers, which is the part that needs a test: the refill rebuilds the picker from
|
||||
/// the vault and restores it from the editor's own selection, so the binding is written before the
|
||||
/// reload rather than after it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ACredentialMadeInTheHostEditor_BindsTheHostToIt()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.BeginEditorCredentialCommand.Execute(null);
|
||||
vault.IsAddingEditorCredential.ShouldBeTrue();
|
||||
|
||||
vault.EditorNewCredentialLabel = "pg-primary";
|
||||
vault.EditorNewCredentialUsername = "postgres";
|
||||
vault.EditorNewCredentialPassword = "s3cret";
|
||||
vault.EditorNewCredentialNotes = "rotated quarterly";
|
||||
|
||||
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
||||
|
||||
var credential = vault.Credentials.ShouldHaveSingleItem();
|
||||
credential.Credential.Username.ShouldBe("postgres");
|
||||
credential.Credential.Notes.ShouldBe("rotated quarterly");
|
||||
|
||||
vault.IsAddingEditorCredential.ShouldBeFalse("the form closes once the credential is in the keychain");
|
||||
vault.EditorNewCredentialPassword.ShouldBeEmpty("the form must not go on holding the password");
|
||||
|
||||
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldBe(
|
||||
credential.EntityId,
|
||||
"the picker has to land on the credential that was just made, through the reload");
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBe(credential.EntityId);
|
||||
vault.Hosts[0].Host.SshKeyId.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The honest consequence of writing immediately, and the same one the new-tag box already carries: a
|
||||
/// credential is a shared item with an id, the host can only name an id that exists, so the credential
|
||||
/// was never part of the host to begin with. What was still being typed is a different matter — that
|
||||
/// includes a password, and it goes with the editor it was typed into.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CancellingTheHostEditor_KeepsTheCredentialItMade_AndDropsWhatWasStillBeingTyped()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.BeginEditorCredentialCommand.Execute(null);
|
||||
vault.EditorNewCredentialLabel = "pg-primary";
|
||||
vault.EditorNewCredentialPassword = "s3cret";
|
||||
|
||||
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
||||
|
||||
// A second one, opened and left half-typed.
|
||||
vault.BeginEditorCredentialCommand.Execute(null);
|
||||
vault.EditorNewCredentialLabel = "half";
|
||||
vault.EditorNewCredentialPassword = "typed-but-never-added";
|
||||
vault.EditorNewCredentialNotes = "half a thought";
|
||||
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
|
||||
vault.Credentials.ShouldHaveSingleItem().Label.ShouldBe("pg-primary");
|
||||
|
||||
vault.IsAddingEditorCredential.ShouldBeFalse();
|
||||
vault.EditorNewCredentialLabel.ShouldBeEmpty();
|
||||
vault.EditorNewCredentialNotes.ShouldBeEmpty();
|
||||
vault.EditorNewCredentialPassword.ShouldBeEmpty(
|
||||
"a password typed into an abandoned form must not survive behind the next host");
|
||||
|
||||
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull(
|
||||
"the binding itself was never saved");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Why this form has fields of its own rather than reusing the keychain screen's four.
|
||||
/// <c>IsEditingCredential</c> is what <c>AVaultEditorIsInTheWay</c> asks about, so sharing it would make
|
||||
/// the whole Vault screen refuse to open an editor, with a sentence naming a form the user cannot see
|
||||
/// on a screen they are not looking at. That is the exact failure the guard was split in two to end.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostEditorsCredentialForm_DoesNotBlockTheKeychainsOwnEditors()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.BeginEditorCredentialCommand.Execute(null);
|
||||
|
||||
vault.NewCredentialCommand.Execute(null);
|
||||
|
||||
vault.IsEditingCredential.ShouldBeTrue(
|
||||
"the keychain's editor lives on another screen and opens regardless");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Where this deliberately parts from the new-tag box beside it, which offers an existing tag rather
|
||||
/// than repeating it. Two tags called "staging" are one intention spelled twice; two credentials called
|
||||
/// "root" are two different passwords, and quietly binding the host to whichever was there already
|
||||
/// would authenticate it as an account nobody chose.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ACredentialMadeInTheHostEditor_UnderANameAlreadyTaken_IsASecondCredential()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
await AddCredentialAsync(vault, "root", password: "first");
|
||||
|
||||
var first = vault.Credentials.ShouldHaveSingleItem().EntityId;
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.BeginEditorCredentialCommand.Execute(null);
|
||||
vault.EditorNewCredentialLabel = "root";
|
||||
vault.EditorNewCredentialPassword = "second";
|
||||
|
||||
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Credentials.Count.ShouldBe(2);
|
||||
|
||||
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldNotBe(
|
||||
first,
|
||||
"binding to the credential that happened to share the name would be the wrong password");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The same refusal <c>CredentialSecret.TryValidate</c> gives the keychain's editor, reaching the user
|
||||
/// here rather than producing an item that looks usable and fails at the handshake.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ACredentialMadeInTheHostEditor_WithNoPassword_IsRefused()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.BeginEditorCredentialCommand.Execute(null);
|
||||
vault.EditorNewCredentialLabel = "pg-primary";
|
||||
|
||||
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Credentials.ShouldBeEmpty();
|
||||
vault.IsAddingEditorCredential.ShouldBeTrue("the form stays open on what it refused");
|
||||
vault.Status.ShouldContain("password");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Tags reach the same editor by a different route — the keychain screen rather than the box under the
|
||||
/// chips — and a chip that only appeared on the next open would send the user round the same detour.
|
||||
@@ -6222,12 +6521,68 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// v5c-4 retired the session shell's own header row and moved its cross-surface button into the sidebar,
|
||||
/// where one control is drawn on both surfaces — so the two directions above are reached through one
|
||||
/// command and one label, resolved by the shell. This is that resolution: the same two outcomes the two
|
||||
/// tests above assert, from the row a user actually clicks now.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSidebarsCrossSurfaceRow_NamesAndOpensWhicheverSurfaceIsNotShowing()
|
||||
{
|
||||
await ConnectedHostWithAPinAsync();
|
||||
|
||||
shell.IsTerminalSurface.ShouldBeTrue();
|
||||
shell.SessionCrossSurfaceLabel.ShouldBe("Open SFTP");
|
||||
|
||||
await shell.OpenOtherSurfaceCommand.ExecuteAsync(null);
|
||||
|
||||
shell.IsTransfersShowing.ShouldBeTrue();
|
||||
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
||||
shell.SessionCrossSurfaceLabel.ShouldBe("Open terminal", "the row turns over with the surface");
|
||||
|
||||
var tabsBefore = shell.Tabs.Count;
|
||||
|
||||
await shell.OpenOtherSurfaceCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Tabs.Count.ShouldBe(tabsBefore + 1, "the other direction dials a terminal at the browsed host");
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Closing the sidebar is a preference about this machine, so it outlives the window — see
|
||||
/// <c>ClientSettings.SessionSidebarOpen</c>. Asserted against the store rather than against a second
|
||||
/// shell built over the same profile: what a fresh launch reads is exactly what is on disk, and building
|
||||
/// another shell here would prove the constructor twice and the storage once.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClosingTheSidebar_IsRememberedForTheNextLaunch()
|
||||
{
|
||||
await ConnectedHostWithAPinAsync();
|
||||
|
||||
shell.IsSessionSidebarOpen.ShouldBeTrue("open is the default, and nothing has closed it");
|
||||
|
||||
shell.ToggleSessionSidebarCommand.Execute(null);
|
||||
|
||||
shell.IsSessionSidebarOpen.ShouldBeFalse();
|
||||
new ClientSettingsStore(paths).Read().SessionSidebarOpen.ShouldBeFalse();
|
||||
|
||||
shell.ToggleSessionSidebarCommand.Execute(null);
|
||||
|
||||
shell.IsSessionSidebarOpen.ShouldBeTrue();
|
||||
new ClientSettingsStore(paths).Read().SessionSidebarOpen.ShouldBeTrue("and reopening is remembered too");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The sidebar's SNIPS row, wired through <c>SnippetsViewModel.InsertCommand</c> rather than a second
|
||||
/// insert path — see the deviation recorded on <c>MainWindowViewModel.InsertSnippetCommand</c>. Proven
|
||||
/// through a real connected tab and a real renderer, the same fixture <c>InsertingASnippet_...</c> above
|
||||
/// uses for the standalone screen, because what is worth proving here is that the shell's command reaches
|
||||
/// that same mechanism rather than reimplementing it.
|
||||
///
|
||||
/// The focus request is asserted here rather than in a test of its own because it is part of what this
|
||||
/// click does: the row that typed the command took the keyboard with it, and only the window can give it
|
||||
/// back. See <c>MainWindowViewModel.TerminalFocusRequested</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
|
||||
@@ -6239,9 +6594,38 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
var row = snippets.Visible.ShouldHaveSingleItem();
|
||||
|
||||
var focusRequests = 0;
|
||||
shell.TerminalFocusRequested += (_, _) => focusRequests++;
|
||||
|
||||
await shell.InsertSnippetCommand.ExecuteAsync(row);
|
||||
|
||||
snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads");
|
||||
focusRequests.ShouldBe(1, "the keyboard goes back to the terminal the command landed in");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the rule above: nothing was typed, so nothing asks for the keyboard. A snip clicked
|
||||
/// with no terminal to put it in lands on the snippets screen instead — see
|
||||
/// <c>MainWindowViewModel.InsertSnippetCommand</c> — and stealing focus into a collapsed WebView on the
|
||||
/// way would leave that screen unable to be typed on.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task InsertingASnippetWithNothingToInsertInto_DoesNotAskForTheTerminal()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
|
||||
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
|
||||
|
||||
var row = snippets.Visible.ShouldHaveSingleItem();
|
||||
|
||||
var focusRequests = 0;
|
||||
shell.TerminalFocusRequested += (_, _) => focusRequests++;
|
||||
|
||||
await shell.InsertSnippetCommand.ExecuteAsync(row);
|
||||
|
||||
shell.Screen.ShouldBe(ShellScreen.Snippets);
|
||||
focusRequests.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -7957,6 +8341,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
await workspace.OpenSessionAsync(
|
||||
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
||||
TerminalSize.Default,
|
||||
progress: null,
|
||||
Token);
|
||||
|
||||
shell.SignOutCommand.Execute(null);
|
||||
@@ -8165,6 +8550,233 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
raised.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// ---- ◆ The path box, and the three things that can be done to a row ----
|
||||
|
||||
/// <remarks>
|
||||
/// The way out of the breadcrumb's own reach. It can only offer what is under the directory already
|
||||
/// listed, so before this box existed a path on the other side of the filesystem cost a walk to the
|
||||
/// common ancestor and back down — a round trip a level — and one somebody already knew could not be
|
||||
/// entered at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TypingAnAbsolutePathOnTheTransfersScreen_ListsIt()
|
||||
{
|
||||
var sftp = await ConnectedTransfersAsync();
|
||||
|
||||
shell.Transfers.RemotePathDraft = "/etc/nginx";
|
||||
|
||||
await shell.Transfers.GoToTypedRemotePathCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.RemotePath.ShouldBe("/etc/nginx");
|
||||
sftp.Listed[^1].ShouldBe("/etc/nginx");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Relative to the pane rather than to the session's own working directory, which is the distinction
|
||||
/// <see cref="SftpPath.Resolve"/> exists for: handing the server a relative path would resolve it
|
||||
/// somewhere the breadcrumb above the box did not predict.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("logs", "/home/deploy/logs")]
|
||||
[InlineData("../shared", "/home/shared")]
|
||||
[InlineData("~/.ssh", "/home/deploy/.ssh")]
|
||||
public async Task TypingARelativePathOnTheTransfersScreen_TakesItFromWhereThePaneIs(
|
||||
string typed, string expected)
|
||||
{
|
||||
await ConnectedTransfersAsync();
|
||||
|
||||
shell.Transfers.RemotePath.ShouldBe("/home/deploy", "the fake opens on the account's home");
|
||||
shell.Transfers.RemotePathDraft = typed;
|
||||
|
||||
await shell.Transfers.GoToTypedRemotePathCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.RemotePath.ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Somebody who cleared the box and pressed Enter has not asked for anything, and the root is a long way
|
||||
/// from where they were standing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClearingThePathBox_PutsThePathBackRatherThanGoingToTheRoot()
|
||||
{
|
||||
await ConnectedTransfersAsync();
|
||||
|
||||
shell.Transfers.RemotePathDraft = " ";
|
||||
|
||||
await shell.Transfers.GoToTypedRemotePathCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.RemotePath.ShouldBe("/home/deploy");
|
||||
shell.Transfers.RemotePathDraft.ShouldBe("/home/deploy");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Renaming asks first and then acts on the path the question named, not on the selection — the promise
|
||||
/// <c>ConfirmDeleteRemoteAsync</c> already makes, kept here for the same reason: nothing between opening
|
||||
/// the form and pressing its button may re-point it at another file.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RenamingARemoteEntry_KeepsItInPlaceUnderTheNewName()
|
||||
{
|
||||
var sftp = await ConnectedTransfersAsync();
|
||||
|
||||
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||
shell.Transfers.RenameRemoteCommand.Execute(null);
|
||||
|
||||
shell.Transfers.IsMovingRemote.ShouldBeTrue();
|
||||
shell.Transfers.RemoteMoveTarget.ShouldBe("notes.txt", "it opens on the name that changes nothing");
|
||||
|
||||
shell.Transfers.RemoteMoveTarget = "notes.md";
|
||||
|
||||
await shell.Transfers.ConfirmMoveRemoteCommand.ExecuteAsync(null);
|
||||
|
||||
sftp.Renamed.ShouldHaveSingleItem().ShouldBe(("/home/deploy/notes.txt", "/home/deploy/notes.md"));
|
||||
shell.Transfers.IsMovingRemote.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A separator in a name is a move wearing a rename's clothing, and the server would answer it with "no
|
||||
/// such file" about a directory nobody meant to name. The form stays open, because what has to be
|
||||
/// corrected is what was typed into it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RenamingToANameWithASeparatorInIt_IsRefusedAndTheFormStaysOpen()
|
||||
{
|
||||
var sftp = await ConnectedTransfersAsync();
|
||||
|
||||
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||
shell.Transfers.RenameRemoteCommand.Execute(null);
|
||||
shell.Transfers.RemoteMoveTarget = "archive/notes.txt";
|
||||
|
||||
await shell.Transfers.ConfirmMoveRemoteCommand.ExecuteAsync(null);
|
||||
|
||||
sftp.Renamed.ShouldBeEmpty();
|
||||
shell.Transfers.IsMovingRemote.ShouldBeTrue();
|
||||
shell.Transfers.Status.ShouldContain("cannot contain");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A move keeps the name and changes the directory, which is the half of the pair a rename cannot do.
|
||||
/// The destination is resolved the way the path box's is, so <c>../shared</c> means what it would mean
|
||||
/// in a shell rather than being handed to the server as typed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingARemoteEntry_KeepsItsNameAndTakesTheTypedDirectory()
|
||||
{
|
||||
var sftp = await ConnectedTransfersAsync();
|
||||
|
||||
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||
shell.Transfers.MoveRemoteCommand.Execute(null);
|
||||
|
||||
shell.Transfers.RemoteMoveTarget.ShouldBe("/home/deploy", "it opens on the directory it is in");
|
||||
|
||||
shell.Transfers.RemoteMoveTarget = "../shared";
|
||||
|
||||
await shell.Transfers.ConfirmMoveRemoteCommand.ExecuteAsync(null);
|
||||
|
||||
sftp.Renamed.ShouldHaveSingleItem().ShouldBe(("/home/deploy/notes.txt", "/home/shared/notes.txt"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The destination that changes nothing. SFTP would answer it with success and a file that had not
|
||||
/// moved, which reads as a move that silently did not happen.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task MovingARemoteEntryIntoTheDirectoryItIsAlreadyIn_IsRefused()
|
||||
{
|
||||
var sftp = await ConnectedTransfersAsync();
|
||||
|
||||
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||
shell.Transfers.MoveRemoteCommand.Execute(null);
|
||||
|
||||
await shell.Transfers.ConfirmMoveRemoteCommand.ExecuteAsync(null);
|
||||
|
||||
sftp.Renamed.ShouldBeEmpty();
|
||||
shell.Transfers.Status.ShouldContain("already there");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The three questions are one at a time by construction — each one's own opening clears the others — so
|
||||
/// that no row can have two forms open over it and no button can arm a second question behind the first.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OpeningTheRenameForm_TakesAwayADeletionThatWasBeingAskedAbout()
|
||||
{
|
||||
await ConnectedTransfersAsync();
|
||||
|
||||
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||
shell.Transfers.DeleteRemoteCommand.Execute(null);
|
||||
shell.Transfers.IsConfirmingRemoteDeletion.ShouldBeTrue();
|
||||
|
||||
shell.Transfers.RenameRemoteCommand.Execute(null);
|
||||
|
||||
shell.Transfers.IsConfirmingRemoteDeletion.ShouldBeFalse();
|
||||
shell.Transfers.IsMovingRemote.ShouldBeTrue();
|
||||
shell.Transfers.CanDeleteRemote.ShouldBeFalse("a question is on screen");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The pane used to go on showing the directory as it was before the file arrived, so the only way to
|
||||
/// see something this application had just sent was to press REFRESH — the application asking to be
|
||||
/// told about what it had done itself.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnUploadThatLandsInTheDirectoryShowing_ReReadsItWithoutBeingAsked()
|
||||
{
|
||||
var sftp = await ConnectedTransfersAsync();
|
||||
|
||||
var before = sftp.Listed.Count;
|
||||
|
||||
shell.Transfers.NoteTransferLanded(Landed("/home/deploy/build.tar.gz"));
|
||||
|
||||
sftp.Listed.Count.ShouldBe(before + 1);
|
||||
sftp.Listed[^1].ShouldBe("/home/deploy");
|
||||
shell.Transfers.RemoteListingIsStale.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Only the directory on screen. A queue moving a hundred files into somewhere nobody is looking at
|
||||
/// would otherwise be a round trip per file for a listing that changed nothing anybody can see.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnUploadThatLandsSomewhereElse_ChangesNothingOnScreen()
|
||||
{
|
||||
var sftp = await ConnectedTransfersAsync();
|
||||
|
||||
var before = sftp.Listed.Count;
|
||||
|
||||
shell.Transfers.NoteTransferLanded(Landed("/srv/releases/build.tar.gz"));
|
||||
|
||||
sftp.Listed.Count.ShouldBe(before);
|
||||
shell.Transfers.RemoteListingIsStale.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The refresh waits rather than interrupting. Re-listing under an open question is how the row somebody
|
||||
/// answered about stops being the row that gets acted on — so the staleness is remembered and settled
|
||||
/// when the question goes.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnUploadThatLandsWhileAQuestionIsOpen_WaitsForItToBeAnswered()
|
||||
{
|
||||
var sftp = await ConnectedTransfersAsync();
|
||||
|
||||
shell.Transfers.SelectedRemoteEntry = shell.Transfers.RemoteEntries[0];
|
||||
shell.Transfers.DeleteRemoteCommand.Execute(null);
|
||||
|
||||
var before = sftp.Listed.Count;
|
||||
|
||||
shell.Transfers.NoteTransferLanded(Landed("/home/deploy/build.tar.gz"));
|
||||
|
||||
sftp.Listed.Count.ShouldBe(before, "nothing may pull the listing out from under an open question");
|
||||
shell.Transfers.RemoteListingIsStale.ShouldBeTrue();
|
||||
|
||||
await shell.Transfers.CancelDeleteRemoteCommand.ExecuteAsync(null);
|
||||
|
||||
sftp.Listed.Count.ShouldBe(before + 1);
|
||||
shell.Transfers.RemoteListingIsStale.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A bucket is an <c>IRemoteFileStore</c> with no <c>HostSecret</c> underneath it, so there is no
|
||||
/// <c>PinnedPaths</c> to read at all — see <see cref="TransfersViewModel.OpenBucketAsync"/>'s own remark.
|
||||
@@ -8770,4 +9382,39 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
|
||||
return vault;
|
||||
}
|
||||
|
||||
/// <summary>A transfers screen connected to the fixture's host, and the session it opened.</summary>
|
||||
private async Task<FakeSftpSession> ConnectedTransfersAsync()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
||||
|
||||
return ssh.OpenedSftp.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>An upload that has just finished, as the queue would report it.</summary>
|
||||
/// <remarks>
|
||||
/// Built here rather than run through <c>FileTransferQueue</c>, because what is asserted is what the
|
||||
/// screen does when a transfer lands and not that a transfer can land: the queue's own path from
|
||||
/// enqueued to completed is covered against a fuller fake in <c>DodoSSH.Client.Transfer.Tests</c>, and
|
||||
/// running one here would put a thread-pool hand-off and this fixture's own dispatcher-less posting
|
||||
/// between the event and the assertion.
|
||||
/// </remarks>
|
||||
private static TransferSnapshot Landed(string remotePath) => new(
|
||||
Guid.CreateVersion7(),
|
||||
TransferDirection.Upload,
|
||||
SftpPath.Name(remotePath),
|
||||
Path.Combine(Path.GetTempPath(), SftpPath.Name(remotePath)),
|
||||
remotePath,
|
||||
128,
|
||||
128,
|
||||
TransferState.Completed,
|
||||
BytesPerSecond: 0,
|
||||
Failure: null);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Transfer;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
@@ -281,6 +282,71 @@ 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,6 +403,46 @@ 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,6 +766,59 @@ 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>
|
||||
|
||||
@@ -540,7 +540,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -652,9 +652,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -760,11 +760,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"AWSSDK.Core": {
|
||||
@@ -249,9 +249,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -270,11 +270,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -379,9 +379,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -481,11 +481,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,8 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
||||
var factory = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
await Should.ThrowAsync<SshAuthenticationException>(async () =>
|
||||
await factory.ConnectAsync(Request(new SshPrivateKeyCredential(Pkcs1(stranger), null)), Token));
|
||||
await factory.ConnectAsync(
|
||||
Request(new SshPrivateKeyCredential(Pkcs1(stranger), null)), progress: null, Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -125,6 +126,86 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
||||
shell.IsOpen.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The claim the connecting card is built on, checked where it can actually be checked: against a real
|
||||
/// handshake rather than a fake that reports whatever it was written to report. Every other test of the
|
||||
/// step list asserts that the shell draws what it is told; this one asserts that what it is told is
|
||||
/// true.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The order is the assertion. A step list is only readable if the reports arrive in the order it draws
|
||||
/// them, and the middle one is the load-bearing part — <see cref="SshConnectionPhase.CheckingHostKey"/>
|
||||
/// comes out of SSH.NET's <c>HostKeyReceived</c>, which is the single interior moment the library gives
|
||||
/// anybody, and it has to land between the other two rather than beside them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="SshConnectionPhase.OpeningShell"/> is deliberately absent: this factory's work ends with
|
||||
/// an authenticated connection, and the phase for opening a channel on one belongs to the layer that
|
||||
/// opens it. <c>TerminalWorkspaceTests</c> covers that half.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AHandshake_ReportsItsPhasesInTheOrderTheyHappen()
|
||||
{
|
||||
var knownHosts = await TrustedStoreAsync();
|
||||
var reported = new List<SshConnectionPhase>();
|
||||
|
||||
await using var connection = await new SshNetConnectionFactory(knownHosts).ConnectAsync(
|
||||
Request(new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null)),
|
||||
new DelegateProgress<SshConnectionPhase>(phase =>
|
||||
{
|
||||
lock (reported)
|
||||
{
|
||||
// Locked because the last two are reported from SSH.NET's own handshake thread rather
|
||||
// than from the awaiting one, which is the whole reason the shell marshals them.
|
||||
reported.Add(phase);
|
||||
}
|
||||
}),
|
||||
Token);
|
||||
|
||||
connection.IsConnected.ShouldBeTrue();
|
||||
|
||||
lock (reported)
|
||||
{
|
||||
reported.ShouldBe(
|
||||
[
|
||||
SshConnectionPhase.Reaching,
|
||||
SshConnectionPhase.CheckingHostKey,
|
||||
SshConnectionPhase.Authenticating,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the phase contract, and the one that would be easy to get wrong by reporting
|
||||
/// optimistically: a refused key stops at the check. Nothing may claim the credential was offered, and
|
||||
/// against an unknown host nothing ever is — the gate returns false and SSH.NET abandons the handshake
|
||||
/// before authentication.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AHostKeyRefusal_NeverClaimsToHaveAuthenticated()
|
||||
{
|
||||
var reported = new List<SshConnectionPhase>();
|
||||
|
||||
await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await new SshNetConnectionFactory(new InMemoryKnownHostStore()).ConnectAsync(
|
||||
Request(new SshPasswordCredential(SshServerFixture.Password)),
|
||||
new DelegateProgress<SshConnectionPhase>(phase =>
|
||||
{
|
||||
lock (reported)
|
||||
{
|
||||
reported.Add(phase);
|
||||
}
|
||||
}),
|
||||
Token));
|
||||
|
||||
lock (reported)
|
||||
{
|
||||
reported.ShouldBe([SshConnectionPhase.Reaching, SshConnectionPhase.CheckingHostKey]);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Pkcs1(RSA key) => Encoding.UTF8.GetBytes(key.ExportRSAPrivateKeyPem());
|
||||
|
||||
private static byte[] Pkcs8(RSA key) => Encoding.UTF8.GetBytes(key.ExportPkcs8PrivateKeyPem());
|
||||
@@ -141,7 +222,7 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
||||
// Learned by being refused, which is the only way this client learns a host key.
|
||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await factory.ConnectAsync(
|
||||
Request(new SshPasswordCredential(SshServerFixture.Password)), Token));
|
||||
Request(new SshPasswordCredential(SshServerFixture.Password)), progress: null, Token));
|
||||
|
||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||
|
||||
@@ -153,6 +234,6 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
||||
var knownHosts = await TrustedStoreAsync();
|
||||
|
||||
return await new SshNetConnectionFactory(knownHosts)
|
||||
.ConnectAsync(Request(credential), Token);
|
||||
.ConnectAsync(Request(credential), progress: null, Token);
|
||||
}
|
||||
}
|
||||
@@ -137,3 +137,14 @@ public sealed class KnownHostStoreTests
|
||||
string fingerprint = "SHA256:approved") =>
|
||||
new(host, port, algorithm, fingerprint);
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IProgress{T}"/> that runs its callback on the thread that reported.</summary>
|
||||
/// <remarks>
|
||||
/// <c>System.Progress<T></c> posts to a captured synchronisation context, or to the thread pool when
|
||||
/// there is none — which is what a test has here — so a list it appended to would be asserted on before it
|
||||
/// had been written. The same reason the shell does not use it either; see <c>VaultViewModel.ReporterFor</c>.
|
||||
/// </remarks>
|
||||
internal sealed class DelegateProgress<T>(Action<T> report) : IProgress<T>
|
||||
{
|
||||
public void Report(T value) => report(value);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
||||
var factory = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await factory.ConnectAsync(request, Token));
|
||||
await factory.ConnectAsync(request, progress: null, Token));
|
||||
|
||||
unknown.Presentation.Host.ShouldBe(InternalHost, "the target's name, not the proxy's");
|
||||
unknown.Presentation.Port.ShouldBe(SshServerFixture.InternalPort);
|
||||
@@ -75,7 +75,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
||||
|
||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||
|
||||
await using var connection = await factory.ConnectAsync(request, Token);
|
||||
await using var connection = await factory.ConnectAsync(request, progress: null, Token);
|
||||
|
||||
connection.IsConnected.ShouldBeTrue();
|
||||
connection.HostKey.Host.ShouldBe(InternalHost);
|
||||
@@ -121,7 +121,8 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
||||
var request = Request(Credential(), new SshLoopbackProxy(DeadPort()));
|
||||
|
||||
var failure = await Should.ThrowAsync<Exception>(async () =>
|
||||
await new SshNetConnectionFactory(new InMemoryKnownHostStore()).ConnectAsync(request, Token));
|
||||
await new SshNetConnectionFactory(new InMemoryKnownHostStore())
|
||||
.ConnectAsync(request, progress: null, Token));
|
||||
|
||||
failure.ShouldNotBeOfType<SshHostKeyUnknownException>();
|
||||
failure.ShouldNotBeOfType<SshHostKeyMismatchException>();
|
||||
@@ -137,7 +138,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
||||
var knownHosts = await TrustedStoreAsync();
|
||||
|
||||
await using var connection = await new SshNetConnectionFactory(knownHosts)
|
||||
.ConnectAsync(Request(Credential(), proxy: null), Token);
|
||||
.ConnectAsync(Request(Credential(), proxy: null), progress: null, Token);
|
||||
|
||||
connection.IsConnected.ShouldBeTrue();
|
||||
}
|
||||
@@ -216,7 +217,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
||||
|
||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await new SshNetConnectionFactory(knownHosts)
|
||||
.ConnectAsync(Request(Credential(), proxy: null), Token));
|
||||
.ConnectAsync(Request(Credential(), proxy: null), progress: null, Token));
|
||||
|
||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||
|
||||
|
||||
@@ -27,11 +27,11 @@ public sealed class PumpOverRealSshTests(SshServerFixture fixture)
|
||||
new SshPasswordCredential(SshServerFixture.Password));
|
||||
|
||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await factory.ConnectAsync(request, TestContext.Current.CancellationToken));
|
||||
await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken));
|
||||
|
||||
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
|
||||
|
||||
return await factory.ConnectAsync(request, TestContext.Current.CancellationToken);
|
||||
return await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -58,6 +58,58 @@ public sealed class RemotePathTests
|
||||
SftpPath.Trail("/").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The four shapes the file browser's path box has to take, because they are the four a shell takes and
|
||||
/// somebody typing a path into a box is typing what they would type into a shell.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("/var/log", "/etc/nginx", "/etc/nginx")]
|
||||
[InlineData("/var/log", "nginx", "/var/log/nginx")]
|
||||
[InlineData("/var/log", "../lib", "/var/lib")]
|
||||
[InlineData("/var/log", "./nginx/", "/var/log/nginx")]
|
||||
public void Resolve_TakesAbsoluteRelativeAndDotted(string current, string typed, string expected)
|
||||
{
|
||||
SftpPath.Resolve(current, typed).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("~", "/home/dodo")]
|
||||
[InlineData("~/.ssh", "/home/dodo/.ssh")]
|
||||
public void Resolve_ExpandsHomeWhenTheSessionHasSaidWhatItIs(string typed, string expected)
|
||||
{
|
||||
SftpPath.Resolve("/var/log", typed, "/home/dodo").ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A directory really can be called <c>~</c>, and a session that has not said where home is has nothing
|
||||
/// to expand it to. Guessing would send somebody to a path nobody typed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Resolve_LeavesHomeAloneWhenNothingHasSaidWhereItIs()
|
||||
{
|
||||
SftpPath.Resolve("/var/log", "~/keys").ShouldBe("/var/log/~/keys");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A blank box is not a request to go to the root, which is where "resolve nothing against the current
|
||||
/// directory" would otherwise land somebody who cleared the box and pressed Enter.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Resolve_AnswersTheCurrentDirectoryForNothingTyped()
|
||||
{
|
||||
SftpPath.Resolve("/var/log", " ").ShouldBe("/var/log");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Above the root is the root, as it is in every shell. The alternative is a path with no leading
|
||||
/// separator, which is not a remote path at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void Resolve_StopsClimbingAtTheRoot()
|
||||
{
|
||||
SftpPath.Resolve("/var", "../../../..").ShouldBe("/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PosixMode_RendersTheKindCharacterAndThreeTriples()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using DotNet.Testcontainers.Containers;
|
||||
using Xunit;
|
||||
@@ -40,6 +42,21 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
|
||||
private const int SshPort = 2222;
|
||||
|
||||
/// <summary>
|
||||
/// How many connections in a row the server has to answer before this fixture calls it ready.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Twenty-five, and the number is measured rather than picked. Probing a fresh container 200 times with
|
||||
/// penalties left at the image's default, the first <c>Not allowed at this time</c> came back at probe
|
||||
/// 18 and 183 of the 200 were refused; with <c>PerSourcePenalties no</c> applied, none of 200 were. Ten
|
||||
/// was tried first and is useless — it sits below the threshold, so the guard passed happily against a
|
||||
/// server that was still penalising. See <see cref="WaitUntilServingAsync"/>.
|
||||
/// </remarks>
|
||||
private const int RequiredStreak = 25;
|
||||
|
||||
/// <summary>How long to keep trying before giving up on the server entirely.</summary>
|
||||
private static readonly TimeSpan ReadyTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly SemaphoreSlim sftpGate = new(1, 1);
|
||||
|
||||
private IContainer? container;
|
||||
@@ -80,52 +97,202 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
.Build();
|
||||
|
||||
await container.StartAsync();
|
||||
await AllowTcpForwardingAsync();
|
||||
await ReconfigureAsync();
|
||||
await WaitUntilServingAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets this server open the direct-tcpip channels a forward is made of.
|
||||
/// Turns off the hardening this suite trips over, and makes the running server re-read its config.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The image ships <c>AllowTcpForwarding no</c>, and nothing says so at the point it bites.</b> A
|
||||
/// dynamic forward starts perfectly happily — it is a local listener, and opening it asks the server
|
||||
/// nothing — and then every connection through it is refused when the channel is opened. SSH.NET
|
||||
/// reports that as <c>SOCKS5: General failure</c> from the proxy, which names neither the server nor
|
||||
/// the setting, and is what the first run of <c>LoopbackProxyTests</c> collected.
|
||||
/// ◆ <b><c>PerSourcePenalties no</c> is the fix for the flake this suite had for months, and the other
|
||||
/// two settings here are not.</b> OpenSSH 9.8 added per-source penalties and 10.x has them on by
|
||||
/// default; this image runs 10.3. A source address that keeps disconnecting without authenticating is
|
||||
/// penalised, and while the penalty holds every connection from it is answered with the clear-text line
|
||||
/// <c>Not allowed at this time</c> and then closed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Patched after start rather than baked in, because the image's entrypoint writes its configuration
|
||||
/// itself on every boot — a mounted file would be overwritten before sshd read it. sshd re-reads on
|
||||
/// <c>SIGHUP</c> and applies the result to connections made after that, and the readiness wait has
|
||||
/// already run, so nothing here races the boot.
|
||||
/// <b>This suite generates exactly that traffic, by design.</b> This client's first contact with an
|
||||
/// unknown host is a connection deliberately refused at the host key — which is a disconnect with no
|
||||
/// authentication attempt — and several tests do nothing else:
|
||||
/// <c>RefusingTheHostKey_AbortsTheConnection</c>, <c>AnUntrustedHost_IsRefusedExactlyAsAShellWouldBe</c>,
|
||||
/// and every helper that learns a host key by being turned away first. Enough of them close together and
|
||||
/// sshd stops talking to the test host altogether, for a while, and then starts again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// From the client that is <c>SshConnectionException: The connection was closed by the remote host</c>
|
||||
/// within milliseconds — no banner, nothing to say which of the many reasons it was. It hits whichever
|
||||
/// class is running when the penalty lands and spares the rest, which is why it read as random and why
|
||||
/// the class it hit lost <em>every</em> connection it made rather than a random few. The one test in that
|
||||
/// class that expects a refusal passed throughout, for the wrong reason.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Two earlier diagnoses were wrong, and are recorded here so they are not tried again.</b>
|
||||
/// <c>MaxStartups</c> was blamed on the reasoning that xUnit runs test classes in parallel, so ten
|
||||
/// unauthenticated connections would be in flight at once — but every class that touches this server
|
||||
/// shares <see cref="SshCollection"/>, and xUnit's unit of parallelism is the collection, so they run one
|
||||
/// after another and never have more than a connection or two open. The reload window was blamed next,
|
||||
/// and a wait for the banner to answer was written and removed as unproven; it was unproven because the
|
||||
/// banner does answer, right up until the penalty lands.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The line is appended rather than replaced in place, unlike the two below it, because the image's
|
||||
/// config does not mention the keyword at all — there is no line to replace, and sshd takes the first
|
||||
/// value it finds for a keyword that appears more than once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b><c>AllowTcpForwarding</c> is what a dynamic forward needs</b>, and the image ships it off as
|
||||
/// hardening. Without it a forward opens perfectly happily — a local listener asks the server nothing —
|
||||
/// and then every connection through it is refused when the channel is opened. SSH.NET reports that as
|
||||
/// <c>SOCKS5: General failure</c>, which names neither the server nor the setting, and is what the first
|
||||
/// run of <c>LoopbackProxyTests</c> collected. That suite is also the alarm if this method ever silently
|
||||
/// stops working.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>MaxStartups</c> is raised for the reason it should have been in the first place rather than as a
|
||||
/// fix for anything: the compiled-in default refuses connections at random past ten unauthenticated ones
|
||||
/// in flight, and a throttle is hardening a test server has no business reproducing. It is kept, not
|
||||
/// because it was ever shown to matter here, but because removing it would be a second change riding
|
||||
/// along with this one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both are replaced in place rather than appended, because sshd_config takes the <em>first</em> value
|
||||
/// it finds for a keyword: an appended line would be dead the day the image ships an uncommented one of
|
||||
/// its own.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b><c>/config/sshd/sshd_config</c>, and there are two.</b> The image also carries
|
||||
/// <c>/etc/ssh/sshd_config</c>, which looks like the file to patch, reads identically, and is not the
|
||||
/// one the running server was started with — patching it changes the text and nothing else, which is a
|
||||
/// fix that appears to work and leaves the failure exactly where it was. Measured with <c>find</c>
|
||||
/// rather than assumed, after the first version of this method did precisely that.
|
||||
/// <c>/etc/ssh/sshd_config</c>, which looks like the file to patch, reads identically, and is not the one
|
||||
/// the running server was started with — patching it changes the text and nothing else, which is a fix
|
||||
/// that appears to work and leaves the failure exactly where it was.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is on for the whole assembly rather than for the one test that needs it. Forwarding is off in
|
||||
/// this image as hardening, not as a behaviour worth reproducing: nothing else here opens a channel of
|
||||
/// any kind, so allowing it changes what exactly one suite can do and what none of the others see.
|
||||
/// ◆ <b>Patched after boot and reloaded, rather than injected before it — which was tried and does not
|
||||
/// work.</b> This image family runs <c>/custom-cont-init.d</c> scripts, which look like the right hook
|
||||
/// and are not: the container's own log puts <c>sshd is listening on port 2222</c> <em>before</em>
|
||||
/// <c>[custom-init] Files found, executing</c>, so a script there edits a file the running server has
|
||||
/// already read. It leaves a config that greps correctly and a server behaving as though it had never
|
||||
/// been touched — the same trap as the wrong file, one layer up. Measured from the log, after a version
|
||||
/// of this fixture did exactly that and failed twenty-eight tests.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task AllowTcpForwardingAsync()
|
||||
private async Task ReconfigureAsync()
|
||||
{
|
||||
var result = await container!.ExecAsync([
|
||||
"sh",
|
||||
"-c",
|
||||
"sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config"
|
||||
+ " && sed -i 's/^#*MaxStartups .*/MaxStartups 200/' /config/sshd/sshd_config"
|
||||
+ " && printf '\\nPerSourcePenalties no\\n' >> /config/sshd/sshd_config"
|
||||
+ " && pkill -HUP sshd",
|
||||
]);
|
||||
|
||||
if (result.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Could not enable TCP forwarding on the test server: {result.Stderr}");
|
||||
$"Could not reconfigure the test server: {result.Stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until the server answers <see cref="RequiredStreak"/> connections in a row with its banner.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>This is a guard rather than a wait, and what it guards against is
|
||||
/// <c>PerSourcePenalties</c> coming back.</b> Reconfiguring above turns it off; this proves it is off,
|
||||
/// immediately and by name, instead of letting the suite discover it later as an unrelated-looking
|
||||
/// failure in whichever class happened to be running.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Consecutive, and deliberately with no pause between them.</b> Each probe opens a connection, reads
|
||||
/// the identification string and disconnects without authenticating — which is exactly the shape of
|
||||
/// connection <c>PerSourcePenalties</c> punishes, and exactly what this suite does all day: a first
|
||||
/// contact with an unknown host is a connection this client deliberately refuses at the host key.
|
||||
/// <see cref="RequiredStreak"/> back to back is therefore not a soak test, it is the specific
|
||||
/// provocation, sized above the measured threshold on purpose, and it costs well under a second when the
|
||||
/// setting is off.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is also the one check that can tell a listening socket from a running server. The container's own
|
||||
/// readiness — a log line and <c>netstat</c> showing <c>:2222</c> — passes on a container whose sshd has
|
||||
/// gone: the socket is published by a host-side proxy that accepts before it has anything to forward to,
|
||||
/// so a dead server presents as a connection accepted and closed rather than as one refused.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Probed from the host rather than with <c>docker exec</c>, deliberately: that is the path the tests
|
||||
/// take, proxy included, and penalties are counted per source address — from inside the container the
|
||||
/// source would be the loopback rather than the address every test connects from.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task WaitUntilServingAsync()
|
||||
{
|
||||
// TimeProvider.System rather than DateTimeOffset.UtcNow, which this repository bans so that time can
|
||||
// be faked — and rather than a fake, because what is being waited on is a real container starting.
|
||||
var deadline = TimeProvider.System.GetUtcNow() + ReadyTimeout;
|
||||
var streak = 0;
|
||||
var last = "no probe ran";
|
||||
|
||||
while (streak < RequiredStreak)
|
||||
{
|
||||
if (TimeProvider.System.GetUtcNow() >= deadline)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The test server did not answer {RequiredStreak} connections in a row within "
|
||||
+ $"{ReadyTimeout}. The last probe said: {last}. If it says \"Not allowed at this "
|
||||
+ "time\", sshd is penalising this source address and PerSourcePenalties is no longer "
|
||||
+ "being turned off — see ReconfigureAsync.");
|
||||
}
|
||||
|
||||
var (answered, what) = await ProbeAsync();
|
||||
last = what;
|
||||
|
||||
if (answered)
|
||||
{
|
||||
streak++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only pause when it is not working. Back-to-back probes are the point while they succeed;
|
||||
// hammering a server that has not finished starting is just noise.
|
||||
streak = 0;
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens a socket and reads far enough to see OpenSSH's identification string.</summary>
|
||||
/// <remarks>
|
||||
/// The description comes back with the answer because the interesting failures are not exceptions. A
|
||||
/// penalised source is told <c>Not allowed at this time</c> in clear text before the socket closes, and
|
||||
/// a suite that only knew "no banner" would have to go and find that out again — which is what happened
|
||||
/// the first time, at some length.
|
||||
/// </remarks>
|
||||
private async Task<(bool Answered, string What)> ProbeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var probe = new TcpClient();
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
await probe.ConnectAsync(Host, Port, timeout.Token);
|
||||
|
||||
var buffer = new byte[64];
|
||||
var read = await probe.GetStream().ReadAtLeastAsync(
|
||||
buffer, 4, throwOnEndOfStream: false, timeout.Token);
|
||||
|
||||
var answered = read >= 4 && "SSH-"u8.SequenceEqual(buffer.AsSpan(0, 4));
|
||||
|
||||
return (
|
||||
answered,
|
||||
answered
|
||||
? "SSH-"
|
||||
: $"{read} bytes: "
|
||||
+ Encoding.ASCII.GetString(buffer, 0, Math.Max(read, 0)).ReplaceLineEndings(" "));
|
||||
}
|
||||
catch (Exception exception) when (exception is SocketException or OperationCanceledException or IOException)
|
||||
{
|
||||
return (false, $"{exception.GetType().Name}: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,12 +335,17 @@ public sealed class SshServerFixture : IAsyncLifetime
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Shared rather than opened per test, and that is a limit of the server rather than an optimisation.
|
||||
/// sshd's <c>MaxStartups</c> drops connections at random once enough are part-way through a handshake,
|
||||
/// and this client's first contact with an unknown host is a connection deliberately <em>refused</em> at
|
||||
/// the host key — so a suite that opened its own session per test made two handshakes per test and
|
||||
/// pushed the whole assembly over the threshold. What that looks like is unrelated tests failing with
|
||||
/// "the connection was closed by the remote host", a different few each run.
|
||||
/// Shared rather than opened per test. This was once explained as a way of staying under the server's
|
||||
/// <c>MaxStartups</c> throttle, on the belief that the suite ran its classes in parallel and made two
|
||||
/// handshakes per test — this client's first contact with an unknown host is a connection deliberately
|
||||
/// <em>refused</em> at the host key, so every session costs two. The parallelism was not real: every
|
||||
/// class here shares one collection and xUnit runs collections, not classes, in parallel. See
|
||||
/// <see cref="WaitUntilServingAsync"/>, which is where that mistake was found and what the failure it
|
||||
/// was blamed for turned out to be.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It stays shared regardless, on the plainer argument: one session is enough, and a handshake per test
|
||||
/// would be seconds of the suite's runtime spent proving nothing this file has not already proved.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Safe to share because an SFTP session holds no per-test state: every test here works in a directory
|
||||
|
||||
@@ -110,7 +110,7 @@ public sealed class TerminalEndToEndTests(SshServerFixture fixture)
|
||||
|
||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||
await workspace.OpenSessionAsync(
|
||||
request, TerminalSize.Default, TestContext.Current.CancellationToken));
|
||||
request, TerminalSize.Default, progress: null, TestContext.Current.CancellationToken));
|
||||
|
||||
unknown.Presentation.Fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix);
|
||||
|
||||
@@ -119,6 +119,7 @@ public sealed class TerminalEndToEndTests(SshServerFixture fixture)
|
||||
return await workspace.OpenSessionAsync(
|
||||
request,
|
||||
new TerminalSize(100, 30, 1000, 750),
|
||||
progress: null,
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "Direct",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
@@ -306,7 +306,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.terminal": {
|
||||
@@ -317,9 +317,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
|
||||
@@ -4,10 +4,44 @@
|
||||
The throughput and backpressure harness the plan requires before any UI exists. Nothing
|
||||
here needs a WebView: the flow control is what is most likely to be wrong, and it is pure
|
||||
logic once ITerminalTransport is a seam.
|
||||
|
||||
And, since RendererReconnectionTests, the renderer's own half of the same protocol — the page
|
||||
that reads these frames, run as a script rather than described in C#. It lives here rather than
|
||||
in a test project of the shell's own because this is where the other end of the socket is
|
||||
tested, and the two halves of "the renderer stays attached" are one mechanism: the host takes
|
||||
a second upgrade over the first, and the page is what makes that second upgrade happen. A
|
||||
project for the shell would still need a reference to this one's subject to say anything.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
A JavaScript engine, so terminal.js can be run as it ships instead of transcribed into C#.
|
||||
See RendererPage for the trade against a node script, and for what an engine that is not
|
||||
Chromium can and cannot prove.
|
||||
-->
|
||||
<PackageReference Include="Jint" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
◆ THE PAGE IS COPIED OUT OF THE SHELL PROJECT, WHICH IS THE ONE ODD THING IN THIS FILE.
|
||||
|
||||
Reaching across for a source file is not something else here does, and the alternatives are
|
||||
worse: referencing DodoSSH.Client.Shell would drag Avalonia into a suite that draws nothing,
|
||||
and a copy of the page checked in beside the tests would be a copy — the thing that quietly
|
||||
stops matching what ships, which is precisely the failure this test exists to catch.
|
||||
|
||||
PreserveNewest rather than Always so that editing the page is what rebuilds it.
|
||||
-->
|
||||
<None Include="../../src/DodoSSH.Client.Shell/WebAssets/terminal.js"
|
||||
Link="Renderer/terminal.js"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
|
||||
<None Include="Renderer/renderer-harness.js" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -139,8 +139,15 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue,
|
||||
/// <inheritdoc />
|
||||
public Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
IProgress<SshConnectionPhase>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// The real factory's own order, so that a test watching this fake is watching the same sequence a
|
||||
// real handshake produces. It cannot report CheckingHostKey — there is no key exchange here to
|
||||
// produce a key — and inventing one would make this the only place that phase came from.
|
||||
progress?.Report(SshConnectionPhase.Reaching);
|
||||
progress?.Report(SshConnectionPhase.Authenticating);
|
||||
|
||||
var connection = new FakeConnection(request, bytesPerShell, blockShellReads);
|
||||
Connections.Add(connection);
|
||||
|
||||
@@ -250,3 +257,15 @@ internal sealed class RecordingTransport : ITerminalTransport
|
||||
TerminalFrame.TryRead(frame, out var actual, out _, out _)
|
||||
&& actual == (byte)opcode);
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="IProgress{T}"/> that runs its callback on the thread that reported.</summary>
|
||||
/// <remarks>
|
||||
/// <c>System.Progress<T></c> would post to a captured synchronisation context, or to the thread pool
|
||||
/// when there is none — which is what a test has here — so a list it appended to would be asserted on before
|
||||
/// it had been written. This is the same reason the shell does not use it either; see
|
||||
/// <c>VaultViewModel.ReporterFor</c>.
|
||||
/// </remarks>
|
||||
internal sealed class DelegateProgress<T>(Action<T> report) : IProgress<T>
|
||||
{
|
||||
public void Report(T value) => report(value);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
The world terminal.js is loaded into by RendererReconnectionTests.
|
||||
|
||||
It fakes exactly what the page's transport touches and nothing else: a WebSocket whose every step is
|
||||
driven from the test, a clock that only moves when a test moves it, and the two DOM objects the page
|
||||
reads at load. There is no Terminal, no fit addon and no WebGL here, because nothing on the reconnection
|
||||
path builds a pane — a test that opens a session will have to add them, and should, rather than this
|
||||
file guessing now at what such a test would want.
|
||||
|
||||
◆ NOTHING HERE MAY DECLARE A NAME terminal.js ALSO DECLARES.
|
||||
|
||||
Both files are evaluated as scripts into the same global scope, and the page's own `const root` against
|
||||
a `var root` here is a SyntaxError before a line of either runs. That is why the page's two elements are
|
||||
`pageRoot` and `pageBanner` below and reached through getElementById, which is how the page reaches them
|
||||
anyway.
|
||||
|
||||
◆ AND THE FAKE SOCKET'S BEHAVIOUR IS THE PART TO GET RIGHT.
|
||||
|
||||
close() is the one method with a rule that is not obvious and that the tests lean on: a socket closed
|
||||
while it is still CONNECTING never fires 'close' at all, per the WebSocket specification, while one
|
||||
closed after it opened does. The page relies on exactly that — connect() closes the attempt it abandons
|
||||
and expects to hear nothing back from it — so a fake that fired 'close' either way would report the loop
|
||||
the page's guards exist to prevent, and one that fired it neither way would hide it.
|
||||
*/
|
||||
|
||||
var clock = { now: 0, next: 1, timers: {} };
|
||||
|
||||
/** Every socket the page has opened, in order, live or dead. */
|
||||
var sockets = [];
|
||||
|
||||
function listeners(target) {
|
||||
target.handlers = {};
|
||||
|
||||
target.addEventListener = function (name, handler) {
|
||||
if (!target.handlers[name]) {
|
||||
target.handlers[name] = [];
|
||||
}
|
||||
|
||||
target.handlers[name].push(handler);
|
||||
};
|
||||
|
||||
target.fire = function (name) {
|
||||
var handlers = target.handlers[name] || [];
|
||||
|
||||
for (var i = 0; i < handlers.length; i++) {
|
||||
handlers[i]({});
|
||||
}
|
||||
};
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function FakeSocket(url, protocols) {
|
||||
this.url = url;
|
||||
this.protocols = protocols;
|
||||
this.readyState = FakeSocket.CONNECTING;
|
||||
this.binaryType = '';
|
||||
|
||||
listeners(this);
|
||||
sockets.push(this);
|
||||
}
|
||||
|
||||
FakeSocket.CONNECTING = 0;
|
||||
FakeSocket.OPEN = 1;
|
||||
FakeSocket.CLOSING = 2;
|
||||
FakeSocket.CLOSED = 3;
|
||||
|
||||
FakeSocket.prototype.send = function () {};
|
||||
|
||||
/** What the page calls. See the note above on why a connecting socket goes quietly. */
|
||||
FakeSocket.prototype.close = function () {
|
||||
if (this.readyState === FakeSocket.CLOSED) {
|
||||
return;
|
||||
}
|
||||
|
||||
var wasConnecting = this.readyState === FakeSocket.CONNECTING;
|
||||
this.readyState = FakeSocket.CLOSED;
|
||||
|
||||
if (!wasConnecting) {
|
||||
this.fire('close');
|
||||
}
|
||||
};
|
||||
|
||||
var pageBanner = { textContent: 'Connecting…' };
|
||||
|
||||
var pageRoot = listeners({
|
||||
dataset: { token: 'test-token', socket: 'ws://127.0.0.1:1/socket' },
|
||||
appendChild: function () {},
|
||||
});
|
||||
|
||||
var document = listeners({
|
||||
hidden: false,
|
||||
getElementById: function (id) { return id === 'root' ? pageRoot : pageBanner; },
|
||||
createElement: function () { return { dataset: {}, style: {}, remove: function () {} }; },
|
||||
});
|
||||
|
||||
var window = listeners({});
|
||||
|
||||
// The page warns through this on paths no reconnection test reaches. Present so that a test which does
|
||||
// reach one fails on its own assertion rather than on a missing global.
|
||||
var console = { warn: function () {}, log: function () {} };
|
||||
|
||||
function setTimeout(callback, delay) {
|
||||
var id = clock.next++;
|
||||
|
||||
clock.timers[id] = { at: clock.now + (delay || 0), callback: callback };
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function clearTimeout(id) {
|
||||
delete clock.timers[id];
|
||||
}
|
||||
|
||||
function ResizeObserver() {
|
||||
this.observe = function () {};
|
||||
}
|
||||
|
||||
// ── What the tests drive the page with ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Runs every timer due within the next `ms`, in the order they fall due.
|
||||
*
|
||||
* One at a time and re-scanned each round rather than collected up front, because a timer's callback
|
||||
* routinely schedules the next one — which is the whole shape of the page's retry — and a snapshot taken
|
||||
* before the first callback ran would miss it.
|
||||
*/
|
||||
function advance(ms) {
|
||||
var target = clock.now + ms;
|
||||
|
||||
for (;;) {
|
||||
var dueId = null;
|
||||
var due = null;
|
||||
|
||||
for (var id in clock.timers) {
|
||||
var timer = clock.timers[id];
|
||||
|
||||
if (timer.at <= target && (due === null || timer.at < due.at)) {
|
||||
due = timer;
|
||||
dueId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (due === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
delete clock.timers[dueId];
|
||||
clock.now = due.at;
|
||||
due.callback();
|
||||
}
|
||||
|
||||
clock.now = target;
|
||||
}
|
||||
|
||||
/** How many sockets the page has opened since it loaded. */
|
||||
function attempts() {
|
||||
return sockets.length;
|
||||
}
|
||||
|
||||
/** What the one status element says — the banner the user sees. */
|
||||
function banner() {
|
||||
return pageBanner.textContent;
|
||||
}
|
||||
|
||||
/** Whether that attempt has been closed, by the page or by the far end. */
|
||||
function isClosed(index) {
|
||||
return sockets[index].readyState === FakeSocket.CLOSED;
|
||||
}
|
||||
|
||||
/** The host accepted the upgrade. */
|
||||
function accept(index) {
|
||||
sockets[index].readyState = FakeSocket.OPEN;
|
||||
sockets[index].fire('open');
|
||||
}
|
||||
|
||||
/** An established socket goes away and the page is told. */
|
||||
function drop(index) {
|
||||
sockets[index].readyState = FakeSocket.CLOSED;
|
||||
sockets[index].fire('close');
|
||||
}
|
||||
|
||||
/** An attempt that never connects: 'error' then 'close', as a refused connection reports itself. */
|
||||
function fail(index) {
|
||||
sockets[index].readyState = FakeSocket.CLOSED;
|
||||
sockets[index].fire('error');
|
||||
sockets[index].fire('close');
|
||||
}
|
||||
|
||||
/**
|
||||
* A close arriving for a socket that died earlier — the host's takeover abort, landing late.
|
||||
*
|
||||
* Separate from drop() because the point of it is the delay: the socket is already dead by the time the
|
||||
* event is delivered, which is what a suspended renderer's queued events look like on the way back.
|
||||
*/
|
||||
function deliverLateClose(index) {
|
||||
sockets[index].readyState = FakeSocket.CLOSED;
|
||||
sockets[index].fire('close');
|
||||
}
|
||||
|
||||
/** The screen the page is on goes away, and comes back. */
|
||||
function becomeHidden() {
|
||||
document.hidden = true;
|
||||
document.fire('visibilitychange');
|
||||
}
|
||||
|
||||
function becomeVisible() {
|
||||
document.hidden = false;
|
||||
document.fire('visibilitychange');
|
||||
}
|
||||
|
||||
function takeFocus() {
|
||||
window.fire('focus');
|
||||
}
|
||||
|
||||
function comeOnline() {
|
||||
window.fire('online');
|
||||
}
|
||||
|
||||
var WebSocket = FakeSocket;
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
|
||||
using Jint;
|
||||
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One load of <c>terminal.js</c>, in a fake browser, for one test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The page's own source is what runs.</b> Not a transcription of its logic into C# — that would test a
|
||||
/// copy, and the copy would be the thing that stayed correct. The file is read from the shell project and
|
||||
/// evaluated as it ships, so a change to the page that breaks the reconnection rules fails here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Jint rather than node, and that is a deliberate trade.</b> A node script would be the obvious way to
|
||||
/// run JavaScript and would need node on every machine and in every CI job that runs the suite — so it
|
||||
/// would be a second test command, run separately, and the first thing to be forgotten. This runs inside
|
||||
/// <c>dotnet test</c> with everything else. What it costs is that the engine is not the engine the page
|
||||
/// actually runs in: Jint is not Chromium, so this can prove the page's own logic and can prove nothing
|
||||
/// about how WebView2 or Android's WebView behave. That boundary is exactly where
|
||||
/// <c>docs/manual-checks.md</c> picks up — see 1.10 and 11.12a.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A fresh engine per test, because the page is a script with module-level state and there is no unloading
|
||||
/// it: two tests sharing one engine would share a socket list, a clock and a backoff.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RendererPage
|
||||
{
|
||||
private readonly Engine engine;
|
||||
|
||||
private RendererPage(Engine engine) => this.engine = engine;
|
||||
|
||||
/// <summary>How many sockets the page has opened since it loaded.</summary>
|
||||
/// <remarks>
|
||||
/// The measurement nearly every test here turns on, and it is deliberately a count of *attempts*
|
||||
/// rather than of live sockets: the failures being guarded against are a page that stops trying and a
|
||||
/// page that never stops, and both are counted rather than observed.
|
||||
/// </remarks>
|
||||
public int Attempts => (int)engine.Evaluate("attempts()").AsNumber();
|
||||
|
||||
/// <summary>What the status element says — the banner a user would be looking at.</summary>
|
||||
public string Banner => engine.Evaluate("banner()").AsString();
|
||||
|
||||
/// <summary>
|
||||
/// Loads the harness and then the page, leaving the page exactly as it is a moment after the WebView
|
||||
/// navigated to it: one socket opened and still connecting.
|
||||
/// </summary>
|
||||
public static RendererPage Load()
|
||||
{
|
||||
var browser = new Engine();
|
||||
|
||||
// Order matters and is not incidental: the page connects on its last line, so every global it
|
||||
// touches — the socket constructor above all — has to be in place before it is evaluated.
|
||||
browser.Execute(Read("Renderer/renderer-harness.js"));
|
||||
browser.Execute(Read("Renderer/terminal.js"));
|
||||
|
||||
return new RendererPage(browser);
|
||||
}
|
||||
|
||||
/// <summary>Runs a line of the harness's own vocabulary — <c>accept(0)</c>, <c>advance(1000)</c>.</summary>
|
||||
public void Do(string script) => engine.Execute(script);
|
||||
|
||||
/// <summary>Whether that attempt has been closed, by the page or by the far end.</summary>
|
||||
public bool IsClosed(int attempt) =>
|
||||
engine.Evaluate(
|
||||
string.Create(CultureInfo.InvariantCulture, $"isClosed({attempt})"))
|
||||
.AsBoolean();
|
||||
|
||||
/// <remarks>
|
||||
/// Both files are copied beside the test assembly by the project file, the page out of the shell
|
||||
/// project it belongs to. Read from disk rather than embedded so that the copy which runs here is the
|
||||
/// same bytes the host serves, with nothing in between that could go stale.
|
||||
/// </remarks>
|
||||
private static string Read(string relativePath) =>
|
||||
File.ReadAllText(Path.Combine(AppContext.BaseDirectory, relativePath));
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The renderer page's half of staying attached — <c>terminal.js</c>'s <c>connect()</c> and what drives it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The host's half is <see cref="TerminalDataPlaneTests"/>, and the two are one mechanism: a socket that
|
||||
/// drops is ordinary here, and the page coming back for another is what makes it ordinary. What these
|
||||
/// tests protect is the property that failure of this mechanism has no other symptom — a terminal whose
|
||||
/// page has given up looks exactly like a terminal whose remote has gone quiet, except for a line of text
|
||||
/// nobody reads twice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Four of these were written against a page that failed them — the stale close, the handshake that never
|
||||
/// finishes, and the two wake-ups — and the rest describe behaviour that was already right and is easy to
|
||||
/// break while fixing those. The two that assert a wake-up does *nothing* pass against either version,
|
||||
/// which is the point of them: they are what stops the cure being worse, and they can only ever fail
|
||||
/// against a future change. See <see cref="RendererPage"/> for how the real file is loaded and for what
|
||||
/// this cannot reach.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RendererReconnectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ThePage_ConnectsWhenItLoads()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Attempts.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADroppedSocket_IsRetriedAndTheBannerClears()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("accept(0)");
|
||||
page.Banner.ShouldBe("");
|
||||
|
||||
page.Do("drop(0)");
|
||||
page.Banner.ShouldStartWith("Reconnecting");
|
||||
|
||||
page.Do("advance(1000)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
|
||||
page.Do("accept(1)");
|
||||
page.Banner.ShouldBe("");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The wait grows within one outage and goes back to a second once a socket has actually opened, so
|
||||
/// that the next outage is not paid for at the previous one's rate.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheWait_GrowsWithinAnOutageAndResetsAfterIt()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("fail(0); advance(1000)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
|
||||
page.Do("fail(1); advance(1999)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
|
||||
page.Do("advance(1)");
|
||||
page.Attempts.ShouldBe(3);
|
||||
|
||||
page.Do("accept(2); drop(2); advance(1000)");
|
||||
page.Attempts.ShouldBe(4);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A close for a socket the page has already replaced must not start a reconnect.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The loop this forbids costs nothing to enter and never leaves: the host aborts the displaced socket
|
||||
/// on every takeover — see <c>TerminalDataPlane.UpgradeAsync</c> — so a stale close that schedules a
|
||||
/// retry displaces the socket that has just succeeded, whose own close schedules the next. The visible
|
||||
/// end of it is a terminal that reconnects every second forever with the banner up for most of it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AStaleClose_DoesNotDisplaceTheSocketThatSucceeded()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("accept(0); drop(0); advance(1000)");
|
||||
page.Do("accept(1)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
|
||||
// The first socket's end, arriving after the page has moved on.
|
||||
page.Do("deliverLateClose(0)");
|
||||
page.Do("advance(60000)");
|
||||
|
||||
page.Attempts.ShouldBe(2);
|
||||
page.Banner.ShouldBe("");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An attempt that never finishes its handshake is given up on rather than waited on forever.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every other retry in the page is scheduled by a close or an error, so a socket that reports neither
|
||||
/// — which is what a renderer suspended mid-handshake leaves behind — used to schedule nothing at all.
|
||||
/// The page then held a banner saying it was reconnecting with no timer pending and no socket coming,
|
||||
/// for the rest of its life.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AHandshakeThatNeverFinishes_IsAbandonedAndRetried()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
// Nothing whatever from the first attempt: no open, no error, no close.
|
||||
page.Do("advance(5000)");
|
||||
page.Attempts.ShouldBe(1);
|
||||
|
||||
page.Do("advance(1000)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
page.IsClosed(0).ShouldBeTrue();
|
||||
|
||||
page.Do("accept(1)");
|
||||
page.Banner.ShouldBe("");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coming back to the page reconnects it, without waiting for a timer that may not be running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The case the whole wake-up path exists for, and the one a test can only approximate: the harness's
|
||||
/// clock stands still here because a real hidden page's clock is throttled rather than stopped, and
|
||||
/// standing still is the honest worst case of that. What is being asserted is that the page does not
|
||||
/// need the clock at all to notice it is back.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void BecomingVisibleAgain_ReconnectsWithoutTheTimer()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("accept(0); becomeHidden(); drop(0)");
|
||||
page.Attempts.ShouldBe(1);
|
||||
|
||||
page.Do("becomeVisible()");
|
||||
page.Attempts.ShouldBe(2);
|
||||
|
||||
// And the timer that was pending when the page woke must not open a third socket on top of the
|
||||
// one that just succeeded — which would be the takeover loop, entered from the other side.
|
||||
page.Do("accept(1); advance(60000)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
page.Banner.ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TakingTheKeyboardBack_AlsoReconnects()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("accept(0); drop(0); takeFocus()");
|
||||
|
||||
page.Attempts.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The wake-ups fire on gestures as ordinary as clicking the window, so the check they make has to be
|
||||
/// the thing that keeps them cheap rather than the frequency. A page whose socket is up must treat all
|
||||
/// of them as nothing at all — anything else would be the takeover loop with a person's mouse driving it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void WakingUpOverAHealthySocket_DoesNothing()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("accept(0)");
|
||||
page.Do("takeFocus(); becomeVisible(); comeOnline(); becomeHidden(); becomeVisible()");
|
||||
|
||||
page.Attempts.ShouldBe(1);
|
||||
page.Banner.ShouldBe("");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An attempt already in flight is left to finish or to time out. Restarting it on every wake-up would
|
||||
/// mean a page being clicked during a slow handshake never completing one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void WakingUpWhileConnecting_LeavesTheAttemptAlone()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("takeFocus(); becomeVisible(); comeOnline()");
|
||||
|
||||
page.Attempts.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -416,6 +416,36 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
||||
TimeProvider.System,
|
||||
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) });
|
||||
|
||||
/// <summary>
|
||||
/// Opens a renderer's socket and returns once the plane is actually holding it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Connected and attached are two different moments, and the gap between them is where this used to
|
||||
/// flake.</b> <c>ClientWebSocket.ConnectAsync</c> completes on the 101, which
|
||||
/// <see cref="TerminalDataPlane.UpgradeAsync"/> writes before it has a <see cref="WebSocket"/> to
|
||||
/// attach — it builds one from the stream and swaps it in a few instructions later, on the accept
|
||||
/// thread. A frame sent in between is dropped, by design rather than by accident: the transport has
|
||||
/// nowhere to put a frame for a renderer that is not there, and queueing it is the unbounded growth the
|
||||
/// credit window exists to prevent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So a test that connected and immediately expected a frame was racing that window on every run. It
|
||||
/// lost one on CI — <c>Output_ReachesTheRenderer</c> read the output frame first and asked why it was
|
||||
/// not the session's opening one, the opening one having been dropped a moment earlier — which is a
|
||||
/// scheduling accident on a loaded machine and says nothing whatever about the transport.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Production does not race it and needs no change: everything that opens a session waits on
|
||||
/// <c>TerminalWorkspace.WaitForRendererAsync</c> first, and that resolves from the same few lines this
|
||||
/// event is raised from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Subscribed before the connection rather than after it, because the event is raised on the accept
|
||||
/// thread and can be over before <c>ConnectAsync</c> has returned here. Bounded, so that a socket that
|
||||
/// never attaches fails this helper rather than hanging the suite in a later receive.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<ClientWebSocket> ConnectAsync(
|
||||
string? token = "",
|
||||
string? origin = null)
|
||||
@@ -433,17 +463,31 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
||||
"Origin",
|
||||
origin ?? string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{plane.Port}"));
|
||||
|
||||
var attached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void OnAttached(object? sender, EventArgs e) => attached.TrySetResult();
|
||||
|
||||
plane.SocketAttached += OnAttached;
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(
|
||||
new Uri($"ws://127.0.0.1:{plane.Port}{TerminalDataPlane.SocketPath}"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await attached.Task.WaitAsync(
|
||||
TimeSpan.FromSeconds(5),
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
plane.SocketAttached -= OnAttached;
|
||||
}
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
@@ -72,12 +72,12 @@ public sealed class TerminalWorkspaceTests
|
||||
workspace.LiveSessionCount.ShouldBe(0);
|
||||
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(1);
|
||||
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(2);
|
||||
}
|
||||
@@ -98,11 +98,63 @@ public sealed class TerminalWorkspaceTests
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await WaitUntilAsync(() => workspace.LiveSessionCount == 0);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="SshConnectionPhase.OpeningShell"/> is the one phase no connection factory can report,
|
||||
/// because by the time it happens the factory has handed back a connection and gone. If this layer did
|
||||
/// not report it the card's last step would light only when the whole session opened, which is the one
|
||||
/// moment the card is already being taken down — a step nobody would ever see lit.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted as the whole sequence rather than as "contains OpeningShell", because the order is the part
|
||||
/// that matters: a step list is only readable if what it is told arrives in the order it draws.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OpeningASession_ReportsTheShellPhaseTheFactoryCannot()
|
||||
{
|
||||
var connections = new FakeConnectionFactory();
|
||||
var reported = new List<SshConnectionPhase>();
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(),
|
||||
TerminalSize.Default,
|
||||
new DelegateProgress<SshConnectionPhase>(reported.Add),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
reported.ShouldBe(
|
||||
[
|
||||
SshConnectionPhase.Reaching,
|
||||
SshConnectionPhase.Authenticating,
|
||||
SshConnectionPhase.OpeningShell,
|
||||
],
|
||||
"the factory's own phases, then the one this layer performs itself");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Nobody watching is the ordinary case — every caller but the connecting card passes null — so it is
|
||||
/// worth one test that the null is a null and not a null reference.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OpeningASession_WorksWithNobodyWatchingItsPhases()
|
||||
{
|
||||
var connections = new FakeConnectionFactory();
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
workspace.IsSessionLive(sessionId).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClosingASessionEndsItAndDisposesItsConnection()
|
||||
{
|
||||
@@ -111,7 +163,7 @@ public sealed class TerminalWorkspaceTests
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.CloseSessionAsync(sessionId);
|
||||
|
||||
@@ -138,9 +190,9 @@ public sealed class TerminalWorkspaceTests
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var first = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
var second = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
workspace.IsSessionLive(first).ShouldBeTrue();
|
||||
workspace.IsSessionLive(second).ShouldBeTrue();
|
||||
@@ -166,7 +218,7 @@ public sealed class TerminalWorkspaceTests
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
var facts = workspace.GetSessionFacts(sessionId).ShouldNotBeNull();
|
||||
var connection = connections.Connections.ShouldHaveSingleItem();
|
||||
@@ -199,7 +251,7 @@ public sealed class TerminalWorkspaceTests
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
(await workspace.PasteAsync(
|
||||
sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
|
||||
@@ -217,12 +269,15 @@ public sealed class TerminalWorkspaceTests
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The event the tab strip listens to, so a dot can go out the moment a shell exits rather than at the
|
||||
/// next thing that happens to repaint. Raised only when the session ended on its own: a tab the user
|
||||
/// closed has a caller who already knows, and telling it would turn one close into two.
|
||||
/// The event the tab strip and the phone's keep-alive listen to, so a dot can go out — and a foreground
|
||||
/// notification can come down — the moment a shell exits rather than at the next thing that happens to
|
||||
/// repaint. The count captured inside the handler is the sharper half of this test: the announcement
|
||||
/// used to fire from inside the run's own finally block, where the run task is not yet complete, so
|
||||
/// <c>LiveSessionCount</c> read from the handler still said 1 — and the phone's notification went on
|
||||
/// claiming a shell that was gone, with nothing left to fire and correct it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASessionEndingOnItsOwnIsAnnounced()
|
||||
public async Task ASessionEndingOnItsOwnIsAnnounced_AfterTheCountStoppedIncludingIt()
|
||||
{
|
||||
// A shell with no output to give: its first read returns 0, which is a remote closing the channel,
|
||||
// so the pump finishes with nobody asking it to.
|
||||
@@ -231,16 +286,18 @@ public sealed class TerminalWorkspaceTests
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var ended = new List<uint>();
|
||||
var liveAtAnnouncement = -1;
|
||||
workspace.SessionEnded += (_, e) =>
|
||||
{
|
||||
lock (ended)
|
||||
{
|
||||
liveAtAnnouncement = workspace.LiveSessionCount;
|
||||
ended.Add(e.SessionId);
|
||||
}
|
||||
};
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await WaitUntilAsync(() =>
|
||||
{
|
||||
@@ -249,31 +306,49 @@ public sealed class TerminalWorkspaceTests
|
||||
return ended.Contains(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
lock (ended)
|
||||
{
|
||||
liveAtAnnouncement.ShouldBe(0, "the announcement must wait for the run to actually complete");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ASessionEndingOnItsOwnIsAnnounced" />
|
||||
/// <remarks>
|
||||
/// The reversal of a recorded decision, and the event's own remark carries why: a close used to be
|
||||
/// announced to nobody, on the theory that the caller already knew — but the phone's keep-alive is not
|
||||
/// the caller, and a close it never heard about left the foreground notification claiming a shell that
|
||||
/// was gone. Announced once, after the drain, so the count a handler reads is already honest.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClosingASessionIsNotAnnouncedBack()
|
||||
public async Task ClosingASessionIsAnnounced_OnceItHasDrained()
|
||||
{
|
||||
var connections = new FakeConnectionFactory();
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var announcements = 0;
|
||||
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
|
||||
var liveAtAnnouncement = -1;
|
||||
workspace.SessionEnded += (_, _) =>
|
||||
{
|
||||
liveAtAnnouncement = workspace.LiveSessionCount;
|
||||
Interlocked.Increment(ref announcements);
|
||||
};
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.CloseSessionAsync(sessionId);
|
||||
|
||||
Volatile.Read(ref announcements)
|
||||
.ShouldBe(0, "a close the caller asked for is not news to report back to it");
|
||||
.ShouldBe(1, "a close is news to the keep-alive even though it is an echo to the closer");
|
||||
liveAtAnnouncement.ShouldBe(0, "announced after the drain, so the count already excludes it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
|
||||
/// that the SSH connections are known to be released rather than assumed to be.
|
||||
/// that the SSH connections are known to be released rather than assumed to be — and that these closes,
|
||||
/// unlike a deliberate one, are announced to nobody: shutdown is dismantling every subscriber along
|
||||
/// with the sessions, and news nobody is left to hear is not news.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DisposingTheWorkspaceClosesEverySession()
|
||||
@@ -282,16 +357,20 @@ public sealed class TerminalWorkspaceTests
|
||||
|
||||
var workspace = CreateWorkspace(connections);
|
||||
|
||||
var announcements = 0;
|
||||
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
|
||||
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.DisposeAsync();
|
||||
|
||||
workspace.LiveSessionCount.ShouldBe(0);
|
||||
connections.Connections.Count.ShouldBe(2);
|
||||
connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
|
||||
Volatile.Read(ref announcements).ShouldBe(0, "shutdown closes are not announced");
|
||||
}
|
||||
|
||||
// ---- Reattach ----
|
||||
@@ -328,7 +407,7 @@ public sealed class TerminalWorkspaceTests
|
||||
using var first = await ConnectRendererAsync(workspace);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
// The session's own opening frame, sent as soon as the pump starts running. Not a replay, and not
|
||||
// what this test is about — read and discarded so it cannot be confused for one below.
|
||||
@@ -395,7 +474,7 @@ public sealed class TerminalWorkspaceTests
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
// The up-arrow, as a PTY expects it. Three bytes, and all three matter.
|
||||
byte[] upArrow = [0x1B, (byte)'[', (byte)'A'];
|
||||
@@ -417,7 +496,7 @@ public sealed class TerminalWorkspaceTests
|
||||
await using var workspace = CreateWorkspace(new FakeConnectionFactory());
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.CloseSessionAsync(sessionId);
|
||||
|
||||
@@ -459,10 +538,26 @@ public sealed class TerminalWorkspaceTests
|
||||
new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Attaches the way the real page does: by fetching the served page, reading the token and socket URL
|
||||
/// back out of it, and presenting them on the upgrade — rather than reaching into the workspace for a
|
||||
/// token it does not expose. A shortcut here would prove only that a socket can be opened, not that the
|
||||
/// workspace serves a page a renderer could actually attach with.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// And it waits for the attach rather than only for the handshake, for the reason
|
||||
/// <c>TerminalDataPlaneTests.ConnectAsync</c> sets out at length: the 101 is written before the socket
|
||||
/// is attachable, and a frame sent in between is dropped. A caller that opens a session on the socket
|
||||
/// this returns and then reads its opening frame is exactly the shape that loses that race.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>WaitForRendererAsync</c> answers only for the first renderer ever to attach, so a second call
|
||||
/// returns immediately without proving anything about the second socket. That is enough here and is
|
||||
/// not luck: the only frames a second socket is given are the replay, and the replay is *caused* by the
|
||||
/// attach — <see cref="TerminalWorkspace.RendererReattached"/> and the frames before it cannot be sent
|
||||
/// early. The day a test sends something else down a reattached socket, this needs the data plane's own
|
||||
/// <c>SocketAttached</c>, which the workspace does not forward today.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
|
||||
{
|
||||
@@ -482,6 +577,8 @@ public sealed class TerminalWorkspaceTests
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user