Public Access
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
cc8bf37321 | ||
|
|
3f5979d639 | ||
|
|
aaff81272a | ||
|
|
4d1f07f253 | ||
|
|
095774c498 | ||
|
|
3977f68870 | ||
|
|
48ea5e22d5 | ||
|
|
810bc48d3f | ||
|
|
671611a9a0 | ||
|
|
21cf77f64a | ||
|
|
dbf6ce1bcf | ||
|
|
242280ce6b | ||
|
|
de0b5f12ae | ||
|
|
009b35e069 |
@@ -291,6 +291,7 @@ jobs:
|
|||||||
# so it is not done either. What reaches users is built, installed and walked through Phase 16
|
# 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.
|
# of docs/manual-checks.md by a person first.
|
||||||
- name: package the windows desktop client
|
- name: package the windows desktop client
|
||||||
|
id: winpack
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -374,6 +375,93 @@ jobs:
|
|||||||
ls -la "$releases"
|
ls -la "$releases"
|
||||||
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
|
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
|
# 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
|
# 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
|
# 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>
|
||||||
@@ -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/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
|
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
|
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.
|
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
|
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.
|
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
|
## Installing on macOS
|
||||||
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
|
|
||||||
|
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
|
### 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
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
||||||
[Installing on Windows](#installing-on-windows) for what a user sees.
|
[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
|
**The macOS half is built on the same machinery**, and signed from the start because Gatekeeper leaves no
|
||||||
first release aimed at strangers), and macOS and Linux packaging.
|
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.
|
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
||||||
|
|
||||||
## Licence
|
## 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
|
**Status:** accepted, 2026-07-30
|
||||||
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
|
**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
|
- **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
|
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
||||||
is therefore required, not a nicety.
|
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.
|
- **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
|
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.
|
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
|
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.
|
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
|
### 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
|
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
|
||||||
|
|||||||
+79
-3
@@ -30,7 +30,10 @@ verified is that it compiles, links, packages, and carries the right natives.
|
|||||||
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
||||||
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
|
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
|
||||||
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
|
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
|
||||||
with the foreground service now counting transfers as well as shells.
|
with the foreground service now counting transfers as well as shells — and, since, an idle-but-connected
|
||||||
|
Files session as well, which a transfer count alone was blind to. *Corrected the same round:* the service's
|
||||||
|
other half — a shell's own opening — had never been wired to anything at all, so a shell survived only for
|
||||||
|
as long as the app stayed foreground; see [Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service).
|
||||||
|
|
||||||
**What was actually checked**, so the rest can be read with the right amount of trust:
|
**What was actually checked**, so the rest can be read with the right amount of trust:
|
||||||
|
|
||||||
@@ -235,6 +238,37 @@ The parts that are definitely different are the on-screen keyboard, and the fact
|
|||||||
needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an
|
needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an
|
||||||
accessory key row for this. That is UI work, not porting.
|
accessory key row for this. That is UI work, not porting.
|
||||||
|
|
||||||
|
> **⚠️ Corrected by the build. The data plane assumed a renderer that attaches once and lives forever, and
|
||||||
|
> that assumption is WebView2's truth, not Android's.** Desktop's WebView2 process starts with the window and
|
||||||
|
> dies with it; `TerminalDataPlane` was written to that reality — one socket, attached once,
|
||||||
|
> `Interlocked.Exchange`-guarded against a second attach ever happening at all. On a phone the WebView's own
|
||||||
|
> renderer process is a separate thing from the app process the foreground service above is keeping alive,
|
||||||
|
> and Android kills *that* independently — under memory pressure, or simply for being backgrounded — with no
|
||||||
|
> foreground service able to save it. The page then reloads with a fresh socket, and three things broke on
|
||||||
|
> that reload before this was found: the second attach was refused outright (`409 Conflict`), because a
|
||||||
|
> second valid upgrade could only mean a bug or a hostile second process, never our own page coming back; a
|
||||||
|
> send into the dead first socket threw, and that exception unwound `TerminalSessionPump`'s flush loop,
|
||||||
|
> freezing the still-live shell behind it — `LiveSessionCount` kept counting a session nothing would ever
|
||||||
|
> drain again; and every byte sent while no page was attached had already spent flow-control credit that no
|
||||||
|
> acknowledgement could ever return, so a session outliving 256 KiB of output into a dead page stalled for
|
||||||
|
> good regardless of the other two. Waiting for the old socket to notice it was dead and close on its own
|
||||||
|
> was never going to be enough either — a killed renderer sends no TCP FIN, so the old receive loop could sit
|
||||||
|
> unaware for the whole 30-second keepalive.
|
||||||
|
>
|
||||||
|
> Fixed as a takeover rather than a guard: a second valid upgrade — origin, token and subprotocol all
|
||||||
|
> checked exactly as before — now displaces whatever socket was attached instead of being refused, since
|
||||||
|
> only this app's own page ever knows the token, so a second valid attach *is* that page, back again.
|
||||||
|
> `TerminalDataPlane.SendAsync` no longer lets a dead-socket send escape as a fault; it reads as "nobody
|
||||||
|
> listening," same as no socket being attached at all. `TerminalWorkspace` resets each live session's credit
|
||||||
|
> window on every attach and resends its `SessionOpened` frame, flagged as a replay, so the fresh page
|
||||||
|
> rebuilds the pane and the pump stops waiting on an acknowledgement that was never coming. And
|
||||||
|
> `terminal.js`'s socket now retries itself, forever, with backoff, instead of reporting the connection
|
||||||
|
> failed and stopping — the page dies with the app anyway, so there is no case where retrying is the wrong
|
||||||
|
> call. What is **not** recovered, and says so rather than pretending otherwise: scrollback across a page
|
||||||
|
> reload. It lived in the page's own DOM, and a reloaded page is a new DOM. The replay banner — *"the view
|
||||||
|
> reconnected; earlier output stayed on the host"* — is that honesty put where the person looking at the
|
||||||
|
> terminal will actually read it, not buried in a log.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Decisions taken
|
## Decisions taken
|
||||||
@@ -280,13 +314,50 @@ What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, th
|
|||||||
|
|
||||||
### Sessions survive backgrounding, via a foreground service
|
### Sessions survive backgrounding, via a foreground service
|
||||||
|
|
||||||
A persistent notification for as long as a shell or a transfer is live.
|
A persistent notification for as long as a shell, a transfer, or an idle-but-connected Files session is
|
||||||
|
live.
|
||||||
|
|
||||||
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
|
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
|
||||||
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
|
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
|
||||||
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
|
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
|
||||||
notification is to look at.
|
notification is to look at.
|
||||||
|
|
||||||
|
**Three corrections found after the first cut shipped, all in the wiring rather than the design:**
|
||||||
|
|
||||||
|
- **A shell opening never started the service.** `SessionKeepAlive` heard `TerminalWorkspace.SessionEnded`
|
||||||
|
and refreshed on that, but nothing announced the opposite event — so a user who opened a shell and
|
||||||
|
backgrounded the app immediately had no foreground service at all, and Android was free to kill the
|
||||||
|
process holding it. `MainWindowViewModel.TerminalSessionOpened` is now wired the same way in
|
||||||
|
`App.axaml.cs`'s `ComposeKeepAlive`.
|
||||||
|
- **A connected-but-idle Files session counted as nothing.** A host open on the Files screen with no
|
||||||
|
transfer moving is a live SFTP connection a dying process would sever, and the old two-argument
|
||||||
|
`Reconcile(liveSessions, activeTransfers)` had no way to hear about it. `TransfersViewModel.HasLiveFileSession`
|
||||||
|
— `IsConnected` with a real `ConnectedCipher`, which a bucket never has — is the third fact `Reconcile` now
|
||||||
|
takes.
|
||||||
|
- **Refreshing the notification restarted the service, which throws when backgrounded.** `Reconcile` called
|
||||||
|
`StartForegroundService` on every refresh, including the common case of a service that was already
|
||||||
|
running. On API 31+ that throws `ForegroundServiceStartNotAllowedException` the instant the app is
|
||||||
|
backgrounded — a transfer finishing in the pocket, one of two shells dying — which crashed the process and
|
||||||
|
took every session with it. `SessionForegroundService` now tracks whether it is already running and, when
|
||||||
|
it is, posts the updated notification through `NotificationManager.Notify` instead of asking Android to
|
||||||
|
start anything.
|
||||||
|
|
||||||
|
**The notification permission is requested, not just declared.** API 33+ requires `POST_NOTIFICATIONS` at
|
||||||
|
runtime or the receipt is silently invisible — the service still runs, but nothing on screen says so.
|
||||||
|
`SessionForegroundService.Reconcile` asks for it the first time in this process there is actually something
|
||||||
|
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
|
### Phone first
|
||||||
|
|
||||||
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
|
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
|
||||||
@@ -523,7 +594,12 @@ go at 360dp:
|
|||||||
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
|
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
|
||||||
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
|
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
|
||||||
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
|
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
|
||||||
dishonesty the unlock screen's count exists to prevent.
|
dishonesty the unlock screen's count exists to prevent. *Corrected since:* the opened half of a shell's
|
||||||
|
lifecycle was never wired in, so the service could never come up for a shell at all; an idle-but-connected
|
||||||
|
Files session now counts as a third live fact rather than nothing; a refresh while backgrounded updates
|
||||||
|
the notification in place instead of restarting the service, which the API throws on; and
|
||||||
|
`POST_NOTIFICATIONS` is now actually requested rather than merely declared. See
|
||||||
|
[Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service) for all four.
|
||||||
7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design,
|
7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design,
|
||||||
plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
|
plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
|
||||||
choosing a passphrase).
|
choosing a passphrase).
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
|
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
|
||||||
> | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. |
|
> | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. |
|
||||||
> | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. |
|
> | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. |
|
||||||
> | QUICK ACCESS's editor, on the phone | **Deferred; the data is not.** `HostSecret.PinnedPaths` is shared, synced and merged on both heads, so a pin made on the desktop reaches the phone and back — Android just has nowhere yet to add or remove one itself. |
|
> | QUICK ACCESS's editor, on the phone | ◆ **Shipped, over the same shared data the deferral above described.** The phone's host editor draws its own QUICK ACCESS section on the same staged `VaultViewModel.EditorPinnedPaths` the desktop's drawer binds — a row per pin, an add field and button, and a remove target sized to this head's 44dp touch floor rather than the desktop's 22-pixel close box. `AddEditorPinCommand`'s refusals surface through a `Status` line the editor page draws for itself, since that page covers the whole screen and the list behind it draws its own `Status` off-screen for as long as it is open. The pins themselves reach a second surface this head has that the desktop does not need: `TransfersViewModel.ConnectedPinnedPaths` carries them as chips on the Files screen while connected, and tapping one runs `GoRemoteCommand` — the same command the breadcrumb trail already used to navigate. Two deviations from the desktop, both named where they land: the chip row is a snapshot taken at connect rather than a live follow of the vault, so a pin edited mid-session shows up on the next connect rather than this one; and the editor's own hint sentence says the pins appear on the Files screen, not above a terminal — this head has no terminal strip for them to sit above, the same honesty the row below already states for the desktop's own hint. |
|
||||||
> | Collapse All beside every section's own collapse chevron | Bound on every heading's view model and shown on only the first — `SidebarGroupHeader.IsFirstBoardSection` is what a virtualised list of sections uses in place of a control of the board's own that would otherwise have to sit above all of them. |
|
> | Collapse All beside every section's own collapse chevron | Bound on every heading's view model and shown on only the first — `SidebarGroupHeader.IsFirstBoardSection` is what a virtualised list of sections uses in place of a control of the board's own that would otherwise have to sit above all of them. |
|
||||||
> | The QUICK ACCESS hint's claim that pins live in a sidebar | "Pinned folders appear above the terminal for this host." — no sidebar exists on this screen for the sentence to point at, so the shipped hint says where they actually draw. |
|
> | The QUICK ACCESS hint's claim that pins live in a sidebar | "Pinned folders appear above the terminal for this host." — no sidebar exists on this screen for the sentence to point at, so the shipped hint says where they actually draw. |
|
||||||
> | The mock's "Saving to **DodoTech ▾** vault" subtitle, with a picker's chevron inside a sentence | The pre-existing `DrawerSubtitle` wording — the vault's name alone, unchanged by this pass. A chevron inside running text implies the text itself is the control, which it is not: the vault picker is its own element, shown only while creating and only above one writable vault, as it always has been. |
|
> | The mock's "Saving to **DodoTech ▾** vault" subtitle, with a picker's chevron inside a sentence | The pre-existing `DrawerSubtitle` wording — the vault's name alone, unchanged by this pass. A chevron inside running text implies the text itself is the control, which it is not: the vault picker is its own element, shown only while creating and only above one writable vault, as it always has been. |
|
||||||
@@ -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. |
|
> | 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. |
|
> | 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. |
|
> | 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. |
|
> | 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. |
|
> | `· 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. |
|
> | 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
|
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,
|
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.
|
`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.
|
||||||
|
|||||||
+290
-9
@@ -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**
|
### 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
|
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 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
|
**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
|
every one of them. Since v5b's chrome pass the rail is permanent furniture — it no longer collapses for
|
||||||
@@ -984,10 +985,12 @@ across that line would leave everyone else in the shared vault seeing a machine
|
|||||||
|
|
||||||
**Failure means:** the set's write is `ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce` again — one
|
**Failure means:** the set's write is `ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce` again — one
|
||||||
command reads the whole set, so "filed one of three" has no half-gesture to hide in the way the old drag's
|
command reads the whole set, so "filed one of three" has no half-gesture to hide in the way the old drag's
|
||||||
payload did. The refusal, though, is asserted by **nothing automated at all**: `VaultViewModel.RegroupChosenHosts`
|
payload did. The refusal is covered headlessly too:
|
||||||
counts the distinct vaults and no test raises it, so this check is the only thing between that sentence and
|
`RegroupingHostsChosenAcrossTwoKeychains_IsRefusedBeforeThePickerOpens` ticks a host in each of two
|
||||||
silence. A panel that does open over a mixed set is the worse half — it would offer one keychain's groups
|
keychains and asserts no picker opens and the sentence is on the status line. What is left for the eye is
|
||||||
for another keychain's machines, which is the half-filed set the refusal exists to prevent.
|
7.9's wiring, as in 7.6 — and a panel that does open over a mixed set is the worse half: it would offer one
|
||||||
|
keychain's groups for another keychain's machines, which is the half-filed set the refusal exists to
|
||||||
|
prevent.
|
||||||
|
|
||||||
### 7.7 A click still selects, and a double click still connects
|
### 7.7 A click still selects, and a double click still connects
|
||||||
|
|
||||||
@@ -1099,7 +1102,7 @@ time)" — is the worse failure of the two: it rebinds a host as a side effect o
|
|||||||
|
|
||||||
## Phase 8 — Adding and removing on the phone's host list
|
## Phase 8 — Adding and removing on the phone's host list
|
||||||
|
|
||||||
Thirteen checks, and the reason there are thirteen rather than none is worth stating: **the layout suite
|
Twenty-one checks, and the reason there are twenty-one rather than none is worth stating: **the layout suite
|
||||||
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
||||||
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
||||||
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
||||||
@@ -1416,6 +1419,60 @@ destinations, so switching under a live one would show a screen titled S3 listin
|
|||||||
the row — that screen keeps its own copy of the host list, so it has to be re-found there by entity id
|
the row — that screen keeps its own copy of the host list, so it has to be re-found there by entity id
|
||||||
rather than handed the vault's object.
|
rather than handed the vault's object.
|
||||||
|
|
||||||
|
### 8.18 QUICK ACCESS in the phone's host editor
|
||||||
|
|
||||||
|
Open a host's editor and scroll to QUICK ACCESS.
|
||||||
|
|
||||||
|
**Pass:** an empty list, an add field and an ADD button. Type `/var/www/app` and press ADD.
|
||||||
|
|
||||||
|
**Pass:** a row appears carrying that path and a ✕ at least 44dp on a side. Type the same path again and
|
||||||
|
press ADD.
|
||||||
|
|
||||||
|
**Pass:** nothing is added, and a sentence appears on the page saying the path is already pinned — this page
|
||||||
|
covers the whole screen while it is open, so that sentence is this page's own `Status` line rather than the
|
||||||
|
one the host list draws above HOSTS, which is off-screen right now. Clear the box and press ADD with nothing
|
||||||
|
typed.
|
||||||
|
|
||||||
|
**Pass:** a sentence saying a pinned path cannot be blank, in the same place.
|
||||||
|
|
||||||
|
Press the ✕ on the pinned row, then SAVE.
|
||||||
|
|
||||||
|
**Pass:** back on HOSTS with the pin gone. Open the editor on that host again.
|
||||||
|
|
||||||
|
**Pass:** QUICK ACCESS is empty — the removal was saved, not merely staged. Re-pin `/var/www/app`, SAVE, and
|
||||||
|
check the same host on the desktop.
|
||||||
|
|
||||||
|
**Pass:** the pin is there. `HostSecret.PinnedPaths` is shared and merged like every other field on a host,
|
||||||
|
so nothing about this page keeps its own copy.
|
||||||
|
|
||||||
|
**Failure means:** a refusal that changes nothing on screen is `AddEditorPinCommand` writing to `Status`
|
||||||
|
with nothing on this page bound to it — the honesty rule broken silently, since the command still behaves
|
||||||
|
correctly and only the telling of it is missing. A pin gone after SAVE-then-reopen but present on the
|
||||||
|
desktop is `BuildHost` not reading `EditorPinnedPaths`, or `EditSelectedHost` not loading it back in.
|
||||||
|
|
||||||
|
### 8.19 Pin chips on the Files screen
|
||||||
|
|
||||||
|
Pin a folder on a host, then connect to it on the files screen (SFTP), either directly or via **Connect via
|
||||||
|
SFTP**.
|
||||||
|
|
||||||
|
**Pass:** once connected, a row of chips appears between the breadcrumb and the listing, one per pin, each
|
||||||
|
at least 44dp tall. Tap one.
|
||||||
|
|
||||||
|
**Pass:** the listing navigates straight to that directory, the same as tapping a breadcrumb crumb does.
|
||||||
|
Disconnect, then connect to a host with nothing pinned.
|
||||||
|
|
||||||
|
**Pass:** no chip row at all — not an empty one. Connect to a bucket instead.
|
||||||
|
|
||||||
|
**Pass:** still no chip row, on any bucket. A bucket has no `HostSecret` underneath it and so nothing to
|
||||||
|
pin.
|
||||||
|
|
||||||
|
**Failure means:** chips that do not move the listing are the row's `GoRemoteCommand` binding pointed at the
|
||||||
|
wrong `DataContext` — see the `$parent[views:FilesScreen]` escape every other command in this file uses. A
|
||||||
|
chip row surviving a disconnect, or appearing under a bucket, is `TransfersViewModel.ConnectedPinnedPaths`
|
||||||
|
not being cleared in `CloseSessionAsync` or `OpenBucketAsync`. A chip row missing a pin added *after* this
|
||||||
|
connect is not a bug — see `ConnectedPinnedPaths`'s own remark on why this is a snapshot rather than a live
|
||||||
|
follow, and try disconnecting and reconnecting instead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 9 — Tag chips and the picker
|
## Phase 9 — Tag chips and the picker
|
||||||
@@ -1689,6 +1746,57 @@ 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
|
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
|
||||||
tap of an arrow key.
|
tap of an arrow key.
|
||||||
|
|
||||||
|
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Pass:** the new terminal renders and takes input straight away — no stuck "Connecting…" status, no blank
|
||||||
|
pane that never receives the prompt.
|
||||||
|
|
||||||
|
**Failure means:** `TerminalDataPlane` refused the page's reattach. The renderer's `WebSocket` does not
|
||||||
|
survive a tab going from one to zero and back to one on every device, and a host that answers a second valid
|
||||||
|
upgrade with `409 Conflict` instead of taking the socket over leaves every terminal after the first
|
||||||
|
permanently unreachable — see the correction in `docs/android-port.md`'s terminal section.
|
||||||
|
|
||||||
|
### 11.12 A backgrounded shell survives its renderer being killed · **needs several minutes, or developer tooling**
|
||||||
|
|
||||||
|
With a shell open and something worth reading in its scrollback, background the app (home button, not back)
|
||||||
|
for several minutes — long enough for Android to consider reclaiming it — then return. If the device exposes
|
||||||
|
it, forcing a stop of the WebView renderer process from Developer Options while backgrounded is the more
|
||||||
|
reliable way to trigger the same thing on demand rather than waiting on the OS's own judgement. Either way,
|
||||||
|
type something once you are back.
|
||||||
|
|
||||||
|
**Pass:** one of two honest outcomes, both good. Either the pane is exactly as it was — the renderer process
|
||||||
|
survived, so nothing needed to happen — or the pane is empty but for a dim line reading `── the view
|
||||||
|
reconnected; earlier output stayed on the host ──`, meaning the page reloaded and reattached. In both cases
|
||||||
|
what is typed now reaches the shell, and the shell is still the same one — not a new tab, not a reconnect
|
||||||
|
sheet, no "Connecting…" status stuck on screen.
|
||||||
|
|
||||||
|
**Failure means:** if the status stays stuck or nothing typed arrives, the renderer's socket did not retry
|
||||||
|
itself — see `terminal.js`'s `connect()` and its backoff. If the pane came back empty with **no** banner, a
|
||||||
|
session that survived a reload is being shown as though its scrollback had too, which is not true and is
|
||||||
|
worse than saying nothing: the banner exists so this is never silently wrong. If typing does nothing but the
|
||||||
|
banner is there, the session's credit window was not reset on reattach and the shell is frozen behind it —
|
||||||
|
see `TerminalWorkspace.ReplayAfterAttachAsync`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 12 — Shared vaults: the operations that span two accounts
|
## Phase 12 — Shared vaults: the operations that span two accounts
|
||||||
@@ -2019,9 +2127,63 @@ Queue several files in each direction, put the phone to sleep with the screen of
|
|||||||
notification goes away when the last one does — with no shell open. With a shell open it stays, because that
|
notification goes away when the last one does — with no shell open. With a shell open it stays, because that
|
||||||
is what it was already for.
|
is what it was already for.
|
||||||
|
|
||||||
|
Now, separately: open a shell to the host, press the home button (backgrounding rather than sleeping — the
|
||||||
|
distinction matters, because backgrounded is the state in which Android is free to kill a process no
|
||||||
|
foreground service is protecting), wait thirty seconds with the shell doing nothing, and return.
|
||||||
|
|
||||||
|
**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. 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
|
**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
|
`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.
|
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. 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
|
||||||
|
|
||||||
|
Connect to a host on the Files screen with no transfer queued — just browse to somewhere and stop. Note the
|
||||||
|
directory shown, then background the app, wait thirty seconds, and return.
|
||||||
|
|
||||||
|
**Pass:** the notification stayed up the whole time (check the shade if the return is too quick to see it
|
||||||
|
directly), and the pane is exactly where it was — the same listing, the same breadcrumb — with no reconnect
|
||||||
|
needed.
|
||||||
|
|
||||||
|
**Failure means:** `TransfersViewModel.HasLiveFileSession` not reaching `SessionKeepAlive`, so an idle but
|
||||||
|
still-open SFTP connection read as nothing running at all and the process was free to die under it.
|
||||||
|
|
||||||
|
### 14.6b The notification permission is asked for once, at the first thing worth showing · **needs Android 13+**
|
||||||
|
|
||||||
|
On a device running Android 13 or later, on a fresh install that has never connected to anything, open a
|
||||||
|
shell or the Files screen for the first time.
|
||||||
|
|
||||||
|
**Pass:** a system dialogue asking to allow notifications appears at that moment — not at launch, and not
|
||||||
|
before this first connect. Answer it either way; the connection completes regardless, and background the app
|
||||||
|
afterwards to confirm nothing else changed about it.
|
||||||
|
|
||||||
|
**Failure means:** the dialogue appearing at launch is asking before there is anything on screen to justify
|
||||||
|
it. Never appearing at all on API 33+ is the harder failure to notice, because nothing else surfaces it —
|
||||||
|
the service still starts and still holds the process open, only the receipt is invisible. See
|
||||||
|
`SessionForegroundService.RequestNotificationPermission`.
|
||||||
|
|
||||||
|
### 14.6c Refusing the permission costs the notification and nothing else
|
||||||
|
|
||||||
|
Continuing from 14.6b: choose **Don't allow** on the system dialogue. Queue a transfer, or open a shell, and
|
||||||
|
background the app.
|
||||||
|
|
||||||
|
**Pass:** no notification appears anywhere, but the transfer still finishes, or the shell is still there on
|
||||||
|
return, exactly as in 14.1–14.6a.
|
||||||
|
|
||||||
|
**Failure means:** anything disconnecting or failing here is the permission refusal being read as though it
|
||||||
|
had refused the service itself, rather than only the notification Android draws for it.
|
||||||
|
|
||||||
### 14.7 SAVE FILE writes where you pointed it, and the file opens
|
### 14.7 SAVE FILE writes where you pointed it, and the file opens
|
||||||
|
|
||||||
@@ -2262,7 +2424,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**
|
### 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
|
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
|
**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.
|
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
|
||||||
@@ -2435,3 +2599,120 @@ package manager will not offer to.
|
|||||||
|
|
||||||
**Failure means:** the channels are not separate, and a public key is signing the application people keep
|
**Failure means:** the channels are not separate, and a public key is signing the application people keep
|
||||||
their credentials in.
|
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
|
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
|
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
|
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
|
suspicion. Anything marked *unverified* has not run on the platform in question and must not be
|
||||||
in question and must not be assumed to work.
|
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
|
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.
|
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
|
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
|
||||||
any vault on macOS.
|
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.
|
**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
|
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
|
||||||
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
|
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
|
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
|
||||||
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
||||||
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
|
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
|
## 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
|
**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.
|
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
|
**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
|
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
|
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.
|
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
|
*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
|
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,
|
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
|
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.
|
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
|
**.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
|
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.
|
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
|
||||||
@@ -90,32 +90,61 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
|
|
||||||
shell.DataContext = viewModel;
|
shell.DataContext = viewModel;
|
||||||
|
|
||||||
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
|
// Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
|
||||||
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
|
// does and why.
|
||||||
//
|
ComposeKeepAlive(workspace, viewModel);
|
||||||
// The transfer count is real now that the document picker gives this head a way to start one, and
|
|
||||||
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
|
|
||||||
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
|
|
||||||
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
|
|
||||||
// moves five files.
|
|
||||||
//
|
|
||||||
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
|
|
||||||
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
|
|
||||||
// because it is subscribed to the workspace, which lives as long as the process.
|
|
||||||
var keepAlive = new SessionKeepAlive(
|
|
||||||
workspace,
|
|
||||||
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
|
|
||||||
|
|
||||||
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
|
||||||
// announces transfers appearing and finishing. Without this the notification would come up when an
|
|
||||||
// upload started and stay up after it finished, which is the failure this class exists to prevent.
|
|
||||||
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
|
|
||||||
|
|
||||||
keepAlive.Refresh();
|
|
||||||
|
|
||||||
return shell;
|
return shell;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires up the foreground service that makes <c>TerminalWorkspace</c>'s promise — that a shell outlives
|
||||||
|
/// a vault lock — true on a platform that stops backgrounded processes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Split out of <see cref="Compose"/> for length rather than for reuse; there is exactly one caller.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The transfer count is real now that the document picker gives this head a way to start one, and it
|
||||||
|
/// matters exactly when nobody is looking: a shell survives backgrounding because somebody opened it,
|
||||||
|
/// and an upload has to survive precisely when nobody is — the screen is off and the phone is in a
|
||||||
|
/// pocket. Queued counts as active, so putting five files in the queue and locking the phone moves five
|
||||||
|
/// files. <c>holdsFileSession</c> covers the third case a count alone cannot: a host connected on the
|
||||||
|
/// Files screen with no transfer moving is still a live SFTP session that backgrounding would sever, and
|
||||||
|
/// <c>TransfersViewModel.HasLiveFileSession</c> is the existing fact — <c>IsConnected</c> with a real
|
||||||
|
/// cipher, which a bucket never has — that answers whether one is open.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>keepAlive</c> is a local rather than a field, matching the desktop head: an Avalonia Application
|
||||||
|
/// has no disposal hook, so a field holding a disposable would have nowhere honest to release it. It
|
||||||
|
/// stays alive because it is subscribed to the workspace, which lives as long as the process.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void ComposeKeepAlive(TerminalWorkspace workspace, MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
var keepAlive = new SessionKeepAlive(
|
||||||
|
workspace,
|
||||||
|
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
|
||||||
|
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession);
|
||||||
|
|
||||||
|
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
||||||
|
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
|
||||||
|
// Without this the notification would come up when an upload started and stay up after it
|
||||||
|
// finished, which is the failure this class exists to prevent.
|
||||||
|
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
|
||||||
|
|
||||||
|
// The half that was missing until now: a shell opening. SessionKeepAlive already heard the
|
||||||
|
// workspace announce a session ending, but nothing announced the opposite — a user who opened a
|
||||||
|
// shell and backgrounded the app had no foreground service at all, because the only wire in was the
|
||||||
|
// one for taking it down. TerminalSessionOpened is that other half, forwarded from
|
||||||
|
// VaultViewModel.SessionOpened, and without this line the service could never come up for a shell
|
||||||
|
// in the first place, which was precisely the promise this whole arrangement exists to keep.
|
||||||
|
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
|
||||||
|
|
||||||
|
keepAlive.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writing to this phone's clipboard.
|
/// Writing to this phone's clipboard.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using global::Android.OS;
|
|||||||
namespace DodoSSH.Client.Android.Platform;
|
namespace DodoSSH.Client.Android.Platform;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Keeps the process alive for as long as a shell or a transfer is live.
|
/// Keeps the process alive for as long as a shell, a transfer, or a connected Files session is live.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -41,6 +41,26 @@ internal sealed class SessionForegroundService : Service
|
|||||||
private const string ChannelId = "dodossh.sessions";
|
private const string ChannelId = "dodossh.sessions";
|
||||||
private const int NotificationId = 1;
|
private const int NotificationId = 1;
|
||||||
|
|
||||||
|
// API 33+ requires the request to name a code the RequestPermissionsResult callback would be handed
|
||||||
|
// back — 1 is fine because this head never implements that callback at all, see
|
||||||
|
// RequestNotificationPermission's own remark for why a result is not worth listening for.
|
||||||
|
private const int NotificationPermissionRequestCode = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether <see cref="OnStartCommand"/> has run for this process without a matching
|
||||||
|
/// <see cref="OnDestroy"/> since — i.e. whether Android currently considers this service foregrounded.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Volatile because <see cref="Reconcile"/> can run on whatever thread called
|
||||||
|
/// <see cref="SessionKeepAlive.Refresh"/>, while this is set from the binder thread Android delivers
|
||||||
|
/// service lifecycle callbacks on — two threads with no other synchronisation between them, and a stale
|
||||||
|
/// read here is the difference between updating a notification in place and calling
|
||||||
|
/// <c>StartForegroundService</c> on a service that is already running, which is what defect 3 was.
|
||||||
|
/// </remarks>
|
||||||
|
private static volatile bool running;
|
||||||
|
|
||||||
|
private static bool notificationPermissionRequested;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
|
/// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
|
||||||
/// wanted here: the point is that they outlive whatever the user does with the interface.
|
/// wanted here: the point is that they outlive whatever the user does with the interface.
|
||||||
@@ -49,7 +69,9 @@ internal sealed class SessionForegroundService : Service
|
|||||||
|
|
||||||
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
|
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
|
||||||
{
|
{
|
||||||
StartForeground(NotificationId, BuildNotification(intent?.GetStringExtra("summary") ?? "Working"));
|
running = true;
|
||||||
|
|
||||||
|
StartForeground(NotificationId, BuildNotification(this, intent?.GetStringExtra("summary") ?? "Working"));
|
||||||
|
|
||||||
// NotSticky: if Android does kill this process, the SSH connections died with it and there is
|
// NotSticky: if Android does kill this process, the SSH connections died with it and there is
|
||||||
// nothing to resume. Restarting the service would produce a notification claiming sessions that no
|
// nothing to resume. Restarting the service would produce a notification claiming sessions that no
|
||||||
@@ -58,14 +80,36 @@ internal sealed class SessionForegroundService : Service
|
|||||||
return StartCommandResult.NotSticky;
|
return StartCommandResult.NotSticky;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// The other half of <see cref="running"/>. Android calls this whether the service stopped itself or
|
||||||
|
/// was stopped from outside — <see cref="Reconcile"/>'s down case calls <c>StopService</c> rather than
|
||||||
|
/// clearing the flag directly, so this override is the one place that actually knows the service has
|
||||||
|
/// gone, matching how <see cref="OnStartCommand"/> is the one place that knows it has come up.
|
||||||
|
/// </remarks>
|
||||||
|
public override void OnDestroy()
|
||||||
|
{
|
||||||
|
running = false;
|
||||||
|
|
||||||
|
base.OnDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android
|
/// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android
|
||||||
/// requires one, and because the user is entitled to know the app is holding connections open. Making
|
/// requires one, and because the user is entitled to know the app is holding connections open. Making
|
||||||
/// it buzz would be a notification about nothing having happened.
|
/// it buzz would be a notification about nothing having happened.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Static, taking the <see cref="Context"/> it needs rather than reading <c>this</c>: the instance path
|
||||||
|
/// through <see cref="OnStartCommand"/> passes the service itself, and the in-place update path through
|
||||||
|
/// <see cref="Reconcile"/> has no service instance at all — only <see cref="PhoneEnvironment.Require"/>
|
||||||
|
/// — because posting to an already-running notification never touches the service's own lifecycle.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private Notification BuildNotification(string summary)
|
private static Notification BuildNotification(Context context, string summary)
|
||||||
{
|
{
|
||||||
var manager = (NotificationManager)GetSystemService(NotificationService)!;
|
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
|
||||||
|
|
||||||
if (OperatingSystem.IsAndroidVersionAtLeast(26))
|
if (OperatingSystem.IsAndroidVersionAtLeast(26))
|
||||||
{
|
{
|
||||||
@@ -79,12 +123,12 @@ internal sealed class SessionForegroundService : Service
|
|||||||
}
|
}
|
||||||
|
|
||||||
var reopen = PendingIntent.GetActivity(
|
var reopen = PendingIntent.GetActivity(
|
||||||
this,
|
context,
|
||||||
0,
|
0,
|
||||||
new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
new Intent(context, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
||||||
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
|
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
|
||||||
|
|
||||||
return new Notification.Builder(this, ChannelId)
|
return new Notification.Builder(context, ChannelId)
|
||||||
.SetContentTitle("DodoSSH")
|
.SetContentTitle("DodoSSH")
|
||||||
.SetContentText(summary)
|
.SetContentText(summary)
|
||||||
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
|
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
|
||||||
@@ -93,37 +137,138 @@ internal sealed class SessionForegroundService : Service
|
|||||||
.Build();
|
.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Starts or stops the service to match what is actually running.</summary>
|
/// <summary>Starts, stops, or refreshes the service's notification to match what is actually running.</summary>
|
||||||
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
||||||
/// <param name="activeTransfers">Transfers still moving bytes.</param>
|
/// <param name="activeTransfers">Transfers still moving bytes.</param>
|
||||||
public static void Reconcile(int liveSessions, int activeTransfers)
|
/// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
|
||||||
|
/// always. Something live and the service not yet running starts it. Something live and the service
|
||||||
|
/// already running is the case that used to call <c>StartForegroundService</c> a second time, which on
|
||||||
|
/// API 31+ throws <c>ForegroundServiceStartNotAllowedException</c> the instant the app is backgrounded
|
||||||
|
/// — a transfer finishing in the pocket, one of two shells dying — crashing the process and taking the
|
||||||
|
/// remaining connections with it. That case — running, and the app backgrounded — now only posts a
|
||||||
|
/// fresh notification through the <see cref="NotificationManager"/> already holding the channel open,
|
||||||
|
/// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
|
||||||
|
/// start even over a service that looks like it is running; the inline remark below is why.
|
||||||
|
/// </remarks>
|
||||||
|
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession)
|
||||||
{
|
{
|
||||||
var context = PhoneEnvironment.Require();
|
var context = PhoneEnvironment.Require();
|
||||||
var intent = new Intent(context, typeof(SessionForegroundService));
|
|
||||||
|
|
||||||
if (liveSessions == 0 && activeTransfers == 0)
|
if (liveSessions == 0 && activeTransfers == 0 && !holdsFileSession)
|
||||||
{
|
{
|
||||||
context.StopService(intent);
|
context.StopService(new Intent(context, typeof(SessionForegroundService)));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The summary says what is actually held, counted rather than generic — the same principle the
|
// The summary says what is actually held, counted rather than generic — the same principle the
|
||||||
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
|
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
|
||||||
intent.PutExtra("summary", Summarise(liveSessions, activeTransfers));
|
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession);
|
||||||
|
|
||||||
context.StartForegroundService(intent);
|
// In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
|
||||||
|
// always allowed and is preferred even when `running` says the service is up: a disconnect followed
|
||||||
|
// by a quick reconnect can land here while the StopService just issued is still in flight, and
|
||||||
|
// posting to that dying service's notification would leave an orphan receipt over an unprotected
|
||||||
|
// process — restarting instead makes the flag's small lag harmless. A start on a service that
|
||||||
|
// really is running only re-delivers OnStartCommand, whose StartForeground updates the same
|
||||||
|
// notification anyway. CurrentActivity is the foreground signal: set on resume, cleared on pause.
|
||||||
|
if (running && PhoneEnvironment.CurrentActivity is null)
|
||||||
|
{
|
||||||
|
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
|
||||||
|
manager.Notify(NotificationId, BuildNotification(context, summary));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RequestNotificationPermission(context);
|
||||||
|
Start(context, summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Summarise(int liveSessions, int activeTransfers)
|
/// <remarks>
|
||||||
|
/// Split out of <see cref="Reconcile"/> for the try/catch alone, which needs its own remark and would
|
||||||
|
/// otherwise crowd the three-way branch above it.
|
||||||
|
/// </remarks>
|
||||||
|
private static void Start(Context context, string summary)
|
||||||
{
|
{
|
||||||
var parts = new List<string>(2);
|
var intent = new Intent(context, typeof(SessionForegroundService));
|
||||||
|
intent.PutExtra("summary", summary);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.StartForegroundService(intent);
|
||||||
|
}
|
||||||
|
catch (Java.Lang.IllegalStateException)
|
||||||
|
{
|
||||||
|
// ForegroundServiceStartNotAllowedException (API 31+) derives from this, and reaching it here
|
||||||
|
// means a start-worthy transition — a shell opening, a Files connection completing, the first
|
||||||
|
// transfer landing in an empty queue — happened while the app was backgrounded, which is
|
||||||
|
// precisely when Android refuses a new foreground start. There is no retry that helps: by the
|
||||||
|
// time this catch runs, the moment such a start would have been allowed has already passed.
|
||||||
|
// Swallowing it is the honest choice and not just the available one — letting the exception
|
||||||
|
// propagate would crash the process and drop the very shells and transfers this service exists
|
||||||
|
// to keep alive. A process that keeps running unprotected outlives one that does not run at all.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks for the receipt notification's own permission, the first time this process actually has
|
||||||
|
/// something to show rather than at launch.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// At most once per process, via <see cref="notificationPermissionRequested"/> — not to work around a
|
||||||
|
/// platform limit, since Android already refuses to show the dialogue twice, but because a second call
|
||||||
|
/// to <c>RequestPermissions</c> after the first is still pending is its own kind of noise. No result is
|
||||||
|
/// read back: there is nothing this class would do differently for a grant versus a refusal, so a
|
||||||
|
/// callback would exist only to be empty. What refusal costs is stated rather than hidden — the
|
||||||
|
/// notification stays invisible — and what it does not cost is the point: the service still starts,
|
||||||
|
/// still holds the process in the foreground, and the shells and transfers it protects are exactly as
|
||||||
|
/// safe as if the user had said yes. See the manifest's own comment on this permission.
|
||||||
|
/// </remarks>
|
||||||
|
private static void RequestNotificationPermission(Context context)
|
||||||
|
{
|
||||||
|
// Isolated as its own guard clause rather than folded into the compound condition below: the
|
||||||
|
// platform-compatibility analyzer only recognises a version check as guarding what follows when it
|
||||||
|
// is the sole condition of its own early return, and PostNotifications is annotated API 33+.
|
||||||
|
if (!OperatingSystem.IsAndroidVersionAtLeast(33))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read into a local rather than referenced again inside the lambda below: the guard clause above
|
||||||
|
// covers a direct call in this method's own body, but the platform-compatibility analyzer treats a
|
||||||
|
// lambda as reachable from anywhere and will not extend the guard across that boundary. Capturing
|
||||||
|
// the already-validated string sidesteps the false positive without weakening the actual check.
|
||||||
|
var postNotifications = global::Android.Manifest.Permission.PostNotifications;
|
||||||
|
|
||||||
|
if (notificationPermissionRequested
|
||||||
|
|| PhoneEnvironment.CurrentActivity is not { } activity
|
||||||
|
|| context.CheckSelfPermission(postNotifications) == Permission.Granted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationPermissionRequested = true;
|
||||||
|
|
||||||
|
activity.RunOnUiThread(() =>
|
||||||
|
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession)
|
||||||
|
{
|
||||||
|
var parts = new List<string>(3);
|
||||||
|
|
||||||
if (liveSessions > 0)
|
if (liveSessions > 0)
|
||||||
{
|
{
|
||||||
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (holdsFileSession)
|
||||||
|
{
|
||||||
|
parts.Add("Files connected");
|
||||||
|
}
|
||||||
|
|
||||||
if (activeTransfers > 0)
|
if (activeTransfers > 0)
|
||||||
{
|
{
|
||||||
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
|
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
|
||||||
|
|||||||
@@ -17,37 +17,53 @@ namespace DodoSSH.Client.Android.Platform;
|
|||||||
/// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which
|
/// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which
|
||||||
/// a counter incremented on open and decremented on close would not.
|
/// a counter incremented on open and decremented on close would not.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Three facts feed <see cref="SessionForegroundService.Reconcile"/>, not one: live shells, moving
|
||||||
|
/// transfers, and an idle-but-connected Files session. The last of those used to be missing entirely —
|
||||||
|
/// a shell survives backgrounding because somebody opened it, but a Files connection with nothing moving
|
||||||
|
/// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that
|
||||||
|
/// gap closed, read the same way the other two facts are: asked, not cached.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class SessionKeepAlive : IDisposable
|
internal sealed class SessionKeepAlive : IDisposable
|
||||||
{
|
{
|
||||||
private readonly TerminalWorkspace workspace;
|
private readonly TerminalWorkspace workspace;
|
||||||
private readonly Func<int> activeTransfers;
|
private readonly Func<int> activeTransfers;
|
||||||
|
private readonly Func<bool> holdsFileSession;
|
||||||
|
|
||||||
/// <param name="workspace">The live shells.</param>
|
/// <param name="workspace">The live shells.</param>
|
||||||
/// <param name="activeTransfers">
|
/// <param name="activeTransfers">
|
||||||
/// How many transfers are moving bytes. A delegate rather than a queue, because file transfer is out
|
/// How many transfers are moving bytes. A delegate rather than a queue, because ownership of the
|
||||||
/// of this head's first scope — see the decision in docs/android-port.md — and this is the seam it
|
/// transfer queue stays with <c>TransfersViewModel</c> — this class only ever asks it a question.
|
||||||
/// will arrive through rather than a dependency taken before there is anything to depend on.
|
|
||||||
/// </param>
|
/// </param>
|
||||||
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers)
|
/// <param name="holdsFileSession">
|
||||||
|
/// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but-
|
||||||
|
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
|
||||||
|
/// </param>
|
||||||
|
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers, Func<bool> holdsFileSession)
|
||||||
{
|
{
|
||||||
this.workspace = workspace;
|
this.workspace = workspace;
|
||||||
this.activeTransfers = activeTransfers;
|
this.activeTransfers = activeTransfers;
|
||||||
|
this.holdsFileSession = holdsFileSession;
|
||||||
|
|
||||||
// Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is
|
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
|
||||||
// a binder call and needs no particular thread. Nothing here touches the interface.
|
// 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;
|
workspace.SessionEnded += OnSessionEnded;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Called after anything that could change either count — opening a shell, closing a tab, a transfer
|
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
|
||||||
/// finishing. Calling it when nothing changed is free: reconciling to the state it is already in is
|
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
|
||||||
/// either a redundant <c>startForegroundService</c> on a running service or a <c>stopService</c> on a
|
/// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
|
||||||
/// stopped one, and Android treats both as no-ops.
|
/// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one,
|
||||||
|
/// and Android treats all of those as no-ops.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public void Refresh() =>
|
public void Refresh() =>
|
||||||
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers());
|
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -56,7 +72,7 @@ internal sealed class SessionKeepAlive : IDisposable
|
|||||||
|
|
||||||
// The notification goes with the composition root. Leaving it up over a process that is shutting
|
// The notification goes with the composition root. Leaving it up over a process that is shutting
|
||||||
// down is how an SSH client acquires a reputation for a notification you cannot get rid of.
|
// down is how an SSH client acquires a reputation for a notification you cannot get rid of.
|
||||||
SessionForegroundService.Reconcile(0, 0);
|
SessionForegroundService.Reconcile(0, 0, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
|
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,12 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
<!-- The service's persistent notification. Runtime-requested on API 33+, and refusal is survivable. -->
|
<!--
|
||||||
|
The service's persistent notification. Requested on API 33+ from SessionForegroundService.Reconcile,
|
||||||
|
the first time in this process there is actually something to show — not at launch, where the ask
|
||||||
|
would justify nothing on screen yet. Refusal is survivable: the service still starts and still holds
|
||||||
|
the process in the foreground either way, so a "no" costs the notification and nothing else.
|
||||||
|
-->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
|
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
|
||||||
|
|||||||
@@ -116,11 +116,27 @@
|
|||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
</Style>
|
</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">
|
<Style Selector="Button.row">
|
||||||
<Setter Property="MinHeight" Value="54" />
|
<Setter Property="MinHeight" Value="54" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
@@ -420,6 +436,20 @@
|
|||||||
<Setter Property="Fill" Value="{StaticResource Live}" />
|
<Setter Property="Fill" Value="{StaticResource Live}" />
|
||||||
</Style>
|
</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. -->
|
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
||||||
<Style Selector="TextBlock.mono">
|
<Style Selector="TextBlock.mono">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
the refusal has no continue button here either.
|
the refusal has no continue button here either.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
|
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
|
||||||
|
|
||||||
<!-- ============ header ============ -->
|
<!-- ============ header ============ -->
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
||||||
@@ -199,8 +199,48 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- ============ pins ============ -->
|
||||||
|
<!--
|
||||||
|
◆ The phone's answer to the desktop's QUICK ACCESS sidebar — the same PinnedPathList this host was
|
||||||
|
connected with, drawn where this head actually browses files rather than beside a terminal it has no
|
||||||
|
strip for. See TransfersViewModel.ConnectedPinnedPaths for why this is a snapshot taken at connect
|
||||||
|
rather than a live follow of the host row: a pin added or removed mid-session shows up here on the
|
||||||
|
next connect, not while this one is still open.
|
||||||
|
|
||||||
|
Its own row rather than folded into the breadcrumb above: the two scroll independently and mean
|
||||||
|
different things — the breadcrumb is where this pane is, the chips are places it can jump to — and a
|
||||||
|
shared row would make ↑ look like one more pin among several.
|
||||||
|
|
||||||
|
Gone rather than empty when nothing is pinned or nothing is connected, which HasConnectedPins already
|
||||||
|
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"
|
||||||
|
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
|
||||||
|
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" Margin="14,0" /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="x:String">
|
||||||
|
<!--
|
||||||
|
Classes="row" at its default 44dp MinHeight rather than the breadcrumb's own 36 — these chips
|
||||||
|
are the destination, not the trail behind it, and a target worth a deliberate tap gets the
|
||||||
|
full touch floor this head holds everything else to. Not trimmed: a chip scrolls sideways with
|
||||||
|
the row rather than being squeezed to fit it, so the full path is always what a tap commits to.
|
||||||
|
-->
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="11,0" CornerRadius="10"
|
||||||
|
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding}" />
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- ============ the listing ============ -->
|
<!-- ============ the listing ============ -->
|
||||||
<Panel Grid.Row="4">
|
<Panel Grid.Row="5">
|
||||||
|
|
||||||
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
||||||
VerticalAlignment="Top" Text="{Binding Status}" />
|
VerticalAlignment="Top" Text="{Binding Status}" />
|
||||||
@@ -266,7 +306,7 @@
|
|||||||
|
|
||||||
<!-- ============ what to do with the chosen entry ============ -->
|
<!-- ============ what to do with the chosen entry ============ -->
|
||||||
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
|
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
|
||||||
<Border Grid.Row="5" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
<Border Grid.Row="6" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||||
<StackPanel Spacing="9">
|
<StackPanel Spacing="9">
|
||||||
|
|
||||||
|
|||||||
@@ -1029,6 +1029,48 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</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
|
◆ 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
|
one vault that can be written to, exactly as on the desktop — an existing host's vault is not a
|
||||||
@@ -1098,6 +1140,60 @@
|
|||||||
Command="{Binding AddEditorTagCommand}" />
|
Command="{Binding AddEditorTagCommand}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ QUICK ACCESS. Staged on the shared VaultViewModel.EditorPinnedPaths the way EditorTagChoices
|
||||||
|
stages tags above — populated when the editor opens, read back by BuildHost on SAVE, left alone
|
||||||
|
by CANCEL. Nothing here is phone-only: a pin added on this page is the same PinnedPathList entry
|
||||||
|
the desktop's drawer stages under the same field, so it syncs to every other client the moment
|
||||||
|
this vault does, exactly as a tag would.
|
||||||
|
|
||||||
|
No folder glyph in the row, unlike the desktop's — this head carries MonoFont and a heading font
|
||||||
|
(see Theme/Phone.axaml) and no icon font, so the desktop's  would draw as a tofu box
|
||||||
|
rather than a folder here. The path text carries the row on its own, trimmed in a Grid's star
|
||||||
|
column rather than a StackPanel's — a StackPanel measures its children at infinity, so
|
||||||
|
TextTrimming never actually engages inside one; this is the same fix TransfersScreen's pane
|
||||||
|
headers needed for the same reason.
|
||||||
|
|
||||||
|
The remove button is Classes="icon" at its default 44x44, not the desktop's 22x22 close box —
|
||||||
|
see this file's touch-floor rule at the top of the class: a target sized for a mouse pointer is
|
||||||
|
not one a thumb can reliably land on.
|
||||||
|
|
||||||
|
See surface B, the Files screen's pin chips, for where these pins actually get used on this
|
||||||
|
head — the hint sentence below names it rather than the desktop's terminal strip, which does
|
||||||
|
not exist here.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="label" Text="QUICK ACCESS" Margin="0,4,0,0" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding EditorPinnedPaths}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><StackPanel Spacing="6" /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="x:String">
|
||||||
|
<Border MinHeight="44" CornerRadius="10" Background="{StaticResource Field}"
|
||||||
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" Padding="12,0">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5" Text="{Binding}"
|
||||||
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
|
<Button Grid.Column="1" Classes="icon" Content="✕"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).RemoveEditorPinCommand}"
|
||||||
|
CommandParameter="{Binding}" ToolTip.Tip="Unpins this path" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,8,Auto">
|
||||||
|
<TextBox Grid.Column="0" Classes="field" Text="{Binding EditorNewPin}"
|
||||||
|
PlaceholderText="/path/to/folder" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Width="72" Content="ADD"
|
||||||
|
Command="{Binding AddEditorPinCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="body"
|
||||||
|
Text="Pins show up as chips on the Files screen while you are connected to this host — tap one to jump straight there." />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ The one control here that publishes something. Turning it on copies this host's address and
|
◆ The one control here that publishes something. Turning it on copies this host's address and
|
||||||
port into plaintext columns the server can read, which is the single deliberate concession in
|
port into plaintext columns the server can read, which is the single deliberate concession in
|
||||||
@@ -1117,6 +1213,18 @@
|
|||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
Text="Not built yet: this app always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it works, the relay will dial on your behalf — which is why the address and port have to be stored in the clear. Everything else about the host stays encrypted either way, and a relayed host needs a port of its own rather than its group's." />
|
Text="Not built yet: this app always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it works, the relay will dial on your behalf — which is why the address and port have to be stored in the clear. Everything else about the host stays encrypted either way, and a relayed host needs a port of its own rather than its group's." />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
||||||
|
own rather than relying on the one the list behind it draws at the top of HOSTS (see this file's
|
||||||
|
other Classes="detail" Text="{Binding Status}") — that TextBlock is off-screen for as long as
|
||||||
|
IsEditing is true. AddEditorTagCommand and AddEditorPinCommand both refuse silently into Status
|
||||||
|
rather than throwing, so without a line to draw it here the honesty rule would be broken: a
|
||||||
|
refusal that writes a message nothing on screen shows is the same, to whoever pressed ADD, as no
|
||||||
|
refusal at all.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
|
||||||
Command="{Binding SaveHostCommand}" />
|
Command="{Binding SaveHostCommand}" />
|
||||||
|
|||||||
@@ -320,8 +320,14 @@
|
|||||||
which was true when a tab could not exist without a session; one can now —
|
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
|
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.
|
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" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -33,6 +33,85 @@
|
|||||||
gesture does the same thing the arrow does.
|
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>
|
<Panel>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
@@ -97,8 +176,12 @@
|
|||||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
<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" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||||
Text="{Binding Label}" />
|
Text="{Binding Label}" />
|
||||||
@@ -284,11 +367,70 @@
|
|||||||
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
||||||
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||||
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
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}">
|
Where a single unchanging "connecting…" used to be. The track counts steps that really finished
|
||||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
against the five there are — StepsDone over StepCount, never a percentage, because the arithmetic
|
||||||
</Button>
|
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>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -139,48 +139,18 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
{
|
{
|
||||||
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
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)
|
foreach (var (label, bytes, latches) in Keys)
|
||||||
{
|
{
|
||||||
var key = new Button
|
var key = CreateKey(label);
|
||||||
{
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (latches)
|
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()
|
private void ToggleControl()
|
||||||
{
|
{
|
||||||
controlLatched = !controlLatched;
|
controlLatched = !controlLatched;
|
||||||
|
|||||||
@@ -74,9 +74,9 @@
|
|||||||
},
|
},
|
||||||
"Microsoft.NET.ILLink.Tasks": {
|
"Microsoft.NET.ILLink.Tasks": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[10.0.10, )",
|
"requested": "[10.0.11, )",
|
||||||
"resolved": "10.0.10",
|
"resolved": "10.0.11",
|
||||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||||
},
|
},
|
||||||
"MinVer": {
|
"MinVer": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
<Setter Property="CornerRadius" Value="6" />
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
<Setter Property="Padding" Value="6,2" />
|
<Setter Property="Padding" Value="6,2" />
|
||||||
<Setter Property="MinHeight" Value="0" />
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontWeight" Value="Medium" />
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
@@ -268,7 +269,26 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- Every button in this window is small, mono and tracked out; only the colours differ. -->
|
<!--
|
||||||
|
Every button in this window is small, mono and tracked out; only the colours differ.
|
||||||
|
|
||||||
|
◆ VerticalContentAlignment IS NOT VerticalAlignment, and this style used to set only the second. The
|
||||||
|
first places the caption inside the button; the second places the button inside its parent. Avalonia's
|
||||||
|
default for content alignment is Stretch, so on any of these given a fixed Height — the hosts toolbar's
|
||||||
|
three at 40, and every dialog's row of them — the ContentPresenter stretched the caption's TextBlock to
|
||||||
|
the full content box and a TextBlock draws its line at the TOP of its bounds. Measured on the hosts
|
||||||
|
toolbar: a 40-pixel button with 9 pixels above the ink and 20 below it. Nothing was the wrong height,
|
||||||
|
which is why this read as one — the box was right and the label sat in the top third of it.
|
||||||
|
|
||||||
|
Center rather than a hand-tuned Padding, because the gap is the difference between the line box and the
|
||||||
|
content box and so moves with the font size: these carry 11.5 by default and the primary action
|
||||||
|
overrides it to 13.5. Every other button class here already sets it — navseg, sesstab, headerghost,
|
||||||
|
sidebarrow, fieldrow, paneicon — so this is the three shapes that were missed rather than a new idiom.
|
||||||
|
|
||||||
|
HorizontalContentAlignment is deliberately left alone. It is Stretch too, and it is invisible on a
|
||||||
|
button sized to its own caption; the ones that are stretched wide state their own (the flyouts' rows
|
||||||
|
ask for Left), and centring those from here would move text nobody complained about.
|
||||||
|
-->
|
||||||
<Style Selector="Button.ghost, Button.accent, Button.danger">
|
<Style Selector="Button.ghost, Button.accent, Button.danger">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="11.5" />
|
<Setter Property="FontSize" Value="11.5" />
|
||||||
@@ -277,6 +297,7 @@
|
|||||||
<Setter Property="Padding" Value="10,5" />
|
<Setter Property="Padding" Value="10,5" />
|
||||||
<Setter Property="MinHeight" Value="0" />
|
<Setter Property="MinHeight" Value="0" />
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -443,6 +464,7 @@
|
|||||||
<Setter Property="Padding" Value="13,7" />
|
<Setter Property="Padding" Value="13,7" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="CornerRadius" Value="12" />
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
@@ -453,26 +475,56 @@
|
|||||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
</Style>
|
</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
|
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
|
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
|
draws them as one list and a row that looked different from its neighbours would read as a separator
|
||||||
that is not one.
|
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">
|
<Style Selector="Button.poprow">
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="Padding" Value="11,4" />
|
<Setter Property="Padding" Value="11,4" />
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
<Setter Property="MinHeight" Value="20" />
|
<Setter Property="MinHeight" Value="20" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.poprow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.poprow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
</Style>
|
</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
|
The check square beside a shown vault in the popover — magenta rather than the accent, because the
|
||||||
@@ -571,10 +623,13 @@
|
|||||||
</Style>
|
</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>
|
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
|
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">
|
<Style Selector="Button.headerghost">
|
||||||
<Setter Property="Height" Value="32" />
|
<Setter Property="Height" Value="32" />
|
||||||
@@ -620,6 +675,30 @@
|
|||||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
</Style>
|
</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
|
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.
|
quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections.
|
||||||
@@ -651,6 +730,7 @@
|
|||||||
-->
|
-->
|
||||||
<Style Selector="Button.choice">
|
<Style Selector="Button.choice">
|
||||||
<Setter Property="Padding" Value="10,5" />
|
<Setter Property="Padding" Value="10,5" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="10.5" />
|
<Setter Property="FontSize" Value="10.5" />
|
||||||
<Setter Property="LetterSpacing" Value="0.5" />
|
<Setter Property="LetterSpacing" Value="0.5" />
|
||||||
@@ -833,18 +913,30 @@
|
|||||||
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
|
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
|
||||||
rather than narrower.
|
rather than narrower.
|
||||||
|
|
||||||
◆ 224 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
◆ 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 1016 less the rail's 190 and the drawer's 320, which is 506. The
|
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 16 of margin on each side, and the vertical scrollbar takes its own —
|
scrolling stack inside it takes 26 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
|
call the usable width 454. A WrapPanel fits floor(454 / (Width + 10)) per row, so two columns needs
|
||||||
Width no more than 227.
|
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
|
Every number here has moved at least once, and always because something beside the cards did:
|
||||||
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
|
· 248, from this reasoning with the two margins left out. It laid out cleanly and the layout harness
|
||||||
the size this application guarantees, which is the shape the cards exist to avoid. The second was 232,
|
passed it, because the harness asks whether a control is inside the window and not how many of them
|
||||||
derived the same way against the drawer's own 304; v5 widened the drawer to 320 for the ADDRESS field's
|
fit on a line — so the grid quietly became one column wide at exactly the size this application
|
||||||
breathing room, which narrowed the budget this number is drawn from and had to move it down in step.
|
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">
|
<Style Selector="Border.tile">
|
||||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||||
@@ -852,7 +944,7 @@
|
|||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="12" />
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
<Setter Property="Padding" Value="12,10" />
|
<Setter Property="Padding" Value="12,10" />
|
||||||
<Setter Property="Width" Value="224" />
|
<Setter Property="Width" Value="214" />
|
||||||
<Setter Property="Margin" Value="0,0,10,10" />
|
<Setter Property="Margin" Value="0,0,10,10" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBoxItem:pointerover Border.tile">
|
<Style Selector="ListBoxItem:pointerover Border.tile">
|
||||||
@@ -1100,6 +1192,7 @@
|
|||||||
<Style Selector="Button.panechip">
|
<Style Selector="Button.panechip">
|
||||||
<Setter Property="Padding" Value="8,4" />
|
<Setter Property="Padding" Value="8,4" />
|
||||||
<Setter Property="MinHeight" Value="0" />
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="11" />
|
<Setter Property="FontSize" Value="11" />
|
||||||
<Setter Property="FontWeight" Value="Medium" />
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
@@ -1144,6 +1237,16 @@
|
|||||||
<Setter Property="Fill" Value="{StaticResource Live}" />
|
<Setter Property="Fill" Value="{StaticResource Live}" />
|
||||||
</Style>
|
</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
|
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.
|
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
|
# 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
|
# 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
|
# to forget one of. Neither desktop platform will take a vector: <ApplicationIcon> wants an .ico
|
||||||
# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays
|
# and nothing else, Window.Icon wants a bitmap, and vpk wants an .icns for the macOS bundle. So
|
||||||
# honest: the numbers below are the ones in that XML, and regenerating is the whole edit.
|
# 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
|
# 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.
|
# 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,
|
# 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
|
# 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.
|
# the gap, and its downsampler is not kind to a hairline.
|
||||||
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
|
$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)
|
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||||
$g = [System.Drawing.Graphics]::FromImage($bitmap)
|
$g = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
$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
|
# 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.
|
# 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
|
$d = $radius * 2.0
|
||||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||||
$path.AddArc(0.0, 0.0, $d, $d, 180, 90)
|
$path.AddArc($inset, $inset, $d, $d, 180, 90)
|
||||||
$path.AddArc($size - $d, 0.0, $d, $d, 270, 90)
|
$path.AddArc($inset + $tile - $d, $inset, $d, $d, 270, 90)
|
||||||
$path.AddArc($size - $d, $size - $d, $d, $d, 0, 90)
|
$path.AddArc($inset + $tile - $d, $inset + $tile - $d, $d, $d, 0, 90)
|
||||||
$path.AddArc(0.0, $size - $d, $d, $d, 90, 90)
|
$path.AddArc($inset, $inset + $tile - $d, $d, $d, 90, 90)
|
||||||
$path.CloseFigure()
|
$path.CloseFigure()
|
||||||
$brush = New-Object System.Drawing.SolidBrush($accent)
|
$brush = New-Object System.Drawing.SolidBrush($accent)
|
||||||
$g.FillPath($brush, $path)
|
$g.FillPath($brush, $path)
|
||||||
|
|
||||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge.
|
# 108-viewport units to pixels, with the outer 18 dropped on each edge. Scaled to the tile and
|
||||||
$scale = [double]$size / 72.0
|
# offset by the inset, so the glyph keeps its place within the tile at either fraction.
|
||||||
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) }
|
$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
|
# 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
|
# 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()
|
$w.Dispose(); $out.Dispose()
|
||||||
|
|
||||||
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
|
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>
|
<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
|
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.
|
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>
|
/// </remarks>
|
||||||
internal static IUpdateChannel ForThisMachine()
|
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();
|
return new UnavailableUpdateChannel();
|
||||||
}
|
}
|
||||||
@@ -55,14 +60,21 @@ internal static class UpdateChannels
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The one file in the repository that names Velopack. It lives beside <c>WindowsDeviceKeyStore</c>
|
/// The one file in the repository that names Velopack. It lives beside the platform key stores rather
|
||||||
/// rather than in a project of its own because it is the same kind of thing — a Windows-only
|
/// than in a project of its own because it is the same kind of thing — a desktop-only implementation of
|
||||||
/// implementation of an interface declared in <c>DodoSSH.Client.Session</c> — and because
|
/// an interface declared in <c>DodoSSH.Client.Session</c> — and because <c>DodoSSH.Client.Shell</c> is
|
||||||
/// <c>DodoSSH.Client.Shell</c> is shared with the Android head, which must never acquire an updater.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
|
/// 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:
|
/// 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.
|
/// the check succeeds, finds nothing, and reports that the client is up to date forever.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const string ReleaseChannel = "win";
|
private const string WindowsReleaseChannel = "win";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The nightly channel, which is a different name rather than the same one on a different tag.
|
/// 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.
|
/// a download somebody watched. A channel each means neither ever sees the other's releases at all.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </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;
|
private readonly UpdateManager manager;
|
||||||
|
|
||||||
@@ -141,10 +172,13 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsSupported => true;
|
public bool IsSupported => true;
|
||||||
|
|
||||||
/// <summary>Always, on this head.</summary>
|
/// <summary>Always, on this head, on either platform.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Velopack's apply runs <c>Update.exe</c> over this installation and restarts it, so the process is
|
/// Velopack's apply hands off to a separate updater process — <c>Update.exe</c> on Windows, the
|
||||||
/// gone by the time anything could have asked a question. The phone's is the other answer; see
|
/// <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.
|
/// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool ApplyingEndsTheProcess => true;
|
public bool ApplyingEndsTheProcess => true;
|
||||||
@@ -183,7 +217,36 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
|
|
||||||
return new UpdateManager(
|
return new UpdateManager(
|
||||||
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
|
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 />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -9,9 +9,17 @@ namespace DodoSSH.Client.App.Platform;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
|
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware,
|
||||||
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
|
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
|
||||||
/// passphrase — which is the honest answer rather than a degraded one.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
/// <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);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()
|
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||||
? new WindowsDeviceKeyStore(paths)
|
{
|
||||||
: new UnavailableDeviceKeyStore();
|
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
|
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.
|
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
|
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
|
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
|
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.
|
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>
|
<Panel>
|
||||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14" MaxWidth="460"
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
||||||
Margin="24">
|
Margin="24">
|
||||||
|
|
||||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||||
@@ -38,15 +121,43 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Two states, deliberately different. Waiting is an accent line under the host's name; a refusal is
|
Bound to StepsDone against StepCount rather than to a percentage: five steps and a maximum of five
|
||||||
the reason, in the palette's red, because it is the only place the reason will be after the user
|
means the bar is a count of things that really finished, and the arithmetic that would turn it into
|
||||||
navigates away from the screen that started the connection.
|
a percentage is exactly the arithmetic that would start inventing one.
|
||||||
-->
|
-->
|
||||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="12"
|
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||||
Foreground="{StaticResource Accent}" HorizontalAlignment="Center"
|
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||||
TextWrapping="Wrap" TextAlignment="Center"
|
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}" HorizontalAlignment="Center">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Spacing="7" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="stepmark"
|
||||||
|
Classes.done="{Binding IsDone}"
|
||||||
|
Classes.running="{Binding IsRunning}"
|
||||||
|
Classes.stopped="{Binding IsStopped}"
|
||||||
|
Text="{Binding Mark}" />
|
||||||
|
<TextBlock Classes="stepcaption mono"
|
||||||
|
Classes.done="{Binding IsDone}"
|
||||||
|
Classes.running="{Binding IsRunning}"
|
||||||
|
Classes.stopped="{Binding IsStopped}"
|
||||||
|
Text="{Binding Caption}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A refusal is the reason, in the palette's red, because it is the only place the reason will be after
|
||||||
|
the user navigates away from the screen that started the connection. It sits under the list rather
|
||||||
|
than replacing it: which row it stopped on is half the answer and the sentence is the other half.
|
||||||
|
-->
|
||||||
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
|
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
|
||||||
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
|
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
|
||||||
TextWrapping="Wrap" TextAlignment="Center"
|
TextWrapping="Wrap" TextAlignment="Center"
|
||||||
@@ -62,14 +173,23 @@
|
|||||||
handshake that finishes afterwards is adopted rather than dropped — see
|
handshake that finishes afterwards is adopted rather than dropped — see
|
||||||
MainWindowViewModel.CloseTabAsync. Two buttons rather than one with a converted label, because the
|
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.
|
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"
|
Beside each, the logs. The step list is this attempt and the log is every other one, which is the
|
||||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
question both a connection taking too long and a connection just refused actually raise — has this
|
||||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
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>
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -584,6 +584,56 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</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
|
◆ 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
|
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
||||||
|
|||||||
@@ -98,6 +98,18 @@
|
|||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<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,*">
|
<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
|
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.
|
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"
|
<TextBlock Grid.Column="0" Text="Hosts" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
|
||||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
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
|
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.
|
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"
|
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10"
|
||||||
FontFamily="{StaticResource MonoFont}"
|
FontFamily="{StaticResource MonoFont}"
|
||||||
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
|
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
|
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.
|
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
|
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.
|
HostsScreen.axaml.cs.
|
||||||
-->
|
-->
|
||||||
<ScrollViewer Grid.Row="3" x:Name="Scroll" HorizontalScrollBarVisibility="Disabled">
|
<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
|
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"
|
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
|
||||||
IsVisible="{Binding HasPins}">
|
IsVisible="{Binding HasPins}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="3">
|
<StackPanel Orientation="Horizontal" Spacing="3">
|
||||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
||||||
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
||||||
<TextBlock Classes="mono" Text="{Binding PinCount}" FontSize="10.5"
|
<TextBlock Classes="mono" Text="{Binding PinCount}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
Foreground="{StaticResource TextFaint}" />
|
||||||
|
|||||||
@@ -148,13 +148,17 @@
|
|||||||
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
CornerRadius="0,0,12,12" ClipToBounds="True">
|
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
<!--
|
||||||
<views:SessionHeader Grid.Row="0"
|
v5c-4: two rows rather than three. The 60-pixel host header that used to sit above this
|
||||||
OpenLabel="Open terminal"
|
screen is gone; the address and the "Open terminal" button it carried are in the
|
||||||
OpenCommand="{Binding OpenTerminalForFilesHostCommand}"
|
sidebar now — see SessionSidebar.axaml — and the pane keeps the height. Its third
|
||||||
EmptyText="{Binding Transfers.Status}" />
|
binding, the Transfers.Status line it printed while no host was open, is not moved
|
||||||
<views:TransfersScreen Grid.Row="1" DataContext="{Binding Transfers}" />
|
either: TransfersScreen draws that same string itself, both in its own empty state and
|
||||||
<views:SessionStatusBar Grid.Row="2" />
|
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>
|
</Grid>
|
||||||
<!--
|
<!--
|
||||||
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
|
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
|
||||||
@@ -268,14 +272,16 @@
|
|||||||
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
CornerRadius="0,0,12,12" ClipToBounds="True">
|
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
<!--
|
||||||
<views:SessionHeader Grid.Row="0"
|
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
|
||||||
OpenLabel="Open SFTP"
|
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
|
||||||
OpenCommand="{Binding SelectFilesHostCommand}"
|
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
|
||||||
OpenCommandParameter="{Binding SelectedTab}"
|
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
||||||
EmptyText="no terminals open · press + or Ctrl+K, or choose a host and press Connect" />
|
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
|
The other thing that can be in the terminal's rectangle: a tab whose session does not
|
||||||
@@ -300,7 +306,7 @@
|
|||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
|
<views:SessionStatusBar Grid.Row="1" ShowsEncoding="True" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<!--
|
<!--
|
||||||
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
|
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>
|
/// <summary>
|
||||||
/// Asks the Linux backend for the one mode it can actually draw inside this window.
|
/// Asks the Linux backend for the one mode it can actually draw inside this window.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -268,6 +283,7 @@ internal sealed partial class MainWindow : Window
|
|||||||
if (shell is { } previous)
|
if (shell is { } previous)
|
||||||
{
|
{
|
||||||
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
|
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
|
||||||
|
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +302,7 @@ internal sealed partial class MainWindow : Window
|
|||||||
wasUnlocked = viewModel.IsUnlocked;
|
wasUnlocked = viewModel.IsUnlocked;
|
||||||
|
|
||||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||||
|
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||||
viewModel.PropertyChanged += OnShellPropertyChanged;
|
viewModel.PropertyChanged += OnShellPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +315,15 @@ internal sealed partial class MainWindow : Window
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
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>
|
/// <remarks>
|
||||||
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
|
/// 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
|
/// 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-
|
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 —
|
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
|
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
|
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the one deviation this rail still
|
||||||
purpose: Pins, which the mock has no screen for at all, and the S3 segment above.
|
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
|
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
|
the selection themselves, so a click would move the highlight before the shell decided anything, and a
|
||||||
@@ -128,19 +129,16 @@
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
KEPT — the mock has no screen for approved host keys at all; see the file-level remark. push_pin
|
◆ NO Pins ROW. The pins screen is still here and still reached in one click — from "Host keys"
|
||||||
is the same codepoint HostsScreen.axaml already draws for a host's own pin badge, reused rather
|
on the Keys screen's own header, which is where a list of approved host keys belongs: they are
|
||||||
than picked afresh so the one concept reads as one glyph everywhere it appears.
|
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}"
|
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
|
||||||
Command="{Binding ShowScreenCommand}"
|
Command="{Binding ShowScreenCommand}"
|
||||||
@@ -191,17 +189,22 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<FlyoutBase.AttachedFlyout>
|
<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
|
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
|
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.
|
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}"
|
Foreground="{StaticResource TextGhost}"
|
||||||
Text="{Binding Email}"
|
Text="{Binding Email}" TextTrimming="CharacterEllipsis"
|
||||||
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -246,7 +249,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Button>
|
</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 —
|
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">
|
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
Foreground="{StaticResource Text}" />
|
Foreground="{StaticResource TextGhost}" />
|
||||||
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
|
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE SAME TREATMENT AS SETTINGS ABOVE AND LOGOUT BELOW, which these two did not have: their
|
||||||
|
label was TextGhost where the other two rows' was Text, so a menu of five equally live
|
||||||
|
destinations drew two of them in the colour this window uses for something switched off. The
|
||||||
|
icons stay one step quieter than the words — the idiom the nav rail's own rows already follow
|
||||||
|
— but "quieter than the word beside it" and "dimmed" are not the same statement.
|
||||||
|
-->
|
||||||
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
|
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
Foreground="{StaticResource TextGhost}" />
|
Foreground="{StaticResource TextGhost}" />
|
||||||
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource TextGhost}" />
|
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -277,11 +287,11 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
Foreground="{StaticResource TextGhost}" />
|
Foreground="{StaticResource TextGhost}" />
|
||||||
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource TextGhost}" />
|
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</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
|
The existing sign-out flow, with its own confirm card — see
|
||||||
@@ -291,7 +301,7 @@
|
|||||||
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
|
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
Foreground="{StaticResource Text}" />
|
Foreground="{StaticResource TextGhost}" />
|
||||||
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
|
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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
|
Every row here is a command the shell already exposes for exactly this purpose — see
|
||||||
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
|
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
|
||||||
so this control carries no logic of its own beyond the list it draws and the click it forwards.
|
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}"
|
<Panel>
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
|
||||||
<StackPanel Spacing="6" Margin="16,20">
|
|
||||||
|
|
||||||
<!-- ============ QUICK ACCESS ============ -->
|
<!-- ============ THE RAIL, WHEN THE COLUMN IS CLOSED ============ -->
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
<!--
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="QUICK ACCESS" FontSize="10" />
|
Painted and bordered like the open column so the closing reads as the same surface narrowing rather
|
||||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="10"
|
than as one piece of furniture being swapped for another.
|
||||||
Foreground="{StaticResource TextGhost}"
|
-->
|
||||||
Text="{Binding SelectedTab.Label}" TextTrimming="CharacterEllipsis" MaxWidth="130" />
|
<Border Width="34" Background="{StaticResource Sidebar}"
|
||||||
</Grid>
|
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}">
|
<!-- ============ THE COLUMN ============ -->
|
||||||
<ItemsControl.ItemTemplate>
|
<Border Width="300" Background="{StaticResource Sidebar}"
|
||||||
<DataTemplate x:DataType="x:String">
|
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
||||||
<!--
|
IsVisible="{Binding IsSessionSidebarOpen}">
|
||||||
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up
|
<StackPanel Spacing="6" Margin="16,20">
|
||||||
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>
|
|
||||||
|
|
||||||
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
|
<!-- ============ THE SESSION ============ -->
|
||||||
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
|
<!--
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
The address, and the button that closes the column. Both on one row, and the address is the
|
||||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
trimming one: a long account@host:port is exactly the string that would otherwise push the
|
||||||
VerticalAlignment="Center" />
|
close button off the edge of a panel whose whole point is that it can be got rid of.
|
||||||
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
|
-->
|
||||||
</StackPanel>
|
<Grid ColumnDefinitions="*,Auto" Margin="8,0,0,0">
|
||||||
</Button>
|
<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>
|
<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"
|
<Button Classes="sidebarrow"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
|
||||||
CommandParameter="{Binding}"
|
CommandParameter="{Binding}"
|
||||||
ToolTip.Tip="{Binding Snippet.Command}">
|
ToolTip.Tip="{Binding}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||||
FontSize="11" Foreground="{StaticResource AccentText}"
|
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
|
||||||
VerticalAlignment="Center" />
|
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
|
||||||
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
|
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -90,19 +120,54 @@
|
|||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}"
|
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
|
||||||
ToolTip.Tip="Opens the snippet editor.">
|
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
|
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</StackPanel>
|
<!-- ============ SNIPS (the terminal surface only) ============ -->
|
||||||
|
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
|
||||||
|
|
||||||
</StackPanel>
|
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
|
||||||
</ScrollViewer>
|
|
||||||
</Border>
|
<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>
|
</UserControl>
|
||||||
|
|||||||
@@ -60,12 +60,23 @@
|
|||||||
ToolTip.Tip="{Binding Address}">
|
ToolTip.Tip="{Binding Address}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
|
<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
|
Three states now, where there were two. Green while the shell behind this tab is running
|
||||||
running, grey while it is connecting and once it has ended. The design's third, amber,
|
and grey once it has ended, as the strip's dots always were — and amber while it is
|
||||||
state has no meaning here — nothing in this application checks whether a host is merely
|
connecting, which used to be grey as well.
|
||||||
reachable — so it is not drawn; see design-notes/v5b-fidelity-notes.md.
|
|
||||||
|
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}"
|
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
|
||||||
|
Classes.connecting="{Binding IsConnecting}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Text="{Binding Label}" 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 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.
|
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
|
A separate control rather than a variant of TitleBar itself, on the same reasoning SessionStatusBar and
|
||||||
SessionStatusBar are their own files: nothing here can be measured by a test that hosts the real window,
|
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
|
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
|
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
|
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"
|
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
|
||||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
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}"
|
Background="{StaticResource KbdChip}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
|
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
|
||||||
|
|||||||
@@ -177,16 +177,27 @@
|
|||||||
thing (go to the parent directory), and the pair reading differently is the design's own choice
|
thing (go to the parent directory), and the pair reading differently is the design's own choice
|
||||||
faithfully carried over rather than a functional difference invented to justify it.
|
faithfully carried over rather than a functional difference invented to justify it.
|
||||||
-->
|
-->
|
||||||
|
<!--
|
||||||
|
◆ THE PATH SITS IN THE STAR COLUMN, ALONE, AND THAT PLACEMENT IS LOAD-BEARING.
|
||||||
|
|
||||||
|
TextTrimming only acts when measure hands the block a finite width, and a horizontal StackPanel
|
||||||
|
never does — it measures every child at infinity, takes the full answer, and an Auto grid column
|
||||||
|
passes that on. With the path in a StackPanel beside the label, a directory deep enough — the
|
||||||
|
remote pane meets one on any real host, this pane on any machine whose profile path is long —
|
||||||
|
made the header wider than the pane and pushed the icon buttons past the window's own edge. The
|
||||||
|
layout suite caught it at the 472-pixel session budget, on the one machine whose home directory
|
||||||
|
was long enough to arm it. Star column: bounded width, working ellipsis, buttons that stay.
|
||||||
|
-->
|
||||||
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
<TextBlock Grid.Column="0" Classes="label" FontSize="10" Text="LOCAL"
|
||||||
<TextBlock Classes="label" FontSize="10" Text="LOCAL" VerticalAlignment="Center" />
|
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium"
|
<TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
|
||||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||||
Text="{Binding LocalPath}" TextTrimming="CharacterEllipsis"
|
HorizontalAlignment="Left"
|
||||||
ToolTip.Tip="{Binding LocalPath}" />
|
Text="{Binding LocalPath}" TextTrimming="CharacterEllipsis"
|
||||||
</StackPanel>
|
ToolTip.Tip="{Binding LocalPath}" />
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
|
||||||
<!--
|
<!--
|
||||||
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
|
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
|
||||||
directory. Without this the pane is stuck on whichever drive the user profile is on. Chips
|
directory. Without this the pane is stuck on whichever drive the user profile is on. Chips
|
||||||
@@ -332,19 +343,26 @@
|
|||||||
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
|
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
|
||||||
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
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.
|
||||||
|
-->
|
||||||
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"
|
||||||
|
Margin="0,0,12,0">
|
||||||
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
|
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
|
||||||
IsVisible="{Binding ShowsHostPicker}" />
|
IsVisible="{Binding ShowsHostPicker}" />
|
||||||
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
|
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
|
||||||
IsVisible="{Binding ShowsBucketPicker}" />
|
IsVisible="{Binding ShowsBucketPicker}" />
|
||||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium"
|
|
||||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
|
||||||
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
|
|
||||||
ToolTip.Tip="{Binding RemotePath}" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
|
<TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
|
||||||
|
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTip.Tip="{Binding RemotePath}" />
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
|
||||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||||
@@ -364,8 +382,9 @@
|
|||||||
<!--
|
<!--
|
||||||
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
|
◆ 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
|
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: the session shell
|
||||||
prints the very same address above this whole screen — see MainWindowViewModel.SessionAddress,
|
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
|
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
|
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
|
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ namespace DodoSSH.Client.Session;
|
|||||||
/// <param name="AutomaticUpdateChecks">
|
/// <param name="AutomaticUpdateChecks">
|
||||||
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
|
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
|
||||||
/// </param>
|
/// </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(
|
public sealed record ClientSettings(
|
||||||
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
|
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.
|
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.
|
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>
|
/// <summary>Brings a value inside the range this type will store.</summary>
|
||||||
public static int ClampTerminalFontSize(int pixels) =>
|
public static int ClampTerminalFontSize(int pixels) =>
|
||||||
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
|
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
|
||||||
|
|||||||
@@ -328,6 +328,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private readonly Func<string, Task>? copyToClipboard;
|
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>
|
/// <remarks>
|
||||||
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
|
/// 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
|
/// 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
|
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
|
||||||
/// the Android head's distribution separately, so it must never acquire one by accident.
|
/// the Android head's distribution separately, so it must never acquire one by accident.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="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(
|
internal MainWindowViewModel(
|
||||||
ClientPaths paths,
|
ClientPaths paths,
|
||||||
ClientCacheFactory caches,
|
ClientCacheFactory caches,
|
||||||
@@ -483,8 +499,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
ResumeHandler? resume = null,
|
ResumeHandler? resume = null,
|
||||||
Func<string, Task>? copyToClipboard = null,
|
Func<string, Task>? copyToClipboard = null,
|
||||||
string? deviceName = 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.paths = paths;
|
||||||
this.caches = caches;
|
this.caches = caches;
|
||||||
this.workspace = workspace;
|
this.workspace = workspace;
|
||||||
@@ -525,21 +544,36 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||||
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
|
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
|
||||||
|
this.workspace.RendererReattached += OnRendererReattached;
|
||||||
|
|
||||||
settings = new ClientSettingsStore(paths);
|
settings = new ClientSettingsStore(paths);
|
||||||
|
|
||||||
updateScreen = CreateUpdateScreen(updates);
|
updateScreen = CreateUpdateScreen(updates);
|
||||||
|
|
||||||
// Read straight away rather than at first use, so the value is right before anything can read it —
|
ApplyStoredPreferences();
|
||||||
// a phone draws its terminal buttons from this, and a size that arrived a moment later would show
|
|
||||||
// as the interface correcting itself.
|
|
||||||
TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
|
|
||||||
|
|
||||||
_ = TellRendererTheFontSizeAsync();
|
_ = TellRendererTheFontSizeAsync();
|
||||||
|
|
||||||
StartSessionShellTracking();
|
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>
|
/// <summary>
|
||||||
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
|
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -635,6 +669,31 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
|
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
|
||||||
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
|
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
|
||||||
|
/// plane's socket-accept thread, and both properties it reads here — <see cref="TerminalFontSize"/> and
|
||||||
|
/// <see cref="SelectedTab"/> — are bound to by the interface.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="TerminalWorkspace.RendererReattached"/> fires once the workspace has replayed what it
|
||||||
|
/// owns — the live sessions. Font size and the choice of active tab are not the workspace's to know;
|
||||||
|
/// they live here, so this is the other half of putting a reattached page back the way it was. The size
|
||||||
|
/// is sent exactly as <see cref="TellRendererTheFontSizeAsync"/> sends it at startup, because nothing
|
||||||
|
/// has changed — the page has merely forgotten, and this is only a reminder.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnRendererReattached(object? sender, EventArgs e) =>
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
_ = workspace.SetFontSizeAsync(TerminalFontSize, CancellationToken.None).AsTask();
|
||||||
|
|
||||||
|
if (SelectedTab is { } tab)
|
||||||
|
{
|
||||||
|
_ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ShellState state = ShellState.Starting;
|
private ShellState state = ShellState.Starting;
|
||||||
|
|
||||||
@@ -958,6 +1017,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal event EventHandler? TerminalSessionOpened;
|
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 IsStarting => State == ShellState.Starting;
|
||||||
|
|
||||||
internal bool IsNeedingServer => State == ShellState.NeedsServer;
|
internal bool IsNeedingServer => State == ShellState.NeedsServer;
|
||||||
@@ -1910,9 +1983,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed first, so the workspace's SessionEnded — which fires as the pump unwinds — finds no tab to
|
// Removed first, so the workspace's SessionEnded — announced once the close below has fully drained
|
||||||
// mark dead and does nothing. The alternative ordering leaves a window in which a tab that is on its
|
// — finds no tab to mark dead and does nothing here. The alternative ordering leaves a window in
|
||||||
// way out is repainted as disconnected.
|
// which a tab that is on its way out is repainted as disconnected.
|
||||||
var index = Tabs.IndexOf(tab);
|
var index = Tabs.IndexOf(tab);
|
||||||
Tabs.Remove(tab);
|
Tabs.Remove(tab);
|
||||||
|
|
||||||
@@ -3051,6 +3124,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||||
|
workspace.RendererReattached -= OnRendererReattached;
|
||||||
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
transfers.PropertyChanged -= OnTransfersPropertyChanged;
|
||||||
|
|
||||||
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
|
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
|
||||||
@@ -3216,7 +3290,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
oldValue.PropertyChanged -= OnVaultPropertyChanged;
|
oldValue.PropertyChanged -= OnVaultPropertyChanged;
|
||||||
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
|
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
|
// 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
|
// 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
|
// has an answer coming, and the tab standing in for it is still in the strip afterwards, because
|
||||||
@@ -3230,6 +3304,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (attempts.Count == 0)
|
if (attempts.Count == 0)
|
||||||
{
|
{
|
||||||
oldValue.ConnectionStarting -= OnVaultConnectionStarting;
|
oldValue.ConnectionStarting -= OnVaultConnectionStarting;
|
||||||
|
oldValue.ConnectionProgress -= OnVaultConnectionProgress;
|
||||||
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
|
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
|
||||||
oldValue.SessionOpened -= OnVaultSessionOpened;
|
oldValue.SessionOpened -= OnVaultSessionOpened;
|
||||||
}
|
}
|
||||||
@@ -3238,6 +3313,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (newValue is not null)
|
if (newValue is not null)
|
||||||
{
|
{
|
||||||
newValue.ConnectionStarting += OnVaultConnectionStarting;
|
newValue.ConnectionStarting += OnVaultConnectionStarting;
|
||||||
|
newValue.ConnectionProgress += OnVaultConnectionProgress;
|
||||||
newValue.ConnectionFailed += OnVaultConnectionFailed;
|
newValue.ConnectionFailed += OnVaultConnectionFailed;
|
||||||
newValue.SessionOpened += OnVaultSessionOpened;
|
newValue.SessionOpened += OnVaultSessionOpened;
|
||||||
newValue.PropertyChanged += OnVaultPropertyChanged;
|
newValue.PropertyChanged += OnVaultPropertyChanged;
|
||||||
@@ -3379,6 +3455,41 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
AdoptTab(tab);
|
AdoptTab(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves a connecting tab's step list on, from the handshake's own report.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The one place the phases raised by <c>VaultViewModel.ConnectionProgress</c> are marshalled, and the
|
||||||
|
/// reason that event does not marshal for itself: doing it here means it happens once, visibly, at the
|
||||||
|
/// only boundary that cares — everything this touches is a view model an Avalonia binding is attached to.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Posted unconditionally rather than applied inline when it looks safe. Some phases really do arrive
|
||||||
|
/// on this thread — the first is reported before the handshake has yielded at all — and a
|
||||||
|
/// <c>CheckAccess</c> fast path for them would buy one dispatcher turn on a card that is up for seconds,
|
||||||
|
/// at the price of the two orderings existing at once and only one of them being the one a test runs.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A step that arrives after the attempt has settled is harmless and needs no guard here:
|
||||||
|
/// <see cref="TerminalTabViewModel.Advance"/> ignores anything reported to a tab that is no longer
|
||||||
|
/// connecting, which is what a posted phase landing behind its own <see cref="OnVaultSessionOpened"/>
|
||||||
|
/// looks like.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A report for an attempt with no tab is dropped, exactly as the other two handlers drop one: the user
|
||||||
|
/// closed the connecting tab and there is nothing left to draw a step on. The handshake is not affected
|
||||||
|
/// and its session is still adopted if it opens.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnVaultConnectionProgress(object? sender, ConnectionProgressEventArgs e) => post(() =>
|
||||||
|
{
|
||||||
|
if (attempts.TryGetValue(e.AttemptId, out var tab))
|
||||||
|
{
|
||||||
|
tab.Advance(e.Step);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Redraws the vault menu after a synchronisation pass found a vault this account had not seen.
|
/// Redraws the vault menu after a synchronisation pass found a vault this account had not seen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3741,6 +3852,72 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
(IsTerminalSurface && SelectedTab is not null)
|
(IsTerminalSurface && SelectedTab is not null)
|
||||||
|| (IsTransfersShowing && Transfers.IsConnected);
|
|| (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>
|
/// <summary>
|
||||||
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
|
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3813,6 +3990,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (screen.CanInsert)
|
if (screen.CanInsert)
|
||||||
{
|
{
|
||||||
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3906,14 +4089,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// neither surface has one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// One property reading whichever surface is showing, rather than one binding per surface reading its own
|
/// 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
|
/// 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.
|
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string? SessionAddress => Surface switch
|
internal string? SessionAddress => Surface switch
|
||||||
@@ -4058,6 +4242,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(SessionIdentityLabel));
|
OnPropertyChanged(nameof(SessionIdentityLabel));
|
||||||
OnPropertyChanged(nameof(SessionIdentityText));
|
OnPropertyChanged(nameof(SessionIdentityText));
|
||||||
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
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>
|
/// <remarks>
|
||||||
|
|||||||
@@ -1,7 +1,129 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Shell.ViewModels;
|
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>
|
/// <summary>
|
||||||
/// How far along a tab's connection is.
|
/// How far along a tab's connection is.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -57,8 +179,23 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Label = label;
|
Label = label;
|
||||||
Address = address;
|
Address = address;
|
||||||
status = "connecting…";
|
|
||||||
isLive = false;
|
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>
|
/// <summary>A tab for a session that is already open.</summary>
|
||||||
@@ -72,6 +209,12 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
state = TerminalTabState.Open;
|
state = TerminalTabState.Open;
|
||||||
status = string.Empty;
|
status = string.Empty;
|
||||||
isLive = true;
|
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>
|
/// <summary>
|
||||||
@@ -128,6 +271,36 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string? IdentityLabel { get; set; }
|
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" />
|
/// <inheritdoc cref="TerminalTabState" />
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private TerminalTabState state;
|
private TerminalTabState state;
|
||||||
@@ -187,6 +360,54 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// <summary>Whether this tab is a connection that never happened.</summary>
|
/// <summary>Whether this tab is a connection that never happened.</summary>
|
||||||
internal bool IsFailed => State is TerminalTabState.Failed;
|
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>
|
/// <summary>Takes ownership of the session that has just opened for this tab.</summary>
|
||||||
internal void Opened(uint sessionId)
|
internal void Opened(uint sessionId)
|
||||||
{
|
{
|
||||||
@@ -194,6 +415,8 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
Status = string.Empty;
|
Status = string.Empty;
|
||||||
IsLive = true;
|
IsLive = true;
|
||||||
State = TerminalTabState.Open;
|
State = TerminalTabState.Open;
|
||||||
|
|
||||||
|
CompleteSteps();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -208,9 +431,33 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Status = reason;
|
Status = reason;
|
||||||
IsLive = false;
|
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;
|
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)
|
partial void OnStateChanged(TerminalTabState value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(HasSession));
|
OnPropertyChanged(nameof(HasSession));
|
||||||
|
|||||||
@@ -620,6 +620,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? connectedCipher;
|
private string? connectedCipher;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether there is a live SFTP connection this session would lose by dying — the phone's foreground-
|
||||||
|
/// service question, not the desktop's.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="ConnectedCipher"/> is already the fact that tells a host apart from a bucket, because only
|
||||||
|
/// a host set it — a bucket is HTTP, per-request, and closes nothing a dying process would have kept
|
||||||
|
/// open, so it answers false here even while <see cref="IsConnected"/> is true. Android reads this to
|
||||||
|
/// decide whether an idle Files screen with no transfer moving still needs the process kept alive; the
|
||||||
|
/// desktop has no such question because nothing stops its process for having gone quiet.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool HasLiveFileSession => IsConnected && ConnectedCipher is not null;
|
||||||
|
|
||||||
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary>
|
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? connectedHostKeyAlgorithm;
|
private string? connectedHostKeyAlgorithm;
|
||||||
@@ -637,6 +650,31 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? connectedIdentityLabel;
|
private string? connectedIdentityLabel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The paths pinned on the connected host, for the phone's Files-screen chip row — the desktop draws
|
||||||
|
/// the same list in its QUICK ACCESS sidebar, over the terminal surface rather than this one. See
|
||||||
|
/// <see cref="VaultViewModel.EditorPinnedPaths"/> for where a pin is actually added or removed; this is
|
||||||
|
/// a read of what was already saved there.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Captured at connect, the same moment <see cref="ConnectedTo"/> is, rather than followed live off the
|
||||||
|
/// host row's own <c>PinnedPaths</c>. A pin edited while this session stays open shows up on the next
|
||||||
|
/// connect rather than mid-session — the same lag <see cref="ConnectedTo"/> itself already carries for
|
||||||
|
/// a relabel — because this screen reads the vault once, at the moment it dials, rather than staying
|
||||||
|
/// wired to a collection it otherwise never has a reason to watch. A bucket has no pins at all:
|
||||||
|
/// <see cref="OpenBucketAsync"/> leaves this empty rather than reading as "not yet known", which is what
|
||||||
|
/// empty already means for a host that connected with none pinned.
|
||||||
|
/// </remarks>
|
||||||
|
internal ObservableCollection<string> ConnectedPinnedPaths { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether the connected host or bucket has any pins to draw as chips.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A read of <see cref="ConnectedPinnedPaths"/> rather than an <c>[ObservableProperty]</c> of its own,
|
||||||
|
/// so it is raised by hand at each of the three places that collection is repopulated or cleared —
|
||||||
|
/// <see cref="MarkHostConnected"/>, <see cref="OpenBucketAsync"/> and <see cref="CloseSessionAsync"/>.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool HasConnectedPins => ConnectedPinnedPaths.Count > 0;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private HostKeyPresentation? pendingHostKey;
|
private HostKeyPresentation? pendingHostKey;
|
||||||
|
|
||||||
@@ -720,13 +758,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
|
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
|
||||||
|
|
||||||
/// <summary>Raised on the UI thread whenever a transfer appears or changes state.</summary>
|
/// <summary>
|
||||||
|
/// Raised on the UI thread whenever a transfer appears or changes state, or a host or bucket connects or
|
||||||
|
/// disconnects.
|
||||||
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// For a head that has to tell the operating system what this process is doing — Android's foreground
|
/// For a head that has to tell the operating system what this process is doing — Android's foreground
|
||||||
/// service, which must be up for as long as bytes are moving and down afterwards. An event rather than
|
/// service, which must be up for as long as bytes are moving, or a host session sits open, and down
|
||||||
/// letting that head watch <see cref="Transfers"/> itself: the collection announces rows arriving and
|
/// afterwards. An event rather than letting that head watch <see cref="Transfers"/> itself: the
|
||||||
/// leaving, and the transition that matters most is neither of those but a row going from RUNNING to
|
/// collection announces rows arriving and leaving, and the transition that matters most is neither of
|
||||||
/// DONE without moving.
|
/// those but a row going from RUNNING to DONE without moving. Connecting and disconnecting are the other
|
||||||
|
/// two transitions the service cares about — see <see cref="HasLiveFileSession"/> — and neither touches
|
||||||
|
/// <see cref="Transfers"/> at all, so they need this same announcement made by hand.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal event EventHandler? ActivityChanged;
|
internal event EventHandler? ActivityChanged;
|
||||||
|
|
||||||
@@ -794,12 +837,48 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
foreach (var root in LocalDirectory.Roots())
|
foreach (var root in LocalDirectory.Roots())
|
||||||
{
|
{
|
||||||
LocalRoots.Add(new CrumbViewModel(root.TrimEnd(Path.DirectorySeparatorChar), root));
|
LocalRoots.Add(new CrumbViewModel(RootChipName(root), root));
|
||||||
}
|
}
|
||||||
|
|
||||||
RefreshLocalCommand.Execute(null);
|
RefreshLocalCommand.Execute(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What a root's chip in the pane header says: <c>C:</c>, <c>/</c>, <c>~</c>, or a mount's name.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A name, never a path — the full path is the chip's <see cref="CrumbViewModel.Path"/> and its command
|
||||||
|
/// parameter, and it stays there. This used to be <c>root.TrimEnd(separator)</c>, which is a name only
|
||||||
|
/// for a Windows drive: on Unix it made the <c>/</c> chip an empty pill and the home chip the entire
|
||||||
|
/// home path, drawn at full width in a header column nothing bounds. A machine whose home directory sat
|
||||||
|
/// deep enough — CI's per-job HOME is forty-six characters — had that one chip push the header's own
|
||||||
|
/// buttons past the window's edge at the session shell's 472-pixel budget.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>~</c> for home is the one substitution rather than a shortening: every shell a user of this
|
||||||
|
/// application has ever typed into already means "my home directory" by it, which is exactly what the
|
||||||
|
/// chip does when pressed.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static string RootChipName(string root)
|
||||||
|
{
|
||||||
|
if (string.Equals(root, LocalDirectory.Home, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "~";
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = root.TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
if (trimmed.Length == 0)
|
||||||
|
{
|
||||||
|
// Unix's "/": trimming eats the whole string, and the root's name is the root itself.
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mount under /media or /run/media names itself by its last segment; a Windows drive ("C:") has
|
||||||
|
// no file-name segment at all, and the trimmed root is already the two-character name it always had.
|
||||||
|
var name = Path.GetFileName(trimmed);
|
||||||
|
return name.Length == 0 ? trimmed : name;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gives up the vault, keeping the connection and anything in flight.
|
/// Gives up the vault, keeping the connection and anything in flight.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -911,6 +990,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
ConnectedHostKeyAlgorithm = null;
|
ConnectedHostKeyAlgorithm = null;
|
||||||
ConnectedIdentityLabel = null;
|
ConnectedIdentityLabel = null;
|
||||||
|
|
||||||
|
// And no pins either — see ConnectedPinnedPaths's own remark.
|
||||||
|
ConnectedPinnedPaths.Clear();
|
||||||
|
OnPropertyChanged(nameof(HasConnectedPins));
|
||||||
|
|
||||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||||
|
|
||||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||||
@@ -997,10 +1080,26 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm;
|
ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm;
|
||||||
ConnectedIdentityLabel = identityLabel;
|
ConnectedIdentityLabel = identityLabel;
|
||||||
|
|
||||||
|
// See ConnectedPinnedPaths's own remark for why this is a snapshot rather than a live follow.
|
||||||
|
ConnectedPinnedPaths.Clear();
|
||||||
|
foreach (var path in row.Host.PinnedPaths)
|
||||||
|
{
|
||||||
|
ConnectedPinnedPaths.Add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(HasConnectedPins));
|
||||||
|
|
||||||
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as
|
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as
|
||||||
// far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with
|
// far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with
|
||||||
// the host's — and anybody comparing the two would be right to believe the host.
|
// the host's — and anybody comparing the two would be right to believe the host.
|
||||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||||
|
|
||||||
|
// Raised here rather than from OnIsConnectedChanged, on purpose: IsConnected is set first, above,
|
||||||
|
// and ConnectedCipher second — a partial method firing off the first assignment would read
|
||||||
|
// HasLiveFileSession against a ConnectedCipher still holding whatever the previous session left
|
||||||
|
// there. Only at the end of this method are both facts actually true together.
|
||||||
|
OnPropertyChanged(nameof(HasLiveFileSession));
|
||||||
|
ActivityChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1775,11 +1874,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
ConnectedCipher = null;
|
ConnectedCipher = null;
|
||||||
ConnectedHostKeyAlgorithm = null;
|
ConnectedHostKeyAlgorithm = null;
|
||||||
ConnectedIdentityLabel = null;
|
ConnectedIdentityLabel = null;
|
||||||
|
ConnectedPinnedPaths.Clear();
|
||||||
|
OnPropertyChanged(nameof(HasConnectedPins));
|
||||||
RemotePath = string.Empty;
|
RemotePath = string.Empty;
|
||||||
RemoteEntries.Clear();
|
RemoteEntries.Clear();
|
||||||
RemoteTrail.Clear();
|
RemoteTrail.Clear();
|
||||||
SelectedRemoteEntry = null;
|
SelectedRemoteEntry = null;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
OnPropertyChanged(nameof(HasLiveFileSession));
|
||||||
|
ActivityChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -74,16 +74,6 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
|
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
|
||||||
|
|
||||||
/// <summary>How long 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 IUpdateChannel updates;
|
||||||
private readonly ClientSettingsStore settings;
|
private readonly ClientSettingsStore settings;
|
||||||
private readonly TimeProvider clock;
|
private readonly TimeProvider clock;
|
||||||
@@ -242,16 +232,40 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
loop = RunCheckLoopAsync(lifetime.Token);
|
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)
|
private async Task RunCheckLoopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true);
|
await Task.Yield();
|
||||||
|
|
||||||
using var timer = new PeriodicTimer(CheckInterval, clock);
|
using var timer = new PeriodicTimer(CheckInterval, clock);
|
||||||
|
|
||||||
do
|
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);
|
await CheckOnceAsync(cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
while (await timer.WaitForNextTickAsync(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;
|
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>
|
/// <summary>A connection that was asked for and did not happen.</summary>
|
||||||
/// <param name="attemptId">The attempt that has just ended.</param>
|
/// <param name="attemptId">The attempt that has just ended.</param>
|
||||||
/// <param name="reason">What to say about it, in the tab.</param>
|
/// <param name="reason">What to say about it, in the tab.</param>
|
||||||
@@ -3086,6 +3111,37 @@ internal sealed partial class VaultViewModel(
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private AuthenticationChoice? editorSelectedAuthentication;
|
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>
|
/// <summary>What the group picker offers: "no group", then every group of the chosen vault.</summary>
|
||||||
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
||||||
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
|
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
|
||||||
@@ -3357,6 +3413,121 @@ internal sealed partial class VaultViewModel(
|
|||||||
OnPropertyChanged(nameof(HasTagChoices));
|
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>
|
/// <summary>
|
||||||
/// The paths pinned on the host being edited, in the order QUICK ACCESS draws them.
|
/// The paths pinned on the host being edited, in the order QUICK ACCESS draws them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3981,6 +4152,15 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal event EventHandler<ConnectionAttemptEventArgs>? ConnectionStarting;
|
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>
|
/// <summary>Raised when a connection this vault announced does not become a session.</summary>
|
||||||
/// <inheritdoc cref="ConnectionStarting" path="/remarks" />
|
/// <inheritdoc cref="ConnectionStarting" path="/remarks" />
|
||||||
internal event EventHandler<ConnectionFailedEventArgs>? ConnectionFailed;
|
internal event EventHandler<ConnectionFailedEventArgs>? ConnectionFailed;
|
||||||
@@ -7092,6 +7272,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
EditorPinnedPaths.Clear();
|
EditorPinnedPaths.Clear();
|
||||||
EditorNewPin = string.Empty;
|
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.
|
// Before the group picker, because a group belongs to one vault and the picker is that vault's.
|
||||||
BuildEditorVaultChoices(editingHostVaultId);
|
BuildEditorVaultChoices(editingHostVaultId);
|
||||||
|
|
||||||
@@ -7177,6 +7360,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
EditorNewTag = string.Empty;
|
EditorNewTag = string.Empty;
|
||||||
BuildTagChoices();
|
BuildTagChoices();
|
||||||
|
|
||||||
|
// As in NewHost, and for the password it can be holding.
|
||||||
|
ClearEditorCredentialForm();
|
||||||
|
|
||||||
LoadEditorPinnedPaths(row.Host.PinnedPaths);
|
LoadEditorPinnedPaths(row.Host.PinnedPaths);
|
||||||
|
|
||||||
BuildEditorVaultChoices(editingHostVaultId);
|
BuildEditorVaultChoices(editingHostVaultId);
|
||||||
@@ -8037,6 +8223,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
{
|
{
|
||||||
IsEditing = false;
|
IsEditing = false;
|
||||||
editingEntityId = null;
|
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;
|
Status = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8070,6 +8260,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
IsEditing = false;
|
IsEditing = false;
|
||||||
|
|
||||||
|
// As CancelEdit does, for the same password.
|
||||||
|
ClearEditorCredentialForm();
|
||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
|
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
|
||||||
@@ -10729,6 +10923,53 @@ internal sealed partial class VaultViewModel(
|
|||||||
return true;
|
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>
|
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Separate from <see cref="ConnectionTarget"/> because a keychain host has no username of its own at
|
/// 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)
|
catch (TimeoutException)
|
||||||
{
|
{
|
||||||
Abandon(
|
Abandon(attempt, RendererNeverStarted);
|
||||||
attempt,
|
|
||||||
"The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
|
|
||||||
+ "runtime is probably missing or blocked; install it and try again.");
|
|
||||||
}
|
}
|
||||||
catch (SshHostKeyUnknownException exception)
|
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>
|
/// <summary>Says, in one place, that an attempt ended without a session and why.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The reason goes to two places on purpose. The status line is where somebody watching this screen is
|
/// 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,
|
HostAuthentication authentication,
|
||||||
CancellationToken cancellationToken)
|
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);
|
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
var request = new SshConnectionRequest(
|
var request = new SshConnectionRequest(
|
||||||
@@ -11087,7 +11350,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
authentication.Credential);
|
authentication.Credential);
|
||||||
|
|
||||||
var sessionId = await workspace
|
var sessionId = await workspace
|
||||||
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
|
.OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||||
|
|||||||
@@ -78,6 +78,50 @@ body {
|
|||||||
height: 100%;
|
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 {
|
#status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
/*
|
/*
|
||||||
The renderer half of the terminal data plane.
|
The renderer half of the terminal data plane.
|
||||||
|
|
||||||
Two things here are load-bearing and easy to get wrong:
|
Three things here are load-bearing and easy to get wrong:
|
||||||
|
|
||||||
1. Output is acknowledged from term.write's completion callback, never on receipt. The
|
1. Output is acknowledged from term.write's completion callback, never on receipt. The
|
||||||
acknowledgement returns flow-control credit to the host, so acknowledging early would tell
|
acknowledgement returns flow-control credit to the host, so acknowledging early would tell
|
||||||
@@ -15,6 +15,14 @@
|
|||||||
partial sequences across writes. Decoding here would corrupt any multi-byte character that
|
partial sequences across writes. Decoding here would corrupt any multi-byte character that
|
||||||
happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
|
happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
|
||||||
conditions that are hardest to reproduce.
|
conditions that are hardest to reproduce.
|
||||||
|
|
||||||
|
3. The socket reconnects itself, forever, with backoff. This page's WebView is routinely killed
|
||||||
|
and reloaded by Android under memory pressure or simply for being backgrounded, so "the
|
||||||
|
socket closed" is an ordinary event here, not the end of the terminal's life — see connect().
|
||||||
|
A reloaded page starts with an empty session map, so createSession is idempotent (a session
|
||||||
|
that already has a pane is left alone) and a SESSION_OPENED frame carries a flag telling this
|
||||||
|
page whether it is a replay: nothing to do for a pane that is still here, and a short banner
|
||||||
|
for one that is not, because that pane's scrollback genuinely did not survive.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SERVER_OUTPUT = 1;
|
const SERVER_OUTPUT = 1;
|
||||||
@@ -33,6 +41,25 @@ const CLIENT_FONT_SIZE_STEP = 4;
|
|||||||
const HEADER_LENGTH = 5;
|
const HEADER_LENGTH = 5;
|
||||||
const SCROLLBACK_LINES = 5000;
|
const SCROLLBACK_LINES = 5000;
|
||||||
|
|
||||||
|
/*
|
||||||
|
How long to wait before trying the socket again, and how that wait grows. Starting quick matters
|
||||||
|
because the ordinary case is a page that just finished loading after its WebView came back — the
|
||||||
|
host's listener has been sitting there the whole time — and capping it matters because there is no
|
||||||
|
point spacing attempts further apart than a person notices. Forever rather than giving up, because
|
||||||
|
giving up would need a way to try again and there is none better than the one already here: the page
|
||||||
|
dies with the app.
|
||||||
|
*/
|
||||||
|
const RECONNECT_INITIAL_DELAY_MS = 1000;
|
||||||
|
const RECONNECT_MAX_DELAY_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
|
||||||
|
does not exist yet means the page reloaded and lost it, and createSession has no way to know which
|
||||||
|
of its callers that is.
|
||||||
|
*/
|
||||||
|
const REPLAY_BANNER = '\x1b[38;5;244m── the view reconnected; earlier output stayed on the host ──\x1b[0m\r\n';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The size panes are created at, until the host says otherwise — which it does as soon as it has read
|
The size panes are created at, until the host says otherwise — which it does as soon as it has read
|
||||||
the stored preference, usually before the first session exists. Kept here as well so a pane opened
|
the stored preference, usually before the first session exists. Kept here as well so a pane opened
|
||||||
@@ -57,14 +84,57 @@ const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
|
|||||||
const root = document.getElementById('root');
|
const root = document.getElementById('root');
|
||||||
const statusBanner = document.getElementById('status');
|
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();
|
const sessions = new Map();
|
||||||
|
|
||||||
/** @type {WebSocket | null} */
|
/** @type {WebSocket | null} */
|
||||||
let socket = null;
|
let socket = null;
|
||||||
|
|
||||||
function setStatus(text) {
|
/** Whose pane is showing, or null before there is one — see activate(). */
|
||||||
statusBanner.textContent = text ?? '';
|
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. */
|
/** Builds a frame: opcode, big-endian session id, then payload. */
|
||||||
@@ -192,7 +262,19 @@ function handleKey(event) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a pane for a session, or returns the one already there.
|
||||||
|
*
|
||||||
|
* Idempotent because a replay can land on a page that never lost its pane — the socket dropped and
|
||||||
|
* came back, but this page's own process survived — and asking for a session that already has a pane
|
||||||
|
* must not build a second one on top of it, orphaning the first one's WebGL context and scrollback.
|
||||||
|
*/
|
||||||
function createSession(sessionId) {
|
function createSession(sessionId) {
|
||||||
|
const existing = sessions.get(sessionId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
const pane = document.createElement('div');
|
const pane = document.createElement('div');
|
||||||
pane.className = 'pane';
|
pane.className = 'pane';
|
||||||
pane.dataset.sessionId = String(sessionId);
|
pane.dataset.sessionId = String(sessionId);
|
||||||
@@ -216,8 +298,31 @@ function createSession(sessionId) {
|
|||||||
// WebGL where it is available. Falling back rather than failing matters because a software
|
// 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
|
// renderer is slow but usable, whereas a blank pane is not — and remote desktops and VMs
|
||||||
// routinely have no usable GPU context.
|
// 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 {
|
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) {
|
} catch (error) {
|
||||||
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
|
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
|
||||||
}
|
}
|
||||||
@@ -230,7 +335,7 @@ function createSession(sessionId) {
|
|||||||
|
|
||||||
term.onResize(() => sendResize(sessionId, term, pane));
|
term.onResize(() => sendResize(sessionId, term, pane));
|
||||||
|
|
||||||
const session = { term, fit, pane };
|
const session = { term, fit, pane, notice: '' };
|
||||||
sessions.set(sessionId, session);
|
sessions.set(sessionId, session);
|
||||||
|
|
||||||
activate(sessionId);
|
activate(sessionId);
|
||||||
@@ -244,6 +349,11 @@ function activate(sessionId) {
|
|||||||
session.pane.dataset.active = String(id === 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);
|
const active = sessions.get(sessionId);
|
||||||
if (active) {
|
if (active) {
|
||||||
active.term.focus();
|
active.term.focus();
|
||||||
@@ -257,10 +367,17 @@ function activate(sessionId) {
|
|||||||
// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
|
// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
|
||||||
// once splits land.
|
// once splits land.
|
||||||
//
|
//
|
||||||
// It is *not* what protects the vault's lock screen, which an earlier version of this comment claimed.
|
// It is *not* what protects the vault's lock screen on the desktop, which an earlier version of this
|
||||||
// Collapsing the host's WebView hides a native child window without resizing it, so this page's viewport
|
// comment claimed. Collapsing WebView2 hides a native child window without resizing it, so this page's
|
||||||
// does not change, no observer fires and this function is never called — measured with a live shell, and
|
// viewport does not change, no observer fires and this function is never called — measured with a live
|
||||||
// confirmed by removing the guard and finding the lock cycle equally clean. See docs/platform-flags.md.
|
// 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;
|
const MINIMUM_FITTABLE_PIXELS = 40;
|
||||||
|
|
||||||
function resize(session, sessionId) {
|
function resize(session, sessionId) {
|
||||||
@@ -290,10 +407,26 @@ function handleFrame(buffer) {
|
|||||||
const payload = new Uint8Array(buffer, HEADER_LENGTH);
|
const payload = new Uint8Array(buffer, HEADER_LENGTH);
|
||||||
|
|
||||||
switch (opcode) {
|
switch (opcode) {
|
||||||
case SERVER_SESSION_OPENED:
|
case SERVER_SESSION_OPENED: {
|
||||||
createSession(sessionId);
|
// Checked before createSession, which would otherwise erase the answer by creating the pane
|
||||||
setStatus('');
|
// this check is asking about.
|
||||||
|
const hadPaneAlready = sessions.has(sessionId);
|
||||||
|
const session = createSession(sessionId);
|
||||||
|
|
||||||
|
// Byte 1 means the host is replaying a session that existed before this socket attached — see
|
||||||
|
// TerminalWorkspace.ReplayAfterAttachAsync. A replay landing on a pane that is still here has
|
||||||
|
// nothing left to do beyond the idempotent create above; one landing on a pane that is not means
|
||||||
|
// this page reloaded and that pane's scrollback went with it, which is worth a line saying so.
|
||||||
|
if (payload.length > 0 && payload[0] === 1 && !hadPaneAlready) {
|
||||||
|
session.term.write(REPLAY_BANNER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This session's own line, and only this one's: a session that is open has nothing to say about
|
||||||
|
// how it ended. The page's own "Connecting…" is cleared by the socket opening, which happens
|
||||||
|
// before any frame can arrive.
|
||||||
|
setSessionNotice(sessionId, '');
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case SERVER_OUTPUT: {
|
case SERVER_OUTPUT: {
|
||||||
const session = sessions.get(sessionId) ?? createSession(sessionId);
|
const session = sessions.get(sessionId) ?? createSession(sessionId);
|
||||||
@@ -344,7 +477,14 @@ function handleFrame(buffer) {
|
|||||||
session.pane.remove();
|
session.pane.remove();
|
||||||
sessions.delete(sessionId);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,8 +505,23 @@ function handleFrame(buffer) {
|
|||||||
is something only this page sees — and a shell that receives a multi-line command inside those
|
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
|
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.
|
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
|
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
|
||||||
@@ -378,6 +533,15 @@ function handleFrame(buffer) {
|
|||||||
session.term.input('\r');
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,14 +561,19 @@ function handleFrame(buffer) {
|
|||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
const reason = new TextDecoder().decode(payload);
|
const reason = new TextDecoder().decode(payload);
|
||||||
|
|
||||||
if (session) {
|
if (!session) {
|
||||||
// The pane and its scrollback stay. The user was probably reading the last thing the
|
// No pane, so there is nothing this page can honestly hang the reason on. It used to go into
|
||||||
// remote said, and that is usually why the session ended.
|
// the banner anyway, which printed one session's ending underneath whichever pane happened to
|
||||||
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
|
// be showing at the time.
|
||||||
session.term.options.cursorBlink = false;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +583,31 @@ function handleFrame(buffer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {number | null} */
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries the socket again after a wait, unless a try is already pending.
|
||||||
|
*
|
||||||
|
* The guard is what keeps 'close' and 'error' from stacking two timers for one failure — a socket
|
||||||
|
* that fails to open typically fires both, and each would otherwise schedule its own reconnect.
|
||||||
|
*/
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectTimer !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTransportStatus('Reconnecting the terminal view…');
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, reconnectDelay);
|
||||||
|
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
const token = root.dataset.token;
|
const token = root.dataset.token;
|
||||||
const url = root.dataset.socket;
|
const url = root.dataset.socket;
|
||||||
@@ -423,16 +617,22 @@ function connect() {
|
|||||||
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
||||||
socket.binaryType = 'arraybuffer';
|
socket.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
socket.addEventListener('open', () => setStatus(''));
|
socket.addEventListener('open', () => {
|
||||||
|
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));
|
socket.addEventListener('message', (event) => handleFrame(event.data));
|
||||||
|
|
||||||
socket.addEventListener('close', () => {
|
// Both close and error retry. They are not the same event on every failure — a socket that never
|
||||||
setStatus('Disconnected from DodoSSH.');
|
// 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('error', () => {
|
socket.addEventListener('close', scheduleReconnect);
|
||||||
setStatus('The terminal connection failed.');
|
socket.addEventListener('error', scheduleReconnect);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
|
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
|
||||||
|
|||||||
@@ -121,12 +121,53 @@ public interface ISshConnection : IAsyncDisposable
|
|||||||
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
|
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>
|
/// <summary>Opens connections, enforcing host key trust before authenticating.</summary>
|
||||||
public interface ISshConnectionFactory
|
public interface ISshConnectionFactory
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Connects and authenticates.
|
/// Connects and authenticates.
|
||||||
/// </summary>
|
/// </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">
|
/// <exception cref="SshHostKeyUnknownException">
|
||||||
/// The host has no pinned key. The caller must show the fingerprint, and only on explicit
|
/// 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.
|
/// confirmation record it via <see cref="IKnownHostStore.TrustAsync"/> and retry.
|
||||||
@@ -134,5 +175,8 @@ public interface ISshConnectionFactory
|
|||||||
/// <exception cref="SshHostKeyMismatchException">
|
/// <exception cref="SshHostKeyMismatchException">
|
||||||
/// The presented key differs from the pin. There is no retry path: this is a hard block.
|
/// The presented key differs from the pin. There is no retry path: this is a hard block.
|
||||||
/// </exception>
|
/// </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 />
|
/// <inheritdoc />
|
||||||
public async Task<ISshConnection> ConnectAsync(
|
public async Task<ISshConnection> ConnectAsync(
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
|
IProgress<SshConnectionPhase>? progress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
var client = new SshClient(BuildConnectionInfo(request));
|
var client = new SshClient(BuildConnectionInfo(request));
|
||||||
|
|
||||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return new SshNetConnection(client, gate.Presented!);
|
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 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);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
// 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(
|
private async Task<HostKeyGate> ConnectThroughHostKeyGateAsync(
|
||||||
BaseClient client,
|
BaseClient client,
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
|
IProgress<SshConnectionPhase>? progress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var gate = new HostKeyGate(knownHosts, request, cancellationToken);
|
var gate = new HostKeyGate(knownHosts, request, progress, cancellationToken);
|
||||||
|
|
||||||
client.HostKeyReceived += gate.OnHostKeyReceived;
|
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
|
try
|
||||||
{
|
{
|
||||||
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -139,6 +149,7 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
private sealed class HostKeyGate(
|
private sealed class HostKeyGate(
|
||||||
IKnownHostStore knownHosts,
|
IKnownHostStore knownHosts,
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
|
IProgress<SshConnectionPhase>? progress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
/// <summary>What the server offered, once the handshake has reached that point.</summary>
|
/// <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;
|
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
|
// 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.
|
// 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);
|
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
|
||||||
mismatch = !matches;
|
mismatch = !matches;
|
||||||
e.CanTrust = 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>
|
/// <summary>The specific exception for a refusal this gate caused, or null if it did not.</summary>
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
private WebSocket? socket;
|
private WebSocket? socket;
|
||||||
private int accepted;
|
|
||||||
private int disposed;
|
private int disposed;
|
||||||
|
|
||||||
/// <param name="assets">Where the renderer's files come from.</param>
|
/// <param name="assets">Where the renderer's files come from.</param>
|
||||||
@@ -106,6 +105,26 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised after a socket attaches — the first one, and every later takeover.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Raised after <see cref="socket"/> has been swapped in but before <see cref="ReceiveLoopAsync"/> starts
|
||||||
|
/// consuming it, on the socket-accept thread — the same thread that is in the middle of
|
||||||
|
/// <see cref="UpgradeAsync"/> for this connection. <see cref="TerminalWorkspace"/> is this event's one
|
||||||
|
/// subscriber, and it uses the ordering to replay session state before anything the fresh page sends
|
||||||
|
/// (a resize, an early acknowledgement) can be dispatched; see its remark for why the two racing is
|
||||||
|
/// harmless regardless.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Unlike <see cref="RendererAttached"/>, which resolves once and answers "has a renderer ever attached"
|
||||||
|
/// for <see cref="TerminalWorkspace.WaitForRendererAsync"/>, this fires every time — because a takeover
|
||||||
|
/// is exactly the case <see cref="RendererAttached"/> was never meant to describe again.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public event EventHandler? SocketAttached;
|
||||||
|
|
||||||
/// <summary>Registers a session so inbound frames can be routed to it.</summary>
|
/// <summary>Registers a session so inbound frames can be routed to it.</summary>
|
||||||
public void Register(uint sessionId, TerminalSessionPump pump)
|
public void Register(uint sessionId, TerminalSessionPump pump)
|
||||||
{
|
{
|
||||||
@@ -148,8 +167,9 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
// Each connection on its own task, and deliberately not awaited. An upgraded WebSocket
|
// Each connection on its own task, and deliberately not awaited. An upgraded WebSocket
|
||||||
// lives for the whole session, so handling connections in sequence would leave the accept
|
// lives for the whole session, so handling connections in sequence would leave the accept
|
||||||
// loop parked inside the receive loop and every later request unanswered — the page's
|
// loop parked inside the receive loop and every later request unanswered — the page's
|
||||||
// script and stylesheet among them. Concurrency needs no coordination here because the
|
// script and stylesheet among them. Two upgrades racing each other need no coordination
|
||||||
// single-attach guard is an interlocked exchange.
|
// here either, because the takeover in UpgradeAsync swaps the shared socket field with an
|
||||||
|
// interlocked exchange rather than assuming it is the only writer.
|
||||||
_ = HandleConnectionAsync(client, linked.Token);
|
_ = HandleConnectionAsync(client, linked.Token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,6 +212,29 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
|
.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Not a cancellation despite the type: .NET's ManagedWebSocket wraps a send that fails because
|
||||||
|
// the underlying connection is already gone — which is exactly what a killed renderer's socket
|
||||||
|
// looks like — in an OperationCanceledException of its own manufacture, regardless of whether
|
||||||
|
// anyone actually cancelled anything. The filter is what tells the two apart: if the caller's
|
||||||
|
// own token were the cause, IsCancellationRequested would be true here and this catch does not
|
||||||
|
// apply, so a real cancellation still propagates. Everything below about why this must not
|
||||||
|
// fault the caller applies here exactly as it does to the exception types in the next catch.
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
when (exception is WebSocketException or ObjectDisposedException
|
||||||
|
or InvalidOperationException or IOException)
|
||||||
|
{
|
||||||
|
// The state check above is not atomic with the send, and a WebView renderer process killed by
|
||||||
|
// Android leaves its socket reporting Open long after nobody is reading from the other end. This
|
||||||
|
// has to read as "nobody listening" — the same as the no-socket case above — and never as a
|
||||||
|
// fault: SendAsync is called from TerminalSessionPump.SendOutputAsync inside the flush loop, and
|
||||||
|
// letting this exception escape would fault that loop. A faulted flush loop stops draining the
|
||||||
|
// credit window, the reader blocks once it fills, and the SSH session behind it freezes for good
|
||||||
|
// while LiveSessionCount still counts it as running. A dropped frame is recoverable — a frozen
|
||||||
|
// session is not.
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
sendGate.Release();
|
sendGate.Release();
|
||||||
@@ -267,6 +310,25 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Takeover, not rejection.</b> A valid upgrade always wins the socket, even when one is already
|
||||||
|
/// attached — the old socket is aborted and the newcomer takes its place. Refusing a second attach used
|
||||||
|
/// to be the rule, on the theory that one renderer lives for the whole process. That is WebView2's
|
||||||
|
/// truth and not Android's: the platform kills the WebView's renderer process under memory pressure or
|
||||||
|
/// simply for being backgrounded, the page reloads, and the reload's socket is a second valid upgrade —
|
||||||
|
/// refusing it left the terminal permanently unreachable with no way back short of restarting the app.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Refusing protects nothing here anyway: only our own page knows the token (see the type-level remark
|
||||||
|
/// on what the token defends against), so a second valid upgrade <em>is</em> our page, reattaching.
|
||||||
|
/// Waiting for the old socket to notice it is dead and close on its own is not a safer alternative
|
||||||
|
/// either — a killed renderer process sends no TCP FIN, so the old receive loop can sit unaware for the
|
||||||
|
/// whole 30-second keepalive interval, and every reload landing in that window would still find the
|
||||||
|
/// door held shut by a socket nobody is on the other end of. Taking over immediately is what makes a
|
||||||
|
/// reload actually reattach.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
private async Task UpgradeAsync(
|
private async Task UpgradeAsync(
|
||||||
Stream stream,
|
Stream stream,
|
||||||
HttpRequestLine request,
|
HttpRequestLine request,
|
||||||
@@ -280,16 +342,6 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Interlocked.Exchange(ref accepted, 1) == 1)
|
|
||||||
{
|
|
||||||
// One renderer, one socket. A second attach would be either a bug or something else on the
|
|
||||||
// machine having found the port.
|
|
||||||
await WriteResponseAsync(
|
|
||||||
stream, "409 Conflict", "text/plain", "Already attached"u8.ToArray(), cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var key = request.Headers.GetValueOrDefault("sec-websocket-key")!;
|
var key = request.Headers.GetValueOrDefault("sec-websocket-key")!;
|
||||||
var accept = ComputeHandshakeAccept(key);
|
var accept = ComputeHandshakeAccept(key);
|
||||||
|
|
||||||
@@ -314,10 +366,28 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
||||||
});
|
});
|
||||||
|
|
||||||
socket = webSocket;
|
// Whatever was attached before is displaced, not merely overwritten: Exchange hands back the old
|
||||||
rendererAttached.TrySetResult();
|
// reference so it can be aborted rather than left to linger as a socket nothing reads from again.
|
||||||
|
// Abort rather than a graceful close — a close frame would wait on a peer that, per the remark
|
||||||
|
// above, may never notice it should reply, and the newcomer already proved it is our page.
|
||||||
|
var previous = Interlocked.Exchange(ref socket, webSocket);
|
||||||
|
previous?.Abort();
|
||||||
|
|
||||||
await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false);
|
rendererAttached.TrySetResult();
|
||||||
|
SocketAttached?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Cleared only if the field still holds this connection's own socket. A takeover has already
|
||||||
|
// swapped in a newer one by the time an aborted receive loop unwinds to here, and clearing the
|
||||||
|
// field regardless would race the newcomer: whichever of the two finished last would win, and
|
||||||
|
// it must always be the newcomer, never this one going away.
|
||||||
|
Interlocked.CompareExchange(ref socket, null, webSocket);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -101,6 +101,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
private readonly Lock sessionGate = new();
|
private readonly Lock sessionGate = new();
|
||||||
private readonly CancellationTokenSource lifetime = new();
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The payload that marks a <see cref="TerminalServerOpcode.SessionOpened"/> frame as a replay rather
|
||||||
|
/// than a fresh open. A one-byte non-empty payload, so terminal.js's existing length check (empty
|
||||||
|
/// payload for a real open) tells the two apart without a second opcode.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly byte[] ReplayMarker = [1];
|
||||||
|
|
||||||
private uint nextSessionId = 1;
|
private uint nextSessionId = 1;
|
||||||
private Task? server;
|
private Task? server;
|
||||||
private int disposed;
|
private int disposed;
|
||||||
@@ -125,6 +132,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
// came from. Nothing here decides anything about the size: the shell owns it, because the shell is
|
// came from. Nothing here decides anything about the size: the shell owns it, because the shell is
|
||||||
// what remembers it between launches.
|
// what remembers it between launches.
|
||||||
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
|
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
|
||||||
|
|
||||||
|
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
|
||||||
|
// handshake handling, and has no business making that wait on however long a replay takes. See
|
||||||
|
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
|
||||||
|
// is harmless.
|
||||||
|
dataPlane.SocketAttached += (_, _) => _ = ReplayAfterAttachAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -224,7 +237,28 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised with the session id when a shell ends on its own.
|
/// A live session's flow-control window, or null when the id names no session this workspace still has
|
||||||
|
/// open.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A test seam rather than something the shell has ever needed: nothing outside this assembly has a
|
||||||
|
/// reason to see a pump's credit window rather than what the transport does with it, but
|
||||||
|
/// <see cref="ReplayAfterAttachAsync"/>'s reset of that window on reattach is exactly the kind of thing
|
||||||
|
/// that is easy to get backwards, and worth asserting directly rather than only through its side
|
||||||
|
/// effects. Internal rather than public, reachable from the test assembly through the
|
||||||
|
/// <c>InternalsVisibleTo</c> this project already declares for it.
|
||||||
|
/// </remarks>
|
||||||
|
internal CreditWindow? CreditsFor(uint sessionId)
|
||||||
|
{
|
||||||
|
lock (sessionGate)
|
||||||
|
{
|
||||||
|
return sessions.TryGetValue(sessionId, out var session) ? session.Pump.Credits : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -235,13 +269,23 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
/// the half of the interface Avalonia draws.
|
/// the half of the interface Avalonia draws.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Raised on whatever thread the pump finished on</b>, which is a thread-pool thread. A handler that
|
/// <b>Raised only after the session's run task has completed, and that ordering is load-bearing.</b>
|
||||||
/// touches an observable collection has to marshal; this type has no toolkit to do it with, which is
|
/// It used to fire from inside the run's own finally block, where the task is by definition not yet
|
||||||
/// exactly why it does not try.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Not raised by <see cref="CloseSessionAsync"/>. That path already has a caller who knows the session is
|
/// <b>Raised by <see cref="CloseSessionAsync"/> too, which reverses a recorded decision.</b> The old
|
||||||
/// going, and telling it what it just asked for is how a tab close turns into a second tab close.
|
/// 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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;
|
public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;
|
||||||
@@ -254,6 +298,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The workspace's own share of "put the page back the way it was" is the sessions — each live one gets
|
||||||
|
/// its <c>SessionOpened</c> frame again, done by the time this fires. What is left is what the workspace
|
||||||
|
/// has no business owning: the font size and which tab is selected are both remembered by the shell, not
|
||||||
|
/// by a terminal, so this is the seam the shell uses to re-push them. See
|
||||||
|
/// <c>MainWindowViewModel</c>'s subscription for the other half.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Raised on the socket-accept thread, same as <see cref="TerminalDataPlane.SocketAttached"/> that
|
||||||
|
/// triggers it — a handler that touches a view model has to marshal.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public event EventHandler? RendererReattached;
|
||||||
|
|
||||||
/// <summary>Starts the loopback listener.</summary>
|
/// <summary>Starts the loopback listener.</summary>
|
||||||
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
|
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
|
||||||
|
|
||||||
@@ -307,13 +369,31 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
dataPlane.RendererAttached.WaitAsync(options.RendererTimeout, cancellationToken);
|
dataPlane.RendererAttached.WaitAsync(options.RendererTimeout, cancellationToken);
|
||||||
|
|
||||||
/// <summary>Connects to a host and starts a terminal for it.</summary>
|
/// <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>
|
/// <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(
|
public async Task<uint> OpenSessionAsync(
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
TerminalSize size,
|
TerminalSize size,
|
||||||
|
IProgress<SshConnectionPhase>? progress,
|
||||||
CancellationToken cancellationToken)
|
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;
|
ISshShellSession shell;
|
||||||
try
|
try
|
||||||
@@ -352,6 +432,10 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
sessions[sessionId] = new LiveSession(connection, pump, run);
|
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;
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +607,14 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
// Expected on the ordinary path: disposing the pump cancels its run.
|
// 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 />
|
/// <inheritdoc />
|
||||||
@@ -563,6 +655,71 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
lifetime.Dispose();
|
lifetime.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rebuilds a freshly (re)attached page's idea of what is running, then tells the shell to rebuild its
|
||||||
|
/// own.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Runs on the socket-accept thread that raised <see cref="TerminalDataPlane.SocketAttached"/> — the
|
||||||
|
/// constructor wires it up fire-and-forget for exactly that reason, so this method owns its own error
|
||||||
|
/// handling rather than leaving an unobserved exception for nobody to see.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Every live session — one whose <c>Run</c> has not completed — gets two things. Its credit window is
|
||||||
|
/// reset, because whatever was outstanding was reserved against bytes sent to a page that is now gone;
|
||||||
|
/// the acknowledgement that would return that credit died with it, and without this reset the session
|
||||||
|
/// would stall the moment 256 KiB of history had accumulated. And it gets its <c>SessionOpened</c> frame
|
||||||
|
/// again, marked with <see cref="ReplayMarker"/> so the page can tell a reattach from a session that is
|
||||||
|
/// genuinely new — the same frame a page that survived the socket drop already has a pane for, and one a
|
||||||
|
/// reloaded page does not.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A session whose shell has already ended gets nothing here. Its scrollback lived only in the page that
|
||||||
|
/// is gone, and sending a frame that implied otherwise would be exactly the kind of dishonesty this
|
||||||
|
/// fix is supposed to remove, not add. The tab strip still shows that session ended; nothing about this
|
||||||
|
/// method changes what <see cref="LiveSessionCount"/> or <see cref="IsSessionLive"/> report.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This can race the fresh page's own first frames — an early resize, an acknowledgement for output it
|
||||||
|
/// already had. That is harmless: every frame in both directions names its session, delivery order
|
||||||
|
/// within a session is preserved by both xterm and the socket, and a frame for a pane the page has not
|
||||||
|
/// created yet is simply dropped, the same as any frame for a session it does not know — see
|
||||||
|
/// <c>terminal.js</c>'s <c>handleFrame</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task ReplayAfterAttachAsync()
|
||||||
|
{
|
||||||
|
KeyValuePair<uint, LiveSession>[] live;
|
||||||
|
|
||||||
|
lock (sessionGate)
|
||||||
|
{
|
||||||
|
live = [.. sessions.Where(entry => !entry.Value.Run.IsCompleted)];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var (sessionId, session) in live)
|
||||||
|
{
|
||||||
|
session.Pump.Credits.Reset();
|
||||||
|
|
||||||
|
await dataPlane
|
||||||
|
.SendAsync(
|
||||||
|
TerminalFrame.Create((byte)TerminalServerOpcode.SessionOpened, sessionId, ReplayMarker),
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
RendererReattached?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// Best-effort, same as every other fire-and-forget path here: a page that dies again mid-replay
|
||||||
|
// leaves nothing worse than the problem this method exists to fix, and there is no caller on
|
||||||
|
// this thread left to hand a failure to.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump)
|
private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -578,21 +735,51 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
|
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
|
||||||
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
|
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
|
||||||
// the application is closing.
|
// the application is closing.
|
||||||
|
//
|
||||||
|
// 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());
|
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only when the session is still one this workspace knows about. CloseSessionAsync removes the
|
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
||||||
// entry before it disposes the pump, so a tab the user closed does not come back as news.
|
/// <remarks>
|
||||||
bool announce;
|
/// <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)
|
bool announce;
|
||||||
{
|
|
||||||
announce = sessions.ContainsKey(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (announce)
|
lock (sessionGate)
|
||||||
{
|
{
|
||||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
announce = sessions.ContainsKey(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (announce)
|
||||||
|
{
|
||||||
|
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
},
|
},
|
||||||
"Microsoft.NET.ILLink.Tasks": {
|
"Microsoft.NET.ILLink.Tasks": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[10.0.10, )",
|
"requested": "[10.0.11, )",
|
||||||
"resolved": "10.0.10",
|
"resolved": "10.0.11",
|
||||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||||
},
|
},
|
||||||
"MinVer": {
|
"MinVer": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
},
|
},
|
||||||
"Microsoft.NET.ILLink.Tasks": {
|
"Microsoft.NET.ILLink.Tasks": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[10.0.10, )",
|
"requested": "[10.0.11, )",
|
||||||
"resolved": "10.0.10",
|
"resolved": "10.0.11",
|
||||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||||
},
|
},
|
||||||
"MinVer": {
|
"MinVer": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
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 button shapes that centre their caption do, and the two that deliberately do not still fill.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ THE DEFECT THIS EXISTS FOR was read as a height problem and was not one. <c>App.axaml</c>'s
|
||||||
|
/// <c>Button.ghost, Button.accent, Button.danger</c> rule set <c>VerticalAlignment</c> — where the button
|
||||||
|
/// sits in its parent — and never <c>VerticalContentAlignment</c>, where the caption sits in the button.
|
||||||
|
/// Avalonia's default for the second is <c>Stretch</c>, so on any of these given a fixed <c>Height</c> the
|
||||||
|
/// content presenter stretched the caption's <see cref="TextBlock"/> to the whole content box, and a
|
||||||
|
/// <see cref="TextBlock"/> draws its line at the TOP of its bounds. The hosts toolbar's three 40-pixel
|
||||||
|
/// buttons measured 9 pixels above the ink and 20 below it. Every box was the height it declared, which is
|
||||||
|
/// exactly why it read as one being wrong: nothing was mis-sized, the labels sat in the top third.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A bare <see cref="Button"/> rather than a screen, because <c>Application.Styles</c> is global — the same
|
||||||
|
/// property <see cref="LayoutHarnessTests"/> leans on — so this measures the style rule itself rather than
|
||||||
|
/// one of the hundred-odd places it lands. A screen-level test would pin one toolbar and leave the dialogs,
|
||||||
|
/// the drawer's Save/Cancel pair and the import screen's buttons uncovered by the thing that fixed them all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ THE LINE BOX IS MEASURED, NOT THE TextBlock's ARRANGED BOUNDS, and the difference is the whole test.
|
||||||
|
/// A stretched caption's <see cref="Visual.Bounds"/> fill the content box, so they are symmetrical about
|
||||||
|
/// the button's middle under the defect just as they are under the fix — the first draft of this suite
|
||||||
|
/// asserted on them, passed on both, and was caught only by
|
||||||
|
/// <see cref="AStretchedCaption_IsNotCentred_AndIsCaught"/>. What actually moves is where the line sits
|
||||||
|
/// INSIDE those bounds: <see cref="TextBlock"/> draws at the top of whatever it is given, so the ink is
|
||||||
|
/// centred only when the box it is drawn in is its own line box.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The gaps are compared to each other rather than to a number. What the caption's own line box measures is
|
||||||
|
/// a property of JetBrains Mono at whichever size the caller set — 11.5 by default and 13.5 on the primary
|
||||||
|
/// 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
|
||||||
|
{
|
||||||
|
/// <summary>Taller than any caption these carry, which is the condition that exposes the defect.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The hosts and keychain toolbars' own number. A button left to size itself cannot show this at all:
|
||||||
|
/// its content box is its caption's line box exactly, so <c>Stretch</c> and <c>Center</c> agree and a
|
||||||
|
/// test written against one would pass under either.
|
||||||
|
/// </remarks>
|
||||||
|
private const double FixedHeight = 40;
|
||||||
|
|
||||||
|
/// <remarks>One pixel, for a content box whose odd leftover cannot be halved evenly.</remarks>
|
||||||
|
private const double Tolerance = 1;
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ghost")]
|
||||||
|
[InlineData("accent")]
|
||||||
|
[InlineData("danger")]
|
||||||
|
[InlineData("navuser")]
|
||||||
|
[InlineData("poprow")]
|
||||||
|
[InlineData("panechip")]
|
||||||
|
[InlineData("chiptoggle")]
|
||||||
|
[InlineData("choice")]
|
||||||
|
public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape)
|
||||||
|
{
|
||||||
|
await MeasureAsync(
|
||||||
|
shape,
|
||||||
|
alignment: null,
|
||||||
|
(above, below) =>
|
||||||
|
Math.Abs(above - below).ShouldBeLessThanOrEqualTo(
|
||||||
|
Tolerance,
|
||||||
|
string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"Button.{shape} left {above:0.#} above the caption and {below:0.#} below it.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The instrument's own calibration: the check fails on the arrangement the fix replaced.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same practice <see cref="LayoutHarnessTests"/> states for the clipping harness, and it earns its
|
||||||
|
/// place here for the same reason. Everything above passes if <c>VerticalContentAlignment</c> is Center
|
||||||
|
/// AND it passes if the caption happens to fill its box, so without this a rule quietly reverted to
|
||||||
|
/// Stretch would have to be caught by eye again. Stretch is set inline here rather than by editing the
|
||||||
|
/// style sheet, because <c>Application.Styles</c> is global and a test that mutated it would be changing
|
||||||
|
/// every other test in the assembly out from under itself.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AStretchedCaption_IsNotCentred_AndIsCaught()
|
||||||
|
{
|
||||||
|
await MeasureAsync(
|
||||||
|
"ghost",
|
||||||
|
VerticalAlignment.Stretch,
|
||||||
|
(above, below) => (below - above).ShouldBeGreaterThan(
|
||||||
|
Tolerance,
|
||||||
|
"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(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var button = new Button { Content = "Group ▾", Height = FixedHeight };
|
||||||
|
button.Classes.Add(shape);
|
||||||
|
|
||||||
|
if (alignment is { } forced)
|
||||||
|
{
|
||||||
|
button.VerticalContentAlignment = forced;
|
||||||
|
}
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var caption = button.GetVisualDescendants().OfType<TextBlock>().Single();
|
||||||
|
var origin = caption.TranslatePoint(default, button);
|
||||||
|
|
||||||
|
origin.ShouldNotBeNull("the caption is not in the button's visual tree");
|
||||||
|
|
||||||
|
// The laid-out line rather than caption.Bounds.Height, which under Stretch is the
|
||||||
|
// content box and so is symmetrical whether or not the ink in it is — see the remark on
|
||||||
|
// the class.
|
||||||
|
var line = caption.TextLayout.Height;
|
||||||
|
|
||||||
|
var above = origin.Value.Y;
|
||||||
|
var below = button.Bounds.Height - above - line;
|
||||||
|
|
||||||
|
assert(above, below);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
@@ -122,8 +122,14 @@ internal static class LayoutHarness
|
|||||||
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
|
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
|
||||||
internal const double SessionSidebarWidth = 300;
|
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>
|
/// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary>
|
||||||
internal const double SessionStatusBarHeight = 37;
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// the top and the bottom of that border, less <see cref="SessionStatusBarHeight"/> for the one fixed
|
||||||
/// <see cref="SessionStatusBarHeight"/> for the two fixed strips the pane sits between.
|
/// strip left below the pane — v5c-4 retired the header above it; see the note where its constant was.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static double SessionScreenHeight =>
|
internal static double SessionScreenHeight =>
|
||||||
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
|
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
|
||||||
- SessionHeaderHeight - SessionStatusBarHeight;
|
- SessionStatusBarHeight;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
|
/// 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;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Presenters;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Headless;
|
using Avalonia.Headless;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Media;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using DodoSSH.Client.App.Views;
|
using DodoSSH.Client.App.Views;
|
||||||
using DodoSSH.Client.Session;
|
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 ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
private Task OnTheRailAsync(Action<NavRail, Window> body) =>
|
private Task OnTheRailAsync(Action<NavRail, Window> body) =>
|
||||||
|
|||||||
@@ -189,6 +189,25 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
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>
|
/// <remarks>
|
||||||
/// The other editor, and it is in this control for the first time: the desktop's group editor used to be
|
/// 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
|
/// a bar across the foot of the hosts screen, where it competed with the grid for the same column. Its
|
||||||
@@ -1255,7 +1274,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
/// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// thing squeezing DISCONNECT off the edge at this width.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -1481,17 +1500,29 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own
|
/// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own
|
||||||
/// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote
|
/// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote
|
||||||
/// listing carrying every colour state a row can show (a directory, an executable, a world-writable
|
/// listing carrying every colour state a row can show (a directory, an executable, a world-writable
|
||||||
/// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either
|
/// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either
|
||||||
/// pane.
|
/// pane.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Both pane headers carry a deep path on purpose, and the paths are set here rather than left to the
|
||||||
|
/// fixture's real filesystem. The header's ellipsis only works because the path sits alone in a star
|
||||||
|
/// column — see the pane header remark in <c>TransfersScreen.axaml</c> — and the regression it guards
|
||||||
|
/// against armed itself only on a machine whose home directory happened to be long: CI's per-job HOME
|
||||||
|
/// found it, every developer machine's short profile path missed it. A pinned sixty-character path asks
|
||||||
|
/// the question on every machine alike.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget()
|
public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget()
|
||||||
{
|
{
|
||||||
transfers.IsConnected = true;
|
transfers.IsConnected = true;
|
||||||
transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
|
transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
|
||||||
|
transfers.LocalPath = "/home/deployment-service/.cache/build/workspaces/site/artefacts";
|
||||||
|
transfers.RemotePath = "/srv/releases/site/shared/uploads/production/2026/08/nightly";
|
||||||
|
|
||||||
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
|
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
|
||||||
"docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
|
"docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
|
||||||
@@ -1565,7 +1596,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// v5b's redraw changes what this test has to hold. Three button shapes live in the rail now rather
|
/// 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
|
/// 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
|
/// 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
|
/// for the segments or have to loosen until it caught nothing, so each shape gets its own count and its
|
||||||
@@ -1573,13 +1604,18 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The rail runs vertically, so what runs out at the window's minimum is still height — a switcher plus
|
/// 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
|
/// 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.
|
/// entry silently dropping off the bottom would still pass every other assertion here.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheNavRailHoldsItsSwitcherSixDestinationsAndTheUserChipAtTheWindowsMinimum()
|
public async Task TheNavRailHoldsItsSwitcherFiveDestinationsAndTheUserChipAtTheWindowsMinimum()
|
||||||
{
|
{
|
||||||
await LayoutHarness.OnTheUiThreadAsync(
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
() =>
|
() =>
|
||||||
@@ -1599,7 +1635,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
|
|
||||||
segments.Count.ShouldBe(3, "SSH, SFTP and S3");
|
segments.Count.ShouldBe(3, "SSH, SFTP and S3");
|
||||||
rows.Count.ShouldBe(
|
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)
|
foreach (var segment in segments)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,18 +40,32 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ISshConnection> ConnectAsync(
|
public async Task<ISshConnection> ConnectAsync(
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
|
IProgress<SshConnectionPhase>? progress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Requests.Add(request);
|
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)
|
if (Gate is { } gate)
|
||||||
{
|
{
|
||||||
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Failure is { } failure
|
if (Failure is { } failure)
|
||||||
? throw failure
|
{
|
||||||
: new FakeSshConnection(request);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
using DodoSSH.Client.Transfer;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Tests;
|
||||||
|
|
||||||
|
/// <summary>What the local pane's root chips are called.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A chip carries a name and never a path — the path is the chip's command parameter, where its length costs
|
||||||
|
/// nothing. The derivation used to be <c>TrimEnd(separator)</c>, which is a name only for a Windows drive:
|
||||||
|
/// on Unix it made the <c>/</c> chip an empty pill and the home chip the whole home path, and a home
|
||||||
|
/// directory deep enough pushed the pane header's own buttons out of the window — CI's per-job HOME is what
|
||||||
|
/// finally said so. These pin the derivation with fixed strings, so the question no longer depends on how
|
||||||
|
/// long a path any particular machine keeps its profile under.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class RootChipNameTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TheUnixRootIsASlash_NotAnEmptyPill() =>
|
||||||
|
TransfersViewModel.RootChipName("/").ShouldBe("/");
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The one substitution rather than a shortening: every shell already means "my home directory" by
|
||||||
|
/// <c>~</c>, and the machine's real home path — however deep — stays on the chip's command alone.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void TheHomeChipIsATilde_HoweverDeepHomeSits() =>
|
||||||
|
TransfersViewModel.RootChipName(LocalDirectory.Home).ShouldBe("~");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AMountNamesItselfByItsLastSegment() =>
|
||||||
|
TransfersViewModel.RootChipName("/media/usb0").ShouldBe("usb0");
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Windows only, because the input only exists there — <c>LocalDirectory.Roots()</c> produces drive
|
||||||
|
/// roots on no other platform, and <c>Path.GetFileName</c> reads <c>C:\</c> differently on Unix.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void AWindowsDriveKeepsItsTwoCharacterName()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransfersViewModel.RootChipName(@"C:\").ShouldBe("C:");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
|||||||
using DodoSSH.Client.Auth;
|
using DodoSSH.Client.Auth;
|
||||||
using DodoSSH.Client.Domain;
|
using DodoSSH.Client.Domain;
|
||||||
using DodoSSH.Client.Import;
|
using DodoSSH.Client.Import;
|
||||||
|
using DodoSSH.Client.ObjectStore;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
|
||||||
// see the csproj for why it is shared rather than reimplemented.
|
// see the csproj for why it is shared rather than reimplemented.
|
||||||
@@ -142,7 +143,14 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
clipboard.Add(text);
|
clipboard.Add(text);
|
||||||
return Task.CompletedTask;
|
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;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -1302,6 +1310,119 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.IsConnectingShowing.ShouldBeFalse();
|
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>
|
/// <remarks>
|
||||||
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
|
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
|
||||||
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
|
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
|
||||||
@@ -2340,6 +2461,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
||||||
TerminalSize.Default,
|
TerminalSize.Default,
|
||||||
|
progress: null,
|
||||||
Token);
|
Token);
|
||||||
|
|
||||||
workspace.LiveSessionCount.ShouldBe(1);
|
workspace.LiveSessionCount.ShouldBe(1);
|
||||||
@@ -2979,6 +3101,161 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.Hosts[0].Host.CredentialId.ShouldBeNull();
|
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>
|
/// <remarks>
|
||||||
/// Tags reach the same editor by a different route — the keychain screen rather than the box under the
|
/// 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.
|
/// chips — and a chip that only appeared on the next open would send the user round the same detour.
|
||||||
@@ -5845,6 +6122,60 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other refusal at the same door, and the one that needs two keychains to raise: a group is an
|
||||||
|
/// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault
|
||||||
|
/// seeing a machine filed under nothing. Checked when the picker is asked for and over the whole set —
|
||||||
|
/// see <see cref="VaultViewModel.RegroupChosenHosts"/> — rather than once per host mid-write, which is
|
||||||
|
/// why no panel opens at all and the status line's sentence has to carry the whole explanation.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task RegroupingHostsChosenAcrossTwoKeychains_IsRefusedBeforeThePickerOpens()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var vaults = shell.Vaults;
|
||||||
|
|
||||||
|
await vaults.LoadAsync(Token);
|
||||||
|
|
||||||
|
vaults.NewVaultCommand.Execute(null);
|
||||||
|
vaults.NewVaultName = "Platform secrets";
|
||||||
|
|
||||||
|
await vaults.CreateVaultCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
vault.NewHostCommand.Execute(null);
|
||||||
|
vault.EditorSelectedVault =
|
||||||
|
vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||||
|
vault.EditorLabel = "prod-web";
|
||||||
|
vault.EditorHostname = "web.internal";
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
|
||||||
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
||||||
|
|
||||||
|
vault.RegroupChosenHostsCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.IsRegroupingChosenHosts.ShouldBeFalse("a group belongs to one keychain");
|
||||||
|
vault.Status.ShouldStartWith("These hosts are in more than one keychain");
|
||||||
|
vault.IsChoosingHosts.ShouldBeTrue("the set was refused, not dissolved");
|
||||||
|
|
||||||
|
// Unticking the visitor is all it takes: the refusal is about the set, not a latch the screen has
|
||||||
|
// to be talked out of.
|
||||||
|
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
|
||||||
|
|
||||||
|
vault.RegroupChosenHostsCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into
|
/// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into
|
||||||
/// another vault: the copy stays in the same keychain, so everything it points at is still there.
|
/// another vault: the copy stays in the same keychain, so everything it points at is still there.
|
||||||
@@ -6167,12 +6498,68 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.IsTerminalShowing.ShouldBeTrue();
|
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>
|
/// <remarks>
|
||||||
/// The sidebar's SNIPS row, wired through <c>SnippetsViewModel.InsertCommand</c> rather than a second
|
/// 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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
|
public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
|
||||||
@@ -6184,9 +6571,38 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
var row = snippets.Visible.ShouldHaveSingleItem();
|
var row = snippets.Visible.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
var focusRequests = 0;
|
||||||
|
shell.TerminalFocusRequested += (_, _) => focusRequests++;
|
||||||
|
|
||||||
await shell.InsertSnippetCommand.ExecuteAsync(row);
|
await shell.InsertSnippetCommand.ExecuteAsync(row);
|
||||||
|
|
||||||
snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads");
|
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]
|
[Fact]
|
||||||
@@ -7902,6 +8318,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
||||||
TerminalSize.Default,
|
TerminalSize.Default,
|
||||||
|
progress: null,
|
||||||
Token);
|
Token);
|
||||||
|
|
||||||
shell.SignOutCommand.Execute(null);
|
shell.SignOutCommand.Execute(null);
|
||||||
@@ -8005,6 +8422,221 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]);
|
shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The phone's Files-screen chip row, proven at the view model rather than through Avalonia: a pin
|
||||||
|
/// saved on the host before this screen ever connects to it is read straight off the row's own
|
||||||
|
/// <c>HostSecret.PinnedPaths</c> at the moment <c>MarkHostConnected</c> runs, which is what
|
||||||
|
/// <see cref="TransfersViewModel.ConnectedPinnedPaths"/>'s own remark promises rather than a live follow
|
||||||
|
/// of the vault.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectingATransfersHostWithPins_PopulatesConnectedPinnedPaths()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
vault.EditorNewPin = "/var/www/app";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
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);
|
||||||
|
shell.Transfers.ConnectedPinnedPaths.ShouldBe(["/var/www/app"]);
|
||||||
|
shell.Transfers.HasConnectedPins.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other half of <see cref="ConnectingATransfersHostWithPins_PopulatesConnectedPinnedPaths"/>: the
|
||||||
|
/// chip row has to go with the connection it belongs to, or a later connect to a host with no pins would
|
||||||
|
/// show the previous host's.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task DisconnectingTheTransfersScreen_ClearsConnectedPinnedPaths()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
vault.EditorNewPin = "/var/www/app";
|
||||||
|
vault.AddEditorPinCommand.Execute(null);
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.Attach(vault, knownHosts);
|
||||||
|
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||||
|
|
||||||
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||||
|
shell.Transfers.HasConnectedPins.ShouldBeTrue();
|
||||||
|
|
||||||
|
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty();
|
||||||
|
shell.Transfers.HasConnectedPins.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The phone's foreground-service question, proven at the view model rather than through Android: a
|
||||||
|
/// connect that opens an SFTP session is exactly the transition <c>SessionKeepAlive</c> needs to hear
|
||||||
|
/// about even when no transfer ever moves — see <see cref="TransfersViewModel.ActivityChanged"/>'s own
|
||||||
|
/// remark for why the queue's own raise, in <c>OnTransferChanged</c>, cannot cover a connect that never
|
||||||
|
/// touches <c>Transfers</c> at all.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectingATransfersHost_RaisesActivityChangedAndTurnsOnHasLiveFileSession()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.Transfers.Attach(vault, knownHosts);
|
||||||
|
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||||
|
|
||||||
|
var raised = 0;
|
||||||
|
shell.Transfers.ActivityChanged += (_, _) => raised++;
|
||||||
|
|
||||||
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
||||||
|
shell.Transfers.HasLiveFileSession.ShouldBeTrue();
|
||||||
|
raised.ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other half: a disconnect is as much a transition the service must hear about as a connect is,
|
||||||
|
/// because it is the moment the connection <see cref="TransfersViewModel.HasLiveFileSession"/> promised
|
||||||
|
/// was open stops being true — and the foreground service would otherwise keep the process alive over a
|
||||||
|
/// session that has already closed.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task DisconnectingTheTransfersScreen_RaisesActivityChangedAndTurnsOffHasLiveFileSession()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.Transfers.Attach(vault, knownHosts);
|
||||||
|
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||||
|
|
||||||
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||||
|
shell.Transfers.HasLiveFileSession.ShouldBeTrue();
|
||||||
|
|
||||||
|
var raised = 0;
|
||||||
|
shell.Transfers.ActivityChanged += (_, _) => raised++;
|
||||||
|
|
||||||
|
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.HasLiveFileSession.ShouldBeFalse();
|
||||||
|
raised.ShouldBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// The bucket here is created through the same keychain route
|
||||||
|
/// <see cref="TheS3ScreenWithNoBuckets_SaysWhereOneIsMadeAndGoesThere"/> exercises, and
|
||||||
|
/// <see cref="FakeObjectStoreFactory"/> stands in for the network the way <see cref="FakeSshConnectionFactory"/>
|
||||||
|
/// already does for SFTP.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectingABucket_LeavesConnectedPinnedPathsEmpty()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());
|
||||||
|
|
||||||
|
vault.NewObjectStoreCommand.Execute(null);
|
||||||
|
vault.BucketEditorLabel = "Backups";
|
||||||
|
vault.BucketEditorBucket = "backups";
|
||||||
|
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
|
||||||
|
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
|
||||||
|
vault.BucketEditorRegion = "eu-west-1";
|
||||||
|
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
// Remote is what ConnectAsync branches on, and Attach's RefreshHosts has already auto-selected the
|
||||||
|
// host ReadyToConnectAsync left in the picker — without this line the command below dialled that
|
||||||
|
// host, and every assertion here passed only because that host happens to have no pins either. The
|
||||||
|
// ConnectedTo check is the proof the bucket path was actually taken.
|
||||||
|
shell.Transfers.Remote = RemoteKind.Bucket;
|
||||||
|
shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];
|
||||||
|
|
||||||
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.ConnectedTo.ShouldBe("s3://backups");
|
||||||
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
||||||
|
shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty();
|
||||||
|
shell.Transfers.HasConnectedPins.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A bucket is HTTP, per-request, with nothing open that a dying process would lose — see
|
||||||
|
/// <see cref="TransfersViewModel.HasLiveFileSession"/>'s own remark. <c>IsConnected</c> alone would have
|
||||||
|
/// answered this wrongly, which is exactly why the flag reads <c>ConnectedCipher</c> as well: nothing
|
||||||
|
/// underneath a bucket ever sets it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ConnectingABucket_LeavesHasLiveFileSessionOff()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());
|
||||||
|
|
||||||
|
vault.NewObjectStoreCommand.Execute(null);
|
||||||
|
vault.BucketEditorLabel = "Backups";
|
||||||
|
vault.BucketEditorBucket = "backups";
|
||||||
|
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
|
||||||
|
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
|
||||||
|
vault.BucketEditorRegion = "eu-west-1";
|
||||||
|
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
// ReadyToConnectAsync already left a host in the picker, and Attach's own RefreshHosts auto-selects
|
||||||
|
// it — so without this the CONNECT command below would dial that host rather than open the bucket,
|
||||||
|
// and a host with no pins would make ConnectedPinnedPathsEmpty-style assertions pass for the wrong
|
||||||
|
// reason. Remote is what ConnectAsync actually branches on.
|
||||||
|
shell.Transfers.Remote = RemoteKind.Bucket;
|
||||||
|
shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];
|
||||||
|
|
||||||
|
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.Transfers.ConnectedTo.ShouldBe("s3://backups", "proof this opened the bucket rather than the host");
|
||||||
|
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
|
||||||
|
shell.Transfers.HasLiveFileSession.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A bucket that opens and lists as empty, so a bucket connect can be proven with no network.</summary>
|
||||||
|
private sealed class FakeObjectStoreFactory : IObjectStoreFactory
|
||||||
|
{
|
||||||
|
public IRemoteFileStore Open(ObjectStoreSecret store) => new FakeBucketStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The minimum <see cref="IRemoteFileStore"/> a bucket connect touches: home, then a listing.</summary>
|
||||||
|
private sealed class FakeBucketStore : IRemoteFileStore
|
||||||
|
{
|
||||||
|
public bool IsConnected => true;
|
||||||
|
|
||||||
|
public string HomeDirectory => "/";
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult<IReadOnlyList<SftpEntry>>([]);
|
||||||
|
|
||||||
|
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult<SftpEntry?>(null);
|
||||||
|
|
||||||
|
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
||||||
|
|
||||||
|
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
||||||
|
|
||||||
|
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
||||||
|
|
||||||
|
public Task DeleteAsync(string path, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
||||||
|
|
||||||
|
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The picker that replaced the desktop's connect bar, and the four things that put it away again. It
|
/// The picker that replaced the desktop's connect bar, and the four things that put it away again. It
|
||||||
|
|||||||
@@ -403,6 +403,46 @@ public sealed class UpdateFlowTests : IDisposable
|
|||||||
channel.Checks.ShouldBe(1);
|
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]
|
[Fact]
|
||||||
public async Task DisposingStopsTheLoop()
|
public async Task DisposingStopsTheLoop()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
|||||||
var factory = new SshNetConnectionFactory(knownHosts);
|
var factory = new SshNetConnectionFactory(knownHosts);
|
||||||
|
|
||||||
await Should.ThrowAsync<SshAuthenticationException>(async () =>
|
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]
|
[Fact]
|
||||||
@@ -125,6 +126,86 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
|||||||
shell.IsOpen.ShouldBeTrue();
|
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[] Pkcs1(RSA key) => Encoding.UTF8.GetBytes(key.ExportRSAPrivateKeyPem());
|
||||||
|
|
||||||
private static byte[] Pkcs8(RSA key) => Encoding.UTF8.GetBytes(key.ExportPkcs8PrivateKeyPem());
|
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.
|
// Learned by being refused, which is the only way this client learns a host key.
|
||||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
await factory.ConnectAsync(
|
await factory.ConnectAsync(
|
||||||
Request(new SshPasswordCredential(SshServerFixture.Password)), Token));
|
Request(new SshPasswordCredential(SshServerFixture.Password)), progress: null, Token));
|
||||||
|
|
||||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||||
|
|
||||||
@@ -153,6 +234,6 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
|||||||
var knownHosts = await TrustedStoreAsync();
|
var knownHosts = await TrustedStoreAsync();
|
||||||
|
|
||||||
return await new SshNetConnectionFactory(knownHosts)
|
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") =>
|
string fingerprint = "SHA256:approved") =>
|
||||||
new(host, port, algorithm, fingerprint);
|
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 factory = new SshNetConnectionFactory(knownHosts);
|
||||||
|
|
||||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
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.Host.ShouldBe(InternalHost, "the target's name, not the proxy's");
|
||||||
unknown.Presentation.Port.ShouldBe(SshServerFixture.InternalPort);
|
unknown.Presentation.Port.ShouldBe(SshServerFixture.InternalPort);
|
||||||
@@ -75,7 +75,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
|||||||
|
|
||||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
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.IsConnected.ShouldBeTrue();
|
||||||
connection.HostKey.Host.ShouldBe(InternalHost);
|
connection.HostKey.Host.ShouldBe(InternalHost);
|
||||||
@@ -121,7 +121,8 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
|||||||
var request = Request(Credential(), new SshLoopbackProxy(DeadPort()));
|
var request = Request(Credential(), new SshLoopbackProxy(DeadPort()));
|
||||||
|
|
||||||
var failure = await Should.ThrowAsync<Exception>(async () =>
|
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<SshHostKeyUnknownException>();
|
||||||
failure.ShouldNotBeOfType<SshHostKeyMismatchException>();
|
failure.ShouldNotBeOfType<SshHostKeyMismatchException>();
|
||||||
@@ -137,7 +138,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
|||||||
var knownHosts = await TrustedStoreAsync();
|
var knownHosts = await TrustedStoreAsync();
|
||||||
|
|
||||||
await using var connection = await new SshNetConnectionFactory(knownHosts)
|
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();
|
connection.IsConnected.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
@@ -216,7 +217,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
|||||||
|
|
||||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
await new SshNetConnectionFactory(knownHosts)
|
await new SshNetConnectionFactory(knownHosts)
|
||||||
.ConnectAsync(Request(Credential(), proxy: null), Token));
|
.ConnectAsync(Request(Credential(), proxy: null), progress: null, Token));
|
||||||
|
|
||||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ public sealed class PumpOverRealSshTests(SshServerFixture fixture)
|
|||||||
new SshPasswordCredential(SshServerFixture.Password));
|
new SshPasswordCredential(SshServerFixture.Password));
|
||||||
|
|
||||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
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);
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using DotNet.Testcontainers.Builders;
|
using DotNet.Testcontainers.Builders;
|
||||||
using DotNet.Testcontainers.Containers;
|
using DotNet.Testcontainers.Containers;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -40,6 +42,21 @@ public sealed class SshServerFixture : IAsyncLifetime
|
|||||||
|
|
||||||
private const int SshPort = 2222;
|
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 readonly SemaphoreSlim sftpGate = new(1, 1);
|
||||||
|
|
||||||
private IContainer? container;
|
private IContainer? container;
|
||||||
@@ -80,52 +97,202 @@ public sealed class SshServerFixture : IAsyncLifetime
|
|||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
await container.StartAsync();
|
await container.StartAsync();
|
||||||
await AllowTcpForwardingAsync();
|
await ReconfigureAsync();
|
||||||
|
await WaitUntilServingAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b>The image ships <c>AllowTcpForwarding no</c>, and nothing says so at the point it bites.</b> A
|
/// ◆ <b><c>PerSourcePenalties no</c> is the fix for the flake this suite had for months, and the other
|
||||||
/// dynamic forward starts perfectly happily — it is a local listener, and opening it asks the server
|
/// two settings here are not.</b> OpenSSH 9.8 added per-source penalties and 10.x has them on by
|
||||||
/// nothing — and then every connection through it is refused when the channel is opened. SSH.NET
|
/// default; this image runs 10.3. A source address that keeps disconnecting without authenticating is
|
||||||
/// reports that as <c>SOCKS5: General failure</c> from the proxy, which names neither the server nor
|
/// penalised, and while the penalty holds every connection from it is answered with the clear-text line
|
||||||
/// the setting, and is what the first run of <c>LoopbackProxyTests</c> collected.
|
/// <c>Not allowed at this time</c> and then closed.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Patched after start rather than baked in, because the image's entrypoint writes its configuration
|
/// <b>This suite generates exactly that traffic, by design.</b> This client's first contact with an
|
||||||
/// itself on every boot — a mounted file would be overwritten before sshd read it. sshd re-reads on
|
/// unknown host is a connection deliberately refused at the host key — which is a disconnect with no
|
||||||
/// <c>SIGHUP</c> and applies the result to connections made after that, and the readiness wait has
|
/// authentication attempt — and several tests do nothing else:
|
||||||
/// already run, so nothing here races the boot.
|
/// <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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b><c>/config/sshd/sshd_config</c>, and there are two.</b> The image also carries
|
/// ◆ <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
|
/// <c>/etc/ssh/sshd_config</c>, which looks like the file to patch, reads identically, and is not the one
|
||||||
/// one the running server was started with — patching it changes the text and nothing else, which is a
|
/// the running server was started with — patching it changes the text and nothing else, which is a fix
|
||||||
/// fix that appears to work and leaves the failure exactly where it was. Measured with <c>find</c>
|
/// that appears to work and leaves the failure exactly where it was.
|
||||||
/// rather than assumed, after the first version of this method did precisely that.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// It is on for the whole assembly rather than for the one test that needs it. Forwarding is off in
|
/// ◆ <b>Patched after boot and reloaded, rather than injected before it — which was tried and does not
|
||||||
/// this image as hardening, not as a behaviour worth reproducing: nothing else here opens a channel of
|
/// work.</b> This image family runs <c>/custom-cont-init.d</c> scripts, which look like the right hook
|
||||||
/// any kind, so allowing it changes what exactly one suite can do and what none of the others see.
|
/// 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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task AllowTcpForwardingAsync()
|
private async Task ReconfigureAsync()
|
||||||
{
|
{
|
||||||
var result = await container!.ExecAsync([
|
var result = await container!.ExecAsync([
|
||||||
"sh",
|
"sh",
|
||||||
"-c",
|
"-c",
|
||||||
"sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config"
|
"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",
|
+ " && pkill -HUP sshd",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (result.ExitCode != 0)
|
if (result.ExitCode != 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Shared rather than opened per test, and that is a limit of the server rather than an optimisation.
|
/// Shared rather than opened per test. This was once explained as a way of staying under the server's
|
||||||
/// sshd's <c>MaxStartups</c> drops connections at random once enough are part-way through a handshake,
|
/// <c>MaxStartups</c> throttle, on the belief that the suite ran its classes in parallel and made two
|
||||||
/// and this client's first contact with an unknown host is a connection deliberately <em>refused</em> at
|
/// handshakes per test — this client's first contact with an unknown host is a connection deliberately
|
||||||
/// the host key — so a suite that opened its own session per test made two handshakes per test and
|
/// <em>refused</em> at the host key, so every session costs two. The parallelism was not real: every
|
||||||
/// pushed the whole assembly over the threshold. What that looks like is unrelated tests failing with
|
/// class here shares one collection and xUnit runs collections, not classes, in parallel. See
|
||||||
/// "the connection was closed by the remote host", a different few each run.
|
/// <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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Safe to share because an SFTP session holds no per-test state: every test here works in a directory
|
/// 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 () =>
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
request, TerminalSize.Default, TestContext.Current.CancellationToken));
|
request, TerminalSize.Default, progress: null, TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
unknown.Presentation.Fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix);
|
unknown.Presentation.Fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix);
|
||||||
|
|
||||||
@@ -119,6 +119,7 @@ public sealed class TerminalEndToEndTests(SshServerFixture fixture)
|
|||||||
return await workspace.OpenSessionAsync(
|
return await workspace.OpenSessionAsync(
|
||||||
request,
|
request,
|
||||||
new TerminalSize(100, 30, 1000, 750),
|
new TerminalSize(100, 30, 1000, 750),
|
||||||
|
progress: null,
|
||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ internal sealed class FakeShellSession : ISshShellSession
|
|||||||
private readonly List<byte> written = [];
|
private readonly List<byte> written = [];
|
||||||
private readonly Lock gate = new();
|
private readonly Lock gate = new();
|
||||||
|
|
||||||
|
private readonly bool blockReads;
|
||||||
|
|
||||||
private long remaining;
|
private long remaining;
|
||||||
private byte pattern;
|
private byte pattern;
|
||||||
|
|
||||||
@@ -16,7 +18,19 @@ internal sealed class FakeShellSession : ISshShellSession
|
|||||||
/// endless producer, which is what a runaway remote process looks like — those sessions are ended
|
/// endless producer, which is what a runaway remote process looks like — those sessions are ended
|
||||||
/// by disposing the pump rather than by running out of data.
|
/// by disposing the pump rather than by running out of data.
|
||||||
/// </param>
|
/// </param>
|
||||||
internal FakeShellSession(long bytesToProduce = 0) => remaining = bytesToProduce;
|
/// <param name="blockReads">
|
||||||
|
/// True for a shell that is open and live but has nothing to say — an idle prompt, rather than either
|
||||||
|
/// end of the "produces bytes" and "hit end of stream" spectrum <paramref name="bytesToProduce"/>
|
||||||
|
/// covers. <see cref="ReadAsync"/> then blocks until cancelled, which is what a real idle SSH channel's
|
||||||
|
/// read does. Exists for tests that need a session whose <c>Run</c> stays live without a background
|
||||||
|
/// read loop racing the test for control of the pump's credit window — see the reattach tests in
|
||||||
|
/// <c>TerminalWorkspaceTests</c>.
|
||||||
|
/// </param>
|
||||||
|
internal FakeShellSession(long bytesToProduce = 0, bool blockReads = false)
|
||||||
|
{
|
||||||
|
remaining = bytesToProduce;
|
||||||
|
this.blockReads = blockReads;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsOpen { get; private set; } = true;
|
public bool IsOpen { get; private set; } = true;
|
||||||
@@ -52,6 +66,13 @@ internal sealed class FakeShellSession : ISshShellSession
|
|||||||
{
|
{
|
||||||
ReadCount++;
|
ReadCount++;
|
||||||
|
|
||||||
|
if (blockReads)
|
||||||
|
{
|
||||||
|
// Never completes on its own. The only way out is the same way a real blocked read ends: the
|
||||||
|
// token being cancelled, which is what disposing the pump does.
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -109,7 +130,8 @@ internal sealed class FakeShellSession : ISshShellSession
|
|||||||
/// workspace is the layer that decides when a session is over, and that decision is what needs a
|
/// workspace is the layer that decides when a session is over, and that decision is what needs a
|
||||||
/// connection whose shell can be made to end on cue.
|
/// connection whose shell can be made to end on cue.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue) : ISshConnectionFactory
|
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue, bool blockShellReads = false)
|
||||||
|
: ISshConnectionFactory
|
||||||
{
|
{
|
||||||
/// <summary>Connections handed out, in order.</summary>
|
/// <summary>Connections handed out, in order.</summary>
|
||||||
internal List<FakeConnection> Connections { get; } = [];
|
internal List<FakeConnection> Connections { get; } = [];
|
||||||
@@ -117,9 +139,16 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue)
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISshConnection> ConnectAsync(
|
public Task<ISshConnection> ConnectAsync(
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
|
IProgress<SshConnectionPhase>? progress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var connection = new FakeConnection(request, bytesPerShell);
|
// 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);
|
Connections.Add(connection);
|
||||||
|
|
||||||
return Task.FromResult<ISshConnection>(connection);
|
return Task.FromResult<ISshConnection>(connection);
|
||||||
@@ -127,7 +156,8 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A connection that opens fake shells and records its own disposal.</summary>
|
/// <summary>A connection that opens fake shells and records its own disposal.</summary>
|
||||||
internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell) : ISshConnection
|
internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell, bool blockShellReads = false)
|
||||||
|
: ISshConnection
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsConnected { get; private set; } = true;
|
public bool IsConnected { get; private set; } = true;
|
||||||
@@ -148,7 +178,7 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Shell = new FakeShellSession(bytesPerShell);
|
Shell = new FakeShellSession(bytesPerShell, blockShellReads);
|
||||||
|
|
||||||
return Task.FromResult<ISshShellSession>(Shell);
|
return Task.FromResult<ISshShellSession>(Shell);
|
||||||
}
|
}
|
||||||
@@ -227,3 +257,15 @@ internal sealed class RecordingTransport : ITerminalTransport
|
|||||||
TerminalFrame.TryRead(frame, out var actual, out _, out _)
|
TerminalFrame.TryRead(frame, out var actual, out _, out _)
|
||||||
&& actual == (byte)opcode);
|
&& 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -142,15 +142,96 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
|||||||
await ConnectAsync(origin: "https://evil.example"));
|
await ConnectAsync(origin: "https://evil.example"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The truth this replaced: a second valid attach used to be a 409, on the theory that one renderer
|
||||||
|
/// lives for the whole process. Android's WebView does not honour that theory — its renderer process is
|
||||||
|
/// routinely killed and the page reloads with a fresh socket — so a second valid attach is now a
|
||||||
|
/// takeover. This asserts both halves: the newcomer gets the connection, and the displaced socket
|
||||||
|
/// actually goes rather than lingering as a phantom nothing is reading from.
|
||||||
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ASecondRenderer_IsRejected()
|
public async Task ASecondRenderer_TakesOver_AndTheFirstSocketIsDropped()
|
||||||
{
|
{
|
||||||
Start();
|
Start();
|
||||||
|
|
||||||
using var first = await ConnectAsync();
|
using var first = await ConnectAsync();
|
||||||
first.State.ShouldBe(WebSocketState.Open);
|
first.State.ShouldBe(WebSocketState.Open);
|
||||||
|
|
||||||
await Should.ThrowAsync<WebSocketException>(async () => await ConnectAsync());
|
using var second = await ConnectAsync();
|
||||||
|
second.State.ShouldBe(WebSocketState.Open);
|
||||||
|
|
||||||
|
// The first socket was aborted rather than closed gracefully — Abort skips the close handshake
|
||||||
|
// entirely, so there is no Close frame for this side to see coming. What a receive on it sees
|
||||||
|
// instead is the connection simply gone, which the client surfaces as an exception rather than as
|
||||||
|
// a state that quietly flips on its own; nothing here reads from the socket otherwise, so the
|
||||||
|
// state alone would not move.
|
||||||
|
var firstBuffer = new byte[16];
|
||||||
|
await Should.ThrowAsync<Exception>(async () =>
|
||||||
|
await first.ReceiveAsync(firstBuffer.AsMemory(), TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
await using var session = new FakeShellSession(bytesToProduce: 64);
|
||||||
|
await using var pump = CreatePump(session);
|
||||||
|
plane.Register(SessionId, pump);
|
||||||
|
|
||||||
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var opened = await ReceiveAsync(second);
|
||||||
|
opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
|
||||||
|
|
||||||
|
var output = await ReceiveAsync(second);
|
||||||
|
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
|
||||||
|
output.Payload.Length.ShouldBe(64);
|
||||||
|
|
||||||
|
await SendAsync(
|
||||||
|
second,
|
||||||
|
(byte)TerminalClientOpcode.Acknowledge,
|
||||||
|
TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length));
|
||||||
|
|
||||||
|
await run;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The other half of the takeover: a renderer process that dies without a close handshake — which is
|
||||||
|
/// what a killed Android WebView actually does, no FIN, nothing — must not fault the send path. A
|
||||||
|
/// faulted send would propagate into <see cref="TerminalSessionPump"/>'s flush loop and freeze a live
|
||||||
|
/// session; see <see cref="TerminalDataPlane.SendAsync"/>'s remark for why. Disposing the client socket
|
||||||
|
/// abruptly, with no close handshake sent, is the closest this harness gets to that: the server-side
|
||||||
|
/// socket is left believing itself open until it actually tries to write to it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Driven straight through <see cref="TerminalDataPlane.SendAsync"/> rather than through a pump, because
|
||||||
|
/// a pump adds nothing here — the point is entirely about the transport's own contract, and a session
|
||||||
|
/// layered on top would only leave it unclear whether a passing test proved the transport never threw or
|
||||||
|
/// merely that the frames never happened to need a live socket.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SendAsync_DoesNotThrow_WhenTheAttachedRendererDiedWithoutClosing_AndAFreshAttachStillReceives()
|
||||||
|
{
|
||||||
|
Start();
|
||||||
|
|
||||||
|
var first = await ConnectAsync();
|
||||||
|
first.State.ShouldBe(WebSocketState.Open);
|
||||||
|
first.Dispose();
|
||||||
|
|
||||||
|
// Whether this particular send lands on the OS's send buffer before the peer's absence is noticed,
|
||||||
|
// or fails immediately, is not the point — either way it must not throw.
|
||||||
|
await Should.NotThrowAsync(async () =>
|
||||||
|
await plane.SendAsync(
|
||||||
|
TerminalFrame.Create((byte)TerminalServerOpcode.Output, SessionId, "before"u8.ToArray()),
|
||||||
|
TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
using var second = await ConnectAsync();
|
||||||
|
|
||||||
|
await Should.NotThrowAsync(async () =>
|
||||||
|
await plane.SendAsync(
|
||||||
|
TerminalFrame.Create((byte)TerminalServerOpcode.Output, SessionId, "after"u8.ToArray()),
|
||||||
|
TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
var output = await ReceiveAsync(second);
|
||||||
|
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
|
||||||
|
Encoding.UTF8.GetString(output.Payload).ShouldBe("after");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Frames ----
|
// ---- Frames ----
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
using DodoSSH.Client.Ssh;
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Terminal.Tests;
|
namespace DodoSSH.Client.Terminal.Tests;
|
||||||
@@ -69,12 +72,12 @@ public sealed class TerminalWorkspaceTests
|
|||||||
workspace.LiveSessionCount.ShouldBe(0);
|
workspace.LiveSessionCount.ShouldBe(0);
|
||||||
|
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
workspace.LiveSessionCount.ShouldBe(1);
|
workspace.LiveSessionCount.ShouldBe(1);
|
||||||
|
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
workspace.LiveSessionCount.ShouldBe(2);
|
workspace.LiveSessionCount.ShouldBe(2);
|
||||||
}
|
}
|
||||||
@@ -95,11 +98,63 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await WaitUntilAsync(() => workspace.LiveSessionCount == 0);
|
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]
|
[Fact]
|
||||||
public async Task ClosingASessionEndsItAndDisposesItsConnection()
|
public async Task ClosingASessionEndsItAndDisposesItsConnection()
|
||||||
{
|
{
|
||||||
@@ -108,7 +163,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await workspace.CloseSessionAsync(sessionId);
|
await workspace.CloseSessionAsync(sessionId);
|
||||||
|
|
||||||
@@ -135,9 +190,9 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var first = await workspace.OpenSessionAsync(
|
var first = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
var second = await workspace.OpenSessionAsync(
|
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(first).ShouldBeTrue();
|
||||||
workspace.IsSessionLive(second).ShouldBeTrue();
|
workspace.IsSessionLive(second).ShouldBeTrue();
|
||||||
@@ -163,7 +218,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
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 facts = workspace.GetSessionFacts(sessionId).ShouldNotBeNull();
|
||||||
var connection = connections.Connections.ShouldHaveSingleItem();
|
var connection = connections.Connections.ShouldHaveSingleItem();
|
||||||
@@ -196,7 +251,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
(await workspace.PasteAsync(
|
(await workspace.PasteAsync(
|
||||||
sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
|
sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
|
||||||
@@ -214,12 +269,15 @@ public sealed class TerminalWorkspaceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The event the tab strip listens to, so a dot can go out the moment a shell exits rather than at the
|
/// The event the tab strip and the phone's keep-alive listen to, so a dot can go out — and a foreground
|
||||||
/// next thing that happens to repaint. Raised only when the session ended on its own: a tab the user
|
/// notification can come down — the moment a shell exits rather than at the next thing that happens to
|
||||||
/// closed has a caller who already knows, and telling it would turn one close into two.
|
/// 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>
|
/// </remarks>
|
||||||
[Fact]
|
[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,
|
// 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.
|
// so the pump finishes with nobody asking it to.
|
||||||
@@ -228,16 +286,18 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var ended = new List<uint>();
|
var ended = new List<uint>();
|
||||||
|
var liveAtAnnouncement = -1;
|
||||||
workspace.SessionEnded += (_, e) =>
|
workspace.SessionEnded += (_, e) =>
|
||||||
{
|
{
|
||||||
lock (ended)
|
lock (ended)
|
||||||
{
|
{
|
||||||
|
liveAtAnnouncement = workspace.LiveSessionCount;
|
||||||
ended.Add(e.SessionId);
|
ended.Add(e.SessionId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await WaitUntilAsync(() =>
|
await WaitUntilAsync(() =>
|
||||||
{
|
{
|
||||||
@@ -246,31 +306,49 @@ public sealed class TerminalWorkspaceTests
|
|||||||
return ended.Contains(sessionId);
|
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]
|
[Fact]
|
||||||
public async Task ClosingASessionIsNotAnnouncedBack()
|
public async Task ClosingASessionIsAnnounced_OnceItHasDrained()
|
||||||
{
|
{
|
||||||
var connections = new FakeConnectionFactory();
|
var connections = new FakeConnectionFactory();
|
||||||
|
|
||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var announcements = 0;
|
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(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await workspace.CloseSessionAsync(sessionId);
|
await workspace.CloseSessionAsync(sessionId);
|
||||||
|
|
||||||
Volatile.Read(ref announcements)
|
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>
|
/// <remarks>
|
||||||
/// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
|
/// 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>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DisposingTheWorkspaceClosesEverySession()
|
public async Task DisposingTheWorkspaceClosesEverySession()
|
||||||
@@ -279,16 +357,99 @@ public sealed class TerminalWorkspaceTests
|
|||||||
|
|
||||||
var workspace = CreateWorkspace(connections);
|
var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
|
var announcements = 0;
|
||||||
|
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
|
||||||
|
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await workspace.DisposeAsync();
|
await workspace.DisposeAsync();
|
||||||
|
|
||||||
workspace.LiveSessionCount.ShouldBe(0);
|
workspace.LiveSessionCount.ShouldBe(0);
|
||||||
connections.Connections.Count.ShouldBe(2);
|
connections.Connections.Count.ShouldBe(2);
|
||||||
connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
|
connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
|
||||||
|
Volatile.Read(ref announcements).ShouldBe(0, "shutdown closes are not announced");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Reattach ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The scenario the whole fix exists for: a page that lost its socket — killed WebView renderer, or
|
||||||
|
/// simply a reload — reattaches, and the session that was already running has to come back rather than
|
||||||
|
/// sit there forever with its output going nowhere.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The session's shell blocks on every read rather than producing output, which is what an idle prompt
|
||||||
|
/// looks like and — for this test — is what keeps its <c>Run</c> live without a background read loop
|
||||||
|
/// competing with this test over the credit window's exact value.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Neither assertion below polls, deliberately. The "before" one does not need to: reserving credit is
|
||||||
|
/// a synchronous call, so it is true the instant it returns. The "after" one does not need to either,
|
||||||
|
/// for a subtler reason — <see cref="TerminalWorkspace.ReplayAfterAttachAsync"/> calls
|
||||||
|
/// <c>Credits.Reset()</c> and only then awaits sending the replay frame for that same session, with no
|
||||||
|
/// suspension between the two, so by the time this test has received that frame the reset has
|
||||||
|
/// necessarily already happened. A poll here would only have hidden a real ordering bug behind a
|
||||||
|
/// generous timeout instead of catching it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ANewRenderer_ReplaysTheLiveSessionAndResetsItsCredits()
|
||||||
|
{
|
||||||
|
var connections = new FakeConnectionFactory(blockShellReads: true);
|
||||||
|
|
||||||
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
workspace.Start();
|
||||||
|
|
||||||
|
using var first = await ConnectRendererAsync(workspace);
|
||||||
|
|
||||||
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
|
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.
|
||||||
|
await ReceiveFrameAsync(first);
|
||||||
|
|
||||||
|
var credits = workspace.CreditsFor(sessionId).ShouldNotBeNull();
|
||||||
|
credits.TryReserve(4096);
|
||||||
|
credits.Outstanding.ShouldBeGreaterThanOrEqualTo(
|
||||||
|
4096, "the pump's own read loop may have reserved a buffer's worth on top of this");
|
||||||
|
|
||||||
|
using var second = await ConnectRendererAsync(workspace);
|
||||||
|
|
||||||
|
var replay = await ReceiveFrameAsync(second);
|
||||||
|
replay.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
|
||||||
|
replay.SessionId.ShouldBe(sessionId);
|
||||||
|
replay.Payload.ShouldBe(new byte[] { 1 }, "a replay is flagged so the page can tell it apart from a fresh open");
|
||||||
|
|
||||||
|
credits.Outstanding.ShouldBe(0, "the replay frame above cannot have been sent before the reset that precedes it");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other half of a reattach: the workspace has replayed what it owns, and this is the seam the
|
||||||
|
/// shell uses to replay what it owns instead — the font size and the selected tab, neither of which a
|
||||||
|
/// terminal session knows anything about. <c>MainWindowViewModel</c>'s subscription is what actually
|
||||||
|
/// does that; this only asserts that the workspace hands it the chance to.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ANewRenderer_RaisesRendererReattached()
|
||||||
|
{
|
||||||
|
var connections = new FakeConnectionFactory();
|
||||||
|
|
||||||
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
workspace.Start();
|
||||||
|
|
||||||
|
using var first = await ConnectRendererAsync(workspace);
|
||||||
|
|
||||||
|
var reattachedCount = 0;
|
||||||
|
workspace.RendererReattached += (_, _) => Interlocked.Increment(ref reattachedCount);
|
||||||
|
|
||||||
|
using var second = await ConnectRendererAsync(workspace);
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => Volatile.Read(ref reattachedCount) > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
@@ -313,7 +474,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
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.
|
// The up-arrow, as a PTY expects it. Three bytes, and all three matter.
|
||||||
byte[] upArrow = [0x1B, (byte)'[', (byte)'A'];
|
byte[] upArrow = [0x1B, (byte)'[', (byte)'A'];
|
||||||
@@ -335,7 +496,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(new FakeConnectionFactory());
|
await using var workspace = CreateWorkspace(new FakeConnectionFactory());
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await workspace.CloseSessionAsync(sessionId);
|
await workspace.CloseSessionAsync(sessionId);
|
||||||
|
|
||||||
@@ -362,12 +523,76 @@ public sealed class TerminalWorkspaceTests
|
|||||||
private static InMemoryTerminalAssetProvider StubAssets() =>
|
private static InMemoryTerminalAssetProvider StubAssets() =>
|
||||||
new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
|
new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
[TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()),
|
// The placeholders, not a token and URL already filled in — the reattach tests below have to
|
||||||
|
// connect a real renderer, and doing that by reading them back out of the served page is what
|
||||||
|
// proves the workspace serves a page a real renderer could actually attach with, rather than
|
||||||
|
// one that merely looks servable.
|
||||||
|
[TerminalDataPlane.PagePath] = new(
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
Encoding.UTF8.GetBytes(
|
||||||
|
$"<html><body data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
|
||||||
|
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></body></html>")),
|
||||||
});
|
});
|
||||||
|
|
||||||
private static SshConnectionRequest Request() =>
|
private static SshConnectionRequest Request() =>
|
||||||
new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
|
new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
|
||||||
|
{
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var page = await http.GetStringAsync(workspace.PageUrl, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var token = ExtractAttribute(page, "data-token");
|
||||||
|
var socketUrl = ExtractAttribute(page, "data-socket");
|
||||||
|
|
||||||
|
var client = new ClientWebSocket();
|
||||||
|
client.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
|
||||||
|
client.Options.AddSubProtocol($"token.{token}");
|
||||||
|
client.Options.SetRequestHeader(
|
||||||
|
"Origin",
|
||||||
|
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{workspace.PageUrl.Port}"));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
client.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractAttribute(string html, string name)
|
||||||
|
{
|
||||||
|
var marker = $"{name}=\"";
|
||||||
|
var start = html.IndexOf(marker, StringComparison.Ordinal) + marker.Length;
|
||||||
|
var end = html.IndexOf('"', start);
|
||||||
|
|
||||||
|
return html[start..end];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveFrameAsync(
|
||||||
|
ClientWebSocket socket)
|
||||||
|
{
|
||||||
|
var buffer = new byte[64 * 1024];
|
||||||
|
|
||||||
|
var result = await socket.ReceiveAsync(buffer.AsMemory(), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload)
|
||||||
|
.ShouldBeTrue();
|
||||||
|
|
||||||
|
return (opcode, sessionId, payload.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Polled rather than awaited on a task, because the point is what an observer of the property
|
/// Polled rather than awaited on a task, because the point is what an observer of the property
|
||||||
/// sees: the pump ends on a thread of its own, and the count has to catch up without anyone
|
/// sees: the pump ends on a thread of its own, and the count has to catch up without anyone
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var first = await factory.ConnectAsync(request, Token);
|
await using var first = await factory.ConnectAsync(request, progress: null, Token);
|
||||||
Assert.Fail("An unseen host key must not be trusted silently.");
|
Assert.Fail("An unseen host key must not be trusted silently.");
|
||||||
}
|
}
|
||||||
catch (SshHostKeyUnknownException exception)
|
catch (SshHostKeyUnknownException exception)
|
||||||
@@ -402,7 +402,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
|
|||||||
await knownHosts.TrustAsync(pin, Token);
|
await knownHosts.TrustAsync(pin, Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var connection = await factory.ConnectAsync(request, Token);
|
await using var connection = await factory.ConnectAsync(request, progress: null, Token);
|
||||||
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
||||||
|
|
||||||
await shell.WriteTextAsync("echo dodossh-e2e-ok\n", Token);
|
await shell.WriteTextAsync("echo dodossh-e2e-ok\n", Token);
|
||||||
|
|||||||
Reference in New Issue
Block a user