Public Access
Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b20f7cae04 | ||
|
|
54bc142dce | ||
|
|
8e61fc72b4 | ||
|
|
8a4058ce3a | ||
|
|
2d95366552 | ||
|
|
9ead42171c | ||
|
|
597e5ae016 | ||
|
|
483ae1fed9 | ||
|
|
7ee2aacdc9 | ||
|
|
dec2cf164a | ||
|
|
7d1f0a4b53 | ||
|
|
fd92b80d4f | ||
|
|
dabc691c35 | ||
|
|
31c836f6ca | ||
|
|
b8c94ecb73 | ||
|
|
aff776cb42 | ||
|
|
91f7586b22 | ||
|
|
c859705b83 | ||
|
|
ed936380b3 | ||
|
|
c270f3fe39 | ||
|
|
56c19b006d | ||
|
|
1908189100 | ||
|
|
2810f7c12b | ||
|
|
c0dd5a147e | ||
|
|
c0396b4f4d | ||
|
|
06369274dc | ||
|
|
cfc2e3ae0c | ||
|
|
e2733e5cbc | ||
|
|
c2c860b4f2 | ||
|
|
4552538533 | ||
|
|
1bf79c7c26 | ||
|
|
1970e20e56 | ||
|
|
b2f93a172b | ||
|
|
882f01c515 | ||
|
|
2f8daacb60 | ||
|
|
ef12e8cc99 | ||
|
|
963cb7f670 | ||
|
|
68964d8a34 | ||
|
|
6934efb1e6 | ||
|
|
e252d337d1 | ||
|
|
fdea3911c1 | ||
|
|
10f80bded1 | ||
|
|
281f849086 | ||
|
|
25407756c3 | ||
|
|
a763f4b113 | ||
|
|
881562e81b | ||
|
|
93e35a0095 | ||
|
|
b80bf23341 | ||
|
|
8c58e5a558 | ||
|
|
cec73010d3 | ||
|
|
53ff15ba86 | ||
|
|
766fe6aebe | ||
|
|
9f73893e14 | ||
|
|
ccaf7a8e72 | ||
|
|
6fc1e3a7c5 | ||
|
|
8a77b7ca68 | ||
|
|
9755b5f6ae | ||
|
|
afc6a042f1 | ||
|
|
e41eca01a8 | ||
|
|
e96d01aab9 | ||
|
|
7f77539ba6 | ||
|
|
ca081af209 | ||
|
|
890a5f2246 | ||
|
|
8c67fce32c | ||
|
|
0ffd259ccd | ||
|
|
9bc9069425 | ||
|
|
e936ab4646 | ||
|
|
506d2803a2 | ||
|
|
cc8bf37321 | ||
|
|
3f5979d639 | ||
|
|
aaff81272a | ||
|
|
4d1f07f253 | ||
|
|
095774c498 | ||
|
|
3977f68870 | ||
|
|
48ea5e22d5 | ||
|
|
810bc48d3f | ||
|
|
671611a9a0 | ||
|
|
21cf77f64a | ||
|
|
dbf6ce1bcf | ||
|
|
242280ce6b | ||
|
|
de0b5f12ae | ||
|
|
009b35e069 | ||
|
|
d32f5609e3 | ||
|
|
9d5ff9f23a | ||
|
|
8209f15741 | ||
|
|
8915650a0d | ||
|
|
b931a06998 | ||
|
|
ca48e18b57 | ||
|
|
c59b517fdf | ||
|
|
bb2f973687 | ||
|
|
c8507b44fe | ||
|
|
422d5ca10e | ||
|
|
d91729c0b8 | ||
|
|
43c939b697 | ||
|
|
1b76c51fbb | ||
|
|
f3c0b9ca1b | ||
|
|
7ca74a1e35 | ||
|
|
d7f0bea258 | ||
|
|
7d64027972 | ||
|
|
281e828e25 | ||
|
|
dca2e888d6 | ||
|
|
2ba7c14e35 | ||
|
|
c3ef4bd8b4 | ||
|
|
369109dd4e | ||
|
|
06f9dcfc27 | ||
|
|
bea0279937 | ||
|
|
49645db680 | ||
|
|
82966af37b | ||
|
|
575a9a9f5e | ||
|
|
6185d74800 | ||
|
|
88809f0d66 | ||
|
|
d8cf16fb46 | ||
|
|
507cd9ff88 | ||
|
|
7b616e0bb0 | ||
|
|
36b8a23020 | ||
|
|
1e8a1f2e83 | ||
|
|
4f9faa2fe3 | ||
|
|
e750ba05e3 | ||
|
|
6d6edb02c1 | ||
|
|
808a9a7fc1 | ||
|
|
f1d6499bb5 | ||
|
|
c882fa0cd3 | ||
|
|
69858f82d1 | ||
|
|
509a7c34f5 | ||
|
|
185790fb14 | ||
|
|
3d9ed03b09 | ||
|
|
cddfeb1f55 | ||
|
|
174ef7c420 | ||
|
|
af0e29a98b |
No files matched your search
+7
-1
@@ -20,8 +20,14 @@
|
|||||||
*.sql text
|
*.sql text
|
||||||
|
|
||||||
# Files that must keep LF regardless of platform — shell scripts run in Linux
|
# Files that must keep LF regardless of platform — shell scripts run in Linux
|
||||||
# containers, and Dockerfiles are read by a Linux daemon.
|
# containers, Dockerfiles are read by a Linux daemon, and an RPM spec is a shell
|
||||||
|
# script in disguise: rpmbuild feeds its %install section to /bin/sh, where a
|
||||||
|
# carriage return at the end of a line is an argument rather than nothing. A udev
|
||||||
|
# rule is parsed by udev's own lexer, which treats a stray carriage return as part
|
||||||
|
# of the value it is reading.
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
*.spec text eol=lf
|
||||||
|
*.rules text eol=lf
|
||||||
Dockerfile text eol=lf
|
Dockerfile text eol=lf
|
||||||
*.Dockerfile text eol=lf
|
*.Dockerfile text eol=lf
|
||||||
Caddyfile text eol=lf
|
Caddyfile text eol=lf
|
||||||
|
|||||||
+536
-4
@@ -29,6 +29,18 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: build and test
|
name: build and test
|
||||||
runs-on: [linux]
|
runs-on: [linux]
|
||||||
|
# ◆ A CEILING, BECAUSE THIS FILE HAD NONE AND A HUNG TEST RAN UNTIL SOMEBODY NOTICED.
|
||||||
|
#
|
||||||
|
# One test in DodoSSH.Client.App.Tests waited on a named pipe that, on Linux, nothing was ever going
|
||||||
|
# to answer — and it waited on a token with no timeout, so the job did not fail, it sat there. It was
|
||||||
|
# killed by hand after twenty minutes, having reported nothing but the eighteen suites that had
|
||||||
|
# already passed.
|
||||||
|
#
|
||||||
|
# Twenty-five minutes against a job that ordinarily takes three. It is not a performance budget and
|
||||||
|
# must not be read as one: a build that legitimately grows past this should have the number raised.
|
||||||
|
# What it buys is that the failure mode is a red job with a named step rather than a runner somebody
|
||||||
|
# has to go and stop.
|
||||||
|
timeout-minutes: 25
|
||||||
steps:
|
steps:
|
||||||
# act_runner runs every `uses:` action with node inside the job container, and this
|
# act_runner runs every `uses:` action with node inside the job container, and this
|
||||||
# runner's image has none — the run died on the first line of actions/checkout with
|
# runner's image has none — the run died on the first line of actions/checkout with
|
||||||
@@ -220,7 +232,12 @@ jobs:
|
|||||||
- name: build
|
- name: build
|
||||||
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
|
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
|
||||||
|
|
||||||
|
# Tighter than the job's own ceiling, and deliberately so: a hang here is the likeliest of the
|
||||||
|
# lot — it is the only step running code written for this repository rather than a tool — and a
|
||||||
|
# step-level timeout names the step in the failure instead of leaving a reader to work out which
|
||||||
|
# of fifteen was still running. Ten minutes against a suite that takes two.
|
||||||
- name: test
|
- name: test
|
||||||
|
timeout-minutes: 10
|
||||||
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
||||||
|
|
||||||
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
||||||
@@ -291,6 +308,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 +392,142 @@ 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."
|
||||||
|
|
||||||
|
# ◆ AND THE THIRD DESKTOP PLATFORM, WHICH IS THE ONE THIS RUNNER ACTUALLY IS.
|
||||||
|
#
|
||||||
|
# Same argument as the two steps above and with none of their cross-compiling: this job runs on
|
||||||
|
# Linux, so the linux-x64 publish here is a native one. What it proves that the others cannot is
|
||||||
|
# the linux-x64 restore graph — libsodium, SkiaSharp and HarfBuzzSharp all ship native assets per
|
||||||
|
# RID, and a package that resolves for win-x64 and has no linux-x64 asset fails here.
|
||||||
|
#
|
||||||
|
# The runner is still Alpine and the package targets glibc distributions, which is fine and is not
|
||||||
|
# glossed over: scripts/ci-linux-rpm.sh explains why rpmbuild composing an RPM for one libc on a
|
||||||
|
# host running another is correct rather than lucky.
|
||||||
|
#
|
||||||
|
# ◆ IT IS ALSO WHERE THE PACKAGING ITSELF IS PROVED, WHICH ON THIS PLATFORM IS NOT A FORMALITY.
|
||||||
|
#
|
||||||
|
# The Windows and macOS steps hand a directory to vpk and vpk decides what the package needs.
|
||||||
|
# There is no vpk here — Velopack's Linux target is an AppImage and this is a distribution
|
||||||
|
# package, updated by dnf rather than by the application — so the dependency metadata is
|
||||||
|
# generated by rpm from the published tree, and scripts/ci-linux-rpm.sh interrogates the result
|
||||||
|
# rather than trusting an exit code. Every check in there is a way a package can be built
|
||||||
|
# successfully and then refuse to install on every host in the world; one of them was written
|
||||||
|
# because .NET's own libmscordbi.so links libmscordaccore.so, which is exactly that.
|
||||||
|
#
|
||||||
|
# Release-shaped rather than nightly-shaped — package name dodossh, tree at /opt/DodoSSH — so
|
||||||
|
# that what is proved on every main build is the packaging the release channel would use. The
|
||||||
|
# desktop job below builds the nightly-shaped one and is the only job that publishes either.
|
||||||
|
#
|
||||||
|
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
|
||||||
|
#
|
||||||
|
# RUNNER_TEMP, dying with the job. An RPM is if anything the easiest of the three to pass on
|
||||||
|
# casually — one file, no installer to click through, `dnf install` and done — which is a reason
|
||||||
|
# to keep it off a downloads page rather than a reason to put it on one. ADR 0013 rule 3.
|
||||||
|
- name: publish and package the linux desktop client
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# RestoreLockedMode=false for the RID, exactly as the two publishes above do — see the long
|
||||||
|
# note on the win-x64 one. This checkout is thrown away, so the lock files it rewrites go
|
||||||
|
# nowhere.
|
||||||
|
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||||
|
--configuration Release --runtime linux-x64 --self-contained true \
|
||||||
|
-p:RestoreLockedMode=false \
|
||||||
|
--output "$RUNNER_TEMP/linux-x64"
|
||||||
|
|
||||||
|
scripts/ci-linux-rpm.sh \
|
||||||
|
--payload "$RUNNER_TEMP/linux-x64" \
|
||||||
|
--version '${{ steps.winpack.outputs.packVersion }}' \
|
||||||
|
--channel release \
|
||||||
|
--out "$RUNNER_TEMP/linux-rpm"
|
||||||
|
|
||||||
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
# 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
|
||||||
@@ -419,6 +573,8 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
android:
|
android:
|
||||||
name: android head
|
name: android head
|
||||||
|
# See the build job. The docker pull and the toolchain image make this one legitimately slower.
|
||||||
|
timeout-minutes: 40
|
||||||
runs-on: [linux]
|
runs-on: [linux]
|
||||||
# Writes the nightly release at the end of the job; see that step for what the capability is and why
|
# Writes the nightly release at the end of the job; see that step for what the capability is and why
|
||||||
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
|
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
|
||||||
@@ -778,7 +934,10 @@ jobs:
|
|||||||
# never made.
|
# never made.
|
||||||
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||||
for name in $names; do
|
for name in $names; do
|
||||||
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
# grep without -q, which has the same exit status and reads to the end of its input. A
|
||||||
|
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
|
||||||
|
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
|
||||||
|
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||||
echo "The release was created but $name is not on it:" >&2
|
echo "The release was created but $name is not on it:" >&2
|
||||||
printf '%s\n' "$published" >&2
|
printf '%s\n' "$published" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -787,8 +946,380 @@ jobs:
|
|||||||
|
|
||||||
echo "Published nightly $VERSION with$names"
|
echo "Published nightly $VERSION with$names"
|
||||||
|
|
||||||
|
desktop:
|
||||||
|
name: desktop nightly
|
||||||
|
# See the build job. Two publishes, a pack and an upload of about 120 MB.
|
||||||
|
timeout-minutes: 40
|
||||||
|
# Gated on the tests, like the image job and for a stronger version of its reason: this one is
|
||||||
|
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
|
||||||
|
# commit whose suite was red.
|
||||||
|
needs: [build]
|
||||||
|
runs-on: [linux]
|
||||||
|
# main only, and the whole job rather than its last step. A v* tag belongs to the release channel,
|
||||||
|
# which no runner may publish — ADR 0013 rule 3 — and the desktop head is already built and packed
|
||||||
|
# on tags by the build job above, so there is nothing here a tag build would gain.
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
# Writes the rolling nightly release at the end of the job. Job-scoped, so no other job in this file
|
||||||
|
# gains it; see the publish step for what the capability is and why this channel may hold it.
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
# Duplicated from the build job; see the comment there for why it cannot be factored out. There
|
||||||
|
# are four copies now, and any change has to be made in all four.
|
||||||
|
- name: ensure node and git
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
SUDO=""
|
||||||
|
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
||||||
|
|
||||||
|
missing=""
|
||||||
|
command -v node >/dev/null 2>&1 || missing="$missing nodejs"
|
||||||
|
command -v git >/dev/null 2>&1 || missing="$missing git"
|
||||||
|
|
||||||
|
if [ -z "$missing" ]; then
|
||||||
|
echo "node $(node --version), git $(git --version)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing:$missing"
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
$SUDO apt-get update -qq
|
||||||
|
$SUDO apt-get install -y --no-install-recommends $missing
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
$SUDO apk add --no-cache $missing
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
$SUDO dnf install -y $missing
|
||||||
|
else
|
||||||
|
echo "No apt-get, apk or dnf here, so node cannot be installed from inside the" >&2
|
||||||
|
echo "job. Point the runner's container.image at something that ships node." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "node $(node --version), git $(git --version)"
|
||||||
|
|
||||||
|
major="$(node --version | sed 's/^v//; s/\..*//')"
|
||||||
|
if [ "$major" -lt 20 ]; then
|
||||||
|
echo "::warning::node $major is older than the runtime these actions target;" \
|
||||||
|
"give the runner an image with node 20 or newer if actions misbehave."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# fetch-depth 0 for the reason the other three jobs give, and here it decides what gets published:
|
||||||
|
# MinVer's answer is this build's version and the number a nightly client compares against.
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
|
with:
|
||||||
|
global-json-file: global.json
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: '**/packages.lock.json'
|
||||||
|
|
||||||
|
# Before the version is read, and that ordering is not tidiness. MinVer arrives as a package, so its
|
||||||
|
# target does not exist until a restore has written obj/*.nuget.g.targets — and `-t:MinVer` on an
|
||||||
|
# unrestored project fails MSB4057 "the target does not exist", which reads like a typo in this file
|
||||||
|
# rather than like a missing restore. The build job's tag check is safe because it runs after that
|
||||||
|
# job's own restore; this job has none, so it needs this one.
|
||||||
|
#
|
||||||
|
# Locked, like the solution restore in the build job. The RID-specific restore the publish needs is
|
||||||
|
# unlocked and asks for that itself, exactly as the build job's publish does.
|
||||||
|
- name: restore
|
||||||
|
run: dotnet restore src/DodoSSH.Client.App/DodoSSH.Client.App.csproj --locked-mode
|
||||||
|
|
||||||
|
# ◆ THE VERSION IS DECIDED ONCE HERE AND THEN FORCED ON EVERYTHING.
|
||||||
|
#
|
||||||
|
# MinVer's own answer is not usable as it stands: until a v* tag exists it is 0.0.0-alpha.0.N, and
|
||||||
|
# vpk refuses to pack anything below 0.0.1. The floor is applied to the *whole build* rather than to
|
||||||
|
# the packaging alone, through MinVerVersionOverride, and that is the part worth understanding.
|
||||||
|
#
|
||||||
|
# Packing a version the assemblies disagree with would put one number in the installer and another
|
||||||
|
# on the preferences screen — the screen a person reads when asked which nightly they are on, and
|
||||||
|
# the number they would then quote into an issue that nobody can match to a build. MinVer sets both
|
||||||
|
# Version and InformationalVersion from the override, so the two cannot drift.
|
||||||
|
#
|
||||||
|
# Monotonic across the boundary, which is what a feed needs: heights keep rising within a floored
|
||||||
|
# version, and the first real tag moves the whole number up past every floored one.
|
||||||
|
- name: the nightly version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# -t:MinVer for the reason the tag check in the build job spells out: without a target named,
|
||||||
|
# -getProperty answers the SDK default and every nightly would claim the same version forever.
|
||||||
|
version="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||||
|
-getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
|
||||||
|
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
echo "Could not read the version from MSBuild." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$version" in
|
||||||
|
0.0.0*)
|
||||||
|
floored="0.0.1${version#0.0.0}"
|
||||||
|
echo "MinVer says $version, which vpk will not pack; this nightly is $floored."
|
||||||
|
version="$floored"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "$version"
|
||||||
|
|
||||||
|
# DodoChannel=nightly is what makes this a different application rather than the same one built
|
||||||
|
# again: it puts the channel in the assembly, which is where DesktopChannel reads it to pick the
|
||||||
|
# feed to poll, whether to accept prereleases, and which profile directory to keep a cache in.
|
||||||
|
#
|
||||||
|
# RestoreLockedMode=false for the RID, exactly as the build job's publish does — see the long note
|
||||||
|
# there. This checkout is thrown away, so the lock files it rewrites go nowhere.
|
||||||
|
- name: publish the nightly
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: >
|
||||||
|
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
|
||||||
|
--configuration Release --runtime win-x64 --self-contained true
|
||||||
|
-p:RestoreLockedMode=false
|
||||||
|
-p:DodoChannel=nightly
|
||||||
|
-p:MinVerVersionOverride="$VERSION"
|
||||||
|
--output "$RUNNER_TEMP/nightly-win-x64"
|
||||||
|
|
||||||
|
# ◆ A DIFFERENT PACK ID, A DIFFERENT CHANNEL, A DIFFERENT TITLE. ALL THREE, AND NONE IS COSMETIC.
|
||||||
|
#
|
||||||
|
# packId decides where Velopack installs and what an installed client matches an update against, so
|
||||||
|
# DodoSSH.Desktop.Nightly is what makes this install *beside* the release build rather than over it,
|
||||||
|
# and what stops either feed's package being applied to the other's install.
|
||||||
|
#
|
||||||
|
# channel decides the name of the index file on the feed — releases.win-nightly.json — and it is a
|
||||||
|
# contract with VelopackUpdateChannel.NightlyChannel. Disagree on this word and the channel answers
|
||||||
|
# nothing, forever, with no error anywhere.
|
||||||
|
#
|
||||||
|
# title is what a person reads in the Start menu and in Add/Remove Programs, and it is the only one
|
||||||
|
# of the three they will ever see. Two entries both called DodoSSH would be the whole benefit of
|
||||||
|
# installing side by side, thrown away at the last step.
|
||||||
|
#
|
||||||
|
# No `vpk download` and so no deltas: this channel deletes its previous release on every push, so
|
||||||
|
# there would be nothing on the feed for a delta to be applied against. A nightly update is a full
|
||||||
|
# download, which is the honest cost of a rolling channel that keeps exactly one build.
|
||||||
|
- name: package the nightly
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
dotnet tool restore
|
||||||
|
|
||||||
|
dotnet vpk '[win]' pack \
|
||||||
|
--skip-updates \
|
||||||
|
--packId DodoSSH.Desktop.Nightly \
|
||||||
|
--packVersion "$VERSION" \
|
||||||
|
--packDir "$RUNNER_TEMP/nightly-win-x64" \
|
||||||
|
--packTitle 'DodoSSH Nightly' \
|
||||||
|
--packAuthors DodoTech \
|
||||||
|
--mainExe DodoSSH.exe \
|
||||||
|
--icon src/DodoSSH.Client.App/Assets/dodossh.ico \
|
||||||
|
--runtime win-x64 \
|
||||||
|
--channel win-nightly \
|
||||||
|
--outputDir "$RUNNER_TEMP/nightly-releases"
|
||||||
|
|
||||||
|
ls -la "$RUNNER_TEMP/nightly-releases"
|
||||||
|
|
||||||
|
# ◆ AND THE LINUX NIGHTLY, WHICH IS A PACKAGE RATHER THAN A FEED.
|
||||||
|
#
|
||||||
|
# Everything above this step is Velopack: an installer, an index file and a client that polls it.
|
||||||
|
# This one is an RPM, and the difference is not a gap in the work. VelopackUpdateChannel refuses
|
||||||
|
# every platform that is not Windows or macOS — deliberately, and its comment says why — so a
|
||||||
|
# Linux build has no self-updater to give a channel to. What it has instead is dnf, which is a
|
||||||
|
# mechanism the user already trusts and already controls, and build/linux/dodossh.spec argues
|
||||||
|
# that at length.
|
||||||
|
#
|
||||||
|
# So there is no rolling *feed* here, only a rolling *file*: the newest nightly RPM sits on the
|
||||||
|
# same release as the Windows nightly and somebody who wants it downloads it. That is a worse
|
||||||
|
# deal than the Windows nightly gets and it is the honest one — the alternative is a package
|
||||||
|
# repository, which is a deployment this project does not have.
|
||||||
|
#
|
||||||
|
# DodoChannel=nightly for the same reason the win-x64 publish above sets it: it is what
|
||||||
|
# ClientPaths reads to suffix the profile directory, so the nightly RPM and a release RPM
|
||||||
|
# installed beside it share no cache, no outbox and no device key. The package name, the tree
|
||||||
|
# under /opt and the desktop entry are the packaging half of that separation and are the
|
||||||
|
# script's `--channel nightly` — see the note there for why it is four things and not one.
|
||||||
|
- name: publish and package the linux nightly
|
||||||
|
id: linuxpack
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||||
|
--configuration Release --runtime linux-x64 --self-contained true \
|
||||||
|
-p:RestoreLockedMode=false \
|
||||||
|
-p:DodoChannel=nightly \
|
||||||
|
-p:MinVerVersionOverride="$VERSION" \
|
||||||
|
--output "$RUNNER_TEMP/nightly-linux-x64"
|
||||||
|
|
||||||
|
scripts/ci-linux-rpm.sh \
|
||||||
|
--payload "$RUNNER_TEMP/nightly-linux-x64" \
|
||||||
|
--version "$VERSION" \
|
||||||
|
--channel nightly \
|
||||||
|
--out "$RUNNER_TEMP/nightly-rpm"
|
||||||
|
|
||||||
|
# ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
|
||||||
|
#
|
||||||
|
# Whoever can write a release here can put a build on every nightly desktop, because Velopack fetches
|
||||||
|
# from this feed and applies what it finds without verifying a signature. That is the same capability
|
||||||
|
# as a signing key reached through a different door, and ADR 0013 rule 3 keeps it off runners.
|
||||||
|
#
|
||||||
|
# It is acceptable here for the reason the android nightly gives, and only for that reason: this is
|
||||||
|
# not that channel. A nightly is a separate application with its own pack id, its own install
|
||||||
|
# directory and its own profile, and it cannot update the build anybody is trusting with their
|
||||||
|
# credentials — the release channel reads a different index and refuses prereleases, so it cannot
|
||||||
|
# even see this one. Anybody installing a nightly is trusting everyone who can write to this
|
||||||
|
# repository, which is a thing to know rather than a thing to discover; the README says so.
|
||||||
|
#
|
||||||
|
# ◆ DELETED AND RECREATED RATHER THAN ADDED TO.
|
||||||
|
#
|
||||||
|
# A rolling channel has to keep exactly one build, and every asset here is version-named, so merging
|
||||||
|
# would grow the release by a hundred and twenty megabytes per push until the forge said no. There is
|
||||||
|
# no atomic form of this in the API, so the shape with the fewest states is to remove both the
|
||||||
|
# release and its tag and make them again. The window where no nightly exists is a few seconds, and
|
||||||
|
# the client's answer to it is the same as to an unreachable forge: the timer swallows it and tries
|
||||||
|
# later; a pressed CHECK NOW says so.
|
||||||
|
- name: publish the nightly release
|
||||||
|
env:
|
||||||
|
FORGE: https://git.dodotech.cloud
|
||||||
|
REPO: DodoTech-Public/DodoSSH
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
TAG: nightly-desktop
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${TOKEN:-}" ]; then
|
||||||
|
echo "No token, so the nightly was built and not published." >&2
|
||||||
|
echo "GITHUB_TOKEN is provided by the runner; an empty one means Actions is configured" >&2
|
||||||
|
echo "without it, and the job's contents: write permission is what asks for it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
api="$FORGE/api/v1/repos/$REPO"
|
||||||
|
auth="Authorization: token $TOKEN"
|
||||||
|
|
||||||
|
# The first "id" with digits after it, for the reason the android job records at length: a
|
||||||
|
# greedy .* walks past the release's own id to the author's, which is -2, and yields nothing.
|
||||||
|
release_id() {
|
||||||
|
grep -oE '"id":[0-9]+' | head -1 | cut -d: -f2
|
||||||
|
}
|
||||||
|
|
||||||
|
existing="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG" 2>/dev/null || true)"
|
||||||
|
if [ -n "$existing" ]; then
|
||||||
|
id="$(printf '%s' "$existing" | release_id)"
|
||||||
|
if [ -n "$id" ]; then
|
||||||
|
echo "Removing the previous $TAG release $id"
|
||||||
|
curl -fsS -X DELETE -H "$auth" "$api/releases/$id" >/dev/null || true
|
||||||
|
else
|
||||||
|
echo "A $TAG release exists and its id could not be read:" >&2
|
||||||
|
printf '%s\n' "$existing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# vpk rather than curl, unlike the android job above, and the difference is what is being
|
||||||
|
# uploaded. An APK is one file the client is told about by a manifest this repository writes; a
|
||||||
|
# Velopack release is a set of files plus an index whose format is Velopack's own. Writing that
|
||||||
|
# index by hand would be reimplementing the tool that is already here.
|
||||||
|
#
|
||||||
|
# --pre is load-bearing twice over. It keeps this out of the release channel, which refuses
|
||||||
|
# prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
|
||||||
|
# channel reads: a desktop nightly published as a stable release would become the newest release
|
||||||
|
# in this repository and every phone on the release channel would start failing its update check
|
||||||
|
# against a release carrying no android manifest.
|
||||||
|
dotnet vpk upload gitea \
|
||||||
|
--skip-updates \
|
||||||
|
--repoUrl "$FORGE/$REPO" \
|
||||||
|
--token "$TOKEN" \
|
||||||
|
--outputDir "$RUNNER_TEMP/nightly-releases" \
|
||||||
|
--channel win-nightly \
|
||||||
|
--tag "$TAG" \
|
||||||
|
--releaseName "Nightly desktop $VERSION" \
|
||||||
|
--targetCommitish "$GITHUB_SHA" \
|
||||||
|
--pre \
|
||||||
|
--publish
|
||||||
|
|
||||||
|
# Asked for back rather than assumed, and the android job's history is why: it once created a
|
||||||
|
# release, uploaded nothing, and reported success for every upload it never made. A nightly
|
||||||
|
# desktop feed that exists and carries no index is a client that checks, finds nothing, and
|
||||||
|
# reports itself up to date forever.
|
||||||
|
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
|
||||||
|
|
||||||
|
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
|
||||||
|
# grep without -q, which has the same exit status and reads to the end of its input. A
|
||||||
|
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
|
||||||
|
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
|
||||||
|
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||||
|
echo "The release was created but $name is not on it:" >&2
|
||||||
|
printf '%s\n' "$published" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ◆ THE RPM GOES ONTO THE RELEASE vpk JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
|
||||||
|
#
|
||||||
|
# One rolling release carrying both heads, because they are one nightly: the same commit,
|
||||||
|
# the same version, and one page a person looks at to find out what is on main. A second tag
|
||||||
|
# would double the delete-and-recreate dance this step already does, and would let the two
|
||||||
|
# drift apart on a run where one of them failed.
|
||||||
|
#
|
||||||
|
# It has to happen here, after `vpk upload`, and not in a step before it. vpk deletes and
|
||||||
|
# recreates this release, so an RPM uploaded first would be attached to something that no
|
||||||
|
# longer exists by the time the job ends — silently, because the delete succeeds.
|
||||||
|
#
|
||||||
|
# curl rather than vpk, unlike the assets above, for the reason the android job gives about
|
||||||
|
# its APK: this is one file the release simply carries. There is no index to write and no
|
||||||
|
# format that belongs to a tool.
|
||||||
|
release="$(printf '%s' "$published" | release_id)"
|
||||||
|
if [ -z "$release" ]; then
|
||||||
|
echo "The release was published and its id could not be read back:" >&2
|
||||||
|
printf '%s\n' "$published" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ◆ THE PACKAGE FIRST AND THE MANIFEST LAST, WHICH IS THE ORDER THE CLIENT DEPENDS ON.
|
||||||
|
#
|
||||||
|
# LinuxUpdateChannel reads linux-<channel>.json and then fetches what it names, exactly as the
|
||||||
|
# phone reads android-nightly.json. A manifest published before its package is a window in
|
||||||
|
# which every installed client is told to download something that is not there yet — and the
|
||||||
|
# client's answer to a missing asset is to throw, which a pressed CHECK NOW reports as a
|
||||||
|
# failure. Same rule, same reason, and the android job's comment says it first.
|
||||||
|
rpm='${{ steps.linuxpack.outputs.rpm }}'
|
||||||
|
manifest='${{ steps.linuxpack.outputs.manifest }}'
|
||||||
|
|
||||||
|
linuxNames=""
|
||||||
|
for file in "$rpm" "$manifest"; do
|
||||||
|
name="$(basename "$file")"
|
||||||
|
echo "Uploading $name ($(wc -c < "$file") bytes)"
|
||||||
|
curl -fsS -X POST -H "$auth" \
|
||||||
|
-F "attachment=@$file" \
|
||||||
|
"$api/releases/$release/assets?name=$name" >/dev/null
|
||||||
|
linuxNames="$linuxNames $name"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Asked for back, exactly as the Velopack assets above are and for the same history: the
|
||||||
|
# android job once created a release, uploaded nothing, and reported success for every
|
||||||
|
# upload it never made.
|
||||||
|
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||||
|
for name in $linuxNames; do
|
||||||
|
# grep without -q; see the sibling loops above and scripts/ci-linux-rpm.sh.
|
||||||
|
if ! printf '%s' "$withRpm" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||||
|
echo "The release was created but $name is not on it:" >&2
|
||||||
|
printf '%s\n' "$withRpm" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Published nightly desktop $VERSION as $TAG, with$linuxNames"
|
||||||
|
|
||||||
image:
|
image:
|
||||||
name: api image
|
name: api image
|
||||||
|
# See the build job. A cold layer cache is the slow case here.
|
||||||
|
timeout-minutes: 40
|
||||||
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
||||||
# clock on every main commit and buys the thing worth having: no image reaches the
|
# clock on every main commit and buys the thing worth having: no image reaches the
|
||||||
# registry from a commit whose tests were red. An image is not a build artefact anyone
|
# registry from a commit whose tests were red. An image is not a build artefact anyone
|
||||||
@@ -1067,9 +1598,10 @@ jobs:
|
|||||||
if: always() && github.event_name != 'pull_request'
|
if: always() && github.event_name != 'pull_request'
|
||||||
run: docker logout registry-docker.dodotech.cloud
|
run: docker logout registry-docker.dodotech.cloud
|
||||||
|
|
||||||
# There is no job here that publishes the desktop client, and there is not going to be one. It is built
|
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
|
||||||
# and packaged here — the two steps at the end of the build job — and what is withheld is only the
|
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
||||||
# upload.
|
# install directory and profile — and the distance between those two sentences is the whole design. What
|
||||||
|
# the build job does for the release channel is prove it still builds and packs; the upload is withheld.
|
||||||
#
|
#
|
||||||
# ◆ ONE REASON, WHERE THIS ONCE CLAIMED TWO, AND THE SECOND WAS NOT TRUE.
|
# ◆ ONE REASON, WHERE THIS ONCE CLAIMED TWO, AND THE SECOND WAS NOT TRUE.
|
||||||
#
|
#
|
||||||
|
|||||||
+8
-1
@@ -87,9 +87,16 @@ Releases/
|
|||||||
*.pfx
|
*.pfx
|
||||||
*.p12
|
*.p12
|
||||||
*.snk
|
*.snk
|
||||||
|
# Signing keystores, with one exception that is tracked deliberately. build/nightly.keystore is
|
||||||
|
# committed in the open on purpose — see ADR 0014 for why a key everybody holds is what makes the
|
||||||
|
# nightly channel safe to run in CI — and a file already tracked is unaffected by an ignore rule
|
||||||
|
# anyway. The negation is here so that reading this list does not suggest the tracked one is an
|
||||||
|
# oversight. The *release* keystore is a real secret under ADR 0011 rule 1 and must never land here.
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
!build/nightly.keystore
|
||||||
appsettings.Local.json
|
appsettings.Local.json
|
||||||
appsettings.*.Local.json
|
appsettings.*.Local.json
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!deploy/.env.example
|
|
||||||
secrets/
|
secrets/
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE TRIMMER'S VERSION IS PINNED HERE BECAUSE OTHERWISE THE LOCK FILES ARE NOT LOCKED.
|
||||||
|
|
||||||
|
Microsoft.NET.ILLink.Tasks is not referenced by anything in this repository. The SDK adds it
|
||||||
|
on its own to any project that sets IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
|
||||||
|
DodoSSH.Crypto do, and the Android head gets it from trimming being on by default there — and
|
||||||
|
the version it asks for is whatever the running SDK happens to bundle. That version lives in
|
||||||
|
the SDK's own Microsoft.NETCoreSdk.BundledVersions.props, as a KnownILLinkPack item.
|
||||||
|
|
||||||
|
Which makes it a dependency whose version is a property of the toolchain rather than of this
|
||||||
|
repository, and that is the whole problem: packages.lock.json records it as a Direct reference
|
||||||
|
with a requested range, so the lock file silently means "whichever SDK last ran a restore".
|
||||||
|
global.json says rollForward: latestMinor, so CI's setup-dotnet installs the newest 10.x SDK
|
||||||
|
that exists on the day it runs. The moment .NET ships a servicing release, CI's SDK asks for a
|
||||||
|
version the committed lock files do not have, and the locked-mode restore in ci.yml fails with
|
||||||
|
NU1004 before a single file is compiled.
|
||||||
|
|
||||||
|
That is not hypothetical. It closed the whole pipeline: main's run 125 and every open pull
|
||||||
|
request went red together, on
|
||||||
|
|
||||||
|
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||||
|
from [10.0.10, ) to [10.0.11, ).
|
||||||
|
|
||||||
|
with nothing in any of those commits touching a package. .NET had shipped SDK 10.0.400, which
|
||||||
|
bundles ILLink 10.0.11 where 10.0.302 bundled 10.0.10, and setup-dotnet installed it the next
|
||||||
|
time anything ran.
|
||||||
|
|
||||||
|
Worse than the outage is the shape of the repair without this pin. Regenerating the lock files
|
||||||
|
holds only until the next servicing release, and it cannot be done from a machine whose newest
|
||||||
|
SDK is older than the runner's: a restore on 10.0.302 writes 10.0.10 straight back and re-breaks
|
||||||
|
CI, so the recorded version becomes a fact about whoever ran restore last rather than about this
|
||||||
|
repository. That is exactly the state locking exists to prevent, and it is not a hypothetical
|
||||||
|
either — every SDK installed on the machine this pin was written on tops out at 10.0.302.
|
||||||
|
|
||||||
|
Pinning it makes the recorded version a decision this repository made, reviewable in a diff
|
||||||
|
like every other version in Directory.Packages.props, and identical on every machine whatever
|
||||||
|
SDK it has. Moving it is then a deliberate edit here plus a regenerated lock file, which is the
|
||||||
|
same ceremony any other dependency bump gets.
|
||||||
|
|
||||||
|
It is an Update on the SDK's item rather than a PackageVersion in Directory.Packages.props, and
|
||||||
|
it has to be: the reference is implicit, so the SDK supplies the version itself and central
|
||||||
|
package management never gets asked. ProcessFrameworkReferences reads @(KnownILLinkPack) when
|
||||||
|
it runs, which is why this lives in Directory.Build.targets — the item does not exist yet while
|
||||||
|
Directory.Build.props is being evaluated.
|
||||||
|
|
||||||
|
Keep this within a patch or two of the runtime the SDK ships. It is the trimming analyzer and
|
||||||
|
the ILLink task, so a small skew is harmless, but a version far behind the framework being
|
||||||
|
analysed is a real way to miss a trim warning.
|
||||||
|
-->
|
||||||
|
<Target Name="PinTheILLinkPackVersion" BeforeTargets="ProcessFrameworkReferences">
|
||||||
|
<!--
|
||||||
|
Inside a target, and not for tidiness. The SDK ships one KnownILLinkPack per target framework
|
||||||
|
and they all share the identity "Microsoft.NET.ILLink.Tasks", so the TargetFramework metadata
|
||||||
|
is the only thing telling net10.0's entry from net8.0's. A condition on %(...) is item
|
||||||
|
batching, which MSBuild permits in a target and rejects during evaluation with MSB4191 — so
|
||||||
|
an ItemGroup at the top of this file cannot express "only the net10.0 one" at all, and the
|
||||||
|
unconditioned Update it would have to become rewrites every framework's entry.
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<KnownILLinkPack Update="Microsoft.NET.ILLink.Tasks"
|
||||||
|
Condition="'%(TargetFramework)' == 'net10.0'"
|
||||||
|
ILLinkPackVersion="10.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -88,8 +88,12 @@
|
|||||||
-->
|
-->
|
||||||
<PackageVersion Include="NSec.Cryptography" Version="26.4.0" />
|
<PackageVersion Include="NSec.Cryptography" Version="26.4.0" />
|
||||||
<PackageVersion Include="libsodium" Version="1.0.22" />
|
<PackageVersion Include="libsodium" Version="1.0.22" />
|
||||||
<!-- Managed differential oracle for the crypto test suite only. -->
|
<!--
|
||||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
Managed differential oracle for the crypto test suite only. Also what SSH.NET pulls in,
|
||||||
|
so central transitive pinning makes this pin its floor too: 2.7.0 is what SSH.NET
|
||||||
|
2026.0.0 asks for, and pinning below that is a downgrade error rather than a preference.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup Label="Desktop client">
|
<ItemGroup Label="Desktop client">
|
||||||
<!--
|
<!--
|
||||||
@@ -98,8 +102,14 @@
|
|||||||
agent forwarding (needs an upstream change; de-scoped from v1) and being handed a
|
agent forwarding (needs an upstream change; de-scoped from v1) and being handed a
|
||||||
pre-connected Stream — it performs its own socket connect, which is why the relay and
|
pre-connected Stream — it performs its own socket connect, which is why the relay and
|
||||||
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
|
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
|
||||||
|
|
||||||
|
2026.0.0 is the fix for GHSA-q939-rpr3-3284, a path traversal in ScpClient.Download's
|
||||||
|
recursive mode that trusts server-supplied names (everything up to and including
|
||||||
|
2025.1.0 is affected). Nothing here uses ScpClient — transfers go through SftpClient —
|
||||||
|
so this was an upgrade on principle rather than an exposure, and the release notes
|
||||||
|
list no breaking changes over 2025.1.0.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
|
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
|
||||||
<!--
|
<!--
|
||||||
The S3 client, for buckets as a remote in the file browser. First-party, Apache-2.0, and
|
The S3 client, for buckets as a remote in the file browser. First-party, Apache-2.0, and
|
||||||
managed only — no native assets — which is the bar this file sets for anything that gets
|
managed only — no native assets — which is the bar this file sets for anything that gets
|
||||||
@@ -163,6 +173,28 @@
|
|||||||
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
|
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="Velopack" Version="1.2.0" />
|
<PackageVersion Include="Velopack" Version="1.2.0" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
|
||||||
|
|
||||||
|
Windows needs nothing here: CNG is in the framework and the Platform Crypto Provider is the TPM
|
||||||
|
behind it, and macOS reaches the Secure Enclave through the Security framework that
|
||||||
|
MacSymbols.cs binds by hand. Linux has neither — the platform's own stack is tpm2-tss, a C
|
||||||
|
library with a large surface — so this is the one head that needs a package to talk to its own
|
||||||
|
hardware.
|
||||||
|
|
||||||
|
Chosen over P/Invoking libtss2-esys for two reasons that both hold at the same time. It is
|
||||||
|
managed all the way down, so there is no native dependency to add to the RPM and nothing to
|
||||||
|
resolve at load time; and it works against a software TPM over a socket exactly as it does
|
||||||
|
against /dev/tpmrm0, which is what makes LinuxDeviceKeyStoreTests able to run the whole seal,
|
||||||
|
unseal and lockout path unattended. Neither of the other two device key stores can be tested at
|
||||||
|
all — see WindowsDeviceKeyStoreTests for the ten minutes that took to find out.
|
||||||
|
|
||||||
|
It carries a runtimes/linux-x64 asset, like SkiaSharp and libsodium, so the RID-specific publish
|
||||||
|
picks it up and the RID-free locked restore does not. See the RuntimeIdentifiers note in
|
||||||
|
DodoSSH.Client.App.csproj for why that distinction is load-bearing here.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="Microsoft.TSS" Version="2.1.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup Label="Versioning">
|
<ItemGroup Label="Versioning">
|
||||||
<!--
|
<!--
|
||||||
@@ -211,5 +243,20 @@
|
|||||||
mistakes that cause real authorization holes.
|
mistakes that cause real authorization holes.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="WireMock.Net" Version="2.13.0" />
|
<PackageVersion Include="WireMock.Net" Version="2.13.0" />
|
||||||
|
<!--
|
||||||
|
A JavaScript interpreter, in managed code, so that terminal.js can be tested as the file that
|
||||||
|
ships rather than as a transcription of it into C#. Test-only and referenced by exactly one
|
||||||
|
project; nothing in src depends on it.
|
||||||
|
|
||||||
|
Chosen over shelling out to node, which is the obvious alternative and needs node present
|
||||||
|
wherever the suite runs — CI installs one already, but a test that only runs when a separate
|
||||||
|
command is remembered is a test that stops being run. Chosen over a headless browser for the
|
||||||
|
same reason several times over.
|
||||||
|
|
||||||
|
What it does not buy: Jint is not Chromium, so this proves the page's own logic and nothing
|
||||||
|
about how WebView2 or Android's WebView behave. That line is drawn in RendererPage's remark and
|
||||||
|
picked up by docs/manual-checks.md 1.10 and 11.12a.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="Jint" Version="4.16.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -69,9 +69,10 @@ src/
|
|||||||
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
||||||
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
||||||
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
|
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
|
||||||
DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked
|
DodoSSH.Client.Import reading ~/.ssh/config and the keys it names when asked; reading a Hetzner
|
||||||
|
Cloud project over its API
|
||||||
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
|
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
|
||||||
DodoSSH.Client.App the desktop head: its views, and its Windows integration
|
DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
|
||||||
DodoSSH.Client.Android the phone head: its views, and its Android integration
|
DodoSSH.Client.Android the phone head: its views, and its Android integration
|
||||||
tests/ one test project per source project
|
tests/ one test project per source project
|
||||||
docs/adr/ architecture decision records
|
docs/adr/ architecture decision records
|
||||||
@@ -80,6 +81,10 @@ 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
|
||||||
|
ci-linux-rpm.sh — builds the Linux .rpm, then interrogates it
|
||||||
|
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
|
||||||
|
build/linux/ the RPM spec the Linux package is built from
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
||||||
@@ -152,8 +157,129 @@ 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.
|
||||||
|
|
||||||
|
## Installing on Linux
|
||||||
|
|
||||||
|
An `.rpm` for x86_64, and the one platform where you get it from `main` rather than from a release — see
|
||||||
|
[the nightly build](#the-nightly-desktop-build) below, which is where the Linux package is published today.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install ./dodossh-nightly-*.x86_64.rpm
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything above about where a client may come from applies unchanged. Four things differ, and the first
|
||||||
|
two are the ones to read before installing.
|
||||||
|
|
||||||
|
**It checks for updates and it installs nothing.** The Windows and macOS builds carry Velopack and replace
|
||||||
|
their own files; this one never does. It checks the project's forge every six hours, and when there is a
|
||||||
|
newer build it downloads it, checks its digest, and hands the file to **PackageKit** — which raises the
|
||||||
|
usual "authentication is required to install software" prompt. You may say no, and nothing is replaced until
|
||||||
|
you say yes. Nothing restarts either: replacing files under a running process is ordinary here, so the build
|
||||||
|
you have keeps running until you close it.
|
||||||
|
|
||||||
|
That split is the point rather than a compromise. Installing software is your package manager's job and it
|
||||||
|
already answers to you; noticing that a build exists is the part `dnf` cannot do for a package that is not
|
||||||
|
in a repository. What DodoSSH must never become is a second thing that can put software on your machine on
|
||||||
|
somebody else's say-so, which is why the address it checks is a constant in the binary and can never be a
|
||||||
|
DodoSSH server — [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) rules 4 and 11.
|
||||||
|
|
||||||
|
The gap that remains: there is **no package repository**, so `dnf upgrade` alone will never bring you a
|
||||||
|
newer DodoSSH. If you turn the check off on PREFERENCES → UPDATES, nothing else will tell you.
|
||||||
|
|
||||||
|
**Your PIN can stand in for your passphrase, if this machine has a TPM you can reach.** The device key is
|
||||||
|
sealed into the TPM and released only against a PIN that **the TPM itself checks** — it counts wrong answers
|
||||||
|
and stops responding after a few, which is what makes four digits enough and what makes a stolen disk
|
||||||
|
useless. Two honest caveats, both in
|
||||||
|
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4:
|
||||||
|
|
||||||
|
- **It is weaker than Windows Hello or Touch ID**, and the difference does not show. Those draw their prompt
|
||||||
|
from inside the operating system; this one is a window DodoSSH draws, so something already running as you
|
||||||
|
could imitate it. It could imitate the passphrase screen too, which is worth far more — but the guarantee
|
||||||
|
is not the same and this page will not pretend it is.
|
||||||
|
- **The package grants your session access to the TPM, and you should know what that means.** Fedora leaves
|
||||||
|
`/dev/tpmrm0` readable only by the `tss` group, so this would otherwise need an administrator and a fresh
|
||||||
|
login. Instead the package installs a udev rule tagging the device `uaccess` — the same mechanism your
|
||||||
|
webcam and your FIDO key already use, which grants an ACL to **whoever is logged in at the machine** and
|
||||||
|
takes it away when they log out. Not over SSH, not to a user switched away on another VT, not to daemons.
|
||||||
|
|
||||||
|
The cost is not secrecy — TPM objects are protected by their own PINs — it is that anyone at the keyboard
|
||||||
|
can make failed attempts, and a TPM answers enough of those by locking out *everyone*. **If this machine
|
||||||
|
unlocks its disk with the TPM at boot**, that means somebody sitting at it could stop the next boot from
|
||||||
|
unlocking until the lockout recovers. If you would rather not have that, one command puts it back:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
|
||||||
|
```
|
||||||
|
|
||||||
|
DodoSSH then behaves as though the machine had no TPM: no PIN offered, passphrase every time.
|
||||||
|
|
||||||
|
See [docs/unlocking-without-the-passphrase.md](docs/unlocking-without-the-passphrase.md).
|
||||||
|
|
||||||
|
**It needs WebKitGTK, because the terminal is a WebView.** Specifically `webkit2gtk4.1`, which `dnf` pulls
|
||||||
|
in for you. Fedora 44 is the distribution the head has actually been run on. RHEL 9 and its rebuilds ship
|
||||||
|
the older 4.0 API line under a different library name and the package deliberately does not claim them —
|
||||||
|
`dnf` will refuse with *nothing provides libwebkit2gtk-4.1.so.0*, which is a better answer than installing
|
||||||
|
and drawing a blank terminal. This project does not ship desktop builds nobody has run, on any platform.
|
||||||
|
|
||||||
|
**Where things go.** The application at `/opt/DodoSSH`, launched as `dodossh` or from your menu; your vault
|
||||||
|
cache at `$XDG_DATA_HOME/dodossh`, usually `~/.local/share/dodossh`. Two different places on purpose:
|
||||||
|
`dnf remove` takes the first and never touches the second, so reinstalling asks for your passphrase rather
|
||||||
|
than starting over. Use **Sign out** inside the application to make the machine genuinely forget.
|
||||||
|
|
||||||
|
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
|
||||||
|
`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
|
||||||
|
not only that the runners are Linux.
|
||||||
|
|
||||||
|
### The nightly desktop build
|
||||||
|
|
||||||
|
There is a second Windows build, published by CI from `main` on every push, on the `nightly-desktop`
|
||||||
|
release. It installs **beside** the release build rather than over it — its own entry called *DodoSSH
|
||||||
|
Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\DodoSSH.Nightly` — so trying it
|
||||||
|
costs nothing you are relying on. It signs in separately and appears to your account as a new device,
|
||||||
|
because it genuinely is a second installation.
|
||||||
|
|
||||||
|
**The Linux `.rpm` is on the same release**, built from the same commit and versioned the same way, as
|
||||||
|
`dodossh-nightly`. It installs beside a release package for the same reasons and by the same four
|
||||||
|
separations — a different package name, a tree at `/opt/DodoSSH.Nightly`, a `dodossh-nightly` launcher with
|
||||||
|
its own menu entry, and a profile at `~/.local/share/dodossh.nightly`. Two RPMs owning different paths
|
||||||
|
cannot replace one another, so `dnf` will keep both rather than treating one as an upgrade of the other.
|
||||||
|
|
||||||
|
It is the only Linux package this project publishes at all today: there is no `v*`-tagged Linux release
|
||||||
|
yet, because no person has walked a Linux build through
|
||||||
|
[docs/manual-checks.md](docs/manual-checks.md). What CI proves on every push to `main` is that the release
|
||||||
|
package still *builds* — the same arrangement Windows and macOS have had since the beginning, where the
|
||||||
|
package is built on every commit and uploaded nowhere.
|
||||||
|
|
||||||
|
**Installing it means trusting everyone who can write a release on this repository, including CI.** The
|
||||||
|
updater applies what the feed serves without verifying a signature, so anyone who can change a workflow
|
||||||
|
file here can put a build on every nightly machine. That is an acceptable trade for a build you are trying
|
||||||
|
and not one for a build holding your infrastructure credentials, which is the entire reason the two
|
||||||
|
channels exist and cannot see each other. The release build refuses prereleases and reads a different feed,
|
||||||
|
so nothing published here can ever reach it. Same arrangement, and same reasoning, as the phone's nightly —
|
||||||
|
[ADR 0014](docs/adr/0014-android-updates.md), and [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md)
|
||||||
|
decision 9.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
@@ -407,8 +533,28 @@ The two vaults are encrypted under different keys, so a move is a re-seal into o
|
|||||||
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
|
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
|
||||||
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
|
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
|
||||||
What a move cannot do is reach a machine that has already synced the host, which is the same limit
|
What a move cannot do is reach a machine that has already synced the host, which is the same limit
|
||||||
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
|
everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the
|
||||||
to" picker on the Keychain screen and cannot be moved yet.
|
Keychain screen and cannot be moved yet.
|
||||||
|
|
||||||
|
**The move asks whether the key comes too**, because that is the half a host's move could not settle on its
|
||||||
|
own. A binding resolves across every vault you can read, so the moved host goes on working for *you* either
|
||||||
|
way — but the people you have just shared it with hold one vault's key, and a host whose key stayed in your
|
||||||
|
personal vault is one they cannot connect with. The tick box beside the picker is unticked, and stays that
|
||||||
|
way on purpose: moving a key into a team's vault hands it to everybody who holds that key, and a disclosure
|
||||||
|
is chosen rather than defaulted into. Under it is the count of everything else that authenticates with that
|
||||||
|
key, which is what makes the answer decidable — a key twenty machines use is a different decision from one
|
||||||
|
nothing else touches. Left unticked, the sentence afterwards names the key that is now outside the
|
||||||
|
destination. A key the host only *inherits* from its group counts too, and is written onto the host on the
|
||||||
|
way across: the group stays behind, so a host that arrived naming nothing would authenticate with nothing.
|
||||||
|
|
||||||
|
**A key or a password can also be moved on its own** — MOVE beside EDIT and DELETE on the Keychain screen,
|
||||||
|
on both heads, for keys and passwords only. It is the same re-seal and tombstone, and it takes a new id in
|
||||||
|
the destination, so **everything that named it is re-aimed at where it went**: every host bound to it and
|
||||||
|
every group lending it as a default, across every vault you can write to. Without that the move would be a
|
||||||
|
deletion with extra steps, since a host bound to something its vault no longer holds refuses to connect
|
||||||
|
rather than falling back to a typed password. Anything that cannot be rewritten here — an item from a newer
|
||||||
|
client, or one in a vault you can only read — is left naming the old item and is counted in the sentence
|
||||||
|
afterwards. The panel says what points at the key before you press it, not after.
|
||||||
|
|
||||||
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
|
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
|
||||||
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
|
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
|
||||||
@@ -727,6 +873,13 @@ keychain plus a terminal — and the spike that gates all of it.
|
|||||||
[Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
|
[Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
|
||||||
are consequences rather than choices.
|
are consequences rather than choices.
|
||||||
|
|
||||||
|
*Relay done:* a host with "Connect through the server relay" ticked is reached through the deployment,
|
||||||
|
for a machine this one cannot dial. The server issues a single-use, thirty-second ticket for that host
|
||||||
|
alone, resolves its address and checks it against a deny list with no configuration escape — loopback,
|
||||||
|
link-local, metadata endpoints — and then pipes raw TCP; the SSH session still ends on your machine, so
|
||||||
|
the relay sees ciphertext. It is off by default (`Relay:Enabled`), and it only helps when the server can
|
||||||
|
reach what you cannot. See [ADR 0004](docs/adr/0004-relay-authorization.md).
|
||||||
|
|
||||||
*Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary
|
*Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary
|
||||||
synced items — encrypted, merged and pushed like every other — and both are invisible until used: a
|
synced items — encrypted, merged and pushed like every other — and both are invisible until used: a
|
||||||
keychain with no groups draws the flat host list it always did. Two things about them are deliberate.
|
keychain with no groups draws the flat host list it always did. Two things about them are deliberate.
|
||||||
@@ -849,8 +1002,39 @@ 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).
|
||||||
|
|
||||||
|
**The Linux half checks, and the package manager installs.** An `.rpm` for x86_64, built and checked on
|
||||||
|
every push to `main` and published to the nightly release, installing to `/opt` with a menu entry and a
|
||||||
|
`dodossh` launcher. It carries no Velopack and replaces nothing itself: it notices a newer build on the
|
||||||
|
project's forge, downloads it, verifies its digest and hands the file to PackageKit, which asks before it
|
||||||
|
installs. Splitting it that way keeps the part that can put software on your machine where it already
|
||||||
|
was — with your package manager — while fixing the half `dnf` cannot do for a package that is in no
|
||||||
|
repository. The device key is sealed into the TPM behind a PIN **the TPM checks and counts failures
|
||||||
|
against**, which is what makes four digits enough. Reaching the TPM needs no administrator and no group:
|
||||||
|
the package installs a udev rule tagging `/dev/tpmrm0` with `uaccess`, so the ACL follows whoever is
|
||||||
|
logged in at the machine and goes away with them — the same mechanism a webcam uses. What that costs, and
|
||||||
|
the one symlink that undoes it, is in the rule's own comments. See
|
||||||
|
[ADR 0007](docs/adr/0007-device-key-protection.md) decision 4,
|
||||||
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 11 and
|
||||||
|
[Installing on Linux](#installing-on-linux).
|
||||||
|
|
||||||
|
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
|
||||||
|
certificate is the first release aimed at strangers), macOS on Intel, a `v*`-tagged Linux release and a
|
||||||
|
signed package repository to serve it from, and Phases 18 and 19 of the manual checks — neither the macOS
|
||||||
|
nor the Linux build has been walked through by a person. There is no macOS runner in CI at all. The Linux
|
||||||
|
package is verified as far as what it *says* about itself — CI builds it and interrogates its
|
||||||
|
dependencies, its file list and its modes, and installs nothing — and the two pieces of Linux behaviour
|
||||||
|
that are code rather than packaging do have tests: the update channel's version comparison and digest
|
||||||
|
check run in CI, and the TPM store's seal, unseal, wrong-PIN and lockout paths run against a software TPM
|
||||||
|
anybody can start with one `docker run`. What no runner covers is `/dev/tpmrm0`, PackageKit and polkit,
|
||||||
|
which is what Phase 19 is for.
|
||||||
- **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,54 @@
|
|||||||
|
# Lets the person sitting at this machine use its TPM, without joining a group.
|
||||||
|
#
|
||||||
|
# ◆ WHAT THIS CHANGES, AND WHAT IT DELIBERATELY DOES NOT.
|
||||||
|
#
|
||||||
|
# Fedora's 60-tpm-udev.rules leaves /dev/tpmrm0 as root:tss mode 0660, so using it means being in
|
||||||
|
# the tss group — which means an administrator, a usermod and a fresh login before DodoSSH will
|
||||||
|
# offer to seal a device key. This adds the uaccess tag, and nothing else: the mode and the group
|
||||||
|
# are untouched, so everything that worked before still works and an ACL is layered on top.
|
||||||
|
#
|
||||||
|
# uaccess is systemd's own mechanism for exactly this question and is what /dev/dri, webcams,
|
||||||
|
# scanners and FIDO keys already use. logind grants a POSIX ACL to the user of the *active local
|
||||||
|
# session* and takes it away again when that session ends. So:
|
||||||
|
#
|
||||||
|
# - somebody logged in at the physical machine gets access;
|
||||||
|
# - somebody logged in over SSH does not;
|
||||||
|
# - a user switched away to another VT does not;
|
||||||
|
# - a system daemon running as its own user does not.
|
||||||
|
#
|
||||||
|
# That is a much narrower grant than adding a person to tss, which is permanent, applies over SSH,
|
||||||
|
# and applies whether or not they are the one at the keyboard.
|
||||||
|
#
|
||||||
|
# ◆ tpmrm AND NOT tpm, WHICH IS THE WHOLE REASON THIS IS DEFENSIBLE.
|
||||||
|
#
|
||||||
|
# /dev/tpm0 is the raw device and access to it is exclusive — two users cannot share it, and a
|
||||||
|
# process holding it can leave the chip in a state that breaks everything else. /dev/tpmrm0 is the
|
||||||
|
# kernel's resource manager: it virtualises handles, swaps contexts and isolates callers from each
|
||||||
|
# other, and it exists *because* the TPM is meant to be usable by more than one unprivileged
|
||||||
|
# program. Granting a seat's user the manager is the use it was built for. Granting the raw device
|
||||||
|
# would not be, and this rule never names it.
|
||||||
|
#
|
||||||
|
# ◆ THE COST, WHICH IS REAL AND IS NOT ABOUT SECRECY.
|
||||||
|
#
|
||||||
|
# TPM keys are protected by their own auth values, so this grants nobody the ability to read
|
||||||
|
# anybody else's sealed data. What it does grant is the ability to make failed authorisation
|
||||||
|
# attempts, and a TPM answers those by counting them and eventually refusing everyone — the same
|
||||||
|
# dictionary-attack lockout DodoSSH relies on to make a four-digit PIN safe.
|
||||||
|
#
|
||||||
|
# On a machine whose disk is unlocked by the TPM at boot (systemd-cryptenroll, Clevis), a local user
|
||||||
|
# who trips that lockout can stop the next boot from unlocking until it recovers. That is a denial
|
||||||
|
# of service against the machine's owner by somebody already sitting at the machine, and it is worth
|
||||||
|
# knowing rather than discovering. An administrator who does not accept it can mask this file:
|
||||||
|
#
|
||||||
|
# sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
|
||||||
|
#
|
||||||
|
# which is the standard override and survives upgrades. DodoSSH then behaves exactly as it did
|
||||||
|
# before this file existed: no device key is offered, and unlocking asks for the passphrase.
|
||||||
|
#
|
||||||
|
# Numbered 71 because 73-seat-late.rules is what turns the tag into an ACL, and 60-tpm-udev.rules
|
||||||
|
# is what creates the device node's ownership in the first place. Anything outside that gap either
|
||||||
|
# never runs or runs too late to matter.
|
||||||
|
|
||||||
|
ACTION=="remove", GOTO="dodossh_tpm_end"
|
||||||
|
SUBSYSTEM=="tpmrm", KERNEL=="tpmrm[0-9]*", TAG+="uaccess"
|
||||||
|
LABEL="dodossh_tpm_end"
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# ==================================================================================================
|
||||||
|
# The Linux desktop head, as an RPM.
|
||||||
|
#
|
||||||
|
# Built by scripts/ci-linux-rpm.sh, which is what supplies every dodo_* macro below and what checks
|
||||||
|
# the result. Nothing here is invoked by hand; `rpmbuild -bb` on this file with no --define set will
|
||||||
|
# fail on the Version line, which is the intended way round.
|
||||||
|
#
|
||||||
|
# ◆ WHY THIS IS AN RPM AND NOT A VELOPACK RELEASE, WHICH IS THE FIRST QUESTION.
|
||||||
|
#
|
||||||
|
# Both desktop platforms that ship today are Velopack: the client polls the project's forge, fetches
|
||||||
|
# a newer build and swaps itself over. ADR 0013 rule 4 is what makes that safe to hold — the feed
|
||||||
|
# address is a const, so no operator can answer "is there a newer version" on the user's behalf.
|
||||||
|
#
|
||||||
|
# This package replaces nothing and installs nothing, and that is deliberate rather than unfinished.
|
||||||
|
# On Linux the package manager is already an updater the user trusts, audits and controls, so putting
|
||||||
|
# a second one inside the application — answering to this project instead — would be building the
|
||||||
|
# capability ADR 0013 rule 4 spends a page making unsettable, on the one platform where nobody had to.
|
||||||
|
#
|
||||||
|
# What the client does do is the half a package manager cannot: LinuxUpdateChannel notices that a
|
||||||
|
# newer build exists on the project's forge, fetches it, checks its digest, and hands the file to
|
||||||
|
# PackageKit — which raises polkit and asks. The user may say no, and nothing is replaced until they
|
||||||
|
# say yes. See ADR 0013 rule 11.
|
||||||
|
#
|
||||||
|
# The consequence to know: discovery is worse here than on the other two desktops, because there is
|
||||||
|
# no package repository to subscribe to and the in-app check is what stands in for one.
|
||||||
|
# ==================================================================================================
|
||||||
|
|
||||||
|
# ◆ THE THREE THINGS THAT MUST BE TURNED OFF, AND WHAT EACH ONE WOULD DO.
|
||||||
|
#
|
||||||
|
# __os_install_post is the chain of brp-* scripts rpm runs over the buildroot: strip, strip-static,
|
||||||
|
# compress, elfperms. Every one of them is wrong here. The payload is a published .NET tree whose
|
||||||
|
# managed PDBs ADR 0013 rule 6 keeps on purpose — a stack with file names and line numbers is this
|
||||||
|
# project's entire diagnostic channel — and brp-strip would take the native symbols with them for a
|
||||||
|
# saving nobody asked for. It also runs the host's binutils over glibc binaries on a build host that
|
||||||
|
# is musl, which is a class of breakage with no good failure message.
|
||||||
|
#
|
||||||
|
# debug_package would then try to build a -debuginfo subpackage out of what strip extracted, from
|
||||||
|
# third-party binaries nobody here will ever step through.
|
||||||
|
#
|
||||||
|
# _build_id_links is the one that surprises people, because turning the other two off does not turn
|
||||||
|
# it off: rpm still writes /usr/lib/.build-id/<xx>/<hash> symlinks for every ELF file in the payload.
|
||||||
|
# For a bundled tree that means dozens of links in a system directory this package has no business
|
||||||
|
# owning, and two files with the same build id — which a .NET publish does produce — make it a build
|
||||||
|
# *warning* about duplicates rather than an error, so it goes unnoticed.
|
||||||
|
%global __os_install_post %{nil}
|
||||||
|
%global debug_package %{nil}
|
||||||
|
%global _build_id_links none
|
||||||
|
|
||||||
|
# ◆ AUTO-REQUIRES STAYS ON. AUTO-PROVIDES DOES NOT. THE ASYMMETRY IS THE WHOLE POINT.
|
||||||
|
#
|
||||||
|
# rpm's elfdeps reads DT_NEEDED and the symbol-version table out of every ELF file in the buildroot,
|
||||||
|
# and the requires half of that is worth much more than a hand-written list: it produces
|
||||||
|
# `libc.so.6(GLIBC_2.38)(64bit)` rather than `glibc`, so the package refuses to install on a host
|
||||||
|
# whose glibc is too old instead of installing and dying at the first launch. A hand list cannot
|
||||||
|
# express that and would go stale the first time a dependency changed underneath it.
|
||||||
|
#
|
||||||
|
# It reads ELF directly rather than by loading anything, so it does this correctly for x86_64 glibc
|
||||||
|
# binaries on a musl build host — which is what lets this package be built on the same Alpine runner
|
||||||
|
# as everything else in ci.yml.
|
||||||
|
#
|
||||||
|
# The provides half is the opposite. Everything under /opt here is a *bundled* copy: libSkiaSharp,
|
||||||
|
# libcoreclr, libe_sqlite3 and the rest belong to this application and to nothing else. Left on,
|
||||||
|
# this package would advertise `libcoreclr.so()(64bit)` to the whole system, and some other package's
|
||||||
|
# dependency could then be satisfied by a private copy inside a directory `dnf remove dodossh` takes
|
||||||
|
# away. Excluding the tree by path is the standard shape for that and costs nothing, because nothing
|
||||||
|
# outside this package is meant to link against anything inside it.
|
||||||
|
#
|
||||||
|
# ◆ WHICH IS WHY THE REQUIRES SIDE NEEDS AN EXCLUSION TOO, AND WHY IT IS COMPUTED.
|
||||||
|
#
|
||||||
|
# The two halves are not independent. A bundled library with a DT_NEEDED on another bundled library
|
||||||
|
# produces a requirement for a soname this package carries and — after the exclusion above — no
|
||||||
|
# longer advertises, which no host on earth can satisfy. It is not hypothetical and it is not rare:
|
||||||
|
# .NET's own libmscordbi.so links libmscordaccore.so, so the very first realistic payload this was
|
||||||
|
# tried against produced a package `dnf install` refuses outright.
|
||||||
|
#
|
||||||
|
# dodo_bundled is the alternation of every lib*.so* filename in the payload, worked out by
|
||||||
|
# scripts/ci-linux-rpm.sh from the tree it was handed rather than listed here. A list in this file
|
||||||
|
# would be a list of Microsoft's and SkiaSharp's internal file names, kept in step by hand, and its
|
||||||
|
# failure mode is the uninstallable package above.
|
||||||
|
#
|
||||||
|
# liblttng-ust is excluded on its own terms and not because it is bundled — it is not. It is what
|
||||||
|
# libcoreclrtraceptprovider.so links, and that file exists so that CoreCLR can emit LTTng trace
|
||||||
|
# events if a host has the tracing stack. Nothing here asks it to. The file is kept and the
|
||||||
|
# requirement is dropped, which is the arrangement that works both ways round: where liblttng-ust is
|
||||||
|
# present the dlopen succeeds and tracing is available, and where it is absent CoreCLR carries on, as
|
||||||
|
# it does on every machine that runs a .NET application without it. Requiring it instead would put
|
||||||
|
# the package out of reach on RHEL, where lttng-ust is in EPEL rather than in the base repositories —
|
||||||
|
# an install failure on a supported distribution, in exchange for a tracing integration this
|
||||||
|
# application does not use.
|
||||||
|
#
|
||||||
|
# scripts/ci-linux-rpm.sh checks the finished package against its own file list afterwards, because
|
||||||
|
# an exclusion that silently stopped matching would put the uninstallable package back with no
|
||||||
|
# warning anywhere.
|
||||||
|
%global __provides_exclude_from ^/opt/.*$
|
||||||
|
%global __requires_exclude ^(liblttng-ust\.so|%{dodo_bundled})
|
||||||
|
|
||||||
|
Name: %{dodo_name}
|
||||||
|
Version: %{dodo_version}
|
||||||
|
Release: %{dodo_release}
|
||||||
|
Summary: SSH client with an end-to-end encrypted credential vault
|
||||||
|
License: MIT
|
||||||
|
URL: https://git.dodotech.cloud/DodoTech-Public/DodoSSH
|
||||||
|
BuildArch: x86_64
|
||||||
|
|
||||||
|
# ◆ THE DEPENDENCIES elfdeps CANNOT SEE, WHICH ARE THE ONES THE PRODUCT IS MADE OF.
|
||||||
|
#
|
||||||
|
# Everything below is reached through dlopen, by a name composed at runtime, so it appears in no
|
||||||
|
# DT_NEEDED entry anywhere in the payload. Left to the automatic scan the package would install
|
||||||
|
# cleanly on a host with none of it and fail at the first window.
|
||||||
|
#
|
||||||
|
# GTK and WebKitGTK are the terminal. Avalonia.Controls.WebView P/Invokes a bare `libwebkit2gtk` and
|
||||||
|
# a bare `libgtk` and resolves them itself, and MainWindow.OnTerminalEnvironmentRequested documents
|
||||||
|
# the backend it was measured against: WebKitGTK 2.52.5 on Fedora 44, which is the 4.1 API line — the
|
||||||
|
# assembly's one fully-spelled soname, libjavascriptcoregtk-4.1.so.0, is that line's other half.
|
||||||
|
#
|
||||||
|
# ◆ THE 4.0 LINE IS DELIBERATELY NOT CLAIMED, AND THE COST OF THAT IS KNOWN.
|
||||||
|
#
|
||||||
|
# The control's own diagnostic says "webkit2gtk 4.0+", so a dependency permitting either line is
|
||||||
|
# tempting and was written first. RHEL 9 and its rebuilds are what it would buy: they ship
|
||||||
|
# webkit2gtk3, which is the 4.0 API under the soname libwebkit2gtk-4.0.so.37 — not .so.0, which is
|
||||||
|
# what makes this a thing to look up rather than guess. Requiring only 4.1 means `dnf install` on
|
||||||
|
# AlmaLinux 9 refuses with "nothing provides libwebkit2gtk-4.1.so.0", by design.
|
||||||
|
#
|
||||||
|
# It is refused because nobody has run it. This project does not ship an Intel macOS package for the
|
||||||
|
# same reason and says so in ADR 0013 decision 10 — the engineering is small and the verification is
|
||||||
|
# the scarce thing — and an install that succeeds and then draws a blank terminal is a worse answer
|
||||||
|
# than one that refuses in a sentence naming the missing library. The day somebody runs the head on
|
||||||
|
# a 4.0 host, this becomes a rich `or` and a line in the README.
|
||||||
|
#
|
||||||
|
# libX11 is Avalonia's own backend, P/Invoked the same way. On Wayland this is satisfied by XWayland,
|
||||||
|
# which every desktop shipping this package has.
|
||||||
|
#
|
||||||
|
# libicu is the one that looks removable and is not. DodoSSH.Client.App sets InvariantGlobalization
|
||||||
|
# to false — deliberately, and the csproj says why: this process prints timestamps and host names to
|
||||||
|
# a person who expects their own locale's conventions. A .NET process configured that way and given
|
||||||
|
# no ICU does not degrade to invariant, it refuses to start. The bare package name is the Fedora and
|
||||||
|
# RHEL spelling; it is the one name here that is a distribution's rather than a soname's, because ICU
|
||||||
|
# bumps its soname on every release and pinning one would make this package expire.
|
||||||
|
Requires: libwebkit2gtk-4.1.so.0()(64bit)
|
||||||
|
Requires: libgtk-3.so.0()(64bit)
|
||||||
|
Requires: libgdk-3.so.0()(64bit)
|
||||||
|
Requires: libgio-2.0.so.0()(64bit)
|
||||||
|
Requires: libglib-2.0.so.0()(64bit)
|
||||||
|
Requires: libgobject-2.0.so.0()(64bit)
|
||||||
|
Requires: libX11.so.6()(64bit)
|
||||||
|
Requires: libicu
|
||||||
|
|
||||||
|
# The size directories under /usr/share/icons/hicolor belong to hicolor-icon-theme and not to this
|
||||||
|
# package, which is the right way round — every application drops icons into them. Depending on the
|
||||||
|
# owner is how those directories come to exist at all on a minimal host, and how they get removed
|
||||||
|
# again when the last thing using them goes.
|
||||||
|
Requires: hicolor-icon-theme
|
||||||
|
|
||||||
|
# ◆ RECOMMENDED RATHER THAN REQUIRED, WHICH IS A JUDGEMENT ABOUT WHAT THE PACKAGE IS FOR.
|
||||||
|
#
|
||||||
|
# PackageKit is what LinuxUpdateChannel hands a downloaded update to, so without it the update path
|
||||||
|
# stops at the last step. It is not required, for two reasons that both point the same way: dnf
|
||||||
|
# installs weak dependencies by default, so the ordinary Fedora install gets it anyway; and a machine
|
||||||
|
# deliberately built without it — a minimal or an immutable one — should still be able to install an
|
||||||
|
# SSH client. The failure is not silent, which is what makes this safe: the channel names the missing
|
||||||
|
# service and the file it fetched, so the answer is one command the user was going to run anyway.
|
||||||
|
#
|
||||||
|
# Nothing is required for the TPM. LinuxDeviceKeyStore talks to /dev/tpmrm0 through a managed stack,
|
||||||
|
# so there is no library to depend on, and a machine with no TPM — or one whose device node this user
|
||||||
|
# may not open, which is the ordinary Fedora case — simply keeps asking for the passphrase.
|
||||||
|
Recommends: PackageKit
|
||||||
|
|
||||||
|
%description
|
||||||
|
DodoSSH is an SSH and SFTP client for teams whose credentials are shared and whose infrastructure is
|
||||||
|
not. Hosts, keys and passphrases live in a vault that is encrypted on this machine and stays
|
||||||
|
encrypted on the server, so the deployment holding a team's secrets can never read them.
|
||||||
|
|
||||||
|
This package carries its own .NET runtime. It checks the project's own forge for newer builds and can
|
||||||
|
download one, but it installs nothing itself: an update is handed to PackageKit, which asks before it
|
||||||
|
replaces anything. The address it checks is fixed and is never a DodoSSH server.
|
||||||
|
|
||||||
|
%install
|
||||||
|
rm -rf %{buildroot}
|
||||||
|
|
||||||
|
# ◆ /opt, AND NOT /usr/lib64/dodossh, WHICH IS THE MORE CONVENTIONAL PLACE.
|
||||||
|
#
|
||||||
|
# The Filesystem Hierarchy Standard reserves /opt for exactly this — an add-on package that arrives
|
||||||
|
# as one self-contained tree — and that is what a published .NET application is: a runtime, thirty
|
||||||
|
# managed assemblies and half a dozen native libraries that only make sense together and are only
|
||||||
|
# ever loaded by each other. Scattering them into the system library directory would mix a bundled
|
||||||
|
# libSkiaSharp in with the system's shared objects, which is the arrangement the provides exclusion
|
||||||
|
# above exists to keep this package out of.
|
||||||
|
#
|
||||||
|
# The directory name carries the channel, so a release and a nightly are two trees rather than one
|
||||||
|
# that overwrites the other. That is the same separation ADR 0013 rule 9 builds on Windows out of
|
||||||
|
# pack ids, arrived at here for free: two RPMs with different names owning different paths cannot
|
||||||
|
# replace one another, and dnf will say so rather than doing it. The profile directory is separated
|
||||||
|
# by the build itself — DodoChannel=nightly is what ClientPaths reads to suffix it — so the two
|
||||||
|
# installs share no state at all.
|
||||||
|
mkdir -p %{buildroot}/opt/%{dodo_dir}
|
||||||
|
cp -a %{dodo_payload}/. %{buildroot}/opt/%{dodo_dir}/
|
||||||
|
|
||||||
|
# ◆ AND THE MODES ARE SET HERE RATHER THAN INHERITED, WHICH IS NOT TIDINESS.
|
||||||
|
#
|
||||||
|
# `cp -a` preserves what the publish directory happened to have, and what it happened to have is a
|
||||||
|
# property of the filesystem it was written on rather than of anything decided here. rpmlint caught
|
||||||
|
# the consequence on the first tree this was tried against: every file in the package was mode 777,
|
||||||
|
# because the tree had come off a mount that reports everything that way.
|
||||||
|
#
|
||||||
|
# For this application that is not a lint warning, it is a hole. A world-writable /opt/DodoSSH lets
|
||||||
|
# any local account replace the SSH client belonging to every other account on the machine — the
|
||||||
|
# binary that holds a team's infrastructure credentials in plaintext while it runs, which is the
|
||||||
|
# thing ADR 0001 spends its whole trust model on. Malware running as nobody-in-particular would not
|
||||||
|
# need to attack the crypto; it would edit the client.
|
||||||
|
#
|
||||||
|
# Stated absolutely and in this order, so the answer does not depend on the publish at all:
|
||||||
|
# directories and the things that are executed or loaded get 0755, and everything else — assemblies,
|
||||||
|
# PDBs, the runtime config, the terminal's HTML and JavaScript — gets 0644. Owned by root, which is
|
||||||
|
# rpm's default for a package that names no %attr user, and is what makes the modes mean anything.
|
||||||
|
find %{buildroot}/opt/%{dodo_dir} -type d -exec chmod 0755 {} +
|
||||||
|
find %{buildroot}/opt/%{dodo_dir} -type f -exec chmod 0644 {} +
|
||||||
|
find %{buildroot}/opt/%{dodo_dir} -type f -name '*.so*' -exec chmod 0755 {} +
|
||||||
|
|
||||||
|
# The two files a .NET publish means to be executed. createdump is the runtime's crash-dump helper
|
||||||
|
# and is only present on some publishes, so it is conditional; the apphost never is, and the check
|
||||||
|
# above this section has already refused a publish without one.
|
||||||
|
chmod 0755 %{buildroot}/opt/%{dodo_dir}/DodoSSH
|
||||||
|
[ -f %{buildroot}/opt/%{dodo_dir}/createdump ] && chmod 0755 %{buildroot}/opt/%{dodo_dir}/createdump
|
||||||
|
true
|
||||||
|
|
||||||
|
# The apphost is the thing to launch and every other file in the tree is loaded relative to it, so
|
||||||
|
# what goes on PATH is a symlink rather than a copy or a wrapper script. .NET resolves its own
|
||||||
|
# directory through /proc/self/exe, which follows the link, so a symlinked apphost finds its runtime
|
||||||
|
# with no help — a wrapper exporting DOTNET_ROOT or cd-ing first would be solving a problem that
|
||||||
|
# does not exist here, and would be one more file to keep in step with the layout.
|
||||||
|
#
|
||||||
|
# Relative — ../../opt from /usr/bin — rather than the absolute /opt path it resolves to. Both work
|
||||||
|
# on a running system and only one works inside a chroot or an image being assembled somewhere other
|
||||||
|
# than /, which is where absolute symlinks out of a package are checked and warned about.
|
||||||
|
mkdir -p %{buildroot}%{_bindir}
|
||||||
|
ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
|
||||||
|
|
||||||
|
# ◆ THE DESKTOP ENTRY IS WRITTEN HERE RATHER THAN SHIPPED AS A SOURCE FILE.
|
||||||
|
#
|
||||||
|
# Three of its seven meaningful lines differ between the release and the nightly, and the two files
|
||||||
|
# that would result are the kind of pair that drifts: somebody adds a MIME type to one of them and
|
||||||
|
# nobody notices for a release. Composed from the same macros the rest of the layout uses, they
|
||||||
|
# cannot.
|
||||||
|
#
|
||||||
|
# StartupWMClass is the line that is easy to leave out and visibly wrong without: Avalonia sets
|
||||||
|
# WM_CLASS from the assembly name, which is DodoSSH for both channels, so without this a launched
|
||||||
|
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
|
||||||
|
# name deliberately and not the package's binary name — the window does not know which package
|
||||||
|
# started it.
|
||||||
|
mkdir -p %{buildroot}%{_datadir}/applications
|
||||||
|
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Version=1.0
|
||||||
|
Name=%{dodo_title}
|
||||||
|
GenericName=SSH Client
|
||||||
|
Comment=SSH and SFTP with an end-to-end encrypted credential vault
|
||||||
|
Exec=%{dodo_bin}
|
||||||
|
Icon=%{dodo_bin}
|
||||||
|
Terminal=false
|
||||||
|
StartupNotify=true
|
||||||
|
StartupWMClass=DodoSSH
|
||||||
|
Categories=Network;RemoteAccess;System;
|
||||||
|
Keywords=SSH;SFTP;Terminal;Remote;Shell;
|
||||||
|
DESKTOP
|
||||||
|
|
||||||
|
# One file per size into the hicolor theme, named for the package so the two channels show different
|
||||||
|
# entries rather than fighting over one path. The sizes are the ones the icon script draws and its
|
||||||
|
# comment argues for; adding one there means adding it here.
|
||||||
|
for size in 48 64 128 256; do
|
||||||
|
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps
|
||||||
|
install -m 0644 %{dodo_icons}/dodossh-${size}.png \
|
||||||
|
%{buildroot}%{_datadir}/icons/hicolor/${size}x${size}/apps/%{dodo_bin}.png
|
||||||
|
done
|
||||||
|
|
||||||
|
# ◆ THE UDEV RULE, WHICH IS THE ONE FILE THIS PACKAGE PUTS OUTSIDE ITS OWN TREE.
|
||||||
|
#
|
||||||
|
# It tags /dev/tpmrm0 with uaccess so the person at the machine can use the TPM without being put in
|
||||||
|
# the tss group by an administrator. The rule itself carries the whole argument, including what it
|
||||||
|
# costs and the one-line way to mask it.
|
||||||
|
#
|
||||||
|
# Installed for both channels, and that is not a mistake to be tidied up. The two packages own
|
||||||
|
# different paths for the same reason ADR 0013 rule 9 gives, but this file is about the machine's
|
||||||
|
# TPM rather than about either build — a nightly and a release on one machine want exactly the same
|
||||||
|
# access, and giving each its own copy would mean removing one package silently revoked the other's.
|
||||||
|
# rpm refuses to let two packages own one path with different content, which is what makes this
|
||||||
|
# safe: the file is byte-identical from both, so it is shared rather than duplicated.
|
||||||
|
#
|
||||||
|
# /usr/lib/udev/rules.d hardcoded rather than %{_udevrulesdir}, which comes from systemd-rpm-macros
|
||||||
|
# and is not present on the Alpine host CI builds this on. Hardcoding a path that has been stable
|
||||||
|
# for a decade beats a macro that expands to nothing and silently installs into the buildroot's root.
|
||||||
|
mkdir -p %{buildroot}/usr/lib/udev/rules.d
|
||||||
|
install -m 0644 %{dodo_udev} %{buildroot}/usr/lib/udev/rules.d/71-dodossh-tpm.rules
|
||||||
|
|
||||||
|
# Installed rather than listed straight out of the source tree. `%license` is a %doc-style marker and
|
||||||
|
# resolves its argument against the build directory, which this spec does not have — there is no
|
||||||
|
# %prep and no %build, because the payload arrives already published. Putting the file where it
|
||||||
|
# belongs first and marking the installed path second is the form that works with neither.
|
||||||
|
mkdir -p %{buildroot}%{_licensedir}/%{name}
|
||||||
|
install -m 0644 %{dodo_licence} %{buildroot}%{_licensedir}/%{name}/LICENSE
|
||||||
|
|
||||||
|
# Modern rpm on Fedora fires file triggers that do both of these, so on the distribution this was
|
||||||
|
# measured against they are redundant. They are here for the ones that do not, and every line ends in
|
||||||
|
# `|| :` so that a host without the tool installed gets a package that installs rather than a
|
||||||
|
# scriptlet failure at the end of a 130 MB transaction.
|
||||||
|
%post
|
||||||
|
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
|
||||||
|
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
|
||||||
|
|
||||||
|
# Reloaded and replayed, because a rule that only takes effect at the next boot is a feature that
|
||||||
|
# does not work on the day it is installed — and "reboot and try again" is the worst possible answer
|
||||||
|
# to "why is the PIN option missing". The trigger is scoped to the tpmrm subsystem so this re-runs
|
||||||
|
# rules for one device rather than for every device on the machine.
|
||||||
|
/usr/bin/udevadm control --reload &>/dev/null || :
|
||||||
|
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
|
||||||
|
|
||||||
|
%postun
|
||||||
|
/usr/bin/update-desktop-database %{_datadir}/applications &>/dev/null || :
|
||||||
|
/usr/bin/gtk-update-icon-cache -qtf %{_datadir}/icons/hicolor &>/dev/null || :
|
||||||
|
|
||||||
|
# On the way out too, so removing the package takes the ACL with it rather than leaving the last
|
||||||
|
# session's user holding access to the TPM until the next reboot.
|
||||||
|
/usr/bin/udevadm control --reload &>/dev/null || :
|
||||||
|
/usr/bin/udevadm trigger --subsystem-match=tpmrm --action=change &>/dev/null || :
|
||||||
|
|
||||||
|
%files
|
||||||
|
# The whole tree, owned by this package, which is what makes `dnf remove` take all of it. The user's
|
||||||
|
# vault cache is not in here and is not touched by an uninstall — ClientPaths puts it under
|
||||||
|
# $XDG_DATA_HOME, which is the Linux spelling of the same decision ADR 0013's consequences record for
|
||||||
|
# Windows: the install moves, the profile does not, and an uninstall is not a sign-out.
|
||||||
|
/opt/%{dodo_dir}
|
||||||
|
%{_bindir}/%{dodo_bin}
|
||||||
|
%{_datadir}/applications/%{dodo_bin}.desktop
|
||||||
|
%{_datadir}/icons/hicolor/*/apps/%{dodo_bin}.png
|
||||||
|
/usr/lib/udev/rules.d/71-dodossh-tpm.rules
|
||||||
|
%license %{_licensedir}/%{name}/LICENSE
|
||||||
|
|
||||||
|
%changelog
|
||||||
|
# Deliberately empty. The changelog of record is the git history and the release notes on the forge,
|
||||||
|
# and a %changelog generated per build from a version number carries no information that `rpm -q`
|
||||||
|
# does not already answer — while a hand-written one is a third place a release has to remember to
|
||||||
|
# edit. rpmlint says W: no-changelog and that warning is accepted.
|
||||||
@@ -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,7 +1,12 @@
|
|||||||
# Development dependencies only: PostgreSQL and Keycloak.
|
# Development dependencies only: PostgreSQL and Keycloak.
|
||||||
#
|
#
|
||||||
# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the
|
# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the
|
||||||
# schema is still churning. The production stack is deploy/docker-compose.yml.
|
# schema is still churning.
|
||||||
|
#
|
||||||
|
# There is no production compose file in this repository. This one said there was, naming a
|
||||||
|
# deploy/docker-compose.yml that has never existed in any commit. What ships instead is the
|
||||||
|
# container image the CI image job builds and pushes; a compose file to run it belongs to the
|
||||||
|
# self-hosting guide in M4, and until that exists the honest thing to say is that it does not.
|
||||||
#
|
#
|
||||||
# docker compose -f deploy/docker-compose.dev.yml up -d
|
# docker compose -f deploy/docker-compose.dev.yml up -d
|
||||||
# dotnet run --project src/DodoSSH.Api
|
# dotnet run --project src/DodoSSH.Api
|
||||||
|
|||||||
@@ -26,7 +26,8 @@
|
|||||||
"post.logout.redirect.uris": "http://127.0.0.1/*"
|
"post.logout.redirect.uris": "http://127.0.0.1/*"
|
||||||
},
|
},
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"http://127.0.0.1/callback"
|
"http://127.0.0.1/callback",
|
||||||
|
"dev.dodotech.dodossh:/callback"
|
||||||
],
|
],
|
||||||
"webOrigins": [],
|
"webOrigins": [],
|
||||||
"protocolMappers": [
|
"protocolMappers": [
|
||||||
|
|||||||
@@ -1,349 +0,0 @@
|
|||||||
# Adding hosts on the phone
|
|
||||||
|
|
||||||
The phone can read a keychain and connect through it. It cannot put anything in one. This document is the
|
|
||||||
plan for the change that fixes that, and it is written to be picked up cold — the decisions, the reasons, the
|
|
||||||
ordered work, and the traps that are already known.
|
|
||||||
|
|
||||||
> **Status: built.** All six steps. Each one compiled with the whole suite green before the next began,
|
|
||||||
> which is the rule the ordering below sets.
|
|
||||||
>
|
|
||||||
> | Step | State | Notes |
|
|
||||||
> | --- | --- | --- |
|
|
||||||
> | 1. `HostGroupSecret` grows | **Done** | Five fields, a version rule the codec did not have, a byte pin, and the "groups are flat" prose rewritten in all four places it appeared. |
|
|
||||||
> | 2. The `Tag` item kind | **Done** | Secret, codec, merge, cipher, repository, both registries, EF entity and the generated `AddTagItem` migration. |
|
|
||||||
> | 3. `HostSecret` grows, `Port` goes nullable | **Done** | `TagSet`, `TagIds`, `Port` as `int?`, `AsksForPassword`, both schema versions, and `HostInheritance` — the resolver. |
|
|
||||||
> | 4. The shared view model | **Done** | Every choke point below, plus the group editor the plan never assigned a step to and without which nothing could set a default at all. |
|
|
||||||
> | 5. The phone | **Done** | The `+`, the sheet, both editor cards, the back guard and EDIT on the connect bar. |
|
|
||||||
> | 6. Tests and false prose | **Done** | Plus a fourth "groups are flat" site in `README.md` this plan did not list, and a cross-vault resolution bug the scouting for it turned up. |
|
|
||||||
>
|
|
||||||
> **Tags shipped after the six steps, in a seventh pass.** They were storable and unreachable when step 6
|
|
||||||
> closed — a full item kind that no screen drew. Both heads now show a chip per tag on a host row and toggle
|
|
||||||
> them in the host editor, which also creates one inline, because wanting a tag and tagging a host are the
|
|
||||||
> same moment. Renaming and deleting live in a TAGS category on the keychain screen: a tag is an item so
|
|
||||||
> that renaming it is one write instead of twenty, and that rename needed somewhere to happen.
|
|
||||||
>
|
|
||||||
> That pass also made the desktop host editor scroll. A picker's height is a chip per tag in the keychain,
|
|
||||||
> so no fixed height holds it — which the layout suite caught the moment its seeder grew tags.
|
|
||||||
>
|
|
||||||
> **`HostFields.From` was answered by a refusal rather than by threading the resolver into the sync engine.**
|
|
||||||
> A relay host may not inherit its port; `HostSecret.TryValidate` refuses one that tries. A plaintext column
|
|
||||||
> derived from a *different* item goes stale when that item is edited and nothing re-pushes the hosts beneath
|
|
||||||
> it, so the relay would keep dialling the old port — which is worse than the restriction.
|
|
||||||
>
|
|
||||||
> **One decision was taken that this plan did not specify.** "Three states where there were two" is four, not
|
|
||||||
> three: a host can bind a key, bind a credential, be pinned to a typed password, or take its group's answer,
|
|
||||||
> and two nullable ids express three of those. Naming neither id now means *inherit*, so
|
|
||||||
> `HostSecret.AsksForPassword` was added to say "a typed password, even under a group that lends a key" out
|
|
||||||
> loud. Nothing stored changed meaning — no group could lend a binding before this build, so every existing
|
|
||||||
> host resolves exactly as it did.
|
|
||||||
|
|
||||||
## What was asked for
|
|
||||||
|
|
||||||
A floating `+` at the bottom right of the phone's HOSTS screen, opening a menu at the bottom offering **new
|
|
||||||
host** or **new group**.
|
|
||||||
|
|
||||||
A **host** carries: which vault it is filed into (personal or a team's), an alias, a hostname or IP address, a
|
|
||||||
group, tags, an SSH port, and a username with either a password or an SSH key.
|
|
||||||
|
|
||||||
A **group** carries: an alias, an optional parent group, and a default SSH port, username and
|
|
||||||
password-or-key — **defaults for the hosts inside it**.
|
|
||||||
|
|
||||||
## The shape of the problem
|
|
||||||
|
|
||||||
Three of those fields do not exist at any layer, and one of them is refused on the record.
|
|
||||||
|
|
||||||
| Asked for | What exists today |
|
|
||||||
| --- | --- |
|
|
||||||
| The `+` and the editors behind it | Nothing. `Theme/Phone.axaml` has no `.fab` class, and its comment says an unused style would be "a claim that the control exists somewhere". `HostsScreen.axaml`'s own v2 note says hosts are created on the desktop and sync down. *(This row named a `ConnectionsScreen.axaml` as a second site. No such file exists or ever has; and the `HostsScreen` statement is an XAML comment, so no phone screen ever rendered that sentence to a user.)* |
|
|
||||||
| Vault picker | **Built.** `VaultViewModel.TargetVaults` / `SelectedTargetVault` / `HasVaultChoice`, hidden at one vault. The desktop's keychain screen already draws it. |
|
|
||||||
| Alias, hostname, port, username, key-or-password, group | **Built**, in the shared `VaultViewModel` host editor — `EditorLabel`, `EditorHostname`, `EditorPort`, `EditorUsername`, `EditorAuthenticationChoices`, `EditorGroupChoices`, `SaveHostCommand`. The phone has never bound any of it. |
|
|
||||||
| Tags | **Nothing.** `SyncEntityType.Tag = 5` and `HostTag = 6` are reserved slots with nothing behind them. `HostSecret` has no tag field. |
|
|
||||||
| A group's parent | **Refused on the record.** `HostGroupSecret`'s own remark says groups are flat because two clients can each re-parent A under B and B under A offline, a scalar merge accepts both, and the result is a cycle no reader can draw and the server cannot see, because it is inside the payload. |
|
|
||||||
| A group's defaults | **Nothing.** `HostGroupSecret` has exactly one field, `Label`. |
|
|
||||||
|
|
||||||
So the phone half is mostly wiring. The domain half is three new capabilities, and one of them overturns a
|
|
||||||
written decision.
|
|
||||||
|
|
||||||
## The three decisions
|
|
||||||
|
|
||||||
**Tags are a real vault item, and a host names them.** A tag is a shared, reusable thing — the point of it is
|
|
||||||
to put the same tag on twenty machines and filter by it later — so it needs an identity of its own, not a
|
|
||||||
string repeated inside twenty payloads. `SyncEntityType.Tag = 5` becomes a live item kind. Membership is a
|
|
||||||
`TagIds` set on `HostSecret`, **not** the reserved `HostTag = 6` join.
|
|
||||||
|
|
||||||
The reason to skip the join is that the merge does not need it. The argument for `HostSecret.GroupId` living on
|
|
||||||
the host is that filing two hosts into one group must be two writes to two items; the same holds here. What a
|
|
||||||
join would have bought beyond that is two machines tagging *the same* host without one of them losing — and
|
|
||||||
`ThreeWayMerge.Map` already gives that. It resolves a keyed collection key by key, honouring presence and
|
|
||||||
absence, which is set semantics with removals. `HostOptions` is merged through it today via `ToNameMap()`, and
|
|
||||||
a tag set keyed by tag id is the same shape. So `HostTag = 6` stays reserved and unused, and the second run
|
|
||||||
through the item-kind checklist is not spent.
|
|
||||||
|
|
||||||
**Group defaults are inherited, not copied.** A host that leaves a field blank uses its group's value; a host
|
|
||||||
that fills one in overrides it. The editor shows the group's value as the field's placeholder, so the form
|
|
||||||
says what leaving it blank will get you. Copying the values into the host at create time was the alternative
|
|
||||||
and it was rejected: it makes a group a one-shot template rather than a live default, and changing a group
|
|
||||||
would then leave every host that had ever been created under it pinned to the old value.
|
|
||||||
|
|
||||||
**Groups get a parent, and the resolver breaks cycles.** `HostGroupSecret` gains `ParentId`. The editor refuses
|
|
||||||
a parent that is already a descendant, which stops a cycle being made here; the resolver walks with a visited
|
|
||||||
set and stops at a repeat, which contains a cycle that arrives from somewhere else. A cycle therefore degrades
|
|
||||||
to a group that reads as a root — flat headings, defaults unresolved past that point — instead of hanging the
|
|
||||||
connect path, and clearing the parent in the editor is the repair. **This is the load-bearing part of the
|
|
||||||
decision:** with inheritance the chain is walked at connect time, so an unguarded cycle is not an undrawable
|
|
||||||
sidebar, it is a shell that never opens.
|
|
||||||
|
|
||||||
`HostGroupSecret`'s "No parent. Groups are flat." remark and `HostGroupSecretTests`' class remark both become
|
|
||||||
false prose and must be rewritten to say what replaced the argument, not deleted. Same for the four rows in
|
|
||||||
`docs/design-import-gaps.md` recording the phone's missing `+`.
|
|
||||||
|
|
||||||
## What the payload becomes
|
|
||||||
|
|
||||||
```
|
|
||||||
HostSecret HostGroupSecret
|
|
||||||
Label Label
|
|
||||||
Hostname ParentId ← new, optional
|
|
||||||
Port int? — null inherits DefaultPort ← new
|
|
||||||
Username null inherits, "" none DefaultUsername ← new
|
|
||||||
Notes DefaultSshKeyId ← new
|
|
||||||
JumpHostIds DefaultCredentialId ← new
|
|
||||||
Options
|
|
||||||
SshKeyId null inherits TagSecret
|
|
||||||
CredentialId null inherits Label
|
|
||||||
AsksForPassword ← new, true only
|
|
||||||
GroupId null = ungrouped
|
|
||||||
TagIds ← new
|
|
||||||
RelayEnabled
|
|
||||||
```
|
|
||||||
|
|
||||||
`Port` becoming `int?` is the change with the widest blast radius, and it is unavoidable: `int` with a default
|
|
||||||
of 22 has no way to say "I have no port of my own". `HostSecret.DefaultPort = 22` stays, as the last fallback
|
|
||||||
after the chain runs out.
|
|
||||||
|
|
||||||
### Three states where there were two
|
|
||||||
|
|
||||||
`SshKeyId` and `CredentialId` both null currently *means* "ask for a password each time" — a decision, not an
|
|
||||||
absence. `AuthenticationChoice`'s own remark argues at length that the two must never be conflated.
|
|
||||||
Inheritance adds a state, so the picker needs an explicit **"Inherit from group"** entry beside **"Password
|
|
||||||
(ask each time)"**, and `Bound(...)` needs to distinguish them. `Username` has the same problem: null means
|
|
||||||
"no username" today and is refused at connect; it has to come to mean "inherit", with "no username" still
|
|
||||||
reachable and still refused.
|
|
||||||
|
|
||||||
**Built as four states, not three, because two nullable ids only carry three.** Key, credential, typed
|
|
||||||
password, inherit — and naming neither id was the third and is now the fourth. `HostSecret.AsksForPassword`
|
|
||||||
carries the difference: null is "not stated", which walks the chain and lands on a typed password if the
|
|
||||||
chain lends nothing, and `true` is "a typed password, even under a group that lends a key". Only `true` is
|
|
||||||
ever written, and a decoded `false` folds back to null, so a host that never touched the field encodes
|
|
||||||
exactly as it did before it existed. `Username` needed no field — an empty string is "no username" and null
|
|
||||||
is "inherit". `Port` needed none either: there is no "explicitly no port", only 22 at the end of the chain.
|
|
||||||
|
|
||||||
Mutual exclusion moves with it. `HostSecret.TryValidate` enforces "a key or a credential, never both" per
|
|
||||||
record; a host naming a credential under a group naming a key is two individually valid records that resolve to
|
|
||||||
two bindings. **Exclusion has to be enforced at resolution**, host-over-group, and the resolver must never
|
|
||||||
return both.
|
|
||||||
|
|
||||||
## The order to build it in
|
|
||||||
|
|
||||||
Each step compiles and its tests pass before the next one starts. Steps 1–3 are the domain and are
|
|
||||||
prerequisites for everything; step 4 is the only user-visible one.
|
|
||||||
|
|
||||||
### 1. `HostGroupSecret` grows
|
|
||||||
|
|
||||||
`src/DodoSSH.Client.Domain/HostGroupSecret.cs`, `HostGroupSecretCodec.cs`, `HostGroupSecretMerge.cs`.
|
|
||||||
|
|
||||||
- Add `ParentId`, `DefaultPort`, `DefaultUsername`, `DefaultSshKeyId`, `DefaultCredentialId`.
|
|
||||||
- `TryValidate`: reject `Guid.Empty` for each id, reject a `ParentId` equal to the group's own id where that is
|
|
||||||
knowable, apply the same 1–65535 range rule to `DefaultPort`, and refuse a group naming both a default key
|
|
||||||
and a default credential.
|
|
||||||
- **The codec needs the host codec's rule, which it does not have today.** `HostGroupSecretCodec` stamps
|
|
||||||
`CurrentSchemaVersion` unconditionally. Copy `HostSecretCodec.SchemaVersionFor` — a *maximum over the fields
|
|
||||||
actually present*, not a ladder — or upgrading one machine makes every group read-only on every other. A
|
|
||||||
group carrying none of the new fields must still encode at version 1, byte for byte.
|
|
||||||
- `HostGroupSecretMerge` gains the five fields, each a scalar.
|
|
||||||
- Add a group byte pin mirroring `AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne`. The group codec has
|
|
||||||
none, and the same "old vaults must re-encode identically" argument applies to it.
|
|
||||||
|
|
||||||
### 2. The `Tag` item kind
|
|
||||||
|
|
||||||
`SyncEntityType.Tag = 5`, `CryptoSpec.AadResourceType.Tag = 8`, `ChangeEntityType.Tag = 5` and the
|
|
||||||
`PublicAPI.Unshipped.txt` line **already exist** — no contract change, no crypto-spec change, no `docs/crypto.md`
|
|
||||||
change. `LocalCacheProtector`'s AAD switch already maps `Tag`.
|
|
||||||
|
|
||||||
New: `TagSecret.cs`, `TagSecretCodec.cs`, `TagSecretMerge.cs` in `DodoSSH.Client.Domain`; `TagCipher.cs`,
|
|
||||||
`TagRepository.cs` in `DodoSSH.Client.Sync`. Edited: `Client.Sync/ItemKinds.cs` (registry entry + `TagKind`),
|
|
||||||
`VaultSession.cs` (a `Tags` repository beside `HostGroups`), `Domain/Hosts.cs` (`VaultTag`),
|
|
||||||
`Infrastructure/DodoDbContext.cs`, `Infrastructure/Configurations/HostAndSyncConfigurations.cs`,
|
|
||||||
`Api/Features/Sync/ItemKinds.cs` (registry entry + server `TagKind`).
|
|
||||||
|
|
||||||
**A server migration is required** — a tag is its own table. Generate it, do not hand-write it:
|
|
||||||
`dotnet ef migrations add AddTagItem --project src/DodoSSH.Infrastructure`, committing all three resulting file
|
|
||||||
changes. **No client migration**: `ClientCacheContext` has one `item` table keyed by `(VaultId, EntityType,
|
|
||||||
EntityId)`, so a new kind is new rows.
|
|
||||||
|
|
||||||
Four traps, all of which pass a naive test suite:
|
|
||||||
|
|
||||||
- **`TagCipher` must say `AadResourceType.Tag` as a constant, never cast from `SyncEntityType.Tag`.** Tag is 5
|
|
||||||
on the wire and 8 in the crypto enum. A cast seals tags under `Credential`, which encrypts and decrypts
|
|
||||||
perfectly on the machine that wrote it and is a spec violation nothing notices until another implementation
|
|
||||||
refuses the item — and the AAD is frozen into stored ciphertext. `AadResourceTypeTests` is the test that
|
|
||||||
catches this, and it only catches it once `PinnedPairs` and `SealSample` have a Tag arm.
|
|
||||||
- **The `ItemKinds.Registry` entry is what `SyncEngine` pulls with.** `SyncedTypes` is projected from it. Miss
|
|
||||||
it and tags encrypt, merge and list perfectly on the machine that made them and are never once requested from
|
|
||||||
the server.
|
|
||||||
- **`TagKind.Fields` returns `null`, not an empty `SyncPlaintextFields`.** An empty record still serialises
|
|
||||||
`relayEnabled: false`, which invites a reader to believe the type has a relay setting that is off.
|
|
||||||
- **`ValidateFields` must refuse everything, with reasons.** There is no default; an unimplemented one accepts
|
|
||||||
whatever arrives. `RelatedId` matters more here than it did for a group — it is the field a future `HostTag`
|
|
||||||
would reach for, and `SyncPlaintextFields` is frozen so it cannot be removed. Follow `SnippetKind` and
|
|
||||||
`ObjectStoreKind`, which are more complete than `HostGroupKind`.
|
|
||||||
|
|
||||||
### 3. `HostSecret` grows, and `Port` goes nullable
|
|
||||||
|
|
||||||
`HostSecret.cs`, `HostSecretCodec.cs`, `HostSecretMerge.cs`, plus a new `TagSet` value type and a new resolver.
|
|
||||||
|
|
||||||
- `TagSet` is a structural-equality wrapper in the shape of `JumpChain` — a plain `IReadOnlyList<Guid>` on a
|
|
||||||
record gets reference equality from the compiler-generated `Equals`, which would make every host read as
|
|
||||||
changed on every sync pass and two identical edits register as a conflict. Unlike `JumpChain` it is a *set*:
|
|
||||||
order carries no meaning, so it sorts and dedupes, and it exposes a `ToIdMap()` for the merge.
|
|
||||||
- `HostSecretMerge` merges `TagIds` through `ThreeWayMerge.Map`, per tag, so two people each adding a different
|
|
||||||
tag to one host both keep theirs. It is not a scalar; a whole-value merge here would silently drop one side.
|
|
||||||
- `Port` becomes `int?`. `TryValidate`'s `is < 1 or > 65535` is already false for null, which is the wanted
|
|
||||||
behaviour but is silent — say so in a comment, and fix the message, which interpolates `{Port}` and renders
|
|
||||||
empty for null.
|
|
||||||
- **`SchemaVersionFor` needs branches for both new fields, and this is the one that loses data if forgotten.**
|
|
||||||
A host with `Port = null` written at version 1 omits the property (`WhenWritingNull`); an older client
|
|
||||||
deserialises `int Port` as 0, `TryValidate` refuses it, and the item reads as **unreadable rather than
|
|
||||||
read-only** — gone, not locked. Add `PortInheritSchemaVersion = 5` and `TagIdsSchemaVersion = 5` (or 5 and 6)
|
|
||||||
and make both bump the maximum.
|
|
||||||
- **`AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne` pins the exact bytes** of a minimal host,
|
|
||||||
including `"port":22`. A host that now inherits its port drops that key. Keep the pinned literal for a host
|
|
||||||
with an explicit port, and add a companion for the inheriting one — do not edit the old literal to match
|
|
||||||
whatever the code produces.
|
|
||||||
- `TagIds` must serialise **last** in `HostPayloadDocument`, and empty must serialise as absent, or every host
|
|
||||||
in every vault looks changed on the first sync after the upgrade.
|
|
||||||
|
|
||||||
New file: the resolver. Given a host and the group list, walk `GroupId` then `ParentId` with a visited set,
|
|
||||||
returning resolved port, username and binding, plus which group each came from — the editor needs the second
|
|
||||||
half for its placeholders. It must never return both a key and a credential.
|
|
||||||
|
|
||||||
### 4. The shared view model, and the two heads
|
|
||||||
|
|
||||||
`VaultViewModel` is where nearly all of it lands, and both heads get it at once because both bind the same
|
|
||||||
properties.
|
|
||||||
|
|
||||||
The choke points, from the connect-path trace:
|
|
||||||
|
|
||||||
- **`TryBuildAuthentication(HostSecret, …)` and `TryBuildConnectionRequest(HostSecret, …)`** both take a bare
|
|
||||||
`HostSecret` — this signature is where group context is currently lost. Both must take the resolved value.
|
|
||||||
This is the single highest-leverage change: it is the only auth resolution in the product, and both heads
|
|
||||||
and both transports go through it.
|
|
||||||
- The credential-username fallback becomes three levels: credential → host → group chain.
|
|
||||||
- `Complete(...)` must refuse on an empty username only *after* the chain has been consulted.
|
|
||||||
- `HostRowViewModel.Address`, `Dialled`, `TransfersViewModel`'s connected-to label and
|
|
||||||
`TerminalWorkspace.Describe` must show the **dialled** address. `MainWindowViewModel.Rank` searches
|
|
||||||
`Address`, so an unresolved one makes hosts unfindable by their real port.
|
|
||||||
- **`HostFields.From` in `HostCipher` must emit the resolved port.** This is the one place where an unresolved
|
|
||||||
read is a wrong wire rather than a wrong label: it fills the relay's plaintext columns, which is what the
|
|
||||||
*server* dials. A relay host with a null port also fails server validation outright.
|
|
||||||
- Host-key pinning keys on host and port. `ForgetHostKeyAsync` and the "pin in use" endpoint set read
|
|
||||||
`row.Host.Port` directly — a host inheriting 2222 pins under 2222 while both look under 22.
|
|
||||||
- `HostRowViewModel.Authentication`, `SelectedHostAsksForAPassword` (in **both** `VaultViewModel` and
|
|
||||||
`TransfersViewModel`) and `SelectedHostAuthenticationNote` all switch on the host's own two ids and must
|
|
||||||
switch on the resolved binding, with new wording for a binding that came from a group.
|
|
||||||
- **`HostsBoundTo`** counts hosts naming a key explicitly. A key deleted while only a *group* names it warns
|
|
||||||
nobody and then refuses every host under that group at connect time. It must walk groups too.
|
|
||||||
- The editor: load must distinguish "unset" from "explicitly this", `BuildHost` must preserve that in both
|
|
||||||
directions, and each field needs the resolved value as its placeholder.
|
|
||||||
- `ImportViewModel.IsAlreadyPresent` compares port and username directly, so a stored host inheriting 22 will
|
|
||||||
never match an imported host at 22 and the import screen re-offers duplicates. Decide whether an imported
|
|
||||||
block with no `Port` should inherit rather than pin 22 — `SshConfigResolver` returns `DefaultPort` today.
|
|
||||||
|
|
||||||
### 5. The phone
|
|
||||||
|
|
||||||
`Views/HostsScreen.axaml` and `Theme/Phone.axaml`. No csproj edit — Avalonia globs `**/*.axaml`. Compiled
|
|
||||||
bindings are on, so a binding to a property that does not exist is a build error; `TreatWarningsAsErrors` is on
|
|
||||||
repo-wide, so any XAML warning fails CI.
|
|
||||||
|
|
||||||
- A `Button.fab` style, and the comment that currently refuses one rewritten to say what it now opens.
|
|
||||||
- The `+` needs a `Panel` wrapping the screen's root `Grid` so it can sit bottom-right without moving. Placing
|
|
||||||
it inside the list row would put it above the connect bar, which appears and disappears with the selection.
|
|
||||||
- The bottom sheet follows `HostKeySheet.axaml`, the head's one existing sheet: a scrim
|
|
||||||
(`Border Background="#9E0E1220"`, a literal because the palette holds no alpha surface), a bottom-aligned
|
|
||||||
`Border` with `CornerRadius="22,22,0,0"`, a decorative grab handle, and **no tap-to-dismiss** unless it is
|
|
||||||
wired by hand, because nothing on this head implements it.
|
|
||||||
- **Scrim reach is a real choice.** Declared inside `HostsScreen`, a scrim darkens the screen area only and the
|
|
||||||
64px nav bar stays lit and tappable. To cover the display the sheet has to sit in `PhoneShell.axaml` beside
|
|
||||||
`HostKeySheet`, which is exactly why that control lives there.
|
|
||||||
- **The back gesture needs a new guard**, above the `switch` in `PhoneShell.axaml.cs`, in the shape of the
|
|
||||||
host-key guard: back should close the sheet or the editor rather than leave the screen. The comment on that
|
|
||||||
method states an invariant about the switch cases staying in step with the surface tests — so add a guard,
|
|
||||||
do not extend the switch. *(This named `IsVaultsSurface` and `IsConnectionsSurface`. Neither exists
|
|
||||||
anywhere in the repo; the real properties are `IsMoreSurface` and `IsTerminalSurface`, and only the first
|
|
||||||
is what the switch mirrors.)*
|
|
||||||
- The editors follow `SnippetsScreen.axaml`: a `Border Classes="card"` in the same grid row as the list,
|
|
||||||
toggled on `IsEditing`, with the list toggled on `!IsEditing`. There is no dialog, no modal and no
|
|
||||||
editor-screen anywhere on this head, and the reason is written down — a form stacked over the list hides what
|
|
||||||
it is about.
|
|
||||||
- Editing an existing host comes nearly free: the shared `IsEditing` panel serves both, so the connect bar gets
|
|
||||||
an EDIT button beside CONNECT. Not asked for, but a `+` that adds hosts with no way to correct one is a
|
|
||||||
strange thing to ship.
|
|
||||||
|
|
||||||
### 6. Tests
|
|
||||||
|
|
||||||
House style: `Subject_Predicate`, articles included, the predicate smuggling in the reason
|
|
||||||
(`_RefusesRatherThanFallingBackToThePassword`). No `Async` suffix on test methods; always on private helpers.
|
|
||||||
Every class carries a remark naming the class of failure it exists to catch. xunit.v3 + Shouldly, with the
|
|
||||||
because-string used as prose.
|
|
||||||
|
|
||||||
- Three shared builders need the new fields: `Domain.Tests/HostFactory.cs`, `Sync.Tests/SyncHarness.cs`,
|
|
||||||
`Sync.Tests/HostCipherTests.cs`. There is no shared group factory and with five new fields there should be.
|
|
||||||
- Three guard tests fail by design and that is what they are for:
|
|
||||||
`HostSecretMergeTests.EveryScalarField_IsRoutedThroughAMerge` (note `GroupId` is missing from its `with`
|
|
||||||
block today — an existing gap worth closing while there) *(closed: `GroupId` is in that block now)*,
|
|
||||||
`ValueSemanticsTests.TryValidate_RejectsWhatCannotBeStored`, and the two "groups are flat" remarks.
|
|
||||||
- `ItemKindsTests.ThePullFilterNamesEveryTypeThisBuildSynchronises` is an ordered list — add Tag in registry
|
|
||||||
order. `AadResourceTypeTests` needs `PinnedPairs`, a `SealSample` arm and a `NewTag()` builder.
|
|
||||||
- `SyncEndpointTests.AHostGroupCarryingAParent_IsRejected` asserts the server refuses a plaintext `ParentId`
|
|
||||||
with a reason containing "flat". Putting the parent **inside the payload** does not break it mechanically,
|
|
||||||
but its stated reason becomes wrong. It should survive with a rewritten comment — the parent lives in the
|
|
||||||
payload, the plaintext column stays refused, and ADR 0004 is why. Decide that deliberately rather than
|
|
||||||
letting it drift. *(Decided that way, and the name says it now:
|
|
||||||
`SyncEndpointTests.AHostGroupCarryingAPlaintextParent_IsRejected`. The column is still refused and the
|
|
||||||
reason is the trust model rather than flatness.)*
|
|
||||||
- `Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch` picks the first type with no kind registered, very
|
|
||||||
likely Tag today. It self-heals to `HostTag` via `Assert.SkipWhen`.
|
|
||||||
- `App.Layout.Tests` measures pixels and bends rather than breaks. Extend the seeders — nesting and a tag row
|
|
||||||
change what the widest realistic content is — rather than the assertions. `ScreenLayoutTests.cs:1310` pins
|
|
||||||
`ChangedFields = "Hostname, Port, Username, Options, Group"` as a literal; add `Tags` to keep it measuring
|
|
||||||
the worst case.
|
|
||||||
- `ShellFlowTests.ReadyToConnectAsync` gives its host both a username and a port, so the inheritance tests need
|
|
||||||
a host with neither, or the group is never the source.
|
|
||||||
|
|
||||||
## Found on the way, and out of scope
|
|
||||||
|
|
||||||
`LocalCacheProtector`'s AAD switch is missing `ConnectionLogEntry`, `ActivityLogEntry` and `ObjectStore`, so
|
|
||||||
`ConflictStore.Record` throws `ArgumentOutOfRangeException` on a conflict for any of those three. Pre-existing,
|
|
||||||
unrelated to any of this, and `Tag` is already in that switch. Worth a separate fix.
|
|
||||||
|
|
||||||
> **Fixed 2026-08-04**, having outlived the phases that shipped the logs and the buckets — which is exactly
|
|
||||||
> the drift a note like this is meant to prevent, so it is worth saying what let it last. Of the three callers
|
|
||||||
> of `AadResourceTypes.For`, two reach it only when an item carries plaintext fields and none of these three
|
|
||||||
> does; the third, `ConflictStore.RecordAsync`, calls it unconditionally but is reached only by a real merge
|
|
||||||
> conflict, which every existing test raised against a `Host`. The arms are in, and two tests now hold them
|
|
||||||
> there: `CacheStoreTests.AConflict_CanBeRecordedForEveryKindOfItem` records one per kind, and
|
|
||||||
> `AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping` fails on the *next* item type added
|
|
||||||
> without one, by name rather than by a list kept by hand.
|
|
||||||
|
|
||||||
## Prose that becomes false
|
|
||||||
|
|
||||||
Not a tidy-up — these are the places the codebase currently tells a user or a maintainer that this feature is
|
|
||||||
deliberately absent, and each states a reason that will no longer hold.
|
|
||||||
|
|
||||||
- `Theme/Phone.axaml` — the "No floating action button" block.
|
|
||||||
- `Views/HostsScreen.axaml` — the v2 and v3 notes on the missing `+`.
|
|
||||||
- ~~`Views/ConnectionsScreen.axaml`~~ — **this file does not exist and never did.** The real inventory is
|
|
||||||
the two above plus `README.md`, `docs/android-port.md` and `docs/design-import-gaps.md`. Neither phone
|
|
||||||
site was "rendered on screen": both are XAML comments.
|
|
||||||
- `HostGroupSecret.cs` and `HostGroupSecretTests.cs` — "No parent. Groups are flat."
|
|
||||||
- `docs/design-import-gaps.md` — the **Add host** row, the two floating-button rows, the "Tag / HostTag still
|
|
||||||
reserved and unused" paragraph, and the tag-chips and groups-on-a-team's-hosts rows in the Hosts table.
|
|
||||||
@@ -99,3 +99,22 @@ real ones.
|
|||||||
- **Client-supplied target address.** One line of convenience, and the relay becomes an open
|
- **Client-supplied target address.** One line of convenience, and the relay becomes an open
|
||||||
proxy into the operator's network.
|
proxy into the operator's network.
|
||||||
- **Allow-listing by hostname.** Defeated by DNS rebinding.
|
- **Allow-listing by hostname.** Defeated by DNS rebinding.
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
Built in `DodoSSH.Api/Features/Relay` and, on the client, `RelayBridge` and `ServerRelay`; see
|
||||||
|
`docs/reaching-a-host-you-cannot-dial.md`, "What step 3 built". Three places differ from the text above:
|
||||||
|
|
||||||
|
- **Backpressure is an awaited copy loop, not `System.IO.Pipelines`.** Each direction reads one buffer and
|
||||||
|
writes it before reading again. The property is the same — a slow peer stops the read, and TCP throttles
|
||||||
|
the origin — and the bound is tighter: two 32 KiB buffers per session.
|
||||||
|
- **There is no `?ticket=` fallback.** Both clients can set a subprotocol, and the fallback's cost — a bearer
|
||||||
|
credential in the URL — was the reason it needed scrubbing at all. It can be added if a constrained
|
||||||
|
client ever needs it; the scrubbing requirement stands.
|
||||||
|
- **The ticket carries no device id.** The server does not see a device on an ordinary request, so there was
|
||||||
|
nothing to put there. `PortForwardId` on the request is refused: a forward's port is ciphertext, and
|
||||||
|
honouring one would mean taking the port from the client.
|
||||||
|
|
||||||
|
The loopback bridge serves the relay only. SSH.NET's `ForwardedPortDynamic` already is the jump-host path's
|
||||||
|
listener, so "one mechanism, two features" is true one level down: both features reach their target through
|
||||||
|
a loopback SOCKS5 proxy named on the connection request.
|
||||||
@@ -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.
|
||||||
@@ -107,6 +107,81 @@ almost nil. Two things to check when it is taken up: `EnrollmentValidation` pins
|
|||||||
`DevicePublicKey` to `CryptoSpec.PublicKeySize` (32 bytes; a P-256 public key is 33 or 65), and the
|
`DevicePublicKey` to `CryptoSpec.PublicKeySize` (32 bytes; a P-256 public key is 33 or 65), and the
|
||||||
envelope's minimum-length rule.
|
envelope's minimum-length rule.
|
||||||
|
|
||||||
|
### 4. Linux: sealed into the TPM behind a PIN the TPM checks
|
||||||
|
|
||||||
|
Added after the Linux head was packaged ([ADR 0013](0013-desktop-distribution-and-updates.md) decision 11).
|
||||||
|
The third desktop platform reached this file the way the first two did — by having to answer the amendment
|
||||||
|
above — and it is the only one where the answer is not a clean yes.
|
||||||
|
|
||||||
|
**The mechanism.** The 32-byte device key is sealed into a TPM 2.0 keyedhash object whose `userAuth` is
|
||||||
|
derived from a PIN. Unsealing is a command the TPM refuses without it. The blob on disk is encrypted to the
|
||||||
|
machine's storage hierarchy, so it is useless on any other machine, and nothing in this application ever
|
||||||
|
compares the PIN. The parent is an ECC P-256 primary rederived from a fixed template on every use rather
|
||||||
|
than made persistent — a persistent handle would occupy an NV slot forever, need cleaning up on withdrawal,
|
||||||
|
and collide with whatever else on the machine chose the same index. ECC rather than RSA because creating an
|
||||||
|
RSA primary on a discrete TPM takes seconds and this runs on the unlock path.
|
||||||
|
|
||||||
|
**The sealed object deliberately does not carry `NoDA`, and that single attribute is what makes a
|
||||||
|
four-digit PIN defensible.** Every wrong answer advances the TPM's dictionary-attack counter and the chip
|
||||||
|
stops answering entirely once it trips — measured against a software TPM at four wrong tries, after which
|
||||||
|
the *correct* PIN gets nothing either. That is the property a short secret has here and cannot have in a
|
||||||
|
file, and it is asserted by a test rather than left to a reading of the template, because removing the
|
||||||
|
attribute would leave every other assertion in that file passing.
|
||||||
|
|
||||||
|
◆ **This is weaker than Windows Hello and Touch ID, and the difference is invisible to the user.** Both of
|
||||||
|
those raise their prompt from inside the platform — a secure desktop, a system sheet — so the process asking
|
||||||
|
never sees the secret. Linux offers nothing to borrow, so the PIN is typed into a window this application
|
||||||
|
draws, and an attacker already running as the user could draw a convincing copy of it. Two things bound
|
||||||
|
that, and neither erases it: the same attacker could counterfeit the passphrase screen, which is a much
|
||||||
|
larger prize, so the feature creates no capability that did not exist; and what they would obtain is a PIN
|
||||||
|
that is worthless without this machine's TPM, where the passphrase is worth every machine. **The parity is
|
||||||
|
not claimed anywhere in the interface**, which is why `DesktopDeviceKeyStores` documents the ordering and
|
||||||
|
why the prompt is deliberately plain rather than dressed to look like a system dialog — teaching people
|
||||||
|
that a convincing DodoSSH PIN box is a system dialog is the one thing this design must not do.
|
||||||
|
|
||||||
|
**Reaching the TPM at all is the other half, and the package now arranges it.** On Fedora `/dev/tpmrm0` is
|
||||||
|
`root:tss` mode 0660 and desktop users are not in `tss`, so without help the ordinary case is a working TPM
|
||||||
|
this process may not open — which reports unavailable and keeps asking for the passphrase, exactly as a
|
||||||
|
machine with no TPM does.
|
||||||
|
|
||||||
|
◆ **This originally said shipping a udev rule to widen that was refused, on the grounds that it is a
|
||||||
|
system-wide decision an application package should not make. That refusal was overturned deliberately, and
|
||||||
|
the reasoning it was replaced with is narrower rather than louder.** The refusal treated "widen access" as
|
||||||
|
one thing. It is two, and they are not close:
|
||||||
|
|
||||||
|
- *Adding a person to `tss`* is what the refusal was really about, and it remains the wrong answer. It is
|
||||||
|
permanent, it applies whether or not they are at the machine, it applies over SSH, and it needs an
|
||||||
|
administrator and a fresh login before the feature appears at all.
|
||||||
|
- *Tagging `/dev/tpmrm0` with `uaccess`* is the mechanism systemd already uses for `/dev/dri`, webcams,
|
||||||
|
scanners and FIDO keys: logind grants a POSIX ACL to the user of the **active local session** and removes
|
||||||
|
it when that session ends. Not over SSH, not on an inactive VT, not to daemons. `build/linux/71-dodossh-tpm.rules`
|
||||||
|
is that one line, and it changes neither the mode nor the group, so anything that worked before still does.
|
||||||
|
|
||||||
|
**`tpmrm` and never `tpm`, which is what makes it defensible at all.** `/dev/tpm0` is the raw device and is
|
||||||
|
exclusive; a process holding it can leave the chip in a state that breaks every other user. `/dev/tpmrm0` is
|
||||||
|
the kernel's resource manager, which virtualises handles and isolates callers — it exists *because* the TPM
|
||||||
|
is meant to be usable by more than one unprivileged program. Granting a seat's user the manager is the use
|
||||||
|
it was designed for.
|
||||||
|
|
||||||
|
**The cost is a denial of service and not a disclosure, and it is stated because it is easy to miss.** TPM
|
||||||
|
objects are protected by their own auth values, so this lets nobody read anybody else's sealed data. What it
|
||||||
|
does allow is *failed* authorisation attempts — and the chip answers those by counting them and eventually
|
||||||
|
refusing everyone, which is the same lockout decision 4 relies on. On a machine whose disk is unlocked by
|
||||||
|
the TPM at boot (`systemd-cryptenroll`, Clevis), somebody sitting at it can trip that lockout and stop the
|
||||||
|
next boot from unlocking until it recovers. An administrator who does not accept that masks the file with
|
||||||
|
`ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules`, which is the standard override, survives upgrades,
|
||||||
|
and puts the behaviour back to the passphrase-only path. The rule says so in its own comments, because the
|
||||||
|
person who needs to know is reading the file rather than this document.
|
||||||
|
|
||||||
|
**What was rejected, and why each would have failed the amendment above.** The Secret Service API —
|
||||||
|
gnome-keyring or KWallet — is out of process and OS-managed and has *no per-use consent*: once the session
|
||||||
|
keyring is unlocked anything running as the user reads the key silently, which is DPAPI's shape exactly and
|
||||||
|
what "Why not DPAPI alone" already refuses. `fprintd` is an authentication oracle rather than a key store:
|
||||||
|
it answers a question and holds nothing, so skipping the call skips the gate — the amendment's mistake with
|
||||||
|
a fingerprint reader attached. A polkit-gated root helper would genuinely work and was declined on surface
|
||||||
|
area: it puts a privileged component in a package that installs nothing setuid, to reach a property the TPM
|
||||||
|
already provides.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
### The cache key had to move, and the spec changed
|
### The cache key had to move, and the spec changed
|
||||||
@@ -138,9 +213,19 @@ would have become false under DPAPI alone. A gesture is still something the atta
|
|||||||
|
|
||||||
### Operational
|
### Operational
|
||||||
|
|
||||||
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
- **A TPM is not always there, and on Linux it is often there and out of reach.** A machine without one gets
|
||||||
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
a store that reports itself unavailable, so unlock keeps asking for the passphrase and neither affordance
|
||||||
is therefore required, not a nicety.
|
appears in the interface. The passphrase path is therefore required, not a nicety — and on Linux it is the
|
||||||
|
default rather than the exception, because the device node is not readable by an ordinary desktop user
|
||||||
|
until somebody says it should be. See decision 4.
|
||||||
|
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
|
||||||
|
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
|
||||||
|
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
|
||||||
|
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,194 @@ 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
|
||||||
|
|
||||||
|
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
|
||||||
|
none, which left the two heads with different answers to the same question — *how does somebody try what
|
||||||
|
is on main?* — for no reason other than the order the work happened in. This is the desktop's answer, and
|
||||||
|
it is the phone's arrangement with one difference that changes how much of it has to be built.
|
||||||
|
|
||||||
|
**Android gets the separation from the platform. Windows does not.** Two Android channels cannot replace
|
||||||
|
one another because the installer refuses a package signed by a different key; a mistake there is loud.
|
||||||
|
Velopack applies what its feed serves and verifies no signature, so on this head the separation is entirely
|
||||||
|
construction:
|
||||||
|
|
||||||
|
| | release | nightly |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| pack id | `DodoSSH.Desktop` | `DodoSSH.Desktop.Nightly` |
|
||||||
|
| Velopack channel | `win` | `win-nightly` |
|
||||||
|
| feed | newest non-prerelease release | the rolling `nightly-desktop` prerelease |
|
||||||
|
| profile | `%LOCALAPPDATA%\DodoSSH` | `%LOCALAPPDATA%\DodoSSH.Nightly` |
|
||||||
|
| cut by | a person, from a `v*` tag | CI, on every push to main |
|
||||||
|
|
||||||
|
Four separations rather than one, because each closes a different door. The pack id decides the install
|
||||||
|
directory and what an installed client matches an update against, so it is what makes a nightly install
|
||||||
|
*beside* rather than *over*. The channel names the index file on the feed, so neither build ever reads the
|
||||||
|
other's — and the release channel additionally refuses prereleases, which is belt and braces in the one
|
||||||
|
direction that matters: an unsigned CI build must never reach a machine somebody is trusting with their
|
||||||
|
credentials. The profile is the one that is easy to skip and would hurt most: the cache schema is migrated
|
||||||
|
on every launch, before unlock, so a shared profile means a nightly quietly upgrading a database the
|
||||||
|
release build then opens.
|
||||||
|
|
||||||
|
**The prerelease flag is also how the two heads stay out of each other's way.** The phone's release channel
|
||||||
|
reads `releases/latest`, which skips prereleases. A desktop nightly published as a stable release would
|
||||||
|
become the newest release in this repository, and every phone on the release channel would start failing
|
||||||
|
its check against a release carrying no Android manifest. The nightly is published with `--pre` for its own
|
||||||
|
sake and for that one.
|
||||||
|
|
||||||
|
**What this costs, stated where somebody will read it before installing:** whoever can write a release on
|
||||||
|
this repository can put a build on every nightly desktop, because the client applies what the feed serves
|
||||||
|
without verifying a signature. That set includes CI and therefore everyone who can change a workflow file.
|
||||||
|
It is the same trade [ADR 0014](0014-android-updates.md) accepted for the phone's nightly, and it is
|
||||||
|
acceptable for the same reason and only that reason: this is not the channel anybody's real credentials are
|
||||||
|
on. Rule 3 is untouched — the release channel still has no job, no token and no runner.
|
||||||
|
|
||||||
|
**The version gets a floor, on this channel only.** MinVer answers `0.0.0-alpha.0.N` until the first `v*`
|
||||||
|
tag and `vpk` refuses to pack anything below `0.0.1`, so the nightly job lifts the patch digit and keeps
|
||||||
|
the prerelease height. It is applied through `MinVerVersionOverride`, which moves the assembly version too
|
||||||
|
— packing a number the assemblies disagree with would put one version in the installer and another on the
|
||||||
|
preferences screen, which is the screen somebody reads when asked which nightly they are running. Ordering
|
||||||
|
across the floor holds: heights rise within a floored version, and the first real tag moves past all of
|
||||||
|
them. The release script gets no floor and must not have one; there, `0.0.0` should be refused.
|
||||||
|
|
||||||
|
### 11. Linux is a third desktop platform, and it is a package rather than an updater
|
||||||
|
|
||||||
|
The Linux head existed and shipped as nothing: a checkout somebody built. `VelopackUpdateChannel` already
|
||||||
|
named the two platforms it serves rather than negating the one it does not, on the reasoning that "a future
|
||||||
|
AppImage should be an addition here rather than a thing that silently already half-happened". This is that
|
||||||
|
addition, and it is not an AppImage.
|
||||||
|
|
||||||
|
**The package is an RPM and nothing inside it replaces anything.** That is the decision, and everything
|
||||||
|
else follows from it. Velopack does have a Linux path, so the shape of decisions 1 through 9 could have been
|
||||||
|
repeated a third time — one more channel, one more index, one more client swapping its own files over. It
|
||||||
|
should not be, because this platform already has the mechanism that whole apparatus is a substitute for.
|
||||||
|
`dnf` is an updater the user already trusts, already audits and already controls, and it updates the
|
||||||
|
runtime's own dependencies besides. Adding a second one that answers to this project instead would be
|
||||||
|
building the capability decision 4 spends a page keeping unsettable, on the one platform where nobody had
|
||||||
|
to.
|
||||||
|
|
||||||
|
◆ **The first version of this rule said "there is no updater in it at all", and that went too far.** It
|
||||||
|
conflated two things the other two platforms happen to do together: *noticing* that a newer build exists,
|
||||||
|
and *installing* it. The second is what the argument above is about, and the first is not — a client that
|
||||||
|
checks a fixed address and tells you what it found has taken no capability from anybody. Written as a flat
|
||||||
|
refusal, the rule bought its safety by paying the cost in the paragraph below, and the cost was avoidable.
|
||||||
|
|
||||||
|
**So the split is: this client checks and fetches, and PackageKit installs.** `LinuxUpdateChannel` reads
|
||||||
|
the same rolling release the Windows nightly feed lives on, compares versions the way `rpm` compares them,
|
||||||
|
downloads the package, verifies its digest, and hands the file to PackageKit — which raises polkit and asks
|
||||||
|
the user, who may say no. `ApplyingEndsTheProcess` is false and nothing is torn down before the answer, which
|
||||||
|
is the phone's shape rather than the desktop's, and is already what `MainWindowViewModel` does with that
|
||||||
|
flag. Four things follow that are worth stating because each is a property the Velopack channels do not have
|
||||||
|
to think about:
|
||||||
|
|
||||||
|
- **The comparison is `rpmvercmp` and not SemVer.** Decision 7 made one version of record for the whole
|
||||||
|
repository, and this is the one place it is not the string that decides: `rpm` will accept or refuse the
|
||||||
|
install, so the client must compare the way `rpm` does or it can offer an update PackageKit then declines.
|
||||||
|
It is the same argument [ADR 0014](0014-android-updates.md) makes for comparing `versionCode`. The
|
||||||
|
manifest therefore carries both — the RPM version to compare and the SemVer to show a person — and the
|
||||||
|
installed side is read out of the rpm database rather than derived from the assembly, so the translation
|
||||||
|
`scripts/ci-linux-rpm.sh` performs exists in exactly one place.
|
||||||
|
- **The digest is checked, and it is not a signature.** Velopack verifies what it downloads and Android
|
||||||
|
refuses a package whose signature does not match the installed one. An RPM fetched over HTTPS and handed
|
||||||
|
to a package manager has neither, so the manifest names a SHA-256 and the client refuses anything else.
|
||||||
|
That catches a truncated download and a caching proxy and says nothing whatever about provenance, because
|
||||||
|
the same job writes the package and the manifest. Provenance here is decision 4's constant address.
|
||||||
|
- **`ONLY_TRUSTED` is cleared, deliberately.** The package is unsigned, so requiring PackageKit to trust it
|
||||||
|
would fail every update at the last step. Clearing the flag routes the request to polkit's
|
||||||
|
`package-install-untrusted` action, which asks for an administrator rather than proceeding quietly — the
|
||||||
|
user is told what they are agreeing to, which is the honest form of an unsigned package.
|
||||||
|
- **Nothing restarts.** Replacing files under a running process is ordinary on Linux and the old inodes stay
|
||||||
|
mapped, so the build keeps running until somebody closes it. That is the same courtesy decision 1's
|
||||||
|
consequences argue for on Windows, arrived at for free.
|
||||||
|
|
||||||
|
The honest cost that remains, recorded because it is the part that will be argued with: **discovery is still
|
||||||
|
worse here than on either other desktop, for anybody who declines the in-app check.** There is no package
|
||||||
|
repository to subscribe to, so `dnf upgrade` on its own will never bring a newer DodoSSH; the in-app check is
|
||||||
|
what stands in for one, and a user who turns it off on PREFERENCES → UPDATES has no other signal. A signed
|
||||||
|
repository is the fix, it is not built, and decision 11's last paragraph says what it would cost.
|
||||||
|
|
||||||
|
**CI publishes it, and only to the nightly channel.** Rule 3 is untouched and applies with its full force:
|
||||||
|
the token that writes a release is the capability to ship somebody a build, and it stays off runners. What
|
||||||
|
changes is nothing, because rule 9 already established a channel CI may publish to and this is a second
|
||||||
|
artefact on it. The release-shaped package is built on every `main` build and on every tag and uploaded
|
||||||
|
nowhere, exactly as the Windows and macOS packages are, which proves the `linux-x64` restore graph and the
|
||||||
|
packaging without putting an installer anybody has run in a place that invites passing it on.
|
||||||
|
|
||||||
|
Note what CI's check is and is not. `vpk` verifies that the main executable actually calls
|
||||||
|
`VelopackApp.Build().Run()` — a real assertion about the application, made for free by the packager. There
|
||||||
|
is no equivalent here, so `scripts/ci-linux-rpm.sh` makes its own: it asks the finished package what it
|
||||||
|
requires, what it provides, what files it holds and what modes they carry, and fails on each way a package
|
||||||
|
can build cleanly and then be uninstallable or unsafe. Two of those checks exist because the first realistic
|
||||||
|
payload tripped them — .NET's `libmscordbi.so` links `libmscordaccore.so`, which with bundled provides
|
||||||
|
excluded produces a dependency no host can satisfy; and `cp -a` off the wrong filesystem produced a
|
||||||
|
world-writable `/opt/DodoSSH`, which is a local privilege escalation against an application holding
|
||||||
|
plaintext credentials.
|
||||||
|
|
||||||
|
**The separations of rule 9 survive translation, and cost less here.** Two RPMs with different names owning
|
||||||
|
different paths cannot replace one another and `dnf` says so, where on Windows the same property had to be
|
||||||
|
built out of pack ids. The profile split is unchanged and still belongs to the build: `DodoChannel=nightly`
|
||||||
|
is what `ClientPaths` reads, and the packaging must not answer that question a second time.
|
||||||
|
|
||||||
|
**The device key store is settled separately and is not nothing.** The first version of this rule said this
|
||||||
|
platform had none and that the passphrase was asked for at every unlock. It has one:
|
||||||
|
[ADR 0007](0007-device-key-protection.md) decision 4 adds a TPM-sealed key released against a PIN the TPM
|
||||||
|
itself checks and counts failures against. It is weaker than Hello and Touch ID in a way that ADR names
|
||||||
|
precisely. Reaching the chip needs no group membership, because this package ships the udev rule that gives
|
||||||
|
the seated user an ACL on `/dev/tpmrm0` — a decision that ADR overturned from its own first answer, and
|
||||||
|
whose cost it states.
|
||||||
|
|
||||||
|
**What this platform does not have is a signature on the package**, which is not decision 8's "unsigned for
|
||||||
|
now": nothing on this platform
|
||||||
|
refuses or warns about an unsigned RPM, so there is no dialog to buy a certificate to remove. What stands in
|
||||||
|
for one is the same thing that stands in for it on the forge — TLS, and a project that publishes from one
|
||||||
|
address. An RPM repository would change that, because repository metadata is signed and a repository is a
|
||||||
|
thing users add once and then trust; that is the point at which a key becomes necessary rather than optional,
|
||||||
|
and it is the reason building one is a decision and not a chore.
|
||||||
|
|
||||||
|
**x86_64 only, for decision 10's reason and not a different one.** aarch64 is one argument to the publish
|
||||||
|
and one to the script. What is missing is somebody to run Phase 19 on such a machine, and this project does
|
||||||
|
not ship desktop builds nobody has run. The same sentence rules out claiming RHEL 9, which ships WebKitGTK's
|
||||||
|
4.0 API line under a soname the package deliberately does not accept: `dnf` refusing with *nothing provides
|
||||||
|
libwebkit2gtk-4.1.so.0* is a better answer than an install that draws a blank terminal.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
|
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
|
||||||
@@ -281,6 +469,10 @@ ADR 0011 changes.
|
|||||||
genuinely the nicest onboarding available and will be proposed again.
|
genuinely the nicest onboarding available and will be proposed again.
|
||||||
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
|
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
|
||||||
inherit it.
|
inherit it.
|
||||||
|
- **A Velopack channel for Linux.** The mechanism exists — Velopack builds an AppImage — and repeating
|
||||||
|
decisions 1 through 9 a third time would have been the consistent-looking move. Declined in decision 11
|
||||||
|
because this is the one desktop platform that already has an updater the user controls, and a second one
|
||||||
|
answering to this project is the capability decision 4 exists to make unsettable.
|
||||||
- **Azure Trusted Signing**, for now, on custody grounds rather than on mechanics. See decision 8.
|
- **Azure Trusted Signing**, for now, on custody grounds rather than on mechanics. See decision 8.
|
||||||
- **A hand-edited version property.** Considered seriously against MinVer, and it loses on the thing that
|
- **A hand-edited version property.** Considered seriously against MinVer, and it loses on the thing that
|
||||||
matters here: the tag is already the version of record for the container image, and two places to write
|
matters here: the tag is already the version of record for the container image, and two places to write
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ is the entire reason there are two.
|
|||||||
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
|
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
|
||||||
caches are encrypted under keys held by two package identities the platform keeps apart.
|
caches are encrypted under keys held by two package identities the platform keeps apart.
|
||||||
|
|
||||||
|
◆ **The desktop has since taken this arrangement, and it had to build what Android is given.**
|
||||||
|
[ADR 0013](0013-desktop-distribution-and-updates.md) decision 9 is this ADR applied to Windows: a nightly
|
||||||
|
CI publishes from main, installing beside the release build rather than over it. The difference worth
|
||||||
|
carrying back here is that every separation this ADR gets from the platform — different package identity,
|
||||||
|
signature-checked updates, a per-app data directory — is on Windows a thing somebody had to choose and can
|
||||||
|
therefore undo. The paragraph above about the attack surface applies there word for word, with one line
|
||||||
|
removed: on the desktop the packages are not signed at all, so the feed is the only thing standing between
|
||||||
|
a nightly install and an arbitrary build.
|
||||||
|
|
||||||
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
|
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
|
||||||
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
|
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
|
||||||
that an installed copy can now learn a newer one exists.
|
that an installed copy can now learn a newer one exists.
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# ADR 0015 — The Android terminal's input path leaves the WebView
|
||||||
|
|
||||||
|
- Status: proposed — **Stage 1 built, unverified on a device**
|
||||||
|
- Date: 2026-08-25
|
||||||
|
- Builds on: [`docs/android-port.md`](../android-port.md) §9, [ADR 0011](0011-android-distribution.md)
|
||||||
|
- Does not settle: whether the renderer stays xterm.js. That is [Stage 3](#stage-3--the-renderer-deliberately-unsettled), and it is deliberately left open.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The phone got the desktop's terminal, and the reasons were good ones. `TerminalDataPlane` already
|
||||||
|
served the page and a binary protocol over a loopback WebSocket, the xterm.js bundles were embedded
|
||||||
|
resources and platform-neutral, and `Avalonia.Controls.WebView` shipped a `net10.0-android36.0`
|
||||||
|
target. `docs/android-port.md` called it *"better news than expected"*, and for rendering it was.
|
||||||
|
|
||||||
|
**For typing it was not, and typing is the complaint.** Not scrollback, not colour, not throughput —
|
||||||
|
the act of putting characters into a shell. That narrows the problem usefully, because the input path
|
||||||
|
and the render path are separable, and only one of them is wrong.
|
||||||
|
|
||||||
|
### Three things sit between a finger and a byte, and the middle one is the fault
|
||||||
|
|
||||||
|
**1. Avalonia and the WebView fight over native focus.** `AvaloniaView.DispatchTouchEvent` ends every
|
||||||
|
handled touch — DOWN and UP alike — with a `RequestFocus()` for Avalonia's own view. The WebView's
|
||||||
|
input connection dies with its focus, so the keyboard swaps layouts and the inset churn can park it
|
||||||
|
over the accessory row that was tapped. `Platform/TerminalFocus.cs` exists to hand focus back, posted
|
||||||
|
to the next main-looper message so the platform has taken its turn first. It works. It is also a
|
||||||
|
counter-move in a fight that only exists because the thing holding the input connection is not the
|
||||||
|
thing the framework thinks is focused.
|
||||||
|
|
||||||
|
**2. The keyboard is a prose keyboard, and no DOM attribute makes it stop.** This is the one that was
|
||||||
|
worth checking rather than assuming, and the check inverted the obvious conclusion.
|
||||||
|
|
||||||
|
The vendored xterm.js *already* sets every hint the web platform has on its helper textarea —
|
||||||
|
`autocapitalize="off"`, `autocorrect="off"`, `spellcheck="false"`. They are set, and Gboard shows the
|
||||||
|
suggestion strip and predictive text anyway. So this is not a missing attribute; it is a ceiling.
|
||||||
|
|
||||||
|
Termux's `TerminalView.onCreateInputConnection` says what the ceiling is. It sets:
|
||||||
|
|
||||||
|
```
|
||||||
|
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||||
|
```
|
||||||
|
|
||||||
|
**`VISIBLE_PASSWORD` is the load-bearing half.** `NO_SUGGESTIONS` alone is advisory and most keyboards
|
||||||
|
ignore it — a point made in Avalonia's own Android keyboard PR discussion, not only in Termux's. What
|
||||||
|
actually turns off autocorrect, the suggestion strip and the predictive composing layer is the
|
||||||
|
*variation*: a field the keyboard believes is a visible password gets no dictionary. That is an
|
||||||
|
`EditorInfo.inputType` on a native `InputConnection`, and a page inside a WebView has no way to reach
|
||||||
|
it — Chromium computes the `EditorInfo` from the focused DOM node, and no combination of attributes on
|
||||||
|
a `<textarea>` produces the visible-password variation.
|
||||||
|
|
||||||
|
**3. The accessory row is a workaround for a keyboard, not a keyboard.** Esc, Tab, Ctrl and the arrows
|
||||||
|
are right and stay. But every tap on one costs the focus round-trip in (1), and none of them can be
|
||||||
|
held or repeated.
|
||||||
|
|
||||||
|
### The finding that decides this ADR
|
||||||
|
|
||||||
|
Avalonia already emits Termux's exact `inputType`. Decompiled from the pinned
|
||||||
|
`Avalonia.Android` 12.1.1, `AndroidInputMethod<TView>.OnCreateInputConnection`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
|
||||||
|
{
|
||||||
|
outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`0x80080` is `TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS`. The guard reads:
|
||||||
|
`ContentType` left at `Normal`, `Multiline` false, `AutoCapitalization` false, and
|
||||||
|
`TextInputOptions.ShowSuggestions` explicitly `false` — a `bool?` whose default is `null`, so it must
|
||||||
|
be set, not merely left alone. The same method also ORs `IME_FLAG_NO_EXTRACT_UI |
|
||||||
|
IME_FLAG_NO_FULLSCREEN` into `imeOptions`, which is the other thing Termux sets and for the same
|
||||||
|
reason: a terminal must never be replaced by a full-screen extract editor in landscape.
|
||||||
|
|
||||||
|
So the keyboard behaviour that makes Termux feel like a terminal is **already available on the
|
||||||
|
version this repository pins**, to any Avalonia control that asks for it. It is not available to the
|
||||||
|
WebView, and never will be. That asymmetry is the whole decision.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Avalonia owns the keystrokes. The WebView renders and nothing else.
|
||||||
|
|
||||||
|
A focusable Avalonia control — `TerminalInputSurface` — becomes the terminal's input target. It draws
|
||||||
|
nothing. It sits in the same `Panel` as the renderer, takes focus when the terminal surface is shown,
|
||||||
|
and keeps it for as long as that surface is up.
|
||||||
|
|
||||||
|
- `TextInputOptions.ShowSuggestions = false`, with `ContentType`, `Multiline` and `AutoCapitalization`
|
||||||
|
left at the defaults the guard above requires. This is the entire fix for the suggestion strip.
|
||||||
|
- `TextInputOptions.ReturnKeyType = Return`, which is the fix for Enter — see the correction below.
|
||||||
|
- It implements `TextInputMethodClient` with an empty surrounding text and a zero-length selection.
|
||||||
|
A terminal has no document for an IME to edit; committed text is forwarded and nothing is retained.
|
||||||
|
- Committed text and key events are encoded to bytes and written to the session through the same path
|
||||||
|
`TerminalScreen`'s accessory keys already use. No new transport, no change to `TerminalDataPlane`.
|
||||||
|
- The native WebView is made non-focusable (`Focusable = false`, `FocusableInTouchMode = false`) once,
|
||||||
|
at attach.
|
||||||
|
|
||||||
|
**`Platform/TerminalFocus.cs` is deleted by this, not amended.** The fight it referees ends when only
|
||||||
|
one side is a focus candidate. Its remarks should move into the new control as the record of why the
|
||||||
|
WebView is deliberately unfocusable, because that reasoning outlives the workaround.
|
||||||
|
|
||||||
|
`Platform/SoftKeyboard.cs` stays. Dismissing a keyboard on the way to a sheet is still a platform
|
||||||
|
call, and Avalonia still offers no way to close one.
|
||||||
|
|
||||||
|
### Not: patching xterm.js's textarea
|
||||||
|
|
||||||
|
Already tried by xterm.js itself, and already failing — see Context (2). The attributes are set. The
|
||||||
|
platform ignores them. There is no version of this that reaches `VISIBLE_PASSWORD` from inside a
|
||||||
|
WebView, so the effort buys nothing.
|
||||||
|
|
||||||
|
### Not: a native Android `View`, Termux-shaped, today
|
||||||
|
|
||||||
|
Termux's `TerminalView` + `TerminalRenderer` drawing to a `Canvas` is the right architecture for a
|
||||||
|
phone-only terminal, and DodoSSH is not a phone-only terminal. Writing one now means two terminal
|
||||||
|
implementations — this one and the desktop's — with two escape-sequence behaviours to keep honest
|
||||||
|
against each other, for a defect that is entirely in the input path. That is a large bill for the
|
||||||
|
wrong problem.
|
||||||
|
|
||||||
|
It also does not become unavailable later. Stage 3 keeps it on the table.
|
||||||
|
|
||||||
|
### Not: replacing xterm.js now
|
||||||
|
|
||||||
|
Rendering is not the complaint. xterm.js with the WebGL addon draws a correct, fast grid, and
|
||||||
|
`terminal.js` already handles renderer death, reattach and refit — hard-won behaviour recorded in
|
||||||
|
`docs/android-port.md` that a rewrite would have to re-earn. Replacing a working renderer to fix a
|
||||||
|
keyboard is the wrong trade, and doing it in the same change as the input move would make neither
|
||||||
|
half attributable when the result is judged on a device.
|
||||||
|
|
||||||
|
## Staging
|
||||||
|
|
||||||
|
### Stage 1 — the input surface · **built**
|
||||||
|
|
||||||
|
The decision above, and nothing else. This is the change that answers the complaint, and it should be
|
||||||
|
judged on a device on its own before anything else is built on it.
|
||||||
|
|
||||||
|
What landed:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| `Views/TerminalInputSurface.cs` | The 1×1 focusable control, `ShowSuggestions = false`, the IME client that retains nothing, and the key→escape-sequence table |
|
||||||
|
| `Views/TerminalInputEventArgs.cs` | One keystroke's bytes, on the way to `TerminalScreen.SendAsync` |
|
||||||
|
| `Platform/RendererFocus.cs` | Makes the `WebView` non-focusable and reports taps on it |
|
||||||
|
| `Platform/TerminalFocus.cs` | **Deleted.** The fight it refereed has one contestant now |
|
||||||
|
| `Views/TerminalScreen.axaml{,.cs}` | Hosts the surface, raises the keyboard on arrival, drops the two posted focus counter-moves from the accessory row |
|
||||||
|
|
||||||
|
**One thing gained that was not asked for, and it was a real defect:** the Ctrl latch now reaches the
|
||||||
|
software keyboard. It never did before — everything typed went straight into the page and never passed
|
||||||
|
`SendAsync`, which is where the latch lives — so `ctrl` then `c` was inert unless the second key also came
|
||||||
|
from the accessory row. Check 11.10d covers it.
|
||||||
|
|
||||||
|
**Verify before building anything on this:** checks 11.10a–11.10e in `docs/manual-checks.md`, and 11.10b
|
||||||
|
first of all, because it is the falsification test at the end of this document.
|
||||||
|
|
||||||
|
> **⚠️ Corrected on the first device run: every Enter closed the keyboard.** The premise held — the
|
||||||
|
> suggestion strip, autocorrect and predictive text were all gone — but the field had been left with
|
||||||
|
> Avalonia's default `ReturnKeyType`, and the same `OnCreateInputConnection` maps that, on a field that is
|
||||||
|
> not `Multiline`, to `IME_ACTION_DONE`:
|
||||||
|
>
|
||||||
|
> ```csharp
|
||||||
|
> _ => options.Multiline ? 1073741824 : 6, // 6 is IME_ACTION_DONE
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Done means done: the keyboard performs the action and dismisses itself. On a text box that is right, and
|
||||||
|
> on a terminal it fires on every command. `TextInputReturnKeyType.Return` — value 1, so
|
||||||
|
> `returnKeyType - 1 => 0 => 1073741824` — is `IME_ACTION_NONE`, which leaves Enter a plain newline with no
|
||||||
|
> action for the IME to perform and no reason for it to hide.
|
||||||
|
>
|
||||||
|
> **The trap worth recording, because it is the same trap 11.10b already warned about from the other side:**
|
||||||
|
> `Multiline = true` also reaches `IME_ACTION_NONE`, through the default arm, and is the wrong way there. It
|
||||||
|
> ORs `TYPE_TEXT_FLAG_MULTI_LINE` into `InputType`, which moves it off `1` and silently fails the
|
||||||
|
> `ShowSuggestions` guard. Taking the obvious route to a working Enter would have handed the suggestion
|
||||||
|
> strip back and looked like the whole ADR had failed. `ReturnKeyType` touches `imeOptions` only.
|
||||||
|
>
|
||||||
|
> Enter's *byte* was never in doubt and is not what changed — `IME_ACTION_DONE` was already synthesising
|
||||||
|
> `KEYCODE_ENTER` through `AvaloniaInputConnection.PerformEditorAction`, so commands ran; the keyboard just
|
||||||
|
> left afterwards. `OnTextInput` now also normalises line endings to CR, for the case where a keyboard with
|
||||||
|
> no editor action commits `"\n"` as ordinary text instead of sending a key event, and so that a pasted
|
||||||
|
> `"\r\n"` does not run its line twice.
|
||||||
|
|
||||||
|
### Stage 2 — the keys around it
|
||||||
|
|
||||||
|
Only once Stage 1 is proven, because each of these is cheap *given* Avalonia owning input and awkward
|
||||||
|
without it:
|
||||||
|
|
||||||
|
- **Hold-to-repeat** on the arrows and Backspace. Currently impossible without a focus round-trip per
|
||||||
|
tap.
|
||||||
|
- **Ctrl held rather than latched**, with latching kept as the fallback. `TerminalScreen`'s remark is
|
||||||
|
right that latching is what phone SSH clients do; held becomes possible when one control sees both
|
||||||
|
the modifier press and the following keystroke.
|
||||||
|
- **Pinch and volume keys for font size**, replacing the pinned `A−`/`A+` buttons. Both are what
|
||||||
|
JuiceSSH does and what its users name first; the buttons stay as the discoverable path.
|
||||||
|
|
||||||
|
Volume-key capture is an `onKeyDown` on the activity and is deliberately opt-in — a phone that cannot
|
||||||
|
change its volume with a shell open is a worse phone.
|
||||||
|
|
||||||
|
### Stage 3 — the renderer, deliberately unsettled
|
||||||
|
|
||||||
|
If, after Stages 1 and 2, the terminal still reads as a web page — touch selection, scrollback
|
||||||
|
survival across renderer death, scroll feel — then the renderer is the next question, and the
|
||||||
|
candidates are known:
|
||||||
|
|
||||||
|
| Option | What it is | Cost |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [XTerm.NET](https://github.com/tomlm/XTerm.NET) | MIT, headless, UI-agnostic; VT100/ANSI, dual buffers, 256/true colour, wide chars | Evaluate emulation fidelity against xterm.js |
|
||||||
|
| [VtNetCore](https://github.com/darrenstarr/VtNetCore) | MIT, .NET Standard 2.0, older but proven | Maintenance status |
|
||||||
|
| Port xterm.js's core | Exact behavioural parity with the desktop | Largest, and a permanent fork |
|
||||||
|
|
||||||
|
Rendered by a custom Avalonia `Control` via `DrawGlyphRun`, which is Termux's architecture expressed
|
||||||
|
in Avalonia and would work on the desktop too — eventually letting the WebView2 dependency go, and
|
||||||
|
with it the `targetSdk` 36 floor `Avalonia.Controls.WebView` currently forces.
|
||||||
|
|
||||||
|
**Stage 1 is not wasted if Stage 3 happens.** The input surface is exactly the layer such a control
|
||||||
|
would need, unchanged. That is the reason for splitting them.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**What gets better, and it is one thing done properly.** No suggestion strip, no autocorrect, no
|
||||||
|
predictive composing layer, no full-screen extract editor, and no focus flap on an accessory key.
|
||||||
|
That is the difference between typing at a shell and fighting a phone.
|
||||||
|
|
||||||
|
**CJK and other composing input is degraded, and this is a real cost.** `VISIBLE_PASSWORD` suppresses
|
||||||
|
composition, which is precisely what makes it work for a terminal and precisely what a Chinese,
|
||||||
|
Japanese or Korean user needs. Termux has the same limitation and lives with it. If it ever needs
|
||||||
|
answering, the answer is a per-session toggle that drops `ShowSuggestions = false` and accepts the
|
||||||
|
prose keyboard back — not a middle setting, because there isn't one.
|
||||||
|
|
||||||
|
**Text selection and copy are unchanged**, still xterm.js's own DOM handling, still without touch
|
||||||
|
selection handles — [xterm.js #5377](https://github.com/xtermjs/xterm.js/issues/5377) is open and
|
||||||
|
unassigned and this ADR does not fix it. Stage 3 territory.
|
||||||
|
|
||||||
|
**Scrollback still dies with the renderer process.** Unchanged, still honest about it via the replay
|
||||||
|
banner, still Stage 3 territory.
|
||||||
|
|
||||||
|
**Nothing here is verifiable headless**, for the reason `docs/manual-checks.md` §11 already gives.
|
||||||
|
Stage 1 needs new manual checks — at minimum: the suggestion strip is absent with a shell open; an
|
||||||
|
accessory key does not disturb the keyboard (§11.10a, which should now pass trivially rather than by
|
||||||
|
posted counter-move); landscape does not produce a full-screen extract editor; and a hardware
|
||||||
|
keyboard still reaches the shell.
|
||||||
|
|
||||||
|
**The hardware-keyboard path moves.** It currently arrives through the WebView; after this it arrives
|
||||||
|
through Avalonia. Behaviour should be identical and probably will not be on the first try.
|
||||||
|
|
||||||
|
## What would falsify this
|
||||||
|
|
||||||
|
If, on a device, `ShowSuggestions = false` does not suppress the suggestion strip — because the guard
|
||||||
|
was not satisfied, or the keyboard ignores the variation too — then Stage 1's premise is wrong and
|
||||||
|
the honest next step is Stage 3 with a native `InputConnection`, not more Avalonia. **Check this
|
||||||
|
first, on the smallest possible spike, before building `TerminalInputSurface` properly.** It is one
|
||||||
|
attached property on a focusable control and a look at the keyboard.
|
||||||
+101
-6
@@ -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,43 @@ 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.
|
||||||
|
|
||||||
|
> **⚠️ Under review, on the input half only.** The renderer's story above stands; what has not held up in
|
||||||
|
> use is *typing*. A software keyboard over a WebView cannot be told to stop autocorrecting — the DOM
|
||||||
|
> attributes xterm.js already sets are advisory and Android ignores them — and the accessory row pays a
|
||||||
|
> focus round-trip per tap. [ADR 0015](adr/0015-android-terminal-input.md) proposes moving the input path to
|
||||||
|
> Avalonia and leaving the renderer exactly as this section describes it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Decisions taken
|
## Decisions taken
|
||||||
@@ -280,13 +320,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 +600,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).
|
||||||
@@ -568,9 +650,16 @@ go at 360dp:
|
|||||||
believing they typed an `l`.
|
believing they typed an `l`.
|
||||||
|
|
||||||
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
|
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
|
||||||
and the bottom bar while a terminal is showing — one binding on `IsShowingPages` each — and the screen
|
and the bottom bar while a shell is showing, and the screen draws a 35-pixel bar in their place: back,
|
||||||
draws a 35-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three
|
the session pills, and a `+` raising a sheet with the three connections there are.
|
||||||
connections there are. That sheet is the head's first control that could be drawn over the renderer, so
|
|
||||||
|
*A shell rather than the surface, and the two parted company once that surface gained a connect page.*
|
||||||
|
With nothing running, Connections is a box, a CONNECT button and the machines connected to before — a
|
||||||
|
page in everything but which enum it is in — so `RefreshChrome` keeps the bar (and, wide, the rail) under
|
||||||
|
it and the Connections entry lights for the first time. It is the one screen reachable by closing your
|
||||||
|
last tab, and collapsing the nav there left the system back gesture as the only route to Hosts or
|
||||||
|
Settings. The header is not part of that: the surface draws its own bar, and a vault header above it is
|
||||||
|
the second row of chrome this head exists to avoid. That sheet is the head's first control that could be drawn over the renderer, so
|
||||||
it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
|
it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
|
||||||
WebView actually composites above Avalonia content is still the unverified question recorded below, and
|
WebView actually composites above Avalonia content is still the unverified question recorded below, and
|
||||||
collapsing is correct under either answer.
|
collapsing is correct under either answer.
|
||||||
@@ -666,6 +755,12 @@ Recorded so they are choices rather than accidents. Any of them is cheap to revi
|
|||||||
local-and-non-roaming requirement more cleanly than any desktop platform does.
|
local-and-non-roaming requirement more cleanly than any desktop platform does.
|
||||||
- **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns
|
- **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns
|
||||||
something like `localhost` on Android and would make every entry from a phone indistinguishable.
|
something like `localhost` on Android and would make every entry from a phone indistinguishable.
|
||||||
|
- **The Hetzner Cloud importer is not ported, and it is the one gap here that is not a platform limit.**
|
||||||
|
Its view model is in `DodoSSH.Client.Shell` like every other and its reader is an `HttpClient` call, so
|
||||||
|
both halves already run on a phone — unlike the `~/.ssh/config` importer beside it, which has no file to
|
||||||
|
read under scoped storage and never will. What is missing is a layout: the desktop draws a five-column
|
||||||
|
table of servers, and a phone version of that is a different screen rather than the same one narrower.
|
||||||
|
Said on the Preferences screen rather than left to be discovered.
|
||||||
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
|
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
|
||||||
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
|
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
|
||||||
confirmed to exist before anything is written to solve it.
|
confirmed to exist before anything is written to solve it.
|
||||||
|
|||||||
+274
-14
@@ -69,7 +69,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> labelled sidebar and the chrome went from 72 tall to 86, so `880x560` became `1016x574` — leaving every
|
> labelled sidebar and the chrome went from 72 tall to 86, so `880x560` became `1016x574` — leaving every
|
||||||
> screen the same `826x464` it was designed against. Four of the tables stop fitting at 690 wide, so
|
> screen the same `826x464` it was designed against. Four of the tables stop fitting at 690 wide, so
|
||||||
> widening the sidebar without widening the window would have broken them where the layout suite was not
|
> widening the sidebar without widening the window would have broken them where the layout suite was not
|
||||||
> looking.
|
> looking. **v5b grows it again**, to `1081x583` — see that section, below.
|
||||||
>
|
>
|
||||||
> **Buckets became a destination** rather than a toggle inside the files screen, matching the phone: the
|
> **Buckets became a destination** rather than a toggle inside the files screen, matching the phone: the
|
||||||
> `HOST` / `BUCKET` pair is gone and `ShellScreen.Buckets` draws the same `TransfersScreen` with the other
|
> `HOST` / `BUCKET` pair is gone and `ShellScreen.Buckets` draws the same `TransfersScreen` with the other
|
||||||
@@ -102,6 +102,14 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is
|
> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is
|
||||||
> a page test rather than a fourth `ShellSurface`.
|
> a page test rather than a fourth `ShellSurface`.
|
||||||
>
|
>
|
||||||
|
> **v5b reverses the paragraph above, and says so rather than leaving it to be found out of date.** The tab
|
||||||
|
> strip this paragraph describes is retired — see the v5b section, below — and with it the rule that made the
|
||||||
|
> rail conditional at all: the switcher it drew moved onto the nav rail's own head as three segments, the
|
||||||
|
> rail stopped collapsing for any of them, and neither SFTP, S3 nor a terminal gets the window's full width
|
||||||
|
> any longer. `IsVaultsTab` outlived the strip essentially unchanged — it is still a page test rather than a
|
||||||
|
> read of a `ShellSurface`, now over which of the rail's own destinations is showing rather than which of
|
||||||
|
> three tabs was.
|
||||||
|
>
|
||||||
> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar
|
> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar
|
||||||
> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds
|
> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds
|
||||||
> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The
|
> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The
|
||||||
@@ -160,11 +168,214 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> | **Share this host** | Omitted, as the vault screen's `SHARED WITH · 6` is. A grant is per *vault* and per-item sharing is M5; a button here would imply this one host could be handed over, which is the thing the architecture does not do. |
|
> | **Share this host** | Omitted, as the vault screen's `SHARED WITH · 6` is. A grant is per *vault* and per-item sharing is M5; a button here would imply this one host could be handed over, which is the thing the architecture does not do. |
|
||||||
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
|
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
|
||||||
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
|
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
|
||||||
> | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. |
|
> | The **Backspace / Default** row | Omitted, and it is the one row on this list that could now be built cheaply — a setting, an opcode and a control, the way the text size was. What it lacks is a reason: which byte backspace sends is a fact about the remote's `stty`, so a client-side switch fixes a mismatch by hiding it. See the Preferences section. |
|
||||||
> | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name. A group moves too, from its card's right-click menu, and takes its nested groups and every host filed under them; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. |
|
> | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name. A group moves too, from its card's right-click menu, and takes its nested groups and every host filed under them; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. |
|
||||||
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
|
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
|
||||||
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. |
|
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. |
|
||||||
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
|
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
|
||||||
|
>
|
||||||
|
> ## The desktop's v5 — hosts, restyled, and two reversals
|
||||||
|
>
|
||||||
|
> A fifth pass, and the first that undoes as much of its own predecessors as it adds: a re-theme both heads
|
||||||
|
> share, pinned folders as a full feature, a last-connected time read off the connection log, and two pieces
|
||||||
|
> of earlier passes put back on the user's own instruction rather than kept.
|
||||||
|
>
|
||||||
|
> **The palette went purple, and three fonts are embedded that were only ever named before.** The shared
|
||||||
|
> scheme moves from v3's near-black-and-blue to a purple accent (`#5D42DE`, with a gradient and a glow of its
|
||||||
|
> own) over the same near-black canvas, and both heads recolour together — `Palette.axaml` is shared, and a
|
||||||
|
> colour is not allowed to mean something different on the two of them. What changed on the desktop alone is
|
||||||
|
> the type: Montserrat, JetBrains Mono and Material Icons are embedded as `AvaloniaResource` in the Shell
|
||||||
|
> project rather than requested by name and left to whatever the machine happens to have, the way `WithInterFont`
|
||||||
|
> alone used to leave Inter registered but unused. Montserrat is the default sans now, with Inter kept as its
|
||||||
|
> fallback rather than the whole answer; `MonoFont` gains JetBrains Mono at the front of its own list, ahead of
|
||||||
|
> the system-mono stack that is still behind it for a glyph JetBrains Mono does not cover. **Android recoloured
|
||||||
|
> with the shared palette and kept its own default sans when this paragraph was first written; it does not any
|
||||||
|
> more** — see "the phone catches up", directly below.
|
||||||
|
>
|
||||||
|
> **The phone catches up.** A later reversal than the one above, on the same reasoning: a face is not a
|
||||||
|
> colour, and the user decided the phone should share both rather than only the second. `WithInterFont` gains
|
||||||
|
> the same `FontManagerOptions` block `Program.cs` sets, off the same embedded Montserrat, no new asset
|
||||||
|
> required. `Theme/Phone.axaml`'s six-rung v2 ladder — 4/9/10/11/12/14 — collapses to the desktop's three:
|
||||||
|
> chip or tag 6, button or field 10, card or output 12; `Button.fab`'s 28 and every sheet's 22 survive
|
||||||
|
> unchanged, being geometry rather than ladder rungs. `Button.primary` and `Button.fab` take `AccentGradient`
|
||||||
|
> and `AccentGlow` in place of a flat fill, the same swap `Button.accent` made on the desktop, `BoxShadow`
|
||||||
|
> cleared the same way on disabled. And the phone's own chrome — the vault header, the bottom bar, the shells
|
||||||
|
> strip, `HostActionBar`, the editor headers, the terminal's collapsed bar and every screen's raised selection
|
||||||
|
> bar — moves from `Chrome`/`Sidebar` to `DeepChrome`, joining the desktop's own v5b titlebar-and-rail move;
|
||||||
|
> `PhoneRail`'s hover takes `Track`, which `Palette.axaml` already names for that row. Borders stay keyed.
|
||||||
|
>
|
||||||
|
> **Flat sections replace the grid of group cards and the breadcrumb trail — reversing v3, on the user's own
|
||||||
|
> approval rather than a defect found in it.** v3's grid held one level of the group tree at a time, opened by
|
||||||
|
> a double-click, with a trail above it saying where you were; that model is retired outright rather than
|
||||||
|
> folded into anything; every group is now a heading on one flat board, in label order, exactly the flattening
|
||||||
|
> the phone's `SidebarRows` has always drawn — a headerless board for a groupless keychain, "No group" first
|
||||||
|
> where there is one, a chevron per heading and one Collapse All. Nothing here was found broken; the grid did
|
||||||
|
> what v3 asked for. The user asked for the phone's shape on the desktop too, and decision 2 in
|
||||||
|
> `hosts-v5-design-spec.md` is that request recorded.
|
||||||
|
>
|
||||||
|
> **Tag chips and a mono address line are back on the card — reversing v4, also by approval and not by
|
||||||
|
> defect.** v4 had replaced both with a two-line `ssh, root, pci, eu-west-1` subtitle and moved the address to
|
||||||
|
> the tooltip alone, on the reasoning that a card read while scanning forty machines wants the kind of machine
|
||||||
|
> more than its address. v5's own mock draws chips and an address on every card, and the user chose the mock's
|
||||||
|
> arrangement over v4's when the two were put side by side — so the card now carries both: the full address is
|
||||||
|
> still on the tooltip, as v4 left it, and a truncated mono copy sits under the name as well.
|
||||||
|
>
|
||||||
|
> **Pinned folders are a full feature, not a field with nowhere to be edited.** `HostSecret.PinnedPaths` is an
|
||||||
|
> ordered, deduplicated list merged per path the way `TagIds` is merged per tag, so two machines pinning
|
||||||
|
> different folders on one host both keep theirs. The drawer's QUICK ACCESS section edits it — a row per pin
|
||||||
|
> with a close box, an add field and button, staged on `VaultViewModel.EditorPinnedPaths` the way the tag
|
||||||
|
> picker stages `editorTagIds` — the card shows a pin-count badge once there is at least one, and the detail
|
||||||
|
> pane repeats the list read-only in its own fieldrow idiom. Clicking a pin above a connected terminal opens
|
||||||
|
> the SFTP tab with the remote pane navigated straight to that path, through the same connect path "Browse
|
||||||
|
> files" already used — a second authenticated connection, exactly as every SFTP session here is.
|
||||||
|
>
|
||||||
|
> **Last connected is read off the synced connection log, and it is deliberately narrow about when.**
|
||||||
|
> `max(StartedAt)` per `HostId` is computed on the hosts screen's own activation and again when a session ends,
|
||||||
|
> then restrung into words on a one-minute tick for as long as the screen stays visible — never on the sync
|
||||||
|
> loop, which is the same rule `LogsViewModel` states for the log itself: nobody is watching their own
|
||||||
|
> connection from an hour ago update on a screen they are not looking at. A connected host prints nothing here;
|
||||||
|
> the green dot already answers "right now", and printing an age beside it would be answering a question about
|
||||||
|
> a session that is not the one still running. **It is scoped to the active vault alone**, the same limitation
|
||||||
|
> `LogsViewModel` and `MainWindowViewModel.RecentConnections` already carry — a host filed in a second vault
|
||||||
|
> this session can read gets no ago-text until something on this screen reads more than one vault's connection
|
||||||
|
> history, which nothing does yet. And a session ending can, briefly, still show the previous entry: the
|
||||||
|
> recorder's own write is queued onto a background task rather than made inline, so a re-read landing before
|
||||||
|
> that write drains reads the log as it stood before the session it is reporting on closed. It self-heals on
|
||||||
|
> the next activation or the next session end, and closing the gap outright was judged not worth blocking a
|
||||||
|
> screen transition on the recorder's queue.
|
||||||
|
>
|
||||||
|
> **Quick connect grew to match, and quietly answers a different question than the mock's own row does.** The
|
||||||
|
> card widened from 520 to 640, gained the mock's `>_` prompt styling and a live dot per row the way the hosts
|
||||||
|
> board's own two-state dot works. What it does not draw is the mock's SSH/SFTP kind column — every palette
|
||||||
|
> connection here is SSH, so a constant printed as though it varied would be exactly the kind of decoration
|
||||||
|
> this document has refused before (the terminal's session footer, the snippet's kind badge); the auth word
|
||||||
|
> (credential/key/password) answers a question that is actually different per row instead.
|
||||||
|
>
|
||||||
|
> | v5 element | What ships instead |
|
||||||
|
> | --- | --- |
|
||||||
|
> | The status dot's third, amber state | ◆ **Still two states, on the same reasoning v3 and v4 both gave and v5 was asked to reconsider.** Green means a terminal is open on that host, grey means one is not; nothing here pings a machine, so the amber "reachable but not connected" state has no fact behind it to draw. |
|
||||||
|
> | The relay checkbox's mock copy | Restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it). The "(not wired up yet)" it carried while this client could not dial the relay came out when it could; the sentence under it now says what ticking it does — the server dials the host, so the server has to be able to reach it, and the address is stored in plain text. |
|
||||||
|
> | 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. |
|
||||||
|
> | 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 | ◆ **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. |
|
||||||
|
> | 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 design's two deeper text steps, `#6D6F84` and `#5D5F74` | Not added as `Palette.axaml` keys. `TextGhost`, already repicked to `#7C7F98` for v3, is reused everywhere the design reaches for either — a resource nothing yet distinguishes from `TextGhost` is not free to carry, on the same reasoning the border ramp's own remarks give. |
|
||||||
|
>
|
||||||
|
> ## The desktop's v5b — chrome, session shell and SFTP restyled
|
||||||
|
>
|
||||||
|
> A sixth pass, and the first aimed at the chrome and the two screens somebody spends the most time inside
|
||||||
|
> rather than at any one screen on its own. **What shipped:** the titlebar and nav rail redrawn against
|
||||||
|
> `TitleBar.dc.html` and `NavRail.dc.html` — a segmented SSH/SFTP/S3 switcher at the rail's own head, a
|
||||||
|
> mode-dependent first row beneath it, and Vaults and Preferences moved off the rail's list and into a
|
||||||
|
> popover under the user chip at its foot. The terminal and the SFTP surface both gained a session shell — an
|
||||||
|
> in-screen tab row, a host header, a bottom status bar and a 300-pixel QUICK ACCESS/SNIPS sidebar —
|
||||||
|
> replacing the window-wide tab strip v3 built and the pin-chip strip that used to sit above the terminal
|
||||||
|
> alone. The SFTP screen's own two panes and its TRANSFERS strip were restyled against `SFTP.dc.html` — row
|
||||||
|
> grids, quiet Material glyphs, slim progress bars — and three screens that had never had a `.dc.html` of
|
||||||
|
> their own got one each: Keychain, Snippets (titled Snips on screen now, matching the rail) and Logs.
|
||||||
|
>
|
||||||
|
> **The nav rail stopped being conditional furniture, and that is the one structural change underneath the
|
||||||
|
> paint.** v3 drew it only under the Vaults tab, so SFTP, S3 and an open terminal each got the window's full
|
||||||
|
> width; v5b's own fidelity pass puts the rail up beside all three, because the design draws it that way and
|
||||||
|
> because the segmented switcher now living at its head is only worth having if it stays reachable from
|
||||||
|
> everywhere. `MainWindowViewModel.IsVaultsTab` still exists and still answers the question it always did —
|
||||||
|
> which of the rail's own six destinations is on screen, as opposed to SFTP or S3 — but nothing about it gates
|
||||||
|
> the rail's own visibility any more. The window's minimum grew again to hold the difference: `1016x574`
|
||||||
|
> became `1081x583`, nine pixels for the titlebar's 44-to-53 and sixty-five for the rail's 190-to-255, added
|
||||||
|
> straight onto the minimum rather than absorbed by shrinking a screen — see `MainWindow.axaml`'s own remark
|
||||||
|
> and `LayoutHarness.MinimumWidth`/`MinimumHeight`. `ScreenWidth` held at 826 because the rail is the only
|
||||||
|
> thing beside a page that grew; `ScreenHeight` grew by the 42 pixels the retired tab strip used to cost every
|
||||||
|
> screen, because nothing replaced that strip as chrome every screen pays for — the terminal and SFTP
|
||||||
|
> surfaces pay for their own tab row out of their own session-shell budget instead. See
|
||||||
|
> `LayoutHarness.ScreenHeight` and `SessionScreenHeight`.
|
||||||
|
>
|
||||||
|
> | v5b element | What ships instead |
|
||||||
|
> | --- | --- |
|
||||||
|
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
|
||||||
|
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
|
||||||
|
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
|
||||||
|
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
|
||||||
|
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
|
||||||
|
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
|
||||||
|
> | No pins destination in the design at all | **The rail agrees with the design now.** `KnownHostsScreen` is still built and still reachable — from **Host keys** on the Keys screen's own header, which was always the second way in — but the rail's Pins row is gone. It was kept through v5b on the grounds that the mock has no screen for approved host keys, which is a reason for the screen to exist and was never a reason for a rail entry once the keychain had a door to the same place. Two rail rows landing on one screen is a rail that has to be read twice. |
|
||||||
|
> | The popover's Settings and Preferences rows, and the design's own Settings-* family of screens | **Landed in v5c.** What was two doors to one room in v5b — Settings and Preferences both opening the same bare `Preferences` screen — is now two of three doors onto their own settings pages: Settings opens General, Preferences opens Preferences, and a third row, Vaults, opens Vaults. All three are real, distinct pages inside one settings mode; see the v5c section, below. |
|
||||||
|
> | `· Org` after the user chip's name, and a `Primary` tag on a vault row in the popover | Neither. There is no organisation concept behind a vault — only the vault itself — and no vault is distinguished as primary; the popover's vault rows are the existing shown-vaults toggles, restyled. |
|
||||||
|
> | The design's titlebar, which has nowhere for a sync indicator | `SYNCED` stays, on the titlebar's right side, ahead of the window's own minimise/maximise/close buttons — the one thing this titlebar keeps that the design's own does not draw at all. |
|
||||||
|
> | Per-tab SFTP sessions, implied by a tab row shared between the terminal and the SFTP surface | **Not built, and not what shipped instead.** A click on the SFTP tab row runs `MainWindowViewModel.SelectFilesHostAsync`, which opens (or reuses) a second, SFTP-specific connection through the same "Browse files" plumbing a pin click already used — an honest second login, not a channel multiplexed onto the terminal's. `SelectedTab` moves with the click, which is also what keeps the sidebar's QUICK ACCESS in step — that list is keyed to `SelectedTab` on both surfaces, so selecting a different terminal tab afterwards can leave QUICK ACCESS naming a host that is not the one the remote pane is actually browsing. |
|
||||||
|
> | A scroll-to-section affordance behind the sidebar's **+ Pin folder** row | Not built — this application has no way to open the host editor already scrolled to one card inside it. `PinFolderFromSidebarCommand` opens the same three-card editor the hosts screen's own EDIT reaches, at the top, which is the closest honest affordance rather than a new one. |
|
||||||
|
> | The TRANSFERS strip's aggregate throughput readout (`8.4 MB/s` beside an arrow) | Omitted. `TransferRowViewModel.Progress` computes a rate per transfer; nothing sums those into one number for the whole queue, and inventing one would be exactly the fabricated fact this document's honesty rule forbids. |
|
||||||
|
> | Freshness/"hot row" colouring and a selection ring on a finished transfer | Not drawn — there is no tracked notion of how recently a row finished, so nothing distinguishes a transfer that just completed from one that finished an hour ago. |
|
||||||
|
> | The design's Material-Symbols-only "draft" glyph for a file row | `insert_drive_file`, the closest classic Material Icons has — a plain document rather than a page with a folded corner. Recorded as a substitution rather than silently swapped; "draft" is simply not a glyph this font contains. |
|
||||||
|
> | `REMOTE` across the SFTP panes' arrow column | `HOST`/`BUCKET`, kept from before this pass rather than adopted from the design. The pair is the difference between a directory tree and a flat namespace with inferred folders, which is a distinction worth keeping even where the design collapses it. |
|
||||||
|
> | `THIS MACHINE` on the local pane | `LOCAL`, the design's own word, adopted — nothing in this file's own comments ever defended "THIS MACHINE" the way HOST/BUCKET is defended above, so there was no reason to keep it. |
|
||||||
|
> | The Keychain screen's `MODIFIED` column and `FINGERPRINT` box | Both dropped. `VaultItem` carries no timestamp of any kind, and `SshKeySecret` has no fingerprint field — computing one would mean parsing armour the type stores verbatim, an already-recorded gap this pass did not revisit. |
|
||||||
|
> | Five separate add buttons (GENERATE / + SSH KEY / + PASSWORD / + TAG / + BUCKET) | One `+ New key` accent button, opening a menu of the same five actions in the same order — the design draws one button because its own mock has one "add" concept; this application has five, and none of them was dropped to fit. |
|
||||||
|
> | `NEW ITEMS GO TO` on the vault-filing picker | `NEW ITEMS FILE TO`, the design's own wording, adopted — this screen's own comments already called the act "filing", so the label was this application's vocabulary already. |
|
||||||
|
> | The Snippets screen's own on-screen name | **Snips**, matching the rail, which already said so. |
|
||||||
|
> | A modified-date on a snippet card | Omitted — `SnippetSecret` carries no timestamp, the same absence every other item kind has. |
|
||||||
|
> | The design's shorter captions — "Type into terminal", the rationale-only sentence for "runs on insert" | The screen's own longer, more actionable wording kept instead: the insert button still names the destination tab (`TYPE INTO {tab}` / `NO TERMINAL OPEN`), and the "runs on insert" caption still states the operational consequence rather than only the reason the setting exists. Both of the design's sentences are true; the ones already here say more. |
|
||||||
|
> | A delete confirmation for a snippet | **Built, matching the mock.** `SnippetsViewModel.RequestDelete`/`ConfirmDelete`/`CancelDelete` say the same vault-wide-reach, tombstone, no-undo sentence `VaultViewModel.HowFarADeletionGoes` already says for every other item kind — additive beside the existing uncounted `DeleteCommand`, which the phone's own DELETE row still calls. |
|
||||||
|
> | The Logs screen's own footer sentence about encryption | Adopted verbatim — verified against this screen's own header remark and ADR 0001 before shipping it, both true, so it is drawn as literal text rather than reworded. |
|
||||||
|
>
|
||||||
|
> ## The desktop's v5c — a settings area, at last
|
||||||
|
>
|
||||||
|
> An eighth pass, and the first to give the design's own Settings area a home of its own rather than folding
|
||||||
|
> its two real screens into the rail's popover. Settings is a full-window **mode** now, swapped in wholesale
|
||||||
|
> rather than laid over anything: its own 53px titlebar reading "Back to application", a 340px `SettingsNav`
|
||||||
|
> rail — SETTINGS (General, Vaults, Account) above CUSTOMIZE (Security, Preferences, Groups, Tags), Logout
|
||||||
|
> pinned below both — and a content column capped at the design's own 1100 pixels, which is 741 beside the
|
||||||
|
> rail at the window's own minimum rather than the full 1100 (`LayoutHarness.SettingsContentWidth`). Seven
|
||||||
|
> pages, not the design's eight: the ORGANISATION section and its one page are refused outright, below. Two
|
||||||
|
> of the seven are wholly new — Account and Security did not exist as screens before this pass — and the
|
||||||
|
> other five are the old `PreferencesScreen.axaml` and `VaultsScreen.axaml` split apart and restyled into the
|
||||||
|
> design's own card idiom; both files are deleted, and every command either one offered is reachable here
|
||||||
|
> exactly once. The popover's own Settings, Vaults and Preferences rows, and everywhere else in the codebase
|
||||||
|
> that used to navigate to the bare `Preferences` or `Vaults` screen, now land in this mode instead —
|
||||||
|
> `MainWindowViewModel.ShowScreen` redirects at that one point rather than at every caller that used to reach
|
||||||
|
> either screen directly.
|
||||||
|
>
|
||||||
|
> The importer moved inside this same chrome rather than staying a screen of its own: it draws over the
|
||||||
|
> Preferences page as a boolean overlay — `SettingsNav` stays lit on Preferences the whole time it is open,
|
||||||
|
> and the titlebar's own back button reads "Back to preferences" instead of "Back to application" — restyled
|
||||||
|
> to the design's own table (ALIAS/HOSTNAME/USER/PORT/WHAT THIS MEANS, one header tick-all button in place of
|
||||||
|
> the old TICK ALL/TICK NONE pair). Known Hosts was restyled the same pass but kept its own place: it stays a
|
||||||
|
> main-chrome screen, reached from the rail's Keys entry and now also from Security's own "Approved host
|
||||||
|
> keys" row, because the design draws no page for it at all. And the S3/Buckets surface — which got none of
|
||||||
|
> v5b's session shell — picked up that shell's *look* this time without its machinery: a 26-pixel padded,
|
||||||
|
> bordered, radius-12 container and nothing else, since a bucket has no tab row, no host to head a card with,
|
||||||
|
> no status-bar fact to print and no pin for a sidebar to show.
|
||||||
|
>
|
||||||
|
> | v5c element | What ships instead |
|
||||||
|
> | --- | --- |
|
||||||
|
> | The Settings-Organisation page, and the rail's own ORGANISATION section | Refused outright. No organisation entity exists anywhere in this product — a team is the membership list behind a shared vault, and there is exactly one tenant per deployment — so the rail simply has no third section; see `SettingsNav.axaml`'s own remark. |
|
||||||
|
> | General: the update-channel switcher, Launch at login, Reopen tabs, the theme control and the language picker | Refused, and carried on the page's own NOT BUILT YET card rather than left silently missing: which channel a copy follows is fixed when it is built, nothing registers this application with Windows' own startup list or remembers a tab list across a launch, only the one dark theme exists, and there is no i18n anywhere in this client. |
|
||||||
|
> | Vaults: the VAULT DEFAULTS card (auto-lock, require-password-on-unlock, relay) and the RECOVERY card (kit, export) | Both refused outright — none of the three settings exists, sync is always on, and there is no recovery kit and no export. |
|
||||||
|
> | Vaults: "Manage devices", and the "3 devices" count on the sync line | Refused. There is no list-devices endpoint anywhere in this client. |
|
||||||
|
> | Vaults: per-vault UNLOCKED/LOCKED chips, and the "Unlock ⟨vault⟩" modal | Refused. This application locks the keychain as a whole, not one vault at a time, so there is no per-vault state for a chip or a modal to act on; the keychain-level fact is the sync card's own dot instead. |
|
||||||
|
> | Vaults: the magenta "Default" badge | Dropped rather than faked. The app does track a "new items go to" vault, but that lives on a different view model than the row being drawn here, and cross-referencing the two per card would be more machinery than the badge is worth. |
|
||||||
|
> | Vaults: "Sorted by name ▾" | Decorative in the mock, and not drawn — `VaultsViewModel.Vaults` is already ordered personal-first-then-name, and there is no second order to switch to. |
|
||||||
|
> | Vaults: member avatar stacks on every card | Refused on an unselected card — `VaultRowViewModel` carries a member *count*, not the members themselves, and only the selected vault's own member list is actually loaded. A count stands in on the card; avatars are real in the members panel, where the data is. |
|
||||||
|
> | Account: Edit profile, Change passphrase, the DEVICES list, Sign out everywhere | All four refused — no endpoint exists for any of them, and the passphrase is a one-time enrollment choice with no change flow. What is new and real on this page instead: the SIGN-IN card's issuer sentence, `MainWindowViewModel.Issuer` off `MeResponse.Issuer`, cached the same turn the name and email are and surfaced here for the first time — "Signing in proves who you are — it never decrypts a vault." |
|
||||||
|
> | Security: the strict-host-key toggle, the allowed-algorithms chips, the auto-lock link row | Refused — the app always asks on a changed key, there is no algorithm allow-list anywhere in the SSH stack, and there is no auto-lock setting for the link to point at. |
|
||||||
|
> | Security: RECENT SECURITY EVENTS as a list of rows | Shipped as the link alone, "See all activity in Logs." A real list would mean a filtered read over `LogsViewModel`'s two logs, and which entries count as "security" is a judgement call the design does not resolve; the honest link is complete on its own. |
|
||||||
|
> | Preferences: the device-name editor, Font/Cursor/Scrollback/Copy on select/Terminal bell, the clipboard-clear delay, confirm-run-on-insert | All refused, on the page's own carried-over NOT-BUILT idiom — none of the seven has anything behind it. Windows Hello's register/"Stop unlocking here" pair moved off this page to Security instead, one home rather than two. |
|
||||||
|
> | Groups: drag-to-reorder, and the mock's "the order here is the order there" sentence | Refused. `HostGroupRowViewModel` orders by the vault new items go into, then by vault name, then by label — there is no manual order to drag into. The page prints the true sentence instead and draws no drag handles. |
|
||||||
|
> | Tags: the LAST APPLIED column | Refused — no timestamp of when a tag was last put on a host exists anywhere in this client. |
|
||||||
|
> | Tags: a per-tag vault chip | Refused — `TagRowViewModel` carries a label and a host count and nothing else; there is no vault id on the row for a chip to read. |
|
||||||
|
> | Import: the "saving to ⟨vault⟩ ▾" picker | Refused as a control. Every import writes through the same `session.ActiveVaultId` every other bulk write does, so there is no per-import target to choose — the footer prints the vault's name as a fact, `ImportViewModel.SelectionSummary`, rather than as a `▾`. |
|
||||||
|
> | Import: the old ADDRESS/AUTHENTICATION/STATE columns | Folded into the design's own column set — ALIAS/HOSTNAME/USER/PORT — with the authentication text moved onto the alias cell's own tooltip rather than kept as a column of its own. |
|
||||||
|
> | Import: TICK ALL / TICK NONE | Replaced by the design's own header tick-all button, one press that ticks or unticks every row through `ImportRowViewModel.ToggleAllCommand`. |
|
||||||
|
> | Import as a nav-rail destination | Refused. It is a boolean overlay over the Preferences settings page (`MainWindowViewModel.IsImportOpen`), not a page of its own — `SettingsNav` stays lit on Preferences throughout, exactly as `Import.dc.html` draws it. |
|
||||||
|
> | The design's fixed 1100px content column | `MaxWidth="1100"` rather than a fixed width — the column is 741 pixels beside the 340px rail at the window's own minimum, where a fixed 1100 would not fit. |
|
||||||
|
> | The status bar and the update banner, while in settings mode | Both hidden — `MainWindow.axaml`'s bottom two rows read `!IsSettingsMode`. The design's own settings titlebar has no room for either, and there is nowhere honest to draw them instead. |
|
||||||
|
> | The design's sentence-case button copy | Not adopted. Every button on every settings page keeps this application's own ALL-CAPS mono convention — `CHECK NOW`, `SIGN OUT`, `OPEN IMPORTER` — over the mock's own "Check now". |
|
||||||
|
> | The design's assumption that Settings is the only thing on screen | Not followed. The quick-connect palette and an unapproved host key's decision card both still draw over settings mode exactly as they interrupt every other screen — a connection question does not stop mattering because the window happens to be showing Settings; see `MainWindow.axaml`'s own remark on the ordering of its Panel. |
|
||||||
|
> | The importer's own sentence, "This is the only control in DodoSSH that opens key material from a directory you did not point at file by file — nothing is read until Import is pressed." | Adopted verbatim — verified against `ImportViewModel.ScanAsync` and `ImportAsync`: scanning never calls `SshConfigLocator.ReadIdentity`, and `ImportAsync` is the only path that ever does. |
|
||||||
|
> | The tags page's own sentence, "Renaming here is one write and every host follows." | Adopted verbatim — verified against `SaveTagAsync`: a host names a tag by id, never by label, so a rename touches nothing but the tag item itself. |
|
||||||
|
> | The known-hosts screen's own intro, "Every pin is a decision recorded at the moment of connecting. Fingerprints are never trimmed — compare them character by character against what the operator published." | Adopted verbatim, matching the stance the rest of this application already takes on a pin. |
|
||||||
|
|
||||||
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
|
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
|
||||||
project each piece would have to land in, and **what the shipped interface does instead**. That last
|
project each piece would have to land in, and **what the shipped interface does instead**. That last
|
||||||
@@ -173,8 +384,9 @@ it for revocation, for relay session recording and for what locking does not do
|
|||||||
rendered with invented data to make a screenshot look finished. Where a feature does not exist, the
|
rendered with invented data to make a screenshot look finished. Where a feature does not exist, the
|
||||||
interface either omits it or says so.
|
interface either omits it or says so.
|
||||||
|
|
||||||
Read alongside [the milestone plan](../README.md#milestones). Most of what follows is not a defect; it is
|
Read alongside [the milestone plan](../README.md#milestones). Most of what follows is not a defect. M2 and
|
||||||
M2 and M3 arriving in a design before it arrives in the code.
|
M3 have since arrived and their rows say so; what is left is either M5 — per-item content keys, and the
|
||||||
|
sharing that would rest on them — or a decision recorded here not to build the thing at all.
|
||||||
|
|
||||||
## The shape of the problem
|
## The shape of the problem
|
||||||
|
|
||||||
@@ -204,9 +416,12 @@ reason and is gone: membership is granted only to an account that exists, so the
|
|||||||
reservation fails to cover. The status stays reserved, because the column holds it in nobody's database and
|
reservation fails to cover. The status stays reserved, because the column holds it in nobody's database and
|
||||||
a client must not fail on a value a later server might send. See [ADR 0009](adr/0009-team-access-model.md).
|
a client must not fail on a value a later server might send. See [ADR 0009](adr/0009-team-access-model.md).
|
||||||
|
|
||||||
**The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the
|
**The client's settings file holds two things.** It writes three files — `cache.db`, `device.key` and
|
||||||
cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be
|
`settings.json` — and the cache's six tables still hold none of them. `ClientSettings` carries the
|
||||||
saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway.
|
terminal's text size and whether this machine looks for a newer build on its own, and both survive a
|
||||||
|
restart. Everything else on the design's TERMINAL panel is unbuilt rather than unbuildable: the data plane
|
||||||
|
grew `TerminalServerOpcode.FontSize` to carry the one that shipped, so a second option is an opcode, a
|
||||||
|
setting and a control rather than a new mechanism.
|
||||||
|
|
||||||
**Three things the design did not ask for and this build now has.** A key can be generated in the client
|
**Three things the design did not ask for and this build now has.** A key can be generated in the client
|
||||||
rather than pasted in (`SshKeyGenerator`, and the `openssh-key-v1` container is written by hand — see
|
rather than pasted in (`SshKeyGenerator`, and the `openssh-key-v1` container is written by hand — see
|
||||||
@@ -297,7 +512,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
|
|||||||
| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. |
|
| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. |
|
||||||
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
|
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
|
||||||
| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. |
|
| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. |
|
||||||
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | Inter (already embedded) for prose, and the system monospace stack the terminal already names. Named once in `App.axaml` as `MonoFont`, so the substitution is reversible in one place. |
|
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | **Shipped, since v5, and not IBM Plex.** The v5 design asked for Montserrat, JetBrains Mono and Material Icons instead, and all three are embedded as `AvaloniaResource` in the Shell project rather than requested by name and left to whatever the machine happens to have. Montserrat is the default sans, with Inter — the earlier substitution — kept only as its fallback; `MonoFont` puts JetBrains Mono first, ahead of the system-monospace stack the terminal still falls back to. Named once each in `Program.cs` and `Palette.axaml`, so a later design's fonts are still a one-line swap. |
|
||||||
| `⌘K`, `⌥↵` | ui | Nothing; the design is Mac-flavoured. | `CTRL K`. Development is Windows-only today (`docs/platform-flags.md`). |
|
| `⌘K`, `⌥↵` | ui | Nothing; the design is Mac-flavoured. | `CTRL K`. Development is Windows-only today (`docs/platform-flags.md`). |
|
||||||
|
|
||||||
**Also worth knowing:** custom chrome went in as `SystemDecorations="BorderOnly"`, not by extending the
|
**Also worth knowing:** custom chrome went in as `SystemDecorations="BorderOnly"`, not by extending the
|
||||||
@@ -322,11 +537,11 @@ caption buttons and window title drawn on top of the application's own — two s
|
|||||||
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
|
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
|
||||||
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
||||||
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
||||||
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. |
|
| Pane header `aes256-gcm` | client-ssh | **No longer a miss — the plumbing this row used to lack now exists.** `ISshConnection.Cipher` and `TerminalWorkspace.GetSessionFacts` were built for the v5b session shell's status bar; see that row in the v5b section, above. | Still not on a pane header — this pre-v5b element does not exist as its own piece of chrome any more. The fact it wanted to show is drawn instead where v5b moved it: the status bar's `SessionCipher`, beside CONNECTED. The session shell's host header separately shows the account and endpoint actually dialled (v3–v4: the tab strip did; v5b moved that fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
|
||||||
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
||||||
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
||||||
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
||||||
| Terminal font size (`--termfs`, 11–16px) | client-storage | See preferences. | Fixed at the renderer's 13px. |
|
| Terminal font size (`--termfs`, 11–16px) | client-storage | — | **Shipped**, and wider than the design's range: 8–32px from the preferences screen or Ctrl+plus/minus/0 over a terminal, carried by `TerminalServerOpcode.FontSize` and kept in `settings.json`. It resizes the grid rather than magnifying it, so every live pane refits and the remotes are told how many columns they now have. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -350,11 +565,11 @@ connect, and it goes through the same host key gate, the same pin and the same t
|
|||||||
| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | All four. `PosixMode` renders `drwxr-xr-x` from the bits SFTP hands over; setuid, setgid and sticky are not shown, because SSH.NET does not surface them and `rwx` where `rws` is true would be worse than nothing. |
|
| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | All four. `PosixMode` renders `drwxr-xr-x` from the bits SFTP hands over; setuid, setgid and sticky are not shown, because SSH.NET does not surface them and `rwx` where `rws` is true would be worse than nothing. |
|
||||||
| Local listing | client-transfer | `LocalDirectory`, which is where this client's `System.IO` now lives. `PERMS` is blank on the local side rather than filled with a plausible-looking POSIX mode that is not a fact about a file on Windows. |
|
| Local listing | client-transfer | `LocalDirectory`, which is where this client's `System.IO` now lives. `PERMS` is blank on the local side rather than filled with a plausible-looking POSIX mode that is not a fact about a file on Windows. |
|
||||||
| Transfer queue, progress, throughput | client-transfer | `FileTransferQueue`. One transfer at a time, so the rate on a row is the rate of the link rather than a share of it. Throughput is measured over a half-second window, not averaged since the start. |
|
| Transfer queue, progress, throughput | client-transfer | `FileTransferQueue`. One transfer at a time, so the rate on a row is the rate of the link rather than a share of it. Throughput is measured over a half-second window, not averaged since the start. |
|
||||||
| `resume supported` | client-transfer | **Within a run of the application.** Every transfer writes to a `.dodossh-part` file beside its destination and is renamed into place at the end, so an interrupted one can never be mistaken for a finished one, and `RESUME` carries on from the part file's own length. A part file found at startup is *not* resumed: nothing records what wrote it, and resuming on the strength of a name matching is how a corrupt artefact gets delivered with nothing reporting a failure. Making it survive a restart needs the preferences store this client has not got — see below. |
|
| `resume supported` | client-transfer | **Within a run of the application.** Every transfer writes to a `.dodossh-part` file beside its destination and is renamed into place at the end, so an interrupted one can never be mistaken for a finished one, and `RESUME` carries on from the part file's own length. A part file found at startup is *not* resumed: nothing records what wrote it, and resuming on the strength of a name matching is how a corrupt artefact gets delivered with nothing reporting a failure. Making it survive a restart needs somewhere to record *what* wrote each part file — the source, the offset and the run — which is a table rather than a setting; `settings.json` holds two scalars and is not that. |
|
||||||
|
|
||||||
| Design element | Layer | What it would take | What ships instead |
|
| Design element | Layer | What it would take | What ships instead |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Per-host last directory | client-storage | Somewhere to persist two panes' navigation state. There is still no settings table. | The remote pane opens on the account's home directory, which the server canonicalises during the handshake; the local pane opens on the user profile. |
|
| Per-host last directory | client-storage | Somewhere to persist two panes' navigation state per host, which is a table rather than a scalar — `settings.json` holds two of those and is the wrong shape for a row per machine. | The remote pane opens on the account's home directory, which the server canonicalises during the handshake; the local pane opens on the user profile. |
|
||||||
| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. `HostSecret.JumpHostIds` is still stored, synced, merged and read by nothing. | Omitted. |
|
| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. `HostSecret.JumpHostIds` is still stored, synced, merged and read by nothing. | Omitted. |
|
||||||
| Overwriting a file that is already there | ui | A prompt, which means a modal this window has no idiom for. | Refused, with the name that is in the way. The remote pane has DELETE and MKDIR so the refusal is not a dead end. |
|
| Overwriting a file that is already there | ui | A prompt, which means a modal this window has no idiom for. | Refused, with the name that is in the way. The remote pane has DELETE and MKDIR so the refusal is not a dead end. |
|
||||||
| Dragging between the panes | ui | Drag-and-drop between two `ListBox`es, plus a drop target that is a directory rather than a row. | Two arrow buttons between the panes, pointing at the pane the file is going to. |
|
| Dragging between the panes | ui | Drag-and-drop between two `ListBox`es, plus a drop target that is a directory rather than a row. | Two arrow buttons between the panes, pointing at the pane the file is going to. |
|
||||||
@@ -476,8 +691,8 @@ lists the rest as absent rather than omitting it silently.
|
|||||||
|
|
||||||
| Design element | Layer | What it would take |
|
| Design element | Layer | What it would take |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Any preference, saved | client-storage | A seventh table in the client cache, or a preference item type in the vault. Which one matters: the vault syncs, so a preference stored there follows you between machines, and a terminal font size probably should not. |
|
| Any preference, saved | — | **Two are.** `settings.json` beside the cache holds the terminal's text size and whether this machine checks for updates on its own. It is deliberately not in the vault: the vault syncs, so a preference kept there would follow you between machines, and a font size chosen for a laptop screen should not arrive on a desktop. Anything per-host or per-item wants a table instead — see the transfers rows above. |
|
||||||
| Terminal font, size, cursor style, cursor blink, scrollback | client-terminal | The store, **and** a frame to carry it. `TerminalServerOpcode` has four values (`Output`, `SessionOpened`, `SessionClosed`, `SessionActivated`) and none carries an option. |
|
| Terminal font, cursor style, cursor blink, scrollback | client-terminal | A setting, an opcode and a control, in that order — the mechanism is no longer the obstacle. `TerminalServerOpcode.FontSize` proved the path and `ClientSettings` is where the value would live; what none of these four has is somebody deciding it is worth a row on the preferences screen. |
|
||||||
| Font family picker offering IBM Plex Mono | ui | Shipping the font, as above. |
|
| Font family picker offering IBM Plex Mono | ui | Shipping the font, as above. |
|
||||||
| Audible bell | client-terminal + ui | xterm.js 6 removed `bellStyle` and `bellSound`; what remains is an `onBell` event, so the page would have to make the sound itself. |
|
| Audible bell | client-terminal + ui | xterm.js 6 removed `bellStyle` and `bellSound`; what remains is an `onBell` event, so the page would have to make the sound itself. |
|
||||||
| `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. |
|
| `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. |
|
||||||
@@ -505,3 +720,48 @@ grid of cards with a drawer — see above. The split it describes did not change
|
|||||||
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
|
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.
|
||||||
|
|
||||||
|
## v5c-5 — one door to settings, not three
|
||||||
|
|
||||||
|
**The user-chip popover keeps its Settings row and has lost Vaults and Preferences.** v5c gave it three, and
|
||||||
|
the table above records why: until that pass "Settings" and "Preferences" had been two doors onto the same
|
||||||
|
bare screen, so splitting them was a real fix, and Vaults arrived beside them when `VaultsScreen` became a
|
||||||
|
settings page too.
|
||||||
|
|
||||||
|
Living with it made the shape of the mistake clear. All three opened the same full-window mode and differed
|
||||||
|
only in which of its seven pages they landed on — and `SettingsNav`, which lists every one of those pages
|
||||||
|
including Vaults and Preferences, is on screen the entire time you are in there. So two of the three rows
|
||||||
|
were deep links into a place the third one opens, saving one click and costing a menu that read as though it
|
||||||
|
offered three destinations. A menu at the foot of the rail is for getting into settings at all; which page
|
||||||
|
you want is a question that has a rail of its own once you are there.
|
||||||
|
|
||||||
|
Nothing became unreachable: both pages are one click deeper, from the rail that was always going to be the
|
||||||
|
way to them. `NavRailTests` asserts the rows are gone from the markup rather than merely unwired, because a
|
||||||
|
row left behind with no handler is a menu entry that does nothing when pressed — which reads as a broken
|
||||||
|
application rather than a tidied menu.
|
||||||
|
|
||||||
|
Logout stays, and still lands on a page rather than on General: its confirmation card is drawn inline on the
|
||||||
|
Account page and nowhere else, so arming it from anywhere else would be a card raised where nobody could see
|
||||||
|
it. The vault switches and **New vault** above the divider stay too — they are what the popover is for
|
||||||
|
besides settings, and neither is a page of the settings mode.
|
||||||
+1469
-158
File diff suppressed because it is too large.
Load diff
+115
-6
@@ -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,
|
||||||
@@ -595,9 +666,23 @@ targeted, not which RID. Only signing needs Windows tooling, which is why `ci.ym
|
|||||||
without a target *evaluates* the project and runs nothing, while MinVer computes the version inside a
|
without a target *evaluates* the project and runs nothing, while MinVer computes the version inside a
|
||||||
target — so the read comes back as the SDK default on a full checkout with every tag present, which looks
|
target — so the read comes back as the SDK default on a full checkout with every tag present, which looks
|
||||||
exactly like a version that was never configured. `-t:MinVer` makes `-getProperty` report the value after
|
exactly like a version that was never configured. `-t:MinVer` makes `-getProperty` report the value after
|
||||||
that target has run, and both readers of it — the tag check in `ci.yml` and `scripts/release-windows.ps1`
|
that target has run, and every reader of it — the tag check in `ci.yml`, the desktop nightly job, and
|
||||||
— pass it. Neither had, and neither had ever run: the CI check is `if:` a tag ref and there are no tags
|
`scripts/release-windows.ps1` — passes it. Neither of the first two had, and neither had ever run: the CI
|
||||||
yet, so the first release would have been refused by its own guard, which would have blamed `fetch-depth`.
|
check is `if:` a tag ref and there are no tags yet, so the first release would have been refused by its own
|
||||||
|
guard, which would have blamed `fetch-depth`.
|
||||||
|
|
||||||
|
**And naming that target requires a restore first, which is a second failure wearing a very different
|
||||||
|
face.** MinVer arrives as a package, so its target is imported from `obj/*.nuget.g.targets` and does not
|
||||||
|
exist at all on a clean checkout:
|
||||||
|
|
||||||
|
```
|
||||||
|
error MSB4057: The target "MinVer" does not exist in the project.
|
||||||
|
```
|
||||||
|
|
||||||
|
That reads like a typo in the workflow rather than like a missing restore, and it does not reproduce on any
|
||||||
|
machine that has built the project before — which is every developer machine and no fresh runner. The
|
||||||
|
build job's tag check is safe because it runs after that job's own restore; the desktop nightly job and the
|
||||||
|
release script each restore before reading, deliberately and with a comment saying why.
|
||||||
|
|
||||||
**The Android head's lock file is outside the solution, so nothing checks it until the android job runs
|
**The Android head's lock file is outside the solution, so nothing checks it until the android job runs
|
||||||
— and the android job was broken for an unrelated reason for the whole of the release that went stale.**
|
— and the android job was broken for an unrelated reason for the whole of the release that went stale.**
|
||||||
@@ -627,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,314 @@
|
|||||||
|
# Reaching a host you cannot dial
|
||||||
|
|
||||||
|
Some machines do not answer from where the user is sitting. This product has **two** answers to that, and
|
||||||
|
one of them works.
|
||||||
|
|
||||||
|
- The **relay** pipes raw TCP through the deployment. Both halves are built: the server issues a ticket and
|
||||||
|
pipes a WebSocket to the host, and the client reaches it through a loopback SOCKS5 bridge. Step 3 below.
|
||||||
|
- A **jump host** reaches the target through a machine already in the keychain. `HostSecret.JumpHostIds`
|
||||||
|
stores the chain; nothing writes it and nothing reads it.
|
||||||
|
|
||||||
|
◆ **An earlier version of this page said the relay's server half was "built and shipped". It was not.** What
|
||||||
|
existed was ADR 0004, the contract records, the options and the CHECK constraint — and `RelayOptions` said,
|
||||||
|
in its own remarks, that there was no endpoint and no dialler. Both halves were built together at step 3.
|
||||||
|
|
||||||
|
This document is the comparison between them, which is the thing that has to be settled before either is
|
||||||
|
built, and then the plan. It replaces an earlier draft of `docs/jump-hosts.md` that recommended deleting the
|
||||||
|
jump chain — see the last section for why that was wrong.
|
||||||
|
|
||||||
|
> **Status: steps 0, 1 and 3 done, and step 4 for the relay. Jump hosts are next.**
|
||||||
|
>
|
||||||
|
> | Step | State | Notes |
|
||||||
|
> | --- | --- | --- |
|
||||||
|
> | 0. Stop promising the relay | **Done, then retired at step 3** | The "not wired up yet" sentence came out of both heads when the relay started working |
|
||||||
|
> | 1. Dial through a loopback proxy | **Done** | `SshLoopbackProxy` on the request, honoured in `BuildConnectionInfo`. **Not the bridge** — see below |
|
||||||
|
> | 2. Jump hosts over it | Not started | No server change, and no bridge either: SSH.NET's own forward is the proxy |
|
||||||
|
> | 3. The relay: the bridge, then the ticket | **Done** | Server: `Features/Relay`. Client: `RelayBridge`, `ServerRelay`, and `VaultViewModel.OpenThroughAsync`. See "What step 3 built" |
|
||||||
|
> | 4. File transfer parity | **Done for the relay** | `TransfersViewModel` opens a relay for a relayed host exactly as the shell does. A jump chain will need the same line when step 2 lands |
|
||||||
|
|
||||||
|
## ◆ The relay's checkbox is a false promise, and that is a defect
|
||||||
|
|
||||||
|
`HostSecret.RelayEnabled` is stored, validated — a relay host may not inherit its port — encoded, merged,
|
||||||
|
and drawn as a checkbox in the host editor on **both** heads. The desktop's says *"Connect through the
|
||||||
|
server relay"* and warns underneath that the address will be stored on the server in plain text. The
|
||||||
|
phone's says the same at more length.
|
||||||
|
|
||||||
|
Nothing on the client reads it. `VaultViewModel` builds `SshConnectionRequest(hostname, port, username,
|
||||||
|
credential)` and `SshNetConnectionFactory` dials that address directly, whether the box is ticked or not.
|
||||||
|
|
||||||
|
So a user who ticks it **pays the privacy and gets nothing**: the host's address and port leave the
|
||||||
|
encrypted payload and land in plaintext columns on the server — the one deliberate concession in the whole
|
||||||
|
design, per ADR 0004 — and the connection is still made from their laptop to the machine they already could
|
||||||
|
not reach. It then fails exactly as it did before, with no hint that the box did nothing.
|
||||||
|
|
||||||
|
This is worse than the jump chain, which is invisible and harmless. It is a control that spends something
|
||||||
|
real.
|
||||||
|
|
||||||
|
**Step 0 is done.** Both heads now name the absence in the label and lead the paragraph under it with what
|
||||||
|
ticking the box actually does today — the way this codebase already handles port forwarding on the phone's
|
||||||
|
More screen. It is left *tickable* rather than disabled, deliberately: a host that already carries the flag
|
||||||
|
has to be able to lose it, and a disabled control would trap the concession on. The sentence comes out again
|
||||||
|
at step 3.
|
||||||
|
|
||||||
|
## The comparison
|
||||||
|
|
||||||
|
Both answers put something between the user and a machine they cannot dial. What differs is *what* is in
|
||||||
|
between, what it costs, and who has to own it.
|
||||||
|
|
||||||
|
| | Relay | Jump host |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Reaches** | Anything the **deployment** can reach | Anything a **machine already in the keychain** can reach |
|
||||||
|
| **Asks of the deployment** | It must sit where it can dial the target, and have the relay enabled | Nothing. The server is not involved at all |
|
||||||
|
| **Tells the operator** | The host's address and port, in plaintext columns, for every opted-in host — plus an audit row per session: target, duration, bytes, close reason, client IP | Nothing beyond the sync metadata every item already produces |
|
||||||
|
| **The intermediate's credentials** | None to manage. The deployment is the intermediate | The bastion is an ordinary host: its own key or password, its own host key to pin, its own group defaults |
|
||||||
|
| **Where SSH terminates** | On the laptop. The relay sees ciphertext, and ADR 0004 is emphatic that no recording is possible | On the laptop. The bastion forwards a TCP stream inside a session the user opened to it |
|
||||||
|
| **When it is unavailable** | Deployment down, no connection — including to hosts that were reachable directly | Bastion down, no connection to what is behind it |
|
||||||
|
| **Fits an estate where** | The DodoSSH server is *inside* the network the targets are on | A bastion is the policy and the server is outside — which is the ordinary enterprise shape |
|
||||||
|
| **Auditable by the operator** | Yes, coarsely, and that is a feature for a team deployment | No, and that is a feature for a private one |
|
||||||
|
|
||||||
|
**The two are not substitutes, and the deciding question is where the deployment sits.** The relay only
|
||||||
|
answers "unreachable" when the server has line of sight the laptop lacks — a deployment inside the VPC, on
|
||||||
|
the office network, on the same Tailnet. Point it at a self-hosted box outside the target's network, which
|
||||||
|
is what most people running this on a VPS will have, and the relay reaches nothing the laptop could not
|
||||||
|
already reach.
|
||||||
|
|
||||||
|
**And you do not get to choose other people's topology.** Shipping this to strangers means shipping into
|
||||||
|
estates whose shape is already decided, and bastion-fronted is the common one. Their `ssh_config` says so:
|
||||||
|
the importer reads `ProxyJump`, records it as an option and writes a note on the host saying *"DodoSSH does
|
||||||
|
not route through a jump host yet"* — a first-run experience that names the limitation on the hosts it
|
||||||
|
matters for.
|
||||||
|
|
||||||
|
**A relay is not a bastion with better manners.** ADR 0004 rejected "server terminates SSH" and kept
|
||||||
|
zero-knowledge, which is right and is not what a jump host asks for either: forwarding a TCP stream through
|
||||||
|
a machine the user has authenticated to reveals nothing to the operator, because the operator is not in it.
|
||||||
|
The privacy ordering is the opposite of what the ADR's framing suggests — the relay is the mechanism that
|
||||||
|
costs a plaintext address, and the jump host is the one that costs nothing.
|
||||||
|
|
||||||
|
## They are one piece of work, and ADR 0004 says so
|
||||||
|
|
||||||
|
The last consequence in ADR 0004, written before either half was built:
|
||||||
|
|
||||||
|
> On the client, SSH.NET cannot be handed a pre-connected stream, so the relay is reached via a loopback TCP
|
||||||
|
> bridge. The same bridge provides ProxyJump via a SOCKS5 dynamic forward — **one mechanism, two features**.
|
||||||
|
|
||||||
|
That is the plan, and it holds up against the pinned package. SSH.NET 2025.1.0 offers
|
||||||
|
`ForwardedPortDynamic`, which is a SOCKS5 proxy served over an established `SshClient`, and
|
||||||
|
`ConnectionInfo(host, port, username, ProxyTypes, proxyHost, proxyPort, proxyUsername, proxyPassword,
|
||||||
|
AuthenticationMethod[])` with `ProxyTypes.Socks5` — checked in `Renci.SshNet.xml` rather than remembered. So:
|
||||||
|
|
||||||
|
- **Jump host:** connect to the bastion as an ordinary host, `AddForwardedPort(new ForwardedPortDynamic(0))`
|
||||||
|
on it, then dial the target with a `ConnectionInfo` pointed at that loopback SOCKS5 port. A chain of two
|
||||||
|
is the same trick twice.
|
||||||
|
- **Relay:** the same shape with a different thing on the loopback socket — a listener that pipes bytes into
|
||||||
|
the `dodossh.relay.v1` WebSocket instead of into a bastion's forward.
|
||||||
|
|
||||||
|
### ◆ But the bridge is the relay's half, not the shared one
|
||||||
|
|
||||||
|
Written into step 1 of this plan and wrong. Building it turned the sentence around: **the jump-host path
|
||||||
|
needs no bridge from this repository at all.** `ForwardedPortDynamic` *is* the loopback listener — SSH.NET
|
||||||
|
accepts on it, speaks SOCKS5 on it, and tunnels what it accepts through the bastion. Nothing is left for a
|
||||||
|
bridge of ours to do. The relay is the case with no `SshClient` to hang a forward off, so it is the one that
|
||||||
|
needs a listener written here, and it is the bridge's only consumer.
|
||||||
|
|
||||||
|
What the two genuinely share is one step lower: **the connection being told to reach its target through a
|
||||||
|
loopback SOCKS5 proxy, while staying about the target**. That is the piece both features stand on, it is
|
||||||
|
fifteen lines in `BuildConnectionInfo`, and it is what step 1 turned out to be.
|
||||||
|
|
||||||
|
So the bridge moves to step 3 and lands with the feature that uses it. Building it now would have been a
|
||||||
|
component whose only caller was two steps away — which is the shape of the two defects this document is
|
||||||
|
about.
|
||||||
|
|
||||||
|
## The work, in order
|
||||||
|
|
||||||
|
**0. Stop promising the relay.** ✅ The checkbox states that the relay is not wired up yet. One line on each
|
||||||
|
head, and the only step that should ship on its own.
|
||||||
|
|
||||||
|
**1. Dial through a loopback proxy.** ✅ `SshConnectionRequest` carries an optional `SshLoopbackProxy`, and
|
||||||
|
`BuildConnectionInfo` builds SSH.NET's proxy `ConnectionInfo` when it is there. Three properties are worth
|
||||||
|
knowing, and each is held by a test in `LoopbackProxyTests`:
|
||||||
|
|
||||||
|
- **The type is a port and nothing else.** A proxy on any interface but loopback cannot be expressed, which
|
||||||
|
matters because the failure mode is an open SOCKS proxy on the user's network for the life of a shell,
|
||||||
|
and nothing would report it.
|
||||||
|
- **SOCKS5 rather than a dumb pipe, so the target stays the target.** The host and port in the request are
|
||||||
|
the ones SSH.NET dials *through* the proxy and the ones the host key gate pins — so the same machine
|
||||||
|
reached through a bastion is pinned under its own name, not under `127.0.0.1:<ephemeral>`, which is not
|
||||||
|
an identity at all.
|
||||||
|
- **A proxy that is not listening fails as a connection error**, not as an unknown host key. The gate
|
||||||
|
translates "no host key seen" into a fingerprint prompt, and a connection that never reached a server has
|
||||||
|
seen none either — so the prompt would offer to fix the wrong thing, with no fingerprint to show.
|
||||||
|
|
||||||
|
Nothing calls it with a proxy yet. That is deliberate and it is one step wide: step 2 is the caller.
|
||||||
|
|
||||||
|
**2. Jump hosts.** No server change, and no bridge. In order:
|
||||||
|
|
||||||
|
- The hop's own connection, and a `ForwardedPortDynamic("127.0.0.1", 0)` started on it, whose `BoundPort`
|
||||||
|
becomes the `SshLoopbackProxy` for the connection after it. A chain of two is that twice.
|
||||||
|
- `VaultViewModel` resolves `JumpHostIds` to hosts in the same vault, applying group inheritance per hop the
|
||||||
|
way the target already gets it, and refuses a chain that crosses a vault — the same refusal the group
|
||||||
|
picker already makes, for the same reason.
|
||||||
|
- ◆ **The target's address is resolved at the last hop, not here.** A SOCKS CONNECT names the target and the
|
||||||
|
bastion resolves it, so what has to be stored on the host is the address *the bastion* can reach — which
|
||||||
|
is what an `ssh_config` means by `HostName` beside a `ProxyJump`, and what the importer is already
|
||||||
|
carrying across verbatim. Nothing needs to change for that to be true; it needs to be said, because a
|
||||||
|
host that resolves here and not there fails as a SOCKS "general failure" naming neither end.
|
||||||
|
- Per-hop host keys. Each hop is a separate handshake against a separate endpoint, so the pin, the unknown
|
||||||
|
key prompt and the changed-key refusal run per hop. **The prompt has to name which hop it is about**, or
|
||||||
|
somebody approves a bastion's fingerprint believing it is the target's — see `HostKeyCard`, which is built
|
||||||
|
around one connection and one question.
|
||||||
|
- Per-hop credentials, including a hop that wants a typed password. `IsAskingForConnectPassword` asks about
|
||||||
|
one host today.
|
||||||
|
- Teardown: the hops belong to the outer session and go with it, including when the outer connect fails
|
||||||
|
half way. A leaked bastion connection is an open session on a machine the user believes they left.
|
||||||
|
- The schema version. A chain becomes a real field, so it joins the ladder in `HostSecretCodec` — a host
|
||||||
|
carrying one must not be editable by a client that would drop it. That is the whole point of the rule.
|
||||||
|
- The editor: a picker over other hosts in the same vault, and the host detail's subtitle finally getting
|
||||||
|
the `⤷ bastion-eu` the design asked for.
|
||||||
|
|
||||||
|
**3. The relay, and the bridge with it.** A loopback `TcpListener` on an ephemeral port that accepts one
|
||||||
|
connection, answers a SOCKS5 CONNECT on it, and pipes the rest into the `dodossh.relay.v1` WebSocket —
|
||||||
|
SOCKS5 rather than a raw pipe so that this path presents the same interface step 1 already speaks, and the
|
||||||
|
target's identity stays the target's. Then `POST /relay/tickets` with the host id and the WebSocket with the
|
||||||
|
ticket in `Sec-WebSocket-Protocol`; the ticket is single-use and expires in 30 seconds, so it is fetched per
|
||||||
|
connect and never cached. The bridge binds `127.0.0.1` explicitly, accepts once and stops listening. Then
|
||||||
|
the sentence step 0 added comes out of both heads.
|
||||||
|
|
||||||
|
**4. File transfer.** `ISftpSessionFactory.OpenSftpAsync` opens its own second connection, so a host that
|
||||||
|
needs a chain or a relay to reach needs it there too, or SFTP silently fails for exactly the hosts this
|
||||||
|
work exists for. ✅ for the relay.
|
||||||
|
|
||||||
|
## What step 3 built
|
||||||
|
|
||||||
|
**The server**, in `DodoSSH.Api/Features/Relay`:
|
||||||
|
|
||||||
|
- `POST /api/v1/relay/tickets` makes every decision. It finds the host, requires Connect on its vault and
|
||||||
|
the relay switched on, resolves the stored name, and runs every answer through `RelayAddressPolicy` —
|
||||||
|
the non-overridable list first, then the operator's denials, allow-list and private-network switch. What
|
||||||
|
passes goes into a Data Protection ticket; the name is not resolved again. **Every target refusal is one
|
||||||
|
answer**, 403 `relay-target-rejected` with one sentence, because anybody who can write a host can point
|
||||||
|
it anywhere, and a distinct "resolves, but refused" would be a scanner for the operator's DNS. The real
|
||||||
|
reason is logged (event 2301).
|
||||||
|
- `GET /api/v1/relay/connect` is anonymous at the HTTP layer and takes the ticket as the second
|
||||||
|
subprotocol. It verifies, checks expiry against `TimeProvider`, and spends the ticket with a unique
|
||||||
|
insert into `relay_ticket_use` before accepting the upgrade — so a refused ticket is a 401 and no socket.
|
||||||
|
After the upgrade, a refusal the client could not have avoided is a close code: 4429 for a limit, 4502
|
||||||
|
for a host the server could not reach, 4408 idle, 4409 lifetime, 1001 at the end of a shutdown's drain.
|
||||||
|
- `relay_session` is the audit row: opened before the dial, closed after, each from a scope of its own.
|
||||||
|
|
||||||
|
**The client:**
|
||||||
|
|
||||||
|
- `RelayBridge` (Client.Ssh) is the loopback SOCKS5 listener. It binds `127.0.0.1`, accepts once, answers
|
||||||
|
one CONNECT without acting on its target, and pipes to the WebSocket. It is handed to the connection as
|
||||||
|
the new `SshLoopbackProxy.Lifetime`, so the connection disposes it on every way a session ends, and a
|
||||||
|
failed connect disposes it before throwing.
|
||||||
|
- `ServerRelay` (Client.Session) asks for the ticket, opens the socket and starts the bridge, and turns
|
||||||
|
every refusal into a sentence. It is `IVaultServer.Relay`, decided from `/api/v1/meta` the way `Events`
|
||||||
|
is; a server without the feature supplies `UnavailableServerRelay`.
|
||||||
|
- `VaultViewModel` opens a relay for a host with the box ticked and **never falls back to dialling it
|
||||||
|
directly** — the user ticked it because that path does not work, or goes somewhere they do not mean. A
|
||||||
|
relay that closed with a reason replaces SSH.NET's transport error with that reason, and the
|
||||||
|
reachability probe is skipped for a relayed host, because it would test the direct path.
|
||||||
|
|
||||||
|
### ◆ Two things that are not what ADR 0004 wrote
|
||||||
|
|
||||||
|
- **No `System.IO.Pipelines`.** Each direction reads one buffer and does not read again until the write
|
||||||
|
has completed. That is the same end-to-end backpressure — a slow peer stops the read, which fills the
|
||||||
|
kernel's window, which throttles the far sender — with a tighter bound: two 32 KiB buffers a session
|
||||||
|
rather than ~2 MiB.
|
||||||
|
- **No `?ticket=` fallback, and no device id in the ticket.** The fallback exists for clients that cannot
|
||||||
|
set a subprotocol, which neither head is, and it would put a bearer credential in the one place — the
|
||||||
|
URL — that access logs keep. A device is never visible to the server on an ordinary request.
|
||||||
|
|
||||||
|
### ◆ A WebSocket operation is never cancelled, on either side
|
||||||
|
|
||||||
|
Cancelling a pending receive or send on a .NET WebSocket aborts it, and an aborted socket sends no close
|
||||||
|
frame. The close frame is the only way the client learns that the server could not reach the host, so both
|
||||||
|
the server's pump and the client's bridge stop a direction by closing and waiting a bounded time instead.
|
||||||
|
`AnIdleSession_IsClosedWithTheIdleCode` holds the server to it: it asserts the code arrives, not merely that
|
||||||
|
the socket ended.
|
||||||
|
|
||||||
|
### What a developer meets
|
||||||
|
|
||||||
|
The relay refuses loopback with no configuration escape, so the development stack's sshd on
|
||||||
|
`localhost:2222` cannot be reached through it. Point a relayed host at an address the API process reaches
|
||||||
|
on a real interface — the machine's own LAN address works — or at the container's address on the Docker
|
||||||
|
network.
|
||||||
|
|
||||||
|
## Traps already known
|
||||||
|
|
||||||
|
**A relay host may not inherit its port, and a jump host has no such rule.** `TryValidate` enforces the
|
||||||
|
first because the server stores the port and a group edit would silently change what the relay dials. The
|
||||||
|
chain has no plaintext counterpart, so it inherits normally — do not copy the restriction across out of
|
||||||
|
symmetry.
|
||||||
|
|
||||||
|
**Two hosts can name each other.** A chain is ids, and nothing stops A jumping through B while B jumps
|
||||||
|
through A. Resolve iteratively with a visited set and refuse a cycle before dialling anything, rather than
|
||||||
|
discovering it as a stack overflow inside a connect.
|
||||||
|
|
||||||
|
**The bastion's own group defaults matter.** A hop is a host, so it resolves its port, username and binding
|
||||||
|
through `HostInheritance` exactly as the target does. Skipping that dials 22 as nobody on a bastion that is
|
||||||
|
on 2222 as `deploy`.
|
||||||
|
|
||||||
|
**`ForwardedPortDynamic(0)` reports its port, and the one-argument constructor's bind address is
|
||||||
|
undocumented.** The first is now pinned by a test rather than trusted. The second is why the two-argument
|
||||||
|
constructor is always used: if the default were `0.0.0.0` the failure would not be a test failure, it would
|
||||||
|
be a SOCKS proxy into the developer's network that nothing reports, so the bound host is asserted too.
|
||||||
|
|
||||||
|
**◆ The test server refuses forwarding, and says so nowhere useful.** `linuxserver/openssh-server` ships
|
||||||
|
`AllowTcpForwarding no`. A dynamic forward starts anyway — opening one asks the server nothing — and every
|
||||||
|
connection through it is then refused at channel-open, which SSH.NET surfaces as `SOCKS5: General failure`,
|
||||||
|
naming neither the server nor the setting. `SshServerFixture` patches it after start and HUPs sshd. **There
|
||||||
|
are two `sshd_config` files in that image** and the running server uses `/config/sshd/sshd_config`; patching
|
||||||
|
`/etc/ssh/sshd_config`, which is the one a search finds first, changes the text and nothing else.
|
||||||
|
|
||||||
|
**The relay bridge and the jump bridge are the same class and not the same lifetime.** A ticket is
|
||||||
|
single-use with a 30-second expiry; a bastion's forward lives as long as the session. Sharing the listener
|
||||||
|
is right, sharing a lifetime policy is not.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- ✅ A connect through a real SOCKS5 forward to a real `sshd`, in `LoopbackProxyTests`. **One container,
|
||||||
|
used as both ends** — the forward is opened on a connection to the fixture's server and the connection
|
||||||
|
under test goes back to the same server through it. A second container would look more like the topology
|
||||||
|
and establish nothing extra: what is under test is that the proxy is honoured, that the target is what
|
||||||
|
gets pinned, and that a failure on the way through is reported as itself.
|
||||||
|
- A cycle in a chain is refused before any socket is opened.
|
||||||
|
- Each hop's host key is asked about separately, and the question names the hop.
|
||||||
|
- A chain crossing a vault is refused with a reason, as the group picker's is.
|
||||||
|
- The bridge binds loopback only — assert the bound address, because the failure is silent and the
|
||||||
|
consequence is an open proxy.
|
||||||
|
- SFTP to a host behind a chain, once step 4 lands.
|
||||||
|
- Mutations that must fail something: bind the bridge on `IPAddress.Any`; drop the visited set; skip group
|
||||||
|
inheritance for a hop; and tear down the outer session without the hops.
|
||||||
|
|
||||||
|
## Prose that becomes false
|
||||||
|
|
||||||
|
- `docs/design-import-gaps.md` — the host subtitle's `⤷ bastion-eu` row, the SFTP `sftp over bastion-eu`
|
||||||
|
row, and the status bar's `via bastion-eu` row, all of which say jump hosts are data-only.
|
||||||
|
- `Client.Import/ImportedHost.cs` — the note written onto every imported host with a `ProxyJump`, and the
|
||||||
|
remark above it.
|
||||||
|
- `README.md` and `docs/android-port.md` wherever the relay is described as available. ✅ at step 3.
|
||||||
|
- ADR 0004 gains a note that its last consequence was built, and how. ✅ at step 3.
|
||||||
|
|
||||||
|
## What the first draft of this document got wrong
|
||||||
|
|
||||||
|
It recommended deleting `JumpHostIds`, on the evidence that nothing writes it, nothing reads it, and it is
|
||||||
|
missing from the schema-version ladder. The first two facts are true and the conclusion did not follow.
|
||||||
|
|
||||||
|
Two things were missed. **ADR 0004 had already designed the implementation** — the loopback bridge, the
|
||||||
|
SOCKS5 dynamic forward, "one mechanism, two features" — so the transport was a solved problem sitting in an
|
||||||
|
accepted ADR, and the fortnight that draft estimated was priced without it. And **the stored shape is
|
||||||
|
right**: an ordered list of host ids is exactly what a chain is, the merge arm is already correct, and the
|
||||||
|
missing schema version is a line to add rather than evidence of a bad model.
|
||||||
|
|
||||||
|
The lesson is narrower than "read the ADRs": it is that *nothing reads this field* was taken as evidence the
|
||||||
|
field was a mistake, when it was evidence of an unfinished feature — and the same reasoning applied one
|
||||||
|
paragraph further would have found the relay checkbox, which is the same shape and is actively lying to
|
||||||
|
users.
|
||||||
|
|
||||||
|
**And then step 1 was wrong too**, in the same direction: it named the bridge as the shared foundation on
|
||||||
|
the strength of ADR 0004's "one mechanism, two features", without checking which half of the mechanism
|
||||||
|
SSH.NET already provides. It provides the jump host's half entirely. The shared piece was one level down and
|
||||||
|
a tenth of the size, and the bridge belongs with the relay. Both corrections came from writing the code
|
||||||
|
rather than from reading more — which is an argument for the step-at-a-time ordering rather than against
|
||||||
|
planning, but only if the plan is edited when a step answers back.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Unlocking without the passphrase
|
||||||
|
|
||||||
|
Every account here is issued a recovery code at enrollment. It is generated on the client, it wraps the
|
||||||
|
identity bundle, the server stores that wrap, and both heads go to some trouble to make sure the user writes
|
||||||
|
it down — the phone raises `FLAG_SECURE` for that screen alone and refuses to let anybody click past it.
|
||||||
|
|
||||||
|
**Nothing can use it.** There is no code path in this product that opens a recovery wrap. This document is
|
||||||
|
the plan for the change that fixes that, and it is written to be picked up cold.
|
||||||
|
|
||||||
|
> **Status: planned. None of the six steps below is built.**
|
||||||
|
>
|
||||||
|
> | Step | State | Notes |
|
||||||
|
> | --- | --- | --- |
|
||||||
|
> | 1. The endpoint that serves the wrap | Not started | `GET /api/v1/me/recovery-wrap`, and deliberately not `/me` |
|
||||||
|
> | 2. `SessionOpener.UnlockWithRecoveryAsync` | Not started | The unwrap, then the path `UnlockAsync` already takes |
|
||||||
|
> | 3. Setting a new passphrase | Not started | `PUT /api/v1/me/wrap`. Delivers *change passphrase* as well |
|
||||||
|
> | 4. Both heads | Not started | A way in from the unlock screen, and a box for the code |
|
||||||
|
> | 5. The prose that becomes false | Not started | Three shipped claims disagree with each other today |
|
||||||
|
> | 6. `crypto.md`'s status note | Not started | "One of four ways" is one of two, and will be one of three |
|
||||||
|
|
||||||
|
## What is wrong
|
||||||
|
|
||||||
|
Walk the failure through, because it is worse than a missing feature.
|
||||||
|
|
||||||
|
Forget the passphrase and the identity bundle cannot be unwrapped. No bundle means no vault keys, and no
|
||||||
|
vault keys means every item in every vault is unreadable. Signing out and back in does not help: the server
|
||||||
|
hands back the same passphrase wrap. The device key would be the other door, and **sign-out withdraws it** —
|
||||||
|
which is the advice the unlock screen gives for exactly this situation. The recovery wrap sitting on the
|
||||||
|
server is the only thing left, and nothing opens it.
|
||||||
|
|
||||||
|
So the loss is total and permanent, and the thing built to prevent it is inert.
|
||||||
|
|
||||||
|
Meanwhile the product says three things about this, and they do not agree with one another:
|
||||||
|
|
||||||
|
| What it says | Where | True today |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| "The code is the only thing standing between a forgotten passphrase and an unrecoverable vault" | `docs/manual-checks.md` §10.2 | **No.** It stands between nothing |
|
||||||
|
| "losing it *along with* the passphrase means the vault is unrecoverable" | `docs/android-port.md`, state 4 | **No.** It implies the code alone is enough |
|
||||||
|
| "Signing out … is the only answer to a forgotten passphrase — nothing can recover one" | `README.md`, Locking | Yes, and it contradicts both of the above |
|
||||||
|
|
||||||
|
The third is the honest one. That is the state this plan changes, and until it does, the first two are the
|
||||||
|
two sentences in this repository most likely to cost somebody their keychain.
|
||||||
|
|
||||||
|
## What already exists
|
||||||
|
|
||||||
|
More than half of it, which is why this is worth doing now rather than treating as a feature.
|
||||||
|
|
||||||
|
- **The code.** `ClientEnrollment.GenerateRecoveryCode` — 160 bits, base32 over a 32-character alphabet with
|
||||||
|
`I`, `L`, `O` and `U` left out so a transcription cannot land on a different valid code, printed as
|
||||||
|
32 characters in groups of five.
|
||||||
|
- **The wrap.** The same class derives `KEK_rc` under `Argon2Profile.RandomSecret` (64 MiB, 3 passes) and
|
||||||
|
sends `RecoveryWrappedPrivateKey` and `RecoveryKdfParameters` with the enrollment.
|
||||||
|
- **The row.** `EnrollmentService` stores it as `UserKeyWrapKind.Recovery`, beside the passphrase and device
|
||||||
|
wraps.
|
||||||
|
- **The specification.** `docs/crypto.md` §2 gives the parameters for `KEK_rc` and §3 puts the wrap in the
|
||||||
|
key hierarchy beside the passphrase and device ones. Nothing below needs a spec change.
|
||||||
|
- **The cache.** `LocalCacheKey` derives from the identity bundle under `dsh1/localcache/v2`, not from `MK`
|
||||||
|
— see the changed-2026-07-30 note in `crypto.md` §3.2. That change was made partly *for* this: a recovery
|
||||||
|
unlock derives a different `MK` and would otherwise open the identity and then fail to read the cache it
|
||||||
|
had itself written. It is already paid for and currently untested from this direction.
|
||||||
|
|
||||||
|
## What is missing, exactly
|
||||||
|
|
||||||
|
| | Gap | Where it lands |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A | Nothing serves the wrap. `MeResponse` carries `WrappedPrivateKey` and `KdfParameters` — the passphrase pair, and only that | `DodoSSH.Contracts`, `Api/Features/Identity` |
|
||||||
|
| B | No unlock path. `SessionOpener` has `UnlockAsync` and `UnlockWithDeviceAsync`, and nothing else | `Client.Session` |
|
||||||
|
| C | No way to set a new passphrase afterwards. No endpoint, no client path, no UI | server + client |
|
||||||
|
| D | No way in from either unlock screen | `UnlockCard.axaml`, Android `LockedScreen.axaml` |
|
||||||
|
|
||||||
|
The device wrap is not a precedent for A: it is cached locally in `StoredUnlockMaterial` at the moment it is
|
||||||
|
registered, so it never has to be fetched.
|
||||||
|
|
||||||
|
## The decisions, and the reasons
|
||||||
|
|
||||||
|
**A separate endpoint, not `/me`.** `GET /api/v1/me/recovery-wrap`, called only when somebody has said they
|
||||||
|
have forgotten their passphrase. `/me` is fetched by every client at every launch, and putting the recovery
|
||||||
|
wrap in it would hand that blob to anybody holding a stolen OIDC session, permanently, for no benefit. The
|
||||||
|
wrap's real defence is Argon2id over 160 bits and it does not stop being safe in the response — this is the
|
||||||
|
cheaper rule of not serving what nothing needs. It answers 404 for an account with no recovery row, which is
|
||||||
|
every account enrolled by a client that did not send one.
|
||||||
|
|
||||||
|
**Online only, and the screen says so.** The wrap is deliberately *not* added to `StoredUnlockMaterial`.
|
||||||
|
Caching it would put a second door on every laptop, protected by a weaker KDF profile than the passphrase
|
||||||
|
one, for a case that already requires a network — recovery begins with signing in.
|
||||||
|
|
||||||
|
**Setting a new passphrase is part of the flow, not a follow-up.** Without it the account unlocks with a
|
||||||
|
one-time code forever, and the code is a thing people keep on paper. This is the same re-wrap a *change
|
||||||
|
passphrase* feature needs, so step 3 delivers both; change-passphrase is then a button, not a project.
|
||||||
|
|
||||||
|
**The identity key is not rotated.** Rotating it would invalidate every vault key grant the account holds
|
||||||
|
and force a rekey of every vault it can read — see [ADR 0010](adr/0010-vault-key-rotation.md) for what one
|
||||||
|
of those costs, and it is per vault. Nothing about the bundle is compromised by its owner proving possession
|
||||||
|
of it, so there is nothing to rotate away from. Identity key rotation is M5 and stays there.
|
||||||
|
|
||||||
|
**The recovery code is not re-issued after use.** Issuing a new one means asking somebody to write down a
|
||||||
|
new code at the exact moment they have demonstrated they lose things, and the old code is not weakened by
|
||||||
|
having been typed. "Issue a new recovery code" is a separate feature with its own screen, and it is the
|
||||||
|
right place for that question.
|
||||||
|
|
||||||
|
**Rate limiting is wanted here and is not a blocker.** This is the first endpoint in the product with a
|
||||||
|
guessable secret behind it, and `docs/platform-flags.md` records that rate limiting is unimplemented (M2).
|
||||||
|
Argon2id at 64 MiB is the cost that matters — a guessing attack pays it per attempt — so this ships without
|
||||||
|
and the endpoint is the reason to do the M2 item next.
|
||||||
|
|
||||||
|
## The work, in order
|
||||||
|
|
||||||
|
Each step compiles with the whole suite green before the next begins.
|
||||||
|
|
||||||
|
1. **The endpoint.** `GET /api/v1/me/recovery-wrap`, returning the wrap and its `KdfParameters`, or 404. A
|
||||||
|
contract type, an entry in `PublicAPI.Unshipped.txt`, and a `SyncEndpointTests`-style refusal test for an
|
||||||
|
account without one.
|
||||||
|
2. **The unlock.** `SessionOpener.UnlockWithRecoveryAsync(string code)`: canonicalise the code, derive
|
||||||
|
`KEK_rc` from the served parameters, unwrap the bundle, and then join the path `UnlockAsync` already
|
||||||
|
takes from the moment it holds one. Nothing after the unwrap should be new code — if it is, the two
|
||||||
|
unlocks have diverged and one of them is wrong.
|
||||||
|
3. **The re-wrap.** `PUT /api/v1/me/wrap` taking a new passphrase wrap and its parameters, and the client
|
||||||
|
half that derives the new `KEK_pp` and calls it. Forced as the last step of a recovery: the session is
|
||||||
|
open, so refusing to go further until a passphrase is set costs nothing and is the only moment the user
|
||||||
|
is certainly present.
|
||||||
|
4. **Both heads.** A "forgotten your passphrase?" route from `UnlockCard` and from Android's
|
||||||
|
`LockedScreen`, a box that accepts the code as printed, and the new-passphrase form after it. The phone's
|
||||||
|
version has to survive the keyboard covering the box — `docs/manual-checks.md` §10.3 lists the five boxes
|
||||||
|
that already do, and this is a sixth.
|
||||||
|
5. **The prose.** Below.
|
||||||
|
6. **`crypto.md`.** A status note, not a spec change — see the last section.
|
||||||
|
|
||||||
|
## Traps already known
|
||||||
|
|
||||||
|
**◆ The code is derived from the string exactly as displayed, dashes included.** `GenerateRecoveryCode`
|
||||||
|
says so where it builds the groups: the separators are not decoration to be stripped before hashing, they
|
||||||
|
are part of the input. So step 2 must *canonicalise to the printed form* rather than normalise it away —
|
||||||
|
up-case, then re-group in fives — and it must not simply strip dashes and hash what is left. Getting this
|
||||||
|
wrong produces a code that verifies nowhere and an error message that says the code is wrong.
|
||||||
|
|
||||||
|
**The alphabet has holes in it.** `I`, `L`, `O` and `U` are not in it. A user who writes `O` for `0` should
|
||||||
|
be met with a code that works, so the canonicaliser should fold the four missing letters onto their
|
||||||
|
look-alikes before deriving. That is a deliberate leniency and belongs beside the alphabet's own comment.
|
||||||
|
|
||||||
|
**The two KDF profiles are not the same, and the stored parameters are what to use.** The passphrase wrap is
|
||||||
|
`PassphraseDefault` (256 MiB, 4 passes) and the recovery wrap is `RandomSecret` (64 MiB, 3), which is
|
||||||
|
correct — 160 random bits do not need the same stretching as a chosen phrase. Derive from the parameters the
|
||||||
|
server served with the wrap, never from a profile constant, or an account enrolled by a client with
|
||||||
|
different settings will refuse a valid code.
|
||||||
|
|
||||||
|
**The cache property is untested from this direction.** A recovery unlock derives a different `MK` and must
|
||||||
|
still read a cache written under a passphrase unlock. That works today because `LocalCacheKey` hangs off the
|
||||||
|
bundle, and no test asserts it, because nothing could reach the state. Assert it in step 2 rather than
|
||||||
|
trusting the note in `crypto.md`.
|
||||||
|
|
||||||
|
**Sign-out is the neighbouring path and must stay reachable.** The unlock screen offers signing out as the
|
||||||
|
answer to a forgotten passphrase. It stays: recovery needs the code, and somebody without it still needs a
|
||||||
|
way to hand a laptop on. What changes is that it stops being the *only* answer, so the copy on both screens
|
||||||
|
has to place them beside each other rather than replacing one with the other.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Round trip: enrol, recover, unlock, set a new passphrase, unlock again with it.
|
||||||
|
- The old passphrase stops working after step 3, and the recovery code still does.
|
||||||
|
- A wrong code is refused without disclosing whether the account has a recovery wrap at all.
|
||||||
|
- The cache written under a passphrase unlock is readable after a recovery unlock — the `localcache/v2`
|
||||||
|
property, asserted rather than assumed.
|
||||||
|
- An account with no recovery row: the endpoint 404s and the screen says the code was never issued rather
|
||||||
|
than that it is wrong.
|
||||||
|
- One `SystemTests` pass against the real API, because this is a two-endpoint flow and that suite is the one
|
||||||
|
that consumes the artefacts that ship.
|
||||||
|
- Mutations to confirm each test can fail: reverse the canonicalisation so dashes are stripped; derive from
|
||||||
|
`Argon2Profile.RandomSecret` instead of from the served parameters; and skip the forced re-wrap in step 3.
|
||||||
|
|
||||||
|
## Prose that becomes false
|
||||||
|
|
||||||
|
Not a tidy-up. Each of these currently tells a user something that this change makes wrong in the other
|
||||||
|
direction, and two of them are wrong right now.
|
||||||
|
|
||||||
|
- `docs/manual-checks.md` §10.2 — "the only thing standing between a forgotten passphrase and an
|
||||||
|
unrecoverable vault". True once this ships, false today. It also needs a new check: typing a code from
|
||||||
|
paper, which is the one part of this no test can perform.
|
||||||
|
- `docs/android-port.md`, state 4 — "losing it along with the passphrase means the vault is unrecoverable".
|
||||||
|
- `README.md`, Locking — "Signing out … is the only answer to a forgotten passphrase — nothing can recover
|
||||||
|
one." Correct today and the sentence this change exists to falsify.
|
||||||
|
- `docs/design-import-gaps.md`, Preferences — "a forgotten passphrase — which is unrecoverable by
|
||||||
|
construction, so the unlock screen would otherwise be a dead end", which is the stated justification for
|
||||||
|
signing out being on that screen. The justification survives; the parenthesis does not.
|
||||||
|
- Both unlock screens, wherever they name signing out as the answer.
|
||||||
|
|
||||||
|
## The spec's "four ways"
|
||||||
|
|
||||||
|
`docs/crypto.md` §3.2 says a passphrase is "only one of four ways to open a vault" — passphrase, device,
|
||||||
|
recovery and escrow. Two exist. This change makes it three; escrow is M5 by decision and has an enum member
|
||||||
|
and nothing else.
|
||||||
|
|
||||||
|
**Do not edit that sentence.** It is normative and it is about the DSH1 format, where four ways is exactly
|
||||||
|
right. What is missing is a statement about *this build*, so add a short table under §3.2 saying which wrap
|
||||||
|
kinds this client can create and which it can open, and update it here rather than in the frozen prose. Land
|
||||||
|
it in the same commit as step 2, so the two can never disagree again.
|
||||||
Executable
+385
@@ -0,0 +1,385 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Turns a published linux-x64 tree into an RPM, and then checks the RPM rather than the exit code.
|
||||||
|
#
|
||||||
|
# Called from two jobs in .github/workflows/ci.yml and written once for the reason the four copies of
|
||||||
|
# "ensure node and git" in that file are not: those cannot be factored out — a composite action would
|
||||||
|
# need the checkout it exists to unblock — and this can. The build job packages the release-shaped
|
||||||
|
# RPM and throws it away, exactly as it does for Windows and macOS; the desktop job packages the
|
||||||
|
# nightly-shaped one and publishes it. Everything that differs between those two is an argument.
|
||||||
|
#
|
||||||
|
# It is also runnable at a desk, which is the point of it being a script:
|
||||||
|
#
|
||||||
|
# dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||||
|
# --configuration Release --runtime linux-x64 --self-contained true \
|
||||||
|
# -p:RestoreLockedMode=false --output /tmp/linux-x64
|
||||||
|
# scripts/ci-linux-rpm.sh --payload /tmp/linux-x64 --version 1.2.3 --out /tmp/rpm
|
||||||
|
#
|
||||||
|
# Nothing here holds a secret and nothing here uploads anything. Publishing is the calling job's, and
|
||||||
|
# ADR 0013 rule 3 is what decides which channel may do it.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
payload=""
|
||||||
|
version=""
|
||||||
|
channel="release"
|
||||||
|
out=""
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--payload) payload="$2"; shift 2 ;;
|
||||||
|
--version) version="$2"; shift 2 ;;
|
||||||
|
--channel) channel="$2"; shift 2 ;;
|
||||||
|
--out) out="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
for required in payload version out; do
|
||||||
|
if [ -z "${!required}" ]; then
|
||||||
|
echo "--$required is required." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$channel" in
|
||||||
|
release|nightly) ;;
|
||||||
|
*) echo "--channel must be release or nightly, not '$channel'." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The apphost, named rather than globbed. A publish that produced no apphost at all would otherwise
|
||||||
|
# package happily into an RPM whose /usr/bin symlink points at nothing, and the first thing anybody
|
||||||
|
# would learn about it is a shell saying "No such file or directory" for a file that is right there.
|
||||||
|
if [ ! -s "$payload/DodoSSH" ]; then
|
||||||
|
echo "There is no apphost at $payload/DodoSSH, so there is nothing to package." >&2
|
||||||
|
ls -la "$payload" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ◆ THE FOUR THINGS THAT DIFFER BETWEEN THE CHANNELS, AND WHY IT IS FOUR AND NOT ONE.
|
||||||
|
#
|
||||||
|
# This is ADR 0013 rule 9's table, in the spelling this platform uses. The package name keeps dnf from
|
||||||
|
# treating one as an upgrade of the other; the directory under /opt keeps the two trees apart on disk;
|
||||||
|
# the /usr/bin name and the desktop entry keep them apart in front of a person, who would otherwise
|
||||||
|
# have two identical launchers and no way to tell which was which.
|
||||||
|
#
|
||||||
|
# The fifth separation — the profile directory holding the vault cache, the outbox and the device key
|
||||||
|
# — is not here and must not be, because it belongs to the build rather than to the package: the
|
||||||
|
# nightly is published with DodoChannel=nightly, and ClientPaths reads that to suffix the directory.
|
||||||
|
# Doing it here as well would be a second answer to a question that already has one.
|
||||||
|
if [ "$channel" = nightly ]; then
|
||||||
|
name=dodossh-nightly
|
||||||
|
dir=DodoSSH.Nightly
|
||||||
|
bin=dodossh-nightly
|
||||||
|
title="DodoSSH Nightly"
|
||||||
|
else
|
||||||
|
name=dodossh
|
||||||
|
dir=DodoSSH
|
||||||
|
bin=dodossh
|
||||||
|
title="DodoSSH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ◆ SEMVER IS NOT AN RPM VERSION, AND THE TRANSLATION IS THE ONE PLACE THIS CAN GO QUIETLY WRONG.
|
||||||
|
#
|
||||||
|
# rpm forbids `-` in a version, which is exactly the character SemVer uses to introduce a prerelease.
|
||||||
|
# MinVer's answer on main is 0.0.1-alpha.0.143, so the naive move — strip the suffix — would give
|
||||||
|
# every nightly the version 0.0.1 and make each one look like a reinstall of the last.
|
||||||
|
#
|
||||||
|
# `~` is rpm's own answer and has been since 4.10: it sorts *before* the empty string, so
|
||||||
|
# 0.0.1~alpha.0.143 is older than 0.0.1 the way alpha.143 is older than the release. Heights keep
|
||||||
|
# rising within a floored version and the first real tag moves past all of them, which is the same
|
||||||
|
# ordering property the Velopack channels have and is worth having on both.
|
||||||
|
#
|
||||||
|
# Build metadata after `+` is dropped rather than translated. It is the one part of a SemVer string
|
||||||
|
# that is defined as not participating in ordering, so keeping it would put a character rpm has no
|
||||||
|
# rule for into the field ordering is done on.
|
||||||
|
rpm_version="${version%%+*}"
|
||||||
|
rpm_version="${rpm_version/-/\~}"
|
||||||
|
|
||||||
|
case "$rpm_version" in
|
||||||
|
*-*)
|
||||||
|
echo "The version $version translates to $rpm_version, which still contains a hyphen." >&2
|
||||||
|
echo "rpm cannot order a version with one in it; this needs a rule rather than a retry." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Always 1. The release field distinguishes several packagings of one upstream version, and there is
|
||||||
|
# only ever one packaging here — the version already carries the build height, because upstream and
|
||||||
|
# packager are the same repository.
|
||||||
|
rpm_release=1
|
||||||
|
|
||||||
|
echo "==> $name $rpm_version-$rpm_release.x86_64, from $payload"
|
||||||
|
|
||||||
|
# ◆ rpmbuild ON A MUSL HOST, WHICH IS FINE, AND WHY IT IS WORTH SAYING SO.
|
||||||
|
#
|
||||||
|
# The runner is Alpine. Nothing about that shows up in the package: rpmbuild composes an archive and
|
||||||
|
# some metadata, and the one part of it that reads the payload — elfdeps, which the spec leaves on for
|
||||||
|
# requires — parses ELF headers with libelf rather than loading anything, so it reads x86_64 glibc
|
||||||
|
# binaries on a musl host correctly. Building in a Fedora container was the alternative and would
|
||||||
|
# have meant `docker cp`-ing a 130 MB publish tree in and the package back out, because this runner is
|
||||||
|
# itself a container and a bind mount resolves on the daemon's host rather than here — see the note on
|
||||||
|
# the android job for the hour that cost.
|
||||||
|
if ! command -v rpmbuild >/dev/null 2>&1; then
|
||||||
|
SUDO=""
|
||||||
|
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
||||||
|
|
||||||
|
echo "==> installing rpm"
|
||||||
|
if command -v apk >/dev/null 2>&1; then
|
||||||
|
$SUDO apk add --no-cache rpm
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
$SUDO dnf install -y rpm-build
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
$SUDO apt-get update -qq
|
||||||
|
$SUDO apt-get install -y --no-install-recommends rpm
|
||||||
|
else
|
||||||
|
echo "No apk, dnf or apt-get here, so rpmbuild cannot be installed from inside the job." >&2
|
||||||
|
echo "Add rpm to the runner's image; the Linux package cannot be built without it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
rpmbuild --version
|
||||||
|
|
||||||
|
# ◆ THE LIST OF PRIVATE SONAMES, WORKED OUT FROM THE TREE RATHER THAN WRITTEN DOWN.
|
||||||
|
#
|
||||||
|
# build/linux/dodossh.spec turns rpm's automatic provides off for everything under /opt, because the
|
||||||
|
# libraries there are this application's private copies and have no business satisfying anybody
|
||||||
|
# else's dependency. The requires side has to be told the same thing about the same set, or a bundled
|
||||||
|
# library that links another bundled library asks for a soname nothing provides and the package
|
||||||
|
# becomes uninstallable on every host — which .NET's own libmscordbi.so, linking libmscordaccore.so,
|
||||||
|
# does on every publish.
|
||||||
|
#
|
||||||
|
# Derived here because the set belongs to the payload. A list in the spec would be Microsoft's and
|
||||||
|
# SkiaSharp's internal file names, maintained by hand, going stale on the first runtime bump — and
|
||||||
|
# going stale in the direction that produces a package nobody can install.
|
||||||
|
#
|
||||||
|
# Only the top level is scanned, which is where a .NET publish puts its native libraries. The dots
|
||||||
|
# are escaped because this becomes a regular expression; nothing else in a soname is a metacharacter.
|
||||||
|
#
|
||||||
|
# A shell glob rather than `find -printf`, which is GNU find's and not busybox's — the runner is
|
||||||
|
# Alpine, where that flag is a usage message and an empty list, which would silently disable the whole
|
||||||
|
# exclusion and put the uninstallable package back.
|
||||||
|
bundled="$(
|
||||||
|
for library in "$payload"/lib*.so*; do
|
||||||
|
[ -f "$library" ] && basename "$library"
|
||||||
|
done |
|
||||||
|
sed 's/\./\\./g' |
|
||||||
|
sort -u |
|
||||||
|
paste -sd '|' -
|
||||||
|
)"
|
||||||
|
|
||||||
|
if [ -z "$bundled" ]; then
|
||||||
|
echo "There are no native libraries at the top of $payload." >&2
|
||||||
|
echo "A self-contained .NET publish always has some, so this is a publish that went wrong" >&2
|
||||||
|
echo "rather than a package with nothing to exclude." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
mkdir -p "$out"
|
||||||
|
|
||||||
|
# --target rather than trusting the host's arch, because the host's arch here is whatever the runner
|
||||||
|
# happens to be and the payload's is decided by the RID the publish asked for. They agree today and
|
||||||
|
# an aarch64 runner would silently make them disagree.
|
||||||
|
#
|
||||||
|
# `dist` is emptied deliberately. It is the %{?dist} tag that puts .fc42 in a filename, and it names
|
||||||
|
# the distribution the package was *built* on — which for a self-contained tree built on Alpine would
|
||||||
|
# be both meaningless and, if rpm guessed something plausible, actively misleading.
|
||||||
|
rpmbuild -bb "$repo_root/build/linux/dodossh.spec" \
|
||||||
|
--target x86_64 \
|
||||||
|
--define "_topdir $work" \
|
||||||
|
--define "_rpmdir $out" \
|
||||||
|
--define "dist %{nil}" \
|
||||||
|
--define "dodo_name $name" \
|
||||||
|
--define "dodo_version $rpm_version" \
|
||||||
|
--define "dodo_release $rpm_release" \
|
||||||
|
--define "dodo_dir $dir" \
|
||||||
|
--define "dodo_bin $bin" \
|
||||||
|
--define "dodo_title $title" \
|
||||||
|
--define "dodo_payload $payload" \
|
||||||
|
--define "dodo_bundled $bundled" \
|
||||||
|
--define "dodo_icons $repo_root/src/DodoSSH.Client.App/Assets" \
|
||||||
|
--define "dodo_udev $repo_root/build/linux/71-dodossh-tpm.rules" \
|
||||||
|
--define "dodo_licence $repo_root/LICENSE"
|
||||||
|
|
||||||
|
# rpmbuild writes into <_rpmdir>/<arch>/, and every caller wants the file rather than the tree.
|
||||||
|
rpm_file="$out/x86_64/$name-$rpm_version-$rpm_release.x86_64.rpm"
|
||||||
|
if [ ! -s "$rpm_file" ]; then
|
||||||
|
echo "rpmbuild reported success and there is no package at $rpm_file." >&2
|
||||||
|
find "$out" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mv "$rpm_file" "$out/"
|
||||||
|
rmdir "$out/x86_64" 2>/dev/null || true
|
||||||
|
rpm_file="$out/$(basename "$rpm_file")"
|
||||||
|
|
||||||
|
echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
|
||||||
|
|
||||||
|
# ◆ AND NOW THE PART THAT IS NOT PACKAGING: ASKING THE PACKAGE WHAT IT SAYS ABOUT ITSELF.
|
||||||
|
#
|
||||||
|
# Everything above can succeed and still produce something nobody can install, and none of the ways
|
||||||
|
# that happens are visible in an exit code. Each check below is one of them.
|
||||||
|
#
|
||||||
|
# ◆ EVERY READER IN A PIPELINE HERE MUST CONSUME ALL OF ITS INPUT, AND THAT IS NOT A STYLE RULE.
|
||||||
|
#
|
||||||
|
# `set -o pipefail` at the top of this file means a pipeline's status is the rightmost non-zero one.
|
||||||
|
# A reader that stops early — `grep -q`, `head -n` — closes the pipe while the writer is still
|
||||||
|
# writing, the writer dies of SIGPIPE with status 141, and pipefail hands that 141 to the `if`. So a
|
||||||
|
# check that found what it was looking for reports the opposite, and a diagnostic printed on the way
|
||||||
|
# to `exit 1` kills the script under `set -e` before it gets there.
|
||||||
|
#
|
||||||
|
# It is a threshold rather than a constant, which is what made it look like somebody else's bug: the
|
||||||
|
# writer only blocks once it has more than a pipe buffer left to write, about 64 KB on Linux. `$files`
|
||||||
|
# is one line per packaged file, so this passed for as long as the published tree was small and then
|
||||||
|
# failed on the commit that pushed the path text past 64 KB — reporting `/opt/DodoSSH/DodoSSH` missing
|
||||||
|
# from a package that plainly contained it, and exiting 141 rather than the 1 it meant to.
|
||||||
|
#
|
||||||
|
# Hence `grep pattern >/dev/null` rather than `grep -q pattern`, which has the same exit status and
|
||||||
|
# reads to the end; and `awk 'NR<=40'` rather than `head -40`, which prints the same lines and reads
|
||||||
|
# to the end. Nothing here is allowed to stop early, and `|| true` is not the fix — it would hide a
|
||||||
|
# real failure of the writer alongside the imaginary one.
|
||||||
|
|
||||||
|
requires="$(rpm -qp --requires "$rpm_file")"
|
||||||
|
provides="$(rpm -qp --provides "$rpm_file")"
|
||||||
|
files="$(rpm -qp --list "$rpm_file")"
|
||||||
|
|
||||||
|
# 1. The bundled-soname trap the spec's provides exclusion sets. If some library under /opt ever
|
||||||
|
# gains a DT_NEEDED on another library under /opt, elfdeps will put that soname in the requires
|
||||||
|
# while the exclusion keeps it out of the provides, and the result is a package no host on earth
|
||||||
|
# can satisfy. Nothing about it looks wrong until an install fails, so it is checked here against
|
||||||
|
# the actual contents rather than assumed.
|
||||||
|
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
|
||||||
|
unsatisfiable=""
|
||||||
|
for soname in $carried; do
|
||||||
|
if printf '%s\n' "$requires" | grep -F "$soname(" >/dev/null; then
|
||||||
|
unsatisfiable="$unsatisfiable $soname"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$unsatisfiable" ]; then
|
||||||
|
echo "This package requires sonames it also carries and does not provide:$unsatisfiable" >&2
|
||||||
|
echo "That is unsatisfiable on every host, and it means the requires exclusion computed above" >&2
|
||||||
|
echo "no longer matches what rpm generated — a bundled library outside the top level, or an rpm" >&2
|
||||||
|
echo "that has stopped honouring __requires_exclude. See build/linux/dodossh.spec." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
|
||||||
|
# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring
|
||||||
|
# the macro would break it silently and in the direction nobody looks.
|
||||||
|
if printf '%s\n' "$provides" | grep -E '^lib.*\.so' >/dev/null; then
|
||||||
|
echo "This package advertises bundled libraries to the system:" >&2
|
||||||
|
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2
|
||||||
|
echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. elfdeps having actually run. If it silently did nothing — a runner image whose rpm was built
|
||||||
|
# without libelf would do exactly that — the package would carry no glibc requirement at all and
|
||||||
|
# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a
|
||||||
|
# self-contained .NET tree cannot fail to have.
|
||||||
|
if ! printf '%s\n' "$requires" | grep '^libc\.so\.6' >/dev/null; then
|
||||||
|
echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2
|
||||||
|
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
|
||||||
|
printf '%s\n' "$requires" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. The four files a person interacts with, named individually. The tree under /opt is thousands of
|
||||||
|
# entries and its absence would be caught by size alone; these are the small ones whose absence
|
||||||
|
# would leave a package that installs, occupies 130 MB and puts nothing on the menu.
|
||||||
|
for expected in \
|
||||||
|
"/opt/$dir/DodoSSH" \
|
||||||
|
"/usr/bin/$bin" \
|
||||||
|
"/usr/share/applications/$bin.desktop" \
|
||||||
|
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
||||||
|
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
||||||
|
do
|
||||||
|
if ! printf '%s\n' "$files" | grep -xF "$expected" >/dev/null; then
|
||||||
|
echo "The package was built and $expected is not in it." >&2
|
||||||
|
printf '%s\n' "$files" | awk 'NR<=40' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 5. Nothing world-writable, which on this package is a security property rather than a lint rule.
|
||||||
|
# The spec sets every mode explicitly and says why; this is the check that it worked, because the
|
||||||
|
# failure it guards against arrives by inheritance from a publish directory rather than by anybody
|
||||||
|
# changing this. A world-writable /opt/DodoSSH lets any local account replace the SSH client every
|
||||||
|
# other account on the machine runs.
|
||||||
|
#
|
||||||
|
# Symlinks are excluded because they are always 777 on Linux and the mode means nothing; what the
|
||||||
|
# link points at is what has permissions.
|
||||||
|
writable="$(
|
||||||
|
rpm -qp --queryformat '[%{FILEMODES:perms} %{FILENAMES}\n]' "$rpm_file" |
|
||||||
|
grep -v '^l' |
|
||||||
|
awk '$1 ~ /^.{8}w/ { print }'
|
||||||
|
)"
|
||||||
|
if [ -n "$writable" ]; then
|
||||||
|
echo "This package contains world-writable files:" >&2
|
||||||
|
printf '%s\n' "$writable" >&2
|
||||||
|
echo "Any local account could replace them. See the mode block in build/linux/dodossh.spec." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> requires:"
|
||||||
|
printf '%s\n' "$requires" | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo "==> $(basename "$rpm_file") is a $name $rpm_version package and says so."
|
||||||
|
|
||||||
|
# ◆ THE MANIFEST, WHICH IS WHAT AN INSTALLED CLIENT READS AND NOT WHAT A PERSON READS.
|
||||||
|
#
|
||||||
|
# The same arrangement the android job publishes, for the same reason its comment gives: a release's own
|
||||||
|
# JSON is a large document that changes shape when the forge is upgraded, and the client fetches this on a
|
||||||
|
# six-hourly timer. A small file this repository writes is a contract; a large one the forge writes is not.
|
||||||
|
#
|
||||||
|
# ◆ THE VERSION IN HERE IS THE RPM VERSION, AND THAT IS THE WHOLE POINT OF THE FILE.
|
||||||
|
#
|
||||||
|
# LinuxUpdateChannel compares with rpmvercmp, because rpm is what will accept or refuse the install — the
|
||||||
|
# same argument AndroidChannelManifest makes for versionCode. Publishing the SemVer instead would mean the
|
||||||
|
# client comparing one thing and the package manager another, and they disagree exactly where it matters:
|
||||||
|
# 0.0.1-alpha.0.143 and 0.0.1~alpha.0.143 are the same build, and only the second one sorts below 0.0.1.
|
||||||
|
#
|
||||||
|
# The SemVer goes in too and is used for nothing but the banner. A person asked which nightly they are on
|
||||||
|
# should be able to answer with the string the preferences screen prints, and that screen reads the
|
||||||
|
# assembly.
|
||||||
|
#
|
||||||
|
# ◆ AND THE DIGEST, WHICH THE OTHER TWO CHANNELS HAVE NO NEED OF.
|
||||||
|
#
|
||||||
|
# Velopack verifies what it downloads and Android refuses a package whose signature does not match the
|
||||||
|
# installed one. Nothing about an RPM fetched over HTTPS and handed to PackageKit checks that it is the
|
||||||
|
# file this release published — TLS authenticates the forge and says nothing about the bytes surviving a
|
||||||
|
# proxy, a cache or a partial write. So the manifest names the digest and the client refuses anything else.
|
||||||
|
#
|
||||||
|
# It is not a signature and must not be read as one: it is written by the same job that writes the package,
|
||||||
|
# so it proves integrity and not provenance. Provenance here is that the address is a constant pointing at
|
||||||
|
# the project's own forge — ADR 0013 rule 4 — and a signed repository is what would improve on it.
|
||||||
|
manifest="$out/linux-$channel.json"
|
||||||
|
digest="$(sha256sum "$rpm_file" | cut -d' ' -f1)"
|
||||||
|
|
||||||
|
cat > "$manifest" <<JSON
|
||||||
|
{
|
||||||
|
"rpmVersion": "$rpm_version-$rpm_release",
|
||||||
|
"version": "$version",
|
||||||
|
"rpm": "$(basename "$rpm_file")",
|
||||||
|
"sha256": "$digest"
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
echo "==> $(basename "$manifest"):"
|
||||||
|
sed 's/^/ /' "$manifest"
|
||||||
|
|
||||||
|
# The lines the calling job reads. Written to stdout as well as to GITHUB_OUTPUT when there is one, so the
|
||||||
|
# desk use above still tells you where the file went.
|
||||||
|
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
||||||
|
{
|
||||||
|
echo "rpm=$rpm_file"
|
||||||
|
echo "rpmName=$(basename "$rpm_file")"
|
||||||
|
echo "manifest=$manifest"
|
||||||
|
echo "manifestName=$(basename "$manifest")"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
echo "$rpm_file"
|
||||||
+60
-10
@@ -169,16 +169,66 @@ Bump ApplicationVersion, commit, move the tag, and run this again.
|
|||||||
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
|
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
|
||||||
New-Item -ItemType Directory -Path $Staging | Out-Null
|
New-Item -ItemType Directory -Path $Staging | Out-Null
|
||||||
|
|
||||||
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
|
# ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE.
|
||||||
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
#
|
||||||
& dotnet build $Project --no-restore --configuration Release `
|
# They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is
|
||||||
-t:SignAndroidPackage `
|
# not. A process's command line is not a secret on Windows: any process running as this user can read
|
||||||
-p:DodoChannel=release `
|
# every argument of every other with one call —
|
||||||
-p:AndroidKeyStore=true `
|
#
|
||||||
-p:AndroidSigningKeyStore=$KeystorePath `
|
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||||
-p:AndroidSigningKeyAlias=$KeyAlias `
|
#
|
||||||
-p:AndroidSigningStorePass=$storePlain `
|
# — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The
|
||||||
-p:AndroidSigningKeyPass=$keyPlain
|
# comment above says the passwords are never stored, and with the arguments written that way it was
|
||||||
|
# true of the disk and false of everything watching the machine.
|
||||||
|
#
|
||||||
|
# env: is .NET for Android's own answer to exactly this, and it names the variable rather than carrying
|
||||||
|
# the value: MSBuild is handed the string "env:DODOSSH_ANDROID_STOREPASS" and reads the password out of
|
||||||
|
# the environment itself. The one restriction is $(AndroidPackageFormat) = aab, where env: is not
|
||||||
|
# supported and file: is the documented alternative; this channel ships an APK, and the guard below
|
||||||
|
# fails loudly rather than silently signing with the literal string if that ever changes.
|
||||||
|
#
|
||||||
|
# Not a perfect channel — an environment block is readable by a process with rights over this one, the
|
||||||
|
# same as memory is — but it is off the command line, out of the audit trail and out of any log that
|
||||||
|
# echoes the build invocation, which is the whole of the difference being bought here.
|
||||||
|
#
|
||||||
|
# -nodeReuse:false so no MSBuild worker outlives this call holding the variables in its environment.
|
||||||
|
# The finally clears them from this process either way, and a persistent node would have kept its own
|
||||||
|
# inherited copy for the next quarter of an hour regardless of what happens here.
|
||||||
|
$packageFormat = (& dotnet msbuild $Project -getProperty:AndroidPackageFormat -nologo `
|
||||||
|
-p:DodoChannel=release | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||||
|
|
||||||
|
if ($packageFormat -eq 'aab') {
|
||||||
|
Stop-With @'
|
||||||
|
This channel now builds an aab, and the env: prefix this script signs with is not supported for that
|
||||||
|
format. The signing would silently take "env:DODOSSH_ANDROID_STOREPASS" for the password itself.
|
||||||
|
|
||||||
|
Switch both passwords to the file: prefix, which is supported for aab, and make sure whatever writes
|
||||||
|
that file removes it again.
|
||||||
|
'@
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$env:DODOSSH_ANDROID_STOREPASS = $storePlain
|
||||||
|
$env:DODOSSH_ANDROID_KEYPASS = $keyPlain
|
||||||
|
|
||||||
|
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
|
||||||
|
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
||||||
|
& dotnet build $Project --no-restore --configuration Release `
|
||||||
|
-nodeReuse:false `
|
||||||
|
-t:SignAndroidPackage `
|
||||||
|
-p:DodoChannel=release `
|
||||||
|
-p:AndroidKeyStore=true `
|
||||||
|
-p:AndroidSigningKeyStore=$KeystorePath `
|
||||||
|
-p:AndroidSigningKeyAlias=$KeyAlias `
|
||||||
|
-p:AndroidSigningStorePass=env:DODOSSH_ANDROID_STOREPASS `
|
||||||
|
-p:AndroidSigningKeyPass=env:DODOSSH_ANDROID_KEYPASS
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
|
||||||
|
# and this runs on the failure path, where it may well not be.
|
||||||
|
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_STOREPASS', $null)
|
||||||
|
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_KEYPASS', $null)
|
||||||
|
}
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
|
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
#!/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 an
|
||||||
|
# ambient environment variable, 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.
|
||||||
|
# It reaches vpk through VPK_TOKEN, exported for that one call and unset afterwards, because the
|
||||||
|
# alternative is --token on a command line that is not private. See the comment at the upload itself.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# No --token in this array, and that is deliberate. It used to be there, and an argument is not a
|
||||||
|
# private place to put a credential: /proc/<pid>/cmdline is world-readable on Linux, `ps` hands out
|
||||||
|
# other processes' arguments freely, and whatever agent is auditing process creation on this machine
|
||||||
|
# keeps its own copy. The comment above is about shell history and was never the whole story.
|
||||||
|
#
|
||||||
|
# vpk documents VPK_TOKEN as --token's environment fallback, so the value never becomes an argument.
|
||||||
|
# Exported for the one call and unset straight after; an environment block is still readable by a
|
||||||
|
# process with rights over this one, but it is out of every process listing and audit log.
|
||||||
|
UPLOAD_ARGS=(
|
||||||
|
upload gitea
|
||||||
|
--repoUrl "$REPO_URL"
|
||||||
|
--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'
|
||||||
|
|
||||||
|
# Unset on the way out whether or not the upload worked, so a failed release does not leave the token
|
||||||
|
# sitting in this shell for whatever runs next.
|
||||||
|
export VPK_TOKEN="$TOKEN"
|
||||||
|
vpk_status=0
|
||||||
|
dotnet vpk "${UPLOAD_ARGS[@]}" || vpk_status=$?
|
||||||
|
unset VPK_TOKEN
|
||||||
|
|
||||||
|
[ "$vpk_status" -eq 0 ] || 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
|
||||||
+50
-10
@@ -16,9 +16,11 @@
|
|||||||
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
|
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
|
||||||
so the bytes that reach users are the bytes that were installed and checked.
|
so the bytes that reach users are the bytes that were installed and checked.
|
||||||
|
|
||||||
The token is prompted for rather than read from a file or an environment variable, and only in the phase
|
The token is prompted for rather than read from a file or an ambient environment variable, and only in
|
||||||
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
|
the phase that needs it — the build does not, and the fewer minutes a credential that can publish an
|
||||||
in a shell's memory the better.
|
update spends in a shell's memory the better. It reaches vpk through VPK_TOKEN, set for that one call
|
||||||
|
and cleared afterwards, because the alternative is --token on a command line every process on this
|
||||||
|
machine can read. See the comment at the upload itself.
|
||||||
|
|
||||||
.PARAMETER Upload
|
.PARAMETER Upload
|
||||||
Publish the packages already in Releases/ instead of building.
|
Publish the packages already in Releases/ instead of building.
|
||||||
@@ -71,6 +73,11 @@ $RepoUrl = 'https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
|
|||||||
# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it
|
# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it
|
||||||
# unsaid on both sides would work too — but unsaid here and stated there is how a feed goes quiet with no
|
# unsaid on both sides would work too — 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.
|
# error at all: the client checks, finds nothing, and reports itself up to date forever.
|
||||||
|
#
|
||||||
|
# The nightly channel is the same three values with different contents — DodoSSH.Desktop.Nightly, DodoSSH
|
||||||
|
# Nightly, win-nightly — and they live in the `desktop nightly` job in .github/workflows/ci.yml rather than
|
||||||
|
# here, because that build is CI's and this script is a person's. Nothing shares them, deliberately: this
|
||||||
|
# file must keep working if that job is deleted. See ADR 0013 decision 9 for what the separation buys.
|
||||||
$Channel = 'win'
|
$Channel = 'win'
|
||||||
|
|
||||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
@@ -100,11 +107,20 @@ Push-Location $RepoRoot
|
|||||||
try {
|
try {
|
||||||
# ---- What is being released -----------------------------------------------------------------------
|
# ---- What is being released -----------------------------------------------------------------------
|
||||||
|
|
||||||
# -t:MinVer, and it is load-bearing. -getProperty on its own evaluates the project and runs no
|
# Restored before the version is read, and both halves of that sentence are load-bearing.
|
||||||
# targets, while MinVer sets Version from inside one — so this read answered the SDK's default
|
#
|
||||||
# 1.0.0 regardless of the tag, and the check below would then have refused to build anything not
|
# -t:MinVer, because -getProperty on its own evaluates the project and runs no targets, while MinVer
|
||||||
# tagged v1.0.0. CI's tag check had the same line and the same fault; both are fixed, and both
|
# sets Version from inside one — so this read answered the SDK's default 1.0.0 regardless of the tag,
|
||||||
# say so, because this is the version that ends up in the package a client compares against.
|
# and the check below would then have refused to build anything not tagged v1.0.0. CI's tag check had
|
||||||
|
# the same line and the same fault.
|
||||||
|
#
|
||||||
|
# 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. It costs seconds on a machine that has built
|
||||||
|
# before, which is every machine except the one this would otherwise fail on.
|
||||||
|
Write-Step 'Restoring the desktop head, so the version can be read'
|
||||||
|
& dotnet restore $Project --locked-mode
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed.' }
|
||||||
|
|
||||||
$version = (& dotnet msbuild $Project -getProperty:Version -t:MinVer -nologo) -replace '\s', ''
|
$version = (& dotnet msbuild $Project -getProperty:Version -t:MinVer -nologo) -replace '\s', ''
|
||||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||||
Stop-With 'Could not read the version from MSBuild.'
|
Stop-With 'Could not read the version from MSBuild.'
|
||||||
@@ -138,10 +154,24 @@ try {
|
|||||||
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
|
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
|
||||||
# fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
|
# fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
|
||||||
# same tag, so a release candidate is a prerelease in both channels or in neither.
|
# same tag, so a release candidate is a prerelease in both channels or in neither.
|
||||||
|
#
|
||||||
|
# ◆ NO --token HERE. IT WAS AN ARGUMENT, AND AN ARGUMENT IS PUBLIC ON THIS MACHINE.
|
||||||
|
#
|
||||||
|
# The comment above says the token never lands in shell history, which was true and was not the
|
||||||
|
# whole story: it went straight into vpk's command line, and any process running as this user can
|
||||||
|
# read that off a running process with one call —
|
||||||
|
#
|
||||||
|
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||||
|
#
|
||||||
|
# — with process-creation auditing and EDR agents keeping their own copy afterwards. A token that
|
||||||
|
# can publish an update every installed copy will fetch and run deserves better than that.
|
||||||
|
#
|
||||||
|
# vpk documents VPK_TOKEN as --token's environment fallback, so the flag simply goes away and the
|
||||||
|
# value never becomes an argument at all. An environment block is still readable by a process with
|
||||||
|
# rights over this one; what it is not is a string sitting in a process listing and an audit log.
|
||||||
$uploadArgs = @(
|
$uploadArgs = @(
|
||||||
'upload', 'gitea',
|
'upload', 'gitea',
|
||||||
'--repoUrl', $RepoUrl,
|
'--repoUrl', $RepoUrl,
|
||||||
'--token', $token,
|
|
||||||
'--outputDir', $ReleasesDir,
|
'--outputDir', $ReleasesDir,
|
||||||
'--channel', $Channel,
|
'--channel', $Channel,
|
||||||
'--releaseName', $tag,
|
'--releaseName', $tag,
|
||||||
@@ -155,7 +185,17 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Step 'Uploading'
|
Write-Step 'Uploading'
|
||||||
& dotnet vpk @uploadArgs
|
|
||||||
|
try {
|
||||||
|
$env:VPK_TOKEN = $token
|
||||||
|
& dotnet vpk @uploadArgs
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
|
||||||
|
# and this runs on the failure path, where it may well not be.
|
||||||
|
[Environment]::SetEnvironmentVariable('VPK_TOKEN', $null)
|
||||||
|
}
|
||||||
|
|
||||||
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
|
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
|
||||||
|
|
||||||
Write-Step "Published $tag."
|
Write-Step "Published $tag."
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ internal sealed class GetMetaEndpoint(
|
|||||||
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
|
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
|
||||||
internal const int SyncProtocolVersion = 1;
|
internal const int SyncProtocolVersion = 1;
|
||||||
|
|
||||||
/// <summary>Feature flag for the relay.</summary>
|
|
||||||
internal const string RelayFeature = "relay";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void Configure()
|
public override void Configure()
|
||||||
{
|
{
|
||||||
@@ -50,7 +47,7 @@ internal sealed class GetMetaEndpoint(
|
|||||||
List<string> features = ["teams"];
|
List<string> features = ["teams"];
|
||||||
if (relay.Value.Enabled)
|
if (relay.Value.Enabled)
|
||||||
{
|
{
|
||||||
features.Add(RelayFeature);
|
features.Add(RelayProtocol.Feature);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advertised so a client knows whether to hold a socket open or rely on its timer. Absence is
|
// Advertised so a client knows whether to hold a socket open or rely on its timer. Absence is
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Relay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides which addresses and ports the relay may dial. ADR 0004 §3, in the order it is written.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This runs on <em>addresses</em>, never on names. The name is resolved once, at ticket issue, and what
|
||||||
|
/// this permits goes into the ticket; the socket dials those addresses and nothing else. A check on the
|
||||||
|
/// name would be defeated by DNS rebinding — answer with a public address for the check and a loopback
|
||||||
|
/// one for the dial — which is the attack ADR 0004 is built around.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Three layers, and the order is the security property. The <see cref="Refused"/> list comes first and
|
||||||
|
/// has no configuration at all: nothing an operator sets, and nothing a host row says, reaches a
|
||||||
|
/// loopback, link-local, unspecified, multicast or metadata address. Then the operator's own denials,
|
||||||
|
/// then their allow-list, then the private-network switch.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>An address that embeds another is judged as the one it embeds.</b> <c>::ffff:127.0.0.1</c> is
|
||||||
|
/// loopback however it is spelled, and a check that compared it against IPv6 loopback alone would pass
|
||||||
|
/// it — the classic bypass the ADR names. The NAT64 well-known prefix is unwrapped the same way, because
|
||||||
|
/// on a network with a NAT64 gateway <c>64:ff9b::a9fe:a9fe</c> <em>is</em> the metadata endpoint.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class RelayAddressPolicy
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Addresses the relay never dials, whatever it is configured to do.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Broader than the ADR's list in two places, both deliberately. <c>240.0.0.0/4</c> — reserved, and
|
||||||
|
/// containing the limited broadcast address — has no host worth reaching. And IPv6 <c>::/96</c>, the
|
||||||
|
/// deprecated IPv4-compatible block, covers the unspecified address and loopback while also refusing
|
||||||
|
/// the spellings of IPv4 that a stack may or may not map; nothing legitimate lives there.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly IPNetwork[] Refused =
|
||||||
|
[
|
||||||
|
// IPv4: "this network", loopback, link-local (which holds 169.254.169.254), multicast, reserved.
|
||||||
|
IPNetwork.Parse("0.0.0.0/8"),
|
||||||
|
IPNetwork.Parse("127.0.0.0/8"),
|
||||||
|
IPNetwork.Parse("169.254.0.0/16"),
|
||||||
|
IPNetwork.Parse("224.0.0.0/4"),
|
||||||
|
IPNetwork.Parse("240.0.0.0/4"),
|
||||||
|
|
||||||
|
// Alibaba Cloud's metadata endpoint, which sits inside carrier-grade NAT space and so would
|
||||||
|
// otherwise be permitted as private — the range a Tailnet also uses, which is why the whole
|
||||||
|
// block cannot simply be refused.
|
||||||
|
IPNetwork.Parse("100.100.100.200/32"),
|
||||||
|
|
||||||
|
// IPv6: IPv4-compatible (with unspecified and loopback), link-local, multicast, and the AWS
|
||||||
|
// metadata endpoint's IPv6 address.
|
||||||
|
IPNetwork.Parse("::/96"),
|
||||||
|
IPNetwork.Parse("fe80::/10"),
|
||||||
|
IPNetwork.Parse("ff00::/8"),
|
||||||
|
IPNetwork.Parse("fd00:ec2::254/128"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ranges <see cref="RelayOptions.AllowPrivateNetworks"/> governs.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Carrier-grade NAT is here rather than treated as public, because it is where a Tailnet lives and
|
||||||
|
/// "the server is on the same Tailnet" is one of the shapes the relay exists for. Site-local
|
||||||
|
/// <c>fec0::/10</c> is deprecated and was private while it lived.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly IPNetwork[] Private =
|
||||||
|
[
|
||||||
|
IPNetwork.Parse("10.0.0.0/8"),
|
||||||
|
IPNetwork.Parse("172.16.0.0/12"),
|
||||||
|
IPNetwork.Parse("192.168.0.0/16"),
|
||||||
|
IPNetwork.Parse("100.64.0.0/10"),
|
||||||
|
IPNetwork.Parse("fc00::/7"),
|
||||||
|
IPNetwork.Parse("fec0::/10"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>The NAT64 well-known prefix, RFC 6052, whose low 32 bits are an IPv4 address.</summary>
|
||||||
|
private static readonly IPNetwork Nat64 = IPNetwork.Parse("64:ff9b::/96");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ports the relay never dials.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// SMTP and nothing else, per ADR 0004: a relay that reached port 25 is a spam cannon with the
|
||||||
|
/// operator's address on it. Database ports stay open, because forwarding to one is a headline use,
|
||||||
|
/// and the control that matters there is who may dial the host at all.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly int[] RefusedPorts = [25, 465, 587];
|
||||||
|
|
||||||
|
private readonly bool allowPrivate;
|
||||||
|
private readonly IPNetwork[] allowed;
|
||||||
|
private readonly IPNetwork[] denied;
|
||||||
|
|
||||||
|
public RelayAddressPolicy(RelayOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
allowPrivate = options.AllowPrivateNetworks;
|
||||||
|
allowed = ParseAll(options.AllowedNetworks);
|
||||||
|
denied = ParseAll(options.DeniedNetworks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether a port may be dialled at all.</summary>
|
||||||
|
internal static bool PermitsPort(int port) =>
|
||||||
|
port is >= 1 and <= 65535 && !RefusedPorts.Contains(port);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether every entry is a network this policy can read.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For startup validation. A typo in a deny list that was silently skipped would be a hole the
|
||||||
|
/// operator believes is closed, so an unreadable entry stops the server instead.
|
||||||
|
/// <para>
|
||||||
|
/// Host bits are refused as well, although <see cref="IPNetwork.TryParse(string?, out IPNetwork)"/>
|
||||||
|
/// would mask them off quietly. <c>10.20.5.7/16</c> could be a /16 written from memory or a /32 with
|
||||||
|
/// the wrong suffix, and in a deny list those differ by sixty-five thousand addresses.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static bool AreNetworks(IEnumerable<string> entries) =>
|
||||||
|
entries.All(entry =>
|
||||||
|
IPNetwork.TryParse(entry, out var network)
|
||||||
|
&& IPAddress.TryParse(entry.AsSpan(0, entry.IndexOf('/', StringComparison.Ordinal)), out var written)
|
||||||
|
&& written.Equals(network.BaseAddress));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The address the relay would actually be talking to, with any embedding unwrapped.
|
||||||
|
/// </summary>
|
||||||
|
internal static IPAddress Normalise(IPAddress address)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(address);
|
||||||
|
|
||||||
|
if (address.IsIPv4MappedToIPv6)
|
||||||
|
{
|
||||||
|
return address.MapToIPv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address.AddressFamily == AddressFamily.InterNetworkV6 && Nat64.Contains(address))
|
||||||
|
{
|
||||||
|
var bytes = address.GetAddressBytes();
|
||||||
|
return new IPAddress(bytes.AsSpan(12, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A scope id is how an address names an interface on this machine, which is not something a
|
||||||
|
// host row gets to choose. Dropped, so what is judged is what is dialled.
|
||||||
|
return address.AddressFamily == AddressFamily.InterNetworkV6 && address.ScopeId != 0
|
||||||
|
? new IPAddress(address.GetAddressBytes())
|
||||||
|
: address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the relay may dial this address.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Judges the normalised address. Callers should dial <see cref="Normalise"/>'s answer too, so that
|
||||||
|
/// the address checked and the address connected to are the same object rather than two spellings
|
||||||
|
/// the operating system is trusted to agree about.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool Permits(IPAddress address)
|
||||||
|
{
|
||||||
|
var target = Normalise(address);
|
||||||
|
|
||||||
|
if (Refused.Any(network => Within(network, target)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (denied.Any(network => Within(network, target)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowed.Length > 0)
|
||||||
|
{
|
||||||
|
return allowed.Any(network => Within(network, target));
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowPrivate || !Private.Any(network => Within(network, target));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="IPNetwork.Contains"/> answers false across address families rather than throwing, so
|
||||||
|
/// a list mixing IPv4 and IPv6 needs no sorting — but it is spelled out here because the one-line
|
||||||
|
/// alternative reads as though it compared across families.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool Within(IPNetwork network, IPAddress address) =>
|
||||||
|
network.BaseAddress.AddressFamily == address.AddressFamily && network.Contains(address);
|
||||||
|
|
||||||
|
private static IPNetwork[] ParseAll(IEnumerable<string> entries) =>
|
||||||
|
[.. entries.Select(entry => IPNetwork.Parse(entry))];
|
||||||
|
}
|
||||||
@@ -0,0 +1,722 @@
|
|||||||
|
using System.Buffers;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
|
using DodoSSH.Contracts;
|
||||||
|
using DodoSSH.Domain;
|
||||||
|
using DodoSSH.Infrastructure;
|
||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Relay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The relay socket: a ticket in, raw bytes to one host and back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Anonymous at the HTTP layer, because the ticket is the authorization and it is the whole of it. A
|
||||||
|
/// ticket carries no API authority — it names one host, one set of addresses and one session — so this
|
||||||
|
/// endpoint can accept it from a client that cannot set an <c>Authorization</c> header on an upgrade,
|
||||||
|
/// which is the constraint ADR 0004 was written around. It is verified, checked for freshness and
|
||||||
|
/// redeemed before the upgrade is accepted, so a refused ticket costs a problem document and no socket.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing here is scoped to the request.</b> A session lasts as long as a shell does, which is hours,
|
||||||
|
/// and anything scoped held across it is a pooled database connection held across it. The database is
|
||||||
|
/// touched three times — redeem, open the audit row, close it — each from a scope of its own that is
|
||||||
|
/// disposed before the next byte moves. That is ADR 0004's consequence about <c>DbContext</c> lifetime.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Backpressure is the await. Each direction reads one buffer, writes it, and does not read again until
|
||||||
|
/// the write has completed, so a slow reader on either side stops this side reading, which fills the
|
||||||
|
/// kernel's receive window, which throttles the sender at the far end. Nothing is queued in between, and
|
||||||
|
/// a session costs its two buffers whatever the peers do.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class RelayConnectEndpoint(
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
IRelayNetwork network,
|
||||||
|
RelayTicketCodec codec,
|
||||||
|
RelaySessionRegistry sessions,
|
||||||
|
IOptions<RelayOptions> options,
|
||||||
|
TimeProvider clock,
|
||||||
|
IHostApplicationLifetime lifetime,
|
||||||
|
ILogger<RelayConnectEndpoint> logger)
|
||||||
|
: EndpointWithoutRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One direction's buffer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Room for an SSH packet at the size OpenSSH sends them, so a bulk transfer moves in one read and
|
||||||
|
/// one write per packet rather than several.
|
||||||
|
/// </remarks>
|
||||||
|
private const int BufferBytes = 32 * 1024;
|
||||||
|
|
||||||
|
/// <summary>How long a close handshake, or a peer's last write, may take before the socket is dropped.</summary>
|
||||||
|
private static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Get(RelayProtocol.ConnectPath);
|
||||||
|
|
||||||
|
// The ticket is the authorization. See the remarks on this class, and ADR 0004.
|
||||||
|
AllowAnonymous();
|
||||||
|
|
||||||
|
Description(b => b
|
||||||
|
.WithName("RelayConnect")
|
||||||
|
.WithSummary("Pipes raw bytes between a WebSocket and the host a relay ticket names.")
|
||||||
|
.WithTags("Relay"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ticket = await AdmitTicketAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (ticket is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lease = sessions.TryAdmit(ticket.UserId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var socket = await HttpContext.WebSockets
|
||||||
|
.AcceptWebSocketAsync(new WebSocketAcceptContext { SubProtocol = RelayProtocol.SubProtocol })
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (lease is null)
|
||||||
|
{
|
||||||
|
RelayLog.LimitReached(logger, ticket.UserId, sessions.Count);
|
||||||
|
|
||||||
|
await CloseAsync(
|
||||||
|
socket,
|
||||||
|
new Ending(
|
||||||
|
RelayCloseReason.Unspecified,
|
||||||
|
RelayProtocol.TooManySessionsCloseCode,
|
||||||
|
"Too many relay sessions are open."))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lifetime.ApplicationStopping.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await CloseAsync(socket, ShuttingDown).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RelayAsync(socket, ticket, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Inside a try that starts before the upgrade, for the reason VaultEventsEndpoint gives: an
|
||||||
|
// accept that throws would otherwise hold a place against this user's cap forever.
|
||||||
|
lease?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Ending ShuttingDown { get; } = new(
|
||||||
|
RelayCloseReason.ServerShutdown,
|
||||||
|
(int)WebSocketCloseStatus.EndpointUnavailable,
|
||||||
|
"The server is shutting down.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Answers everything that is not a redeemable ticket, as ordinary HTTP, before any socket exists.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The redeemed ticket, or null when a response has been sent.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// A ticket that is forged, expired, spent, or simply absent all answer 401
|
||||||
|
/// <c>relay-ticket-invalid</c>, with one remedy — ask for another — and no hint of which it was.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<RelayTicket?> AdmitTicketAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!options.Value.Enabled)
|
||||||
|
{
|
||||||
|
await Send.ResultAsync(Problems.Coded(
|
||||||
|
StatusCodes.Status404NotFound,
|
||||||
|
ProblemCodes.RelayUnavailable,
|
||||||
|
"This server does not relay. GET /api/v1/meta lists the features it does offer."))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var offered = HttpContext.WebSockets.WebSocketRequestedProtocols;
|
||||||
|
|
||||||
|
if (!HttpContext.WebSockets.IsWebSocketRequest
|
||||||
|
|| !offered.Contains(RelayProtocol.SubProtocol, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
await Send.ResultAsync(Problems.Coded(
|
||||||
|
StatusCodes.Status400BadRequest,
|
||||||
|
ProblemCodes.MalformedRequest,
|
||||||
|
"This endpoint is a WebSocket. Send an upgrade request offering the "
|
||||||
|
+ $"'{RelayProtocol.SubProtocol}' subprotocol and the ticket as a second one, "
|
||||||
|
+ $"'{RelayProtocol.TicketProtocolPrefix}<ticket>'."))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sealedTicket = offered
|
||||||
|
.FirstOrDefault(protocol => protocol.StartsWith(RelayProtocol.TicketProtocolPrefix, StringComparison.Ordinal))
|
||||||
|
?[RelayProtocol.TicketProtocolPrefix.Length..];
|
||||||
|
|
||||||
|
var ticket = sealedTicket is { Length: > 0 } ? codec.TryOpen(sealedTicket) : null;
|
||||||
|
|
||||||
|
if (ticket is null || !await RedeemAsync(ticket, cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
RelayLog.TicketRefused(logger, ticket?.SessionId);
|
||||||
|
|
||||||
|
await Send.ResultAsync(Problems.Coded(
|
||||||
|
StatusCodes.Status401Unauthorized,
|
||||||
|
ProblemCodes.RelayTicketInvalid,
|
||||||
|
"This relay ticket is not valid: it is expired, already used, or was not issued here. "
|
||||||
|
+ $"Ask for another from POST {RelayProtocol.TicketsPath}."))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spends the ticket, or answers false when it has been spent already.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A unique insert, which is the only form of "once" that holds across nodes: two redemptions racing
|
||||||
|
/// on two nodes both attempt the insert and the database lets exactly one of them win.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Rows for tickets that can no longer verify are swept on the way. They guard nothing, and sweeping
|
||||||
|
/// here — on a path that runs once per session rather than once per request — keeps the table the
|
||||||
|
/// size of the last thirty seconds without a background job to forget about.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<bool> RedeemAsync(RelayTicket ticket, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var scope = scopes.CreateAsyncScope();
|
||||||
|
await using var _ = scope.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
|
||||||
|
await database.RelayTicketUses
|
||||||
|
.Where(use => use.ExpiresAtUtc < now)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
database.RelayTicketUses.Add(new RelayTicketUse
|
||||||
|
{
|
||||||
|
TicketId = ticket.TicketId,
|
||||||
|
ExpiresAtUtc = ticket.ExpiresAt,
|
||||||
|
UsedAtUtc = now,
|
||||||
|
});
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException exception)
|
||||||
|
when (string.Equals(
|
||||||
|
(exception.InnerException as PostgresException)?.SqlState,
|
||||||
|
PostgresErrorCodes.UniqueViolation,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
|
||||||
|
private async Task RelayAsync(WebSocket socket, RelayTicket ticket, CancellationToken requestAborted)
|
||||||
|
{
|
||||||
|
var started = clock.GetUtcNow();
|
||||||
|
|
||||||
|
await OpenAuditAsync(ticket, started).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var dialled = await DialAsync(ticket, requestAborted).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (dialled is not var (target, address))
|
||||||
|
{
|
||||||
|
RelayLog.TargetUnreachable(logger, ticket.SessionId, ticket.HostId);
|
||||||
|
|
||||||
|
var unreachable = new Ending(
|
||||||
|
RelayCloseReason.TargetUnreachable,
|
||||||
|
RelayProtocol.TargetUnreachableCloseCode,
|
||||||
|
"The relay could not reach the host.");
|
||||||
|
|
||||||
|
await CloseAsync(socket, unreachable).ConfigureAwait(false);
|
||||||
|
await CloseAuditAsync(ticket.SessionId, null, new Counters(clock, started), unreachable.Reason).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var counters = new Counters(clock, started);
|
||||||
|
Ending ending;
|
||||||
|
|
||||||
|
await using (target.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
|
||||||
|
|
||||||
|
ending = await PumpAsync(socket, target, counters, started, requestAborted).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
RelayLog.SessionClosed(
|
||||||
|
logger,
|
||||||
|
ticket.SessionId,
|
||||||
|
ending.Reason,
|
||||||
|
counters.ClientToTarget,
|
||||||
|
counters.TargetToClient);
|
||||||
|
|
||||||
|
await CloseAuditAsync(ticket.SessionId, address, counters, ending.Reason).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connects to the first of the ticket's addresses that answers, within the connect timeout overall.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Overall rather than per address, so a name with sixteen dead addresses costs five seconds rather
|
||||||
|
/// than eighty. In the order resolved, which is the order the operating system's resolver prefers.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<(Stream Target, IPAddress Address)?> DialAsync(
|
||||||
|
RelayTicket ticket,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(options.Value.ConnectTimeout);
|
||||||
|
|
||||||
|
foreach (var address in ticket.TargetAddresses)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var target = await network
|
||||||
|
.ConnectAsync(address, ticket.TargetPort, timeout.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return (target, address);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or System.Net.Sockets.SocketException)
|
||||||
|
{
|
||||||
|
// The next address, if there is one.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs both directions and the clock until one of them ends the session, then ends it politely.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>No operation on the WebSocket is ever cancelled.</b> Cancelling a pending receive or send on a
|
||||||
|
/// .NET WebSocket aborts it, and an aborted socket cannot send the close frame that is the only way
|
||||||
|
/// a client learns <em>why</em> its session ended — which, for an idle timeout or a drain, is the
|
||||||
|
/// difference between "reconnect" and "something is broken". So the token stops the target side and
|
||||||
|
/// the clock; the sender is let finish its last write; the close goes out; and the receiver ends on
|
||||||
|
/// the client's answering close. Each wait is bounded, and the socket is aborted only when a peer
|
||||||
|
/// has stopped answering altogether.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<Ending> PumpAsync(
|
||||||
|
WebSocket socket,
|
||||||
|
Stream target,
|
||||||
|
Counters counters,
|
||||||
|
DateTimeOffset started,
|
||||||
|
CancellationToken requestAborted)
|
||||||
|
{
|
||||||
|
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
|
||||||
|
|
||||||
|
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
|
||||||
|
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
|
||||||
|
var minding = MindAsync(counters, started, pump.Token);
|
||||||
|
|
||||||
|
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
|
||||||
|
var ending = await first.ConfigureAwait(false) ?? new Ending(
|
||||||
|
RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
|
||||||
|
|
||||||
|
await pump.CancelAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
// The only sender must be done before the close frame is written: a WebSocket permits one send
|
||||||
|
// at a time and faults permanently on a second.
|
||||||
|
if (!await SettlesAsync(downstream).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
socket.Abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
await CloseAsync(socket, ending).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!await SettlesAsync(upstream).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
socket.Abort();
|
||||||
|
await SettlesAsync(upstream).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await SettlesAsync(minding).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return ending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Client to target: every binary message is the next bytes of the stream.</summary>
|
||||||
|
private static async Task<Ending?> ClientToTargetAsync(
|
||||||
|
WebSocket socket,
|
||||||
|
Stream target,
|
||||||
|
Counters counters,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
ValueWebSocketReceiveResult received;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Not cancellable, deliberately. See PumpAsync.
|
||||||
|
received = await socket.ReceiveAsync(buffer.AsMemory(), CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is WebSocketException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
return ClientGone;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (received.MessageType == WebSocketMessageType.Close)
|
||||||
|
{
|
||||||
|
return ClientGone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A byte pipe has no text. A client sending some has the wrong idea about this endpoint,
|
||||||
|
// and forwarding it into an SSH stream would corrupt that stream at best.
|
||||||
|
if (received.MessageType != WebSocketMessageType.Binary)
|
||||||
|
{
|
||||||
|
return new Ending(
|
||||||
|
RelayCloseReason.Error,
|
||||||
|
(int)WebSocketCloseStatus.InvalidMessageType,
|
||||||
|
"The relay carries binary messages only.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await target.WriteAsync(buffer.AsMemory(0, received.Count), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return TargetGone;
|
||||||
|
}
|
||||||
|
|
||||||
|
counters.AddClientToTarget(received.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Target to client: every read becomes one binary message.</summary>
|
||||||
|
private static async Task<Ending?> TargetToClientAsync(
|
||||||
|
Stream target,
|
||||||
|
WebSocket socket,
|
||||||
|
Counters counters,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int read;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
read = await target.ReadAsync(buffer.AsMemory(0, BufferBytes), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return TargetGone;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
return TargetGone;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Not cancellable, deliberately. See PumpAsync.
|
||||||
|
await socket
|
||||||
|
.SendAsync(
|
||||||
|
buffer.AsMemory(0, read),
|
||||||
|
WebSocketMessageType.Binary,
|
||||||
|
endOfMessage: true,
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException)
|
||||||
|
{
|
||||||
|
return ClientGone;
|
||||||
|
}
|
||||||
|
|
||||||
|
counters.AddTargetToClient(read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends the session at the idle timeout, the lifetime cap, or the end of a shutdown's drain.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A shutdown does not end a session; it starts a countdown. ADR 0004 asks that a
|
||||||
|
/// <c>docker compose up -d</c> not guillotine live shells, so a session in progress when the server is
|
||||||
|
/// told to stop is given the drain timeout to finish — most will, because the user was already
|
||||||
|
/// closing it or the deployment waits — and is closed with 1001 only when that runs out, which tells
|
||||||
|
/// the client to reconnect rather than to report a fault. The host's own shutdown timeout is set
|
||||||
|
/// longer than the drain for this to be possible at all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Idle means no bytes in either direction. WebSocket pings are not bytes of the stream and do not
|
||||||
|
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<Ending?> MindAsync(Counters counters, DateTimeOffset started, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var settings = options.Value;
|
||||||
|
var hardDeadline = started + settings.MaxSessionDuration;
|
||||||
|
DateTimeOffset? drainDeadline = null;
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
|
||||||
|
if (drainDeadline is null && lifetime.ApplicationStopping.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
drainDeadline = now + settings.DrainTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= hardDeadline)
|
||||||
|
{
|
||||||
|
return new Ending(
|
||||||
|
RelayCloseReason.DurationLimit,
|
||||||
|
RelayProtocol.DurationLimitCloseCode,
|
||||||
|
"This relay session reached its maximum lifetime.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drainDeadline is { } drain && now >= drain)
|
||||||
|
{
|
||||||
|
return ShuttingDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
|
||||||
|
|
||||||
|
if (now >= idleDeadline)
|
||||||
|
{
|
||||||
|
return new Ending(
|
||||||
|
RelayCloseReason.IdleTimeout,
|
||||||
|
RelayProtocol.IdleTimeoutCloseCode,
|
||||||
|
"This relay session carried nothing for the idle timeout.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
|
||||||
|
|
||||||
|
// Woken early by the start of a shutdown, so the drain is measured from when it began rather
|
||||||
|
// than from whenever the idle clock next came round.
|
||||||
|
using var wake = drainDeadline is null
|
||||||
|
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.ApplicationStopping)
|
||||||
|
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// The shutdown began. Round again to start the drain.
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Ending ClientGone { get; } =
|
||||||
|
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
|
||||||
|
|
||||||
|
private static Ending TargetGone { get; } =
|
||||||
|
new(RelayCloseReason.TargetClosed, (int)WebSocketCloseStatus.NormalClosure, "The host closed the connection.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the audit row, before the target is dialled, so a session that never reached its host is
|
||||||
|
/// still one the operator can see.
|
||||||
|
/// </summary>
|
||||||
|
private async Task OpenAuditAsync(RelayTicket ticket, DateTimeOffset started)
|
||||||
|
{
|
||||||
|
var scope = scopes.CreateAsyncScope();
|
||||||
|
await using var _ = scope.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||||
|
|
||||||
|
database.RelaySessions.Add(new RelaySession
|
||||||
|
{
|
||||||
|
Id = ticket.SessionId,
|
||||||
|
UserId = ticket.UserId,
|
||||||
|
HostId = ticket.HostId,
|
||||||
|
VaultId = ticket.VaultId,
|
||||||
|
TargetHost = ticket.TargetHost,
|
||||||
|
TargetPort = ticket.TargetPort,
|
||||||
|
ClientAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
StartedAtUtc = started,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Not the request's token: the row has to be written even for a client that has already gone,
|
||||||
|
// or a session that was cut off would be the one with no record.
|
||||||
|
await database.SaveChangesAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the audit row. A failure is logged and swallowed: the bytes have already moved, and
|
||||||
|
/// throwing here would only turn a finished session into an unhandled exception.
|
||||||
|
/// </summary>
|
||||||
|
private async Task CloseAuditAsync(
|
||||||
|
Guid sessionId,
|
||||||
|
IPAddress? address,
|
||||||
|
Counters counters,
|
||||||
|
RelayCloseReason reason)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scope = scopes.CreateAsyncScope();
|
||||||
|
await using var _ = scope.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||||
|
var ended = clock.GetUtcNow();
|
||||||
|
var dialled = address?.ToString();
|
||||||
|
|
||||||
|
await database.RelaySessions
|
||||||
|
.Where(session => session.Id == sessionId)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
set => set
|
||||||
|
.SetProperty(session => session.EndedAtUtc, ended)
|
||||||
|
.SetProperty(session => session.TargetAddress, dialled)
|
||||||
|
.SetProperty(session => session.BytesClientToTarget, counters.ClientToTarget)
|
||||||
|
.SetProperty(session => session.BytesTargetToClient, counters.TargetToClient)
|
||||||
|
.SetProperty(session => session.CloseReason, reason),
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is DbUpdateException or NpgsqlException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
RelayLog.AuditCloseFailed(logger, sessionId, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CloseAsync(WebSocket socket, Ending ending)
|
||||||
|
{
|
||||||
|
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var timeout = new CancellationTokenSource(CloseTimeout);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await socket
|
||||||
|
.CloseOutputAsync((WebSocketCloseStatus)ending.Code, ending.Description, timeout.Token)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// The peer is already gone. There is nothing to tell it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Waits for a loop to finish, for at most the close timeout.</summary>
|
||||||
|
/// <returns>Whether it finished.</returns>
|
||||||
|
private static async Task<bool> SettlesAsync(Task loop)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
when (exception is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How a session ended, and what the client is told.</summary>
|
||||||
|
private sealed record Ending(RelayCloseReason Reason, int Code, string Description);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Byte counts and the time of the last byte, written by the two directions and read by the clock.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class Counters(TimeProvider clock, DateTimeOffset started)
|
||||||
|
{
|
||||||
|
private long clientToTarget;
|
||||||
|
private long targetToClient;
|
||||||
|
private long lastActivityTicks = started.UtcTicks;
|
||||||
|
|
||||||
|
internal long ClientToTarget => Interlocked.Read(ref clientToTarget);
|
||||||
|
|
||||||
|
internal long TargetToClient => Interlocked.Read(ref targetToClient);
|
||||||
|
|
||||||
|
internal DateTimeOffset LastActivity => new(Interlocked.Read(ref lastActivityTicks), TimeSpan.Zero);
|
||||||
|
|
||||||
|
internal void AddClientToTarget(int count)
|
||||||
|
{
|
||||||
|
Interlocked.Add(ref clientToTarget, count);
|
||||||
|
Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void AddTargetToClient(int count)
|
||||||
|
{
|
||||||
|
Interlocked.Add(ref targetToClient, count);
|
||||||
|
Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using DodoSSH.Domain;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Relay;
|
||||||
|
|
||||||
|
/// <summary>Source-generated log messages for the relay.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Ids, counts and reasons. Never a ticket, which is a bearer credential for thirty seconds, and never
|
||||||
|
/// the target's address: that is in the audit table, where retention is the operator's decision, and
|
||||||
|
/// copying it into a log would make it the log pipeline's decision too.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="TargetRejected"/> is the one place the reason for a refused target is written down. The
|
||||||
|
/// client is told nothing specific, deliberately — see <c>RelayTicketEndpoint</c> — so this line is how
|
||||||
|
/// an operator answers "why will the relay not reach this host".
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static partial class RelayLog
|
||||||
|
{
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2301,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Relay ticket refused for user {UserId} and host {HostId}: {Reason}.")]
|
||||||
|
internal static partial void TargetRejected(ILogger logger, Guid userId, Guid hostId, string reason);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2302,
|
||||||
|
Level = LogLevel.Debug,
|
||||||
|
Message = "Relay ticket issued to user {UserId} for host {HostId}, session {SessionId}, with "
|
||||||
|
+ "{AddressCount} permitted address(es).")]
|
||||||
|
internal static partial void TicketIssued(
|
||||||
|
ILogger logger,
|
||||||
|
Guid userId,
|
||||||
|
Guid hostId,
|
||||||
|
Guid sessionId,
|
||||||
|
int addressCount);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The session id is known only for a ticket that verified and had already been spent, which is the
|
||||||
|
/// case worth an operator's attention: a replay, or a client retrying a redemption it thinks failed.
|
||||||
|
/// </remarks>
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2303,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Relay ticket refused at the socket (session {SessionId}): forged, expired or already used.")]
|
||||||
|
internal static partial void TicketRefused(ILogger logger, Guid? sessionId);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2304,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Relay session refused for user {UserId}: a session limit is met, with {SessionCount} "
|
||||||
|
+ "open on this node.")]
|
||||||
|
internal static partial void LimitReached(ILogger logger, Guid userId, int sessionCount);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2305,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Relay session {SessionId} could not reach host {HostId} on any permitted address.")]
|
||||||
|
internal static partial void TargetUnreachable(ILogger logger, Guid sessionId, Guid hostId);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2306,
|
||||||
|
Level = LogLevel.Debug,
|
||||||
|
Message = "Relay session {SessionId} opened for user {UserId} to host {HostId}; {SessionCount} "
|
||||||
|
+ "open on this node.")]
|
||||||
|
internal static partial void SessionOpened(
|
||||||
|
ILogger logger,
|
||||||
|
Guid sessionId,
|
||||||
|
Guid userId,
|
||||||
|
Guid hostId,
|
||||||
|
int sessionCount);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2307,
|
||||||
|
Level = LogLevel.Debug,
|
||||||
|
Message = "Relay session {SessionId} closed ({Reason}) after {ClientToTarget} byte(s) up and "
|
||||||
|
+ "{TargetToClient} down.")]
|
||||||
|
internal static partial void SessionClosed(
|
||||||
|
ILogger logger,
|
||||||
|
Guid sessionId,
|
||||||
|
RelayCloseReason reason,
|
||||||
|
long clientToTarget,
|
||||||
|
long targetToClient);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A warning rather than an error: the session itself is over and was fine. What is lost is the
|
||||||
|
/// audit row's second half, which leaves it looking like a session still open.
|
||||||
|
/// </remarks>
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2308,
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
|
||||||
|
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Relay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The two things the relay asks of the network: what a name resolves to, and a connection to an address.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A seam for tests, and a narrow one on purpose. <see cref="RelayAddressPolicy"/> is not behind it:
|
||||||
|
/// whatever this answers, the policy judges, and a test that replaces this still runs every address
|
||||||
|
/// through the real rules. What a test cannot do without the seam is dial anything, because every
|
||||||
|
/// address a test machine can reach on its own — loopback — is one the policy refuses outright.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal interface IRelayNetwork
|
||||||
|
{
|
||||||
|
/// <summary>Resolves a stored hostname, which may be an address literal.</summary>
|
||||||
|
Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Opens a TCP connection to one address.</summary>
|
||||||
|
/// <returns>A stream that owns the connection.</returns>
|
||||||
|
Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The operating system's resolver and sockets.</summary>
|
||||||
|
internal sealed class SystemRelayNetwork : IRelayNetwork
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken) =>
|
||||||
|
|
||||||
|
// A literal parses to itself without a lookup, which is what an address typed into a host
|
||||||
|
// editor should do — and what makes "10.0.0.5" mean the same thing on every node.
|
||||||
|
IPAddress.TryParse(host, out var literal)
|
||||||
|
? Task.FromResult<IPAddress[]>([literal])
|
||||||
|
: Dns.GetHostAddressesAsync(host, cancellationToken);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
|
||||||
|
{
|
||||||
|
// An interactive shell is a stream of keystrokes; Nagle would hold each one back for the
|
||||||
|
// acknowledgement of the last, which is the latency the user feels as a sticky terminal.
|
||||||
|
NoDelay = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new NetworkStream(socket, ownsSocket: true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
socket.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using DodoSSH.Api.Setup;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Relay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts the relay sessions open on this node, and refuses the ones over ADR 0004's limits.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Per node, and so per user <em>per node</em>: a user behind a round-robin balancer with three nodes
|
||||||
|
/// could hold three times the per-user cap. That is accepted rather than fixed, because fixing it means
|
||||||
|
/// shared state on the hot path of every connect, and the cap exists to stop one account exhausting a
|
||||||
|
/// node — which a per-node count does exactly.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A singleton, because a session outlives the request that opened it. It holds counts and nothing else;
|
||||||
|
/// the sockets belong to the handlers that opened them.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
||||||
|
{
|
||||||
|
private readonly Lock gate = new();
|
||||||
|
private readonly Dictionary<Guid, int> perUser = [];
|
||||||
|
private int total;
|
||||||
|
|
||||||
|
/// <summary>How many sessions are open on this node.</summary>
|
||||||
|
internal int Count
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether a session for this user would be admitted now.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Advisory, for the ticket endpoint: it lets a caller already at the cap hear so as an HTTP status
|
||||||
|
/// before it has spent a ticket, but only <see cref="TryAdmit"/> decides, since the answer here can
|
||||||
|
/// be stale by the time the socket arrives — possibly at another node.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool HasRoomFor(Guid userId)
|
||||||
|
{
|
||||||
|
var settings = options.Value;
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return total < settings.MaxConcurrentSessionsTotal
|
||||||
|
&& perUser.GetValueOrDefault(userId) < settings.MaxConcurrentSessionsPerUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Admits a session, or answers null when a limit is met.</summary>
|
||||||
|
/// <returns>A lease that gives the place back when disposed.</returns>
|
||||||
|
internal Lease? TryAdmit(Guid userId)
|
||||||
|
{
|
||||||
|
var settings = options.Value;
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
var held = perUser.GetValueOrDefault(userId);
|
||||||
|
|
||||||
|
if (total >= settings.MaxConcurrentSessionsTotal || held >= settings.MaxConcurrentSessionsPerUser)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
perUser[userId] = held + 1;
|
||||||
|
total++;
|
||||||
|
|
||||||
|
return new Lease(this, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Release(Guid userId)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
total--;
|
||||||
|
|
||||||
|
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
|
||||||
|
// session open and not of every user who ever had one.
|
||||||
|
if (perUser[userId] <= 1)
|
||||||
|
{
|
||||||
|
perUser.Remove(userId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
perUser[userId]--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
|
||||||
|
internal sealed class Lease(RelaySessionRegistry registry, Guid userId) : IDisposable
|
||||||
|
{
|
||||||
|
private int released;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref released, 1) == 0)
|
||||||
|
{
|
||||||
|
registry.Release(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Relay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Everything the relay socket needs to know, decided by the ticket endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The addresses are the resolved ones and the socket dials nothing else. There is no hostname for it
|
||||||
|
/// to resolve again — <see cref="TargetHost"/> is carried for the audit row only — which is what keeps a
|
||||||
|
/// DNS answer that changes between issue and connect from changing where the bytes go.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="TicketId">The replay guard's key.</param>
|
||||||
|
/// <param name="SessionId">The audit row's id, already given to the client.</param>
|
||||||
|
/// <param name="UserId">Who asked.</param>
|
||||||
|
/// <param name="HostId">The host it is for.</param>
|
||||||
|
/// <param name="VaultId">The host's vault at issue.</param>
|
||||||
|
/// <param name="TargetHost">The hostname as stored, for the audit row.</param>
|
||||||
|
/// <param name="TargetAddresses">The permitted addresses the name resolved to, in the order to try.</param>
|
||||||
|
/// <param name="TargetPort">The port.</param>
|
||||||
|
/// <param name="ExpiresAt">When the ticket stops verifying.</param>
|
||||||
|
internal sealed record RelayTicket(
|
||||||
|
Guid TicketId,
|
||||||
|
Guid SessionId,
|
||||||
|
Guid UserId,
|
||||||
|
Guid HostId,
|
||||||
|
Guid VaultId,
|
||||||
|
string TargetHost,
|
||||||
|
IReadOnlyList<IPAddress> TargetAddresses,
|
||||||
|
int TargetPort,
|
||||||
|
DateTimeOffset ExpiresAt);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seals and opens relay tickets.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A Data Protection payload, per ADR 0004: authenticated and encrypted, with key rotation handled by
|
||||||
|
/// the framework, and verifiable on any node that shares the key ring. Encrypted rather than merely
|
||||||
|
/// signed because the ticket travels in a header every proxy on the way can read, and what it holds is
|
||||||
|
/// the resolved address of a machine on the operator's network.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The expiry is inside the payload and checked against <see cref="TimeProvider"/>, rather than left to
|
||||||
|
/// the framework's time-limited protector, which reads the system clock directly — the thing this
|
||||||
|
/// repository bans so that time can be faked in tests.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The layout is a version byte and fixed fields, written by hand. Small enough that a serialiser would
|
||||||
|
/// be the larger half of the code, and a hand layout has no reflection for trimming to break.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class RelayTicketCodec(IDataProtectionProvider protection, TimeProvider clock)
|
||||||
|
{
|
||||||
|
/// <summary>The purpose string, which isolates these payloads from every other protector's.</summary>
|
||||||
|
private const string Purpose = "DodoSSH.Relay.Ticket";
|
||||||
|
|
||||||
|
private const byte LayoutVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>More addresses than any real name resolves to, and a bound on what a ticket can carry.</summary>
|
||||||
|
internal const int MaxAddresses = 16;
|
||||||
|
|
||||||
|
private readonly IDataProtector protector = protection.CreateProtector(Purpose);
|
||||||
|
|
||||||
|
internal string Seal(RelayTicket ticket)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(ticket);
|
||||||
|
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
using (var writer = new BinaryWriter(buffer))
|
||||||
|
{
|
||||||
|
writer.Write(LayoutVersion);
|
||||||
|
writer.Write(ticket.TicketId.ToByteArray());
|
||||||
|
writer.Write(ticket.SessionId.ToByteArray());
|
||||||
|
writer.Write(ticket.UserId.ToByteArray());
|
||||||
|
writer.Write(ticket.HostId.ToByteArray());
|
||||||
|
writer.Write(ticket.VaultId.ToByteArray());
|
||||||
|
writer.Write(ticket.ExpiresAt.ToUnixTimeMilliseconds());
|
||||||
|
writer.Write(ticket.TargetPort);
|
||||||
|
writer.Write(ticket.TargetHost);
|
||||||
|
writer.Write((byte)ticket.TargetAddresses.Count);
|
||||||
|
|
||||||
|
foreach (var address in ticket.TargetAddresses)
|
||||||
|
{
|
||||||
|
var bytes = address.GetAddressBytes();
|
||||||
|
writer.Write((byte)bytes.Length);
|
||||||
|
writer.Write(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64url, whose alphabet is all token characters, so it can stand in a subprotocol name.
|
||||||
|
return WebEncoders.Base64UrlEncode(protector.Protect(buffer.ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens a ticket, or answers null for one that is forged, damaged or expired.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One answer for all three, because the caller's remedy is the same — ask for another — and a
|
||||||
|
/// distinct "expired" would tell a forger which of their attempts had verified.
|
||||||
|
/// </remarks>
|
||||||
|
internal RelayTicket? TryOpen(string sealedTicket)
|
||||||
|
{
|
||||||
|
byte[] payload;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = protector.Unprotect(WebEncoders.Base64UrlDecode(sealedTicket));
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is CryptographicException or FormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
RelayTicket ticket;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ticket = Read(payload);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
when (exception is EndOfStreamException or ArgumentException or FormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticket.ExpiresAt > clock.GetUtcNow() ? ticket : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RelayTicket Read(byte[] payload)
|
||||||
|
{
|
||||||
|
using var reader = new BinaryReader(new MemoryStream(payload));
|
||||||
|
|
||||||
|
if (reader.ReadByte() != LayoutVersion)
|
||||||
|
{
|
||||||
|
throw new FormatException("Unknown ticket layout.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticketId = new Guid(reader.ReadBytes(16));
|
||||||
|
var sessionId = new Guid(reader.ReadBytes(16));
|
||||||
|
var userId = new Guid(reader.ReadBytes(16));
|
||||||
|
var hostId = new Guid(reader.ReadBytes(16));
|
||||||
|
var vaultId = new Guid(reader.ReadBytes(16));
|
||||||
|
var expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(reader.ReadInt64());
|
||||||
|
var port = reader.ReadInt32();
|
||||||
|
var host = reader.ReadString();
|
||||||
|
var count = reader.ReadByte();
|
||||||
|
|
||||||
|
if (count is 0 or > MaxAddresses)
|
||||||
|
{
|
||||||
|
throw new FormatException("A ticket names between one and sixteen addresses.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var addresses = new IPAddress[count];
|
||||||
|
|
||||||
|
for (var index = 0; index < count; index++)
|
||||||
|
{
|
||||||
|
var length = reader.ReadByte();
|
||||||
|
|
||||||
|
if (length is not (4 or 16))
|
||||||
|
{
|
||||||
|
throw new FormatException("An address is four or sixteen bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses[index] = new IPAddress(reader.ReadBytes(length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RelayTicket(ticketId, sessionId, userId, hostId, vaultId, host, addresses, port, expiresAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using DodoSSH.Api.Authorization;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
|
using DodoSSH.Contracts;
|
||||||
|
using DodoSSH.Domain.Authorization;
|
||||||
|
using DodoSSH.Infrastructure;
|
||||||
|
using FastEndpoints;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Relay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues the ticket that lets exactly one relay socket reach exactly one host.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every decision the relay makes is made here, with the full bearer token and the full access model in
|
||||||
|
/// hand: which host, whether the caller may connect to it, which addresses its name resolves to and
|
||||||
|
/// which of those the policy permits. The socket that follows decides nothing. It checks that the ticket
|
||||||
|
/// is genuine, fresh and unused, and dials what the ticket says. See ADR 0004.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Every refusal about the target is the same refusal.</b> A host that does not exist, one in a vault
|
||||||
|
/// the caller cannot reach, one without the relay switched on, one whose name does not resolve, and one
|
||||||
|
/// whose every address the policy refuses all answer 403 <c>relay-target-rejected</c> with the same
|
||||||
|
/// sentence. Anyone who can write a host can set its address to anything, so a distinct answer for
|
||||||
|
/// "resolves, but refused" would turn this endpoint into a scanner for the operator's internal DNS. The
|
||||||
|
/// real reason goes to the server log, where the operator can read it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class RelayTicketEndpoint(
|
||||||
|
ICurrentUserContext currentUser,
|
||||||
|
IVaultAccessService vaultAccess,
|
||||||
|
DodoDbContext database,
|
||||||
|
IRelayNetwork network,
|
||||||
|
RelayTicketCodec codec,
|
||||||
|
RelaySessionRegistry sessions,
|
||||||
|
IOptions<RelayOptions> options,
|
||||||
|
TimeProvider clock,
|
||||||
|
ILogger<RelayTicketEndpoint> logger)
|
||||||
|
: Endpoint<RelayTicketRequest, Results<Ok<RelayTicketResponse>, ProblemHttpResult>>
|
||||||
|
{
|
||||||
|
/// <summary>What every target refusal says, whatever the reason was.</summary>
|
||||||
|
private const string RejectedDetail =
|
||||||
|
"The relay will not connect to this host. Check that the relay is switched on for it and that its "
|
||||||
|
+ "address is one this server may reach; the server's log records which.";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void Configure()
|
||||||
|
{
|
||||||
|
Post(RelayProtocol.TicketsPath);
|
||||||
|
|
||||||
|
// Enrolled, matching sync. A relayed host is still a host whose name, username and key are
|
||||||
|
// ciphertext, so a caller with no identity key has no way to have learned which host to ask for.
|
||||||
|
Policies(Auth.EnrolledPolicy);
|
||||||
|
|
||||||
|
Description(b => b
|
||||||
|
.WithName("IssueRelayTicket")
|
||||||
|
.WithSummary("Issues a single-use ticket for one relay connection to one host.")
|
||||||
|
.WithTags("Relay"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task<Results<Ok<RelayTicketResponse>, ProblemHttpResult>> ExecuteAsync(
|
||||||
|
RelayTicketRequest req,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var settings = options.Value;
|
||||||
|
|
||||||
|
if (!settings.Enabled)
|
||||||
|
{
|
||||||
|
return Problems.Coded(
|
||||||
|
StatusCodes.Status404NotFound,
|
||||||
|
ProblemCodes.RelayUnavailable,
|
||||||
|
"This server does not relay. Connect to the host directly; GET /api/v1/meta lists the "
|
||||||
|
+ "features this server does offer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||||
|
var (target, refusal) = await FindTargetAsync(user.Id, req, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (target is not var (hostname, port, vaultId))
|
||||||
|
{
|
||||||
|
return Rejected(user.Id, req.HostId, refusal!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before resolving rather than after: a caller already at the cap is told so without this server
|
||||||
|
// spending a DNS query on a ticket that could not be redeemed.
|
||||||
|
if (!sessions.HasRoomFor(user.Id))
|
||||||
|
{
|
||||||
|
RelayLog.LimitReached(logger, user.Id, sessions.Count);
|
||||||
|
|
||||||
|
return Problems.Coded(
|
||||||
|
StatusCodes.Status429TooManyRequests,
|
||||||
|
ProblemCodes.RelayLimitReached,
|
||||||
|
"You have as many relay sessions open as this server allows. Close one and try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var addresses = await ResolvePermittedAsync(hostname, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (addresses is not { Length: > 0 })
|
||||||
|
{
|
||||||
|
return Rejected(user.Id, req.HostId, addresses is null
|
||||||
|
? "its name did not resolve"
|
||||||
|
: "every address its name resolved to is refused by policy");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticket = new RelayTicket(
|
||||||
|
TicketId: Guid.CreateVersion7(),
|
||||||
|
SessionId: Guid.CreateVersion7(),
|
||||||
|
UserId: user.Id,
|
||||||
|
HostId: req.HostId,
|
||||||
|
VaultId: vaultId,
|
||||||
|
TargetHost: hostname,
|
||||||
|
TargetAddresses: addresses,
|
||||||
|
TargetPort: port,
|
||||||
|
ExpiresAt: clock.GetUtcNow() + settings.TicketLifetime);
|
||||||
|
|
||||||
|
RelayLog.TicketIssued(logger, user.Id, req.HostId, ticket.SessionId, addresses.Length);
|
||||||
|
|
||||||
|
return TypedResults.Ok(new RelayTicketResponse(
|
||||||
|
Ticket: codec.Seal(ticket),
|
||||||
|
WebSocketUrl: new Uri(settings.WebSocketUrl!, UriKind.Absolute),
|
||||||
|
SubProtocol: RelayProtocol.SubProtocol,
|
||||||
|
SessionId: ticket.SessionId,
|
||||||
|
ExpiresAt: ticket.ExpiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the host, and decides whether this caller may be relayed to it at all.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The stored target, or the reason there is none — for the log, never for the caller.</returns>
|
||||||
|
private async Task<((string Hostname, int Port, Guid VaultId)? Target, string? Refusal)> FindTargetAsync(
|
||||||
|
Guid userId,
|
||||||
|
RelayTicketRequest req,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (req.PortForwardId is not null)
|
||||||
|
{
|
||||||
|
return (null, "a port forward was requested, which v1 does not relay");
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = await database.Hosts
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(h => h.Id == req.HostId && h.DeletedAtUtc == null, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (host is null)
|
||||||
|
{
|
||||||
|
return (null, "no such host");
|
||||||
|
}
|
||||||
|
|
||||||
|
var access = await vaultAccess.ResolveAsync(userId, host.VaultId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Connect rather than Read, because Connect is the permission that says what this is. Every role
|
||||||
|
// that reads today also connects — see VaultAccessService.ForRole — but a role that ever split
|
||||||
|
// them should be refused a relay here without anybody remembering to look.
|
||||||
|
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Connect))
|
||||||
|
{
|
||||||
|
return (null, "the caller may not connect to this host's vault");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CHECK constraint makes the second half unreachable while the first is true. Tested anyway,
|
||||||
|
// because the cost of trusting it is dialling a null.
|
||||||
|
if (!host.RelayEnabled || host.Hostname is not { } hostname || host.Port is not { } port)
|
||||||
|
{
|
||||||
|
return (null, "the relay is not switched on for this host");
|
||||||
|
}
|
||||||
|
|
||||||
|
return RelayAddressPolicy.PermitsPort(port)
|
||||||
|
? ((hostname, port, host.VaultId), null)
|
||||||
|
: (null, $"port {port} is refused by policy");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the stored name, and keeps the addresses the policy permits.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The permitted addresses, possibly none; or null when the name did not resolve.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Filtered rather than refused as a whole when one answer is bad. A name that resolves to a public
|
||||||
|
/// address and a link-local one is dialled on the public address, and never on the other — the
|
||||||
|
/// ticket carries only what passed, so there is nothing else for the socket to try.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The addresses kept are the normalised ones, so what is dialled is exactly what was judged rather
|
||||||
|
/// than a second spelling of it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<IPAddress[]?> ResolvePermittedAsync(string hostname, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var policy = new RelayAddressPolicy(options.Value);
|
||||||
|
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(options.Value.ConnectTimeout);
|
||||||
|
|
||||||
|
IPAddress[] resolved;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
resolved = await network.ResolveAsync(hostname, timeout.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
.. resolved
|
||||||
|
.Where(policy.Permits)
|
||||||
|
.Select(RelayAddressPolicy.Normalise)
|
||||||
|
.Distinct()
|
||||||
|
.Take(RelayTicketCodec.MaxAddresses),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProblemHttpResult Rejected(Guid userId, Guid hostId, string reason)
|
||||||
|
{
|
||||||
|
RelayLog.TargetRejected(logger, userId, hostId, reason);
|
||||||
|
|
||||||
|
return Problems.Coded(StatusCodes.Status403Forbidden, ProblemCodes.RelayTargetRejected, RejectedDetail);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,9 @@ builder.Services.AddSingleton<VaultEventHub>();
|
|||||||
builder.Services.AddSingleton<IVaultEventPublisher>(
|
builder.Services.AddSingleton<IVaultEventPublisher>(
|
||||||
provider => provider.GetRequiredService<VaultEventHub>());
|
provider => provider.GetRequiredService<VaultEventHub>());
|
||||||
|
|
||||||
|
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
|
||||||
|
builder.Services.AddDodoRelay();
|
||||||
|
|
||||||
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
|
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
|
||||||
// DbContext, and a singleton would capture one for the lifetime of the process.
|
// DbContext, and a singleton would capture one for the lifetime of the process.
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ internal static class Configuration
|
|||||||
+ "setting exists to provide.")
|
+ "setting exists to provide.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
|
services.AddRelayOptions();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The relay's options, which carry more rules than any other section. See ADR 0004.</summary>
|
||||||
|
private static void AddRelayOptions(this IServiceCollection services) =>
|
||||||
services.AddOptions<RelayOptions>()
|
services.AddOptions<RelayOptions>()
|
||||||
.BindConfiguration(RelayOptions.SectionName)
|
.BindConfiguration(RelayOptions.SectionName)
|
||||||
.ValidateDataAnnotations()
|
.ValidateDataAnnotations()
|
||||||
@@ -63,11 +70,28 @@ internal static class Configuration
|
|||||||
.Validate(
|
.Validate(
|
||||||
options => options.IdleTimeout < options.MaxSessionDuration,
|
options => options.IdleTimeout < options.MaxSessionDuration,
|
||||||
"Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.")
|
"Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.")
|
||||||
|
.Validate(
|
||||||
|
options => options.TicketLifetime > TimeSpan.Zero
|
||||||
|
&& options.TicketLifetime <= TimeSpan.FromSeconds(30),
|
||||||
|
"Relay:TicketLifetime must be positive and at most 30 seconds. A ticket is a bearer "
|
||||||
|
+ "credential in a header; ADR 0004 bounds how long one may be worth stealing.")
|
||||||
|
.Validate(
|
||||||
|
options => options.ConnectTimeout > TimeSpan.Zero && options.DrainTimeout >= TimeSpan.Zero,
|
||||||
|
"Relay:ConnectTimeout must be positive and Relay:DrainTimeout must not be negative.")
|
||||||
|
.Validate(
|
||||||
|
options => Features.Relay.RelayAddressPolicy.AreNetworks(options.AllowedNetworks)
|
||||||
|
&& Features.Relay.RelayAddressPolicy.AreNetworks(options.DeniedNetworks),
|
||||||
|
"Relay:AllowedNetworks and Relay:DeniedNetworks must hold CIDR networks such as "
|
||||||
|
+ "10.20.0.0/16, with no host bits set. An entry this server cannot read would be a gap "
|
||||||
|
+ "the operator believes is closed.")
|
||||||
|
.Validate(
|
||||||
|
options => !options.Enabled
|
||||||
|
|| (Uri.TryCreate(options.WebSocketUrl, UriKind.Absolute, out var url)
|
||||||
|
&& (string.Equals(url.Scheme, Uri.UriSchemeWs, StringComparison.Ordinal)
|
||||||
|
|| string.Equals(url.Scheme, Uri.UriSchemeWss, StringComparison.Ordinal))),
|
||||||
|
"Relay:WebSocketUrl must be an absolute ws:// or wss:// URL.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Warns about configuration that is individually valid but dangerous in combination.
|
/// Warns about configuration that is individually valid but dangerous in combination.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -115,13 +115,50 @@ public sealed class RelayOptions
|
|||||||
/// Whether RFC1918 and other private ranges may be dialled.
|
/// Whether RFC1918 and other private ranges may be dialled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
|
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
|
||||||
/// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
|
/// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
|
||||||
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. Loopback,
|
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
|
||||||
/// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Governs the private ranges only — RFC1918, carrier-grade NAT (which is where a Tailnet lives) and
|
||||||
|
/// IPv6 unique-local — and never the list <c>RelayAddressPolicy</c> refuses with no configuration
|
||||||
|
/// escape: loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses.
|
||||||
|
/// Nothing here can re-open those, which is what ADR 0004 §3 means by non-overridable.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool AllowPrivateNetworks { get; set; } = true;
|
public bool AllowPrivateNetworks { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Networks, in CIDR form, that a relay target must fall inside. Empty means any network the other
|
||||||
|
/// rules permit.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// When set, this is the whole answer for the ranges it names: a target inside one is permitted even
|
||||||
|
/// with <see cref="AllowPrivateNetworks"/> off, because "only 10.20.0.0/16" is the precise thing an
|
||||||
|
/// operator means by setting both. It still cannot reach the non-overridable list, and
|
||||||
|
/// <see cref="DeniedNetworks"/> still wins over it.
|
||||||
|
/// </remarks>
|
||||||
|
public IList<string> AllowedNetworks { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Networks, in CIDR form, that a relay target may never fall inside.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Checked before <see cref="AllowedNetworks"/>, so a deny inside an allow is a hole in it — the way
|
||||||
|
/// round that lets an operator carve the database subnet out of an otherwise permitted range.
|
||||||
|
/// </remarks>
|
||||||
|
public IList<string> DeniedNetworks { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A directory to keep the ticket key ring in. Unset means the platform default.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Losing the key ring is harmless in the way losing <c>Sync:CursorSigningKey</c> is: a ticket lives
|
||||||
|
/// thirty seconds, so a restart invalidates at most the connects in flight and the client asks
|
||||||
|
/// again. What does need it is more than one node — every node must be able to read every other's
|
||||||
|
/// tickets for round-robin to work, so a multi-node deployment points this at shared storage.
|
||||||
|
/// </remarks>
|
||||||
|
public string? KeyRingDirectory { get; set; }
|
||||||
|
|
||||||
/// <summary>Maximum concurrent sessions per user.</summary>
|
/// <summary>Maximum concurrent sessions per user.</summary>
|
||||||
[Range(1, 1000)]
|
[Range(1, 1000)]
|
||||||
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
|
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using DodoSSH.Api.Features.Events;
|
using DodoSSH.Api.Features.Events;
|
||||||
using DodoSSH.Api.Features.Identity;
|
using DodoSSH.Api.Features.Identity;
|
||||||
using DodoSSH.Api.Features.Meta;
|
using DodoSSH.Api.Features.Meta;
|
||||||
|
using DodoSSH.Api.Features.Relay;
|
||||||
using DodoSSH.Api.Features.Sync;
|
using DodoSSH.Api.Features.Sync;
|
||||||
using DodoSSH.Api.Features.Teams;
|
using DodoSSH.Api.Features.Teams;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
@@ -45,6 +46,8 @@ internal static class EndpointRegistration
|
|||||||
typeof(SyncPullEndpoint),
|
typeof(SyncPullEndpoint),
|
||||||
typeof(SyncPushEndpoint),
|
typeof(SyncPushEndpoint),
|
||||||
typeof(VaultEventsEndpoint),
|
typeof(VaultEventsEndpoint),
|
||||||
|
typeof(RelayTicketEndpoint),
|
||||||
|
typeof(RelayConnectEndpoint),
|
||||||
typeof(CreateTeamEndpoint),
|
typeof(CreateTeamEndpoint),
|
||||||
typeof(ListTeamsEndpoint),
|
typeof(ListTeamsEndpoint),
|
||||||
typeof(UpdateTeamEndpoint),
|
typeof(UpdateTeamEndpoint),
|
||||||
@@ -65,7 +68,6 @@ internal static class EndpointRegistration
|
|||||||
// Registered as each feature lands:
|
// Registered as each feature lands:
|
||||||
// Identity — key rotation, passphrase change
|
// Identity — key rotation, passphrase change
|
||||||
// Vaults — per-item ACLs
|
// Vaults — per-item ACLs
|
||||||
// Relay — tickets and the WebSocket
|
|
||||||
// Audit, Admin
|
// Audit, Admin
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using DodoSSH.Api.Features.Relay;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.DataProtection.KeyManagement;
|
||||||
|
using Microsoft.AspNetCore.DataProtection.Repositories;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Setup;
|
||||||
|
|
||||||
|
/// <summary>The relay's services. See ADR 0004.</summary>
|
||||||
|
internal static class RelaySetup
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// How much longer than the drain the host waits before it stops regardless.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Room for the close handshakes and the audit rows' final writes once the drain has run out, which
|
||||||
|
/// is the part of a shutdown an operator would otherwise lose without noticing.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly TimeSpan ShutdownMargin = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
internal static IServiceCollection AddDodoRelay(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
// Singletons: a session outlives the request that opened it, a protector is safe to share, and
|
||||||
|
// the network seam holds no state.
|
||||||
|
services.AddSingleton<RelayTicketCodec>();
|
||||||
|
services.AddSingleton<RelaySessionRegistry>();
|
||||||
|
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
|
||||||
|
|
||||||
|
// The application name isolates this deployment's payloads from any other ASP.NET application
|
||||||
|
// sharing a key directory, and — unlike the default, which is derived from the content root —
|
||||||
|
// is the same on every node, which is what lets any node verify any node's ticket.
|
||||||
|
services.AddDataProtection().SetApplicationName("DodoSSH");
|
||||||
|
|
||||||
|
services.AddOptions<KeyManagementOptions>()
|
||||||
|
.Configure<IOptions<RelayOptions>, ILoggerFactory>((keys, relay, loggers) =>
|
||||||
|
{
|
||||||
|
if (relay.Value.KeyRingDirectory is { Length: > 0 } directory)
|
||||||
|
{
|
||||||
|
keys.XmlRepository = new FileSystemXmlRepository(new DirectoryInfo(directory), loggers);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Longer than the drain, or the host would stop waiting for live sessions before the drain ADR 0004
|
||||||
|
// promises them had run. Harmless with the relay off: nothing else holds a request open that long,
|
||||||
|
// and the host stops as soon as the last request has.
|
||||||
|
services.AddOptions<HostOptions>()
|
||||||
|
.Configure<IOptions<RelayOptions>>((host, relay) =>
|
||||||
|
{
|
||||||
|
var wanted = relay.Value.DrainTimeout + ShutdownMargin;
|
||||||
|
|
||||||
|
if (wanted > host.ShutdownTimeout)
|
||||||
|
{
|
||||||
|
host.ShutdownTimeout = wanted;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,36 +86,71 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
var shell = new PhoneShell();
|
var shell = new PhoneShell();
|
||||||
|
|
||||||
var viewModel = ComposeShell(
|
var viewModel = ComposeShell(
|
||||||
paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell));
|
paths,
|
||||||
|
caches,
|
||||||
|
workspace,
|
||||||
|
knownHosts,
|
||||||
|
connections,
|
||||||
|
ClipboardWriter(shell),
|
||||||
|
ClipboardReader(shell));
|
||||||
|
|
||||||
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>
|
||||||
@@ -147,6 +182,26 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Reads this phone's clipboard.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The other direction, looked up per call for the same reason and with the same fall-through. One
|
||||||
|
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
|
||||||
|
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Wired on this head although a phone has no right button, and not for symmetry: this head runs the same
|
||||||
|
/// renderer page, and a device with a mouse, a trackpad case or a desktop dock has a secondary click.
|
||||||
|
/// Whether Android's WebView delivers one as a DOM <c>contextmenu</c> event is unverified — see
|
||||||
|
/// <c>docs/manual-checks.md</c> 11.13 — and leaving this out in the meantime would make the paste half
|
||||||
|
/// the one thing that worked on one head and silently did nothing on the other.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static Func<Task<string?>> ClipboardReader(Visual surface) =>
|
||||||
|
async () => TopLevel.GetTopLevel(surface) is { Clipboard: { } clipboard }
|
||||||
|
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
|
||||||
|
: null;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
|
/// Split from <see cref="Compose"/> only for length. The division is a real one though: above this is
|
||||||
/// the platform graph, and below it is the shell every head shares.
|
/// the platform graph, and below it is the shell every head shares.
|
||||||
@@ -157,7 +212,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
TerminalWorkspace workspace,
|
TerminalWorkspace workspace,
|
||||||
VaultKnownHostStore knownHosts,
|
VaultKnownHostStore knownHosts,
|
||||||
SshNetConnectionFactory connections,
|
SshNetConnectionFactory connections,
|
||||||
Func<string, Task> copyToClipboard)
|
Func<string, Task> copyToClipboard,
|
||||||
|
Func<Task<string?>> readClipboard)
|
||||||
{
|
{
|
||||||
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
|
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
|
||||||
// key. A straight implementation of the interface the session layer has always taken.
|
// key. A straight implementation of the interface the session layer has always taken.
|
||||||
@@ -207,6 +263,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
deviceName: PhoneEnvironment.DeviceName,
|
deviceName: PhoneEnvironment.DeviceName,
|
||||||
|
|
||||||
copyToClipboard: copyToClipboard,
|
copyToClipboard: copyToClipboard,
|
||||||
|
readClipboard: readClipboard,
|
||||||
|
|
||||||
updates: updates);
|
updates: updates);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
|
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Android;
|
using Avalonia.Android;
|
||||||
|
using Avalonia.Media;
|
||||||
using DodoSSH.Client.Android.Platform;
|
using DodoSSH.Client.Android.Platform;
|
||||||
using global::Android.App;
|
using global::Android.App;
|
||||||
using global::Android.Runtime;
|
using global::Android.Runtime;
|
||||||
@@ -46,6 +47,30 @@ public sealed class DodoSshAndroidApplication : AvaloniaAndroidApplication<DodoS
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// This used to be the one place the two heads disagreed on purpose: the recorded v2 decision was
|
||||||
|
/// "Android recolours with the shared palette and keeps its own default sans", on the reasoning that a
|
||||||
|
/// phone's system font is a phone's own business. The user has reversed that, this pass — the phone now
|
||||||
|
/// takes the desktop's face as well as its colours, and <c>docs/design-import-gaps.md</c> is corrected
|
||||||
|
/// to say so rather than left asserting a decision that no longer holds.
|
||||||
|
///
|
||||||
|
/// <c>WithInterFont</c> still registers Inter, for the same reason <c>Program.cs</c> keeps it on the
|
||||||
|
/// desktop: it is what the layout suite pins and what a glyph Montserrat does not cover falls back to.
|
||||||
|
/// What changes is which face answers first. Montserrat ships embedded in
|
||||||
|
/// <c>DodoSSH.Client.Shell/Assets/Fonts</c> already — this project references that assembly for
|
||||||
|
/// <c>Theme/Phone.axaml</c>'s own <c>MonoFont</c>, so no csproj or asset work was needed to reach it
|
||||||
|
/// from here, only the same <see cref="FontManagerOptions"/> block the desktop's
|
||||||
|
/// <c>Program.BuildAvaloniaApp</c> sets.
|
||||||
|
/// </remarks>
|
||||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
|
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
|
||||||
base.CustomizeAppBuilder(builder).WithInterFont();
|
base.CustomizeAppBuilder(builder)
|
||||||
|
.WithInterFont()
|
||||||
|
.With(new FontManagerOptions
|
||||||
|
{
|
||||||
|
DefaultFamilyName = "avares://DodoSSH.Client.Shell/Assets/Fonts#Montserrat",
|
||||||
|
FontFallbacks =
|
||||||
|
[
|
||||||
|
new FontFallback { FontFamily = new FontFamily("avares://Avalonia.Fonts.Inter/Assets#Inter") },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,11 @@ public sealed class MainActivity : AvaloniaMainActivity
|
|||||||
PhoneEnvironment.CurrentActivity = this;
|
PhoneEnvironment.CurrentActivity = this;
|
||||||
|
|
||||||
AndroidRedirectCallback.Abandon();
|
AndroidRedirectCallback.Abandon();
|
||||||
|
|
||||||
|
// And the keyboard may have gone away while this activity was not on screen, without Avalonia's
|
||||||
|
// input pane raising anything to say so. PhoneShell re-reads the inset from this. See
|
||||||
|
// PhoneEnvironment.Resumed, which carries the whole of why.
|
||||||
|
PhoneEnvironment.RaiseResumed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -100,6 +100,35 @@ internal static class PhoneEnvironment
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static global::Android.App.Activity? CurrentActivity { get; set; }
|
public static global::Android.App.Activity? CurrentActivity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Raised each time the launcher activity comes back to the foreground.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Exists because the software keyboard can go away without Avalonia being told.</b>
|
||||||
|
/// <c>IInputPane.StateChanged</c> is the only signal <c>PhoneShell</c> has for the keyboard opening and
|
||||||
|
/// closing, and it is not raised when Android dismisses the keyboard on the way out of the app — the
|
||||||
|
/// activity is being paused, and the pane is simply gone by the time anything could have reported it.
|
||||||
|
/// So the inset that holds the interface clear of the keyboard was still applied on the way back in,
|
||||||
|
/// and the whole phone drew an entire keyboard's height above the bottom of a screen with no keyboard
|
||||||
|
/// on it. Reproduced every time: open the keyboard anywhere, press Home, return — 838 of 2340 pixels
|
||||||
|
/// dead, and staying that way until the keyboard was opened and closed again.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// An event here rather than a call into the view, because the view is Avalonia's and this is Android's:
|
||||||
|
/// <c>MainActivity</c> already owns <c>OnResume</c> and already writes <see cref="CurrentActivity"/>
|
||||||
|
/// from it, and a static event is the same shape of hand-off one step further. Anything else that needs
|
||||||
|
/// to re-read platform state on the way back in can subscribe without <c>MainActivity</c> learning what
|
||||||
|
/// it is.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Subscribers are responsible for unsubscribing — this is a static event and holds whatever it is
|
||||||
|
/// given for the life of the process. <c>PhoneShell</c> attaches and detaches with its visual tree.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static event EventHandler? Resumed;
|
||||||
|
|
||||||
|
/// <summary>Announces that the launcher activity is foreground again.</summary>
|
||||||
|
internal static void RaiseResumed() => Resumed?.Invoke(null, EventArgs.Empty);
|
||||||
|
|
||||||
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
|
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
|
||||||
public static Context Require() =>
|
public static Context Require() =>
|
||||||
context ?? throw new InvalidOperationException(
|
context ?? throw new InvalidOperationException(
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using global::Android.Views;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
|
||||||
|
/// back.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>This replaces <c>TerminalFocus</c>, and it is the opposite move.</b> That class handed Android's own
|
||||||
|
/// view focus <em>back</em> to the <c>WebView</c> after Avalonia's <c>DispatchTouchEvent</c> took it, because
|
||||||
|
/// the input connection the keyboard was attached to belonged to the page. Under ADR 0015 it no longer does:
|
||||||
|
/// <see cref="Views.TerminalInputSurface"/> is the input target, and it is an Avalonia control, so the focus
|
||||||
|
/// the platform requests for Avalonia's view after every handled touch is now the focus we want. The fight
|
||||||
|
/// ends by removing one of the contestants rather than by refereeing it faster.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Non-focusable, not non-interactive, and the distinction is the whole design.</b> Focusability governs
|
||||||
|
/// where keyboard and D-pad input go; it has nothing to do with touch delivery. A <c>WebView</c> with
|
||||||
|
/// <c>Focusable=false</c> still receives, scrolls and hit-tests every touch it is given — so the page keeps
|
||||||
|
/// its <c>touch-action: pan-y</c> scrollback exactly as before — but it can no longer take an input
|
||||||
|
/// connection, and Chromium's own <c>showSoftInput</c> for the focused DOM node cannot succeed from an
|
||||||
|
/// unfocused view. That is precisely the split wanted: the page keeps the finger, Avalonia keeps the
|
||||||
|
/// keyboard.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why a tap has to be reported at all.</b> With the page unable to raise a keyboard, the ordinary way
|
||||||
|
/// back after the system back gesture lowers one — touch the terminal — would otherwise do nothing. The
|
||||||
|
/// listener is attached with <c>Handled = false</c> throughout, so it observes without consuming: the
|
||||||
|
/// <c>WebView</c>'s own <c>OnTouchEvent</c> runs after it exactly as if nothing were listening.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A tap is distinguished from a scroll the way every Android view does it, by
|
||||||
|
/// <c>ViewConfiguration.ScaledTouchSlop</c>. Without that test, dragging the scrollback would raise the
|
||||||
|
/// keyboard over the very output being scrolled to.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
|
||||||
|
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
|
||||||
|
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
|
||||||
|
/// terminal. Every step is allowed to be absent — there is no activity while backgrounded, and no
|
||||||
|
/// <c>WebView</c> until the terminal surface has been shown once.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class RendererFocus
|
||||||
|
{
|
||||||
|
/// <summary>The view already configured, so that showing the terminal twice does not listen twice.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Weak, because this is a static holding an Android view: a strong reference would keep the whole view
|
||||||
|
/// hierarchy, and the activity behind it, alive for the life of the process.
|
||||||
|
/// </remarks>
|
||||||
|
private static WeakReference<global::Android.Webkit.WebView>? configured;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
|
||||||
|
/// taps it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
|
||||||
|
/// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is
|
||||||
|
/// free to tear its renderer down and build a new one, which is a case this head already handles
|
||||||
|
/// elsewhere (see <c>docs/android-port.md</c>).
|
||||||
|
/// </remarks>
|
||||||
|
public static void Suppress(Action onTapped)
|
||||||
|
{
|
||||||
|
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FindWebView(decor) is not { } webView)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configured is not null && configured.TryGetTarget(out var already) && ReferenceEquals(already, webView))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.Focusable = false;
|
||||||
|
webView.FocusableInTouchMode = false;
|
||||||
|
webView.ClearFocus();
|
||||||
|
|
||||||
|
WatchForTaps(webView, onTapped);
|
||||||
|
|
||||||
|
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A tap is a press and a release that did not travel further than the platform's own
|
||||||
|
/// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious:
|
||||||
|
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
|
||||||
|
/// </remarks>
|
||||||
|
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
|
||||||
|
{
|
||||||
|
// The fallback is the platform's own historical default, for a context that has somehow gone.
|
||||||
|
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
|
||||||
|
var downX = 0f;
|
||||||
|
var downY = 0f;
|
||||||
|
var moved = false;
|
||||||
|
|
||||||
|
webView.Touch += (_, e) =>
|
||||||
|
{
|
||||||
|
// Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
|
||||||
|
e.Handled = false;
|
||||||
|
|
||||||
|
if (e.Event is not { } motion)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (motion.Action)
|
||||||
|
{
|
||||||
|
case MotionEventActions.Down:
|
||||||
|
downX = motion.GetX();
|
||||||
|
downY = motion.GetY();
|
||||||
|
moved = false;
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MotionEventActions.Move:
|
||||||
|
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MotionEventActions.Up when !moved:
|
||||||
|
onTapped();
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < parent.ChildCount; i++)
|
||||||
|
{
|
||||||
|
switch (parent.GetChildAt(i))
|
||||||
|
{
|
||||||
|
case global::Android.Webkit.WebView webView:
|
||||||
|
return webView;
|
||||||
|
|
||||||
|
case ViewGroup child when FindWebView(child) is { } found:
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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. -->
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
The launcher mark, and it is the same mark PhoneShell's header and the desktop titlebar draw:
|
The launcher mark, and it is the same mark PhoneShell's header and the desktop titlebar draw:
|
||||||
>_ in the canvas colour on a solid accent tile. Filled rather than outlined since v2.
|
>_ in AccentInk on a solid accent tile. Filled rather than outlined since v2.
|
||||||
|
|
||||||
The tile is not in this file. It is the background layer — @color/dodo_accent, see ic_launcher.xml
|
The tile is not in this file. It is the background layer — @color/dodo_accent, see ic_launcher.xml
|
||||||
— and that is the whole trick of the filled design on Android: the rounding a launcher applies is
|
— and that is the whole trick of the filled design on Android: the rounding a launcher applies is
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
rounded shape inside the first, visibly clipped at the corners on any device whose mask is not the
|
rounded shape inside the first, visibly clipped at the corners on any device whose mask is not the
|
||||||
one it was drawn for.
|
one it was drawn for.
|
||||||
|
|
||||||
#0E1220 is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
|
#FFFFFF is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
|
||||||
Android resource cannot reference a XAML dictionary. It equals Canvas today and is named
|
Android resource cannot reference a XAML dictionary. Through v2 it equalled Canvas and this file
|
||||||
separately in the palette for a reason worth keeping in mind here: this is ink on the accent, not
|
followed it when the two parted: v5's ink on the accent is white, the way every accent surface in
|
||||||
the window behind it, and it follows AccentInk if the two ever part.
|
that palette carries white, while the window behind it went its own way to #05050A.
|
||||||
|
|
||||||
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer 18 on each
|
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer 18 on each
|
||||||
edge is what the launcher eats for masking and parallax. The glyph spans 36.06..71.94, which is
|
edge is what the launcher eats for masking and parallax. The glyph spans 36.06..71.94, which is
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<path
|
<path
|
||||||
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
|
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
|
||||||
android:fillColor="#00000000"
|
android:fillColor="#00000000"
|
||||||
android:strokeColor="#0E1220"
|
android:strokeColor="#FFFFFF"
|
||||||
android:strokeWidth="5.4"
|
android:strokeWidth="5.4"
|
||||||
android:strokeLineCap="round"
|
android:strokeLineCap="round"
|
||||||
android:strokeLineJoin="round" />
|
android:strokeLineJoin="round" />
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<path
|
<path
|
||||||
android:pathData="M54,64.68 L71.94,64.68"
|
android:pathData="M54,64.68 L71.94,64.68"
|
||||||
android:fillColor="#00000000"
|
android:fillColor="#00000000"
|
||||||
android:strokeColor="#0E1220"
|
android:strokeColor="#FFFFFF"
|
||||||
android:strokeWidth="5.4"
|
android:strokeWidth="5.4"
|
||||||
android:strokeLineCap="round" />
|
android:strokeLineCap="round" />
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
Worth shipping rather than leaving out: a launcher with themed icons on and no monochrome
|
Worth shipping rather than leaving out: a launcher with themed icons on and no monochrome
|
||||||
layer to use falls back to the full-colour icon, so the one app on the home screen still
|
layer to use falls back to the full-colour icon, so the one app on the home screen still
|
||||||
drawn in blue is this one.
|
drawn in its own accent is this one.
|
||||||
-->
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
|
|||||||
@@ -9,12 +9,12 @@
|
|||||||
between an Android resource and a XAML resource dictionary, so the duplication is stated rather than
|
between an Android resource and a XAML resource dictionary, so the duplication is stated rather than
|
||||||
hidden.
|
hidden.
|
||||||
-->
|
-->
|
||||||
<color name="dodo_window">#0E1220</color>
|
<color name="dodo_window">#05050A</color>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
AccentColor from the same palette, here because the launcher icon's background layer is a colour
|
AccentColor from the same palette, here because the launcher icon's background layer is a colour
|
||||||
and not a drawable. Same hand-kept duplication as above, and the same rule: if the palette moves,
|
and not a drawable. Same hand-kept duplication as above, and the same rule: if the palette moves,
|
||||||
this moves with it.
|
this moves with it.
|
||||||
-->
|
-->
|
||||||
<color name="dodo_accent">#5B8CFF</color>
|
<color name="dodo_accent">#5D42DE</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -12,10 +12,28 @@
|
|||||||
the alternative, and the red one is the one that costs something.
|
the alternative, and the red one is the one that costs something.
|
||||||
|
|
||||||
── v2 ──────────────────────────────────────────────────────────────────────────────────────────────
|
── v2 ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
The second design rounds everything. The corner radii below are the design's own — 4 for a tag, 9 for a
|
The second design rounded everything on its own ladder — 4 for a tag, 9 for a button or a pill, 10 for
|
||||||
button or a pill, 10 for a list row, 11 for the search well, 12 for a card, 14 for a block of
|
a list row, 11 for the search well, 12 for a card, 14 for a block of monospaced output — and the numbers
|
||||||
monospaced output — and they are a ladder rather than a set of preferences: the radius says how big the
|
below carried it for three passes: the radius said how big the thing was, so a 12 on a chip or a 4 on a
|
||||||
thing is, so a 12 on a chip or a 4 on a card reads as the wrong size before it reads as the wrong shape.
|
card read as the wrong size before it read as the wrong shape. Left as a record of that reasoning rather
|
||||||
|
than deleted, because the reasoning still holds; only the numbers it was reasoning about have moved.
|
||||||
|
|
||||||
|
── v5 ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
This pass takes the desktop's v5 ladder instead of v2's own, on the same reversal recorded for the
|
||||||
|
fonts: the phone now matches the desktop's shape as well as its face. Three rungs rather than six —
|
||||||
|
cards and sections stay 12, a button or a field is 10, a chip or a tag is 6 — collapsing v2's 9/10/11
|
||||||
|
into the one value the desktop's buttons and fields already use, and moving its 4 up to 6 and its 14
|
||||||
|
down to 12 to land on the desktop's own chip and card numbers. The ladder still says how big a thing
|
||||||
|
is before it says what shape it is; it is just a shorter ladder now, because the desktop it is copying
|
||||||
|
never drew v2's 11-radius search well or a card any rounder than a chip's neighbour a step away, and the
|
||||||
|
same case that closed the gap between 9, 10 and 11 closes the one between 12 and 14.
|
||||||
|
|
||||||
|
Not every radius on this head belongs to this ladder. The floating action button is 28 — half its own
|
||||||
|
56, which is a circle rather than a ladder rung — and the sheets stay at 22 on their top corners only,
|
||||||
|
which is a phone idiom this codebase's own bottom sheets have used since v2 and the desktop draws
|
||||||
|
nothing like. Both are documented where they are set rather than here, for the reason FAB and sheet
|
||||||
|
radii are always documented locally: a reader who only ever meets one of them should not have to find
|
||||||
|
this paragraph to learn it was deliberate.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Style Selector="Button.primary">
|
<Style Selector="Button.primary">
|
||||||
@@ -23,15 +41,32 @@
|
|||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="12" />
|
<Setter Property="FontSize" Value="12" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ Gradient and glow, replacing a flat Accent fill — the same swap App.axaml's Button.accent made on the
|
||||||
|
desktop, and for the same reason: this design draws every primary button top-to-bottom from a lighter
|
||||||
|
violet into the accent proper, lifted off the surface with a soft violet glow rather than a border. Both
|
||||||
|
live in Palette.axaml as AccentGradient and AccentGlow, already resolvable here because the palette is
|
||||||
|
shared — this is a resource-key swap, not new colour.
|
||||||
|
|
||||||
|
Background and BoxShadow are set here rather than as plain Setters above, because Fluent's default
|
||||||
|
button template only lets a style reach the fill and the glow through the ContentPresenter it draws
|
||||||
|
itself around — the same reason the desktop's rule targets /template/ ContentPresenter rather than the
|
||||||
|
Button. Height, radius and the rest stay ordinary Setters on Button.primary itself; only the two the
|
||||||
|
template intercepts move down here.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.primary /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||||
|
<Setter Property="BoxShadow" Value="{StaticResource AccentGlow}" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Button.primary:pressed /template/ ContentPresenter">
|
<Style Selector="Button.primary:pressed /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||||
<Setter Property="Opacity" Value="0.82" />
|
<Setter Property="Opacity" Value="0.82" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -39,9 +74,15 @@
|
|||||||
Disabled is drawn as flat and unlit rather than merely dimmed. The design's CONTINUE button on the
|
Disabled is drawn as flat and unlit rather than merely dimmed. The design's CONTINUE button on the
|
||||||
recovery screen is disabled until the checkbox is ticked, and a user who cannot tell it is disabled
|
recovery screen is disabled until the checkbox is ticked, and a user who cannot tell it is disabled
|
||||||
reads the screen as broken rather than as waiting for them.
|
reads the screen as broken rather than as waiting for them.
|
||||||
|
|
||||||
|
BoxShadow has to be cleared here too, as "none" rather than left unset — see the remark on
|
||||||
|
Button.accent:disabled in the desktop's App.axaml for why the literal string is required and an empty
|
||||||
|
BoxShadows is not: the base rule's glow Setter is still in effect wherever a more specific one does not
|
||||||
|
override it, and a disabled primary button lit with a glow would read as wanting to be pressed.
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
|
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||||
|
<Setter Property="BoxShadow" Value="none" />
|
||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
|
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -54,7 +95,7 @@
|
|||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="11.5" />
|
<Setter Property="FontSize" Value="11.5" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
@@ -69,17 +110,33 @@
|
|||||||
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
|
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
<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="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" />
|
||||||
@@ -110,10 +167,124 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||||
<Setter Property="FontSize" Value="20" />
|
<Setter Property="FontSize" Value="20" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<!--
|
||||||
|
◆ THE FILL BELOW IS NOT DECORATION. IT IS UNDOING ONE FLUENT PUTS THERE.
|
||||||
|
|
||||||
|
`accent` is not a name this theme invented — Fluent ships a `Button.accent` style of its own, and a
|
||||||
|
button classed `icon accent` matches it. So these three buttons were being painted with Fluent's
|
||||||
|
system-accent slab, #6275AC, a blue that appears nowhere in Palette.axaml and reads as a control from
|
||||||
|
a different application sitting in this one's header. Measured off a screenshot rather than guessed:
|
||||||
|
the fill was #6275AC where the accent is #5D42DE.
|
||||||
|
|
||||||
|
The rule above could not stop it, because it sets Foreground and FontSize and Fluent's fill is on the
|
||||||
|
ContentPresenter — which is also why `Button.icon`'s own Background="Transparent" does not reach it.
|
||||||
|
App.axaml has carried the desktop's answer to exactly this since v5b: override the ContentPresenter,
|
||||||
|
not the Button. This head simply never had one, so the desktop looked right and the phone did not.
|
||||||
|
|
||||||
|
Transparent rather than the desktop's gradient, and the difference is the control rather than an
|
||||||
|
inconsistency. The desktop's Button.accent is a filled primary slab — this head has one of those too,
|
||||||
|
and it is Button.primary. `icon accent` is a 44-pixel glyph in a header row beside other glyphs; what
|
||||||
|
"accent" asks for there is the accent *colour on the mark*, which is what the rule above gives it. A
|
||||||
|
slab would make one header icon a button and its neighbours not.
|
||||||
|
|
||||||
|
Pressed is deliberately not restated: Button.icon:pressed below sets ChromeHover on every icon button
|
||||||
|
and, coming later in this file, wins over this rule for the moment a finger is down. That is the
|
||||||
|
feedback the plain icon buttons give and there is no reason this one should differ.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.icon.accent /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.icon.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.icon.accent:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Button.icon:pressed /template/ ContentPresenter">
|
<Style Selector="Button.icon:pressed /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============ one key of the terminal's accessory keypad ============
|
||||||
|
|
||||||
|
A class rather than eleven sets of attributes assigned in code, and the move out of the code-behind is
|
||||||
|
most of what makes the latched state honest: Ctrl on and Ctrl off used to be two Background values
|
||||||
|
written by hand in TerminalScreen.axaml.cs, which meant the row named two of this palette's brushes
|
||||||
|
itself and any third state would have named a third. It is `latched` here, and the code toggles a class.
|
||||||
|
|
||||||
|
── the numbers, and why they are these ─────────────────────────────────────────────────────────────────
|
||||||
|
40 tall, where the row this replaced was 30. Android asks for 48 on a touch target and every terminal on
|
||||||
|
the platform lands near it; 30 was chosen to save a strip of the terminal above and took the keypad past
|
||||||
|
the point of working, which is the wrong end of that trade. 40 with a 2-pixel margin gives 44 of pitch,
|
||||||
|
which is the same figure TextBox.field uses for a box a thumb has to hit.
|
||||||
|
|
||||||
|
HorizontalAlignment Stretch, because these live in UniformGrid cells and the cell is the share of the
|
||||||
|
width the key is entitled to. Left to Center a key would shrink to its label and "|" would be a sliver
|
||||||
|
between two wide neighbours — which is the layout the flexed row exists to stop.
|
||||||
|
|
||||||
|
── pressed has to be visible, and it was not ───────────────────────────────────────────────────────────
|
||||||
|
These keys are Focusable="False" so that using one does not take the keyboard away from the terminal
|
||||||
|
(see TerminalScreen.axaml.cs), and a button that never takes focus also never shows the focus adorner
|
||||||
|
Fluent would otherwise give it. That left a press with no feedback at all on a control whose entire job
|
||||||
|
is to be pressed while somebody is looking at the line of output above it. The pressed fill below is the
|
||||||
|
only thing that says a key was hit — and on a keypad over a remote shell, "did that send?" is a question
|
||||||
|
worth answering in the interface rather than in the scrollback.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.hotkey">
|
||||||
|
<Setter Property="Height" Value="40" />
|
||||||
|
<Setter Property="MinHeight" Value="40" />
|
||||||
|
<Setter Property="MinWidth" Value="0" />
|
||||||
|
<Setter Property="Margin" Value="2,0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||||
|
</Style>
|
||||||
|
<!--
|
||||||
|
Latched Ctrl. The same fill a press gives, held rather than flashed — a modifier that is on and does not
|
||||||
|
look on is how somebody sends ^L to a database prompt believing they typed an l.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.hotkey.latched">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey.latched /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.hotkey:disabled">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The floating action button the design has asked for since v2, and which this theme refused twice — on
|
The floating action button the design has asked for since v2, and which this theme refused twice — on
|
||||||
the grounds that it would open an editor this head had not got, and that an unused style is a claim the
|
the grounds that it would open an editor this head had not got, and that an unused style is a claim the
|
||||||
@@ -126,7 +297,17 @@
|
|||||||
|
|
||||||
Accent-filled, which it shares with Button.primary and with nothing else — and it means the same thing
|
Accent-filled, which it shares with Button.primary and with nothing else — and it means the same thing
|
||||||
in both places: the one action on the surface that is not a choice between peers. Circular by radius
|
in both places: the one action on the surface that is not a choice between peers. Circular by radius
|
||||||
rather than by a Path, so the pressed state the template draws is the same shape as the button.
|
rather than by a Path, so the pressed state the template draws is the same shape as the button — 28,
|
||||||
|
exactly half its own 56, which is a geometric constraint rather than a ladder rung and does not move
|
||||||
|
with the rest of this file's radii.
|
||||||
|
|
||||||
|
◆ Gradient and glow since v5, matching Button.primary rather than staying a flat fill once that one
|
||||||
|
moved. The two are the only accent-filled controls on this head and are read as one idea — "the thing
|
||||||
|
this surface wants you to do" — so a flat FAB beside a gradient primary button would be the seam this
|
||||||
|
codebase's palette file keeps warning about, one screenshot over from the button it echoes. The glow
|
||||||
|
reads as well on a floating circle as it does on a bar-anchored rectangle: if anything a control that
|
||||||
|
already floats over content earns a lift more than one sitting in a row of chrome does, so it is kept
|
||||||
|
rather than dropped for the FAB's own shape.
|
||||||
|
|
||||||
Still only on HOSTS. The design puts a second one on S3 and that editor genuinely does not exist yet, so
|
Still only on HOSTS. The design puts a second one on S3 and that editor genuinely does not exist yet, so
|
||||||
the style being here is not permission to draw one there.
|
the style being here is not permission to draw one there.
|
||||||
@@ -135,7 +316,6 @@
|
|||||||
<Setter Property="Width" Value="56" />
|
<Setter Property="Width" Value="56" />
|
||||||
<Setter Property="Height" Value="56" />
|
<Setter Property="Height" Value="56" />
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="28" />
|
<Setter Property="CornerRadius" Value="28" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
@@ -145,8 +325,14 @@
|
|||||||
<Setter Property="FontSize" Value="24" />
|
<Setter Property="FontSize" Value="24" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<!-- See the remark above Button.primary's own /template/ ContentPresenter rule: BoxShadow has no home on
|
||||||
|
a Button itself, so the fill moves down here alongside it rather than staying a plain Setter. -->
|
||||||
|
<Style Selector="Button.fab /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||||
|
<Setter Property="BoxShadow" Value="{StaticResource AccentGlow}" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Button.fab:pressed /template/ ContentPresenter">
|
<Style Selector="Button.fab:pressed /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||||
<Setter Property="Opacity" Value="0.82" />
|
<Setter Property="Opacity" Value="0.82" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -161,7 +347,7 @@
|
|||||||
interface glitching rather than as a tap being received.
|
interface glitching rather than as a tap being received.
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.scrim">
|
<Style Selector="Button.scrim">
|
||||||
<Setter Property="Background" Value="#9E0E1220" />
|
<Setter Property="Background" Value="#9E05050A" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="0" />
|
<Setter Property="CornerRadius" Value="0" />
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
@@ -169,10 +355,10 @@
|
|||||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.scrim:pointerover /template/ ContentPresenter">
|
<Style Selector="Button.scrim:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="#9E0E1220" />
|
<Setter Property="Background" Value="#9E05050A" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.scrim:pressed /template/ ContentPresenter">
|
<Style Selector="Button.scrim:pressed /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="#9E0E1220" />
|
<Setter Property="Background" Value="#9E05050A" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -194,7 +380,7 @@
|
|||||||
-->
|
-->
|
||||||
<Style Selector="Border.output">
|
<Style Selector="Border.output">
|
||||||
<Setter Property="Background" Value="{StaticResource TerminalSurface}" />
|
<Setter Property="Background" Value="{StaticResource TerminalSurface}" />
|
||||||
<Setter Property="CornerRadius" Value="14" />
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
<Setter Property="Padding" Value="12" />
|
<Setter Property="Padding" Value="12" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -205,7 +391,7 @@
|
|||||||
-->
|
-->
|
||||||
<Style Selector="Border.tag">
|
<Style Selector="Border.tag">
|
||||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||||
<Setter Property="CornerRadius" Value="4" />
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
<Setter Property="Padding" Value="7,2" />
|
<Setter Property="Padding" Value="7,2" />
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
@@ -231,7 +417,7 @@
|
|||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="4" />
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
<Setter Property="Padding" Value="11,0" />
|
<Setter Property="Padding" Value="11,0" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
@@ -267,11 +453,17 @@
|
|||||||
Checked is a filled surface with accent *text*, not an accent fill. v2 makes that distinction
|
Checked is a filled surface with accent *text*, not an accent fill. v2 makes that distinction
|
||||||
everywhere — see the remark on AccentText in Palette.axaml — and a chip is where it matters most: a row
|
everywhere — see the remark on AccentText in Palette.axaml — and a chip is where it matters most: a row
|
||||||
of four solid blue lozenges is a row of four things that all look like the primary action.
|
of four solid blue lozenges is a row of four things that all look like the primary action.
|
||||||
|
|
||||||
|
◆ Its own radius rather than Border.tag's or Button.chiptoggle's, despite the name. v2 drew this control
|
||||||
|
on the ladder's button-or-pill rung rather than its tag rung — 9, not 4 — and the v5 pass carries the
|
||||||
|
same rung forward to 10 rather than to Border.tag's 6: it is a much larger control at 34 tall against a
|
||||||
|
tag's line-height, and a radius picked for a 34-pixel chip is not the one a 20-pixel tag needs, whatever
|
||||||
|
the class is called. See the remark on Phone.axaml's own ladder, above.
|
||||||
-->
|
-->
|
||||||
<Style Selector="RadioButton.chip">
|
<Style Selector="RadioButton.chip">
|
||||||
<Setter Property="MinHeight" Value="34" />
|
<Setter Property="MinHeight" Value="34" />
|
||||||
<Setter Property="Padding" Value="13,6" />
|
<Setter Property="Padding" Value="13,6" />
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
@@ -314,7 +506,59 @@
|
|||||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="11" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ The two port boxes, which are NumericUpDowns and were the only unthemed controls on this head.
|
||||||
|
|
||||||
|
They sit in a two-column Grid with a plain TextBox.field beside them — port next to username in the host
|
||||||
|
editor, and port next to default-user in the group editor — so the mismatch was not subtle: a bright
|
||||||
|
Fluent border and a near-black fill against this theme's #101019 and hairline, at the one place on the
|
||||||
|
screen where the two are a hand's width apart and directly comparable.
|
||||||
|
|
||||||
|
Not `Classes="field"` on the two call sites, because that class is a TextBox class and this is not a
|
||||||
|
TextBox: NumericUpDown is a ButtonSpinner wrapping one, so half the chrome being corrected belongs to
|
||||||
|
the spinner and would not take a TextBox setter. Typing the control instead also means a third port box
|
||||||
|
added later is right without anybody remembering, which is the property the desktop's own bare
|
||||||
|
`Selector="NumericUpDown"` in App.axaml has had since it was written.
|
||||||
|
|
||||||
|
Three selectors rather than one for the same reason Button.icon.accent needs two: Fluent puts the fill
|
||||||
|
and the border on template parts, and a value set on the control alone is a TemplateBinding the theme's
|
||||||
|
own wins over. The numbers are TextBox.field's, exactly — 44 tall, 10 radius, Field on BorderMid — so
|
||||||
|
the pair reads as one row of two boxes rather than as a box and a visitor.
|
||||||
|
-->
|
||||||
|
<Style Selector="NumericUpDown">
|
||||||
|
<Setter Property="Height" Value="44" />
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="NumericUpDown /template/ ButtonSpinner">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
</Style>
|
||||||
|
<!--
|
||||||
|
The inner box gives its chrome up entirely: the spinner above is drawing the border and the fill, and a
|
||||||
|
TextBox that kept its own would put a second rounded rectangle a pixel inside the first.
|
||||||
|
-->
|
||||||
|
<Style Selector="NumericUpDown /template/ TextBox">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="14,0" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="12" />
|
<Setter Property="FontSize" Value="12" />
|
||||||
@@ -358,6 +602,31 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Red for a shell whose connection broke, splitting the grey that used to cover every dead tab. A shell
|
||||||
|
somebody exited and a shell that was cut off are the two dead states with the least in common, and on a
|
||||||
|
phone the difference matters more than it does on a desk: a mobile link drops on its own, and the tab
|
||||||
|
that went quiet while the screen was off is the one worth dialling again. See
|
||||||
|
TerminalTabViewModel.IsDropped.
|
||||||
|
-->
|
||||||
|
<Style Selector="Ellipse.dot.dropped">
|
||||||
|
<Setter Property="Fill" Value="{StaticResource Danger}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
<!-- 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}" />
|
||||||
@@ -370,10 +639,29 @@
|
|||||||
<Setter Property="FontSize" Value="9.5" />
|
<Setter Property="FontSize" Value="9.5" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<!--
|
||||||
|
◆ Wraps, like .body below it, and the omission was doing real damage rather than none.
|
||||||
|
|
||||||
|
A TextBlock that neither wraps nor trims does not shrink to fit and does not report that it did not:
|
||||||
|
it lays out at its full single-line width and the parent clips whatever runs past the edge. So a
|
||||||
|
.detail long enough to overflow ended mid-word against the screen, with no ellipsis to say anything
|
||||||
|
was missing — "A machine to connect to, filed under a group if you" on the add sheet, which reads as
|
||||||
|
a sentence somebody forgot to finish rather than as text that was cut.
|
||||||
|
|
||||||
|
It is a style rather than an attribute on the ones that overflow today, because which of the fifty-odd
|
||||||
|
.detail blocks overflows is a function of the device's width and the user's font scale, not of the
|
||||||
|
string: every one of them is a candidate at 320dp that is fine at 411dp, and fixing the handful that
|
||||||
|
happen to be visibly wrong on a Pixel 5 would leave the rest to be found one narrow phone at a time.
|
||||||
|
|
||||||
|
The nine that genuinely want one line — list rows and log lines, where a wrapped detail would push the
|
||||||
|
row's own height around — now say TextWrapping="NoWrap" beside their TextTrimming, which is the pairing
|
||||||
|
that was always meant and was previously being had by accident.
|
||||||
|
-->
|
||||||
<Style Selector="TextBlock.detail">
|
<Style Selector="TextBlock.detail">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
<Setter Property="FontSize" Value="10" />
|
<Setter Property="FontSize" Value="10" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TextBlock.body">
|
<Style Selector="TextBlock.body">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
|||||||
@@ -48,7 +48,12 @@
|
|||||||
the refusal has no continue button here either.
|
the refusal has no continue button here either.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
|
<!--
|
||||||
|
Nine rows since the disconnect strip arrived; it is row 7, between the listing and the action bar, so
|
||||||
|
that what it says is next to the buttons it replaces the point of. Auto and empty except while the
|
||||||
|
session has been lost, so an ordinary browse pays nothing for it.
|
||||||
|
-->
|
||||||
|
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto,Auto">
|
||||||
|
|
||||||
<!-- ============ header ============ -->
|
<!-- ============ 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">
|
||||||
@@ -78,7 +83,13 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- ============ opening one ============ -->
|
<!-- ============ opening one ============ -->
|
||||||
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding !IsConnected}">
|
<!--
|
||||||
|
ShowsRemoteInvitation rather than !IsConnected, which stopped being the same question when a
|
||||||
|
connection could be lost rather than only closed: a session that went away leaves its listing on
|
||||||
|
screen, and this card springing back over it would hide the one thing the strip below is offering to
|
||||||
|
put the user back into. See TransfersViewModel.ShowsRemoteContents.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding ShowsRemoteInvitation}">
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
|
|
||||||
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
|
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
|
||||||
@@ -174,11 +185,50 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ ◆ typing where to go ============ -->
|
||||||
|
<!--
|
||||||
|
The breadcrumb below reaches only what is under the directory already listed, so crossing a host's
|
||||||
|
filesystem was a tap and a round trip a level and a path somebody already knew could not be entered at
|
||||||
|
all. This is the way in for one that is: absolute, relative to the directory showing, or starting at
|
||||||
|
~. See SftpPath.Resolve.
|
||||||
|
|
||||||
|
GO is a real button rather than only the keyboard's own action key, and that is a phone decision: a
|
||||||
|
software keyboard's return key is whatever the IME decided to draw, this application does not set the
|
||||||
|
IME action, and a path box whose only commit was a key that may say "newline" would be a box nothing
|
||||||
|
could be submitted from. The Enter binding stays for the hardware keyboards this head does meet.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="*,8,Auto" Margin="14,0,14,6"
|
||||||
|
IsVisible="{Binding ShowsRemoteContents}">
|
||||||
|
<TextBox Grid.Column="0" Classes="field" Text="{Binding RemotePathDraft}"
|
||||||
|
PlaceholderText="/path/to/somewhere">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding GoToTypedRemotePathCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Padding="16,0" Content="GO"
|
||||||
|
Command="{Binding GoToTypedRemotePathCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ How many are gathered up, and the one gesture that gathers or releases them all. Only while
|
||||||
|
something is — an always-present row would spend 30 pixels of a listing on a count of zero.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="3" ColumnDefinitions="Auto,*" Margin="14,0,14,6" VerticalAlignment="Bottom"
|
||||||
|
IsVisible="{Binding HasCheckedRemote}">
|
||||||
|
<Button Grid.Column="0" Classes="secondary" MinHeight="36" Padding="12,0"
|
||||||
|
Command="{Binding ToggleAllRemoteCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="ALL / NONE" />
|
||||||
|
</Button>
|
||||||
|
<TextBlock Grid.Column="1" Classes="mono" FontSize="12" Margin="10,0,0,0"
|
||||||
|
VerticalAlignment="Center" Foreground="{StaticResource AccentText}"
|
||||||
|
Text="{Binding CheckedRemoteLabel}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<!-- ============ where we are ============ -->
|
<!-- ============ where we are ============ -->
|
||||||
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||||
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
|
IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
||||||
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="9"
|
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
|
||||||
Command="{Binding RemoteUpCommand}">
|
Command="{Binding RemoteUpCommand}">
|
||||||
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
|
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -188,7 +238,7 @@
|
|||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||||
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="9"
|
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="10"
|
||||||
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||||||
CommandParameter="{Binding Path}">
|
CommandParameter="{Binding Path}">
|
||||||
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
|
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
|
||||||
@@ -199,10 +249,50 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- ============ the listing ============ -->
|
<!-- ============ pins ============ -->
|
||||||
<Panel Grid.Row="4">
|
<!--
|
||||||
|
◆ 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.
|
||||||
|
|
||||||
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
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="5" 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 ============ -->
|
||||||
|
<Panel Grid.Row="6">
|
||||||
|
|
||||||
|
<TextBlock Classes="body" IsVisible="{Binding ShowsRemoteInvitation}" Margin="24,12"
|
||||||
VerticalAlignment="Top" Text="{Binding Status}" />
|
VerticalAlignment="Top" Text="{Binding Status}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -219,7 +309,7 @@
|
|||||||
selection navigate would send a single desktop click walking into a directory.
|
selection navigate would send a single desktop click walking into a directory.
|
||||||
-->
|
-->
|
||||||
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
|
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
|
||||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding IsConnected}"
|
Background="Transparent" BorderThickness="0" IsVisible="{Binding ShowsRemoteContents}"
|
||||||
Tapped="OnRemoteEntryTapped">
|
Tapped="OnRemoteEntryTapped">
|
||||||
<ListBox.Styles>
|
<ListBox.Styles>
|
||||||
<Style Selector="ListBoxItem">
|
<Style Selector="ListBoxItem">
|
||||||
@@ -236,15 +326,27 @@
|
|||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||||
<Border MinHeight="52" Padding="10,7">
|
<Border MinHeight="52" Padding="10,7">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
|
||||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
<!--
|
||||||
|
◆ The tick, which on this head is the only multi-selection there can be: a phone has no
|
||||||
|
ctrl-click and no shift-click. It is not the list's selection — that stays "the row I am
|
||||||
|
looking at", which RENAME, MOVE and PERMS act on one at a time — and this is "the rows I
|
||||||
|
have gathered up", which SAVE and DELETE take. See RemoteEntryRowViewModel.IsChecked.
|
||||||
|
|
||||||
|
Its own 40-pixel column so a thumb aimed at it does not open the directory beside it: the
|
||||||
|
row's own tap navigates, which is this head's one behaviour the desktop does not share.
|
||||||
|
-->
|
||||||
|
<CheckBox Grid.Column="0" Width="40" MinWidth="0" VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding IsChecked}" Click="OnRemoteRowChecked" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center" FontSize="12" Text="▸"
|
VerticalAlignment="Center" FontSize="12" Text="▸"
|
||||||
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
|
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
|
||||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center" FontSize="12" Text="·"
|
VerticalAlignment="Center" FontSize="12" Text="·"
|
||||||
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
|
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
|
||||||
|
|
||||||
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
<StackPanel Grid.Column="2" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||||
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
|
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||||
@@ -255,7 +357,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Modified}"
|
<TextBlock Grid.Column="3" Classes="detail" Text="{Binding Modified}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -264,8 +366,50 @@
|
|||||||
</ListBox>
|
</ListBox>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ what the screen says once the session has stopped ============ -->
|
||||||
|
<!--
|
||||||
|
The phone's half of the desktop's SessionEndedBar, and it binds this screen's own view model rather
|
||||||
|
than the shell's — this control's DataContext is TransfersViewModel, where the desktop's strip sits in
|
||||||
|
MainWindow beside a status bar and can ask which surface it is on. The facts are the same two:
|
||||||
|
TransfersViewModel.LostConnection and the reconnect that goes back to the directory showing.
|
||||||
|
|
||||||
|
A phone needs it more than a desk does, and for the reason its terminal does: a mobile link drops on
|
||||||
|
its own and the screen goes off, so this session is discovered later with a listing on it and no
|
||||||
|
account of why nothing works. The listing stays deliberately — see ShowsRemoteContents.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="7" IsVisible="{Binding IsConnectionLost}" Background="{StaticResource DeepChrome}"
|
||||||
|
BorderBrush="{StaticResource Danger}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||||
|
<Ellipse Classes="dot" Classes.dropped="True" Width="7" Height="7" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="DISCONNECTED" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Two lines maximum, for the reason the terminal's strip caps its own: a transport error can hand
|
||||||
|
over whatever the operating system called it, and a strip that grew to fit one would eat the
|
||||||
|
listing above it on a screen that has none to spare.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||||
|
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{Binding LostConnection}" />
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding ReconnectCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding DisconnectCommand}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- ============ what to do with the chosen entry ============ -->
|
<!-- ============ what to do with the chosen entry ============ -->
|
||||||
<Border Grid.Row="5" IsVisible="{Binding IsConnected}" Background="{StaticResource Chrome}"
|
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
|
||||||
|
<Border Grid.Row="8" 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">
|
||||||
|
|
||||||
@@ -287,6 +431,105 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ Renaming and moving, in the place the deletion question already occupies and never at the same
|
||||||
|
time as it. One form for both — see RemoteMoveRequest for why the two questions share a shape and
|
||||||
|
differ only in their words — and no danger colours on it, because a rename is undone by renaming
|
||||||
|
back and the deletion above is the one thing here that nothing can walk back.
|
||||||
|
-->
|
||||||
|
<Border IsVisible="{Binding IsMovingRemote}" Background="{StaticResource Raised}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||||
|
Padding="13,11">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemoteMove.Question}" />
|
||||||
|
<TextBlock Classes="detail" TextWrapping="Wrap" Text="{Binding PendingRemoteMove.FullPath}" />
|
||||||
|
<TextBox Classes="field" Text="{Binding RemoteMoveTarget}"
|
||||||
|
PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding ConfirmMoveRemoteCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<TextBlock Classes="body" Text="{Binding PendingRemoteMove.Note}" />
|
||||||
|
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||||
|
<Button Grid.Column="0" Classes="primary" Height="44"
|
||||||
|
Content="{Binding PendingRemoteMove.ConfirmLabel}"
|
||||||
|
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||||
|
Command="{Binding CancelMoveRemoteCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ The mode, as nine boxes and the number they add up to — in the place the other two questions
|
||||||
|
already occupy and never at the same time as either. See RemotePermissionsRequest for why it is
|
||||||
|
boxes rather than a field to type 755 into, and TransfersScreen.axaml for the desktop's own copy
|
||||||
|
of the same form.
|
||||||
|
|
||||||
|
Compact rather than a second layout: the three columns are the mode's own three triples, and at
|
||||||
|
360dp they are exactly what fits. The octal is printed underneath as it changes, which is what
|
||||||
|
lets somebody who came here to type chmod check the boxes they just ticked.
|
||||||
|
-->
|
||||||
|
<Border IsVisible="{Binding IsChangingRemotePermissions}" Background="{StaticResource Raised}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||||
|
Padding="13,11">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemotePermissions.Question}" />
|
||||||
|
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PendingRemotePermissions.FullPath}" />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,44,44,44" Margin="0,2">
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="1" Classes="detail" Text="read"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="2" Classes="detail" Text="write"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
<TextBlock Grid.Row="0" Grid.Column="3" Classes="detail"
|
||||||
|
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
|
||||||
|
HorizontalAlignment="Center" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="12" Text="owner"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
|
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
|
||||||
|
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
|
||||||
|
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="12" Text="group"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
|
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
|
||||||
|
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
|
||||||
|
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="12" Text="others"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
|
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
|
||||||
|
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
|
||||||
|
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||||
|
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource AccentText}"
|
||||||
|
Text="{Binding PendingRemotePermissions.Preview}" />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||||
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="APPLY"
|
||||||
|
Command="{Binding ConfirmRemotePermissionsCommand}"
|
||||||
|
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||||
|
Command="{Binding CancelRemotePermissionsCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
|
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
|
||||||
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
|
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
|
||||||
@@ -308,7 +551,7 @@
|
|||||||
Foreground="{StaticResource Accent}"
|
Foreground="{StaticResource Accent}"
|
||||||
Background="{StaticResource Raised}" />
|
Background="{StaticResource Raised}" />
|
||||||
<TextBlock Classes="detail" Text="{Binding Progress}"
|
<TextBlock Classes="detail" Text="{Binding Progress}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -345,20 +588,44 @@
|
|||||||
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
||||||
a file is selected and something is connected — and is reused rather than restated here.
|
a file is selected and something is connected — and is reused rather than restated here.
|
||||||
-->
|
-->
|
||||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
||||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
||||||
IsEnabled="{Binding CanDownload}" />
|
IsEnabled="{Binding CanDownload}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
<!--
|
||||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
◆ The two that put a file right rather than taking it away, above the row DELETE is on and away
|
||||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
from it. Both open a form in place of these buttons — see the card above — because a phone has
|
||||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
|
nowhere to put a text field except where something else already was.
|
||||||
Command="{Binding DisconnectCommand}" />
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||||
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="RENAME"
|
||||||
|
Command="{Binding RenameRemoteCommand}" IsEnabled="{Binding CanRenameRemote}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
||||||
|
Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
<!--
|
||||||
|
◆ PERMS beside DELETE rather than on the row above, which is full: it is the fourth thing that can
|
||||||
|
be done to a chosen row and the third that puts one right. Dark on a bucket, where an object has an
|
||||||
|
access policy rather than nine bits — see CanChangeRemotePermissions.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||||
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="PERMS"
|
||||||
|
Command="{Binding ChangeRemotePermissionsCommand}"
|
||||||
|
IsEnabled="{Binding CanChangeRemotePermissions}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="DELETE"
|
||||||
|
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||||
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CLOSE"
|
||||||
|
Command="{Binding DisconnectCommand}" />
|
||||||
|
<Panel Grid.Column="2" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
|
||||||
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
||||||
|
|
||||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ internal sealed partial class FilesScreen : UserControl
|
|||||||
/// read its size the same gesture as fetching it.
|
/// read its size the same gesture as fetching it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <summary>Tells the screen a row's tick moved, so the count above the listing keeps up.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The desktop's own handler, for the reason given there: the count is a property of the collection
|
||||||
|
/// rather than of any row, and each listing replaces every row it has. See
|
||||||
|
/// <c>TransfersViewModel.RemoteChecksChanged</c>.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) =>
|
||||||
|
(DataContext as TransfersViewModel)?.RemoteChecksChanged();
|
||||||
|
|
||||||
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
|
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<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.Android.Views.HostActionBar"
|
||||||
|
x:DataType="vm:VaultViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE CONTEXTUAL ACTION BAR, WHICH IS WHAT THE CONNECT CARD BECAME.
|
||||||
|
|
||||||
|
A long press on a host used to raise a card over the bottom of the list: a password box, CONNECT, EDIT,
|
||||||
|
MOVE and DELETE. It was a menu drawn as a form, in the one part of the screen a list grows into — so it
|
||||||
|
covered rows, it had room for five things and never a sixth, and everything on it was about exactly one
|
||||||
|
machine. What replaced it is a selection and this bar.
|
||||||
|
|
||||||
|
It takes the vault header's place rather than sitting under it, which is the arrangement Android has used
|
||||||
|
for this since contextual action bars existed and is the reason it can be unambiguous: while it is up, the
|
||||||
|
screen is about the ticked hosts and about nothing else. See PhoneShell.ShowsHostSelectionBar, which is
|
||||||
|
where the swap happens and why it is computed rather than bound.
|
||||||
|
|
||||||
|
Left to right: the cross that leaves selection mode, the count, then the two controls. The pencil is out
|
||||||
|
in front of the menu because editing is the thing people reach for most and is worth not opening a menu
|
||||||
|
for; the other six are behind the ⋯, which is a sheet rather than a flyout — see
|
||||||
|
VaultViewModel.IsHostActionSheetOpen.
|
||||||
|
|
||||||
|
── one host or several ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
The pencil is drawn only while exactly one host is ticked, and it is collapsed rather than greyed. Three
|
||||||
|
of the seven actions are like that — edit, connect, and browse — and the reason is the same for all
|
||||||
|
three: a form, a terminal and a file session are each about one machine, and there is no sensible reading
|
||||||
|
of "edit these six". The three that stay are the ones a count makes better rather than worse.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- DeepChrome rather than Chrome, since v5: this bar takes the vault header's own place, so it takes
|
||||||
|
the vault header's own surface too — see the remark on that header in PhoneShell.axaml. -->
|
||||||
|
<Border Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||||
|
BorderThickness="0,0,0,1" Padding="6,0" Height="56">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The cross, and it is the first thing on the bar for the reason back arrows are: the way out of a
|
||||||
|
mode belongs at the edge the thumb reaches without crossing anything it might press by mistake.
|
||||||
|
-->
|
||||||
|
<Button Grid.Column="0" Classes="icon" Content="✕"
|
||||||
|
Command="{Binding ClearHostChoiceCommand}"
|
||||||
|
ToolTip.Tip="Stop choosing hosts" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The count, and only the count. The bar it sits in is already saying what the number is about, and
|
||||||
|
"6 hosts selected" beside a pencil and a menu is the width those two icons need at 360dp.
|
||||||
|
-->
|
||||||
|
<TextBlock Grid.Column="1" Classes="heading" FontSize="17" Margin="8,0"
|
||||||
|
VerticalAlignment="Center" Text="{Binding ChosenHostsLabel}" />
|
||||||
|
|
||||||
|
<Button Grid.Column="2" Classes="icon" Content="✎" FontSize="15"
|
||||||
|
IsVisible="{Binding HasOneChosenHost}"
|
||||||
|
Command="{Binding EditChosenHostCommand}"
|
||||||
|
ToolTip.Tip="Edit this host" />
|
||||||
|
|
||||||
|
<Button Grid.Column="3" Classes="icon" Content="⋯" FontSize="18"
|
||||||
|
Command="{Binding OpenHostActionSheetCommand}"
|
||||||
|
ToolTip.Tip="More things to do with these hosts" />
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
|
/// <summary>The bar that takes the header's place while hosts are chosen.</summary>
|
||||||
|
internal sealed partial class HostActionBar : UserControl
|
||||||
|
{
|
||||||
|
public HostActionBar() => AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@
|
|||||||
and the plan names presenting it as dismissible as the one design mistake that matters here. A phone
|
and the plan names presenting it as dismissible as the one design mistake that matters here. A phone
|
||||||
makes this easy to get wrong, because a bottom sheet is swipe-to-dismiss by convention; this is not a
|
makes this easy to get wrong, because a bottom sheet is swipe-to-dismiss by convention; this is not a
|
||||||
sheet for that reason, it is a full-screen panel.
|
sheet for that reason, it is a full-screen panel.
|
||||||
|
|
||||||
|
◆ WHAT IS BEHIND IT IS NO LONGER ALWAYS THE HOST LIST. The shell used to move to HOSTS before letting a
|
||||||
|
handshake ask this, because the desktop head drew the question as a banner on that screen; both heads draw
|
||||||
|
it over the surface now, so this is raised over whatever the user was on — including the connect box on the
|
||||||
|
Connections surface, which is where a machine that is in no keychain at all is dialled from. Nothing here
|
||||||
|
changes for that, and that is the point: this control never knew which screen it was over. See
|
||||||
|
MainWindowViewModel.OnVaultConnectionFailed and HostKeyCard.axaml on the desktop.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
@@ -29,7 +36,7 @@
|
|||||||
written out because the palette holds no alpha variant of a surface — see QuickConnect on the
|
written out because the palette holds no alpha variant of a surface — see QuickConnect on the
|
||||||
desktop, which carries the same note.
|
desktop, which carries the same note.
|
||||||
-->
|
-->
|
||||||
<Border Background="#9E0E1220" />
|
<Border Background="#9E05050A" />
|
||||||
|
|
||||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||||
@@ -42,12 +49,29 @@
|
|||||||
|
|
||||||
<TextBlock Classes="title" Text="UNKNOWN HOST KEY" FontSize="13" />
|
<TextBlock Classes="title" Text="UNKNOWN HOST KEY" FontSize="13" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ The port and the full stop are one Run, and that is the whole of the fix.
|
||||||
|
|
||||||
|
Avalonia puts a space between adjacent Runs, which is what makes "First contact with", the
|
||||||
|
host and the separator read as a sentence without any of them carrying padding. The trailing
|
||||||
|
Run began with a full stop, so it got that space too and the sheet said
|
||||||
|
|
||||||
|
First contact with 10.0.2.2 · 2222 . Nothing in this keychain…
|
||||||
|
|
||||||
|
— a gap before the stop, on the one dialogue in this application whose entire job is to be
|
||||||
|
read carefully before somebody answers it. StringFormat welds the stop to the number inside a
|
||||||
|
single Run, where no inter-Run space can get in, and the sentence that follows starts a Run of
|
||||||
|
its own so it still gets the space it wants.
|
||||||
|
|
||||||
|
The `{}` prefix is required: a StringFormat beginning with a brace would otherwise be parsed
|
||||||
|
as a markup extension.
|
||||||
|
-->
|
||||||
<TextBlock Classes="body" Margin="0,6,0,0">
|
<TextBlock Classes="body" Margin="0,6,0,0">
|
||||||
<Run Text="First contact with" />
|
<Run Text="First contact with" />
|
||||||
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
||||||
<Run Text="·" />
|
<Run Text="·" />
|
||||||
<Run Text="{Binding PendingHostKey.Port}" />
|
<Run Text="{Binding PendingHostKey.Port, StringFormat='{}{0}.'}" />
|
||||||
<Run Text=". Nothing in this keychain has approved this key." />
|
<Run Text="Nothing in this keychain has approved this key." />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
|
|
||||||
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
|
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
|
||||||
@@ -107,8 +131,15 @@
|
|||||||
<!--
|
<!--
|
||||||
The only control on the screen, and it goes back rather than forward. Compare the sheet above,
|
The only control on the screen, and it goes back rather than forward. Compare the sheet above,
|
||||||
where the accent button connects.
|
where the accent button connects.
|
||||||
|
|
||||||
|
◆ IT SAID "BACK TO HOSTS", AND IT NEITHER WENT THERE NOR CAME BACK. Two things were wrong with
|
||||||
|
it at once. RejectHostKey cleared only the pending key, so pressing it left HasHostKeyMismatch
|
||||||
|
set and this opaque panel up over whatever the user navigated to next — including the host editor
|
||||||
|
the paragraph above sends them to. And the shell no longer moves to Hosts to ask the question, so
|
||||||
|
even fixed it does not land there: it lowers this panel and reveals the screen that was already
|
||||||
|
underneath. "BACK" is what that is. See VaultViewModel.RejectHostKey.
|
||||||
-->
|
-->
|
||||||
<Button Classes="secondary" Content="BACK TO HOSTS" Margin="0,24,0,0"
|
<Button Classes="secondary" Content="BACK" Margin="0,24,0,0"
|
||||||
Command="{Binding RejectHostKeyCommand}" />
|
Command="{Binding RejectHostKeyCommand}" />
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,23 +1,26 @@
|
|||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Android.Views;
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
/// <summary>Design 02 — the host list, and the connect bar that replaces the desktop's right column.</summary>
|
/// <summary>Design 02 — the host list, and the two gestures that are the whole of this file.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b>Two gestures on one list, and this file is the whole of the difference between them.</b> A tap on a
|
/// ◆ <b>A tap connects, a long press chooses, and once anything is chosen a tap ticks instead.</b> The
|
||||||
/// host connects to it; a long press asks about it, which is what raises the bar. Why they were split is on
|
/// branch in the middle of that is why the tap is handled here rather than bound in the markup: which of
|
||||||
/// the screen itself; what is here is the mechanics, and there are two of them worth knowing.
|
/// the two a tap means depends on whether the screen is in selection mode, and Avalonia's bindings cannot
|
||||||
|
/// ask.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain <c>Grid</c>.
|
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain
|
||||||
/// A button as the item template swallows the press before the list sees it — <c>FilesScreen</c> writes that
|
/// <c>Border</c>. A button as the item template swallows the press before the list sees it —
|
||||||
/// out at length — leaving nothing selected and every control that reads the selection doing nothing. Both
|
/// <c>FilesScreen</c> writes that out at length — so neither handler would ever run.
|
||||||
/// events fire after the list has moved its selection, which is what lets these read it.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class HostsScreen : UserControl
|
internal sealed partial class HostsScreen : UserControl
|
||||||
@@ -27,18 +30,18 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Avalonia raises <c>Tapped</c> on release whatever the press lasted, so without this a long press
|
/// Avalonia raises <c>Tapped</c> on release whatever the press lasted, so without this a long press
|
||||||
/// would open the bar and then connect — the two gestures firing one after the other on one touch, which
|
/// would tick the row and then the tap it becomes would untick it again — one touch doing a thing and
|
||||||
/// is the one outcome that would make both of them untrustworthy. Set when the hold starts and cleared
|
/// undoing it, which is the one outcome that would make both gestures untrustworthy. Set when the hold
|
||||||
/// by the tap it suppresses, so it never survives the gesture that set it.
|
/// starts and cleared by the tap it suppresses, so it never survives the gesture that set it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private bool held;
|
private bool held;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The long press is attached here rather than in the markup so that it sits beside the property that
|
/// Both gestures are attached here rather than in the markup so that they sit beside the property that
|
||||||
/// makes it fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than assumed:
|
/// makes one of them fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than
|
||||||
/// it is the whole of the gesture, and a default that changed would take it away silently — every tap
|
/// assumed: it is the whole of the long press, and a default that changed would take it away silently —
|
||||||
/// would go on working and nothing would ever open the bar again.
|
/// every tap would go on working and nothing would ever choose a host again.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b><c>FindControl</c> rather than the field the name generator declares for <c>x:Name</c></b>,
|
/// ◆ <b><c>FindControl</c> rather than the field the name generator declares for <c>x:Name</c></b>,
|
||||||
@@ -62,20 +65,20 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Connects to the row that was tapped.
|
/// Connects to the row that was tapped, or ticks it where hosts are already ticked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Guarded on the selection being a host rather than on what was under the finger. A tap on a group
|
/// ◆ <b>The branch is the selection, not the row.</b> Once anything is chosen the screen is in selection
|
||||||
/// heading moves the list's selection and the view model bounces it straight back to whatever was chosen
|
/// mode — the bar across the top says so — and in that mode a tap adds and removes rather than
|
||||||
/// before — see <c>VaultViewModel.SelectedSidebarRow</c> — so reading the selection here answers "a host,
|
/// connecting. That is what every Android list does, and the alternative is worse than a rule to
|
||||||
/// or nothing" without this file needing to know that rule. The cost of getting it wrong is connecting to
|
/// remember: a tap that connected while five machines sat ticked would open a terminal on top of a
|
||||||
/// a machine the user was not pointing at.
|
/// selection somebody was halfway through building.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Fire-and-forget, as the desktop grid's activation is: the command reports its own failures onto the
|
/// Fire-and-forget where it connects, as the desktop grid's activation is: the command reports its own
|
||||||
/// status line — an unknown host key, a refused password — and awaiting it here would be an event handler
|
/// failures onto the status line — an unknown host key, a refused password — and awaiting it here would
|
||||||
/// returning a task nothing observes.
|
/// be an event handler returning a task nothing observes.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnRowTapped(object? sender, TappedEventArgs e)
|
private void OnRowTapped(object? sender, TappedEventArgs e)
|
||||||
@@ -86,25 +89,25 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
|
if (DataContext is not VaultViewModel vault || RowUnder(e) is not { } row)
|
||||||
{
|
{
|
||||||
_ = vault.ConnectToRowCommand.ExecuteAsync(row);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (vault.IsChoosingHosts)
|
||||||
|
{
|
||||||
|
vault.ToggleHostChoiceCommand.Execute(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = vault.ConnectToRowCommand.ExecuteAsync(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Puts a tick against the row that was held, entering selection mode with it.</summary>
|
||||||
/// Raises the bar about the row that was held.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
|
||||||
/// On <see cref="HoldingState.Started"/> rather than on completion, so the bar is up while the finger is
|
/// On <see cref="HoldingState.Started"/> rather than on completion, so the bar is up while the finger is
|
||||||
/// still down. A long press that showed nothing until release would be a gesture with no way to tell it
|
/// still down. A long press that showed nothing until release would be a gesture with no way to tell it
|
||||||
/// had been recognised, and the only feedback available on this list is the thing it does.
|
/// had been recognised, and the only feedback available on this list is the thing it does.
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// It goes through <c>OpenHostPaneCommand</c>, which is the desktop's own "ask about this host" — the two
|
|
||||||
/// heads raise different furniture from one flag rather than keeping a selection rule each.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
|
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -115,9 +118,33 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
|
|
||||||
held = true;
|
held = true;
|
||||||
|
|
||||||
if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
|
if (DataContext is VaultViewModel vault && RowUnder(e) is { } row)
|
||||||
{
|
{
|
||||||
vault.OpenHostPaneCommand.Execute(row);
|
vault.ChooseHostCommand.Execute(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The host the gesture landed on, or null where it landed on something that is not one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Read off the element under the finger rather than off the list's selection, which is what both
|
||||||
|
/// handlers used to do.</b> The selection was defensible while a tap only ever connected: a tap on a
|
||||||
|
/// group heading moves the selection and the view model bounces it straight back — see
|
||||||
|
/// <c>VaultViewModel.SelectedSidebarRow</c> — so reading it answered "a host, or nothing" for free.
|
||||||
|
/// It stops being defensible the moment a tap can tick one: a heading would then bounce the selection to
|
||||||
|
/// whichever host was last chosen and tick <em>that</em>, which is a machine the user was not pointing
|
||||||
|
/// at going into a set they are about to delete.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The ancestor rather than <c>e.Source</c> itself, because the source is whatever leaf the finger
|
||||||
|
/// landed on — a tag chip's <c>TextBlock</c> has a string for a data context, and the row's own
|
||||||
|
/// <c>Ellipse</c> has the row. The <see cref="ListBoxItem"/> is the one element in that chain whose data
|
||||||
|
/// context is always the list's item, whatever kind it is.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static HostRowViewModel? RowUnder(RoutedEventArgs e) =>
|
||||||
|
(e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true)?.DataContext
|
||||||
|
as HostRowViewModel;
|
||||||
}
|
}
|
||||||
@@ -149,8 +149,19 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Button Grid.Column="2" Classes="danger" Height="44" Width="104" Content="DELETE"
|
<!--
|
||||||
Command="{Binding DeleteSelectedItemCommand}" />
|
MOVE beside it, and only where there is somewhere to move to — the rule the host's MOVE follows on
|
||||||
|
this head, for the reason a phone has: there is no room to draw a button that answers with a
|
||||||
|
refusal. It is the ghost of the pair rather than the danger one, because a move is undone by
|
||||||
|
moving it back.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="secondary" Height="44" Width="86" Content="MOVE"
|
||||||
|
IsVisible="{Binding CanMoveSelectedItem}"
|
||||||
|
Command="{Binding MoveSelectedItemCommand}" />
|
||||||
|
<Button Classes="danger" Height="44" Width="104" Content="DELETE"
|
||||||
|
Command="{Binding DeleteSelectedItemCommand}" />
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -180,6 +191,47 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ ◆ moving it to another vault ============ -->
|
||||||
|
<!--
|
||||||
|
The desktop's panel, in the place the deletion question uses and never at the same time as it: MOVE
|
||||||
|
disarms a pending deletion on the way in, and the buttons that ask either question are hidden while
|
||||||
|
one is up.
|
||||||
|
|
||||||
|
Both sentences are here rather than only in the status line afterwards, which on a phone is one line at
|
||||||
|
the bottom of a screen somebody has already navigated away from. The second one is the count of what
|
||||||
|
points at this key — every one of them is re-aimed at the vault it moves to, and that is the part
|
||||||
|
nobody can see from a keychain row.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="3" IsVisible="{Binding IsMovingItem}" Margin="12,4"
|
||||||
|
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||||
|
BorderThickness="1" CornerRadius="12" Padding="14,12">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="label" Text="MOVE TO VAULT" />
|
||||||
|
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Text="{Binding MovingItemLabel}" />
|
||||||
|
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
|
||||||
|
ItemsSource="{Binding MoveItemVaultChoices}"
|
||||||
|
SelectedItem="{Binding SelectedMoveItemVault}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||||
|
Text="It is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody in the vault it leaves can." />
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding HasMovingItemUsage}"
|
||||||
|
Text="{Binding MovingItemUsage}" />
|
||||||
|
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||||
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||||
|
Command="{Binding ConfirmMoveItemCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||||
|
Command="{Binding CancelMoveItemCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- ============ the items ============ -->
|
<!-- ============ the items ============ -->
|
||||||
<Panel Grid.Row="4">
|
<Panel Grid.Row="4">
|
||||||
|
|
||||||
@@ -235,7 +287,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<!-- What is known *about* an item, never the secret. -->
|
<!-- What is known *about* an item, never the secret. -->
|
||||||
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
|
<TextBlock Classes="detail" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||||
|
|||||||
@@ -91,8 +91,8 @@
|
|||||||
|
|
||||||
Not Border.card, and neither are its two counterparts on HOSTS and FILES: that class is a panel,
|
Not Border.card, and neither are its two counterparts on HOSTS and FILES: that class is a panel,
|
||||||
and this is a warning, so the background and the border are the warn pair rather than the chrome
|
and this is a warning, so the background and the border are the warn pair rather than the chrome
|
||||||
one. What it does take is the radius the v2 ladder gives anything card-sized, which is what the
|
one. What it does take is the radius the ladder gives anything card-sized — 12, unchanged from v2
|
||||||
other two already draw.
|
to v5 — which is what the other two already draw.
|
||||||
-->
|
-->
|
||||||
<Border IsVisible="{Binding HasLiveSessions}" Margin="0,22,0,0"
|
<Border IsVisible="{Binding HasLiveSessions}" Margin="0,22,0,0"
|
||||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||||
|
|||||||
@@ -106,11 +106,11 @@
|
|||||||
|
|
||||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
|
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
|
||||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
||||||
Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
|
Text="{Binding Address}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
TextDim rather than the `detail` class's own faint step. This block is drawn on
|
TextDim rather than the `detail` class's own faint step. This block is drawn on
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||||
Text="·" />
|
Text="·" />
|
||||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||||
Text="{Binding DeviceName}" TextTrimming="CharacterEllipsis" />
|
Text="{Binding DeviceName}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- A refused host key is the one outcome worth a colour of its own. -->
|
<!-- A refused host key is the one outcome worth a colour of its own. -->
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
||||||
Text="{Binding ItemLabel}" TextTrimming="CharacterEllipsis" />
|
Text="{Binding ItemLabel}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
<!-- The names of the fields that changed, never their values. -->
|
<!-- The names of the fields that changed, never their values. -->
|
||||||
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasChangedFields}"
|
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasChangedFields}"
|
||||||
|
|||||||
@@ -36,6 +36,18 @@
|
|||||||
The .railentry styles live in this file because they are this control's own shape and nothing else wears
|
The .railentry styles live in this file because they are this control's own shape and nothing else wears
|
||||||
them; every colour in them is resolved from Theme/Palette.axaml by key, which is the rule that matters —
|
them; every colour in them is resolved from Theme/Palette.axaml by key, which is the rule that matters —
|
||||||
a rail that named its own blues is how a fifth surface colour ends up in the application.
|
a rail that named its own blues is how a fifth surface colour ends up in the application.
|
||||||
|
|
||||||
|
── DEEP CHROME, SINCE v5 ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
This rail's own surface moves from Sidebar to DeepChrome in this pass, matching the desktop's NavRail —
|
||||||
|
which is exactly what this control is standing in for on a wide phone, and which took the same v5b move
|
||||||
|
itself: Palette.axaml's own remark on DeepChrome names "the rail down the side" as one of the five things
|
||||||
|
that colour is for. Sidebar stays what a card sits on; this is the frame the cards sit inside, the same
|
||||||
|
distinction PhoneShell's header, strip and bottom bar are drawing at the same time.
|
||||||
|
|
||||||
|
The hover fill moves too, from Panel to Track — and Track rather than Hover, because Palette.axaml's own
|
||||||
|
remark on Track names "the rail row a pointer sits over" as one of the three things that colour is for,
|
||||||
|
literally. Panel was never named for this; it read close enough that nobody had gone looking for the
|
||||||
|
seam, which is exactly the kind of gap Track exists to close.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
@@ -54,7 +66,7 @@
|
|||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.railentry:pointerover /template/ ContentPresenter">
|
<Style Selector="Button.railentry:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.railentry.active /template/ ContentPresenter">
|
<Style Selector="Button.railentry.active /template/ ContentPresenter">
|
||||||
@@ -96,7 +108,7 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Border Width="188" Background="{StaticResource Sidebar}"
|
<Border Width="188" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -134,15 +146,17 @@
|
|||||||
|
|
||||||
<!--
|
<!--
|
||||||
The terminal is a surface rather than a page, so it goes through its own command — the same
|
The terminal is a surface rather than a page, so it goes through its own command — the same
|
||||||
reason the bottom bar's middle entry does. It is never lit, and deliberately: this rail is
|
reason the bottom bar's middle entry does. It lights on IsTerminalSurface, which on this control
|
||||||
collapsed while a shell is showing, so IsCurrent could only ever read false. What marks that
|
can only mean one thing: the rail stands down for a shell, so the one moment it is drawn beside
|
||||||
destination as current is the surface filling the screen.
|
this surface is Connections with nothing running. That screen is a page in everything but which
|
||||||
|
enum it is in, and a rail sitting beside it with no entry lit would be pointing nowhere.
|
||||||
|
|
||||||
No count, unlike the two above it. The shells strip sits above the bottom of every page on this
|
No count, unlike the two above it. The shells strip sits above the bottom of every page on this
|
||||||
surface and is that count, drawn as the sessions themselves — a number beside this word would be
|
surface and is that count, drawn as the sessions themselves — a number beside this word would be
|
||||||
the same fact said twice, three inches apart.
|
the same fact said twice, three inches apart.
|
||||||
-->
|
-->
|
||||||
<Button Classes="railentry" Command="{Binding ShowTerminalCommand}">
|
<Button Classes="railentry" Classes.active="{Binding IsTerminalSurface}"
|
||||||
|
Command="{Binding ShowTerminalCommand}">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
|
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
|
||||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
|
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
|
||||||
|
|||||||
@@ -25,8 +25,9 @@
|
|||||||
|
|
||||||
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
|
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
|
||||||
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
|
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
|
||||||
bottom bar itself. All three are bound on IsShowingPages, which is the same question asked once — the
|
bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
|
||||||
surface is either a page or a terminal, and these are the chrome a page has.
|
computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which
|
||||||
|
is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome.
|
||||||
|
|
||||||
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
|
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
|
||||||
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
|
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
|
||||||
@@ -96,9 +97,18 @@
|
|||||||
the surface. See PhoneShell.ShowsVaultHeader.
|
the surface. See PhoneShell.ShowsVaultHeader.
|
||||||
-->
|
-->
|
||||||
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
|
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
|
||||||
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
<!--
|
||||||
|
◆ DeepChrome rather than Chrome, since v5. The desktop's own titlebar and nav rail made the same
|
||||||
|
move in v5b — one step darker than Chrome, #0B0B14 against #10111E, so the frame reads as what
|
||||||
|
holds the glass rather than as another pane of it — and this header is the phone's equivalent
|
||||||
|
furniture: it is what the desktop's titlebar is, on the surface that has no window to carry one.
|
||||||
|
HostActionBar, which takes this header's own place while hosts are selected, and the editor's
|
||||||
|
header in HostsScreen, which the header stands down for, both move with it for the same reason;
|
||||||
|
see the remark on each.
|
||||||
|
-->
|
||||||
|
<Border Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||||
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
|
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
|
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
|
||||||
@@ -127,30 +137,18 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ THE PENCIL, and it is here rather than on the row for the reason the whole gesture split
|
◆ THE PENCIL WAS HERE, AND IT MOVED INTO THE ACTION BAR.
|
||||||
happened: choosing a host on this head no longer raises a bar carrying EDIT, because that bar
|
|
||||||
was five controls over the bottom of the list in the way of a tap that means "connect". EDIT is
|
|
||||||
the one of the five common enough to be worth a control that is always in the same place, so it
|
|
||||||
is in the header — the phone's only piece of persistent chrome — and the long press still
|
|
||||||
reaches the other four. See HostsScreen.axaml.
|
|
||||||
|
|
||||||
Two conditions, nested rather than combined, because Avalonia's bindings have no "and" and the
|
It was in the header because a long press raised a connect card carrying EDIT, and a card over
|
||||||
two belong to different view models: which screen is showing is the shell's question, and
|
the bottom of the list was the wrong place for the one action people reach for most. Both
|
||||||
whether there is a host to edit is the vault's. That is the same arrangement the header itself
|
halves of that changed at once: a long press now chooses hosts rather than raising a card, and
|
||||||
is wrapped in one level up.
|
the bar it raises takes this header's place — so the pencil is in that bar, beside the count of
|
||||||
|
what it would edit. See HostActionBar.axaml, which is drawn in the Panel below this one.
|
||||||
|
|
||||||
Collapsed rather than disabled when there is nothing chosen. A greyed pencil sitting beside the
|
What is left in this row is the vault's name, the sync light and LOCK: three facts about the
|
||||||
vault's name on every screen would be a permanent reminder of a control that is only ever about
|
keychain, and none of them about a row.
|
||||||
one row.
|
|
||||||
-->
|
-->
|
||||||
<Panel Grid.Column="3" IsVisible="{Binding IsHostsShowing}">
|
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
||||||
<Button Classes="icon" Content="✎" FontSize="15" Margin="4,0,0,0"
|
|
||||||
IsVisible="{Binding Vault.CanEditSelectedHost}"
|
|
||||||
Command="{Binding Vault.EditSelectedHostCommand}"
|
|
||||||
ToolTip.Tip="Edit the selected host" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Button Grid.Column="4" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
|
||||||
ToolTip.Tip="Lock the keychain">
|
ToolTip.Tip="Lock the keychain">
|
||||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
||||||
Foreground="{StaticResource TextDim}" />
|
Foreground="{StaticResource TextDim}" />
|
||||||
@@ -159,6 +157,21 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ ◆ the contextual action bar ============ -->
|
||||||
|
<!--
|
||||||
|
In the header's row and in its place, never beside it. A bar that stacked under the vault's name
|
||||||
|
would be two rows of chrome above a list at 360dp, and worse, it would leave the keychain's name and
|
||||||
|
LOCK on screen over a list that is no longer about the keychain — see PhoneShell.ShowsHostSelectionBar,
|
||||||
|
which is where the two are made exclusive.
|
||||||
|
|
||||||
|
Wrapped so the data context can be the vault, as every other screen in this file is: what the bar
|
||||||
|
counts and what its four controls do are one vault's business, and the shell's own binding would
|
||||||
|
resolve none of them.
|
||||||
|
-->
|
||||||
|
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsHostSelectionBar}">
|
||||||
|
<views:HostActionBar DataContext="{Binding Vault}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- The screens. Only one draws; which one is the shell's business. -->
|
<!-- The screens. Only one draws; which one is the shell's business. -->
|
||||||
<Panel Grid.Row="1">
|
<Panel Grid.Row="1">
|
||||||
<!--
|
<!--
|
||||||
@@ -252,6 +265,12 @@
|
|||||||
◆ The two host-key decisions, over everything. Outside the screen Panel deliberately: file transfer
|
◆ The two host-key decisions, over everything. Outside the screen Panel deliberately: file transfer
|
||||||
makes its own trust decision on the desktop, and the phone now has that screen — so a sheet nested
|
makes its own trust decision on the desktop, and the phone now has that screen — so a sheet nested
|
||||||
inside the hosts screen would be one the files screen could not raise.
|
inside the hosts screen would be one the files screen could not raise.
|
||||||
|
|
||||||
|
◆ And that arrangement is what the shell now relies on rather than merely permits. It used to move to
|
||||||
|
HOSTS before letting a handshake ask the question, because the desktop head drew it as a banner on that
|
||||||
|
screen; it does not any more, so this sheet is what is over the Connections surface when a machine
|
||||||
|
typed into the connect box by hand turns out to be a first contact. Which is the case the navigation
|
||||||
|
was worst for — it took the box away. See MainWindowViewModel.OnVaultConnectionFailed.
|
||||||
-->
|
-->
|
||||||
<Panel Grid.Row="1">
|
<Panel Grid.Row="1">
|
||||||
<views:HostKeySheet DataContext="{Binding Vault}" />
|
<views:HostKeySheet DataContext="{Binding Vault}" />
|
||||||
@@ -271,9 +290,17 @@
|
|||||||
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
|
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
|
||||||
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
|
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
|
||||||
whether there are any tabs and this one is about which surface is up.
|
whether there are any tabs and this one is about which surface is up.
|
||||||
|
|
||||||
|
◆ And it stands down under the host editor, which is a page rather than a card since the pencil moved
|
||||||
|
into the action bar. A strip of open shells above a form is the same two-rows-of-chrome problem the
|
||||||
|
terminal has, on a screen where the form is the whole point of being there. See
|
||||||
|
PhoneShell.ShowsShellStrip, which is where that "and" is made, Avalonia's bindings having none.
|
||||||
-->
|
-->
|
||||||
<Panel Grid.Row="2" IsVisible="{Binding IsShowingPages}">
|
<Panel Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsShellStrip}">
|
||||||
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
|
<!-- DeepChrome rather than Sidebar, since v5, joining the header and the bar above and below it:
|
||||||
|
the strip is chrome the same way they are — a frame around the screen rather than a pane of
|
||||||
|
it — and Sidebar is what a card sits on, which this row is not. -->
|
||||||
|
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
||||||
@@ -282,7 +309,7 @@
|
|||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||||
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="9"
|
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="10"
|
||||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||||
@@ -293,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>
|
||||||
@@ -308,10 +341,15 @@
|
|||||||
|
|
||||||
<!-- ============ navigation ============ -->
|
<!-- ============ navigation ============ -->
|
||||||
<!--
|
<!--
|
||||||
Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's three
|
Gone while a shell is showing, which is the whole of that surface's arrangement: the bar's three
|
||||||
destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's
|
destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's
|
||||||
own bar. See TerminalScreen.axaml.
|
own bar. See TerminalScreen.axaml.
|
||||||
|
|
||||||
|
◆ A shell rather than the surface, which are not the same question — Connections with nothing running
|
||||||
|
is that surface drawing a page, and the bar stays under it. The screen it stands down for is the one
|
||||||
|
that pays for the room; a box asking which machine to connect to does not, and taking the nav away
|
||||||
|
from somebody who has just closed their last tab left them with only the back gesture.
|
||||||
|
|
||||||
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
|
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
|
||||||
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
|
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
|
||||||
|
|
||||||
@@ -321,8 +359,10 @@
|
|||||||
shape of everything else already behind the hub. What is left is the two halves of using this
|
shape of everything else already behind the hub. What is left is the two halves of using this
|
||||||
application, and the drawer holding the rest.
|
application, and the drawer holding the rest.
|
||||||
-->
|
-->
|
||||||
|
<!-- DeepChrome rather than Chrome, since v5 — see the remark on the vault header, above, which this
|
||||||
|
bar is the foot of the same frame the header is the top of. -->
|
||||||
<Border Grid.Row="3" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
|
<Border Grid.Row="3" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
|
||||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||||
BorderThickness="0,1,0,0" Height="64">
|
BorderThickness="0,1,0,0" Height="64">
|
||||||
<Grid ColumnDefinitions="*,*,*">
|
<Grid ColumnDefinitions="*,*,*">
|
||||||
|
|
||||||
@@ -340,12 +380,15 @@
|
|||||||
rather than the door. The enum member stays ShellSurface.Terminal, for the reason the keychain's
|
rather than the door. The enum member stays ShellSurface.Terminal, for the reason the keychain's
|
||||||
tab is not called Vault: the surface is a terminal, and the word a user reads is the product's.
|
tab is not called Vault: the surface is a terminal, and the word a user reads is the product's.
|
||||||
|
|
||||||
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed
|
◆ It lights again, and IsTerminalSurface is the right question rather than a near miss. This bar
|
||||||
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
|
is drawn in exactly two situations — a page, and Connections with nothing running — and that flag
|
||||||
would be a rule about a state this control cannot be in. What marks it as current is the surface
|
separates them: false for every page, true for the one screen where this is where you are. With a
|
||||||
filling the screen.
|
shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was
|
||||||
|
collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the
|
||||||
|
bar can now sit under and not point at is the entry looking broken instead.
|
||||||
-->
|
-->
|
||||||
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
|
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
|
||||||
|
IsCurrent="{Binding IsTerminalSurface}"
|
||||||
Command="{Binding ShowTerminalCommand}" />
|
Command="{Binding ShowTerminalCommand}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
{
|
{
|
||||||
private MainWindowViewModel? shell;
|
private MainWindowViewModel? shell;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The open vault, while there is one, so that this control hears about the hosts screen's own state.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>A second subscription, and it is the price of the header being swappable.</b> Two of the flags
|
||||||
|
/// below are questions about the vault rather than about the shell — whether hosts are ticked, and
|
||||||
|
/// whether the host editor is filling the screen — and the shell does not forward the vault's
|
||||||
|
/// notifications. Kept in step from <see cref="OnShellChanged"/>, because <c>Vault</c> is replaced on
|
||||||
|
/// every unlock and nulled on every lock; a handler left on a disposed vault would keep it alive.
|
||||||
|
/// </remarks>
|
||||||
|
private VaultViewModel? vault;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Everything the phone draws, which is the element the software keyboard is kept off.
|
/// Everything the phone draws, which is the element the software keyboard is kept off.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -90,10 +102,40 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
TryOfferDeviceUnlock();
|
TryOfferDeviceUnlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FollowTheVault();
|
||||||
RefreshChrome();
|
RefreshChrome();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves this control's second subscription onto whichever vault is open now.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared before being swapped, so that the ordinary case — a shell notification about something else
|
||||||
|
/// entirely — costs one reference comparison rather than an unsubscribe and a resubscribe per property
|
||||||
|
/// change on the shell.
|
||||||
|
/// </remarks>
|
||||||
|
private void FollowTheVault()
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(vault, shell?.Vault))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vault is not null)
|
||||||
|
{
|
||||||
|
vault.PropertyChanged -= OnVaultChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
vault = shell?.Vault;
|
||||||
|
|
||||||
|
if (vault is not null)
|
||||||
|
{
|
||||||
|
vault.PropertyChanged += OnVaultChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnVaultChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
|
=> RefreshChrome();
|
||||||
|
|
||||||
/// <summary>Whether this surface is wide enough to be laid out like the desktop.</summary>
|
/// <summary>Whether this surface is wide enough to be laid out like the desktop.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A property of the control rather than of the view model, because it is a fact about the surface and
|
/// A property of the control rather than of the view model, because it is a fact about the surface and
|
||||||
@@ -104,10 +146,19 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
|
||||||
|
|
||||||
/// <summary>Whether the rail down the left edge is drawn.</summary>
|
/// <summary>Whether the rail down the left edge is drawn.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The wide surface's answer to <see cref="ShowsBottomBarProperty"/> and asks the same question about
|
||||||
|
/// which screen is up, so the two move together — including over Connections with nothing running. See
|
||||||
|
/// <see cref="RefreshChrome"/>.
|
||||||
|
/// </remarks>
|
||||||
public static readonly StyledProperty<bool> ShowsRailProperty =
|
public static readonly StyledProperty<bool> ShowsRailProperty =
|
||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
|
||||||
|
|
||||||
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
|
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Not simply the pages: it also stays up on Connections with nothing running, which is the terminal
|
||||||
|
/// surface drawing a page. See <see cref="RefreshChrome"/>.
|
||||||
|
/// </remarks>
|
||||||
public static readonly StyledProperty<bool> ShowsBottomBarProperty =
|
public static readonly StyledProperty<bool> ShowsBottomBarProperty =
|
||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
|
||||||
|
|
||||||
@@ -115,6 +166,14 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
|
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
|
||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultHeader));
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultHeader));
|
||||||
|
|
||||||
|
/// <summary>Whether the bar about the chosen hosts is drawn in the header's place.</summary>
|
||||||
|
public static readonly StyledProperty<bool> ShowsHostSelectionBarProperty =
|
||||||
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsHostSelectionBar));
|
||||||
|
|
||||||
|
/// <summary>Whether the strip of open shells above the bottom bar is drawn.</summary>
|
||||||
|
public static readonly StyledProperty<bool> ShowsShellStripProperty =
|
||||||
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsShellStrip));
|
||||||
|
|
||||||
/// <inheritdoc cref="IsWideProperty" />
|
/// <inheritdoc cref="IsWideProperty" />
|
||||||
public bool IsWide
|
public bool IsWide
|
||||||
{
|
{
|
||||||
@@ -143,17 +202,40 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
private set => SetValue(ShowsVaultHeaderProperty, value);
|
private set => SetValue(ShowsVaultHeaderProperty, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ShowsHostSelectionBarProperty" />
|
||||||
|
public bool ShowsHostSelectionBar
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsHostSelectionBarProperty);
|
||||||
|
private set => SetValue(ShowsHostSelectionBarProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ShowsShellStripProperty" />
|
||||||
|
public bool ShowsShellStrip
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsShellStripProperty);
|
||||||
|
private set => SetValue(ShowsShellStripProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Works out which chrome this surface should be wearing.
|
/// Works out which chrome this surface should be wearing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b>Three flags computed here rather than three conditions in the markup, because Avalonia's
|
/// ◆ <b>Five flags computed here rather than five conditions in the markup, because Avalonia's
|
||||||
/// bindings have no "and" and none of these is a single question any more.</b> Everywhere else on this
|
/// bindings have no "and" and none of these is a single question any more.</b> Everywhere else on this
|
||||||
/// head that costs a wrapper element; here it would cost two nested ones per row and the header's would
|
/// head that costs a wrapper element; here it would cost two nested ones per row and the header's would
|
||||||
/// have to be an "or", which a wrapper cannot express at all.
|
/// have to be an "or", which a wrapper cannot express at all.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// ◆ <b>The nav stands down for a shell rather than for the terminal surface, and those parted company
|
||||||
|
/// when that surface gained a page.</b> Connections with nothing running is a box, a CONNECT button and
|
||||||
|
/// the machines connected to before — see TerminalScreen.axaml — and none of that is worth the screen a
|
||||||
|
/// shell is worth it for. It is also the one screen somebody can arrive at by closing their last tab,
|
||||||
|
/// which made the collapsed bar a way to end up with no route to Hosts or Settings but the system back
|
||||||
|
/// gesture. The header is deliberately not part of this: the surface draws its own bar with the back
|
||||||
|
/// arrow and the +, and the vault header above that is the second row this head exists to avoid.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
/// <b>The header is the one worth reading twice.</b> Narrow, it stands down behind SETTINGS, because the
|
/// <b>The header is the one worth reading twice.</b> Narrow, it stands down behind SETTINGS, because the
|
||||||
/// screens under that hub draw their own header with a back arrow and two rows of chrome is what this
|
/// screens under that hub draw their own header with a back arrow and two rows of chrome is what this
|
||||||
/// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail
|
/// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail
|
||||||
@@ -161,7 +243,16 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
/// Losing them on the keychain would be losing the only LOCK button on the surface.
|
/// Losing them on the keychain would be losing the only LOCK button on the surface.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Recomputed on every shell notification rather than on a named list of them. Three boolean
|
/// ◆ <b>The header is now a swap rather than a switch, and the editor takes the whole screen.</b> Two
|
||||||
|
/// more flags and two more inputs, both of them the vault's rather than the shell's — see
|
||||||
|
/// <see cref="vault"/>. While hosts are ticked the header stands down and
|
||||||
|
/// <see cref="ShowsHostSelectionBar"/> puts the action bar in its place, which is what makes that bar
|
||||||
|
/// unambiguous: the screen is about the ticked hosts and nothing else. While the host editor is open it
|
||||||
|
/// is a page rather than a card, so all four rows of chrome stand down and the form has the display —
|
||||||
|
/// which is what "opens in a separate page" means on a 360dp screen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Recomputed on every notification from either object rather than on a named list of them. Five boolean
|
||||||
/// comparisons and no allocation is cheaper than being wrong: the properties this reads are computed
|
/// comparisons and no allocation is cheaper than being wrong: the properties this reads are computed
|
||||||
/// ones, and which of them raise a change is a fact about a file in another project that nothing here
|
/// ones, and which of them raise a change is a fact about a file in another project that nothing here
|
||||||
/// would notice going stale.
|
/// would notice going stale.
|
||||||
@@ -172,6 +263,24 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
var wide = body.Bounds.Width >= WideAt;
|
var wide = body.Bounds.Width >= WideAt;
|
||||||
var pages = shell?.IsShowingPages == true;
|
var pages = shell?.IsShowingPages == true;
|
||||||
|
|
||||||
|
// ◆ Connections with nothing running, which is the terminal surface drawing a page: a box, a CONNECT
|
||||||
|
// button and the machines connected to before. The nav stands down for a shell — the whole of the
|
||||||
|
// arrangement below — and there is no shell here to stand down for, so it stays. Taking it away on
|
||||||
|
// this one screen was worst where it was least affordable: somebody who has just closed their last
|
||||||
|
// tab, or who pressed Connections to see what was open and found nothing, was left on a screen whose
|
||||||
|
// only way to Hosts or Settings was the system back gesture.
|
||||||
|
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
|
||||||
|
|
||||||
|
// The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
|
||||||
|
// not the shells strip, and not the way off the screen. Its own header carries the back arrow, which
|
||||||
|
// is the one control it needs and the one the system gesture already maps to.
|
||||||
|
var editing = vault?.IsEditing == true;
|
||||||
|
|
||||||
|
// Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by
|
||||||
|
// navigating — and a bar counting hosts over the transfers screen would be chrome about a list that
|
||||||
|
// is not on the display.
|
||||||
|
var choosing = vault?.IsChoosingHosts == true && shell?.IsHostsShowing == true;
|
||||||
|
|
||||||
// Before the flags, because it changes what one of them reads. Nothing else on this head navigates
|
// Before the flags, because it changes what one of them reads. Nothing else on this head navigates
|
||||||
// in response to a resize, and this is not navigation for its own sake: the hub is a list of the
|
// in response to a resize, and this is not navigation for its own sake: the hub is a list of the
|
||||||
// destinations the rail now carries, so an unfolded device would otherwise sit on a menu of things
|
// destinations the rail now carries, so an unfolded device would otherwise sit on a menu of things
|
||||||
@@ -183,9 +292,11 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
IsWide = wide;
|
IsWide = wide;
|
||||||
ShowsRail = wide && pages;
|
ShowsRail = wide && (pages || connectPage) && !editing;
|
||||||
ShowsBottomBar = !wide && pages;
|
ShowsBottomBar = !wide && (pages || connectPage) && !editing;
|
||||||
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
|
ShowsShellStrip = pages && !editing;
|
||||||
|
ShowsHostSelectionBar = pages && choosing && !editing;
|
||||||
|
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
@@ -195,6 +306,7 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FollowTheVault();
|
||||||
RefreshChrome();
|
RefreshChrome();
|
||||||
|
|
||||||
if (e.PropertyName is nameof(MainWindowViewModel.State))
|
if (e.PropertyName is nameof(MainWindowViewModel.State))
|
||||||
@@ -279,11 +391,15 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
ApplyKeyboardInset(keyboard);
|
ApplyKeyboardInset(keyboard);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PhoneEnvironment.Resumed += OnActivityResumed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
|
PhoneEnvironment.Resumed -= OnActivityResumed;
|
||||||
|
|
||||||
if (TopLevel.GetTopLevel(this) is { } top)
|
if (TopLevel.GetTopLevel(this) is { } top)
|
||||||
{
|
{
|
||||||
top.BackRequested -= OnBackRequested;
|
top.BackRequested -= OnBackRequested;
|
||||||
@@ -298,6 +414,43 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
base.OnDetachedFromVisualTree(e);
|
base.OnDetachedFromVisualTree(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-reads the keyboard inset on the way back into the application.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The margin can be stale here and there is no other moment it gets corrected.</b> Android takes
|
||||||
|
/// the software keyboard away when this activity stops, and Avalonia's input pane raises no
|
||||||
|
/// <c>StateChanged</c> for it — the pane is gone with the window rather than closed in front of one. So
|
||||||
|
/// <see cref="ApplyKeyboardInset(double)"/> last ran for a keyboard that was up, and the interface comes
|
||||||
|
/// back holding a whole keyboard's worth of empty space it no longer needs. It is not subtle and it does
|
||||||
|
/// not recover: a third of the display, on every screen, until something opens and closes the keyboard.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asking the pane rather than assuming zero, because resuming does not always mean the keyboard went.
|
||||||
|
/// A permission dialogue, the biometric sheet and the sign-in browser all pause this activity and can
|
||||||
|
/// all hand it back with the keyboard exactly as it was — and a handler that flattened the inset on the
|
||||||
|
/// way in would then strand the focused box under a keyboard that is genuinely there. The pane knows
|
||||||
|
/// which of the two happened; this only has to ask it again.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Posted rather than run inline. <c>OnResume</c> is early — the window has not necessarily been laid
|
||||||
|
/// out again and the pane's own state is settled by the insets that arrive with the next frame, so
|
||||||
|
/// reading it on this turn can return the state that is on the way out. Background priority puts the
|
||||||
|
/// read after that.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnActivityResumed(object? sender, EventArgs e) =>
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (keyboard is not null)
|
||||||
|
{
|
||||||
|
ApplyKeyboardInset(keyboard);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DispatcherPriority.Background);
|
||||||
|
|
||||||
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
||||||
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
|
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
|
||||||
|
|
||||||
@@ -500,16 +653,23 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
|
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Order is the whole of it. The two sheets sit over the list and the two editors sit in place of it, so
|
/// Order is the whole of it. The sheets sit over the list, the panels sit above it and the editors sit
|
||||||
/// a sheet has to go first — closing an editor while a sheet was open would leave the sheet floating
|
/// in place of it, so a sheet has to go first — closing an editor while a sheet was open would leave the
|
||||||
/// over a list nobody asked to see, and the second back would then close the sheet rather than the
|
/// sheet floating over a list nobody asked to see, and the second back would then close the sheet rather
|
||||||
/// editor the user was looking at.
|
/// than the editor the user was looking at.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The editors are cancelled rather than merely hidden. Cancelling is what clears the boxes, and the
|
/// The editors are cancelled rather than merely hidden. Cancelling is what clears the boxes, and the
|
||||||
/// host editor's boxes are the ones worth clearing: leaving a half-typed hostname behind would have the
|
/// host editor's boxes are the ones worth clearing: leaving a half-typed hostname behind would have the
|
||||||
/// next NEW HOST open on somebody else's abandoned draft.
|
/// next NEW HOST open on somebody else's abandoned draft.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Selection mode is last and is still a thing back has to spend itself on.</b> It is a mode rather
|
||||||
|
/// than a surface — the list underneath is fully drawn and the only sign of it is the bar across the top
|
||||||
|
/// — and a gesture that left the application from it would take somebody out of the app because they had
|
||||||
|
/// held a row down. Its panels go before it, in the order they are stacked: the picker or the question is
|
||||||
|
/// what the user is looking at, and the ticks underneath are what it is about.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
|
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
|
||||||
{
|
{
|
||||||
@@ -518,22 +678,71 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return TryLowerASheet(vault) || TryCloseSomethingBehindTheSheets(vault);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lowers the nearest of the four sheets, which are what sits over everything else.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The four cannot be open at once — each is raised from a control the others hide — so their order
|
||||||
|
/// between themselves decides nothing. What matters is that all of them come before the panels and the
|
||||||
|
/// editors underneath: closing an editor while a sheet was open would leave the sheet floating over a
|
||||||
|
/// list nobody asked to see.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool TryLowerASheet(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
// ◆ The action bar's own menu, first of the four because it is raised from chrome that is already
|
||||||
|
// over everything else.
|
||||||
|
if (vault.IsHostActionSheetOpen)
|
||||||
|
{
|
||||||
|
vault.CloseHostActionSheetCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The password sheet, which is what a tap on a machine that wants one raises. Cancelled rather than
|
||||||
|
// hidden, because cancelling is what empties the box — see VaultViewModel.CancelConnectPassword.
|
||||||
|
if (vault.IsAskingForConnectPassword)
|
||||||
|
{
|
||||||
|
vault.CancelConnectPasswordCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (vault.IsAddSheetOpen)
|
if (vault.IsAddSheetOpen)
|
||||||
{
|
{
|
||||||
vault.CloseAddSheetCommand.Execute(null);
|
vault.CloseAddSheetCommand.Execute(null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The other sheet, and it is checked beside the first rather than after the editors for the same
|
|
||||||
// reason: it is raised over the list, so it is the nearest thing on screen. The two cannot be open
|
|
||||||
// at once — one is raised by the +, the other by a heading, and each hides the list the other's
|
|
||||||
// control is on — so their order between themselves decides nothing.
|
|
||||||
if (vault.GroupSheet is not null)
|
if (vault.GroupSheet is not null)
|
||||||
{
|
{
|
||||||
vault.CloseGroupSheetCommand.Execute(null);
|
vault.CloseGroupSheetCommand.Execute(null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Closes the nearest of the panels, the editors and selection mode itself.</summary>
|
||||||
|
/// <inheritdoc cref="TryCloseAnOpenEditor" path="/remarks" />
|
||||||
|
private static bool TryCloseSomethingBehindTheSheets(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
if (vault.IsSendingChosenHostsToAVault)
|
||||||
|
{
|
||||||
|
vault.CancelSendChosenHostsToAVaultCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vault.IsRegroupingChosenHosts)
|
||||||
|
{
|
||||||
|
vault.CancelRegroupChosenHostsCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vault.IsConfirmingChosenHostDeletion)
|
||||||
|
{
|
||||||
|
vault.CancelDeleteCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (vault.IsEditing)
|
if (vault.IsEditing)
|
||||||
{
|
{
|
||||||
vault.CancelEditCommand.Execute(null);
|
vault.CancelEditCommand.Execute(null);
|
||||||
@@ -546,6 +755,12 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (vault.IsChoosingHosts)
|
||||||
|
{
|
||||||
|
vault.ClearHostChoiceCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,8 @@
|
|||||||
Text="Text size is on the terminal itself, where the text is — the phone puts A− and A+ in the shell's own bar rather than a step away from what they resize." />
|
Text="Text size is on the terminal itself, where the text is — the phone puts A− and A+ in the shell's own bar rather than a step away from what they resize." />
|
||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." />
|
Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." />
|
||||||
|
<TextBlock Classes="body"
|
||||||
|
Text="Importing a Hetzner Cloud project would work here — it is a token and a network request, not a file — and the wizard behind it is already shared. What is missing is a phone layout for it: the desktop draws a five-column table, and the screen it would become is not the desktop's with narrower columns. Until then it is a thing to do on a desktop; the hosts it makes sync here like any other." />
|
||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
|
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -36,8 +36,16 @@
|
|||||||
good way to read a code and a bad way to copy one: a user who cannot select it will photograph the
|
good way to read a code and a bad way to copy one: a user who cannot select it will photograph the
|
||||||
screen, and that is a worse home for it than their clipboard.
|
screen, and that is a worse home for it than their clipboard.
|
||||||
-->
|
-->
|
||||||
|
<!--
|
||||||
|
◆ CornerRadius 12, not 6. Its padding, 14,13, is Border.card's own — this box was already drawn to a
|
||||||
|
card's proportions, in every dimension but the corner — so under v2's ladder 6 was an outlier this
|
||||||
|
file never explained. It is worse than unexplained now: v5's ladder gives 6 to a chip, and a
|
||||||
|
full-width box holding the one secret this whole screen exists to show is not a chip wearing a
|
||||||
|
card's padding by coincidence. 12 is what a card-shaped box gets on this ladder, and this one always
|
||||||
|
was one.
|
||||||
|
-->
|
||||||
<Border Margin="0,20,0,0" Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
<Border Margin="0,20,0,0" Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||||
BorderThickness="1" CornerRadius="6" Padding="14,13">
|
BorderThickness="1" CornerRadius="12" Padding="14,13">
|
||||||
<SelectableTextBlock Text="{Binding RecoveryCode}"
|
<SelectableTextBlock Text="{Binding RecoveryCode}"
|
||||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||||
Foreground="{StaticResource Accent}"
|
Foreground="{StaticResource Accent}"
|
||||||
|
|||||||
@@ -20,9 +20,17 @@
|
|||||||
<StackPanel Spacing="10" HorizontalAlignment="Center" Margin="0,48,0,36">
|
<StackPanel Spacing="10" HorizontalAlignment="Center" Margin="0,48,0,36">
|
||||||
<!--
|
<!--
|
||||||
Filled rather than outlined since v2, the same mark the header, the desktop titlebar and the
|
Filled rather than outlined since v2, the same mark the header, the desktop titlebar and the
|
||||||
launcher icon carry. The radius is not picked: the mark runs 6 at 20 and 8 at 26, which is a
|
launcher icon carry. The radius is not picked: the mark runs 6 at 20 and 8 at 26, a third of a
|
||||||
third of a unit per unit of tile and lands on 14 at 44 — and 14 is a rung of Phone.axaml's
|
unit per unit of tile, and lands on 14 at 44.
|
||||||
ladder, the one for a block of monospaced output, which is what this is.
|
|
||||||
|
◆ That used to be a rung of Phone.axaml's own ladder too — 14, the one for a block of monospaced
|
||||||
|
output, which this glyph reads as — and the coincidence was worth a sentence: one fewer number
|
||||||
|
the ladder had to introduce. It is a coincidence no longer. v5's ladder moves output to 12 and
|
||||||
|
never reaches 14 at all, so the mark's own 6/8/14 progression stands on its own now rather than
|
||||||
|
borrowing a card- or output-shaped justification — a self-contained proportion for a badge that
|
||||||
|
answers to nothing but its own three sizes. Left at 14 rather than moved to 12 with everything
|
||||||
|
else: the ladder governs content surfaces, and this is a mark, sized off its own tile rather than
|
||||||
|
off what it holds.
|
||||||
-->
|
-->
|
||||||
<Border Width="44" Height="44" CornerRadius="14" Background="{StaticResource Accent}"
|
<Border Width="44" Height="44" CornerRadius="14" Background="{StaticResource Accent}"
|
||||||
HorizontalAlignment="Center">
|
HorizontalAlignment="Center">
|
||||||
|
|||||||
@@ -64,6 +64,26 @@
|
|||||||
|
|
||||||
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
|
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ Which vault a *new* snippet is filed into. Hidden for an existing one — its vault is not a
|
||||||
|
field of this form, and changing it is MOVE below — and hidden entirely where there is only one
|
||||||
|
vault to choose between, which is where most people stay.
|
||||||
|
-->
|
||||||
|
<StackPanel Spacing="6" IsVisible="{Binding ShowsEditorVaultChoice}">
|
||||||
|
<TextBlock Classes="label" Text="VAULT" />
|
||||||
|
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
|
||||||
|
ItemsSource="{Binding EditorVaultChoices}"
|
||||||
|
SelectedItem="{Binding EditorSelectedVault}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Classes="body"
|
||||||
|
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
|
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
|
||||||
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
|
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
VerticalContentAlignment="Top" Padding="14,10" />
|
VerticalContentAlignment="Top" Padding="14,10" />
|
||||||
@@ -125,10 +145,23 @@
|
|||||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<TextBlock Text="{Binding Badge}" />
|
<TextBlock Text="{Binding Badge}" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ Which vault this one is in, drawn only where there is more than one to be in. A shared
|
||||||
|
snippet is a command the rest of a team can read and insert into their own terminals,
|
||||||
|
and this card is the only place that fact appears before somebody taps EDIT.
|
||||||
|
-->
|
||||||
|
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
|
||||||
|
<TextBlock Text="{Binding VaultBadge}" />
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
|
<!-- The command, on the surface every block of monospace in this design is drawn on.
|
||||||
<Border Classes="output" CornerRadius="9" Padding="11,9">
|
No radius override any more: it diverged from Border.output's own 14 under v2 for a
|
||||||
|
smaller card-nested block, and now that the style's own value has moved to 12 there is
|
||||||
|
no gap left worth a second number for — it takes the class's, like every other output
|
||||||
|
block on this head. -->
|
||||||
|
<Border Classes="output" Padding="11,9">
|
||||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Preview}"
|
<TextBlock Classes="mono" FontSize="11" Text="{Binding Preview}"
|
||||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||||
</Border>
|
</Border>
|
||||||
@@ -145,26 +178,76 @@
|
|||||||
second column to put it in. Both buttons name the terminal — "TYPE INTO pg-primary" — because on a
|
second column to put it in. Both buttons name the terminal — "TYPE INTO pg-primary" — because on a
|
||||||
phone the tab strip may be scrolled away and "whatever is in the terminal receives this" is not a
|
phone the tab strip may be scrolled away and "whatever is in the terminal receives this" is not a
|
||||||
sentence anybody should have to guess the subject of.
|
sentence anybody should have to guess the subject of.
|
||||||
|
|
||||||
|
DeepChrome rather than Chrome, since v5: this bar is chrome furniture in the same sense the bottom
|
||||||
|
nav bar it sits above is — a raised strip of frame rather than a pane of content — and joins the same
|
||||||
|
DeepChrome decision PhoneShell's header, strip and bar made. See the remark there.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="4" IsVisible="{Binding HasSelection}" Background="{StaticResource Chrome}"
|
<Border Grid.Row="4" IsVisible="{Binding HasSelection}" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="14,12"
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="14,12"
|
||||||
IsEnabled="{Binding !IsEditing}">
|
IsEnabled="{Binding !IsEditing}">
|
||||||
<StackPanel Spacing="9">
|
<StackPanel Spacing="9">
|
||||||
|
|
||||||
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
|
<StackPanel Spacing="9" IsVisible="{Binding ShowsSelectionActions}">
|
||||||
IsEnabled="{Binding CanInsert}" />
|
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
|
||||||
|
IsEnabled="{Binding CanInsert}" />
|
||||||
|
|
||||||
<!-- Only for a snippet marked as running. For every other one there is nothing this could mean. -->
|
<!-- Only for a snippet marked as running. For every other one there is nothing this could mean. -->
|
||||||
<Button Classes="secondary" Height="44" Content="{Binding RunLabel}"
|
<Button Classes="secondary" Height="44" Content="{Binding RunLabel}"
|
||||||
Command="{Binding RunCommand}" IsVisible="{Binding SelectionRuns}"
|
Command="{Binding RunCommand}" IsVisible="{Binding SelectionRuns}"
|
||||||
IsEnabled="{Binding CanInsert}" />
|
IsEnabled="{Binding CanInsert}" />
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,8,*">
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
||||||
Command="{Binding EditCommand}" />
|
Command="{Binding EditCommand}" />
|
||||||
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
|
<!--
|
||||||
Command="{Binding DeleteCommand}" />
|
◆ Sharing the snippet. Beside EDIT rather than inside it, which is the line the hosts screen
|
||||||
</Grid>
|
draws too: the two vaults are encrypted under different keys, so this is a re-seal into one and
|
||||||
|
a tombstone in the other — nothing a SAVE could do. It shows only where there is somewhere to
|
||||||
|
move to; see SnippetsViewModel.CanMove.
|
||||||
|
-->
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
||||||
|
IsVisible="{Binding CanMove}" Command="{Binding MoveCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ A row of its own, under the pair rather than beside them, as the hosts screen puts it. A phone
|
||||||
|
has no hover and no tooltip, so the only thing separating a destructive button from an ordinary
|
||||||
|
one is where a thumb lands.
|
||||||
|
-->
|
||||||
|
<Button Classes="danger" Height="44" Content="DELETE" Command="{Binding DeleteCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ MOVING THE SNIPPET TO ANOTHER VAULT, in the place the insert controls were. A picker and two
|
||||||
|
buttons rather than a question with a yes: what is being asked is which vault, and a move is undone
|
||||||
|
by moving it back.
|
||||||
|
|
||||||
|
The sentence is not decoration. Unlike a host, a snippet crosses whole — there is no group and no
|
||||||
|
tag to leave behind — so what there is to say is who can read it afterwards; and on a phone, where
|
||||||
|
the status line is one line at the bottom of a screen somebody has already navigated away from,
|
||||||
|
before the tap is the only place that reliably gets read.
|
||||||
|
-->
|
||||||
|
<StackPanel Spacing="10" IsVisible="{Binding IsMoving}">
|
||||||
|
<TextBlock Classes="label" Text="MOVE TO VAULT" />
|
||||||
|
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
|
||||||
|
ItemsSource="{Binding MoveVaultChoices}"
|
||||||
|
SelectedItem="{Binding SelectedMoveVault}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Classes="body"
|
||||||
|
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
|
||||||
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||||
|
Command="{Binding ConfirmMoveCommand}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||||
|
Command="{Binding CancelMoveCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One keystroke's worth of bytes, on its way from <see cref="TerminalInputSurface"/> to the shell.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Bytes rather than text, because half of what a terminal receives has no text form: a cursor key is an
|
||||||
|
/// escape sequence and Ctrl-C is a single byte below the printable range. Encoding at the surface — where
|
||||||
|
/// the key is still known — rather than downstream is what keeps the send path from having to guess which
|
||||||
|
/// kind it was handed.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="bytes">The encoded keystroke.</param>
|
||||||
|
internal sealed class TerminalInputEventArgs(byte[] bytes) : EventArgs
|
||||||
|
{
|
||||||
|
public byte[] Bytes { get; } = bytes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Input.TextInput;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The terminal's keyboard, as an Avalonia control that draws nothing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>This exists so that the software keyboard is configured by us rather than by Chromium</b>, and that
|
||||||
|
/// single sentence is the whole of ADR 0015. A phone keyboard over a terminal has to be told to stop
|
||||||
|
/// autocorrecting, stop predicting, stop capitalising and stop offering a suggestion strip, and on Android
|
||||||
|
/// the only setting that actually achieves it is an <c>EditorInfo.inputType</c> carrying
|
||||||
|
/// <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD</c>. A page inside a <c>WebView</c> cannot reach that field —
|
||||||
|
/// Chromium derives the whole <c>EditorInfo</c> from the focused DOM node, and xterm.js already sets every
|
||||||
|
/// attribute the web platform offers (<c>autocorrect</c>, <c>autocapitalize</c>, <c>spellcheck</c>) to no
|
||||||
|
/// effect, because they are advisory and Gboard ignores them.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Avalonia can reach it, on the version this repository already pins.</b>
|
||||||
|
/// <c>AndroidInputMethod.OnCreateInputConnection</c> in Avalonia.Android 12.1.1 reads:
|
||||||
|
/// <code>
|
||||||
|
/// if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
|
||||||
|
/// outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
|
||||||
|
/// </code>
|
||||||
|
/// <c>0x80080</c> is <c>TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | TYPE_TEXT_FLAG_NO_SUGGESTIONS</c> — the exact
|
||||||
|
/// pair Termux's own <c>TerminalView</c> sets, and the reason typing in Termux feels like a terminal. The
|
||||||
|
/// guard is why the constructor leaves <c>ContentType</c>, <c>Multiline</c> and <c>AutoCapitalization</c>
|
||||||
|
/// alone: any of those would move <c>InputType</c> off <c>1</c> and the branch would never be taken.
|
||||||
|
/// <c>ShowSuggestions</c> is a <c>bool?</c> defaulting to <c>null</c>, so it must be set to <c>false</c>
|
||||||
|
/// rather than merely left.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Two options are set, and the second one is not optional either.</b> The default
|
||||||
|
/// <c>ReturnKeyType</c> maps to <c>IME_ACTION_DONE</c> on a non-multiline field, which closed the keyboard
|
||||||
|
/// on every Enter — that is, on every command. <c>ReturnKeyType.Return</c> is <c>IME_ACTION_NONE</c> and
|
||||||
|
/// touches nothing else. The constructor carries the full argument, including why the obvious alternative
|
||||||
|
/// of <c>Multiline</c> cannot be used without giving the suggestion strip back.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One pixel, and never drawn.</b> Focus in Avalonia has nothing to do with hit-testing, so the input
|
||||||
|
/// target does not need to cover the terminal — and must not, because the renderer is a native child view
|
||||||
|
/// composited above everything Avalonia paints, so a control stretched over it would be invisible, unable
|
||||||
|
/// to receive the touches the <c>WebView</c> consumes first, and in the way of the connect page that shares
|
||||||
|
/// this Panel when no session is open. A 1×1 control in the corner is focusable, costs one layout slot, and
|
||||||
|
/// is the whole of what an input target has to be. Taps on the terminal itself are noticed by
|
||||||
|
/// <see cref="Platform.RendererFocus"/> instead, which sees them where they actually land.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing is retained.</b> <see cref="TerminalImeClient"/> reports no surrounding text and no preedit,
|
||||||
|
/// because a terminal has no document for an input method to edit — the remote holds the line and the only
|
||||||
|
/// honest answer to "what is around the cursor" is "ask the host, which you cannot". Committed text is
|
||||||
|
/// turned into bytes and forwarded the instant it arrives.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class TerminalInputSurface : Control
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bytes the shell should receive, already encoded.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Raised rather than sent here, because the Ctrl latch belongs to the accessory row and the session to
|
||||||
|
/// the view model. <c>TerminalScreen.SendAsync</c> owns both and applies the latch to anything one byte
|
||||||
|
/// long, which is exactly the set a latch should reach: a typed letter, yes; a pasted line or a cursor
|
||||||
|
/// key's escape sequence, no.
|
||||||
|
/// </remarks>
|
||||||
|
internal event EventHandler<TerminalInputEventArgs>? Input;
|
||||||
|
|
||||||
|
private readonly TerminalImeClient client;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the text event about to arrive has already been sent as a control byte.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Necessary because handling a key event does not stop the text that follows it.</b> Avalonia's
|
||||||
|
/// <c>AndroidKeyboardEventsHelper</c> raises the key event and then, independently, raises a text input
|
||||||
|
/// event for any key whose <c>UnicodeChar</c> is 32 or above — it reads the handled flag from the first
|
||||||
|
/// only to combine into its own return value, never to skip the second. So Ctrl-C on a hardware keyboard
|
||||||
|
/// produces both a <c>KeyDown</c> this class turns into <c>0x03</c> and a text input carrying a literal
|
||||||
|
/// <c>c</c>, and without this flag the shell receives an interrupt followed by a stray character.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A plain field rather than anything cleverer because the two events are raised one after the other in
|
||||||
|
/// the same dispatch, and it is cleared at the top of every <see cref="OnKeyDown"/> so a chord that
|
||||||
|
/// somehow produced no text cannot swallow the next thing typed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The keys in <see cref="TryTranslate"/> need no such guard — Enter, Tab, Escape and Backspace all
|
||||||
|
/// carry a <c>UnicodeChar</c> below 32, so no text event is raised for them in the first place.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private bool controlByteAlreadySent;
|
||||||
|
|
||||||
|
public TerminalInputSurface()
|
||||||
|
{
|
||||||
|
client = new TerminalImeClient(this);
|
||||||
|
|
||||||
|
Focusable = true;
|
||||||
|
|
||||||
|
// Drawn nowhere and hit-tested never — see the remarks. The size is 1 rather than 0 only because a
|
||||||
|
// zero-area control is a shape no layout pass has a reason to keep.
|
||||||
|
Width = 1;
|
||||||
|
Height = 1;
|
||||||
|
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left;
|
||||||
|
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top;
|
||||||
|
IsHitTestVisible = false;
|
||||||
|
|
||||||
|
// The one line that fixes the keyboard. See the remarks for why almost nothing else is set.
|
||||||
|
TextInputOptions.SetShowSuggestions(this, false);
|
||||||
|
|
||||||
|
// ◆ AND THE ONE THAT KEEPS THE KEYBOARD UP WHEN A COMMAND IS RUN.
|
||||||
|
//
|
||||||
|
// Avalonia maps ReturnKeyType.Default, on a field that is not Multiline, to IME_ACTION_DONE — and
|
||||||
|
// Done means done: the keyboard performs the action and dismisses itself. Every Enter therefore
|
||||||
|
// closed the keyboard, which on a terminal is every command.
|
||||||
|
//
|
||||||
|
// Return is the member that maps to IME_ACTION_NONE, leaving the key as a plain newline with no
|
||||||
|
// action attached and no reason for the IME to hide. Its whole switch arm is
|
||||||
|
// `returnKeyType - 1 => 0 => 1073741824`, which is 0x40000000, IME_ACTION_NONE.
|
||||||
|
//
|
||||||
|
// Multiline=true reaches the same IME_ACTION_NONE through the default arm and is the wrong way to
|
||||||
|
// get there: it also ORs TYPE_TEXT_FLAG_MULTI_LINE into InputType, which moves InputType off 1 and
|
||||||
|
// silently fails the ShowSuggestions guard above. The two options cannot both be had that way — one
|
||||||
|
// trades the suggestion strip back for a working Enter. ReturnKeyType costs nothing and is the
|
||||||
|
// reason this file sets it rather than Multiline.
|
||||||
|
TextInputOptions.SetReturnKeyType(this, TextInputReturnKeyType.Return);
|
||||||
|
|
||||||
|
AddHandler(TextInputMethodClientRequestedEvent, OnTextInputMethodClientRequested);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes focus and asks for the keyboard.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The request is made even when focus was already here, which is the case that matters: the system back
|
||||||
|
/// gesture puts the keyboard away without moving focus, so the way back has to be an activation request
|
||||||
|
/// rather than a focus change that would be a no-op. <c>AndroidInputMethod</c> answers
|
||||||
|
/// <c>InputPaneActivationRequested</c> with <c>ShowSoftInput</c>, which is precisely this.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Focusing for the first time raises the keyboard on its own — <c>SetClient</c> calls
|
||||||
|
/// <c>RestartInput</c> and <c>ShowSoftInput</c> when it takes a non-null client — so the ordering here is
|
||||||
|
/// belt and braces rather than two mechanisms disagreeing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal void Acquire()
|
||||||
|
{
|
||||||
|
Focus();
|
||||||
|
client.RequestKeyboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTextInputMethodClientRequested(object? sender, TextInputMethodClientRequestedEventArgs e) =>
|
||||||
|
e.Client = client;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything the IME commits and everything a hardware keyboard types that is not in
|
||||||
|
/// <see cref="TryTranslate"/>'s table. UTF-8 because that is what the pty is told the session is, and
|
||||||
|
/// because a multi-byte character has to arrive as one write or a partial sequence reaches the remote.
|
||||||
|
/// </remarks>
|
||||||
|
protected override void OnTextInput(TextInputEventArgs e)
|
||||||
|
{
|
||||||
|
if (controlByteAlreadySent)
|
||||||
|
{
|
||||||
|
controlByteAlreadySent = false;
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(e.Text))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newlines arrive as carriage returns, whichever way they came. A keyboard with no editor action
|
||||||
|
// attached may commit "\n" as ordinary text rather than sending KEYCODE_ENTER, and a paste carries
|
||||||
|
// whatever the clipboard held — but a terminal's line terminator from a keyboard is CR, and a shell
|
||||||
|
// handed LF for Enter is a shell that looks like it ignored the key. Normalised rather than special
|
||||||
|
// -cased so that a pasted "\r\n" does not run the line twice.
|
||||||
|
var text = e.Text.ReplaceLineEndings("\r");
|
||||||
|
|
||||||
|
Input?.Invoke(this, new TerminalInputEventArgs(Encoding.UTF8.GetBytes(text)));
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
base.OnTextInput(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Only the keys that have no text of their own are handled here; everything printable is left alone so
|
||||||
|
/// that it arrives once, through <see cref="OnTextInput"/>. Handling both would double every letter.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Ctrl with a letter is translated here rather than left to the latch, because a hardware keyboard can
|
||||||
|
/// hold a modifier and this is the only place that sees the chord.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
protected override void OnKeyDown(KeyEventArgs e)
|
||||||
|
{
|
||||||
|
controlByteAlreadySent = false;
|
||||||
|
|
||||||
|
if (e.KeyModifiers.HasFlag(KeyModifiers.Control) && TryControlChord(e.Key, out var chord))
|
||||||
|
{
|
||||||
|
Input?.Invoke(this, new TerminalInputEventArgs(chord));
|
||||||
|
|
||||||
|
// The letter's own text event is still coming; see the field's remarks.
|
||||||
|
controlByteAlreadySent = true;
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryTranslate(e.Key, out var bytes))
|
||||||
|
{
|
||||||
|
Input?.Invoke(this, new TerminalInputEventArgs(bytes));
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnKeyDown(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The ASCII rule and nothing cleverer, matching <c>TerminalScreen.SendAsync</c>'s latch: Ctrl-A through
|
||||||
|
/// Ctrl-Z are <c>0x01</c>–<c>0x1A</c>, which is the letter masked with <c>0x1F</c>.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool TryControlChord(Key key, out byte[] bytes)
|
||||||
|
{
|
||||||
|
if (key is >= Key.A and <= Key.Z)
|
||||||
|
{
|
||||||
|
bytes = [(byte)((byte)('a' + (key - Key.A)) & 0x1F)];
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes = [];
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keys a terminal expects as escape sequences rather than as text.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Cursor keys are written as CSI rather than SS3, which is the same choice — and the same reasoning —
|
||||||
|
/// as the accessory row's table in <c>TerminalScreen</c>: the difference between <c>CSI A</c> and
|
||||||
|
/// <c>SS3 A</c> is the difference between working in bash and not, and nothing here tracks the remote's
|
||||||
|
/// application-cursor mode.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b><see cref="Key.Delete"/> is deliberately spent on backspace, and it is the one lossy choice in
|
||||||
|
/// this file.</b> Avalonia's Android input connection turns an IME's <c>deleteSurroundingText</c> into a
|
||||||
|
/// dispatched <c>KEYCODE_FORWARD_DEL</c> — see <c>TextEditBuffer.Remove</c> — so a soft keyboard that
|
||||||
|
/// deletes through the edit buffer rather than through a real <c>KEYCODE_DEL</c> arrives here as
|
||||||
|
/// <c>Delete</c>. Mapping it to <c>CSI 3 ~</c> would be correct for a hardware forward-delete and would
|
||||||
|
/// make backspace delete forwards on any keyboard taking the other route, which is the worse failure by
|
||||||
|
/// a wide margin: a phone has no forward-delete key to miss, and a broken backspace makes the terminal
|
||||||
|
/// unusable. Revisit if a hardware keyboard's Delete is ever worth more than this insurance.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static bool TryTranslate(Key key, out byte[] bytes)
|
||||||
|
{
|
||||||
|
bytes = key switch
|
||||||
|
{
|
||||||
|
Key.Enter => [0x0D],
|
||||||
|
Key.Tab => [0x09],
|
||||||
|
Key.Escape => [0x1B],
|
||||||
|
Key.Back or Key.Delete => [0x7F],
|
||||||
|
|
||||||
|
Key.Up => [0x1B, (byte)'[', (byte)'A'],
|
||||||
|
Key.Down => [0x1B, (byte)'[', (byte)'B'],
|
||||||
|
Key.Right => [0x1B, (byte)'[', (byte)'C'],
|
||||||
|
Key.Left => [0x1B, (byte)'[', (byte)'D'],
|
||||||
|
|
||||||
|
Key.Home => [0x1B, (byte)'[', (byte)'H'],
|
||||||
|
Key.End => [0x1B, (byte)'[', (byte)'F'],
|
||||||
|
Key.Insert => [0x1B, (byte)'[', (byte)'2', (byte)'~'],
|
||||||
|
Key.PageUp => [0x1B, (byte)'[', (byte)'5', (byte)'~'],
|
||||||
|
Key.PageDown => [0x1B, (byte)'[', (byte)'6', (byte)'~'],
|
||||||
|
|
||||||
|
_ => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return bytes.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What Android's input method is told about the text it is editing, which is that there is none.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <c>SupportsSurroundingText</c> and <c>SupportsPreedit</c> are both false, and
|
||||||
|
/// <see cref="SurroundingText"/> is empty, because they are the truth: the line being edited lives on
|
||||||
|
/// the far side of an SSH channel and this client has never been told what is on it. Claiming otherwise
|
||||||
|
/// would invite the IME to reason about text it cannot see and to issue edits against positions that
|
||||||
|
/// mean nothing here.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Selection"/> has to be settable because <c>TextEditBuffer</c> writes it before every
|
||||||
|
/// replacement, so it is stored and ignored rather than refused — a property that threw would take the
|
||||||
|
/// input connection down with it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="CursorRectangle"/> is empty for the same reason the control is 1×1: it positions an IME's
|
||||||
|
/// composition popup, and a field that never composes has no popup to place.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class TerminalImeClient(Visual visual) : TextInputMethodClient
|
||||||
|
{
|
||||||
|
public override Visual TextViewVisual => visual;
|
||||||
|
|
||||||
|
public override bool SupportsPreedit => false;
|
||||||
|
|
||||||
|
public override bool SupportsSurroundingText => false;
|
||||||
|
|
||||||
|
public override string SurroundingText => string.Empty;
|
||||||
|
|
||||||
|
public override Rect CursorRectangle => default;
|
||||||
|
|
||||||
|
public override TextSelection Selection { get; set; }
|
||||||
|
|
||||||
|
/// <remarks>Exposed because <c>RaiseInputPaneActivationRequested</c> is protected on the base.</remarks>
|
||||||
|
internal void RequestKeyboard() => RaiseInputPaneActivationRequested();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,13 @@
|
|||||||
|
|
||||||
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
|
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
|
||||||
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
|
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
|
||||||
four-entry bottom bar are all collapsed by PhoneShell while this surface is up, and what replaces them
|
three-entry bottom bar are all collapsed by PhoneShell while a shell is showing, and what replaces them
|
||||||
is the row below: back, the sessions, and the way to open another one.
|
is the row below: back, the sessions, and the way to open another one.
|
||||||
|
|
||||||
|
With nothing running it is only the header and the strip. This surface is then the connect page below —
|
||||||
|
a box and the machines connected to before — which is not what the screen was being cleared for, and is
|
||||||
|
the one screen reachable by closing your last tab, so the nav stays. See PhoneShell.RefreshChrome.
|
||||||
|
|
||||||
That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this
|
That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this
|
||||||
screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every
|
screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every
|
||||||
one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap
|
one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap
|
||||||
@@ -29,9 +33,94 @@
|
|||||||
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">
|
<!--
|
||||||
|
Four rows since the disconnect strip arrived. It is Auto and empty except while the selected tab's shell
|
||||||
|
has stopped, so an ordinary session pays nothing for it — and when it is up it takes its height off the
|
||||||
|
renderer rather than drawing over it, which on this head is not optional either: the renderer is a native
|
||||||
|
child view composited above everything Avalonia draws, so a banner over its rectangle would be invisible.
|
||||||
|
-->
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
<!-- ============ the bar ============ -->
|
<!-- ============ the bar ============ -->
|
||||||
<!--
|
<!--
|
||||||
@@ -45,7 +134,10 @@
|
|||||||
to 34, the pills to 30 — because a bar that shrank around controls that did not would only have moved
|
to 34, the pills to 30 — because a bar that shrank around controls that did not would only have moved
|
||||||
the clipping somewhere harder to see.
|
the clipping somewhere harder to see.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="0" Height="35" Background="{StaticResource Chrome}"
|
<!-- DeepChrome rather than Chrome, since v5: this bar is what the vault header, the strip and the
|
||||||
|
bottom bar collapse into while a shell is showing, so it takes their surface along with their
|
||||||
|
job — see the remark on the header in PhoneShell.axaml. -->
|
||||||
|
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
@@ -83,15 +175,21 @@
|
|||||||
row where nothing sits above or below it to be hit by mistake.
|
row where nothing sits above or below it to be hit by mistake.
|
||||||
-->
|
-->
|
||||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||||
BorderThickness="1" CornerRadius="9" Height="30">
|
BorderThickness="1" CornerRadius="10" Height="30">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="9"
|
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="10"
|
||||||
VerticalContentAlignment="Center"
|
VerticalContentAlignment="Center"
|
||||||
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, and
|
||||||
|
red where a connection broke rather than a shell exiting; see the same dot in
|
||||||
|
PhoneShell, and Phone.axaml for why neither of the last two is a rule broken.
|
||||||
|
-->
|
||||||
|
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||||
|
Classes.connecting="{Binding IsConnecting}"
|
||||||
|
Classes.dropped="{Binding IsDropped}" Width="6" Height="6"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||||
Text="{Binding Label}" />
|
Text="{Binding Label}" />
|
||||||
@@ -112,7 +210,7 @@
|
|||||||
It reads as a misprint, which for the control that ends a session is the wrong thing
|
It reads as a misprint, which for the control that ends a session is the wrong thing
|
||||||
to look like.
|
to look like.
|
||||||
-->
|
-->
|
||||||
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,9,9,0"
|
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,10,10,0"
|
||||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
|
||||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||||
@@ -243,7 +341,7 @@
|
|||||||
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
|
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||||
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
|
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
|
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
|
||||||
VerticalAlignment="Center" Text="{Binding Started}" />
|
VerticalAlignment="Center" Text="{Binding Started}" />
|
||||||
@@ -277,11 +375,82 @@
|
|||||||
<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">
|
||||||
|
<!--
|
||||||
|
Only for a refusal, and it runs the same command the disconnect strip below the renderer does —
|
||||||
|
the vault's own connect path, back into this same tab. Worth more on a phone than on a desk: the
|
||||||
|
commonest refusal here is a mobile link that was not up yet, and the alternative is going back
|
||||||
|
to the hosts screen to find a machine whose name is already on this card.
|
||||||
|
-->
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||||
|
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||||
|
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
|
||||||
|
IsVisible="{Binding SelectedTab.IsFailed}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="TRY AGAIN" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||||
|
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||||
|
Command="{Binding ShowScreenCommand}"
|
||||||
|
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="SHOW LOGS" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -302,6 +471,29 @@
|
|||||||
design's #171A26. See WebAssets/terminal.css.
|
design's #171A26. See WebAssets/terminal.css.
|
||||||
-->
|
-->
|
||||||
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============ the keyboard ============
|
||||||
|
|
||||||
|
One pixel in the corner, drawn never, and it is where every character typed into a shell on this
|
||||||
|
phone arrives. ADR 0015 has the whole argument; the short of it is that a software keyboard over a
|
||||||
|
terminal has to be told to stop autocorrecting, and on Android the only setting that works is an
|
||||||
|
inputType on a native input connection — which a page inside a WebView cannot reach and an Avalonia
|
||||||
|
control can, on the version already pinned here.
|
||||||
|
|
||||||
|
Not stretched over the renderer, which would be the obvious shape and is wrong three times: the
|
||||||
|
WebView composites above everything Avalonia paints so it would be invisible, it would never see the
|
||||||
|
touches the WebView consumes first, and it would sit over the connect page that shares this Panel
|
||||||
|
whenever no session is open. Focus does not need area. Taps on the terminal are noticed by
|
||||||
|
RendererFocus, where they actually land.
|
||||||
|
|
||||||
|
Collapsed with the renderer, on the same binding and for a reason of its own: a focusable control is
|
||||||
|
a tab stop and a focus candidate wherever it lives, and this Panel is also the connect page when no
|
||||||
|
session is open. Left always-visible it would be a stray target on a screen with a real text box on
|
||||||
|
it, offering a keyboard configured for a shell to somebody typing a hostname.
|
||||||
|
-->
|
||||||
|
<views:TerminalInputSurface x:Name="Input"
|
||||||
|
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- ============ accessory keys ============ -->
|
<!-- ============ accessory keys ============ -->
|
||||||
@@ -313,70 +505,164 @@
|
|||||||
-->
|
-->
|
||||||
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
||||||
<!--
|
<!--
|
||||||
33 tall rather than 50, a third off it like the bar at the top, with the keys inside coming down from
|
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
|
||||||
38 to 30 for the reason the pills did: a row that shrank around its own contents would clip them.
|
|
||||||
|
|
||||||
The five pixels above it are a gap and not a border. The renderer is a native child view, so nothing
|
What this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
|
||||||
Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its own —
|
and it was wrong in two ways that compounded each other.
|
||||||
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
|
|
||||||
that has gone wrong rather than as two that are different things.
|
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
|
||||||
|
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
|
||||||
|
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
|
||||||
|
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
|
||||||
|
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
|
||||||
|
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
|
||||||
|
to be the first thing anybody noticed about this row.
|
||||||
|
|
||||||
|
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
|
||||||
|
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
|
||||||
|
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
|
||||||
|
which is a trade worth making right up until the control stops working, and 30 is past that.
|
||||||
|
|
||||||
|
── what the other clients do, because all of them agree ─────────────────────────────────────────────
|
||||||
|
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
|
||||||
|
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
|
||||||
|
second row — Termux ships two by default. That is not convention for its own sake: a key whose
|
||||||
|
position is fixed can be hit without looking, which is the entire point of a modifier row you use
|
||||||
|
while reading the output above it. A key that might have scrolled has to be found first.
|
||||||
|
|
||||||
|
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
|
||||||
|
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
|
||||||
|
ones that benefit, because the arrows were the keys living past the right edge.
|
||||||
|
|
||||||
|
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
|
||||||
|
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
|
||||||
|
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
|
||||||
|
Ctrl is not the better surface.
|
||||||
|
|
||||||
|
── the split ────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
|
||||||
|
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
|
||||||
|
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
|
||||||
|
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
|
||||||
|
|
||||||
|
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
|
||||||
|
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of
|
||||||
|
its own — and a terminal whose last line of output is flush against a row of grey keys reads as one
|
||||||
|
surface that has gone wrong rather than as two that are different things.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="2" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
|
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<!--
|
||||||
|
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
|
||||||
|
its own, the screen goes off, and the tab that went quiet is discovered later with no idea whether the
|
||||||
|
shell was exited or cut off. So it names which, prints the sentence that came with it, and offers the
|
||||||
|
one useful answer.
|
||||||
|
|
||||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
Two lines rather than the desktop's one, because 360dp cannot hold a word, a sentence and two buttons
|
||||||
VerticalScrollBarVisibility="Disabled">
|
across. The word and the buttons share the top line — the buttons are where a thumb already is — and
|
||||||
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
|
the sentence wraps underneath at two lines maximum, which is enough for anything sshd or a socket
|
||||||
VerticalAlignment="Center" />
|
error actually says and short of a strip that eats the terminal.
|
||||||
</ScrollViewer>
|
|
||||||
|
RECONNECT is disabled rather than hidden when there is nothing to dial: a machine typed in by hand has
|
||||||
|
no keychain entry to redial, and a control visibly refusing answers "why not" better than an absent one.
|
||||||
|
See MainWindowViewModel.CanReconnectSelectedTab.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="2" Background="{StaticResource DeepChrome}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="16,8"
|
||||||
|
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||||
|
<Ellipse Classes="dot" Classes.dropped="{Binding IsSessionDropped}" Width="7" Height="7"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="{Binding SessionEndedText}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="row" MinHeight="40" Padding="12,0"
|
||||||
|
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||||
|
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="row" MinHeight="40" Padding="12,0"
|
||||||
|
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||||
|
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{Binding SessionEndedDetail}"
|
||||||
|
IsVisible="{Binding SessionEndedDetail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
|
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
|
||||||
beside them.
|
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
|
||||||
|
between them instead of taking what their labels happen to need — "ctrl" is four characters and
|
||||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
"|" is one, and on a row of keys those two want the same target, not proportional ones.
|
||||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the state
|
|
||||||
in which hunting for the control that fixes it is worst. That argument is answered rather than
|
|
||||||
abandoned: outside the ScrollViewer they cannot scroll away, and the row they had costs 36 pixels
|
|
||||||
on a surface this change exists to give back.
|
|
||||||
|
|
||||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
|
||||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in this
|
|
||||||
screen.
|
|
||||||
|
|
||||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
|
||||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
|
||||||
never look like.
|
|
||||||
|
|
||||||
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
|
||||||
TerminalScreen.axaml.cs. Every button on this row sits over a terminal somebody is typing into,
|
|
||||||
and an ordinary one takes Avalonia's focus off the NativeWebView on tap, which makes the package
|
|
||||||
resign the page's focus. Making the text smaller must not cost the keyboard.
|
|
||||||
-->
|
-->
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
|
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
|
||||||
VerticalAlignment="Center">
|
|
||||||
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
|
|
||||||
VerticalAlignment="Center" />
|
|
||||||
|
|
||||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
|
<!--
|
||||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
The cursor keys, and the two text-size buttons riding at the end.
|
||||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
|
||||||
BorderThickness="1" Focusable="False"
|
|
||||||
Command="{Binding ShrinkTerminalFontCommand}"
|
|
||||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
|
||||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
|
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
|
||||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
|
||||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
|
||||||
BorderThickness="1" Focusable="False"
|
them every time the row's contents changed.
|
||||||
Command="{Binding EnlargeTerminalFontCommand}"
|
-->
|
||||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
||||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
|
||||||
</Button>
|
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
<!--
|
||||||
|
The text-size buttons.
|
||||||
|
|
||||||
|
They used to have a row of their own above the terminal, on the argument that the accessory row
|
||||||
|
scrolls and these two must never be off-screen — a terminal too small to read is exactly the
|
||||||
|
state in which hunting for the control that fixes it is worst. Nothing on this keypad scrolls
|
||||||
|
any more, so that argument is now satisfied by construction rather than by placement.
|
||||||
|
|
||||||
|
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
||||||
|
has no keyboard to press it with, which is why the two heads differ here and nowhere else in
|
||||||
|
this screen.
|
||||||
|
|
||||||
|
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||||
|
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||||
|
never look like.
|
||||||
|
|
||||||
|
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
||||||
|
TerminalScreen.axaml.cs. Every button on this keypad sits over a terminal somebody is typing
|
||||||
|
into, and an ordinary one takes Avalonia's focus off the input surface on tap. Making the text
|
||||||
|
smaller must not cost the keyboard.
|
||||||
|
-->
|
||||||
|
<!-- No Spacing: Button.hotkey carries a 2-pixel side margin, so the gaps here match the
|
||||||
|
UniformGrid cells' without being counted twice. -->
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="8,0,0,0">
|
||||||
|
<Border Width="1" Height="20" Background="{StaticResource Border}" Margin="0,0,4,0"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||||
|
Command="{Binding ShrinkTerminalFontCommand}"
|
||||||
|
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||||
|
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||||
|
Command="{Binding EnlargeTerminalFontCommand}"
|
||||||
|
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||||
|
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -389,9 +675,10 @@
|
|||||||
of them.
|
of them.
|
||||||
|
|
||||||
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
|
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
|
||||||
but this surface raises it. The scrim reaching only the screen area is not a compromise here the way
|
but this surface raises it. The scrim reaching only the screen area is the full display while a shell
|
||||||
it was there — the bottom bar is collapsed while a terminal is showing, so the screen area is the
|
is showing, because the bottom bar is collapsed for it. Raised from the connect page it is not, and
|
||||||
display.
|
that lands on the add sheet's own answer: the three destinations under the scrim stay tappable, and for
|
||||||
|
a menu of places to connect from, navigating away is a perfectly good way to decide not to.
|
||||||
|
|
||||||
Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu:
|
Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu:
|
||||||
each of the three destinations is a picker, and the shell they open lands back on this surface as a
|
each of the three destinations is a picker, and the shell they open lands back on this surface as a
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
using DodoSSH.Client.Android.Platform;
|
using DodoSSH.Client.Android.Platform;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
@@ -37,7 +40,14 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
/// keys to fit 360dp produces ten targets too small to hit.
|
/// keys to fit 360dp produces ten targets too small to hit.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static readonly (string Label, byte[] Bytes, bool Latches)[] Keys =
|
/// <summary>The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Seven, which is what the row divides between — see the keypad's remark in TerminalScreen.axaml for
|
||||||
|
/// why the set is fixed and flexed rather than scrollable. Adding an eighth is a matter of adding it
|
||||||
|
/// here and changing the <c>Columns</c> on the UniformGrid; the two numbers have to agree, and the row
|
||||||
|
/// simply gets narrower keys.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly (string Label, byte[] Bytes, bool Latches)[] ModifierKeys =
|
||||||
[
|
[
|
||||||
("esc", [0x1B], false),
|
("esc", [0x1B], false),
|
||||||
("tab", [0x09], false),
|
("tab", [0x09], false),
|
||||||
@@ -46,12 +56,31 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
("-", [(byte)'-'], false),
|
("-", [(byte)'-'], false),
|
||||||
("~", [(byte)'~'], false),
|
("~", [(byte)'~'], false),
|
||||||
("/", [(byte)'/'], false),
|
("/", [(byte)'/'], false),
|
||||||
|
];
|
||||||
|
|
||||||
// The cursor keys, as the ANSI sequences a PTY expects. Written out rather than composed, because
|
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
|
||||||
// the difference between CSI A and SS3 A is the difference between working in bash and not.
|
/// <remarks>
|
||||||
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
/// <para>
|
||||||
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
/// The ANSI sequences a PTY expects, written out rather than composed, because the difference between
|
||||||
|
/// CSI A and SS3 A is the difference between working in bash and not.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Left, down, up, right</b> — the order every other client on the platform puts them in, and not
|
||||||
|
/// the order they were in here. On one flat row the pairs have to read as pairs: horizontal at the ends
|
||||||
|
/// and vertical in the middle means the two keys that undo each other are always neighbours, and a
|
||||||
|
/// thumb learns the row's shape rather than its labels. Up-down-left-right is the order they are named
|
||||||
|
/// in, which is a different thing from the order they are used in.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// These four were the keys that used to live past the right-hand edge of the scroller — the most
|
||||||
|
/// pressed keys on the keypad, and the ones you had to go looking for.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly (string Label, byte[] Bytes, bool Latches)[] CursorKeys =
|
||||||
|
[
|
||||||
("←", [0x1B, (byte)'[', (byte)'D'], false),
|
("←", [0x1B, (byte)'[', (byte)'D'], false),
|
||||||
|
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
||||||
|
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
||||||
("→", [0x1B, (byte)'[', (byte)'C'], false),
|
("→", [0x1B, (byte)'[', (byte)'C'], false),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -67,26 +96,119 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
|
|
||||||
private Button? controlKey;
|
private Button? controlKey;
|
||||||
|
|
||||||
|
private readonly TerminalInputSurface input;
|
||||||
|
|
||||||
|
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
|
||||||
|
private MainWindowViewModel? subscribed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The keyboard is raised on the transition into the terminal and not on every notification of it.
|
||||||
|
/// <c>IsTerminalShowing</c> is a computed property re-raised by several other setters, so acting on the
|
||||||
|
/// notification rather than the edge would call <c>ShowSoftInput</c> repeatedly for one arrival.
|
||||||
|
/// </remarks>
|
||||||
|
private bool terminalWasShowing;
|
||||||
|
|
||||||
public TerminalScreen()
|
public TerminalScreen()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
BuildAccessoryRow();
|
BuildAccessoryRow();
|
||||||
|
|
||||||
|
input = this.FindControl<TerminalInputSurface>("Input")!;
|
||||||
|
input.Input += (_, e) => SendAsync(e.Bytes);
|
||||||
|
|
||||||
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
||||||
|
|
||||||
renderer.EnvironmentRequested += OnRendererEnvironmentRequested;
|
renderer.EnvironmentRequested += (sender, e) =>
|
||||||
|
{
|
||||||
|
OnRendererEnvironmentRequested(sender, e);
|
||||||
|
|
||||||
|
// The precise moment the platform WebView is being built, which is the earliest one at which
|
||||||
|
// there is a native view to take the keyboard away from — and the one that fires again if
|
||||||
|
// Android reclaims the renderer and the page comes back. Posted rather than called, because the
|
||||||
|
// child is not in the hierarchy yet while its environment is still being asked for.
|
||||||
|
Dispatcher.UIThread.Post(AdoptRenderer, DispatcherPriority.Loaded);
|
||||||
|
};
|
||||||
|
|
||||||
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
|
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
|
||||||
DataContextChanged += (_, _) =>
|
DataContextChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
|
if (subscribed is { } previous)
|
||||||
|
{
|
||||||
|
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||||
|
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||||
|
subscribed = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (DataContext is MainWindowViewModel shell)
|
if (DataContext is MainWindowViewModel shell)
|
||||||
{
|
{
|
||||||
renderer.Source = shell.TerminalPageUrl;
|
renderer.Source = shell.TerminalPageUrl;
|
||||||
|
|
||||||
|
shell.PropertyChanged += OnShellPropertyChanged;
|
||||||
|
|
||||||
|
// The snippets screen's "TYPE INTO {tab}" lands here as well as on the desktop. It types at
|
||||||
|
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
|
||||||
|
// they look. See MainWindowViewModel.TerminalFocusRequested.
|
||||||
|
shell.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||||
|
subscribed = shell;
|
||||||
|
|
||||||
|
terminalWasShowing = shell.IsTerminalShowing;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
|
||||||
|
/// </summary>
|
||||||
|
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName is not nameof(MainWindowViewModel.IsTerminalShowing) ||
|
||||||
|
sender is not MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var showing = shell.IsTerminalShowing;
|
||||||
|
|
||||||
|
if (showing == terminalWasShowing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalWasShowing = showing;
|
||||||
|
|
||||||
|
if (!showing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posted to Loaded so the renderer's own native child, which becomes visible on this same change,
|
||||||
|
// exists by the time AdoptRenderer goes looking for it. Idempotent with the EnvironmentRequested
|
||||||
|
// path above: whichever runs first configures the view and the other finds it already done.
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
AdoptRenderer();
|
||||||
|
input.Acquire();
|
||||||
|
},
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTerminalFocusRequested(object? sender, EventArgs e) =>
|
||||||
|
Dispatcher.UIThread.Post(input.Acquire, DispatcherPriority.Loaded);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the keyboard off the renderer and puts the taps on it to use.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
|
||||||
|
/// the tap has to be reported rather than left to the page.
|
||||||
|
/// </remarks>
|
||||||
|
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Puts the keyboard away on the way to the connect menu.
|
/// Puts the keyboard away on the way to the connect menu.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -137,50 +259,28 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
|
|
||||||
private void BuildAccessoryRow()
|
private void BuildAccessoryRow()
|
||||||
{
|
{
|
||||||
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
// ── nothing to hand back any more ──────────────────────────────────────────────────────────────
|
||||||
|
// This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
|
||||||
|
// the platform requests Android's own focus for Avalonia's view after dispatching each handled
|
||||||
|
// touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
|
||||||
|
// TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
|
||||||
|
// the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
|
||||||
|
// stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
|
||||||
|
|
||||||
foreach (var (label, bytes, latches) in Keys)
|
Fill(this.FindControl<UniformGrid>("AccessoryKeys")!, ModifierKeys);
|
||||||
|
Fill(this.FindControl<UniformGrid>("ArrowKeys")!, CursorKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts one table's worth of keys into one row of the keypad.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The rows differ in what they hold and in nothing else — same control, same class, same wiring — so
|
||||||
|
/// they are filled by one method rather than by two that would drift.
|
||||||
|
/// </remarks>
|
||||||
|
private void Fill(UniformGrid row, (string Label, byte[] Bytes, bool Latches)[] keys)
|
||||||
|
{
|
||||||
|
foreach (var (label, bytes, latches) in keys)
|
||||||
{
|
{
|
||||||
var key = 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 +296,39 @@ 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 = label,
|
||||||
|
|
||||||
|
// ◆ Every dimension and brush this used to set by hand is now Button.hotkey in Phone.axaml,
|
||||||
|
// and the move is what lets the row have a pressed state at all: a style can say :pressed and
|
||||||
|
// an assignment cannot. It also takes this file out of the business of naming the palette's
|
||||||
|
// brushes, which is how a keypad ends up with a grey nothing else uses.
|
||||||
|
//
|
||||||
|
// Content is the string rather than a TextBlock built here for the same reason — the class
|
||||||
|
// carries the font and the alignment, so a wrapper element would only be somewhere for them to
|
||||||
|
// disagree.
|
||||||
|
Classes = { "hotkey" },
|
||||||
|
|
||||||
|
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
||||||
|
//
|
||||||
|
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
||||||
|
// ordinary buttons they take Avalonia's focus on tap, which takes it off whatever the
|
||||||
|
// terminal's input target is — and a modifier row that dismisses the keyboard every time it
|
||||||
|
// is used is not a modifier row. Focusable=false is what a toolbar button is, and it means
|
||||||
|
// the focused element never changes: TerminalInputSurface is still it, so the input
|
||||||
|
// connection is never torn down and nothing has to be handed back.
|
||||||
|
//
|
||||||
|
// This used to be only half a fix, because the target was the NativeWebView and Android keeps
|
||||||
|
// a focus of its own that the platform hands to Avalonia's view after every touch it
|
||||||
|
// dispatches. That half is gone rather than countered: under ADR 0015 the input target is an
|
||||||
|
// Avalonia control, so the focus Android moves is already where the keyboard should be.
|
||||||
|
Focusable = false,
|
||||||
|
};
|
||||||
|
|
||||||
private void ToggleControl()
|
private void ToggleControl()
|
||||||
{
|
{
|
||||||
controlLatched = !controlLatched;
|
controlLatched = !controlLatched;
|
||||||
@@ -207,18 +340,12 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
|
|
||||||
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
|
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
|
||||||
// ^L to a database prompt believing they typed an l.
|
// ^L to a database prompt believing they typed an l.
|
||||||
controlKey.Background = Palette(controlLatched ? "Active" : "Panel");
|
//
|
||||||
controlKey.Foreground = Palette(controlLatched ? "AccentText" : "TextDim");
|
// A class rather than two brush assignments, so what "latched" looks like is stated once in
|
||||||
|
// Phone.axaml beside what "pressed" looks like — which is where somebody changing either will be.
|
||||||
|
controlKey.Classes.Set("latched", controlLatched);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One brush from <c>Theme/Palette.axaml</c>, by key.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The accessory row is built in code because its keys come from a table, so its colours cannot be set in
|
|
||||||
/// XAML with the rest of the screen's. Resolving them by name is the next best thing: a row that named
|
|
||||||
/// its own blues is how the palette ends up with a fifth surface nobody meant to add.
|
|
||||||
/// </remarks>
|
|
||||||
private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush;
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
|
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
|
||||||
/// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a
|
/// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a
|
||||||
|
|||||||
@@ -220,7 +220,7 @@
|
|||||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
|
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ The one fact on this row that decides whether the button at the foot of the screen
|
◆ The one fact on this row that decides whether the button at the foot of the screen
|
||||||
@@ -336,9 +336,11 @@
|
|||||||
the thing the whole screen is about — a member can only be selected under one, so choosing who is the
|
the thing the whole screen is about — a member can only be selected under one, so choosing who is the
|
||||||
only half left to make.
|
only half left to make.
|
||||||
-->
|
-->
|
||||||
|
<!-- DeepChrome rather than Chrome, since v5 — the same raised-bar decision SnippetsScreen and
|
||||||
|
FilesScreen make for their own selection bars; see the remark on PhoneShell's vault header. -->
|
||||||
<Border Grid.Row="4"
|
<Border Grid.Row="4"
|
||||||
IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}"
|
IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||||
BorderThickness="0,1,0,0" Padding="14,12">
|
BorderThickness="0,1,0,0" Padding="14,12">
|
||||||
<StackPanel Spacing="9">
|
<StackPanel Spacing="9">
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -984,7 +984,7 @@
|
|||||||
"type": "Project",
|
"type": "Project",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"NSec.Cryptography": "[26.4.0, )",
|
"NSec.Cryptography": "[26.4.0, )",
|
||||||
"SSH.NET": "[2025.1.0, )"
|
"SSH.NET": "[2026.0.0, )"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dodossh.client.storage": {
|
"dodossh.client.storage": {
|
||||||
@@ -1044,9 +1044,9 @@
|
|||||||
},
|
},
|
||||||
"BouncyCastle.Cryptography": {
|
"BouncyCastle.Cryptography": {
|
||||||
"type": "CentralTransitive",
|
"type": "CentralTransitive",
|
||||||
"requested": "[2.6.2, )",
|
"requested": "[2.7.0, )",
|
||||||
"resolved": "2.6.2",
|
"resolved": "2.7.0",
|
||||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||||
},
|
},
|
||||||
"EFCore.NamingConventions": {
|
"EFCore.NamingConventions": {
|
||||||
"type": "CentralTransitive",
|
"type": "CentralTransitive",
|
||||||
@@ -1140,11 +1140,11 @@
|
|||||||
},
|
},
|
||||||
"SSH.NET": {
|
"SSH.NET": {
|
||||||
"type": "CentralTransitive",
|
"type": "CentralTransitive",
|
||||||
"requested": "[2025.1.0, )",
|
"requested": "[2026.0.0, )",
|
||||||
"resolved": "2025.1.0",
|
"resolved": "2026.0.0",
|
||||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"BouncyCastle.Cryptography": "2.6.2",
|
"BouncyCastle.Cryptography": "2.7.0",
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,6 +259,22 @@ public interface ISyncApi
|
|||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks the server for leave to relay one connection to one host.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The request names a host and nothing else. Where it is, and whether this caller may reach it, is the
|
||||||
|
/// server's decision, made from its own copy of the host's relay address — which is what keeps the relay
|
||||||
|
/// from being an open proxy that a client points wherever it likes. See ADR 0004.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IRelayApi
|
||||||
|
{
|
||||||
|
/// <summary>Issues a single-use ticket, good for thirty seconds, for one relay socket to this host.</summary>
|
||||||
|
/// <exception cref="DodoSshApiException">
|
||||||
|
/// The server refused: <c>relay-unavailable</c>, <c>relay-target-rejected</c> or
|
||||||
|
/// <c>relay-limit-reached</c>, each with its own remedy.
|
||||||
|
/// </exception>
|
||||||
|
Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The typed client for one DodoSSH server.
|
/// The typed client for one DodoSSH server.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -275,7 +291,7 @@ public interface ISyncApi
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
|
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
|
||||||
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi
|
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi, IRelayApi
|
||||||
{
|
{
|
||||||
private const string MetaPath = "/api/v1/meta";
|
private const string MetaPath = "/api/v1/meta";
|
||||||
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
|
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
|
||||||
@@ -310,6 +326,20 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
|
|||||||
DodoSshJsonContext.Default.DodoSshConfiguration,
|
DodoSshJsonContext.Default.DodoSshConfiguration,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Never cached, and never fetched ahead of need: a ticket is spent by the socket it opens and is
|
||||||
|
/// worthless thirty seconds after issue, so the only right moment to ask for one is the connect that
|
||||||
|
/// will use it.
|
||||||
|
/// </remarks>
|
||||||
|
public Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken) =>
|
||||||
|
SendAsync(
|
||||||
|
HttpMethod.Post,
|
||||||
|
RelayProtocol.TicketsPath,
|
||||||
|
JsonContent.Create(new RelayTicketRequest(hostId, PortForwardId: null), DodoSshJsonContext.Default.RelayTicketRequest),
|
||||||
|
DodoSshJsonContext.Default.RelayTicketResponse,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads the caller's profile, unlock material and reachable vaults.
|
/// Reads the caller's profile, unlock material and reachable vaults.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+696
-174
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Input.Platform;
|
using Avalonia.Input.Platform;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
using DodoSSH.Client.App.Platform;
|
using DodoSSH.Client.App.Platform;
|
||||||
using DodoSSH.Client.App.Views;
|
using DodoSSH.Client.App.Views;
|
||||||
using DodoSSH.Client.Auth;
|
using DodoSSH.Client.Auth;
|
||||||
@@ -33,12 +34,77 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
Compose(desktop);
|
ShowSomethingWhileComposing(desktop);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts a window on screen first, and composes the application behind it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The splash <em>is</em> the main window for a moment, which is what makes this work at all.</b> The
|
||||||
|
/// desktop lifetime shows whatever <c>MainWindow</c> holds once this method returns and then runs the
|
||||||
|
/// message loop; a window created here and merely shown would be a window with no lifetime attached, and
|
||||||
|
/// with <c>ShutdownMode.OnLastWindowClose</c> the first close of anything would end the process. Handing
|
||||||
|
/// the property over is one assignment, and Avalonia reads it lazily.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>And composition is posted rather than called.</b> It is synchronous and takes long enough to be
|
||||||
|
/// worth a splash in the first place — which is the whole argument for this method — so running it here
|
||||||
|
/// would block the very dispatcher turn that has to paint the thing. Background priority puts it behind
|
||||||
|
/// rendering, which is what makes this a painted window rather than merely a shown one.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void ShowSomethingWhileComposing(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var splash = new SplashWindow();
|
||||||
|
|
||||||
|
desktop.MainWindow = splash;
|
||||||
|
|
||||||
|
// Fully qualified, and it has to be: Application inherits an instance Dispatcher property from
|
||||||
|
// AvaloniaObject, member lookup beats the namespace, and a static method here cannot reach an
|
||||||
|
// instance property — so the unqualified name is a compile error rather than the type. Same below.
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.Post(
|
||||||
|
() => ComposeBehind(desktop, splash),
|
||||||
|
DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The order at the end is not interchangeable. <c>ShutdownMode</c> is <c>OnLastWindowClose</c>, so the
|
||||||
|
/// real window is shown while the splash is still open and the splash is closed afterwards; closing the
|
||||||
|
/// only window there is would end the process instead of handing it over.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A failure is caught rather than left to the dispatcher, and the splash is exactly why.</b> Before
|
||||||
|
/// this method existed a composition that threw took the process with it and left nothing behind. Now
|
||||||
|
/// there is something to leave behind — so an exception escaping a posted callback would abandon a window
|
||||||
|
/// saying "Starting…" over a process that had stopped, which is worse than the crash it replaced. What
|
||||||
|
/// arrives instead is the sentence and a way to close it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void ComposeBehind(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
|
SplashWindow splash)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Compose(desktop);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
splash.CouldNotStart(exception.Message);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
desktop.MainWindow?.Show();
|
||||||
|
splash.Close();
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
|
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
|
||||||
@@ -72,6 +138,17 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Reads this machine's clipboard.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The other direction, looked up per call for the same reason and with the same fall-through. One
|
||||||
|
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
|
||||||
|
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
|
||||||
|
/// </remarks>
|
||||||
|
private static Func<Task<string?>> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) =>
|
||||||
|
async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard }
|
||||||
|
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
|
||||||
|
: null;
|
||||||
|
|
||||||
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
|
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
|
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
|
||||||
@@ -92,7 +169,10 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
|
|
||||||
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
var paths = ClientPaths.Default;
|
// ForChannel rather than Default, so a nightly keeps its cache, outbox and device key somewhere
|
||||||
|
// the release build never opens. See ClientPaths.ForChannel for why sharing them is the failure
|
||||||
|
// worth spending a directory on.
|
||||||
|
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||||
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
||||||
|
|
||||||
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
|
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
|
||||||
@@ -110,14 +190,75 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
var browser = new SystemBrowserLauncher();
|
var browser = new SystemBrowserLauncher();
|
||||||
|
|
||||||
// Both chosen once, here, because each is a property of the machine rather than of any session. A
|
// Both chosen once, here, because each is a property of the machine rather than of any session. A
|
||||||
// computer with a usable TPM gets the store that keeps a device key behind a Windows consent prompt;
|
// Windows box with a usable TPM gets a consent prompt, a Mac with a Secure Enclave gets Touch ID, a
|
||||||
// anything else gets one that reports itself unavailable, so unlock keeps asking for the passphrase
|
// Linux box whose TPM this user may open gets a PIN the TPM checks — see PinPrompt — and anything
|
||||||
|
// else gets a store that reports itself unavailable, so unlock keeps asking for the passphrase
|
||||||
// (ADR 0007). The update channel answers the same shape of question about how this copy was
|
// (ADR 0007). The update channel answers the same shape of question about how this copy was
|
||||||
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0013.
|
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0013.
|
||||||
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
|
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths, PinPrompt(desktop));
|
||||||
var updates = UpdateChannels.ForThisMachine();
|
var updates = UpdateChannels.ForThisMachine();
|
||||||
|
|
||||||
var viewModel = new MainWindowViewModel(
|
var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections,
|
||||||
|
browser, updates);
|
||||||
|
|
||||||
|
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||||
|
|
||||||
|
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
||||||
|
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
||||||
|
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
||||||
|
var others = SingleInstance.Listen(
|
||||||
|
paths,
|
||||||
|
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
|
||||||
|
|
||||||
|
// Started rather than awaited: the framework's initialisation must not block on a schema
|
||||||
|
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||||
|
// discarding the task here is safe rather than merely convenient.
|
||||||
|
_ = viewModel.StartAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
WireShutdown(desktop, viewModel, workspace, caches, others);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the window somebody has just asked for by launching the application again.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Un-minimised first, because activating a minimised window leaves it minimised and the launch would
|
||||||
|
/// look ignored. <c>Activate</c> is a request rather than a guarantee — Windows refuses the foreground to
|
||||||
|
/// a process that did not receive the input, and what happens then is a flashing taskbar button, which is
|
||||||
|
/// still an answer. That is the platform's own rule about focus stealing and not a thing to work around.
|
||||||
|
/// </remarks>
|
||||||
|
private static void BringToFront(Window? window)
|
||||||
|
{
|
||||||
|
if (window is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.WindowState is WindowState.Minimized)
|
||||||
|
{
|
||||||
|
window.WindowState = WindowState.Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Show();
|
||||||
|
window.Activate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Split out of <see cref="Compose"/> for length rather than for reuse — it has one caller, and the
|
||||||
|
/// method it came out of is at the limit the analyzers set.
|
||||||
|
/// </remarks>
|
||||||
|
private static MainWindowViewModel BuildShell(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
|
ClientPaths paths,
|
||||||
|
ClientCacheFactory caches,
|
||||||
|
TerminalWorkspace workspace,
|
||||||
|
VaultKnownHostStore knownHosts,
|
||||||
|
IDeviceKeyStore deviceKeys,
|
||||||
|
SshNetConnectionFactory connections,
|
||||||
|
SystemBrowserLauncher browser,
|
||||||
|
IUpdateChannel updates) =>
|
||||||
|
new(
|
||||||
paths,
|
paths,
|
||||||
caches,
|
caches,
|
||||||
workspace,
|
workspace,
|
||||||
@@ -137,18 +278,88 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
|
|
||||||
copyToClipboard: ClipboardWriter(desktop),
|
copyToClipboard: ClipboardWriter(desktop),
|
||||||
updates: updates);
|
readClipboard: ClipboardReader(desktop),
|
||||||
|
updates: updates,
|
||||||
|
openInEditor: OpenInDefaultEditorAsync,
|
||||||
|
watchFile: WatchFile);
|
||||||
|
|
||||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
/// <summary>
|
||||||
|
/// Hands a local file to whatever this machine opens that kind of file with.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
|
||||||
|
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
|
||||||
|
/// Services binding depending on which desktop this is, and watching a file is a platform object with
|
||||||
|
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
|
||||||
|
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
|
||||||
|
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
|
||||||
|
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
|
||||||
|
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
|
||||||
|
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static Task OpenInDefaultEditorAsync(string path)
|
||||||
|
{
|
||||||
|
using var opened = System.Diagnostics.Process.Start(
|
||||||
|
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||||
|
|
||||||
// Started rather than awaited: the framework's initialisation must not block on a schema
|
return Task.CompletedTask;
|
||||||
// migration. The view model shows its own progress and handles its own failures, which is why
|
|
||||||
// discarding the task here is safe rather than merely convenient.
|
|
||||||
_ = viewModel.StartAsync(CancellationToken.None);
|
|
||||||
|
|
||||||
WireShutdown(desktop, viewModel, workspace, caches);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watches one file for saves, until the handle is disposed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The directory is watched and the name filtered, which is not the same as watching the file.</b>
|
||||||
|
/// A great many editors do not write in place: they write a temporary file beside the original and
|
||||||
|
/// rename it over the top, which to a watcher pointed at an inode is the file disappearing. Watching the
|
||||||
|
/// containing directory for that name catches the rename as a <c>Created</c>, which is what makes this
|
||||||
|
/// work with the editors people actually use.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Every kind of change is one callback, and the shell debounces nothing.</b> One save can raise two
|
||||||
|
/// or three events; what the shell does with each is stat the host and compare, which is a round trip
|
||||||
|
/// but a small one, and the alternative — a timer here — is a delay before somebody's edit reaches the
|
||||||
|
/// server for the sake of saving it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static FileSystemWatcher WatchFile(string path, Action onChanged)
|
||||||
|
{
|
||||||
|
var watcher = new FileSystemWatcher(
|
||||||
|
Path.GetDirectoryName(path)!, Path.GetFileName(path))
|
||||||
|
{
|
||||||
|
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName,
|
||||||
|
};
|
||||||
|
|
||||||
|
watcher.Changed += (_, _) => onChanged();
|
||||||
|
watcher.Created += (_, _) => onChanged();
|
||||||
|
watcher.Renamed += (_, _) => onChanged();
|
||||||
|
|
||||||
|
watcher.EnableRaisingEvents = true;
|
||||||
|
|
||||||
|
return watcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How the Linux device key store asks for a PIN, and the only thing that ever calls it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The lifetime is captured rather than the window, so this can be composed before there is one —
|
||||||
|
/// composition runs first, and <c>DevicePinWindow.AskAsync</c> answers null when there is nothing to
|
||||||
|
/// sit over. Windows and macOS never reach it: on those platforms the consent dialog is the operating
|
||||||
|
/// system's and this process is not in the conversation. See ADR 0007.
|
||||||
|
/// </remarks>
|
||||||
|
private static Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> PinPrompt(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop) =>
|
||||||
|
(purpose, cancellationToken) =>
|
||||||
|
DevicePinWindow.AskAsync(() => desktop.MainWindow, purpose, cancellationToken);
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and
|
/// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and
|
||||||
/// blocking the UI thread on their disposal is how an application comes to take several seconds to close —
|
/// blocking the UI thread on their disposal is how an application comes to take several seconds to close —
|
||||||
@@ -158,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
IClassicDesktopStyleApplicationLifetime desktop,
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
MainWindowViewModel viewModel,
|
MainWindowViewModel viewModel,
|
||||||
TerminalWorkspace workspace,
|
TerminalWorkspace workspace,
|
||||||
ClientCacheFactory caches)
|
ClientCacheFactory caches,
|
||||||
|
IAsyncDisposable others)
|
||||||
{
|
{
|
||||||
var shuttingDown = false;
|
var shuttingDown = false;
|
||||||
|
|
||||||
@@ -177,6 +389,11 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
await viewModel.DisposeAsync().ConfigureAwait(true);
|
await viewModel.DisposeAsync().ConfigureAwait(true);
|
||||||
await workspace.DisposeAsync().ConfigureAwait(true);
|
await workspace.DisposeAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
|
// Awaited rather than dropped, so the pipe name is free before this process is. It matters for
|
||||||
|
// one caller and matters a lot there: an update relaunches this application, and a successor
|
||||||
|
// that found the name still taken would be a second instance refusing itself.
|
||||||
|
await others.DisposeAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
caches.Dispose();
|
caches.Dispose();
|
||||||
|
|
||||||
desktop.Shutdown();
|
desktop.Shutdown();
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 786 B |
Binary file not shown.
|
After Width: | Height: | Size: 1002 B |
@@ -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
|
||||||
@@ -22,37 +27,57 @@ Set-StrictMode -Version Latest
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Add-Type -AssemblyName System.Drawing
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5B8CFF') # dodo_accent / AccentColor
|
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5D42DE') # dodo_accent / AccentColor
|
||||||
$ink = [System.Drawing.ColorTranslator]::FromHtml('#0E1220') # AccentInk
|
$ink = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') # AccentInk
|
||||||
|
|
||||||
# Every size Windows asks for: 16 in a titlebar and a tree, 32 on the desktop, 48 in a large-icon
|
# Every size Windows asks for: 16 in a titlebar and a tree, 32 on the desktop, 48 in a large-icon
|
||||||
# view, 256 for the preview pane. Shipping fewer means Windows downsamples one of these to fill
|
# view, 256 for the preview pane. Shipping fewer means Windows downsamples one of these to fill
|
||||||
# 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,120 @@ $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)"
|
||||||
|
|
||||||
|
# ---- dodossh-<size>.png, for the Linux desktop entry ---------------------------------------------
|
||||||
|
#
|
||||||
|
# The third platform, and the only one that wants the raster loose rather than in a container. A
|
||||||
|
# freedesktop icon theme is a directory tree — hicolor/48x48/apps/dodossh.png and friends — and the
|
||||||
|
# RPM built by scripts/ci-linux-rpm.sh installs one file per size into it.
|
||||||
|
#
|
||||||
|
# Written here for the reason this whole file exists: one geometry, regenerated in one command. The
|
||||||
|
# alternative was to have the packaging script pull the 256px frame out of dodossh.ico at build time,
|
||||||
|
# which works — the .ico's entries really are PNGs — and puts a second place that has to know how an
|
||||||
|
# ICO directory is laid out. It also silently ties the Linux icon to whatever sizes Windows happens
|
||||||
|
# to want.
|
||||||
|
#
|
||||||
|
# ◆ THE TILE BLEEDS TO THE EDGE HERE, AS IT DOES ON WINDOWS.
|
||||||
|
#
|
||||||
|
# tileFraction stays 1.0. The 0.8047 inset above is Apple's icon grid and nothing else — GNOME, KDE
|
||||||
|
# and every panel that reads hicolor draw the icon at the size they asked for, with no shadow and no
|
||||||
|
# magnification to leave air for. An inset icon would simply be the small one in the launcher.
|
||||||
|
#
|
||||||
|
# ◆ AND THE SIZES ARE NOT A ROUND NUMBER OF THEM.
|
||||||
|
#
|
||||||
|
# 48 is the size a GNOME dash and most panels ask for, 128 and 256 are what a large-icon file manager
|
||||||
|
# and GNOME Software use, and 64 is between them because the 48 upscaled to it is visibly soft. There
|
||||||
|
# is deliberately nothing below 48: the hairline floor in New-MarkPng keeps a 16px mark legible, but
|
||||||
|
# no Linux desktop asks this theme for one — window titlebars there draw no icon at all.
|
||||||
|
$linuxSizes = @(48, 64, 128, 256)
|
||||||
|
|
||||||
|
foreach ($size in $linuxSizes)
|
||||||
|
{
|
||||||
|
[byte[]]$png = New-MarkPng $size
|
||||||
|
$pngTarget = Join-Path $PSScriptRoot "dodossh-$size.png"
|
||||||
|
[System.IO.File]::WriteAllBytes($pngTarget, $png)
|
||||||
|
Write-Output "Wrote $pngTarget ($((Get-Item $pngTarget).Length) bytes)"
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.6 KiB |
@@ -42,6 +42,45 @@
|
|||||||
-->
|
-->
|
||||||
<ApplicationIcon>Assets/dodossh.ico</ApplicationIcon>
|
<ApplicationIcon>Assets/dodossh.ico</ApplicationIcon>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============ WHICH CHANNEL THIS BUILD BELONGS TO ============
|
||||||
|
|
||||||
|
The same split the Android head has, for the same reason and with one difference worth stating up
|
||||||
|
front: Android gets separation for free, because the platform refuses an update signed by a
|
||||||
|
different key, so its two channels cannot replace one another whatever anybody does. Nothing
|
||||||
|
refuses anything here. Velopack applies what its feed serves, so the separation has to be built:
|
||||||
|
two pack ids, two Velopack channels, two feeds, and a profile directory each.
|
||||||
|
|
||||||
|
release — DodoSSH.Desktop, Velopack channel win, read from the newest non-prerelease release.
|
||||||
|
Cut from a v* tag by scripts/release-windows.ps1, by a person. See ADR 0013 rule 3.
|
||||||
|
nightly — DodoSSH.Desktop.Nightly, Velopack channel win-nightly, read from a prerelease release
|
||||||
|
CI replaces on every push to main.
|
||||||
|
|
||||||
|
Default release, so an unqualified `dotnet build` is the real application and the nightly is the
|
||||||
|
one you have to ask for.
|
||||||
|
|
||||||
|
What this property does *not* decide is the pack id or the title. Those are arguments to vpk and
|
||||||
|
live where the packaging happens — in ci.yml for the nightly and in the release script for the
|
||||||
|
release. Putting them here would suggest the build knows which package it will end up inside,
|
||||||
|
and it does not.
|
||||||
|
-->
|
||||||
|
<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.
|
||||||
@@ -60,6 +99,16 @@
|
|||||||
<AvaloniaResource Include="Assets/dodossh.ico" />
|
<AvaloniaResource Include="Assets/dodossh.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!--
|
||||||
|
Which channel this build belongs to, carried in the assembly the same way the Android head carries
|
||||||
|
it. Metadata rather than a compile-time constant for the reason stated there: the updater needs the
|
||||||
|
string rather than a branch, and a value baked into the assembly is one a crash report can be asked
|
||||||
|
for. See DesktopChannel, which is the only thing that reads it.
|
||||||
|
-->
|
||||||
|
<AssemblyMetadata Include="DodoChannel" Value="$(DodoChannel)" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Two native symbol files, and they are the reason a self-contained publish weighed 227 MB.
|
Two native symbol files, and they are the reason a self-contained publish weighed 227 MB.
|
||||||
|
|
||||||
@@ -102,6 +151,14 @@
|
|||||||
interface declared in DodoSSH.Client.Session.
|
interface declared in DodoSSH.Client.Session.
|
||||||
-->
|
-->
|
||||||
<PackageReference Include="Velopack" />
|
<PackageReference Include="Velopack" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Here for the same reason and with the same boundary as Velopack above: LinuxDeviceKeyStore is a
|
||||||
|
Linux-only implementation of an interface declared in DodoSSH.Client.Session, and it lives in
|
||||||
|
Platform/ beside the Windows and macOS stores. The Android head has its own keystore and must
|
||||||
|
never see this one.
|
||||||
|
-->
|
||||||
|
<PackageReference Include="Microsoft.TSS" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which of the two desktop channels this build belongs to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The counterpart of the Android head's <c>DodoChannel</c> metadata, read the same way and for the same
|
||||||
|
/// reasons. Three things depend on the answer and they are listed here rather than discovered one at a
|
||||||
|
/// time: which Velopack channel the updater reads, whether that read considers prereleases, and which
|
||||||
|
/// profile directory this copy keeps its cache and device key in.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Android gets this separation from the platform and this head has to build it.</b> Two Android
|
||||||
|
/// channels cannot replace one another because the installer refuses a package signed by a different key.
|
||||||
|
/// Nothing refuses anything on Windows: Velopack applies what its feed serves, without verifying a
|
||||||
|
/// signature. So the two channels are kept apart by construction here — a pack id each, so they install
|
||||||
|
/// in different directories; a Velopack channel each, so neither ever reads the other's release index; and
|
||||||
|
/// a profile directory each, so a nightly cannot migrate the schema of a cache the release build is using.
|
||||||
|
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c> rule 9.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Resolved once, at first use. The channel of a build does not change while it runs, and a value read
|
||||||
|
/// per call site is one that eventually gets read differently in two places.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class DesktopChannel
|
||||||
|
{
|
||||||
|
/// <summary>The build this channel is not: the one a person cuts from a tag.</summary>
|
||||||
|
internal const string Release = "release";
|
||||||
|
|
||||||
|
/// <summary>The build CI publishes from main, which installs beside the release one.</summary>
|
||||||
|
internal const string Nightly = "nightly";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What this build says it is, defaulting to the release channel.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The csproj always writes the metadata, so the fallback covers a build that reached here some other
|
||||||
|
/// way — an assembly loaded by a test host, a designer, a trimmed-down copy. <see cref="Release"/> is
|
||||||
|
/// the safe answer for all of them: it is what the value was before there were two channels, and every
|
||||||
|
/// consequence of being wrong about it is inert. A build that is not installed has no updater at all
|
||||||
|
/// (see <c>UpdateChannels.ForThisMachine</c>), and the profile directory it names is the one every
|
||||||
|
/// existing install already uses.
|
||||||
|
/// </remarks>
|
||||||
|
internal static string Name { get; } = Read();
|
||||||
|
|
||||||
|
/// <summary>Whether this build belongs to the nightly channel.</summary>
|
||||||
|
internal static bool IsNightly => string.Equals(Name, Nightly, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static string Read()
|
||||||
|
{
|
||||||
|
var declared = typeof(DesktopChannel).Assembly
|
||||||
|
.GetCustomAttributes<AssemblyMetadataAttribute>()
|
||||||
|
.FirstOrDefault(attribute => string.Equals(attribute.Key, "DodoChannel", StringComparison.Ordinal))
|
||||||
|
?.Value;
|
||||||
|
|
||||||
|
// Only the two the csproj declares are honoured. An unrecognised value is a build made by
|
||||||
|
// something nobody here wrote, and answering "release" to it is the same inert default as
|
||||||
|
// answering it to no value at all — rather than pointing an updater at a feed named by a string
|
||||||
|
// of unknown origin.
|
||||||
|
return string.Equals(declared, Nightly, StringComparison.Ordinal) ? Nightly : Release;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using Tpm2Lib;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>Why the store is asking for a PIN.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The two cases need different words in front of a person — one is choosing a PIN and the other is
|
||||||
|
/// entering one — and the store is the only thing that knows which is happening. Passing an enum rather
|
||||||
|
/// than two delegates keeps the prompt a single seam.
|
||||||
|
/// </remarks>
|
||||||
|
public enum DevicePinPurpose
|
||||||
|
{
|
||||||
|
/// <summary>A PIN is being chosen, as part of registering this machine.</summary>
|
||||||
|
Choosing,
|
||||||
|
|
||||||
|
/// <summary>A PIN is being asked for, to unlock.</summary>
|
||||||
|
Entering,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps the device key sealed inside the TPM, released only against a PIN the TPM itself checks.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The Linux answer to <see cref="WindowsDeviceKeyStore"/> and <c>MacDeviceKeyStore</c>, and ADR 0007's
|
||||||
|
/// bar is the one it has to clear: <b>the enforcement must be the platform's rather than this process's.</b>
|
||||||
|
/// A store that drew its own prompt over a file it could read anyway would be the mistake that ADR's
|
||||||
|
/// 2026-07-30 amendment exists to record — malware running as the user skips the prompt and reads the file.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Here the TPM holds the key and the TPM checks the PIN.</b> The 32-byte device key is sealed into a
|
||||||
|
/// keyedhash object whose <c>userAuth</c> is derived from the PIN, so unsealing is a command the TPM
|
||||||
|
/// refuses without it. The blob on disk is encrypted to the TPM's storage hierarchy and is useless on any
|
||||||
|
/// other machine, and the PIN is never compared by anything in this assembly. Malware running as the user
|
||||||
|
/// can ask; it cannot answer.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>And the TPM counts the failures, which is what makes a short PIN safe.</b> The sealed object
|
||||||
|
/// deliberately does not carry <c>NoDA</c>, so every wrong answer advances the dictionary-attack counter
|
||||||
|
/// and the TPM stops answering at all once it trips. Measured against a software TPM: four wrong PINs and
|
||||||
|
/// it refuses the right one too, until the lockout recovers. That is the property a four-digit secret has
|
||||||
|
/// on this platform and cannot have in a file.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>What this does NOT match, stated because the difference is real and invisible.</b> Windows Hello
|
||||||
|
/// and Touch ID raise their prompt from the platform — a secure desktop on one, a system sheet on the
|
||||||
|
/// other — so the process asking never sees the secret. Linux has no equivalent to borrow, so the PIN is
|
||||||
|
/// typed into a window this application draws, and a sufficiently determined attacker already running as
|
||||||
|
/// the user could draw a convincing copy of it. The mitigation is that they could do the same for the
|
||||||
|
/// passphrase screen, which is a much larger prize, so this is not a capability the feature creates. It is
|
||||||
|
/// still a weaker guarantee than the other two desktops have and ADR 0007 says so rather than implying
|
||||||
|
/// parity.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Availability still depends on reaching the device, and the package arranges that rather than asking
|
||||||
|
/// the user to.</b> Fedora leaves <c>/dev/tpmrm0</c> as <c>root:tss</c> mode 0660, so without help this
|
||||||
|
/// process could not open it on an ordinary desktop account. The RPM installs
|
||||||
|
/// <c>build/linux/71-dodossh-tpm.rules</c>, which tags the device <c>uaccess</c> — logind then grants an
|
||||||
|
/// ACL to whoever is logged in at the machine, and takes it away when they log out. Not over SSH and not
|
||||||
|
/// to a user on an inactive VT.
|
||||||
|
/// <para>
|
||||||
|
/// So a build run from a checkout, or one on a machine where an administrator has masked that rule, still
|
||||||
|
/// finds the device unreadable and reports unavailable — which keeps the passphrase path exactly as it was.
|
||||||
|
/// The rule carries its own reasoning, including the lockout denial-of-service it accepts; see ADR 0007
|
||||||
|
/// decision 4.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
|
public sealed class LinuxDeviceKeyStore : IDeviceKeyStore
|
||||||
|
{
|
||||||
|
/// <summary>What the blob on disk starts with, so a file from elsewhere is refused rather than parsed.</summary>
|
||||||
|
private static readonly byte[] Magic = "DSHTPMv1"u8.ToArray();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Domain separation for the PIN, so the value handed to the TPM is this application's and not the
|
||||||
|
/// user's PIN as typed.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The TPM compares an authValue of at most one digest in length, so something has to reduce a typed
|
||||||
|
/// string to bytes; hashing with a label rather than truncating means a PIN reused elsewhere does not
|
||||||
|
/// arrive at the TPM as the same bytes it does there. It buys nothing against an attacker who has the
|
||||||
|
/// blob and the machine — they have this constant too — and it costs one line.
|
||||||
|
/// </remarks>
|
||||||
|
private const string PinLabel = "dodossh/devicekey/pin/v1";
|
||||||
|
|
||||||
|
private readonly ClientPaths paths;
|
||||||
|
private readonly Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin;
|
||||||
|
private readonly Func<Tpm2Device> openDevice;
|
||||||
|
|
||||||
|
/// <summary>Creates a store against this machine's TPM.</summary>
|
||||||
|
/// <param name="paths">Where the sealed blob lives.</param>
|
||||||
|
/// <param name="askForPin">
|
||||||
|
/// Shows the PIN prompt and answers with what was typed, or null if the user declined. Declining is
|
||||||
|
/// ordinary — see <see cref="IDeviceKeyStore"/> — and lands on the passphrase.
|
||||||
|
/// </param>
|
||||||
|
public LinuxDeviceKeyStore(
|
||||||
|
ClientPaths paths,
|
||||||
|
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
||||||
|
: this(paths, askForPin, static () => new LinuxTpmDevice())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a store against a named TPM, which is how the tests reach a simulator.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The seam exists because this class is testable in a way the other two stores are not, and that is
|
||||||
|
/// worth keeping. Windows and macOS put the prompt inside the platform, so a test that exercised
|
||||||
|
/// sealing would block forever on a dialog — <c>WindowsDeviceKeyStoreTests</c> records the ten minutes
|
||||||
|
/// that took to discover. Here the prompt is a delegate and the TPM is a device, so both can be
|
||||||
|
/// supplied and the whole path runs unattended.
|
||||||
|
/// </remarks>
|
||||||
|
internal LinuxDeviceKeyStore(
|
||||||
|
ClientPaths paths,
|
||||||
|
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin,
|
||||||
|
Func<Tpm2Device> openDevice)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
ArgumentNullException.ThrowIfNull(askForPin);
|
||||||
|
ArgumentNullException.ThrowIfNull(openDevice);
|
||||||
|
|
||||||
|
this.paths = paths;
|
||||||
|
this.askForPin = askForPin;
|
||||||
|
this.openDevice = openDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The parent every sealed object hangs off, as a template rather than as a stored handle.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Recreated from a fixed template on every use, and not made persistent.</b> A persistent handle
|
||||||
|
/// would occupy one of the TPM's small number of NV slots forever, would have to be cleaned up by
|
||||||
|
/// <see cref="ForgetAsync"/>, and would collide with whatever else on the machine had chosen the same
|
||||||
|
/// index. Deriving it costs a command and leaves nothing behind.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>ECC P-256 rather than RSA-2048, and that is a user-visible choice.</b> The two are equally
|
||||||
|
/// standard as storage roots. Creating an RSA primary on a discrete TPM takes seconds — the key is
|
||||||
|
/// generated, not derived — and this runs on the unlock path, so it would show up as the application
|
||||||
|
/// hanging every time somebody opened their vault. The prototype this was built from used RSA and that
|
||||||
|
/// is exactly what it was changed for.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>NoDA</c> is set <em>here</em> and deliberately not on the sealed object. The parent has no
|
||||||
|
/// authValue to guess, so counting failures against it would only give an attacker a way to lock the
|
||||||
|
/// machine's TPM out by asking for something they were never going to get.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static TpmPublic PrimaryTemplate => new(
|
||||||
|
TpmAlgId.Sha256,
|
||||||
|
ObjectAttr.Restricted | ObjectAttr.Decrypt | ObjectAttr.FixedTPM | ObjectAttr.FixedParent
|
||||||
|
| ObjectAttr.SensitiveDataOrigin | ObjectAttr.UserWithAuth | ObjectAttr.NoDA,
|
||||||
|
null,
|
||||||
|
new EccParms(
|
||||||
|
new SymDefObject(TpmAlgId.Aes, 128, TpmAlgId.Cfb),
|
||||||
|
new NullAsymScheme(),
|
||||||
|
EccCurve.TpmEccNistP256,
|
||||||
|
new NullKdfScheme()),
|
||||||
|
new EccPoint());
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The sealed object: bytes the TPM holds, released against an authValue the TPM checks.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Keyedhash with a null scheme is TPM 2.0's "just keep these bytes" object. <c>UserWithAuth</c> is
|
||||||
|
/// what makes the authValue the thing that opens it, and the deliberate <em>absence</em> of
|
||||||
|
/// <c>NoDA</c> is what puts every wrong PIN on the dictionary-attack counter. Removing that one
|
||||||
|
/// attribute would leave a four-digit secret that could be guessed at full speed by anything holding
|
||||||
|
/// the blob and the machine, which is the whole of the protection this class offers.
|
||||||
|
/// </remarks>
|
||||||
|
private static TpmPublic SealedTemplate => new(
|
||||||
|
TpmAlgId.Sha256,
|
||||||
|
ObjectAttr.FixedTPM | ObjectAttr.FixedParent | ObjectAttr.UserWithAuth,
|
||||||
|
null,
|
||||||
|
new KeyedhashParms(new NullSchemeKeyedhash()),
|
||||||
|
new Tpm2bDigestKeyedhash());
|
||||||
|
|
||||||
|
/// <summary>Whether this machine has a TPM this process may use.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Probes by creating and flushing a primary rather than by looking for the device node, for the
|
||||||
|
/// reason <c>WindowsDeviceKeyStore.IsSupported</c> gives about its own provider: the node can be
|
||||||
|
/// present on a machine where the first real command then fails. Permissions are the common case here
|
||||||
|
/// and they fail at open; an owner hierarchy with an authValue set is the rarer one and fails at
|
||||||
|
/// CreatePrimary. Neither is a fault and both mean the same thing.
|
||||||
|
/// </remarks>
|
||||||
|
internal static bool IsSupported() => IsSupported(static () => new LinuxTpmDevice());
|
||||||
|
|
||||||
|
/// <inheritdoc cref="IsSupported()" />
|
||||||
|
internal static bool IsSupported(Func<Tpm2Device> openDevice)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(openDevice);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var device = openDevice();
|
||||||
|
device.Connect();
|
||||||
|
|
||||||
|
var tpm = new Tpm2(device);
|
||||||
|
var primary = CreatePrimary(tpm);
|
||||||
|
|
||||||
|
tpm.FlushContext(primary);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// Every way this fails means the same thing to the caller: no device key here, so keep asking
|
||||||
|
// for the passphrase. A missing device node throws IOException, a device this user may not
|
||||||
|
// open throws UnauthorizedAccessException, and a TPM that refuses the command throws
|
||||||
|
// TpmException — and on a machine with no TPM at all the constructor itself may throw before
|
||||||
|
// any of that.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||||
|
ValueTask.FromResult(IsSupported(openDevice));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Asks for a PIN first and writes nothing if the user declines, which matters more here than the
|
||||||
|
/// symmetry suggests: <c>SaveAsync</c> is called as part of registering this machine, and a half-done
|
||||||
|
/// registration would leave a wrap on the server that nothing can open.
|
||||||
|
/// </remarks>
|
||||||
|
public async ValueTask SaveAsync(
|
||||||
|
ReadOnlyMemory<byte> devicePrivateKey,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var pin = await askForPin(DevicePinPurpose.Choosing, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(pin))
|
||||||
|
{
|
||||||
|
throw new OperationCanceledException("No PIN was chosen, so no device key was stored.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = devicePrivateKey.ToArray();
|
||||||
|
|
||||||
|
// Off the calling thread, because every command below blocks on the chip. Creating the primary
|
||||||
|
// alone is hundreds of milliseconds on real hardware, and this is called from the unlock path —
|
||||||
|
// which on the desktop head is the UI thread.
|
||||||
|
var packed = await Task.Run(() => Seal(pin, key), cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
paths.EnsureCreated();
|
||||||
|
|
||||||
|
await File.WriteAllBytesAsync(paths.DeviceKeyFile, packed, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seals the key into the TPM and answers with the blob to store.</summary>
|
||||||
|
private byte[] Seal(string pin, byte[] devicePrivateKey)
|
||||||
|
{
|
||||||
|
var auth = PinAuth(pin);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var device = openDevice();
|
||||||
|
device.Connect();
|
||||||
|
|
||||||
|
var tpm = new Tpm2(device);
|
||||||
|
var primary = CreatePrimary(tpm);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var priv = tpm.Create(
|
||||||
|
primary,
|
||||||
|
new SensitiveCreate(auth, devicePrivateKey),
|
||||||
|
SealedTemplate,
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
out var pub,
|
||||||
|
out _,
|
||||||
|
out _,
|
||||||
|
out _);
|
||||||
|
|
||||||
|
return Pack(pub, priv);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
tpm.FlushContext(primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(auth);
|
||||||
|
CryptographicOperations.ZeroMemory(devicePrivateKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One attempt per call, and a wrong PIN is not retried here.</b> Every wrong answer advances the
|
||||||
|
/// TPM's lockout counter, so a loop offering three tries would spend a third of the machine's budget
|
||||||
|
/// on one mistyped digit. Returning null lands on the passphrase, which always works; somebody who
|
||||||
|
/// wants to try the PIN again asks for a device unlock again, which is a deliberate act rather than a
|
||||||
|
/// loop.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A locked-out TPM is deliberately indistinguishable from a wrong PIN.</b>
|
||||||
|
/// <see cref="IDeviceKeyStore.TryLoadAsync"/> asks for exactly that and the reasoning holds here: the
|
||||||
|
/// caller does the same thing in every case, and "your TPM is in lockout" describes the keystore
|
||||||
|
/// rather than telling somebody something they can act on — the answer is still to type the
|
||||||
|
/// passphrase.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!File.Exists(paths.DeviceKeyFile))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!TryUnpack(stored, out var pub, out var priv))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the blob is known to be readable and before anything is asked of the user, because a
|
||||||
|
// prompt raised over a file this cannot use is a question with no useful answer.
|
||||||
|
var pin = await askForPin(DevicePinPurpose.Entering, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(pin))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Task.Run(() => Unseal(pin, pub, priv), cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks the TPM to release the key, and answers null however it declines.</summary>
|
||||||
|
private byte[]? Unseal(string pin, TpmPublic pub, TpmPrivate priv)
|
||||||
|
{
|
||||||
|
var auth = PinAuth(pin);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var device = openDevice();
|
||||||
|
device.Connect();
|
||||||
|
|
||||||
|
var tpm = new Tpm2(device);
|
||||||
|
var primary = CreatePrimary(tpm);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var loaded = tpm.Load(primary, priv, pub);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return tpm[auth].Unseal(loaded);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
tpm.FlushContext(loaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
tpm.FlushContext(primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// TpmException covers the wrong PIN and the lockout; the rest covers a TPM that has gone away
|
||||||
|
// between the availability check and now, which a suspend or a firmware update can do.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(auth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Deleting the blob is the whole of it, and that is a property of sealing rather than an omission.
|
||||||
|
/// The object lives nowhere but in that file — it was never made persistent inside the TPM, see
|
||||||
|
/// <see cref="PrimaryTemplate"/> — so the bytes are unrecoverable the moment it is gone. There is no
|
||||||
|
/// TPM state to clean up and nothing left behind for a later install to trip over.
|
||||||
|
/// </remarks>
|
||||||
|
public ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (File.Exists(paths.DeviceKeyFile))
|
||||||
|
{
|
||||||
|
File.Delete(paths.DeviceKeyFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Derives the TPM authValue from the PIN as typed.</summary>
|
||||||
|
private static byte[] PinAuth(string pin) =>
|
||||||
|
SHA256.HashData(Encoding.UTF8.GetBytes($"{PinLabel} {pin}"));
|
||||||
|
|
||||||
|
private static TpmHandle CreatePrimary(Tpm2 tpm) =>
|
||||||
|
tpm.CreatePrimary(
|
||||||
|
TpmRh.Owner,
|
||||||
|
new SensitiveCreate(),
|
||||||
|
PrimaryTemplate,
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
out _,
|
||||||
|
out _,
|
||||||
|
out _,
|
||||||
|
out _);
|
||||||
|
|
||||||
|
/// <summary>Puts the two halves of the sealed object in one file.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both are needed to load it and neither is secret — the private half is encrypted to this TPM's
|
||||||
|
/// storage hierarchy, which is what makes the file useless anywhere else. Length-prefixed rather than
|
||||||
|
/// concatenated because their sizes vary with the TPM's algorithms, and magic-prefixed so that a file
|
||||||
|
/// left by another version, or by the Windows store on a dual-boot profile directory, is refused
|
||||||
|
/// rather than fed to the TPM as a malformed structure.
|
||||||
|
/// </remarks>
|
||||||
|
private static byte[] Pack(TpmPublic pub, TpmPrivate priv)
|
||||||
|
{
|
||||||
|
var publicPart = pub.GetTpmRepresentation();
|
||||||
|
var privatePart = priv.GetTpmRepresentation();
|
||||||
|
|
||||||
|
var packed = new byte[Magic.Length + 4 + publicPart.Length + 4 + privatePart.Length];
|
||||||
|
var at = Magic.Length;
|
||||||
|
|
||||||
|
Magic.CopyTo(packed.AsSpan());
|
||||||
|
|
||||||
|
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), publicPart.Length);
|
||||||
|
at += 4;
|
||||||
|
publicPart.CopyTo(packed.AsSpan(at));
|
||||||
|
at += publicPart.Length;
|
||||||
|
|
||||||
|
BinaryPrimitives.WriteInt32BigEndian(packed.AsSpan(at), privatePart.Length);
|
||||||
|
at += 4;
|
||||||
|
privatePart.CopyTo(packed.AsSpan(at));
|
||||||
|
|
||||||
|
return packed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads back what <see cref="Pack"/> wrote, or answers false.</summary>
|
||||||
|
private static bool TryUnpack(byte[] stored, out TpmPublic pub, out TpmPrivate priv)
|
||||||
|
{
|
||||||
|
pub = new TpmPublic();
|
||||||
|
priv = new TpmPrivate();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (stored.Length < Magic.Length + 8 || !stored.AsSpan(0, Magic.Length).SequenceEqual(Magic))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var at = Magic.Length;
|
||||||
|
|
||||||
|
var publicLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
|
||||||
|
at += 4;
|
||||||
|
|
||||||
|
if (publicLength < 0 || at + publicLength + 4 > stored.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var publicPart = stored.AsSpan(at, publicLength).ToArray();
|
||||||
|
at += publicLength;
|
||||||
|
|
||||||
|
var privateLength = BinaryPrimitives.ReadInt32BigEndian(stored.AsSpan(at));
|
||||||
|
at += 4;
|
||||||
|
|
||||||
|
if (privateLength < 0 || at + privateLength != stored.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var privatePart = stored.AsSpan(at, privateLength).ToArray();
|
||||||
|
|
||||||
|
pub = Marshaller.FromTpmRepresentation<TpmPublic>(publicPart);
|
||||||
|
priv = Marshaller.FromTpmRepresentation<TpmPrivate>(privatePart);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// A blob that does not parse is a blob this machine cannot use, which is the same answer as no
|
||||||
|
// blob at all. Throwing here would mean a corrupted file stopped the application from starting.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Text.Json.Serialization.Metadata;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>What a channel's release says it is publishing.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Written by <c>scripts/ci-linux-rpm.sh</c> and read here, and it is a small file this repository
|
||||||
|
/// controls rather than the forge's own release JSON for the reason the android manifest gives: this is
|
||||||
|
/// fetched on a six-hourly timer, and a document whose shape changes when the forge is upgraded is not a
|
||||||
|
/// contract.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The comparison is <see cref="RpmVersion"/> and never <see cref="Version"/>.</b> That is the string
|
||||||
|
/// <c>rpm</c> will order the install against, so comparing anything else would let this offer an update
|
||||||
|
/// PackageKit then refuses — the same argument <c>AndroidChannelManifest</c> makes for <c>versionCode</c>.
|
||||||
|
/// The two differ exactly where it matters: <c>0.0.1-alpha.0.143</c> and <c>0.0.1~alpha.0.143</c> are one
|
||||||
|
/// build, and only the second sorts below <c>0.0.1</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="RpmVersion">The version-release string rpm knows the package by.</param>
|
||||||
|
/// <param name="Version">What that build calls itself, for a human.</param>
|
||||||
|
/// <param name="Rpm">The asset on the same release that holds it.</param>
|
||||||
|
/// <param name="Sha256">The package's digest, lower-case hex.</param>
|
||||||
|
internal sealed record LinuxChannelManifest(
|
||||||
|
[property: JsonPropertyName("rpmVersion")] string RpmVersion,
|
||||||
|
[property: JsonPropertyName("version")] string Version,
|
||||||
|
[property: JsonPropertyName("rpm")] string Rpm,
|
||||||
|
[property: JsonPropertyName("sha256")] string Sha256);
|
||||||
|
|
||||||
|
/// <summary>One release as the forge describes it. Only the assets are read.</summary>
|
||||||
|
internal sealed record LinuxForgeRelease(
|
||||||
|
[property: JsonPropertyName("assets")] IReadOnlyList<LinuxForgeAsset>? Assets);
|
||||||
|
|
||||||
|
/// <summary>One file attached to a release.</summary>
|
||||||
|
internal sealed record LinuxForgeAsset(
|
||||||
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
[property: JsonPropertyName("browser_download_url")] string? DownloadUrl);
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||||
|
[JsonSerializable(typeof(LinuxChannelManifest))]
|
||||||
|
[JsonSerializable(typeof(LinuxForgeRelease))]
|
||||||
|
internal sealed partial class LinuxForgeJsonContext : JsonSerializerContext;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches a newer RPM from the project's own forge and asks the system's package manager to install it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing here replaces anything.</b> That is the difference from
|
||||||
|
/// <see cref="VelopackUpdateChannel"/> and it is the decision ADR 0013 rule 11 records rather than an
|
||||||
|
/// unfinished half: on this platform the package manager is already an updater the user trusts, audits and
|
||||||
|
/// controls, so this class does the two things the package manager cannot — notice that a build exists and
|
||||||
|
/// fetch it — and then hands the file to PackageKit, which raises polkit and asks. The shape is the
|
||||||
|
/// phone's, not the desktop's: <see cref="ApplyingEndsTheProcess"/> is false, and the answer may be no.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The feed is a constant and the deployment is never asked.</b> ADR 0011 rule 2, carried unchanged
|
||||||
|
/// from both other channels: an operator who could answer the update check could pin a chosen user to a
|
||||||
|
/// build with a known hole, without holding any key at all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The digest is checked and is not a signature.</b> Velopack verifies what it downloads and Android
|
||||||
|
/// refuses a package whose signature does not match the installed one; an RPM fetched over HTTPS and
|
||||||
|
/// handed to PackageKit has neither. So the manifest names a digest and this refuses anything else —
|
||||||
|
/// which catches a truncated download, a caching proxy and a corrupted mirror, and catches nothing at all
|
||||||
|
/// about who built the file, because the same job wrote both. Provenance here is the constant address; a
|
||||||
|
/// signed repository is what would improve on it, and ADR 0013 rule 11 says why there is not one yet.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
|
internal sealed class LinuxUpdateChannel : IUpdateChannel
|
||||||
|
{
|
||||||
|
/// <summary>The project's own forge, and the one address in this file.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The owner half of the path matters for the reason <c>AndroidUpdateChannel</c> records: the
|
||||||
|
/// repository moved between organisations and Gitea leaves a 301 behind, so a client naming the old
|
||||||
|
/// one works until somebody deletes the redirect.
|
||||||
|
/// </remarks>
|
||||||
|
private const string RepositoryApi = "https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH";
|
||||||
|
|
||||||
|
private readonly string channel;
|
||||||
|
private readonly string packageName;
|
||||||
|
private readonly string dataDirectory;
|
||||||
|
private readonly HttpClient http;
|
||||||
|
private readonly Func<string, string?> installedVersion;
|
||||||
|
private readonly Action<string> handToInstaller;
|
||||||
|
|
||||||
|
private (string Version, string RpmVersion, string Url, string Sha256)? found;
|
||||||
|
private string? fetched;
|
||||||
|
|
||||||
|
/// <summary>Creates a channel for the running build.</summary>
|
||||||
|
internal LinuxUpdateChannel(string channel)
|
||||||
|
: this(
|
||||||
|
channel,
|
||||||
|
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
|
||||||
|
new HttpClient(),
|
||||||
|
InstalledRpmVersion,
|
||||||
|
HandToPackageKit)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a channel with its outside world supplied, which is how the tests reach it.</summary>
|
||||||
|
internal LinuxUpdateChannel(
|
||||||
|
string channel,
|
||||||
|
string dataDirectory,
|
||||||
|
HttpClient http,
|
||||||
|
Func<string, string?> installedVersion,
|
||||||
|
Action<string> handToInstaller)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(channel);
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(dataDirectory);
|
||||||
|
ArgumentNullException.ThrowIfNull(http);
|
||||||
|
ArgumentNullException.ThrowIfNull(installedVersion);
|
||||||
|
ArgumentNullException.ThrowIfNull(handToInstaller);
|
||||||
|
|
||||||
|
this.channel = channel;
|
||||||
|
this.dataDirectory = dataDirectory;
|
||||||
|
this.http = http;
|
||||||
|
this.installedVersion = installedVersion;
|
||||||
|
this.handToInstaller = handToInstaller;
|
||||||
|
|
||||||
|
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal)
|
||||||
|
? "dodossh-nightly"
|
||||||
|
: "dodossh";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// True unconditionally, because whether this copy is one that can be updated was already decided by
|
||||||
|
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class
|
||||||
|
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at
|
||||||
|
/// composition rather than at every call site.
|
||||||
|
/// </remarks>
|
||||||
|
public bool IsSupported => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string CurrentVersion => ClientVersion.Current;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// False, and for the phone's reason rather than the desktop's. Applying hands a file to PackageKit
|
||||||
|
/// and returns; polkit then asks, and the user may say no. A caller that had already torn the vault
|
||||||
|
/// down would answer "not now" with a locked vault and every shell gone.
|
||||||
|
/// </remarks>
|
||||||
|
public bool ApplyingEndsTheProcess => false;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <exception cref="HttpRequestException">The forge could not be reached, or refused.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">The release is there and does not carry a usable build.</exception>
|
||||||
|
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var release = await ReadAsync(
|
||||||
|
ReleaseUrl(),
|
||||||
|
LinuxForgeJsonContext.Default.LinuxForgeRelease,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var manifestName = $"linux-{channel}.json";
|
||||||
|
|
||||||
|
if (Asset(release, manifestName) is not { } manifestAsset)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"The {channel} release carries no {manifestName}, so there is nothing saying what it "
|
||||||
|
+ "publishes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = await ReadAsync(
|
||||||
|
manifestAsset,
|
||||||
|
LinuxForgeJsonContext.Default.LinuxChannelManifest,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (manifest is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"The {channel} release's {manifestName} could not be read as a manifest.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one place null is returned, and it means what null is documented to mean: this build is
|
||||||
|
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
|
||||||
|
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Asset(release, manifest.Rpm) is not { } rpm)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"The {channel} release advertises {manifest.Version} but carries no {manifest.Rpm}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
found = (manifest.Version, manifest.RpmVersion, rpm, manifest.Sha256);
|
||||||
|
|
||||||
|
return new AvailableUpdate(manifest.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DownloadAsync(
|
||||||
|
AvailableUpdate update,
|
||||||
|
IProgress<int> progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(update);
|
||||||
|
ArgumentNullException.ThrowIfNull(progress);
|
||||||
|
|
||||||
|
if (found is not { } target
|
||||||
|
|| !string.Equals(target.Version, update.Version, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"That update did not come from this channel's last check. Check again before downloading.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under the profile directory rather than /tmp, because a package this size on a tmpfs is a
|
||||||
|
// machine that runs out of memory downloading an update. One name, overwritten, so a client that
|
||||||
|
// fetched three updates it never installed is not holding three hundred megabytes.
|
||||||
|
var directory = Path.Combine(dataDirectory, "updates");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
var path = Path.Combine(directory, $"{packageName}-update.rpm");
|
||||||
|
|
||||||
|
await FetchAsync(target.Url, path, progress, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// ◆ Verified before it is ever named to the installer, and the file is removed if it does not
|
||||||
|
// match. Leaving a package that failed its digest on disk would put a file somebody could install
|
||||||
|
// by hand next to a filename that says it is an update.
|
||||||
|
var digest = Convert.ToHexStringLower(await Sha256OfAsync(path, cancellationToken).ConfigureAwait(false));
|
||||||
|
|
||||||
|
if (!string.Equals(digest, target.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"The downloaded package is not the one {channel} published: expected {target.Sha256}, "
|
||||||
|
+ $"got {digest}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fetched = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Streams one URL to one path, reporting progress as it goes.</summary>
|
||||||
|
private async Task FetchAsync(
|
||||||
|
string url,
|
||||||
|
string path,
|
||||||
|
IProgress<int> progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await http
|
||||||
|
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var total = response.Content.Headers.ContentLength ?? 0;
|
||||||
|
|
||||||
|
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var destination = File.Create(path);
|
||||||
|
|
||||||
|
await using (stream.ConfigureAwait(false))
|
||||||
|
await using (destination.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var buffer = new byte[81920];
|
||||||
|
long copied = 0;
|
||||||
|
int read;
|
||||||
|
|
||||||
|
while ((read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
|
||||||
|
{
|
||||||
|
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
copied += read;
|
||||||
|
|
||||||
|
if (total > 0)
|
||||||
|
{
|
||||||
|
progress.Report((int)(copied * 100 / total));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>This installs nothing and returns immediately.</b> It asks PackageKit — the freedesktop service
|
||||||
|
/// every RPM distribution runs — to install a local file, which raises a polkit prompt drawn by the
|
||||||
|
/// desktop rather than by this application. The user may refuse, and refusing is not a failure.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The process is not restarted and must not be.</b> Replacing files under a running process is
|
||||||
|
/// ordinary on Linux: the old inodes stay mapped and this build keeps running until somebody closes
|
||||||
|
/// it. That is the right behaviour for an application that deliberately keeps shells open across a
|
||||||
|
/// lock — the same argument ADR 0013 makes for waiting for a restart the user asks for — and it is why
|
||||||
|
/// the name inherited from the interface reads oddly here. There is no equivalent of Android's
|
||||||
|
/// "the platform ends the process", so nothing ends it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public void ApplyAndRestart(AvailableUpdate update)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(update);
|
||||||
|
|
||||||
|
if (fetched is not { } path || !File.Exists(path))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("There is no downloaded update to install.");
|
||||||
|
}
|
||||||
|
|
||||||
|
handToInstaller(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Which release this build's channel reads.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>releases/latest</c> skips prereleases, which keeps the nightly — published as one — out of the
|
||||||
|
/// release channel's answer even though both live on the same forge.
|
||||||
|
/// </remarks>
|
||||||
|
private string ReleaseUrl() =>
|
||||||
|
string.Equals(channel, "nightly", StringComparison.Ordinal)
|
||||||
|
? $"{RepositoryApi}/releases/tags/nightly-desktop"
|
||||||
|
: $"{RepositoryApi}/releases/latest";
|
||||||
|
|
||||||
|
private static string? Asset(LinuxForgeRelease? release, string name) =>
|
||||||
|
release?.Assets?.FirstOrDefault(
|
||||||
|
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
|
||||||
|
|
||||||
|
private async Task<T?> ReadAsync<T>(
|
||||||
|
string url,
|
||||||
|
JsonTypeInfo<T> shape,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var stream = await http.GetStreamAsync(url, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using (stream.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return await JsonSerializer
|
||||||
|
.DeserializeAsync(stream, shape, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> Sha256OfAsync(string path, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var stream = File.OpenRead(path);
|
||||||
|
|
||||||
|
await using (stream.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What rpm records for the installed package, which is the number a newer build must beat.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Read from the package database rather than from the assembly, for the reason
|
||||||
|
/// <c>AndroidUpdateChannel.ReadInstalledVersionCode</c> gives about reading it from the platform: the
|
||||||
|
/// assembly's version is a SemVer string and this comparison has to be the one the installer will
|
||||||
|
/// make. Deriving the rpm version from the assembly here would mean a second copy of the translation
|
||||||
|
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every
|
||||||
|
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then
|
||||||
|
/// an install PackageKit declines rather than a security fix that never arrives.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static string? InstalledRpmVersion(string packageName)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(new ProcessStartInfo("rpm")
|
||||||
|
{
|
||||||
|
ArgumentList = { "-q", "--queryformat", "%{VERSION}-%{RELEASE}", packageName },
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var answer = process.StandardOutput.ReadToEnd().Trim();
|
||||||
|
|
||||||
|
process.WaitForExit(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
return process.ExitCode == 0 && answer.Length > 0 ? answer : null;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks PackageKit to install a local file, and lets polkit do the asking.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Over <c>gdbus</c> rather than a D-Bus library, and that is a dependency argument rather than a
|
||||||
|
/// stylistic one.</b> <c>gdbus</c> ships in glib2, which this package already depends on twice over —
|
||||||
|
/// GTK and WebKitGTK are the terminal — so it is present on every machine that can run this at all.
|
||||||
|
/// Adding a managed D-Bus stack to reach two method calls would be a package, a lock file entry and a
|
||||||
|
/// second thing to keep current, for no capability. <c>BrowserLauncher</c> settles the same trade the
|
||||||
|
/// same way one layer down.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Two calls, because PackageKit's interface is a factory.</b> <c>CreateTransaction</c> returns an
|
||||||
|
/// object path and the work is asked of that; a transaction is single-use, so this cannot be cached.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The flags word is zero, and zero is load-bearing.</b> Bit 0 is <c>ONLY_TRUSTED</c>. This
|
||||||
|
/// package is not signed — ADR 0013 rule 11 says why, and why that is not decision 8's "unsigned for
|
||||||
|
/// now" — so requiring trust would make every update fail at the last step. Clearing it is what routes
|
||||||
|
/// the request to polkit's <c>package-install-untrusted</c> action, which asks for an administrator
|
||||||
|
/// rather than proceeding quietly. The user sees what they are agreeing to.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void HandToPackageKit(string path)
|
||||||
|
{
|
||||||
|
var transaction = Gdbus(
|
||||||
|
"--system",
|
||||||
|
"--dest", "org.freedesktop.PackageKit",
|
||||||
|
"--object-path", "/org/freedesktop/PackageKit",
|
||||||
|
"--method", "org.freedesktop.PackageKit.CreateTransaction");
|
||||||
|
|
||||||
|
// gdbus prints a GVariant tuple — (objectpath '/17_beadcfae',) — and the path is the only thing
|
||||||
|
// in it. Parsed by taking what is between the quotes rather than with a regular expression over a
|
||||||
|
// format nobody has promised to keep.
|
||||||
|
var opened = transaction.IndexOf('\'', StringComparison.Ordinal);
|
||||||
|
var closed = transaction.LastIndexOf('\'');
|
||||||
|
|
||||||
|
if (opened < 0 || closed <= opened)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"PackageKit did not return a transaction to install into: {transaction}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var objectPath = transaction[(opened + 1)..closed];
|
||||||
|
|
||||||
|
Gdbus(
|
||||||
|
"--system",
|
||||||
|
"--dest", "org.freedesktop.PackageKit",
|
||||||
|
"--object-path", objectPath,
|
||||||
|
"--method", "org.freedesktop.PackageKit.Transaction.InstallFiles",
|
||||||
|
"0",
|
||||||
|
$"['{path}']");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs one gdbus call and answers with its output.</summary>
|
||||||
|
private static string Gdbus(params string[] arguments)
|
||||||
|
{
|
||||||
|
var start = new ProcessStartInfo("gdbus")
|
||||||
|
{
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var argument in arguments)
|
||||||
|
{
|
||||||
|
start.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var process = Process.Start(start)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"gdbus could not be started, so the package could not be handed to PackageKit.");
|
||||||
|
|
||||||
|
var output = process.StandardOutput.ReadToEnd();
|
||||||
|
var error = process.StandardError.ReadToEnd();
|
||||||
|
|
||||||
|
process.WaitForExit(TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
{
|
||||||
|
// The message is shown to the user by UpdateViewModel, so it says what to do rather than what
|
||||||
|
// failed. A missing PackageKit is the likely cause on a minimal install and is the one thing
|
||||||
|
// they can fix.
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
string.Format(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
"PackageKit would not take the package. Install it with `sudo dnf install PackageKit`, "
|
||||||
|
+ "or install the file yourself: {0}. ({1})",
|
||||||
|
arguments[^1],
|
||||||
|
error.Trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compares two RPM version strings the way <c>rpm</c> itself does.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why this exists rather than a SemVer comparison.</b> <c>AndroidUpdateChannel</c> settles the same
|
||||||
|
/// question and its reasoning carries over exactly: the comparison has to be the one the installer will
|
||||||
|
/// make, because anything else can offer an update the platform then refuses. On the phone that number is
|
||||||
|
/// Android's <c>versionCode</c>; here it is the RPM version, and the authority that reads it is
|
||||||
|
/// <c>rpm</c> — so this is <c>rpmvercmp</c> and not an approximation of it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It matters in a specific direction. The nightly channel is rolling and CI replaces it on every push, so
|
||||||
|
/// the published version normally only rises — but a revert, a force-push or a re-run of an older commit
|
||||||
|
/// can lower it, and a client comparing "is this different from mine" would then offer a downgrade that
|
||||||
|
/// PackageKit declines. The user sees an update that fails, every six hours, forever.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The algorithm is rpm's, including the parts that look like bugs.</b> Segments of digits and segments
|
||||||
|
/// of letters are compared separately and never against each other; everything that is neither is a
|
||||||
|
/// separator, so <c>1.0_1</c> and <c>1.0.1</c> are equal. Leading zeros are stripped, so <c>1.01</c> and
|
||||||
|
/// <c>1.1</c> are equal too. <c>~</c> sorts before anything including the end of the string, which is what
|
||||||
|
/// makes <c>0.0.1~alpha.0.143</c> older than <c>0.0.1</c> and is the whole reason
|
||||||
|
/// <c>scripts/ci-linux-rpm.sh</c> translates a SemVer prerelease to one. <c>^</c> sorts after everything
|
||||||
|
/// except the end of the string.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Verified against <c>rpm</c>'s own answers rather than against a reading of the source: the cases in
|
||||||
|
/// <c>RpmVersionTests</c> were generated by running <c>rpm.vercmp</c> in a container, so a disagreement is
|
||||||
|
/// a disagreement with the program that will actually decide.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class RpmVersion
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Orders two RPM version strings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="left">The left version.</param>
|
||||||
|
/// <param name="right">The right version.</param>
|
||||||
|
/// <returns>-1, 0 or 1, as <c>rpmvercmp</c> would answer.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Null and empty are accepted and treated as equal to each other and older than anything else, which
|
||||||
|
/// <c>rpm</c>'s own Lua binding refuses outright. The refusal is right for a packaging tool and wrong
|
||||||
|
/// here: this compares a version read off a feed, and a feed that answered with an empty string should
|
||||||
|
/// leave the client on the build it has rather than throwing on a background timer.
|
||||||
|
/// </remarks>
|
||||||
|
internal static int Compare(string? left, string? right)
|
||||||
|
{
|
||||||
|
var one = left ?? string.Empty;
|
||||||
|
var two = right ?? string.Empty;
|
||||||
|
|
||||||
|
if (string.Equals(one, two, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int i = 0, j = 0;
|
||||||
|
|
||||||
|
while (i < one.Length || j < two.Length)
|
||||||
|
{
|
||||||
|
// Separators. Anything that is not alphanumeric, ~ or ^ divides segments and is skipped on
|
||||||
|
// both sides independently — which is why 1.0_1 and 1.0.1 compare equal.
|
||||||
|
Skip(one, ref i);
|
||||||
|
Skip(two, ref j);
|
||||||
|
|
||||||
|
if (CompareMarkers(one, i, two, j) is { } marker)
|
||||||
|
{
|
||||||
|
if (marker != 0)
|
||||||
|
{
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both sides carried the same marker, so step over it and keep going.
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i >= one.Length || j >= two.Length)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CompareSegments(one, ref i, two, ref j) is { } ordering)
|
||||||
|
{
|
||||||
|
return ordering;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both ran out together, so every segment matched and the only differences were separators.
|
||||||
|
if (i >= one.Length && j >= two.Length)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whichever still has characters left has more version, and more version is newer.
|
||||||
|
return i < one.Length ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Advances past anything that divides segments rather than taking part in them.</summary>
|
||||||
|
private static void Skip(string value, ref int at)
|
||||||
|
{
|
||||||
|
while (at < value.Length && !IsVersionCharacter(value[at]))
|
||||||
|
{
|
||||||
|
at++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orders the two sides when either is at a <c>~</c> or a <c>^</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// Null when neither side is at a marker, so the caller should compare segments; 0 when both are at
|
||||||
|
/// the same one, so the caller should step over it; otherwise the ordering.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>~</c> sorts before everything including the end of the string, which is what carries the whole
|
||||||
|
/// prerelease ordering. <c>^</c> sorts after everything <em>except</em> the end of the string, so
|
||||||
|
/// 1.0^post is newer than 1.0 and 1.2.3 is older than 1.2.3^ — the end-of-string checks coming first
|
||||||
|
/// is the difference between the two halves of that sentence.
|
||||||
|
/// </remarks>
|
||||||
|
private static int? CompareMarkers(string one, int i, string two, int j)
|
||||||
|
{
|
||||||
|
var oneTilde = i < one.Length && one[i] == '~';
|
||||||
|
var twoTilde = j < two.Length && two[j] == '~';
|
||||||
|
|
||||||
|
if (oneTilde || twoTilde)
|
||||||
|
{
|
||||||
|
return oneTilde && twoTilde ? 0 : oneTilde ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var oneCaret = i < one.Length && one[i] == '^';
|
||||||
|
var twoCaret = j < two.Length && two[j] == '^';
|
||||||
|
|
||||||
|
if (!oneCaret && !twoCaret)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i >= one.Length)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (j >= two.Length)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return oneCaret && twoCaret ? 0 : oneCaret ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes one segment from each side and orders them, advancing past both.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The ordering, or null if the two segments were equal and the walk should continue.</returns>
|
||||||
|
private static int? CompareSegments(string one, ref int i, string two, ref int j)
|
||||||
|
{
|
||||||
|
var numeric = char.IsAsciiDigit(one[i]);
|
||||||
|
|
||||||
|
var start1 = i;
|
||||||
|
var start2 = j;
|
||||||
|
|
||||||
|
Take(one, ref i, numeric);
|
||||||
|
Take(two, ref j, numeric);
|
||||||
|
|
||||||
|
var segment1 = one.AsSpan(start1, i - start1);
|
||||||
|
var segment2 = two.AsSpan(start2, j - start2);
|
||||||
|
|
||||||
|
// The two sides are at segments of different kinds — digits against letters. rpm calls the
|
||||||
|
// numeric one newer, always, on the reasoning that a version that has started counting is further
|
||||||
|
// along than one still spelling. segment1 is never empty: its kind was chosen from its own first
|
||||||
|
// character.
|
||||||
|
if (segment2.IsEmpty)
|
||||||
|
{
|
||||||
|
return numeric ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numeric)
|
||||||
|
{
|
||||||
|
segment1 = segment1.TrimStart('0');
|
||||||
|
segment2 = segment2.TrimStart('0');
|
||||||
|
|
||||||
|
// Longer, once the zeros are gone, is larger. Compared before the characters are, because a
|
||||||
|
// string comparison would call "9" larger than "10".
|
||||||
|
if (segment1.Length != segment2.Length)
|
||||||
|
{
|
||||||
|
return segment1.Length > segment2.Length ? 1 : -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ordering = segment1.CompareTo(segment2, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
return ordering == 0 ? null : ordering > 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Advances over one run of digits, or one run of letters.</summary>
|
||||||
|
private static void Take(string value, ref int at, bool numeric)
|
||||||
|
{
|
||||||
|
while (at < value.Length
|
||||||
|
&& (numeric ? char.IsAsciiDigit(value[at]) : char.IsAsciiLetter(value[at])))
|
||||||
|
{
|
||||||
|
at++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether this character takes part in the comparison rather than dividing it.</summary>
|
||||||
|
private static bool IsVersionCharacter(char value) =>
|
||||||
|
char.IsAsciiLetterOrDigit(value) || value == '~' || value == '^';
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One desktop instance per profile, and a way for a second launch to raise the first.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Per profile rather than per machine, which decides two things at once.</b> The lock lives in
|
||||||
|
/// <see cref="ClientPaths.InstanceLockFile"/>, so a nightly and a release build never exclude each other —
|
||||||
|
/// they are separate applications by design, ADR 0013 — and two people signed in to one machine each get
|
||||||
|
/// their own instance. A global mutex would have had to re-derive both of those from somewhere.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Why one instance at all.</b> The cache is a SQLite file with an outbox in it, and the whole design
|
||||||
|
/// assumes one writer: <see cref="ClientPaths"/> says so about two machines sharing a profile through a
|
||||||
|
/// cloud sync client, and two processes on one machine is the same hazard with a shorter path to it. The
|
||||||
|
/// terminal's loopback listener is the visible half — the second process fails to bind, gets a port of its
|
||||||
|
/// own or refuses to start a workspace at all — and the cache is the half that would quietly corrupt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A file handle rather than a named mutex</b>, because the operating system releases it when the
|
||||||
|
/// process ends <em>however</em> it ends. A named mutex abandoned by a killed process is a state both
|
||||||
|
/// platforms model differently and neither models simply; an exclusive handle is closed by the kernel on
|
||||||
|
/// Windows and by the file descriptor going away on Unix, so there is no such thing as a stale lock here.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>And a named pipe for the other half, because exiting silently is not an answer.</b> Somebody who
|
||||||
|
/// double-clicks the icon and sees nothing happen has been told the application is broken. The second
|
||||||
|
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
||||||
|
/// what makes the exclusion invisible rather than mysterious.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class SingleInstance
|
||||||
|
{
|
||||||
|
/// <summary>The one thing a second launch has to say.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared against a constant rather than parsed, for the reason <c>MainWindow</c>'s release-focus
|
||||||
|
/// message is: one message exists and treating anything else as a command would be a wider door than
|
||||||
|
/// this needs. Anything may connect to a per-user pipe — it is not a security boundary — so what arrives
|
||||||
|
/// is checked rather than trusted.
|
||||||
|
/// </remarks>
|
||||||
|
private const string ComeForward = "dodossh.come-forward";
|
||||||
|
|
||||||
|
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
||||||
|
/// not a bound on anything slow; it is how long to wait before concluding that whoever holds the lock
|
||||||
|
/// cannot be talked to.
|
||||||
|
/// </remarks>
|
||||||
|
private const int AnswerTimeoutMilliseconds = 500;
|
||||||
|
|
||||||
|
/// <summary>How long to keep trying for a lock that is held by nobody who answers.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>This is the window an update relaunch lands in, and without it an update would look like a
|
||||||
|
/// crash.</b> Velopack's apply ends this process and its updater then starts the new build; the two are
|
||||||
|
/// ordered — the updater waits on the old process id — but the whole sequence is one where a lock held a
|
||||||
|
/// moment too long would make the relaunched application exit on the spot, having apparently uninstalled
|
||||||
|
/// itself. The same window covers an ordinary double-double-click, where the first process holds the file
|
||||||
|
/// and has not reached its pipe yet.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Only reached when nobody answers the pipe. A live instance replies in milliseconds and the second
|
||||||
|
/// launch is gone before any of this matters.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly TimeSpan DefaultPatience = TimeSpan.FromSeconds(3);
|
||||||
|
|
||||||
|
/// <summary>How long to wait between attempts within that window.</summary>
|
||||||
|
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Claims this profile for this process, or asks whoever holds it to come forward.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// A handle to hold for the life of the process, or <see langword="null"/> when another instance has it —
|
||||||
|
/// in which case that instance has already been asked to raise its window and this process should end.
|
||||||
|
/// </returns>
|
||||||
|
/// <param name="paths">The profile to claim. Its directory is created if it is not there.</param>
|
||||||
|
/// <param name="patience">
|
||||||
|
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
||||||
|
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
||||||
|
/// </param>
|
||||||
|
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
paths.EnsureCreated();
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// No profile directory means no lock file, and every build before this one started anyway. What
|
||||||
|
// follows will fail on its own terms and say something better than this could.
|
||||||
|
return NoClaim.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counted rather than clocked. Thread.Sleep is not a clock and reading one here would be pretending
|
||||||
|
// otherwise — what this bounds is the number of attempts, and the wall time is whatever the platform
|
||||||
|
// makes of them.
|
||||||
|
var attemptsLeft = (int)Math.Ceiling(
|
||||||
|
(patience ?? DefaultPatience).TotalMilliseconds / RetryDelay.TotalMilliseconds);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (TryTake(paths.InstanceLockFile) is { } taken)
|
||||||
|
{
|
||||||
|
return taken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
||||||
|
// round trip on a local pipe rather than the whole window below.
|
||||||
|
if (AskTheRunningOneToComeForward(paths))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attemptsLeft-- <= 0)
|
||||||
|
{
|
||||||
|
// Held by something that will not talk. Giving up rather than starting anyway, because what
|
||||||
|
// is being protected is a database with one writer, and "probably nobody" is not an answer
|
||||||
|
// worth corrupting it for.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(RetryDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Answers the launches that were refused, for as long as the returned handle is held.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <paramref name="comeForward"/> is invoked on a thread pool thread, because that is where a pipe
|
||||||
|
/// accept completes. A caller that touches a window has to marshal.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="paths">The profile this instance claimed.</param>
|
||||||
|
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
||||||
|
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
ArgumentNullException.ThrowIfNull(comeForward);
|
||||||
|
|
||||||
|
var listener = new Doorbell(PipeNameFor(paths), comeForward);
|
||||||
|
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
return listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
|
||||||
|
/// <returns>Whether anything was listening.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
||||||
|
/// and proves itself, and this is the part with a protocol in it.
|
||||||
|
/// </remarks>
|
||||||
|
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var pipe = new NamedPipeClientStream(
|
||||||
|
serverName: ".",
|
||||||
|
PipeNameFor(paths),
|
||||||
|
PipeDirection.Out);
|
||||||
|
|
||||||
|
pipe.Connect(AnswerTimeoutMilliseconds);
|
||||||
|
|
||||||
|
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
||||||
|
|
||||||
|
writer.WriteLine(ComeForward);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
when (exception is TimeoutException or IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Nothing there, or nothing willing. The caller decides what that means; here it is only ever
|
||||||
|
// "no".
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
||||||
|
/// tidier and is worse: it makes the file's existence part of the protocol, so a second process opening
|
||||||
|
/// it while the first is closing gets a handle to something on its way out. An empty file that is always
|
||||||
|
/// there and sometimes locked has one fewer state.
|
||||||
|
/// </remarks>
|
||||||
|
private static IDisposable? TryTake(string lockFile)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new FileStream(
|
||||||
|
lockFile,
|
||||||
|
FileMode.OpenOrCreate,
|
||||||
|
FileAccess.ReadWrite,
|
||||||
|
FileShare.None,
|
||||||
|
bufferSize: 1,
|
||||||
|
FileOptions.None);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// Somebody else holds it. The one expected failure, and the whole point.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// A profile this user cannot write to, which is a broken installation rather than a second
|
||||||
|
// instance. Answered as a claim so the application starts and fails on something legible.
|
||||||
|
return NoClaim.Instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The pipe both sides name, derived from the profile so that it is scoped exactly as the lock is.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A digest of the path rather than the path itself: a pipe name may not contain a directory separator
|
||||||
|
/// on Windows, and on Unix the name becomes a file in a temporary directory whose length is bounded.
|
||||||
|
/// Eight bytes of SHA-256, which is not doing security work — it is making one string out of another
|
||||||
|
/// deterministically, and <c>string.GetHashCode</c> cannot because it is randomised per process.
|
||||||
|
/// </remarks>
|
||||||
|
private static string PipeNameFor(ClientPaths paths)
|
||||||
|
{
|
||||||
|
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(paths.DataDirectory));
|
||||||
|
|
||||||
|
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A claim over nothing, for the cases where there was nothing to claim.</summary>
|
||||||
|
private sealed class NoClaim : IDisposable
|
||||||
|
{
|
||||||
|
internal static NoClaim Instance { get; } = new();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
// Nothing was taken.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The accept loop that answers refused launches.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One server instance for the life of the doorbell, disconnected between callers rather than disposed
|
||||||
|
/// and rebuilt. The loop is what makes a third and fourth launch work as well as the second.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>THE ONE INSTANCE IS THE WHOLE CORRECTNESS OF THIS CLASS ON LINUX, AND A FRESH ONE PER CALLER
|
||||||
|
/// LOSES LAUNCHES.</b> This was written the obvious way — a <c>using</c> inside the loop, a new stream
|
||||||
|
/// per connection — which is fine on Windows and quietly wrong on Unix, where a named pipe is a Unix
|
||||||
|
/// domain socket. Disposing the server there closes the <em>listening</em> socket and unlinks its path,
|
||||||
|
/// so a second launch that connected while the first was being served has its connection dropped on the
|
||||||
|
/// floor: its <c>Connect</c> returned, its write succeeded into a buffer, and nobody ever read it. The
|
||||||
|
/// launch was told it had rung the doorbell and the window never came forward.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Measured rather than reasoned about, in a container, because the two platforms disagree and this
|
||||||
|
/// machine is only one of them: three rings back to back are answered three times with one instance and
|
||||||
|
/// once with a fresh one per connection. <c>Disconnect</c> ends the conversation and keeps the socket, so
|
||||||
|
/// there is never a moment with nothing listening.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Which makes <c>leaveOpen</c> on the reader load-bearing as well. A <see cref="StreamReader"/> disposes
|
||||||
|
/// the stream it was given, and the stream is now the server that has to survive into the next
|
||||||
|
/// iteration — so the reader that was harmless per-connection would close the doorbell after one ring.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class Doorbell : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly string name;
|
||||||
|
private readonly Action comeForward;
|
||||||
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
|
|
||||||
|
private Task? loop;
|
||||||
|
|
||||||
|
internal Doorbell(string name, Action comeForward)
|
||||||
|
{
|
||||||
|
this.name = name;
|
||||||
|
this.comeForward = comeForward;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Start() => loop = RunAsync(lifetime.Token);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (loop is { } running)
|
||||||
|
{
|
||||||
|
// Awaited rather than abandoned, so the pipe name is free by the time this returns — which
|
||||||
|
// matters for exactly one caller: an update that is about to relaunch this application and
|
||||||
|
// would otherwise race its own successor for the name.
|
||||||
|
await running.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
lifetime.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var server = new NamedPipeServerStream(
|
||||||
|
name,
|
||||||
|
PipeDirection.In,
|
||||||
|
maxNumberOfServerInstances: 1,
|
||||||
|
PipeTransmissionMode.Byte,
|
||||||
|
PipeOptions.Asynchronous);
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// leaveOpen, because this server outlives the conversation. See the remark above.
|
||||||
|
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
||||||
|
{
|
||||||
|
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
comeForward();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HangUp(server);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// A client that connected and hung up mid-sentence. Round again: giving up here would
|
||||||
|
// leave every later launch with nothing to talk to, which is the silent-exit behaviour
|
||||||
|
// this class exists to avoid.
|
||||||
|
HangUp(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
||||||
|
/// an <see cref="IOException"/> above may already have left it in, and the state a second call would
|
||||||
|
/// find. Throwing out of the error path would take the loop with it.
|
||||||
|
/// </remarks>
|
||||||
|
private static void HangUp(NamedPipeServerStream server)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (server.IsConnected)
|
||||||
|
{
|
||||||
|
server.Disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// Already disconnected. Nothing to end.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
using Velopack;
|
using Velopack;
|
||||||
using Velopack.Sources;
|
using Velopack.Sources;
|
||||||
@@ -31,7 +32,19 @@ internal static class UpdateChannels
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static IUpdateChannel ForThisMachine()
|
internal static IUpdateChannel ForThisMachine()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
// ◆ Linux is answered first and separately, because it is not a Velopack platform at all.
|
||||||
|
//
|
||||||
|
// This used to be the place that said so — a negation over the two packaged platforms, with a note
|
||||||
|
// that a future AppImage should be an addition here rather than something that had silently
|
||||||
|
// half-happened. It is an addition, and it is not an AppImage: the Linux head ships as an RPM and
|
||||||
|
// is updated by the machine's own package manager, so its channel fetches and hands off rather
|
||||||
|
// than replacing anything. See LinuxUpdateChannel and ADR 0013 rule 11.
|
||||||
|
if (OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return LinuxChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
|
||||||
{
|
{
|
||||||
return new UnavailableUpdateChannel();
|
return new UnavailableUpdateChannel();
|
||||||
}
|
}
|
||||||
@@ -52,17 +65,53 @@ internal static class UpdateChannels
|
|||||||
return new UnavailableUpdateChannel();
|
return new UnavailableUpdateChannel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
|
||||||
|
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
|
||||||
|
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
|
||||||
|
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
|
||||||
|
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
|
||||||
|
/// out.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
|
||||||
|
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
|
||||||
|
/// differently. The install root is a constant in two places — here and the packaging script — and
|
||||||
|
/// they are named in each other's comments.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("linux")]
|
||||||
|
private static IUpdateChannel LinuxChannel()
|
||||||
|
{
|
||||||
|
var root = DesktopChannel.IsNightly ? "/opt/DodoSSH.Nightly" : "/opt/DodoSSH";
|
||||||
|
|
||||||
|
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
|
return string.Equals(here, root, StringComparison.Ordinal)
|
||||||
|
? new LinuxUpdateChannel(DesktopChannel.Name)
|
||||||
|
: new UnavailableUpdateChannel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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 +152,45 @@ 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>
|
||||||
|
/// The nightly channel, which is a different name rather than the same one on a different tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A contract with the <c>desktop nightly</c> job in <c>.github/workflows/ci.yml</c>, which passes
|
||||||
|
/// this word to both <c>vpk pack</c> and <c>vpk upload</c>. The name reaches the wire: Velopack
|
||||||
|
/// publishes its index as <c>releases.win-nightly.json</c> and looks for exactly that file, so a
|
||||||
|
/// disagreement between the two sides is a channel that answers nothing, forever, without an error.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Two names rather than one name on two tags, and that is the part doing the work.</b> Both
|
||||||
|
/// channels are published to the same repository, so a client that read the other's index could be
|
||||||
|
/// offered a package built under a different pack id. Velopack would refuse it, but at the far end of
|
||||||
|
/// a download somebody watched. A channel each means neither ever sees the other's releases at all.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -117,15 +204,37 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private UpdateInfo? found;
|
private UpdateInfo? found;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The last thing actually fetched, kept separately from the last thing found.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Because the two can disagree, and when they do it is an apply that pays for it.</b> A check
|
||||||
|
/// overwrites <see cref="found"/>, and the shell now checks while a build is already downloaded and
|
||||||
|
/// waiting — that is how it replaces a pending build when the feed has moved on, and how it takes one
|
||||||
|
/// last look on the way to a restart. Either of those can land a newer answer in <c>found</c> and then
|
||||||
|
/// fail to fetch it, or be given up on halfway; the shell's answer is to restart with the build it
|
||||||
|
/// already has, and <see cref="Matched"/> resolving only against <c>found</c> would have thrown at it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Which turned a slow forge into a refused restart: the offer on screen was real, the package on disk
|
||||||
|
/// was real, and the only thing wrong was that this class had forgotten which one it was.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private UpdateInfo? fetched;
|
||||||
|
|
||||||
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
|
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
|
||||||
|
|
||||||
/// <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;
|
||||||
@@ -138,10 +247,63 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string CurrentVersion => ClientVersion.Current;
|
public string CurrentVersion => ClientVersion.Current;
|
||||||
|
|
||||||
internal static UpdateManager CreateManager() =>
|
/// <summary>
|
||||||
new(
|
/// The updater for this build's channel.
|
||||||
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: false),
|
/// </summary>
|
||||||
new UpdateOptions { ExplicitChannel = ReleaseChannel });
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The prerelease flag is the half Velopack cannot work out for itself.</b> The channel could be
|
||||||
|
/// left to the installed layout — Velopack records what a package was built with — but whether to
|
||||||
|
/// consider prereleases is a property of the feed rather than of the install, and it decides more
|
||||||
|
/// than it looks. The nightly is published as a prerelease deliberately: the Android head's release
|
||||||
|
/// channel reads <c>releases/latest</c>, which skips prereleases, so a desktop nightly published as a
|
||||||
|
/// stable release would become the newest release in this repository and the phone's release channel
|
||||||
|
/// would start finding no Android manifest on it. One flag here keeps the two heads out of each
|
||||||
|
/// other's way.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The release channel takes <c>false</c>, so it cannot see the nightly at all — which is the property
|
||||||
|
/// that matters most, because that is the direction where a mistake would put an unsigned CI build on
|
||||||
|
/// a machine somebody trusts with their credentials.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static UpdateManager CreateManager()
|
||||||
|
{
|
||||||
|
var nightly = DesktopChannel.IsNightly;
|
||||||
|
|
||||||
|
return new UpdateManager(
|
||||||
|
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
|
||||||
|
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 />
|
||||||
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
||||||
@@ -178,7 +340,23 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
|
|
||||||
// Velopack reports progress as an Action<int> and the rest of this codebase speaks IProgress<T>,
|
// Velopack reports progress as an Action<int> and the rest of this codebase speaks IProgress<T>,
|
||||||
// so the adaptation happens here rather than leaking the older shape into the view models.
|
// so the adaptation happens here rather than leaking the older shape into the view models.
|
||||||
return manager.DownloadUpdatesAsync(Matched(update), progress.Report, cancellationToken);
|
return DownloadAndRememberAsync(Matched(update), progress, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Recorded after the await and not before, so that a download which threw leaves the previous fetch as
|
||||||
|
/// the one this channel can still apply. See <see cref="fetched"/>.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task DownloadAndRememberAsync(
|
||||||
|
UpdateInfo info,
|
||||||
|
IProgress<int> progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await manager
|
||||||
|
.DownloadUpdatesAsync(info, progress.Report, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
fetched = info;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -192,21 +370,39 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
|
/// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
|
||||||
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
|
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
|
||||||
/// <see cref="CheckAsync"/> returned; a mismatch is a bug in this application rather than anything a
|
/// <see cref="CheckAsync"/> returned, or what a download of that then fetched; a mismatch is a bug in
|
||||||
/// user did, which is why it throws rather than resolving to some safe-looking default.
|
/// this application rather than anything a user did, which is why it throws rather than resolving to
|
||||||
|
/// some safe-looking default.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// What was fetched is tried before what was found, because that is the one an apply names and the one a
|
||||||
|
/// later check cannot take away. See <see cref="fetched"/> for how the two come to disagree.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private UpdateInfo Matched(AvailableUpdate update)
|
private UpdateInfo Matched(AvailableUpdate update)
|
||||||
{
|
{
|
||||||
if (found is not { } info
|
if (Names(fetched, update))
|
||||||
|| !string.Equals(info.TargetFullRelease.Version.ToString(), update.Version, StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
return fetched!;
|
||||||
$"No update matching {update.Version} has been found by this channel. "
|
|
||||||
+ "Call CheckAsync and pass back what it returned.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return info;
|
if (Names(found, update))
|
||||||
|
{
|
||||||
|
return found!;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"No update matching {update.Version} has been found or fetched by this channel. "
|
||||||
|
+ "Call CheckAsync and pass back what it returned.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool Names(UpdateInfo? info, AvailableUpdate update) =>
|
||||||
|
info is not null
|
||||||
|
&& string.Equals(
|
||||||
|
info.TargetFullRelease.Version.ToString(),
|
||||||
|
update.Version,
|
||||||
|
StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
@@ -9,9 +9,26 @@ 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
|
/// gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the passphrase — which is
|
||||||
/// passphrase — which is the honest answer rather than a degraded one.
|
/// the honest answer rather than a degraded one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Every real store is asked whether it works rather than told that it does.</b> Each
|
||||||
|
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and discarding it — because on
|
||||||
|
/// all three platforms the provider is present and reports itself present on machines where creating a key
|
||||||
|
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, on macOS also every unsigned
|
||||||
|
/// development build since enclave keys need a signing identity, and on Linux the ordinary case of a
|
||||||
|
/// working TPM whose device node this user may not open. Inferring from the OS would mean each of those
|
||||||
|
/// discovering the truth at the moment somebody tried to unlock.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The three are not equally strong, and the ordering here does not imply they are.</b> Windows and
|
||||||
|
/// macOS put the consent gesture inside the platform — a secure desktop and a system Touch ID sheet — so
|
||||||
|
/// the process asking never sees the secret. The Linux store's PIN is typed into a window this
|
||||||
|
/// application draws, and only the <em>checking</em> is the TPM's. That is a real difference, it is
|
||||||
|
/// recorded in ADR 0007 rather than smoothed over, and it is why <see cref="LinuxDeviceKeyStore"/> is
|
||||||
|
/// tried last rather than first among platforms that could in principle overlap.
|
||||||
/// </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
|
||||||
@@ -25,13 +42,37 @@ namespace DodoSSH.Client.App.Platform;
|
|||||||
public static class DesktopDeviceKeyStores
|
public static class DesktopDeviceKeyStores
|
||||||
{
|
{
|
||||||
/// <summary>The best store this machine supports.</summary>
|
/// <summary>The best store this machine supports.</summary>
|
||||||
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
|
/// <param name="paths">Where a stored key lives.</param>
|
||||||
|
/// <param name="askForPin">
|
||||||
|
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
|
||||||
|
/// raises its own dialog and this process is not in the conversation. A caller with no way to draw one
|
||||||
|
/// may pass a delegate answering null, which makes the Linux store decline every unlock; that is the
|
||||||
|
/// right shape for a headless host and the wrong one for the application, which supplies a real dialog
|
||||||
|
/// in <c>App.axaml.cs</c>.
|
||||||
|
/// </param>
|
||||||
|
public static IDeviceKeyStore ForThisMachine(
|
||||||
|
ClientPaths paths,
|
||||||
|
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
ArgumentNullException.ThrowIfNull(askForPin);
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsLinux() && LinuxDeviceKeyStore.IsSupported())
|
||||||
|
{
|
||||||
|
return new LinuxDeviceKeyStore(paths, askForPin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UnavailableDeviceKeyStore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using DodoSSH.Client.App.Platform;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
using Velopack;
|
using Velopack;
|
||||||
|
|
||||||
@@ -36,6 +37,27 @@ internal static class Program
|
|||||||
|
|
||||||
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||||
|
|
||||||
|
/*
|
||||||
|
One instance per profile, and nothing above this line is affected by it: the installer's own runs
|
||||||
|
have already exited inside VelopackApp.Run, and the WebView2 environment variable is a property of
|
||||||
|
this process rather than of the application.
|
||||||
|
|
||||||
|
Claimed here rather than after Avalonia is configured, so that a second launch starts nothing — no
|
||||||
|
toolkit, no window, no splash flashing on screen before it thinks better of it. What it does
|
||||||
|
instead is ask the running instance to come forward, which is why this is not simply an exit.
|
||||||
|
|
||||||
|
The handle is held for as long as Main runs, which is as long as the message loop does. See
|
||||||
|
SingleInstance for why it is a file rather than a mutex, and for what the profile scoping buys.
|
||||||
|
*/
|
||||||
|
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||||
|
|
||||||
|
using var claim = SingleInstance.Claim(paths);
|
||||||
|
|
||||||
|
if (claim is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +91,12 @@ internal static class Program
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var folder = Path.Combine(ClientPaths.Default.DataDirectory, "WebView2");
|
// The same profile directory the rest of the application resolves, channel and all — a nightly
|
||||||
|
// pointing WebView2 at the release build's profile would put two browser profiles in one folder
|
||||||
|
// and hand the pair of them whichever process opened first.
|
||||||
|
var folder = Path.Combine(
|
||||||
|
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
|
||||||
|
"WebView2");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -87,17 +114,25 @@ internal static class Program
|
|||||||
|
|
||||||
/// <summary>Used by the designer as well as by <see cref="Main"/>.</summary>
|
/// <summary>Used by the designer as well as by <see cref="Main"/>.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <c>WithInterFont</c> registers Inter; it does not make it the default, and until this line the
|
/// <c>WithInterFont</c> still registers Inter, and it is still what the layout suite pins: see
|
||||||
/// application shipped a font it then declined to use — falling back to Segoe UI on Windows and to
|
/// HeadlessApp. What changed is which face answers when nothing more specific is asked for. The v5
|
||||||
/// whatever fontconfig offered on Linux. Almost nothing visible moves, because App.axaml sets
|
/// design specifies Montserrat, and unlike the substitution this comment used to explain, Montserrat
|
||||||
/// <c>MonoFont</c> on essentially every control that draws text, but the fallback behind those is now
|
/// now ships embedded in the shell project rather than being requested by a name the machine may not
|
||||||
/// a font that travels with the build rather than one the machine is assumed to have. The layout
|
/// have — see DodoSSH.Client.Shell/Assets/Fonts and its csproj entry. Inter stays registered and is
|
||||||
/// suite pins the same family, and has to: see HeadlessApp.
|
/// named as a fallback, so a glyph Montserrat does not cover still resolves to a font that travels
|
||||||
|
/// with the build rather than to whatever the platform's default sans happens to be.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static AppBuilder BuildAvaloniaApp() =>
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
AppBuilder.Configure<DodoSshApp>()
|
AppBuilder.Configure<DodoSshApp>()
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
.WithInterFont()
|
.WithInterFont()
|
||||||
.With(new FontManagerOptions { DefaultFamilyName = "avares://Avalonia.Fonts.Inter/Assets#Inter" })
|
.With(new FontManagerOptions
|
||||||
|
{
|
||||||
|
DefaultFamilyName = "avares://DodoSSH.Client.Shell/Assets/Fonts#Montserrat",
|
||||||
|
FontFallbacks =
|
||||||
|
[
|
||||||
|
new FontFallback { FontFamily = new FontFamily("avares://Avalonia.Fonts.Inter/Assets#Inter") },
|
||||||
|
],
|
||||||
|
})
|
||||||
.LogToTrace();
|
.LogToTrace();
|
||||||
}
|
}
|
||||||
@@ -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,29 +46,138 @@
|
|||||||
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">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Which machine, and at what address. Both selectable, for the reason the session column's own address
|
||||||
|
is — see SessionSidebar.axaml: a TextBlock in Avalonia is a drawing of a string rather than a string,
|
||||||
|
and this pair is the one place the shell says what it is dialling *before* anything has answered.
|
||||||
|
That is exactly when the value is wanted somewhere else: in a ticket, in a message to whoever owns
|
||||||
|
the host, in the ssh command being written in the window beside this one. It is also the moment it is
|
||||||
|
least available anywhere else, because there is no prompt yet and may never be one — a refused
|
||||||
|
connection leaves this card holding the only copy on screen.
|
||||||
|
|
||||||
|
Both rather than only the name. They are two lines of one identifier, and a pair where the first can
|
||||||
|
be taken and the second cannot is a worse answer than either.
|
||||||
|
|
||||||
|
The trimming stays, and the tooltips are what make it safe rather than a trap. A drag reaches only
|
||||||
|
the glyphs that were drawn, so a name or an address wearing an ellipsis has to be taken with
|
||||||
|
select-all — Ctrl+A, then Ctrl+C — which takes the whole value, because the ellipsis is a drawing and
|
||||||
|
not a shorter string. The hover says what the whole value is, so nobody copies half of one without
|
||||||
|
having been told there is a half. Same argument, same shape, as the sidebar's.
|
||||||
|
-->
|
||||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16" FontWeight="Medium"
|
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16"
|
||||||
Foreground="{StaticResource Text}" HorizontalAlignment="Center"
|
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
ToolTip.Tip="{Binding SelectedTab.Label}" />
|
||||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||||
TextTrimming="CharacterEllipsis" />
|
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTip.Tip="{Binding SelectedTab.Address}" />
|
||||||
</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 +193,36 @@
|
|||||||
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">
|
||||||
|
<!--
|
||||||
|
TRY AGAIN rather than RECONNECT, because nothing was ever connected: this tab is a handshake that
|
||||||
|
was refused. It runs the same command the disconnect strip's RECONNECT does — the vault's own
|
||||||
|
connect path, into this same tab — and it is worth having here for the reason the commonest
|
||||||
|
refusal is worth retrying at all: a host that was not up yet is a host that will be in a minute.
|
||||||
|
|
||||||
|
Disabled rather than hidden when there is nothing to dial, exactly as the strip's is. See
|
||||||
|
MainWindowViewModel.CanReconnectSelectedTab.
|
||||||
|
-->
|
||||||
|
<Button Classes="ghost" Content="TRY AGAIN" Command="{Binding ReconnectTabCommand}"
|
||||||
|
CommandParameter="{Binding SelectedTab}"
|
||||||
|
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
|
||||||
|
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||||
|
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
|
||||||
|
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
|
||||||
|
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
|
||||||
|
CommandParameter="{Binding SelectedTab}"
|
||||||
|
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||||
|
<Button Classes="ghost" Content="CLOSE TAB" Command="{Binding CloseTabCommand}"
|
||||||
|
CommandParameter="{Binding SelectedTab}"
|
||||||
|
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.DevicePinWindow"
|
||||||
|
Width="420" SizeToContent="Height"
|
||||||
|
CanResize="False" ShowInTaskbar="False"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{StaticResource Surface}"
|
||||||
|
Title="DodoSSH">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The PIN that unseals this machine's device key, on Linux and nowhere else.
|
||||||
|
|
||||||
|
◆ A WINDOW, WHERE EVERY OTHER QUESTION THIS APPLICATION ASKS IS A CARD.
|
||||||
|
|
||||||
|
ConfirmDeleteCard, HostKeyCard and the rest are overlays bound to view-model state, and that is the
|
||||||
|
right shape for a question about the vault: the shell owns the moment and the Android head shows the
|
||||||
|
same one. This is not that kind of question. It belongs to a platform integration — LinuxDeviceKeyStore
|
||||||
|
— and the shell it would otherwise live in is shared with the phone, which has a fingerprint and must
|
||||||
|
never grow a PIN box. Keeping it here keeps DodoSSH.Client.Shell platform-neutral, which is the
|
||||||
|
property IDeviceKeyStore exists to protect.
|
||||||
|
|
||||||
|
◆ AND IT IS DELIBERATELY PLAIN.
|
||||||
|
|
||||||
|
Windows and macOS draw their equivalent themselves — a secure desktop and a system sheet — and this one
|
||||||
|
cannot be either. Dressing it up to look like a system dialog would be teaching people that a
|
||||||
|
convincing DodoSSH PIN box is a system dialog, which is the phishing this platform's design cannot
|
||||||
|
prevent and should not rehearse. It says what it is, it names the application, and it stops there.
|
||||||
|
ADR 0007 records the difference rather than smoothing it over.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<StackPanel Margin="20" Spacing="12">
|
||||||
|
|
||||||
|
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
|
||||||
|
|
||||||
|
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
|
||||||
|
x:Name="Explanation" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
RevealPassword is off and there is no eye button. A PIN is short enough to retype and this box is
|
||||||
|
open on a screen somebody may be sharing.
|
||||||
|
-->
|
||||||
|
<TextBox x:Name="Pin" PasswordChar="•" PlaceholderText="PIN" FontSize="14" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Only when choosing. A mistyped PIN at registration is not recoverable in any useful sense: the key is
|
||||||
|
sealed to it, and the only way back is to withdraw the device and register again. Asking twice costs
|
||||||
|
one box.
|
||||||
|
-->
|
||||||
|
<TextBox x:Name="Confirm" PasswordChar="•" PlaceholderText="PIN again" FontSize="14"
|
||||||
|
IsVisible="False" />
|
||||||
|
|
||||||
|
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||||
|
x:Name="Problem" IsVisible="False" />
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||||
|
<Button x:Name="Cancel" Content="CANCEL" />
|
||||||
|
<Button x:Name="Accept" Classes="accent" Content="UNLOCK" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Window>
|
||||||
Loaded 100 of 315 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user