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
|
||||
|
||||
# Files that must keep LF regardless of platform — shell scripts run in Linux
|
||||
# containers, and Dockerfiles are read by a Linux daemon.
|
||||
# containers, Dockerfiles are read by a Linux daemon, and an RPM spec is a shell
|
||||
# script in disguise: rpmbuild feeds its %install section to /bin/sh, where a
|
||||
# carriage return at the end of a line is an argument rather than nothing. A udev
|
||||
# rule is parsed by udev's own lexer, which treats a stray carriage return as part
|
||||
# of the value it is reading.
|
||||
*.sh text eol=lf
|
||||
*.spec text eol=lf
|
||||
*.rules text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.Dockerfile text eol=lf
|
||||
Caddyfile text eol=lf
|
||||
|
||||
+536
-4
@@ -29,6 +29,18 @@ jobs:
|
||||
build:
|
||||
name: build and test
|
||||
runs-on: [linux]
|
||||
# ◆ A CEILING, BECAUSE THIS FILE HAD NONE AND A HUNG TEST RAN UNTIL SOMEBODY NOTICED.
|
||||
#
|
||||
# One test in DodoSSH.Client.App.Tests waited on a named pipe that, on Linux, nothing was ever going
|
||||
# to answer — and it waited on a token with no timeout, so the job did not fail, it sat there. It was
|
||||
# killed by hand after twenty minutes, having reported nothing but the eighteen suites that had
|
||||
# already passed.
|
||||
#
|
||||
# Twenty-five minutes against a job that ordinarily takes three. It is not a performance budget and
|
||||
# must not be read as one: a build that legitimately grows past this should have the number raised.
|
||||
# What it buys is that the failure mode is a red job with a named step rather than a runner somebody
|
||||
# has to go and stop.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
# act_runner runs every `uses:` action with node inside the job container, and this
|
||||
# runner's image has none — the run died on the first line of actions/checkout with
|
||||
@@ -220,7 +232,12 @@ jobs:
|
||||
- name: build
|
||||
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
|
||||
|
||||
# Tighter than the job's own ceiling, and deliberately so: a hang here is the likeliest of the
|
||||
# lot — it is the only step running code written for this repository rather than a tool — and a
|
||||
# step-level timeout names the step in the failure instead of leaving a reader to work out which
|
||||
# of fifteen was still running. Ten minutes against a suite that takes two.
|
||||
- name: test
|
||||
timeout-minutes: 10
|
||||
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
||||
|
||||
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
||||
@@ -291,6 +308,7 @@ jobs:
|
||||
# so it is not done either. What reaches users is built, installed and walked through Phase 16
|
||||
# of docs/manual-checks.md by a person first.
|
||||
- name: package the windows desktop client
|
||||
id: winpack
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -374,6 +392,142 @@ jobs:
|
||||
ls -la "$releases"
|
||||
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
|
||||
|
||||
# Handed to the macOS step below rather than worked out again there. The floor logic above
|
||||
# is thirty lines of reasoning about MinVer's pre-first-tag answer, and a second copy of it
|
||||
# is a second thing to keep in step — while two desktop packages built from one commit
|
||||
# carrying different version numbers is precisely the confusion this file spends that
|
||||
# reasoning to avoid.
|
||||
echo "packVersion=$packVersion" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ◆ AND THE macOS BUNDLE IS BUILT HERE, ON LINUX, AND IS ALSO THROWN AWAY.
|
||||
#
|
||||
# Same argument as the Windows step above, one platform along: the failures a release is most
|
||||
# exposed to are the ones only the packager finds, and the person who would otherwise find them
|
||||
# is the one midway through a release on the one Mac that can cut one.
|
||||
#
|
||||
# What this catches that the Windows step cannot: the osx-arm64 restore graph. A native package
|
||||
# that resolves for win-x64 and has no osx-arm64 asset — libsodium and SkiaSharp both ship per
|
||||
# RID — fails here, on every main build, rather than at the first `dotnet publish` of a release
|
||||
# nobody can retry without a Mac.
|
||||
#
|
||||
# ◆ bundle, NOT pack, AND THE DIFFERENCE IS NOT A CHOICE.
|
||||
#
|
||||
# `vpk [osx]` cross-compiling from a non-Mac offers exactly one packaging verb: bundle, which
|
||||
# builds the .app. There is no `[osx] pack` off a Mac, and that is correct rather than a gap —
|
||||
# pack signs with codesign, submits to Apple with notarytool and staples the ticket, all of
|
||||
# which is Apple tooling that exists on no other platform. So this proves the bundle and stops
|
||||
# where the platform does.
|
||||
#
|
||||
# No --plist and no --icon either, deliberately. Both are proved by scripts/release-macos.sh on
|
||||
# the machine that can also check the result; passing a rendered plist here would mean copying
|
||||
# the substitution out of that script to no end, since nothing looks at what this produces.
|
||||
#
|
||||
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
|
||||
#
|
||||
# RUNNER_TEMP, dying with the job. ADR 0013 rule 3 puts the capability to ship somebody a build
|
||||
# on a machine which is not a runner, and an unsigned .app is additionally something no Mac
|
||||
# would open — so publishing it would be handing out a file whose only possible use is confusion.
|
||||
- name: publish and bundle the macos desktop client
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# RestoreLockedMode=false for the RID, exactly as the win-x64 publish above does — see the
|
||||
# long note there for why the committed lock files are deliberately RID-free. This runner's
|
||||
# checkout is thrown away, so the lock files it rewrites go nowhere.
|
||||
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||
--configuration Release --runtime osx-arm64 --self-contained true \
|
||||
-p:RestoreLockedMode=false \
|
||||
--output "$RUNNER_TEMP/osx-arm64"
|
||||
|
||||
# The apphost has no extension on macOS, so this is `DodoSSH` and not `DodoSSH.exe`. Named
|
||||
# rather than globbed, because a publish that produced no apphost at all would otherwise
|
||||
# bundle happily and produce an .app that launches nothing.
|
||||
if [ ! -s "$RUNNER_TEMP/osx-arm64/DodoSSH" ]; then
|
||||
echo "The osx-arm64 publish produced no apphost." >&2
|
||||
ls -la "$RUNNER_TEMP/osx-arm64" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bundles="$RUNNER_TEMP/osx-bundle"
|
||||
|
||||
# The quotes around [osx] are load-bearing, exactly as they are on '[win]' above: unquoted,
|
||||
# the shell reads it as a glob matching any one of o, s and x.
|
||||
dotnet vpk '[osx]' bundle \
|
||||
--skip-updates \
|
||||
--packId DodoSSH.Desktop \
|
||||
--packVersion '${{ steps.winpack.outputs.packVersion }}' \
|
||||
--packDir "$RUNNER_TEMP/osx-arm64" \
|
||||
--packTitle DodoSSH \
|
||||
--packAuthors DodoTech \
|
||||
--mainExe DodoSSH \
|
||||
--bundleId dev.dodotech.dodossh \
|
||||
--runtime osx-arm64 \
|
||||
--channel osx \
|
||||
--outputDir "$bundles"
|
||||
|
||||
# Asked for rather than inferred from an exit code, for the reason the Windows step gives.
|
||||
# The Info.plist is the specific thing worth naming: a bundle missing it is a directory
|
||||
# macOS will not treat as an application at all, and it is the one part of the .app that
|
||||
# vpk composes rather than copies.
|
||||
app="$bundles/DodoSSH.Desktop.app"
|
||||
if [ ! -s "$app/Contents/Info.plist" ]; then
|
||||
echo "vpk reported success and there is no Info.plist at $app/Contents/Info.plist." >&2
|
||||
find "$bundles" -maxdepth 3 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
|
||||
|
||||
# ◆ AND THE THIRD DESKTOP PLATFORM, WHICH IS THE ONE THIS RUNNER ACTUALLY IS.
|
||||
#
|
||||
# Same argument as the two steps above and with none of their cross-compiling: this job runs on
|
||||
# Linux, so the linux-x64 publish here is a native one. What it proves that the others cannot is
|
||||
# the linux-x64 restore graph — libsodium, SkiaSharp and HarfBuzzSharp all ship native assets per
|
||||
# RID, and a package that resolves for win-x64 and has no linux-x64 asset fails here.
|
||||
#
|
||||
# The runner is still Alpine and the package targets glibc distributions, which is fine and is not
|
||||
# glossed over: scripts/ci-linux-rpm.sh explains why rpmbuild composing an RPM for one libc on a
|
||||
# host running another is correct rather than lucky.
|
||||
#
|
||||
# ◆ IT IS ALSO WHERE THE PACKAGING ITSELF IS PROVED, WHICH ON THIS PLATFORM IS NOT A FORMALITY.
|
||||
#
|
||||
# The Windows and macOS steps hand a directory to vpk and vpk decides what the package needs.
|
||||
# There is no vpk here — Velopack's Linux target is an AppImage and this is a distribution
|
||||
# package, updated by dnf rather than by the application — so the dependency metadata is
|
||||
# generated by rpm from the published tree, and scripts/ci-linux-rpm.sh interrogates the result
|
||||
# rather than trusting an exit code. Every check in there is a way a package can be built
|
||||
# successfully and then refuse to install on every host in the world; one of them was written
|
||||
# because .NET's own libmscordbi.so links libmscordaccore.so, which is exactly that.
|
||||
#
|
||||
# Release-shaped rather than nightly-shaped — package name dodossh, tree at /opt/DodoSSH — so
|
||||
# that what is proved on every main build is the packaging the release channel would use. The
|
||||
# desktop job below builds the nightly-shaped one and is the only job that publishes either.
|
||||
#
|
||||
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
|
||||
#
|
||||
# RUNNER_TEMP, dying with the job. An RPM is if anything the easiest of the three to pass on
|
||||
# casually — one file, no installer to click through, `dnf install` and done — which is a reason
|
||||
# to keep it off a downloads page rather than a reason to put it on one. ADR 0013 rule 3.
|
||||
- name: publish and package the linux desktop client
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# RestoreLockedMode=false for the RID, exactly as the two publishes above do — see the long
|
||||
# note on the win-x64 one. This checkout is thrown away, so the lock files it rewrites go
|
||||
# nowhere.
|
||||
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||
--configuration Release --runtime linux-x64 --self-contained true \
|
||||
-p:RestoreLockedMode=false \
|
||||
--output "$RUNNER_TEMP/linux-x64"
|
||||
|
||||
scripts/ci-linux-rpm.sh \
|
||||
--payload "$RUNNER_TEMP/linux-x64" \
|
||||
--version '${{ steps.winpack.outputs.packVersion }}' \
|
||||
--channel release \
|
||||
--out "$RUNNER_TEMP/linux-rpm"
|
||||
|
||||
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
||||
# server through Testcontainers and runs the API as a child process — so it needs a
|
||||
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
||||
@@ -419,6 +573,8 @@ jobs:
|
||||
exit 0
|
||||
android:
|
||||
name: android head
|
||||
# See the build job. The docker pull and the toolchain image make this one legitimately slower.
|
||||
timeout-minutes: 40
|
||||
runs-on: [linux]
|
||||
# Writes the nightly release at the end of the job; see that step for what the capability is and why
|
||||
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
|
||||
@@ -778,7 +934,10 @@ jobs:
|
||||
# never made.
|
||||
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||
for name in $names; do
|
||||
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
||||
# grep without -q, which has the same exit status and reads to the end of its input. A
|
||||
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
|
||||
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
|
||||
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||
echo "The release was created but $name is not on it:" >&2
|
||||
printf '%s\n' "$published" >&2
|
||||
exit 1
|
||||
@@ -787,8 +946,380 @@ jobs:
|
||||
|
||||
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:
|
||||
name: api image
|
||||
# See the build job. A cold layer cache is the slow case here.
|
||||
timeout-minutes: 40
|
||||
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
||||
# clock on every main commit and buys the thing worth having: no image reaches the
|
||||
# registry from a commit whose tests were red. An image is not a build artefact anyone
|
||||
@@ -1067,9 +1598,10 @@ jobs:
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
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
|
||||
# and packaged here — the two steps at the end of the build job — and what is withheld is only the
|
||||
# upload.
|
||||
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
|
||||
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
||||
# 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.
|
||||
#
|
||||
|
||||
+8
-1
@@ -87,9 +87,16 @@ Releases/
|
||||
*.pfx
|
||||
*.p12
|
||||
*.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
|
||||
.env
|
||||
.env.*
|
||||
!deploy/.env.example
|
||||
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="libsodium" Version="1.0.22" />
|
||||
<!-- Managed differential oracle for the crypto test suite only. -->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<!--
|
||||
Managed differential oracle for the crypto test suite only. Also what SSH.NET pulls in,
|
||||
so central transitive pinning makes this pin its floor too: 2.7.0 is what SSH.NET
|
||||
2026.0.0 asks for, and pinning below that is a downgrade error rather than a preference.
|
||||
-->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Desktop client">
|
||||
<!--
|
||||
@@ -98,8 +102,14 @@
|
||||
agent forwarding (needs an upstream change; de-scoped from v1) and being handed a
|
||||
pre-connected Stream — it performs its own socket connect, which is why the relay and
|
||||
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
|
||||
|
||||
2026.0.0 is the fix for GHSA-q939-rpr3-3284, a path traversal in ScpClient.Download's
|
||||
recursive mode that trusts server-supplied names (everything up to and including
|
||||
2025.1.0 is affected). Nothing here uses ScpClient — transfers go through SftpClient —
|
||||
so this was an upgrade on principle rather than an exposure, and the release notes
|
||||
list no breaking changes over 2025.1.0.
|
||||
-->
|
||||
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
|
||||
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
|
||||
<!--
|
||||
The S3 client, for buckets as a remote in the file browser. First-party, Apache-2.0, and
|
||||
managed only — no native assets — which is the bar this file sets for anything that gets
|
||||
@@ -163,6 +173,28 @@
|
||||
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
|
||||
-->
|
||||
<PackageVersion Include="Velopack" Version="1.2.0" />
|
||||
|
||||
<!--
|
||||
Microsoft's own TPM 2.0 stack, TSS.MSR, and the only way LinuxDeviceKeyStore reaches the chip.
|
||||
|
||||
Windows needs nothing here: CNG is in the framework and the Platform Crypto Provider is the TPM
|
||||
behind it, and macOS reaches the Secure Enclave through the Security framework that
|
||||
MacSymbols.cs binds by hand. Linux has neither — the platform's own stack is tpm2-tss, a C
|
||||
library with a large surface — so this is the one head that needs a package to talk to its own
|
||||
hardware.
|
||||
|
||||
Chosen over P/Invoking libtss2-esys for two reasons that both hold at the same time. It is
|
||||
managed all the way down, so there is no native dependency to add to the RPM and nothing to
|
||||
resolve at load time; and it works against a software TPM over a socket exactly as it does
|
||||
against /dev/tpmrm0, which is what makes LinuxDeviceKeyStoreTests able to run the whole seal,
|
||||
unseal and lockout path unattended. Neither of the other two device key stores can be tested at
|
||||
all — see WindowsDeviceKeyStoreTests for the ten minutes that took to find out.
|
||||
|
||||
It carries a runtimes/linux-x64 asset, like SkiaSharp and libsodium, so the RID-specific publish
|
||||
picks it up and the RID-free locked restore does not. See the RuntimeIdentifiers note in
|
||||
DodoSSH.Client.App.csproj for why that distinction is load-bearing here.
|
||||
-->
|
||||
<PackageVersion Include="Microsoft.TSS" Version="2.1.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Versioning">
|
||||
<!--
|
||||
@@ -211,5 +243,20 @@
|
||||
mistakes that cause real authorization holes.
|
||||
-->
|
||||
<PackageVersion Include="WireMock.Net" Version="2.13.0" />
|
||||
<!--
|
||||
A JavaScript interpreter, in managed code, so that terminal.js can be tested as the file that
|
||||
ships rather than as a transcription of it into C#. Test-only and referenced by exactly one
|
||||
project; nothing in src depends on it.
|
||||
|
||||
Chosen over shelling out to node, which is the obvious alternative and needs node present
|
||||
wherever the suite runs — CI installs one already, but a test that only runs when a separate
|
||||
command is remembered is a test that stops being run. Chosen over a headless browser for the
|
||||
same reason several times over.
|
||||
|
||||
What it does not buy: Jint is not Chromium, so this proves the page's own logic and nothing
|
||||
about how WebView2 or Android's WebView behave. That line is drawn in RendererPage's remark and
|
||||
picked up by docs/manual-checks.md 1.10 and 11.12a.
|
||||
-->
|
||||
<PackageVersion Include="Jint" Version="4.16.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -69,9 +69,10 @@ src/
|
||||
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
||||
DodoSSH.Client.Transfer the transfer queue, part files and resume, and the local file listing
|
||||
DodoSSH.Client.ObjectStore S3-compatible buckets, behind the same interface as SFTP
|
||||
DodoSSH.Client.Import reading ~/.ssh/config, and the keys it names when asked
|
||||
DodoSSH.Client.Import reading ~/.ssh/config and the keys it names when asked; reading a Hetzner
|
||||
Cloud project over its API
|
||||
DodoSSH.Client.Shell the view models both heads drive, the renderer's files, the palette
|
||||
DodoSSH.Client.App the desktop head: its views, and its Windows integration
|
||||
DodoSSH.Client.App the desktop head: its views, and its Windows, macOS and Linux integration
|
||||
DodoSSH.Client.Android the phone head: its views, and its Android integration
|
||||
tests/ one test project per source project
|
||||
docs/adr/ architecture decision records
|
||||
@@ -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/android-port.md the Android head: what was decided, what is built, what is left
|
||||
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
|
||||
release-macos.sh — the same, signed and notarized, on a Mac
|
||||
ci-linux-rpm.sh — builds the Linux .rpm, then interrogates it
|
||||
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
|
||||
build/linux/ the RPM spec the Linux package is built from
|
||||
```
|
||||
|
||||
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
||||
@@ -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
|
||||
this machine's device key from your account.
|
||||
|
||||
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
|
||||
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
|
||||
## Installing on macOS
|
||||
|
||||
A `.pkg` on the same release page, for Apple Silicon. Everything above about where a client may come from,
|
||||
about the update check and about uninstalling applies unchanged; what differs is worth three short
|
||||
paragraphs.
|
||||
|
||||
**It is signed and notarized, so there is no warning to click past.** That is not generosity — macOS refuses
|
||||
to open an un-notarized download outright rather than warning about it, so unlike the Windows build there
|
||||
was never an unsigned option. If you *do* see "cannot be opened because Apple cannot check it for malicious
|
||||
software", the file did not come from the project's release page, and that is worth taking literally.
|
||||
|
||||
**Apple Silicon only for now.** An Intel package is a small amount of work and no one here has an Intel Mac
|
||||
to check it on, and this project does not ship desktop builds nobody has run — see
|
||||
[docs/manual-checks.md](docs/manual-checks.md). Under Rosetta the arm64 build will not run; there is no
|
||||
graceful version of that, and the honest answer is that the platform is not covered yet.
|
||||
|
||||
**Touch ID can stand in for your passphrase**, on a Mac with a Secure Enclave. The key that unwraps your
|
||||
device key is generated inside the enclave and never leaves it, and the enclave — not DodoSSH — is what
|
||||
requires your fingerprint or login password before it will use it. Cancel the prompt and you get the
|
||||
passphrase screen, always. The application lives at `/Applications/DodoSSH.Desktop.app` and your vault cache
|
||||
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
|
||||
first never touches the second.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
|
||||
to" picker on the Keychain screen and cannot be moved yet.
|
||||
everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the
|
||||
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
|
||||
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
|
||||
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
|
||||
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.
|
||||
@@ -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
|
||||
[Installing on Windows](#installing-on-windows) for what a user sees.
|
||||
|
||||
Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the
|
||||
first release aimed at strangers), and macOS and Linux packaging.
|
||||
**The macOS half is built on the same machinery**, and signed from the start because Gatekeeper leaves no
|
||||
choice: `scripts/release-macos.sh` publishes, signs every native library, notarizes with Apple and staples
|
||||
the ticket before it will hand anything over, and refuses to upload until a person has installed it. The
|
||||
device key is held in the Secure Enclave behind Touch ID. CI publishes `osx-arm64` and builds the `.app`
|
||||
on every main build to prove it still packages, and uploads nothing. See
|
||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
|
||||
[Installing on macOS](#installing-on-macos).
|
||||
|
||||
**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.
|
||||
|
||||
## 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.
|
||||
#
|
||||
# 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
|
||||
# dotnet run --project src/DodoSSH.Api
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"post.logout.redirect.uris": "http://127.0.0.1/*"
|
||||
},
|
||||
"redirectUris": [
|
||||
"http://127.0.0.1/callback"
|
||||
"http://127.0.0.1/callback",
|
||||
"dev.dodotech.dodossh:/callback"
|
||||
],
|
||||
"webOrigins": [],
|
||||
"protocolMappers": [
|
||||
|
||||
@@ -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
|
||||
proxy into the operator's network.
|
||||
- **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
|
||||
**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
|
||||
envelope's minimum-length rule.
|
||||
|
||||
### 4. Linux: sealed into the TPM behind a PIN the TPM checks
|
||||
|
||||
Added after the Linux head was packaged ([ADR 0013](0013-desktop-distribution-and-updates.md) decision 11).
|
||||
The third desktop platform reached this file the way the first two did — by having to answer the amendment
|
||||
above — and it is the only one where the answer is not a clean yes.
|
||||
|
||||
**The mechanism.** The 32-byte device key is sealed into a TPM 2.0 keyedhash object whose `userAuth` is
|
||||
derived from a PIN. Unsealing is a command the TPM refuses without it. The blob on disk is encrypted to the
|
||||
machine's storage hierarchy, so it is useless on any other machine, and nothing in this application ever
|
||||
compares the PIN. The parent is an ECC P-256 primary rederived from a fixed template on every use rather
|
||||
than made persistent — a persistent handle would occupy an NV slot forever, need cleaning up on withdrawal,
|
||||
and collide with whatever else on the machine chose the same index. ECC rather than RSA because creating an
|
||||
RSA primary on a discrete TPM takes seconds and this runs on the unlock path.
|
||||
|
||||
**The sealed object deliberately does not carry `NoDA`, and that single attribute is what makes a
|
||||
four-digit PIN defensible.** Every wrong answer advances the TPM's dictionary-attack counter and the chip
|
||||
stops answering entirely once it trips — measured against a software TPM at four wrong tries, after which
|
||||
the *correct* PIN gets nothing either. That is the property a short secret has here and cannot have in a
|
||||
file, and it is asserted by a test rather than left to a reading of the template, because removing the
|
||||
attribute would leave every other assertion in that file passing.
|
||||
|
||||
◆ **This is weaker than Windows Hello and Touch ID, and the difference is invisible to the user.** Both of
|
||||
those raise their prompt from inside the platform — a secure desktop, a system sheet — so the process asking
|
||||
never sees the secret. Linux offers nothing to borrow, so the PIN is typed into a window this application
|
||||
draws, and an attacker already running as the user could draw a convincing copy of it. Two things bound
|
||||
that, and neither erases it: the same attacker could counterfeit the passphrase screen, which is a much
|
||||
larger prize, so the feature creates no capability that did not exist; and what they would obtain is a PIN
|
||||
that is worthless without this machine's TPM, where the passphrase is worth every machine. **The parity is
|
||||
not claimed anywhere in the interface**, which is why `DesktopDeviceKeyStores` documents the ordering and
|
||||
why the prompt is deliberately plain rather than dressed to look like a system dialog — teaching people
|
||||
that a convincing DodoSSH PIN box is a system dialog is the one thing this design must not do.
|
||||
|
||||
**Reaching the TPM at all is the other half, and the package now arranges it.** On Fedora `/dev/tpmrm0` is
|
||||
`root:tss` mode 0660 and desktop users are not in `tss`, so without help the ordinary case is a working TPM
|
||||
this process may not open — which reports unavailable and keeps asking for the passphrase, exactly as a
|
||||
machine with no TPM does.
|
||||
|
||||
◆ **This originally said shipping a udev rule to widen that was refused, on the grounds that it is a
|
||||
system-wide decision an application package should not make. That refusal was overturned deliberately, and
|
||||
the reasoning it was replaced with is narrower rather than louder.** The refusal treated "widen access" as
|
||||
one thing. It is two, and they are not close:
|
||||
|
||||
- *Adding a person to `tss`* is what the refusal was really about, and it remains the wrong answer. It is
|
||||
permanent, it applies whether or not they are at the machine, it applies over SSH, and it needs an
|
||||
administrator and a fresh login before the feature appears at all.
|
||||
- *Tagging `/dev/tpmrm0` with `uaccess`* is the mechanism systemd already uses for `/dev/dri`, webcams,
|
||||
scanners and FIDO keys: logind grants a POSIX ACL to the user of the **active local session** and removes
|
||||
it when that session ends. Not over SSH, not on an inactive VT, not to daemons. `build/linux/71-dodossh-tpm.rules`
|
||||
is that one line, and it changes neither the mode nor the group, so anything that worked before still does.
|
||||
|
||||
**`tpmrm` and never `tpm`, which is what makes it defensible at all.** `/dev/tpm0` is the raw device and is
|
||||
exclusive; a process holding it can leave the chip in a state that breaks every other user. `/dev/tpmrm0` is
|
||||
the kernel's resource manager, which virtualises handles and isolates callers — it exists *because* the TPM
|
||||
is meant to be usable by more than one unprivileged program. Granting a seat's user the manager is the use
|
||||
it was designed for.
|
||||
|
||||
**The cost is a denial of service and not a disclosure, and it is stated because it is easy to miss.** TPM
|
||||
objects are protected by their own auth values, so this lets nobody read anybody else's sealed data. What it
|
||||
does allow is *failed* authorisation attempts — and the chip answers those by counting them and eventually
|
||||
refusing everyone, which is the same lockout decision 4 relies on. On a machine whose disk is unlocked by
|
||||
the TPM at boot (`systemd-cryptenroll`, Clevis), somebody sitting at it can trip that lockout and stop the
|
||||
next boot from unlocking until it recovers. An administrator who does not accept that masks the file with
|
||||
`ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules`, which is the standard override, survives upgrades,
|
||||
and puts the behaviour back to the passphrase-only path. The rule says so in its own comments, because the
|
||||
person who needs to know is reading the file rather than this document.
|
||||
|
||||
**What was rejected, and why each would have failed the amendment above.** The Secret Service API —
|
||||
gnome-keyring or KWallet — is out of process and OS-managed and has *no per-use consent*: once the session
|
||||
keyring is unlocked anything running as the user reads the key silently, which is DPAPI's shape exactly and
|
||||
what "Why not DPAPI alone" already refuses. `fprintd` is an authentication oracle rather than a key store:
|
||||
it answers a question and holds nothing, so skipping the call skips the gate — the amendment's mistake with
|
||||
a fingerprint reader attached. A polkit-gated root helper would genuinely work and was declined on surface
|
||||
area: it puts a privileged component in a package that installs nothing setuid, to reach a property the TPM
|
||||
already provides.
|
||||
|
||||
## Consequences
|
||||
|
||||
### The cache key had to move, and the spec changed
|
||||
@@ -138,9 +213,19 @@ would have become false under DPAPI alone. A gesture is still something the atta
|
||||
|
||||
### Operational
|
||||
|
||||
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
||||
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
||||
is therefore required, not a nicety.
|
||||
- **A TPM is not always there, and on Linux it is often there and out of reach.** A machine without one gets
|
||||
a store that reports itself unavailable, so unlock keeps asking for the passphrase and neither affordance
|
||||
appears in the interface. The passphrase path is therefore required, not a nicety — and on Linux it is the
|
||||
default rather than the exception, because the device node is not readable by an ordinary desktop user
|
||||
until somebody says it should be. See decision 4.
|
||||
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
|
||||
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
|
||||
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
|
||||
platform's rather than the process's — which is the entire point of the 2026-07-30 amendment above, and
|
||||
the thing a self-drawn prompt over a protected file would fail to be. The mechanical differences are
|
||||
incidental: P-256 with ECIES because the enclave holds no other kind of key, and no prompt when sealing
|
||||
because the public half needs no consent. See docs/platform-flags.md for the three ordinary Macs where the
|
||||
probe answers no, one of which is every unsigned development build.
|
||||
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
|
||||
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
|
||||
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
|
||||
|
||||
@@ -228,6 +228,194 @@ changes.
|
||||
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
||||
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
||||
|
||||
**This rule is Windows-only, and macOS gets the opposite one.** See decision 10: there is no "unsigned for
|
||||
now" available on that platform at any price, because Gatekeeper refuses rather than warns.
|
||||
|
||||
### 10. macOS is a second desktop platform on the same machinery, signed from the start
|
||||
|
||||
The macOS head is the same application, the same Velopack, and the same two-phase person-run release. Four
|
||||
things differ, and each is forced rather than chosen.
|
||||
|
||||
**Signing is a precondition, not an improvement.** Decision 8's whole argument — one dialog per user per
|
||||
lifetime, buy a certificate when a stranger is invited to install — has no macOS equivalent. An
|
||||
un-notarized download is refused outright, so the Developer ID certificate and the notarization round trip
|
||||
are the price of the package existing. `scripts/release-macos.sh` therefore refuses to run without the
|
||||
signing identities, where the Windows script refuses nothing.
|
||||
|
||||
**The channels are `osx` and `osx-nightly`, and they are separate for decision 9's reason.** Four channels
|
||||
now publish to one repository, and the only thing keeping a Mac from being offered a Windows package is
|
||||
that it never reads that index. The macOS nightly channel is named and has no publisher: CI builds and
|
||||
bundles the macOS head to prove it still builds, and uploads nothing, exactly as it does for the Windows
|
||||
release channel.
|
||||
|
||||
**The pack id is shared with Windows, and on macOS it is visible.** vpk names the bundle after the pack id,
|
||||
so `/Applications` holds `DodoSSH.Desktop.app`. Decision 2's reasoning applies with more force here rather
|
||||
than less: a pack id of `DodoSSH` would put Velopack's install root on `~/Library/Application
|
||||
Support/DodoSSH`, which is `ClientPaths.DataDirectory`, and an uninstall would take the user's un-synced
|
||||
outbox with it. `CFBundleDisplayName` puts the product name back in front of a person; the directory keeps
|
||||
the id.
|
||||
|
||||
**arm64 only, because the check is the scarce thing.** Velopack keys a channel to one architecture, and an
|
||||
Intel package would be the only artefact in this repository reaching users without somebody having walked
|
||||
Phase 18 against it. The engineering for a second channel is small and is described in the release script;
|
||||
what is missing is an Intel Mac to verify on, and shipping blind is the thing this project's manual-check
|
||||
discipline exists to refuse.
|
||||
|
||||
**And one thing that does not differ, which is worth saying because it is the expensive half.** The
|
||||
capability to publish still lives on a person's machine and never in CI. Notarization does not change that:
|
||||
Apple's ticket says this build came from this developer account, and says nothing about whether the build
|
||||
should have been made. Velopack clients still apply what their feed serves. Rule 3 is untouched.
|
||||
|
||||
### 9. There is a second desktop channel, published by CI, and it is a second application
|
||||
|
||||
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
|
||||
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
|
||||
|
||||
**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.
|
||||
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
|
||||
inherit it.
|
||||
- **A Velopack channel for Linux.** The mechanism exists — Velopack builds an AppImage — and repeating
|
||||
decisions 1 through 9 a third time would have been the consistent-looking move. Declined in decision 11
|
||||
because this is the one desktop platform that already has an updater the user controls, and a second one
|
||||
answering to this project is the capability decision 4 exists to make unsettable.
|
||||
- **Azure Trusted Signing**, for now, on custody grounds rather than on mechanics. See decision 8.
|
||||
- **A hand-edited version property.** Considered seriously against MinVer, and it loses on the thing that
|
||||
matters here: the tag is already the version of record for the container image, and two places to write
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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,
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -280,13 +320,50 @@ What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, th
|
||||
|
||||
### 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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
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,
|
||||
plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
|
||||
choosing a passphrase).
|
||||
@@ -568,9 +650,16 @@ go at 360dp:
|
||||
believing they typed an `l`.
|
||||
|
||||
**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
|
||||
draws a 35-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three
|
||||
connections there are. That sheet is the head's first control that could be drawn over the renderer, so
|
||||
and the bottom bar while a shell is showing, and the screen draws a 35-pixel bar in their place: back,
|
||||
the session pills, and a `+` raising a sheet with the three connections there are.
|
||||
|
||||
*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
|
||||
WebView actually composites above Avalonia content is still the unverified question recorded below, and
|
||||
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.
|
||||
- **The device name** on log entries comes from the head, not `Environment.MachineName`, which returns
|
||||
something like `localhost` on Android and would make every entry from a phone indistinguishable.
|
||||
- **The Hetzner Cloud importer is not ported, and it is the one gap here that is not a platform limit.**
|
||||
Its view model is in `DodoSSH.Client.Shell` like every other and its reader is an `HttpClient` call, so
|
||||
both halves already run on a phone — unlike the `~/.ssh/config` importer beside it, which has no file to
|
||||
read under scoped storage and never will. What is missing is a layout: the desktop draws a five-column
|
||||
table of servers, and a phone version of that is a different screen rather than the same one narrower.
|
||||
Said on the Preferences screen rather than left to be discovered.
|
||||
- **`NativeKeyboardFocus` is not ported.** It exists for a documented Win32 asymmetry — focus crosses into
|
||||
WebView2 but does not come back — and Android's focus model is different enough that the problem should be
|
||||
confirmed to exist before anything is written to solve it.
|
||||
|
||||
+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
|
||||
> 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
|
||||
> 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
|
||||
> `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
|
||||
> 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
|
||||
> 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
|
||||
@@ -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. |
|
||||
> | **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. |
|
||||
> | 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. |
|
||||
> | **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. |
|
||||
> | 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
|
||||
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
|
||||
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
|
||||
M2 and M3 arriving in a design before it arrives in the code.
|
||||
Read alongside [the milestone plan](../README.md#milestones). Most of what follows is not a defect. M2 and
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be
|
||||
saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway.
|
||||
**The client's settings file holds two things.** It writes three files — `cache.db`, `device.key` and
|
||||
`settings.json` — and the cache's six tables still hold none of them. `ClientSettings` carries the
|
||||
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
|
||||
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 `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. |
|
||||
| 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`). |
|
||||
|
||||
**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. |
|
||||
| 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 `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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| `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 |
|
||||
| --- | --- | --- | --- |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -476,8 +691,8 @@ lists the rest as absent rather than omitting it silently.
|
||||
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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, 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. |
|
||||
| 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. |
|
||||
@@ -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
|
||||
sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire,
|
||||
`IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected.
|
||||
|
||||
---
|
||||
|
||||
## v5c-4 — two more, asked for after living with v5b
|
||||
|
||||
**The session shell's host header is gone, and it is a deliberate departure from the design.**
|
||||
`Terminal.dc.html` and `SFTP.dc.html` both draw a 60-pixel row above the pane carrying the address on the
|
||||
left and a cross-surface button on the right, and v5b shipped it as `SessionHeader.axaml`. Both of the two
|
||||
facts it held now live at the head of the sidebar beside the pane — the address as its own line, and the
|
||||
button stretched across the column under it — and the pane is 60 pixels taller for it. The reasoning is the
|
||||
one the design cannot see from a mock: this is a window somebody keeps a terminal open in all day, and a
|
||||
full-width strip repeating an address the tab already names was the cheapest 60 pixels in the layout to give
|
||||
back. `LayoutHarness.SessionScreenHeight` no longer subtracts a header, for the same reason it stopped
|
||||
subtracting the retired window-wide tab strip.
|
||||
|
||||
**The sidebar closes, which the design has no state for.** 300 pixels of a 1081-pixel minimum is a lot to
|
||||
spend on a list that is often two rows long, so `MainWindowViewModel.IsSessionSidebarOpen` folds the column
|
||||
to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel
|
||||
that vanishes without trace is one people report as lost. The choice is written through to
|
||||
`ClientSettings.SessionSidebarOpen` rather than held for the session: it is a decision about how much of the
|
||||
window a terminal gets, and one that had to be made again on every launch would not really be on offer.
|
||||
|
||||
## v5c-5 — one door to settings, not three
|
||||
|
||||
**The user-chip popover keeps its Settings row and has lost Vaults and Preferences.** v5c gave it three, and
|
||||
the table above records why: until that pass "Settings" and "Preferences" had been two doors onto the same
|
||||
bare screen, so splitting them was a real fix, and Vaults arrived beside them when `VaultsScreen` became a
|
||||
settings page too.
|
||||
|
||||
Living with it made the shape of the mistake clear. All three opened the same full-window mode and differed
|
||||
only in which of its seven pages they landed on — and `SettingsNav`, which lists every one of those pages
|
||||
including Vaults and Preferences, is on screen the entire time you are in there. So two of the three rows
|
||||
were deep links into a place the third one opens, saving one click and costing a menu that read as though it
|
||||
offered three destinations. A menu at the foot of the rail is for getting into settings at all; which page
|
||||
you want is a question that has a rail of its own once you are there.
|
||||
|
||||
Nothing became unreachable: both pages are one click deeper, from the rail that was always going to be the
|
||||
way to them. `NavRailTests` asserts the rows are gone from the markup rather than merely unwired, because a
|
||||
row left behind with no handler is a menu entry that does nothing when pressed — which reads as a broken
|
||||
application rather than a tidied menu.
|
||||
|
||||
Logout stays, and still lands on a page rather than on General: its confirmation card is drawn inline on the
|
||||
Account page and nowhere else, so arming it from anywhere else would be a card raised where nobody could see
|
||||
it. The vault switches and **New vault** above the divider stay too — they are what the popover is for
|
||||
besides settings, and neither is a page of the settings mode.
|
||||
+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
|
||||
have already cost time once. Development is Windows-first, but **the full test suite now runs on
|
||||
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
|
||||
suspicion. **macOS is still untested**, and anything marked *unverified* has not run on the platform
|
||||
in question and must not be assumed to work.
|
||||
suspicion. Anything marked *unverified* has not run on the platform in question and must not be
|
||||
assumed to work.
|
||||
|
||||
**macOS now builds and packages, and has still never run.** The distinction matters more here than
|
||||
anywhere else on this page, because the two halves are verified in completely different places. The
|
||||
build is measured on every main and tag build: CI publishes `osx-arm64` and runs `vpk [osx] bundle`
|
||||
on a Linux runner, which is enough to catch a restore graph with no macOS native asset and an `.app`
|
||||
that will not compose. Everything past that — whether the window draws, whether the terminal's
|
||||
loopback WebSocket reaches WKWebView, whether the Secure Enclave holds a device key — is verified
|
||||
only by a person walking Phase 18 of [manual-checks.md](manual-checks.md) on a Mac, because **there
|
||||
is no macOS runner in CI**. Treat every macOS runtime claim below as unverified unless it says
|
||||
otherwise.
|
||||
|
||||
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
|
||||
has been verified or made moot — not when it merely stops being convenient.
|
||||
@@ -17,6 +27,19 @@ docs/crypto.md §1. *Already mitigated* — but if a BCL AEAD path is ever added
|
||||
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
|
||||
any vault on macOS.
|
||||
|
||||
**The Secure Enclave holds P-256 keys and nothing else**, which is why `MacDeviceKeyStore` wraps the
|
||||
device key with ECIES rather than with the RSA-OAEP the Windows store uses. It will not hold an RSA
|
||||
key at any size, so this is not a preference. The useful consequence is that the macOS shape is
|
||||
*better* than the Windows one: `SecKeyCopyPublicKey` works on an enclave key without prompting, so
|
||||
registering a device is silent and only unlock asks — where Windows raises a dialog at key creation
|
||||
too. *Unverified:* no enclave call in this repository has ever run.
|
||||
|
||||
**Three ordinary Macs have no usable enclave**, and `IsSupported` probes rather than infers for that
|
||||
reason: an Intel machine without a T2, a machine with no login password set, and — the one that
|
||||
surprises people — **any build that is not code signed**, because enclave key creation needs a
|
||||
signing identity. So `dotnet run` correctly offers no device key at all. Do not "fix" this by
|
||||
checking the OS instead; the offer would then put a wrap on the server that nothing can ever open.
|
||||
|
||||
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
|
||||
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
|
||||
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
|
||||
@@ -26,7 +49,11 @@ and the parameters are stored per user at enrollment, so a bad default is a per-
|
||||
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
|
||||
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
||||
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
|
||||
or notarization fails with an error that does not name the offending file.
|
||||
or notarization fails with an error that does not name the offending file. *Mitigated* in
|
||||
`scripts/release-macos.sh`, which signs every `.dylib` and `createdump` in a loop before vpk touches
|
||||
anything — vpk's own pass uses `codesign --deep`, which is the shape Apple documents as wrong for
|
||||
nested code and is the likeliest source of that unnamed rejection. The loop looks redundant next to
|
||||
`--deep` and is not; do not delete it because a release once succeeded without it.
|
||||
|
||||
## Desktop client
|
||||
|
||||
@@ -361,11 +388,55 @@ agent of our own plus ProxyJump covers the real use cases.
|
||||
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
|
||||
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
|
||||
|
||||
**That suite has an intermittent `The connection was closed by the remote host`**, on whichever test
|
||||
connects first, within tens of milliseconds. Seen in CI and reproducible locally. *Mitigated, not
|
||||
solved:* `SshServerFixture` now raises sshd's `MaxStartups` from its compiled-in `10:30:100`, which
|
||||
refuses connections at random past ten unauthenticated ones in flight — reachable because xUnit runs
|
||||
test classes in parallel and most of them connect. The fixture comment carries the full argument and
|
||||
is explicit that the cure is unproven.
|
||||
|
||||
**And the reason it is unproven is a measurement trap worth not falling into twice.** Docker
|
||||
throughput on the Windows development machine swings enough to swamp the effect: the identical
|
||||
unmodified suite ran 85/85 clean and, an hour later, failed 13 runs out of 15. Any before/after flake
|
||||
comparison taken there is noise. Measure this class of thing in CI, or make the server say why —
|
||||
raise sshd's `LogLevel`, disable Ryuk so the container outlives the run, and read `docker logs`.
|
||||
|
||||
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
|
||||
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
|
||||
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
||||
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
||||
|
||||
**The App Sandbox is ruled out on macOS for the same reason, and the entitlements say so.** A
|
||||
sandboxed process cannot listen on loopback without `com.apple.security.network.server`, and the
|
||||
terminal is that listener. Developer ID distribution outside the App Store does not require the
|
||||
sandbox, so this costs nothing today — but it does mean the Mac App Store is closed to this
|
||||
application without solving the data plane differently first. See
|
||||
`build/macos/DodoSSH.entitlements`.
|
||||
|
||||
**The hardened runtime is not optional and .NET needs four holes punched in it.** Notarization
|
||||
refuses a Developer ID submission without it, and CoreCLR will not start under it without
|
||||
`allow-jit` and `allow-unsigned-executable-memory` — both, not either, because the runtime allocates
|
||||
executable memory outside the `MAP_JIT` path as well. `disable-library-validation` and
|
||||
`allow-dyld-environment-variables` are needed for Velopack's updater rather than for the runtime.
|
||||
Each is argued individually in the entitlements file; the failure mode for a missing one is a
|
||||
process that dies during runtime initialisation, before anything exists that could report it.
|
||||
|
||||
**`vpk` cross-compiles to macOS only as far as the bundle.** `vpk [osx] bundle` runs anywhere and
|
||||
produces a real `.app`; there is no `[osx] pack` off a Mac, because pack drives `codesign`,
|
||||
`notarytool` and `stapler`. So CI can prove the bundle builds and only a Mac can produce something
|
||||
installable. Note this is the *opposite* of the Windows story, where `vpk [win] pack` builds the
|
||||
whole installer on Linux — the asymmetry is Apple tooling, not a Velopack limitation.
|
||||
|
||||
**A custom `Info.plist` is copied verbatim by vpk, with no substitution whatsoever.** That is why
|
||||
`--plist` and `--bundleId` are mutually exclusive, and why `build/macos/Info.plist.template` is a
|
||||
template the release script renders rather than a committed file. A committed plist would carry one
|
||||
version into every release afterwards, and the symptom is silent: Velopack's index would still be
|
||||
right, the updater would still work, and only Get Info and any crash report would disagree.
|
||||
|
||||
**macOS app icons live on an 824-in-1024 grid.** An icon that bleeds to the edge of its canvas is
|
||||
not bolder, it is the one icon in the Dock that is too big. `dodossh-icon.ps1` draws the `.icns` at
|
||||
that fraction and the `.ico` at full bleed, from one geometry.
|
||||
|
||||
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
|
||||
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
||||
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
||||
@@ -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
|
||||
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
|
||||
that target has run, and both readers of it — the tag check in `ci.yml` and `scripts/release-windows.ps1`
|
||||
— pass it. Neither had, and neither had ever run: the CI 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`.
|
||||
that target has run, and every reader of it — the tag check in `ci.yml`, the desktop nightly job, and
|
||||
`scripts/release-windows.ps1` — passes it. Neither of the first two had, and neither had ever run: the CI
|
||||
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
|
||||
— 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
|
||||
on being noticed later than every other one.
|
||||
|
||||
**A lock file can go stale with nothing in this repository changing, because `Microsoft.NET.ILLink.Tasks`
|
||||
is versioned by the SDK and `global.json` lets the SDK float.** The reference is implicit — nothing in any
|
||||
`.csproj` asks for it — and its version tracks the runtime patch band, while `global.json` pins only
|
||||
`10.0.100` with `rollForward: latestMinor`. So `setup-dotnet` installs whatever the newest 10.x SDK is on
|
||||
the day, and the moment that SDK's band moves, locked-mode restore stops:
|
||||
|
||||
```
|
||||
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||
from [10.0.10, ) to [10.0.11, ).
|
||||
```
|
||||
|
||||
It named `DodoSSH.Client.Android`, `DodoSSH.Contracts` and `DodoSSH.Crypto` — the three lock files that
|
||||
carry the entry — on a commit that touched none of them and no dependency at all.
|
||||
|
||||
The fix is `--force-evaluate` on those three, **from a machine whose SDK is at least as new as the
|
||||
runner's**, which is the part that is easy to get wrong: a `--force-evaluate` from an older SDK rewrites
|
||||
the lock at the older version, changes nothing, and looks like it worked. Check `dotnet --version` against
|
||||
the version in the error before believing a regeneration.
|
||||
|
||||
This will recur on every SDK patch that moves the band. It is the accepted cost of letting the SDK float:
|
||||
the alternative is pinning an exact SDK in `global.json`, which trades a recurring lock-file bump for a
|
||||
recurring toolchain bump and makes every contributor install one specific SDK. Neither is free, and this
|
||||
repository has chosen the floating side deliberately.
|
||||
|
||||
**.NET for Android cannot be built on a musl host, and this project's runner is Alpine. Every message the
|
||||
toolchain produces on the way to saying so names a missing file that is present.** Three CI rounds went
|
||||
into this and the first two fixed symptoms, so the messages are worth reading in the order they arrive.
|
||||
|
||||
@@ -0,0 +1,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 }
|
||||
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
|
||||
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
||||
& dotnet build $Project --no-restore --configuration Release `
|
||||
-t:SignAndroidPackage `
|
||||
-p:DodoChannel=release `
|
||||
-p:AndroidKeyStore=true `
|
||||
-p:AndroidSigningKeyStore=$KeystorePath `
|
||||
-p:AndroidSigningKeyAlias=$KeyAlias `
|
||||
-p:AndroidSigningStorePass=$storePlain `
|
||||
-p:AndroidSigningKeyPass=$keyPlain
|
||||
# ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE.
|
||||
#
|
||||
# They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is
|
||||
# not. A process's command line is not a secret on Windows: any process running as this user can read
|
||||
# every argument of every other with one call —
|
||||
#
|
||||
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||
#
|
||||
# — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The
|
||||
# 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.' }
|
||||
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
|
||||
in a shell's memory the better.
|
||||
The token is prompted for rather than read from a file or an ambient environment variable, and only in
|
||||
the phase that needs it — the build does not, and the fewer minutes a credential that can publish an
|
||||
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
|
||||
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
|
||||
# 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.
|
||||
#
|
||||
# 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'
|
||||
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
@@ -100,11 +107,20 @@ Push-Location $RepoRoot
|
||||
try {
|
||||
# ---- What is being released -----------------------------------------------------------------------
|
||||
|
||||
# -t:MinVer, and it is load-bearing. -getProperty on its own evaluates the project and runs no
|
||||
# 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
|
||||
# tagged v1.0.0. CI's tag check had the same line and the same fault; both are fixed, and both
|
||||
# say so, because this is the version that ends up in the package a client compares against.
|
||||
# Restored before the version is read, and both halves of that sentence are load-bearing.
|
||||
#
|
||||
# -t:MinVer, because -getProperty on its own evaluates the project and runs no 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 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', ''
|
||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||
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
|
||||
# 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.
|
||||
#
|
||||
# ◆ 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 = @(
|
||||
'upload', 'gitea',
|
||||
'--repoUrl', $RepoUrl,
|
||||
'--token', $token,
|
||||
'--outputDir', $ReleasesDir,
|
||||
'--channel', $Channel,
|
||||
'--releaseName', $tag,
|
||||
@@ -155,7 +185,17 @@ try {
|
||||
}
|
||||
|
||||
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.' }
|
||||
|
||||
Write-Step "Published $tag."
|
||||
|
||||
@@ -26,9 +26,6 @@ internal sealed class GetMetaEndpoint(
|
||||
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
|
||||
internal const int SyncProtocolVersion = 1;
|
||||
|
||||
/// <summary>Feature flag for the relay.</summary>
|
||||
internal const string RelayFeature = "relay";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
@@ -50,7 +47,7 @@ internal sealed class GetMetaEndpoint(
|
||||
List<string> features = ["teams"];
|
||||
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
|
||||
|
||||
@@ -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>(
|
||||
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
|
||||
// DbContext, and a singleton would capture one for the lifetime of the process.
|
||||
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
||||
|
||||
@@ -54,6 +54,13 @@ internal static class Configuration
|
||||
+ "setting exists to provide.")
|
||||
.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>()
|
||||
.BindConfiguration(RelayOptions.SectionName)
|
||||
.ValidateDataAnnotations()
|
||||
@@ -63,11 +70,28 @@ internal static class Configuration
|
||||
.Validate(
|
||||
options => options.IdleTimeout < options.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();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warns about configuration that is individually valid but dangerous in combination.
|
||||
/// </summary>
|
||||
|
||||
@@ -115,13 +115,50 @@ public sealed class RelayOptions
|
||||
/// Whether RFC1918 and other private ranges may be dialled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. Loopback,
|
||||
/// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
|
||||
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
|
||||
/// </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>
|
||||
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>
|
||||
[Range(1, 1000)]
|
||||
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using DodoSSH.Api.Features.Events;
|
||||
using DodoSSH.Api.Features.Identity;
|
||||
using DodoSSH.Api.Features.Meta;
|
||||
using DodoSSH.Api.Features.Relay;
|
||||
using DodoSSH.Api.Features.Sync;
|
||||
using DodoSSH.Api.Features.Teams;
|
||||
using DodoSSH.Contracts;
|
||||
@@ -45,6 +46,8 @@ internal static class EndpointRegistration
|
||||
typeof(SyncPullEndpoint),
|
||||
typeof(SyncPushEndpoint),
|
||||
typeof(VaultEventsEndpoint),
|
||||
typeof(RelayTicketEndpoint),
|
||||
typeof(RelayConnectEndpoint),
|
||||
typeof(CreateTeamEndpoint),
|
||||
typeof(ListTeamsEndpoint),
|
||||
typeof(UpdateTeamEndpoint),
|
||||
@@ -65,7 +68,6 @@ internal static class EndpointRegistration
|
||||
// Registered as each feature lands:
|
||||
// Identity — key rotation, passphrase change
|
||||
// Vaults — per-item ACLs
|
||||
// Relay — tickets and the WebSocket
|
||||
// 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 viewModel = ComposeShell(
|
||||
paths, caches, workspace, knownHosts, connections, ClipboardWriter(shell));
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
connections,
|
||||
ClipboardWriter(shell),
|
||||
ClipboardReader(shell));
|
||||
|
||||
shell.DataContext = viewModel;
|
||||
|
||||
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
|
||||
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
|
||||
//
|
||||
// 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();
|
||||
// Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
|
||||
// does and why.
|
||||
ComposeKeepAlive(workspace, viewModel);
|
||||
|
||||
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>
|
||||
/// Writing to this phone's clipboard.
|
||||
/// </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>
|
||||
/// 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.
|
||||
@@ -157,7 +212,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
TerminalWorkspace workspace,
|
||||
VaultKnownHostStore knownHosts,
|
||||
SshNetConnectionFactory connections,
|
||||
Func<string, Task> copyToClipboard)
|
||||
Func<string, Task> copyToClipboard,
|
||||
Func<Task<string?>> readClipboard)
|
||||
{
|
||||
// Difference 3: the Android keystore, with a fingerprint or the device credential releasing the
|
||||
// key. A straight implementation of the interface the session layer has always taken.
|
||||
@@ -207,6 +263,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
deviceName: PhoneEnvironment.DeviceName,
|
||||
|
||||
copyToClipboard: copyToClipboard,
|
||||
readClipboard: readClipboard,
|
||||
|
||||
updates: updates);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
|
||||
using Avalonia;
|
||||
using Avalonia.Android;
|
||||
using Avalonia.Media;
|
||||
using DodoSSH.Client.Android.Platform;
|
||||
using global::Android.App;
|
||||
using global::Android.Runtime;
|
||||
@@ -46,6 +47,30 @@ public sealed class DodoSshAndroidApplication : AvaloniaAndroidApplication<DodoS
|
||||
}
|
||||
|
||||
/// <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) =>
|
||||
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;
|
||||
|
||||
AndroidRedirectCallback.Abandon();
|
||||
|
||||
// And the keyboard may have gone away while this activity was not on screen, without Avalonia's
|
||||
// input pane raising anything to say so. PhoneShell re-reads the inset from this. See
|
||||
// PhoneEnvironment.Resumed, which carries the whole of why.
|
||||
PhoneEnvironment.RaiseResumed();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -100,6 +100,35 @@ internal static class PhoneEnvironment
|
||||
/// </remarks>
|
||||
public static global::Android.App.Activity? CurrentActivity { get; set; }
|
||||
|
||||
/// <summary>Raised each time the launcher activity comes back to the foreground.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Exists because the software keyboard can go away without Avalonia being told.</b>
|
||||
/// <c>IInputPane.StateChanged</c> is the only signal <c>PhoneShell</c> has for the keyboard opening and
|
||||
/// closing, and it is not raised when Android dismisses the keyboard on the way out of the app — the
|
||||
/// activity is being paused, and the pane is simply gone by the time anything could have reported it.
|
||||
/// So the inset that holds the interface clear of the keyboard was still applied on the way back in,
|
||||
/// and the whole phone drew an entire keyboard's height above the bottom of a screen with no keyboard
|
||||
/// on it. Reproduced every time: open the keyboard anywhere, press Home, return — 838 of 2340 pixels
|
||||
/// dead, and staying that way until the keyboard was opened and closed again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An event here rather than a call into the view, because the view is Avalonia's and this is Android's:
|
||||
/// <c>MainActivity</c> already owns <c>OnResume</c> and already writes <see cref="CurrentActivity"/>
|
||||
/// from it, and a static event is the same shape of hand-off one step further. Anything else that needs
|
||||
/// to re-read platform state on the way back in can subscribe without <c>MainActivity</c> learning what
|
||||
/// it is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Subscribers are responsible for unsubscribing — this is a static event and holds whatever it is
|
||||
/// given for the life of the process. <c>PhoneShell</c> attaches and detaches with its visual tree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static event EventHandler? Resumed;
|
||||
|
||||
/// <summary>Announces that the launcher activity is foreground again.</summary>
|
||||
internal static void RaiseResumed() => Resumed?.Invoke(null, EventArgs.Empty);
|
||||
|
||||
/// <summary>The application context, once <see cref="Attach"/> has run.</summary>
|
||||
public static Context Require() =>
|
||||
context ?? throw new InvalidOperationException(
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using global::Android.Views;
|
||||
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Takes the keyboard away from the terminal's <c>WebView</c>, and reports the taps that should bring it
|
||||
/// back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This replaces <c>TerminalFocus</c>, and it is the opposite move.</b> That class handed Android's own
|
||||
/// view focus <em>back</em> to the <c>WebView</c> after Avalonia's <c>DispatchTouchEvent</c> took it, because
|
||||
/// the input connection the keyboard was attached to belonged to the page. Under ADR 0015 it no longer does:
|
||||
/// <see cref="Views.TerminalInputSurface"/> is the input target, and it is an Avalonia control, so the focus
|
||||
/// the platform requests for Avalonia's view after every handled touch is now the focus we want. The fight
|
||||
/// ends by removing one of the contestants rather than by refereeing it faster.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Non-focusable, not non-interactive, and the distinction is the whole design.</b> Focusability governs
|
||||
/// where keyboard and D-pad input go; it has nothing to do with touch delivery. A <c>WebView</c> with
|
||||
/// <c>Focusable=false</c> still receives, scrolls and hit-tests every touch it is given — so the page keeps
|
||||
/// its <c>touch-action: pan-y</c> scrollback exactly as before — but it can no longer take an input
|
||||
/// connection, and Chromium's own <c>showSoftInput</c> for the focused DOM node cannot succeed from an
|
||||
/// unfocused view. That is precisely the split wanted: the page keeps the finger, Avalonia keeps the
|
||||
/// keyboard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why a tap has to be reported at all.</b> With the page unable to raise a keyboard, the ordinary way
|
||||
/// back after the system back gesture lowers one — touch the terminal — would otherwise do nothing. The
|
||||
/// listener is attached with <c>Handled = false</c> throughout, so it observes without consuming: the
|
||||
/// <c>WebView</c>'s own <c>OnTouchEvent</c> runs after it exactly as if nothing were listening.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A tap is distinguished from a scroll the way every Android view does it, by
|
||||
/// <c>ViewConfiguration.ScaledTouchSlop</c>. Without that test, dragging the scrollback would raise the
|
||||
/// keyboard over the very output being scrolled to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, for the reason
|
||||
/// <c>TerminalFocus</c> gave and which has not changed: the control does not expose its platform child, and
|
||||
/// this application only ever has the one <c>WebView</c>, so the walk's first match is necessarily the
|
||||
/// terminal. Every step is allowed to be absent — there is no activity while backgrounded, and no
|
||||
/// <c>WebView</c> until the terminal surface has been shown once.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class RendererFocus
|
||||
{
|
||||
/// <summary>The view already configured, so that showing the terminal twice does not listen twice.</summary>
|
||||
/// <remarks>
|
||||
/// Weak, because this is a static holding an Android view: a strong reference would keep the whole view
|
||||
/// hierarchy, and the activity behind it, alive for the life of the process.
|
||||
/// </remarks>
|
||||
private static WeakReference<global::Android.Webkit.WebView>? configured;
|
||||
|
||||
/// <summary>
|
||||
/// Makes the renderer unable to hold the keyboard, and calls <paramref name="onTapped"/> when somebody
|
||||
/// taps it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Idempotent, and called on every transition into the terminal rather than once at startup, because the
|
||||
/// <c>WebView</c> does not exist until the surface it lives on has been shown — and because Android is
|
||||
/// free to tear its renderer down and build a new one, which is a case this head already handles
|
||||
/// elsewhere (see <c>docs/android-port.md</c>).
|
||||
/// </remarks>
|
||||
public static void Suppress(Action onTapped)
|
||||
{
|
||||
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (FindWebView(decor) is not { } webView)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (configured is not null && configured.TryGetTarget(out var already) && ReferenceEquals(already, webView))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
webView.Focusable = false;
|
||||
webView.FocusableInTouchMode = false;
|
||||
webView.ClearFocus();
|
||||
|
||||
WatchForTaps(webView, onTapped);
|
||||
|
||||
configured = new WeakReference<global::Android.Webkit.WebView>(webView);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls <paramref name="onTapped"/> for a tap, and stays out of the way of everything else.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A tap is a press and a release that did not travel further than the platform's own
|
||||
/// <c>ScaledTouchSlop</c> — the same test every Android view uses, and necessary rather than fastidious:
|
||||
/// without it, dragging the scrollback would raise the keyboard over the output being scrolled to.
|
||||
/// </remarks>
|
||||
private static void WatchForTaps(global::Android.Webkit.WebView webView, Action onTapped)
|
||||
{
|
||||
// The fallback is the platform's own historical default, for a context that has somehow gone.
|
||||
var slop = webView.Context is { } context ? ViewConfiguration.Get(context)?.ScaledTouchSlop ?? 8 : 8;
|
||||
var downX = 0f;
|
||||
var downY = 0f;
|
||||
var moved = false;
|
||||
|
||||
webView.Touch += (_, e) =>
|
||||
{
|
||||
// Observed, never consumed. The WebView's own OnTouchEvent still runs, so the page scrolls.
|
||||
e.Handled = false;
|
||||
|
||||
if (e.Event is not { } motion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (motion.Action)
|
||||
{
|
||||
case MotionEventActions.Down:
|
||||
downX = motion.GetX();
|
||||
downY = motion.GetY();
|
||||
moved = false;
|
||||
|
||||
break;
|
||||
|
||||
case MotionEventActions.Move:
|
||||
moved |= Math.Abs(motion.GetX() - downX) > slop || Math.Abs(motion.GetY() - downY) > slop;
|
||||
|
||||
break;
|
||||
|
||||
case MotionEventActions.Up when !moved:
|
||||
onTapped();
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static global::Android.Webkit.WebView? FindWebView(ViewGroup parent)
|
||||
{
|
||||
for (var i = 0; i < parent.ChildCount; i++)
|
||||
{
|
||||
switch (parent.GetChildAt(i))
|
||||
{
|
||||
case global::Android.Webkit.WebView webView:
|
||||
return webView;
|
||||
|
||||
case ViewGroup child when FindWebView(child) is { } found:
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using global::Android.OS;
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -41,6 +41,26 @@ internal sealed class SessionForegroundService : Service
|
||||
private const string ChannelId = "dodossh.sessions";
|
||||
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>
|
||||
/// 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.
|
||||
@@ -49,7 +69,9 @@ internal sealed class SessionForegroundService : Service
|
||||
|
||||
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
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <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
|
||||
/// 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.
|
||||
/// </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>
|
||||
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))
|
||||
{
|
||||
@@ -79,12 +123,12 @@ internal sealed class SessionForegroundService : Service
|
||||
}
|
||||
|
||||
var reopen = PendingIntent.GetActivity(
|
||||
this,
|
||||
context,
|
||||
0,
|
||||
new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
||||
new Intent(context, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
||||
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
|
||||
|
||||
return new Notification.Builder(this, ChannelId)
|
||||
return new Notification.Builder(context, ChannelId)
|
||||
.SetContentTitle("DodoSSH")
|
||||
.SetContentText(summary)
|
||||
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
|
||||
@@ -93,37 +137,138 @@ internal sealed class SessionForegroundService : Service
|
||||
.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="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 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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
{
|
||||
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
||||
}
|
||||
|
||||
if (holdsFileSession)
|
||||
{
|
||||
parts.Add("Files connected");
|
||||
}
|
||||
|
||||
if (activeTransfers > 0)
|
||||
{
|
||||
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
|
||||
/// a counter incremented on open and decremented on close would not.
|
||||
/// </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>
|
||||
internal sealed class SessionKeepAlive : IDisposable
|
||||
{
|
||||
private readonly TerminalWorkspace workspace;
|
||||
private readonly Func<int> activeTransfers;
|
||||
private readonly Func<bool> holdsFileSession;
|
||||
|
||||
/// <param name="workspace">The live shells.</param>
|
||||
/// <param name="activeTransfers">
|
||||
/// How many transfers are moving bytes. A delegate rather than a queue, because file transfer is out
|
||||
/// of this head's first scope — see the decision in docs/android-port.md — and this is the seam it
|
||||
/// will arrive through rather than a dependency taken before there is anything to depend on.
|
||||
/// How many transfers are moving bytes. A delegate rather than a queue, because ownership of the
|
||||
/// transfer queue stays with <c>TransfersViewModel</c> — this class only ever asks it a question.
|
||||
/// </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.activeTransfers = activeTransfers;
|
||||
this.holdsFileSession = holdsFileSession;
|
||||
|
||||
// Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is
|
||||
// a binder call and needs no particular thread. Nothing here touches the interface.
|
||||
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
|
||||
// closer's own — which is fine: starting and stopping a service is a binder call and needs no
|
||||
// particular thread. Nothing here touches the interface. That the announcement waits for the run to
|
||||
// actually complete, and comes for deliberate closes too, is what makes reading LiveSessionCount
|
||||
// from it honest — the event's own remark carries the stuck notification that taught us both.
|
||||
workspace.SessionEnded += OnSessionEnded;
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
||||
/// <remarks>
|
||||
/// Called after anything that could change either count — opening a shell, closing a tab, a transfer
|
||||
/// finishing. Calling it when nothing changed is free: reconciling to the state it is already in is
|
||||
/// either a redundant <c>startForegroundService</c> on a running service or a <c>stopService</c> on a
|
||||
/// stopped one, and Android treats both as no-ops.
|
||||
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
|
||||
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
|
||||
/// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
|
||||
/// 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>
|
||||
public void Refresh() =>
|
||||
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers());
|
||||
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
||||
// 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();
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<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" />
|
||||
|
||||
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
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
|
||||
— 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
|
||||
one it was drawn for.
|
||||
|
||||
#0E1220 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
|
||||
separately in the palette for a reason worth keeping in mind here: this is ink on the accent, not
|
||||
the window behind it, and it follows AccentInk if the two ever part.
|
||||
#FFFFFF is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
|
||||
Android resource cannot reference a XAML dictionary. Through v2 it equalled Canvas and this file
|
||||
followed it when the two parted: v5's ink on the accent is white, the way every accent surface in
|
||||
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
|
||||
edge is what the launcher eats for masking and parallax. The glyph spans 36.06..71.94, which is
|
||||
@@ -35,7 +35,7 @@
|
||||
<path
|
||||
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#0E1220"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.4"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
@@ -44,7 +44,7 @@
|
||||
<path
|
||||
android:pathData="M54,64.68 L71.94,64.68"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#0E1220"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.4"
|
||||
android:strokeLineCap="round" />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
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
|
||||
drawn in blue is this one.
|
||||
drawn in its own accent is this one.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
between an Android resource and a XAML resource dictionary, so the duplication is stated rather than
|
||||
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
|
||||
and not a drawable. Same hand-kept duplication as above, and the same rule: if the palette moves,
|
||||
this moves with it.
|
||||
-->
|
||||
<color name="dodo_accent">#5B8CFF</color>
|
||||
<color name="dodo_accent">#5D42DE</color>
|
||||
</resources>
|
||||
@@ -12,10 +12,28 @@
|
||||
the alternative, and the red one is the one that costs something.
|
||||
|
||||
── v2 ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||
The second design rounds everything. The corner radii below are the design's own — 4 for a tag, 9 for a
|
||||
button or a pill, 10 for a list row, 11 for the search well, 12 for a card, 14 for a block of
|
||||
monospaced output — and they are a ladder rather than a set of preferences: the radius says how big the
|
||||
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.
|
||||
The second design rounded everything on its own ladder — 4 for a tag, 9 for a button or a pill, 10 for
|
||||
a list row, 11 for the search well, 12 for a card, 14 for a block of monospaced output — and the numbers
|
||||
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
|
||||
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">
|
||||
@@ -23,15 +41,32 @@
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</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">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||
<Setter Property="Opacity" Value="0.82" />
|
||||
</Style>
|
||||
|
||||
@@ -39,9 +74,15 @@
|
||||
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
|
||||
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">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
<Setter Property="BoxShadow" Value="none" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
@@ -54,7 +95,7 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
@@ -69,17 +110,33 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<!-- A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse. -->
|
||||
<!--
|
||||
A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse.
|
||||
|
||||
◆ VerticalContentAlignment, because that height is the whole point of this class and Avalonia's default
|
||||
for content alignment is Stretch — so the content presenter stretched the caption to the full row and a
|
||||
TextBlock draws its line at the TOP of what it is given. Most rows here never showed it, having a
|
||||
StackPanel or a Grid of already-centred children in them, which is what made the four that did look
|
||||
like four unrelated mistakes: the breadcrumb chips and the up-one-directory button on FilesScreen, and
|
||||
TerminalScreen's CLOSE THIS TAB, each a bare TextBlock in a row 36 or 44 tall with no vertical padding.
|
||||
Measured at those numbers, the caption sat flush against the top edge with 21 to 33 pixels below it.
|
||||
|
||||
The desktop head's App.axaml carries the same setter on its own shapes for the same reason, and excludes
|
||||
two of them — see the remark on Button.ghost there. Nothing is excluded here: no row's content depends
|
||||
on being stretched, there being no full-height strip inside any of the thirty-three, and the Grids that
|
||||
stop filling hold only children that already centre themselves, so they land where they always did.
|
||||
-->
|
||||
<Style Selector="Button.row">
|
||||
<Setter Property="MinHeight" Value="54" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
@@ -110,10 +167,124 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
</Style>
|
||||
<!--
|
||||
◆ THE FILL BELOW IS NOT DECORATION. IT IS UNDOING ONE FLUENT PUTS THERE.
|
||||
|
||||
`accent` is not a name this theme invented — Fluent ships a `Button.accent` style of its own, and a
|
||||
button classed `icon accent` matches it. So these three buttons were being painted with Fluent's
|
||||
system-accent slab, #6275AC, a blue that appears nowhere in Palette.axaml and reads as a control from
|
||||
a different application sitting in this one's header. Measured off a screenshot rather than guessed:
|
||||
the fill was #6275AC where the accent is #5D42DE.
|
||||
|
||||
The rule above could not stop it, because it sets Foreground and FontSize and Fluent's fill is on the
|
||||
ContentPresenter — which is also why `Button.icon`'s own Background="Transparent" does not reach it.
|
||||
App.axaml has carried the desktop's answer to exactly this since v5b: override the ContentPresenter,
|
||||
not the Button. This head simply never had one, so the desktop looked right and the phone did not.
|
||||
|
||||
Transparent rather than the desktop's gradient, and the difference is the control rather than an
|
||||
inconsistency. The desktop's Button.accent is a filled primary slab — this head has one of those too,
|
||||
and it is Button.primary. `icon accent` is a 44-pixel glyph in a header row beside other glyphs; what
|
||||
"accent" asks for there is the accent *colour on the mark*, which is what the rule above gives it. A
|
||||
slab would make one header icon a button and its neighbours not.
|
||||
|
||||
Pressed is deliberately not restated: Button.icon:pressed below sets ChromeHover on every icon button
|
||||
and, coming later in this file, wins over this rule for the moment a finger is down. That is the
|
||||
feedback the plain icon buttons give and there is no reason this one should differ.
|
||||
-->
|
||||
<Style Selector="Button.icon.accent /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon.accent:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.icon:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
============ one key of the terminal's accessory keypad ============
|
||||
|
||||
A class rather than eleven sets of attributes assigned in code, and the move out of the code-behind is
|
||||
most of what makes the latched state honest: Ctrl on and Ctrl off used to be two Background values
|
||||
written by hand in TerminalScreen.axaml.cs, which meant the row named two of this palette's brushes
|
||||
itself and any third state would have named a third. It is `latched` here, and the code toggles a class.
|
||||
|
||||
── the numbers, and why they are these ─────────────────────────────────────────────────────────────────
|
||||
40 tall, where the row this replaced was 30. Android asks for 48 on a touch target and every terminal on
|
||||
the platform lands near it; 30 was chosen to save a strip of the terminal above and took the keypad past
|
||||
the point of working, which is the wrong end of that trade. 40 with a 2-pixel margin gives 44 of pitch,
|
||||
which is the same figure TextBox.field uses for a box a thumb has to hit.
|
||||
|
||||
HorizontalAlignment Stretch, because these live in UniformGrid cells and the cell is the share of the
|
||||
width the key is entitled to. Left to Center a key would shrink to its label and "|" would be a sliver
|
||||
between two wide neighbours — which is the layout the flexed row exists to stop.
|
||||
|
||||
── pressed has to be visible, and it was not ───────────────────────────────────────────────────────────
|
||||
These keys are Focusable="False" so that using one does not take the keyboard away from the terminal
|
||||
(see TerminalScreen.axaml.cs), and a button that never takes focus also never shows the focus adorner
|
||||
Fluent would otherwise give it. That left a press with no feedback at all on a control whose entire job
|
||||
is to be pressed while somebody is looking at the line of output above it. The pressed fill below is the
|
||||
only thing that says a key was hit — and on a keypad over a remote shell, "did that send?" is a question
|
||||
worth answering in the interface rather than in the scrollback.
|
||||
-->
|
||||
<Style Selector="Button.hotkey">
|
||||
<Setter Property="Height" Value="40" />
|
||||
<Setter Property="MinHeight" Value="40" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="Margin" Value="2,0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
<!--
|
||||
Latched Ctrl. The same fill a press gives, held rather than flashed — a modifier that is on and does not
|
||||
look on is how somebody sends ^L to a database prompt believing they typed an l.
|
||||
-->
|
||||
<Style Selector="Button.hotkey.latched">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey.latched /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
</Style>
|
||||
<Style Selector="Button.hotkey:disabled">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The floating action button the design has asked for since v2, and which this theme refused twice — on
|
||||
the grounds that it would open an editor this head had not got, and that an unused style is a claim the
|
||||
@@ -126,7 +297,17 @@
|
||||
|
||||
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
|
||||
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
|
||||
the style being here is not permission to draw one there.
|
||||
@@ -135,7 +316,6 @@
|
||||
<Setter Property="Width" Value="56" />
|
||||
<Setter Property="Height" Value="56" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="28" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
@@ -145,8 +325,14 @@
|
||||
<Setter Property="FontSize" Value="24" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</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">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||
<Setter Property="Opacity" Value="0.82" />
|
||||
</Style>
|
||||
|
||||
@@ -161,7 +347,7 @@
|
||||
interface glitching rather than as a tap being received.
|
||||
-->
|
||||
<Style Selector="Button.scrim">
|
||||
<Setter Property="Background" Value="#9E0E1220" />
|
||||
<Setter Property="Background" Value="#9E05050A" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
@@ -169,10 +355,10 @@
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
<Style Selector="Button.scrim:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#9E0E1220" />
|
||||
<Setter Property="Background" Value="#9E05050A" />
|
||||
</Style>
|
||||
<Style Selector="Button.scrim:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#9E0E1220" />
|
||||
<Setter Property="Background" Value="#9E05050A" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
@@ -194,7 +380,7 @@
|
||||
-->
|
||||
<Style Selector="Border.output">
|
||||
<Setter Property="Background" Value="{StaticResource TerminalSurface}" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="12" />
|
||||
</Style>
|
||||
|
||||
@@ -205,7 +391,7 @@
|
||||
-->
|
||||
<Style Selector="Border.tag">
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="7,2" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
@@ -231,7 +417,7 @@
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="11,0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<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
|
||||
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.
|
||||
|
||||
◆ 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">
|
||||
<Setter Property="MinHeight" Value="34" />
|
||||
<Setter Property="Padding" Value="13,6" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
@@ -314,7 +506,59 @@
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<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="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
@@ -358,6 +602,31 @@
|
||||
<Setter Property="Fill" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Amber, and it does not contradict the remark above. That one says green is a fact about a host rather
|
||||
than an accent, and this is the colour for a fact that is not settled yet: green is what is true, purple
|
||||
is what you can press, and a connection still being made is neither. The palette's own rule gives amber
|
||||
to the caveat worth reading, which is exactly what this is.
|
||||
|
||||
Only the tab strips use it, and only for a tab with no shell behind it yet — the same amber, from the
|
||||
same brush, as the track and the running step on the connecting screen, so that a tab and the screen it
|
||||
opens agree about what is happening. See TerminalScreen.axaml.
|
||||
-->
|
||||
<Style Selector="Ellipse.dot.connecting">
|
||||
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Red for a shell whose connection broke, splitting the grey that used to cover every dead tab. A shell
|
||||
somebody exited and a shell that was cut off are the two dead states with the least in common, and on a
|
||||
phone the difference matters more than it does on a desk: a mobile link drops on its own, and the tab
|
||||
that went quiet while the screen was off is the one worth dialling again. See
|
||||
TerminalTabViewModel.IsDropped.
|
||||
-->
|
||||
<Style Selector="Ellipse.dot.dropped">
|
||||
<Setter Property="Fill" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
||||
<Style Selector="TextBlock.mono">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
@@ -370,10 +639,29 @@
|
||||
<Setter Property="FontSize" Value="9.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<!--
|
||||
◆ Wraps, like .body below it, and the omission was doing real damage rather than none.
|
||||
|
||||
A TextBlock that neither wraps nor trims does not shrink to fit and does not report that it did not:
|
||||
it lays out at its full single-line width and the parent clips whatever runs past the edge. So a
|
||||
.detail long enough to overflow ended mid-word against the screen, with no ellipsis to say anything
|
||||
was missing — "A machine to connect to, filed under a group if you" on the add sheet, which reads as
|
||||
a sentence somebody forgot to finish rather than as text that was cut.
|
||||
|
||||
It is a style rather than an attribute on the ones that overflow today, because which of the fifty-odd
|
||||
.detail blocks overflows is a function of the device's width and the user's font scale, not of the
|
||||
string: every one of them is a candidate at 320dp that is fine at 411dp, and fixing the handful that
|
||||
happen to be visibly wrong on a Pixel 5 would leave the rest to be found one narrow phone at a time.
|
||||
|
||||
The nine that genuinely want one line — list rows and log lines, where a wrapped detail would push the
|
||||
row's own height around — now say TextWrapping="NoWrap" beside their TextTrimming, which is the pairing
|
||||
that was always meant and was previously being had by accident.
|
||||
-->
|
||||
<Style Selector="TextBlock.detail">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="10" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.body">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
|
||||
@@ -48,7 +48,12 @@
|
||||
the refusal has no continue button here either.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
|
||||
<!--
|
||||
Nine rows since the disconnect strip arrived; it is row 7, between the listing and the action bar, so
|
||||
that what it says is next to the buttons it replaces the point of. Auto and empty except while the
|
||||
session has been lost, so an ordinary browse pays nothing for it.
|
||||
-->
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*,Auto,Auto">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
||||
@@ -78,7 +83,13 @@
|
||||
</Grid>
|
||||
|
||||
<!-- ============ opening one ============ -->
|
||||
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding !IsConnected}">
|
||||
<!--
|
||||
ShowsRemoteInvitation rather than !IsConnected, which stopped being the same question when a
|
||||
connection could be lost rather than only closed: a session that went away leaves its listing on
|
||||
screen, and this card springing back over it would hide the one thing the strip below is offering to
|
||||
put the user back into. See TransfersViewModel.ShowsRemoteContents.
|
||||
-->
|
||||
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding ShowsRemoteInvitation}">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
|
||||
@@ -174,11 +185,50 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ ◆ typing where to go ============ -->
|
||||
<!--
|
||||
The breadcrumb below reaches only what is under the directory already listed, so crossing a host's
|
||||
filesystem was a tap and a round trip a level and a path somebody already knew could not be entered at
|
||||
all. This is the way in for one that is: absolute, relative to the directory showing, or starting at
|
||||
~. See SftpPath.Resolve.
|
||||
|
||||
GO is a real button rather than only the keyboard's own action key, and that is a phone decision: a
|
||||
software keyboard's return key is whatever the IME decided to draw, this application does not set the
|
||||
IME action, and a path box whose only commit was a key that may say "newline" would be a box nothing
|
||||
could be submitted from. The Enter binding stays for the hardware keyboards this head does meet.
|
||||
-->
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,8,Auto" Margin="14,0,14,6"
|
||||
IsVisible="{Binding ShowsRemoteContents}">
|
||||
<TextBox Grid.Column="0" Classes="field" Text="{Binding RemotePathDraft}"
|
||||
PlaceholderText="/path/to/somewhere">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding GoToTypedRemotePathCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Padding="16,0" Content="GO"
|
||||
Command="{Binding GoToTypedRemotePathCommand}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ How many are gathered up, and the one gesture that gathers or releases them all. Only while
|
||||
something is — an always-present row would spend 30 pixels of a listing on a count of zero.
|
||||
-->
|
||||
<Grid Grid.Row="3" ColumnDefinitions="Auto,*" Margin="14,0,14,6" VerticalAlignment="Bottom"
|
||||
IsVisible="{Binding HasCheckedRemote}">
|
||||
<Button Grid.Column="0" Classes="secondary" MinHeight="36" Padding="12,0"
|
||||
Command="{Binding ToggleAllRemoteCommand}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="ALL / NONE" />
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="12" Margin="10,0,0,0"
|
||||
VerticalAlignment="Center" Foreground="{StaticResource AccentText}"
|
||||
Text="{Binding CheckedRemoteLabel}" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ where we are ============ -->
|
||||
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
|
||||
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding ShowsRemoteContents}" Margin="0,0,0,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
||||
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="9"
|
||||
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
|
||||
Command="{Binding RemoteUpCommand}">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
|
||||
</Button>
|
||||
@@ -188,7 +238,7 @@
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<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}"
|
||||
CommandParameter="{Binding Path}">
|
||||
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
|
||||
@@ -199,10 +249,50 @@
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ the listing ============ -->
|
||||
<Panel Grid.Row="4">
|
||||
<!-- ============ pins ============ -->
|
||||
<!--
|
||||
◆ The phone's answer to the desktop's QUICK ACCESS sidebar — the same PinnedPathList this host was
|
||||
connected with, drawn where this head actually browses files rather than beside a terminal it has no
|
||||
strip for. See TransfersViewModel.ConnectedPinnedPaths for why this is a snapshot taken at connect
|
||||
rather than a live follow of the host row: a pin added or removed mid-session shows up here on the
|
||||
next connect, not while this one is still open.
|
||||
|
||||
<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}" />
|
||||
|
||||
<!--
|
||||
@@ -219,7 +309,7 @@
|
||||
selection navigate would send a single desktop click walking into a directory.
|
||||
-->
|
||||
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
|
||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding IsConnected}"
|
||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding ShowsRemoteContents}"
|
||||
Tapped="OnRemoteEntryTapped">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
@@ -236,15 +326,27 @@
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Border MinHeight="52" Padding="10,7">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
|
||||
<!--
|
||||
◆ The tick, which on this head is the only multi-selection there can be: a phone has no
|
||||
ctrl-click and no shift-click. It is not the list's selection — that stays "the row I am
|
||||
looking at", which RENAME, MOVE and PERMS act on one at a time — and this is "the rows I
|
||||
have gathered up", which SAVE and DELETE take. See RemoteEntryRowViewModel.IsChecked.
|
||||
|
||||
Its own 40-pixel column so a thumb aimed at it does not open the directory beside it: the
|
||||
row's own tap navigates, which is this head's one behaviour the desktop does not share.
|
||||
-->
|
||||
<CheckBox Grid.Column="0" Width="40" MinWidth="0" VerticalAlignment="Center"
|
||||
IsChecked="{Binding IsChecked}" Click="OnRemoteRowChecked" />
|
||||
|
||||
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="▸"
|
||||
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
|
||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
||||
<TextBlock Grid.Column="1" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="·"
|
||||
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||
<StackPanel Grid.Column="2" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
@@ -255,7 +357,7 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Modified}"
|
||||
<TextBlock Grid.Column="3" Classes="detail" Text="{Binding Modified}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -264,8 +366,50 @@
|
||||
</ListBox>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ what the screen says once the session has stopped ============ -->
|
||||
<!--
|
||||
The phone's half of the desktop's SessionEndedBar, and it binds this screen's own view model rather
|
||||
than the shell's — this control's DataContext is TransfersViewModel, where the desktop's strip sits in
|
||||
MainWindow beside a status bar and can ask which surface it is on. The facts are the same two:
|
||||
TransfersViewModel.LostConnection and the reconnect that goes back to the directory showing.
|
||||
|
||||
A phone needs it more than a desk does, and for the reason its terminal does: a mobile link drops on
|
||||
its own and the screen goes off, so this session is discovered later with a listing on it and no
|
||||
account of why nothing works. The listing stays deliberately — see ShowsRemoteContents.
|
||||
-->
|
||||
<Border Grid.Row="7" IsVisible="{Binding IsConnectionLost}" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Danger}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||
<StackPanel Spacing="8">
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot" Classes.dropped="True" Width="7" Height="7" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="label" FontSize="9" Text="DISCONNECTED" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Two lines maximum, for the reason the terminal's strip caps its own: a transport error can hand
|
||||
over whatever the operating system called it, and a strip that grew to fit one would eat the
|
||||
listing above it on a screen that has none to spare.
|
||||
-->
|
||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding LostConnection}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding ReconnectCommand}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
|
||||
</Button>
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0" Command="{Binding DisconnectCommand}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ what to do with the chosen entry ============ -->
|
||||
<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">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
@@ -287,6 +431,105 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ Renaming and moving, in the place the deletion question already occupies and never at the same
|
||||
time as it. One form for both — see RemoteMoveRequest for why the two questions share a shape and
|
||||
differ only in their words — and no danger colours on it, because a rename is undone by renaming
|
||||
back and the deletion above is the one thing here that nothing can walk back.
|
||||
-->
|
||||
<Border IsVisible="{Binding IsMovingRemote}" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||
Padding="13,11">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteMove.Question}" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" Text="{Binding PendingRemoteMove.FullPath}" />
|
||||
<TextBox Classes="field" Text="{Binding RemoteMoveTarget}"
|
||||
PlaceholderText="{Binding PendingRemoteMove.FieldHint}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding ConfirmMoveRemoteCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBlock Classes="body" Text="{Binding PendingRemoteMove.Note}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44"
|
||||
Content="{Binding PendingRemoteMove.ConfirmLabel}"
|
||||
Command="{Binding ConfirmMoveRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelMoveRemoteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ The mode, as nine boxes and the number they add up to — in the place the other two questions
|
||||
already occupy and never at the same time as either. See RemotePermissionsRequest for why it is
|
||||
boxes rather than a field to type 755 into, and TransfersScreen.axaml for the desktop's own copy
|
||||
of the same form.
|
||||
|
||||
Compact rather than a second layout: the three columns are the mode's own three triples, and at
|
||||
360dp they are exactly what fits. The octal is printed underneath as it changes, which is what
|
||||
lets somebody who came here to type chmod check the boxes they just ticked.
|
||||
-->
|
||||
<Border IsVisible="{Binding IsChangingRemotePermissions}" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||
Padding="13,11">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemotePermissions.Question}" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemotePermissions.FullPath}" />
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,*,*" RowDefinitions="Auto,44,44,44" Margin="0,2">
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Classes="detail" Text="read"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" Classes="detail" Text="write"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="3" Classes="detail"
|
||||
Text="{Binding PendingRemotePermissions.ExecuteLabel}"
|
||||
HorizontalAlignment="Center" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="mono" FontSize="12" Text="owner"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OwnerRead}" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OwnerWrite}" />
|
||||
<CheckBox Grid.Row="1" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OwnerExecute}" />
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Classes="mono" FontSize="12" Text="group"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<CheckBox Grid.Row="2" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.GroupRead}" />
|
||||
<CheckBox Grid.Row="2" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.GroupWrite}" />
|
||||
<CheckBox Grid.Row="2" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.GroupExecute}" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Classes="mono" FontSize="12" Text="others"
|
||||
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<CheckBox Grid.Row="3" Grid.Column="1" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OthersRead}" />
|
||||
<CheckBox Grid.Row="3" Grid.Column="2" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OthersWrite}" />
|
||||
<CheckBox Grid.Row="3" Grid.Column="3" HorizontalAlignment="Center" MinWidth="0"
|
||||
IsChecked="{Binding PendingRemotePermissions.OthersExecute}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource AccentText}"
|
||||
Text="{Binding PendingRemotePermissions.Preview}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="APPLY"
|
||||
Command="{Binding ConfirmRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding PendingRemotePermissions.HasChanges}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelRemotePermissionsCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
|
||||
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
|
||||
@@ -308,7 +551,7 @@
|
||||
Foreground="{StaticResource Accent}"
|
||||
Background="{StaticResource Raised}" />
|
||||
<TextBlock Classes="detail" Text="{Binding Progress}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -345,20 +588,44 @@
|
||||
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
||||
a file is selected and something is connected — and is reused rather than restated here.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
||||
IsEnabled="{Binding CanDownload}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
<!--
|
||||
◆ The two that put a file right rather than taking it away, above the row DELETE is on and away
|
||||
from it. Both open a form in place of these buttons — see the card above — because a phone has
|
||||
nowhere to put a text field except where something else already was.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="RENAME"
|
||||
Command="{Binding RenameRemoteCommand}" IsEnabled="{Binding CanRenameRemote}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
|
||||
Command="{Binding MoveRemoteCommand}" IsEnabled="{Binding CanMoveRemote}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
||||
<!--
|
||||
◆ PERMS beside DELETE rather than on the row above, which is full: it is the fourth thing that can
|
||||
be done to a chosen row and the third that puts one right. Dark on a bucket, where an object has an
|
||||
access policy rather than nine bits — see CanChangeRemotePermissions.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="PERMS"
|
||||
Command="{Binding ChangeRemotePermissionsCommand}"
|
||||
IsEnabled="{Binding CanChangeRemotePermissions}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsAskingAboutRemoteEntry}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="CLOSE"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
<Panel Grid.Column="2" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsAskingAboutRemoteEntry}"
|
||||
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
||||
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||
|
||||
@@ -35,6 +35,15 @@ internal sealed partial class FilesScreen : UserControl
|
||||
/// read its size the same gesture as fetching it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>Tells the screen a row's tick moved, so the count above the listing keeps up.</summary>
|
||||
/// <remarks>
|
||||
/// The desktop's own handler, for the reason given there: the count is a property of the collection
|
||||
/// rather than of any row, and each listing replaces every row it has. See
|
||||
/// <c>TransfersViewModel.RemoteChecksChanged</c>.
|
||||
/// </remarks>
|
||||
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) =>
|
||||
(DataContext as TransfersViewModel)?.RemoteChecksChanged();
|
||||
|
||||
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
◆ 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>
|
||||
@@ -29,7 +36,7 @@
|
||||
written out because the palette holds no alpha variant of a surface — see QuickConnect on the
|
||||
desktop, which carries the same note.
|
||||
-->
|
||||
<Border Background="#9E0E1220" />
|
||||
<Border Background="#9E05050A" />
|
||||
|
||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
@@ -42,12 +49,29 @@
|
||||
|
||||
<TextBlock Classes="title" Text="UNKNOWN HOST KEY" FontSize="13" />
|
||||
|
||||
<!--
|
||||
◆ The port and the full stop are one Run, and that is the whole of the fix.
|
||||
|
||||
Avalonia puts a space between adjacent Runs, which is what makes "First contact with", the
|
||||
host and the separator read as a sentence without any of them carrying padding. The trailing
|
||||
Run began with a full stop, so it got that space too and the sheet said
|
||||
|
||||
First contact with 10.0.2.2 · 2222 . Nothing in this keychain…
|
||||
|
||||
— a gap before the stop, on the one dialogue in this application whose entire job is to be
|
||||
read carefully before somebody answers it. StringFormat welds the stop to the number inside a
|
||||
single Run, where no inter-Run space can get in, and the sentence that follows starts a Run of
|
||||
its own so it still gets the space it wants.
|
||||
|
||||
The `{}` prefix is required: a StringFormat beginning with a brace would otherwise be parsed
|
||||
as a markup extension.
|
||||
-->
|
||||
<TextBlock Classes="body" Margin="0,6,0,0">
|
||||
<Run Text="First contact with" />
|
||||
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
||||
<Run Text="·" />
|
||||
<Run Text="{Binding PendingHostKey.Port}" />
|
||||
<Run Text=". Nothing in this keychain has approved this key." />
|
||||
<Run Text="{Binding PendingHostKey.Port, StringFormat='{}{0}.'}" />
|
||||
<Run Text="Nothing in this keychain has approved this key." />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Classes="label" Margin="0,14,0,5" Foreground="{StaticResource TextFaint}"
|
||||
@@ -107,8 +131,15 @@
|
||||
<!--
|
||||
The only control on the screen, and it goes back rather than forward. Compare the sheet above,
|
||||
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}" />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,23 +1,26 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
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>
|
||||
/// <para>
|
||||
/// ◆ <b>Two gestures on one list, and this file is the whole of the difference between them.</b> A tap on a
|
||||
/// host connects to it; a long press asks about it, which is what raises the bar. Why they were split is on
|
||||
/// the screen itself; what is here is the mechanics, and there are two of them worth knowing.
|
||||
/// ◆ <b>A tap connects, a long press chooses, and once anything is chosen a tap ticks instead.</b> The
|
||||
/// branch in the middle of that is why the tap is handled here rather than bound in the markup: which of
|
||||
/// the two a tap means depends on whether the screen is in selection mode, and Avalonia's bindings cannot
|
||||
/// ask.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain <c>Grid</c>.
|
||||
/// A button as the item template swallows the press before the list sees it — <c>FilesScreen</c> writes that
|
||||
/// out at length — leaving nothing selected and every control that reads the selection doing nothing. Both
|
||||
/// events fire after the list has moved its selection, which is what lets these read it.
|
||||
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain
|
||||
/// <c>Border</c>. A button as the item template swallows the press before the list sees it —
|
||||
/// <c>FilesScreen</c> writes that out at length — so neither handler would ever run.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class HostsScreen : UserControl
|
||||
@@ -27,18 +30,18 @@ internal sealed partial class HostsScreen : UserControl
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// is the one outcome that would make both of them untrustworthy. Set when the hold starts and cleared
|
||||
/// by the tap it suppresses, so it never survives the gesture that set it.
|
||||
/// would tick the row and then the tap it becomes would untick it again — one touch doing a thing and
|
||||
/// undoing it, which is the one outcome that would make both gestures untrustworthy. Set when the hold
|
||||
/// starts and cleared by the tap it suppresses, so it never survives the gesture that set it.
|
||||
/// </remarks>
|
||||
private bool held;
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The long press is attached here rather than in the markup so that it sits beside the property that
|
||||
/// makes it fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than assumed:
|
||||
/// it is the whole of the gesture, and a default that changed would take it away silently — every tap
|
||||
/// would go on working and nothing would ever open the bar again.
|
||||
/// Both gestures are attached here rather than in the markup so that they sit beside the property that
|
||||
/// makes one of them fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than
|
||||
/// assumed: it is the whole of the long press, and a default that changed would take it away silently —
|
||||
/// every tap would go on working and nothing would ever choose a host again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <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>
|
||||
/// Connects to the row that was tapped.
|
||||
/// Connects to the row that was tapped, or ticks it where hosts are already ticked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Guarded on the selection being a host rather than on what was under the finger. A tap on a group
|
||||
/// heading moves the list's selection and the view model bounces it straight back to whatever was chosen
|
||||
/// before — see <c>VaultViewModel.SelectedSidebarRow</c> — so reading the selection here answers "a host,
|
||||
/// or nothing" without this file needing to know that rule. The cost of getting it wrong is connecting to
|
||||
/// a machine the user was not pointing at.
|
||||
/// ◆ <b>The branch is the selection, not the row.</b> Once anything is chosen the screen is in selection
|
||||
/// mode — the bar across the top says so — and in that mode a tap adds and removes rather than
|
||||
/// connecting. That is what every Android list does, and the alternative is worse than a rule to
|
||||
/// remember: a tap that connected while five machines sat ticked would open a terminal on top of a
|
||||
/// selection somebody was halfway through building.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fire-and-forget, as the desktop grid's activation is: the command reports its own failures onto the
|
||||
/// status line — an unknown host key, a refused password — and awaiting it here would be an event handler
|
||||
/// returning a task nothing observes.
|
||||
/// Fire-and-forget where it connects, as the desktop grid's activation is: the command reports its own
|
||||
/// failures onto the status line — an unknown host key, a refused password — and awaiting it here would
|
||||
/// be an event handler returning a task nothing observes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnRowTapped(object? sender, TappedEventArgs e)
|
||||
@@ -86,25 +89,25 @@ internal sealed partial class HostsScreen : UserControl
|
||||
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>
|
||||
/// Raises the bar about the row that was held.
|
||||
/// </summary>
|
||||
/// <summary>Puts a tick against the row that was held, entering selection mode with it.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// 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>
|
||||
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
|
||||
{
|
||||
@@ -115,9 +118,33 @@ internal sealed partial class HostsScreen : UserControl
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
</Border>
|
||||
|
||||
@@ -180,6 +191,47 @@
|
||||
</StackPanel>
|
||||
</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 ============ -->
|
||||
<Panel Grid.Row="4">
|
||||
|
||||
@@ -235,7 +287,7 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!-- What is known *about* an item, never the secret. -->
|
||||
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" Text="{Binding Detail}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||||
|
||||
@@ -91,8 +91,8 @@
|
||||
|
||||
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
|
||||
one. What it does take is the radius the v2 ladder gives anything card-sized, which is what the
|
||||
other two already draw.
|
||||
one. What it does take is the radius the ladder gives anything card-sized — 12, unchanged from v2
|
||||
to v5 — which is what the other two already draw.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasLiveSessions}" Margin="0,22,0,0"
|
||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||
|
||||
@@ -106,11 +106,11 @@
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
||||
Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
|
||||
Text="{Binding Address}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!--
|
||||
TextDim rather than the `detail` class's own faint step. This block is drawn on
|
||||
@@ -124,7 +124,7 @@
|
||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
Text="·" />
|
||||
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
Text="{Binding DeviceName}" TextTrimming="CharacterEllipsis" />
|
||||
Text="{Binding DeviceName}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- A refused host key is the one outcome worth a colour of its own. -->
|
||||
@@ -158,7 +158,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
|
||||
Text="{Binding ItemLabel}" TextTrimming="CharacterEllipsis" />
|
||||
Text="{Binding ItemLabel}" TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!-- The names of the fields that changed, never their values. -->
|
||||
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasChangedFields}"
|
||||
|
||||
@@ -36,6 +36,18 @@
|
||||
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 —
|
||||
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>
|
||||
@@ -54,7 +66,7 @@
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.railentry:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.railentry.active /template/ ContentPresenter">
|
||||
@@ -96,7 +108,7 @@
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Border Width="188" Background="{StaticResource Sidebar}"
|
||||
<Border Width="188" Background="{StaticResource DeepChrome}"
|
||||
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
|
||||
reason the bottom bar's middle entry does. It is never lit, and deliberately: this rail is
|
||||
collapsed while a shell is showing, so IsCurrent could only ever read false. What marks that
|
||||
destination as current is the surface filling the screen.
|
||||
reason the bottom bar's middle entry does. It lights on IsTerminalSurface, which on this control
|
||||
can only mean one thing: the rail stands down for a shell, so the one moment it is drawn beside
|
||||
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
|
||||
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.
|
||||
-->
|
||||
<Button Classes="railentry" Command="{Binding ShowTerminalCommand}">
|
||||
<Button Classes="railentry" Classes.active="{Binding IsTerminalSurface}"
|
||||
Command="{Binding ShowTerminalCommand}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
|
||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
|
||||
|
||||
@@ -25,8 +25,9 @@
|
||||
|
||||
── 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
|
||||
bottom bar itself. All three are bound on IsShowingPages, which is the same question asked once — the
|
||||
surface is either a page or a terminal, and these are the chrome a page has.
|
||||
bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
|
||||
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
|
||||
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.
|
||||
-->
|
||||
<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">
|
||||
<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
|
||||
@@ -127,30 +137,18 @@
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
◆ THE PENCIL, and it is here rather than on the row for the reason the whole gesture split
|
||||
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.
|
||||
◆ THE PENCIL WAS HERE, AND IT MOVED INTO THE ACTION BAR.
|
||||
|
||||
Two conditions, nested rather than combined, because Avalonia's bindings have no "and" and the
|
||||
two belong to different view models: which screen is showing is the shell's question, and
|
||||
whether there is a host to edit is the vault's. That is the same arrangement the header itself
|
||||
is wrapped in one level up.
|
||||
It was in the header because a long press raised a connect card carrying EDIT, and a card over
|
||||
the bottom of the list was the wrong place for the one action people reach for most. Both
|
||||
halves of that changed at once: a long press now chooses hosts rather than raising a card, and
|
||||
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
|
||||
vault's name on every screen would be a permanent reminder of a control that is only ever about
|
||||
one row.
|
||||
What is left in this row is the vault's name, the sync light and LOCK: three facts about the
|
||||
keychain, and none of them about a row.
|
||||
-->
|
||||
<Panel Grid.Column="3" IsVisible="{Binding IsHostsShowing}">
|
||||
<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}"
|
||||
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
||||
ToolTip.Tip="Lock the keychain">
|
||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
@@ -159,6 +157,21 @@
|
||||
</Border>
|
||||
</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. -->
|
||||
<Panel Grid.Row="1">
|
||||
<!--
|
||||
@@ -252,6 +265,12 @@
|
||||
◆ 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
|
||||
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">
|
||||
<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
|
||||
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.
|
||||
|
||||
◆ 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}">
|
||||
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
|
||||
<Panel Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsShellStrip}">
|
||||
<!-- 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">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
||||
@@ -282,7 +309,7 @@
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<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}"
|
||||
BorderThickness="1"
|
||||
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 —
|
||||
connecting opens the tab first — and a dot that was green before anything had
|
||||
answered would be the one thing on this strip claiming something untrue.
|
||||
|
||||
Amber while it is being made, which is the other half of that correction. Not being
|
||||
green stopped the dot lying, but it left a tab still dialling drawn exactly like a
|
||||
tab whose shell has exited — the two states on this strip with the least in common,
|
||||
one worth waiting for and one over. See Phone.axaml.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
@@ -308,10 +341,15 @@
|
||||
|
||||
<!-- ============ 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
|
||||
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
|
||||
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
|
||||
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}"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Height="64">
|
||||
<Grid ColumnDefinitions="*,*,*">
|
||||
|
||||
@@ -340,12 +380,15 @@
|
||||
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.
|
||||
|
||||
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed
|
||||
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
|
||||
would be a rule about a state this control cannot be in. What marks it as current is the surface
|
||||
filling the screen.
|
||||
◆ It lights again, and IsTerminalSurface is the right question rather than a near miss. This bar
|
||||
is drawn in exactly two situations — a page, and Connections with nothing running — and that flag
|
||||
separates them: false for every page, true for the one screen where this is where you are. With a
|
||||
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="⌗"
|
||||
IsCurrent="{Binding IsTerminalSurface}"
|
||||
Command="{Binding ShowTerminalCommand}" />
|
||||
|
||||
<!--
|
||||
|
||||
@@ -17,6 +17,18 @@ internal sealed partial class PhoneShell : UserControl
|
||||
{
|
||||
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>
|
||||
/// Everything the phone draws, which is the element the software keyboard is kept off.
|
||||
/// </summary>
|
||||
@@ -90,10 +102,40 @@ internal sealed partial class PhoneShell : UserControl
|
||||
TryOfferDeviceUnlock();
|
||||
}
|
||||
|
||||
FollowTheVault();
|
||||
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>
|
||||
/// <remarks>
|
||||
/// 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));
|
||||
|
||||
/// <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 =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
|
||||
|
||||
/// <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 =
|
||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
|
||||
|
||||
@@ -115,6 +166,14 @@ internal sealed partial class PhoneShell : UserControl
|
||||
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
|
||||
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" />
|
||||
public bool IsWide
|
||||
{
|
||||
@@ -143,17 +202,40 @@ internal sealed partial class PhoneShell : UserControl
|
||||
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>
|
||||
/// Works out which chrome this surface should be wearing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <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
|
||||
/// 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.
|
||||
/// </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
|
||||
/// 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
|
||||
@@ -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.
|
||||
/// </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
|
||||
/// ones, and which of them raise a change is a fact about a file in another project that nothing here
|
||||
/// would notice going stale.
|
||||
@@ -172,6 +263,24 @@ internal sealed partial class PhoneShell : UserControl
|
||||
var wide = body.Bounds.Width >= WideAt;
|
||||
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
|
||||
// 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
|
||||
@@ -183,9 +292,11 @@ internal sealed partial class PhoneShell : UserControl
|
||||
}
|
||||
|
||||
IsWide = wide;
|
||||
ShowsRail = wide && pages;
|
||||
ShowsBottomBar = !wide && pages;
|
||||
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
|
||||
ShowsRail = wide && (pages || connectPage) && !editing;
|
||||
ShowsBottomBar = !wide && (pages || connectPage) && !editing;
|
||||
ShowsShellStrip = pages && !editing;
|
||||
ShowsHostSelectionBar = pages && choosing && !editing;
|
||||
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
|
||||
}
|
||||
|
||||
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
@@ -195,6 +306,7 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
FollowTheVault();
|
||||
RefreshChrome();
|
||||
|
||||
if (e.PropertyName is nameof(MainWindowViewModel.State))
|
||||
@@ -279,11 +391,15 @@ internal sealed partial class PhoneShell : UserControl
|
||||
ApplyKeyboardInset(keyboard);
|
||||
}
|
||||
}
|
||||
|
||||
PhoneEnvironment.Resumed += OnActivityResumed;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
PhoneEnvironment.Resumed -= OnActivityResumed;
|
||||
|
||||
if (TopLevel.GetTopLevel(this) is { } top)
|
||||
{
|
||||
top.BackRequested -= OnBackRequested;
|
||||
@@ -298,6 +414,43 @@ internal sealed partial class PhoneShell : UserControl
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the keyboard inset on the way back into the application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The margin can be stale here and there is no other moment it gets corrected.</b> Android takes
|
||||
/// the software keyboard away when this activity stops, and Avalonia's input pane raises no
|
||||
/// <c>StateChanged</c> for it — the pane is gone with the window rather than closed in front of one. So
|
||||
/// <see cref="ApplyKeyboardInset(double)"/> last ran for a keyboard that was up, and the interface comes
|
||||
/// back holding a whole keyboard's worth of empty space it no longer needs. It is not subtle and it does
|
||||
/// not recover: a third of the display, on every screen, until something opens and closes the keyboard.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asking the pane rather than assuming zero, because resuming does not always mean the keyboard went.
|
||||
/// A permission dialogue, the biometric sheet and the sign-in browser all pause this activity and can
|
||||
/// all hand it back with the keyboard exactly as it was — and a handler that flattened the inset on the
|
||||
/// way in would then strand the focused box under a keyboard that is genuinely there. The pane knows
|
||||
/// which of the two happened; this only has to ask it again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Posted rather than run inline. <c>OnResume</c> is early — the window has not necessarily been laid
|
||||
/// out again and the pane's own state is settled by the insets that arrive with the next frame, so
|
||||
/// reading it on this turn can return the state that is on the way out. Background priority puts the
|
||||
/// read after that.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnActivityResumed(object? sender, EventArgs e) =>
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (keyboard is not null)
|
||||
{
|
||||
ApplyKeyboardInset(keyboard);
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Background);
|
||||
|
||||
private void OnKeyboardChanged(object? sender, InputPaneStateEventArgs e)
|
||||
=> ApplyKeyboardInset(e.NewState is InputPaneState.Open ? e.EndRect.Height : 0);
|
||||
|
||||
@@ -500,16 +653,23 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Order is the whole of it. The two sheets sit over the list and the two editors sit in place of it, so
|
||||
/// a sheet has to go first — closing an editor while a sheet was open would leave the sheet floating
|
||||
/// over a list nobody asked to see, and the second back would then close the sheet rather than the
|
||||
/// editor the user was looking at.
|
||||
/// Order is the whole of it. The sheets sit over the list, the panels sit above it and the editors sit
|
||||
/// in place of it, so a sheet has to go first — closing an editor while a sheet was open would leave the
|
||||
/// sheet floating over a list nobody asked to see, and the second back would then close the sheet rather
|
||||
/// than the editor the user was looking at.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// next NEW HOST open on somebody else's abandoned draft.
|
||||
/// </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>
|
||||
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
|
||||
{
|
||||
@@ -518,22 +678,71 @@ internal sealed partial class PhoneShell : UserControl
|
||||
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)
|
||||
{
|
||||
vault.CloseAddSheetCommand.Execute(null);
|
||||
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)
|
||||
{
|
||||
vault.CloseGroupSheetCommand.Execute(null);
|
||||
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)
|
||||
{
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
@@ -546,6 +755,12 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
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." />
|
||||
<TextBlock Classes="body"
|
||||
Text="Importing an ~/.ssh/config has no meaning on Android: scoped storage means there is no such file for this application to read." />
|
||||
<TextBlock Classes="body"
|
||||
Text="Importing a Hetzner Cloud project would work here — it is a token and a network request, not a file — and the wizard behind it is already shared. What is missing is a phone layout for it: the desktop draws a five-column table, and the screen it would become is not the desktop's with narrower columns. Until then it is a thing to do on a desktop; the hosts it makes sync here like any other." />
|
||||
<TextBlock Classes="body"
|
||||
Text="Signing out is on the lock screen, where somebody who has forgotten their passphrase can reach it without unlocking anything first." />
|
||||
</StackPanel>
|
||||
|
||||
@@ -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
|
||||
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}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14,13">
|
||||
BorderThickness="1" CornerRadius="12" Padding="14,13">
|
||||
<SelectableTextBlock Text="{Binding RecoveryCode}"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||
Foreground="{StaticResource Accent}"
|
||||
|
||||
@@ -20,9 +20,17 @@
|
||||
<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
|
||||
launcher icon carry. The radius is not picked: the mark runs 6 at 20 and 8 at 26, which is a
|
||||
third of a unit per unit of tile and lands on 14 at 44 — and 14 is a rung of Phone.axaml's
|
||||
ladder, the one for a block of monospaced output, which is what this is.
|
||||
launcher icon carry. The radius is not picked: the mark runs 6 at 20 and 8 at 26, a third of a
|
||||
unit per unit of tile, and lands on 14 at 44.
|
||||
|
||||
◆ 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}"
|
||||
HorizontalAlignment="Center">
|
||||
|
||||
@@ -64,6 +64,26 @@
|
||||
|
||||
<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"
|
||||
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
VerticalContentAlignment="Top" Padding="14,10" />
|
||||
@@ -125,10 +145,23 @@
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" />
|
||||
</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>
|
||||
|
||||
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
|
||||
<Border Classes="output" CornerRadius="9" Padding="11,9">
|
||||
<!-- The command, on the surface every block of monospace in this design is drawn on.
|
||||
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}"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
@@ -145,26 +178,76 @@
|
||||
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
|
||||
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"
|
||||
IsEnabled="{Binding !IsEditing}">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
|
||||
IsEnabled="{Binding CanInsert}" />
|
||||
<StackPanel Spacing="9" IsVisible="{Binding ShowsSelectionActions}">
|
||||
<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. -->
|
||||
<Button Classes="secondary" Height="44" Content="{Binding RunLabel}"
|
||||
Command="{Binding RunCommand}" IsVisible="{Binding SelectionRuns}"
|
||||
IsEnabled="{Binding CanInsert}" />
|
||||
<!-- 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}"
|
||||
Command="{Binding RunCommand}" IsVisible="{Binding SelectionRuns}"
|
||||
IsEnabled="{Binding CanInsert}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
||||
Command="{Binding EditCommand}" />
|
||||
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteCommand}" />
|
||||
</Grid>
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
||||
Command="{Binding EditCommand}" />
|
||||
<!--
|
||||
◆ Sharing the snippet. Beside EDIT rather than inside it, which is the line the hosts screen
|
||||
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>
|
||||
</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 ───────────────────────────────────────────────────────────────────────────
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
@@ -29,9 +33,94 @@
|
||||
gesture does the same thing the arrow does.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
|
||||
<!--
|
||||
── the connecting step list ─────────────────────────────────────────────────────────────────────
|
||||
The same five rows the desktop's ConnectingCard draws, from the same reported phases, in this head's
|
||||
own sizes. Kept here rather than in Phone.axaml because nothing else on this head has a step list —
|
||||
the theme file is for what more than one screen shares, and a rule that exists for one control is
|
||||
easier to read beside it.
|
||||
|
||||
Amber for the step in flight, green behind it, red where it stopped. That is the palette's rule
|
||||
rather than an exception to it: green is what is true and purple is what you can press, and a step
|
||||
still happening is neither. See ConnectingCard.axaml for the longer version of this argument, and
|
||||
Palette.axaml for the rule itself.
|
||||
|
||||
A phone needs this more than a desktop does, which is the same thing the connecting block below
|
||||
already says about itself: mobile links are slower and drop more often, so the stretch this describes
|
||||
is longer here and more likely to end badly.
|
||||
-->
|
||||
<!--
|
||||
Its own FontFamily rather than the row also carrying the mono class, which is this head's convention
|
||||
and not a stylistic preference: Phone.axaml's mono sets a colour and a size along with the family, so
|
||||
a caption wearing both classes would be asking two rules for one Foreground and settling it on style
|
||||
ordering. Every other text class here — body, label, title, detail — names its own family for exactly
|
||||
that reason. The desktop's mono sets the family alone, which is why ConnectingCard composes the two
|
||||
and this does not.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepcaption">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||
</Style>
|
||||
|
||||
<!-- Fixed width and centred: four different characters on a ragged edge is a list that looks broken. -->
|
||||
<Style Selector="TextBlock.stepmark">
|
||||
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Width" Value="13" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
4 rather than the desktop's 5, which is the only deliberate difference between the two heads here:
|
||||
this bar sits in a column 24 from each edge of a 360dp screen rather than under a 460-wide card, so
|
||||
the same height reads as a heavier rule across a narrower span.
|
||||
-->
|
||||
<Style Selector="ProgressBar.steptrack">
|
||||
<Setter Property="Height" Value="4" />
|
||||
<Setter Property="MinHeight" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="2" />
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="ProgressBar.steptrack.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
</UserControl.Styles>
|
||||
|
||||
<Panel>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!--
|
||||
Four rows since the disconnect strip arrived. It is Auto and empty except while the selected tab's shell
|
||||
has stopped, so an ordinary session pays nothing for it — and when it is up it takes its height off the
|
||||
renderer rather than drawing over it, which on this head is not optional either: the renderer is a native
|
||||
child view composited above everything Avalonia draws, so a banner over its rectangle would be invisible.
|
||||
-->
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!-- ============ the bar ============ -->
|
||||
<!--
|
||||
@@ -45,7 +134,10 @@
|
||||
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.
|
||||
-->
|
||||
<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">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
@@ -83,15 +175,21 @@
|
||||
row where nothing sits above or below it to be hit by mistake.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="9" Height="30">
|
||||
BorderThickness="1" CornerRadius="10" Height="30">
|
||||
<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"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
<!--
|
||||
Green only while there is a shell behind it, amber while one is being made, and
|
||||
red where a connection broke rather than a shell exiting; see the same dot in
|
||||
PhoneShell, and Phone.axaml for why neither of the last two is a rule broken.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}"
|
||||
Classes.dropped="{Binding IsDropped}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||
Text="{Binding Label}" />
|
||||
@@ -112,7 +210,7 @@
|
||||
It reads as a misprint, which for the control that ends a session is the wrong thing
|
||||
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"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
@@ -243,7 +341,7 @@
|
||||
<TextBlock Classes="mono" FontSize="13" FontWeight="SemiBold"
|
||||
Text="{Binding HostLabel}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="detail" FontSize="10"
|
||||
VerticalAlignment="Center" Text="{Binding Started}" />
|
||||
@@ -277,11 +375,82 @@
|
||||
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
||||
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Where a single unchanging "connecting…" used to be. The track counts steps that really finished
|
||||
against the five there are — StepsDone over StepCount, never a percentage, because the arithmetic
|
||||
that makes a percentage is the arithmetic that starts inventing one. See TerminalTabViewModel.
|
||||
|
||||
Drawn for both states rather than once per state: a refused connection has the same five rows and
|
||||
the same track, and the only differences are that one row is red and the track stops where it got
|
||||
to. Two templates kept identical for the sake of a colour is how the two drift apart.
|
||||
-->
|
||||
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="6" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9">
|
||||
<TextBlock Classes="stepmark"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Mark}" />
|
||||
<TextBlock Classes="stepcaption"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Caption}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
Only for a refusal now. While a connection is being made this used to be the whole of what this
|
||||
screen said, and it is now the step list's running row said twice — so it is shown for the one
|
||||
state the list cannot put into words: why it stopped.
|
||||
-->
|
||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}"
|
||||
Foreground="{StaticResource Danger}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
|
||||
<!--
|
||||
Two 44-high targets side by side rather than one, and the second is the logs: the step list is
|
||||
this attempt and the log is every other one, which is the question a connection that is taking too
|
||||
long on a mobile link actually raises — has this machine ever worked from here. Reached the
|
||||
ordinary way, through ShowScreenCommand, exactly as the rail and MORE reach it.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Left">
|
||||
<!--
|
||||
Only for a refusal, and it runs the same command the disconnect strip below the renderer does —
|
||||
the vault's own connect path, back into this same tab. Worth more on a phone than on a desk: the
|
||||
commonest refusal here is a mobile link that was not up yet, and the alternative is going back
|
||||
to the hosts screen to find a machine whose name is already on this card.
|
||||
-->
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="TRY AGAIN" />
|
||||
</Button>
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||
</Button>
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="SHOW LOGS" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -302,6 +471,29 @@
|
||||
design's #171A26. See WebAssets/terminal.css.
|
||||
-->
|
||||
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
<!--
|
||||
============ the keyboard ============
|
||||
|
||||
One pixel in the corner, drawn never, and it is where every character typed into a shell on this
|
||||
phone arrives. ADR 0015 has the whole argument; the short of it is that a software keyboard over a
|
||||
terminal has to be told to stop autocorrecting, and on Android the only setting that works is an
|
||||
inputType on a native input connection — which a page inside a WebView cannot reach and an Avalonia
|
||||
control can, on the version already pinned here.
|
||||
|
||||
Not stretched over the renderer, which would be the obvious shape and is wrong three times: the
|
||||
WebView composites above everything Avalonia paints so it would be invisible, it would never see the
|
||||
touches the WebView consumes first, and it would sit over the connect page that shares this Panel
|
||||
whenever no session is open. Focus does not need area. Taps on the terminal are noticed by
|
||||
RendererFocus, where they actually land.
|
||||
|
||||
Collapsed with the renderer, on the same binding and for a reason of its own: a focusable control is
|
||||
a tab stop and a focus candidate wherever it lives, and this Panel is also the connect page when no
|
||||
session is open. Left always-visible it would be a stray target on a screen with a real text box on
|
||||
it, offering a keyboard configured for a shell to somebody typing a hostname.
|
||||
-->
|
||||
<views:TerminalInputSurface x:Name="Input"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ accessory keys ============ -->
|
||||
@@ -313,70 +505,164 @@
|
||||
-->
|
||||
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
|
||||
<!--
|
||||
33 tall rather than 50, a third off it like the bar at the top, with the keys inside coming down from
|
||||
38 to 30 for the reason the pills did: a row that shrank around its own contents would clip them.
|
||||
◆ TWO FIXED ROWS THAT FILL THE WIDTH, AND NOTHING HERE SCROLLS. THAT IS THE WHOLE CHANGE.
|
||||
|
||||
The five pixels above it are a gap and not a border. The renderer is a native child view, so nothing
|
||||
Avalonia draws can sit on top of it — a hairline between the two would have to be a row of its own —
|
||||
and a terminal whose last line of output is flush against a row of grey keys reads as one surface
|
||||
that has gone wrong rather than as two that are different things.
|
||||
What this replaced was one 33-pixel row holding eleven 30-by-44 keys inside a horizontal ScrollViewer,
|
||||
and it was wrong in two ways that compounded each other.
|
||||
|
||||
── the scroller was eating the presses ──────────────────────────────────────────────────────────────
|
||||
A horizontally scrollable strip and a row of buttons want opposite things from the same gesture. Every
|
||||
tap starts as a press and a few pixels of travel, and a ScrollViewer that can pan claims exactly that:
|
||||
a thumb coming down at an angle — which is how a thumb comes down — is read as the start of a pan, the
|
||||
button never sees a click, and the row slides slightly instead. Pressing a key and having the keys
|
||||
move is the single most confidence-destroying thing a keyboard can do, and it happened often enough
|
||||
to be the first thing anybody noticed about this row.
|
||||
|
||||
── and the keys were too small to hit anyway ─────────────────────────────────────────────────────────
|
||||
30 tall. Android's own minimum touch target is 48 and every accessory row in every terminal on the
|
||||
platform is somewhere near it. The height had been taken down from 38 to save a strip of terminal,
|
||||
which is a trade worth making right up until the control stops working, and 30 is past that.
|
||||
|
||||
── what the other clients do, because all of them agree ─────────────────────────────────────────────
|
||||
Termux, Termius, JuiceSSH, ConnectBot and Blink all ship this row and not one of them scrolls it.
|
||||
They divide the width evenly between a fixed set of keys and, where the set does not fit, they use a
|
||||
second row — Termux ships two by default. That is not convention for its own sake: a key whose
|
||||
position is fixed can be hit without looking, which is the entire point of a modifier row you use
|
||||
while reading the output above it. A key that might have scrolled has to be found first.
|
||||
|
||||
So: two rows, every key stretched to an equal share of the width, none of it scrollable, and 40 tall
|
||||
instead of 30. Nothing was dropped — all eleven keys are here, and the four that mattered most are the
|
||||
ones that benefit, because the arrows were the keys living past the right edge.
|
||||
|
||||
── what it costs, stated rather than buried ─────────────────────────────────────────────────────────
|
||||
About 51 pixels of terminal, which is four or five lines of output. That is a real price and it is the
|
||||
right one: the row that was cheaper did not work, and a terminal with five more lines and no reachable
|
||||
Ctrl is not the better surface.
|
||||
|
||||
── the split ────────────────────────────────────────────────────────────────────────────────────────
|
||||
Modifiers and punctuation on top, cursor keys underneath. The arrows go on the lower row because they
|
||||
are the keys pressed repeatedly and in sequence — history, then along the line — so they want the row
|
||||
the thumb rests nearest, and there are only four of them, which leaves them the widest targets on the
|
||||
whole keypad. The text-size buttons ride at that row's right-hand end, outside the flex.
|
||||
|
||||
The five pixels above the whole thing are a gap and not a border. The renderer is a native child view,
|
||||
so nothing Avalonia draws can sit on top of it — a hairline between the two would have to be a row of
|
||||
its own — and a terminal whose last line of output is flush against a row of grey keys reads as one
|
||||
surface that has gone wrong rather than as two that are different things.
|
||||
-->
|
||||
<Border Grid.Row="2" IsVisible="{Binding IsTerminalShowing}" Height="33" Margin="0,5,0,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<!-- ============ what a terminal says once its shell has stopped ============ -->
|
||||
<!--
|
||||
The phone's half of SessionEndedBar, and it needs it more than the desk does: a mobile link drops on
|
||||
its own, the screen goes off, and the tab that went quiet is discovered later with no idea whether the
|
||||
shell was exited or cut off. So it names which, prints the sentence that came with it, and offers the
|
||||
one useful answer.
|
||||
|
||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
|
||||
VerticalAlignment="Center" />
|
||||
</ScrollViewer>
|
||||
Two lines rather than the desktop's one, because 360dp cannot hold a word, a sentence and two buttons
|
||||
across. The word and the buttons share the top line — the buttons are where a thumb already is — and
|
||||
the sentence wraps underneath at two lines maximum, which is enough for anything sshd or a socket
|
||||
error actually says and short of a strip that eats the terminal.
|
||||
|
||||
RECONNECT is disabled rather than hidden when there is nothing to dial: a machine typed in by hand has
|
||||
no keychain entry to redial, and a control visibly refusing answers "why not" better than an absent one.
|
||||
See MainWindowViewModel.CanReconnectSelectedTab.
|
||||
-->
|
||||
<Border Grid.Row="2" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="16,8"
|
||||
IsVisible="{Binding SessionEndedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
|
||||
<StackPanel Spacing="6">
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot" Classes.dropped="{Binding IsSessionDropped}" Width="7" Height="7"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="label" FontSize="9" Text="{Binding SessionEndedText}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="row" MinHeight="40" Padding="12,0"
|
||||
Command="{Binding ReconnectTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="RECONNECT" />
|
||||
</Button>
|
||||
<Button Classes="row" MinHeight="40" Padding="12,0"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding SessionEndedDetail}"
|
||||
IsVisible="{Binding SessionEndedDetail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Margin="0,5,0,0">
|
||||
<StackPanel Spacing="4">
|
||||
|
||||
<!--
|
||||
The text-size buttons, pinned at this row's right-hand end rather than scrolling with the keys
|
||||
beside them.
|
||||
|
||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the state
|
||||
in which hunting for the control that fixes it is worst. That argument is answered rather than
|
||||
abandoned: outside the ScrollViewer they cannot scroll away, and the row they had costs 36 pixels
|
||||
on a surface this change exists to give back.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in this
|
||||
screen.
|
||||
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
|
||||
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
||||
TerminalScreen.axaml.cs. Every button on this row sits over a terminal somebody is typing into,
|
||||
and an ordinary one takes Avalonia's focus off the NativeWebView on tap, which makes the package
|
||||
resign the page's focus. Making the text smaller must not cost the keyboard.
|
||||
Modifiers and punctuation. Populated from the table in the code-behind, which is also where the
|
||||
bytes each one sends live; a UniformGrid rather than a StackPanel so the seven divide the width
|
||||
between them instead of taking what their labels happen to need — "ctrl" is four characters and
|
||||
"|" is one, and on a row of keys those two want the same target, not proportional ones.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="5" Margin="8,0,12,0"
|
||||
VerticalAlignment="Center">
|
||||
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
|
||||
VerticalAlignment="Center" />
|
||||
<UniformGrid x:Name="AccessoryKeys" Columns="7" Margin="8,0" />
|
||||
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" Focusable="False"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
<!--
|
||||
The cursor keys, and the two text-size buttons riding at the end.
|
||||
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" Focusable="False"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
Grid rather than a second UniformGrid across the whole width: the arrows share what is left after
|
||||
A− and A+ have taken their fixed 44 each, so the four of them flex and the two of them do not.
|
||||
Putting all six in one uniform row would make the font buttons as wide as an arrow key and move
|
||||
them every time the row's contents changed.
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
||||
|
||||
<UniformGrid x:Name="ArrowKeys" Grid.Column="0" Columns="4" />
|
||||
|
||||
<!--
|
||||
The text-size buttons.
|
||||
|
||||
They used to have a row of their own above the terminal, on the argument that the accessory row
|
||||
scrolls and these two must never be off-screen — a terminal too small to read is exactly the
|
||||
state in which hunting for the control that fixes it is worst. Nothing on this keypad scrolls
|
||||
any more, so that argument is now satisfied by construction rather than by placement.
|
||||
|
||||
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head
|
||||
has no keyboard to press it with, which is why the two heads differ here and nowhere else in
|
||||
this screen.
|
||||
|
||||
Disabled at the ends rather than clamping silently. A button that keeps accepting taps and does
|
||||
nothing reads as the terminal having stopped responding, which is the one thing this screen must
|
||||
never look like.
|
||||
|
||||
◆ Focusable="False", for the reason the accessory keys carry in code — see
|
||||
TerminalScreen.axaml.cs. Every button on this keypad sits over a terminal somebody is typing
|
||||
into, and an ordinary one takes Avalonia's focus off the input surface on tap. Making the text
|
||||
smaller must not cost the keyboard.
|
||||
-->
|
||||
<!-- No Spacing: Button.hotkey carries a 2-pixel side margin, so the gaps here match the
|
||||
UniformGrid cells' without being counted twice. -->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="8,0,0,0">
|
||||
<Border Width="1" Height="20" Background="{StaticResource Border}" Margin="0,0,4,0"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||
Command="{Binding ShrinkTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanShrinkTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="hotkey" Width="44" Focusable="False"
|
||||
Command="{Binding EnlargeTerminalFontCommand}"
|
||||
IsEnabled="{Binding CanEnlargeTerminalFont}">
|
||||
<TextBlock Classes="mono" FontSize="15" Text="A+" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
@@ -389,9 +675,10 @@
|
||||
of them.
|
||||
|
||||
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
|
||||
it was there — the bottom bar is collapsed while a terminal is showing, so the screen area is the
|
||||
display.
|
||||
but this surface raises it. The scrim reaching only the screen area is the full display while a shell
|
||||
is showing, because the bottom bar is collapsed for it. Raised from the connect page it is not, and
|
||||
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:
|
||||
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 Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
|
||||
using DodoSSH.Client.Android.Platform;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
@@ -37,7 +40,14 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
/// keys to fit 360dp produces ten targets too small to hit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly (string Label, byte[] Bytes, bool Latches)[] Keys =
|
||||
/// <summary>The upper row: the modifiers, and the punctuation a symbols layer is two taps away from.</summary>
|
||||
/// <remarks>
|
||||
/// Seven, which is what the row divides between — see the keypad's remark in TerminalScreen.axaml for
|
||||
/// why the set is fixed and flexed rather than scrollable. Adding an eighth is a matter of adding it
|
||||
/// here and changing the <c>Columns</c> on the UniformGrid; the two numbers have to agree, and the row
|
||||
/// simply gets narrower keys.
|
||||
/// </remarks>
|
||||
private static readonly (string Label, byte[] Bytes, bool Latches)[] ModifierKeys =
|
||||
[
|
||||
("esc", [0x1B], false),
|
||||
("tab", [0x09], false),
|
||||
@@ -46,12 +56,31 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
("-", [(byte)'-'], false),
|
||||
("~", [(byte)'~'], false),
|
||||
("/", [(byte)'/'], false),
|
||||
];
|
||||
|
||||
// The cursor keys, as the ANSI sequences a PTY expects. Written out rather than composed, because
|
||||
// the difference between CSI A and SS3 A is the difference between working in bash and not.
|
||||
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
||||
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
||||
/// <summary>The lower row: the cursor keys, which is where they became reachable.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The ANSI sequences a PTY expects, written out rather than composed, because the difference between
|
||||
/// CSI A and SS3 A is the difference between working in bash and not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Left, down, up, right</b> — the order every other client on the platform puts them in, and not
|
||||
/// the order they were in here. On one flat row the pairs have to read as pairs: horizontal at the ends
|
||||
/// and vertical in the middle means the two keys that undo each other are always neighbours, and a
|
||||
/// thumb learns the row's shape rather than its labels. Up-down-left-right is the order they are named
|
||||
/// in, which is a different thing from the order they are used in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// These four were the keys that used to live past the right-hand edge of the scroller — the most
|
||||
/// pressed keys on the keypad, and the ones you had to go looking for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly (string Label, byte[] Bytes, bool Latches)[] CursorKeys =
|
||||
[
|
||||
("←", [0x1B, (byte)'[', (byte)'D'], false),
|
||||
("↓", [0x1B, (byte)'[', (byte)'B'], false),
|
||||
("↑", [0x1B, (byte)'[', (byte)'A'], false),
|
||||
("→", [0x1B, (byte)'[', (byte)'C'], false),
|
||||
];
|
||||
|
||||
@@ -67,26 +96,119 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
|
||||
private Button? controlKey;
|
||||
|
||||
private readonly TerminalInputSurface input;
|
||||
|
||||
/// <summary>The shell whose <c>PropertyChanged</c> this screen is subscribed to, so it can unsubscribe.</summary>
|
||||
private MainWindowViewModel? subscribed;
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="MainWindowViewModel.IsTerminalShowing"/> was last seen to be.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The keyboard is raised on the transition into the terminal and not on every notification of it.
|
||||
/// <c>IsTerminalShowing</c> is a computed property re-raised by several other setters, so acting on the
|
||||
/// notification rather than the edge would call <c>ShowSoftInput</c> repeatedly for one arrival.
|
||||
/// </remarks>
|
||||
private bool terminalWasShowing;
|
||||
|
||||
public TerminalScreen()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
|
||||
BuildAccessoryRow();
|
||||
|
||||
input = this.FindControl<TerminalInputSurface>("Input")!;
|
||||
input.Input += (_, e) => SendAsync(e.Bytes);
|
||||
|
||||
var renderer = this.FindControl<NativeWebView>("Renderer")!;
|
||||
|
||||
renderer.EnvironmentRequested += OnRendererEnvironmentRequested;
|
||||
renderer.EnvironmentRequested += (sender, e) =>
|
||||
{
|
||||
OnRendererEnvironmentRequested(sender, e);
|
||||
|
||||
// The precise moment the platform WebView is being built, which is the earliest one at which
|
||||
// there is a native view to take the keyboard away from — and the one that fires again if
|
||||
// Android reclaims the renderer and the page comes back. Posted rather than called, because the
|
||||
// child is not in the hierarchy yet while its environment is still being asked for.
|
||||
Dispatcher.UIThread.Post(AdoptRenderer, DispatcherPriority.Loaded);
|
||||
};
|
||||
|
||||
// The URL is only known once the loopback listener has bound a port, so it cannot be set in XAML.
|
||||
DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (subscribed is { } previous)
|
||||
{
|
||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||
subscribed = null;
|
||||
}
|
||||
|
||||
if (DataContext is MainWindowViewModel shell)
|
||||
{
|
||||
renderer.Source = shell.TerminalPageUrl;
|
||||
|
||||
shell.PropertyChanged += OnShellPropertyChanged;
|
||||
|
||||
// The snippets screen's "TYPE INTO {tab}" lands here as well as on the desktop. It types at
|
||||
// a prompt the user is about to want to keep typing at, so the keyboard should be up when
|
||||
// they look. See MainWindowViewModel.TerminalFocusRequested.
|
||||
shell.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||
subscribed = shell;
|
||||
|
||||
terminalWasShowing = shell.IsTerminalShowing;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the keyboard when a shell arrives, and never while one is merely being redrawn.
|
||||
/// </summary>
|
||||
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is not nameof(MainWindowViewModel.IsTerminalShowing) ||
|
||||
sender is not MainWindowViewModel shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var showing = shell.IsTerminalShowing;
|
||||
|
||||
if (showing == terminalWasShowing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
terminalWasShowing = showing;
|
||||
|
||||
if (!showing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Posted to Loaded so the renderer's own native child, which becomes visible on this same change,
|
||||
// exists by the time AdoptRenderer goes looking for it. Idempotent with the EnvironmentRequested
|
||||
// path above: whichever runs first configures the view and the other finds it already done.
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
AdoptRenderer();
|
||||
input.Acquire();
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
private void OnTerminalFocusRequested(object? sender, EventArgs e) =>
|
||||
Dispatcher.UIThread.Post(input.Acquire, DispatcherPriority.Loaded);
|
||||
|
||||
/// <summary>
|
||||
/// Takes the keyboard off the renderer and puts the taps on it to use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See <see cref="RendererFocus"/> for why a <c>WebView</c> that cannot hold focus still scrolls, and why
|
||||
/// the tap has to be reported rather than left to the page.
|
||||
/// </remarks>
|
||||
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
|
||||
|
||||
/// <summary>
|
||||
/// Puts the keyboard away on the way to the connect menu.
|
||||
/// </summary>
|
||||
@@ -137,50 +259,28 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
|
||||
private void BuildAccessoryRow()
|
||||
{
|
||||
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
||||
// ── nothing to hand back any more ──────────────────────────────────────────────────────────────
|
||||
// This row used to counter both halves of every press with a posted TerminalFocus.Return(), because
|
||||
// the platform requests Android's own focus for Avalonia's view after dispatching each handled
|
||||
// touch, and the keyboard belonged to the WebView's input connection. Under ADR 0015 it belongs to
|
||||
// TerminalInputSurface, which is an Avalonia control — so the focus the platform takes is the focus
|
||||
// the keyboard already wants, and the counter-move is not merely unnecessary but wrong. The keys
|
||||
// stay Focusable=false, which is now what keeps Avalonia's own focus on the input surface.
|
||||
|
||||
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
|
||||
{
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
|
||||
// 44 wide, and that is the number that matters: the design draws these flexed across the
|
||||
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
|
||||
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
|
||||
// costs nothing a width does: the keys are a single row with the terminal above and the
|
||||
// system's gesture bar below, so there is no neighbour a short press can land on instead.
|
||||
MinWidth = 44,
|
||||
Height = 30,
|
||||
Padding = new Thickness(10, 0),
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Background = Palette("Panel"),
|
||||
BorderBrush = Palette("BorderMid"),
|
||||
BorderThickness = new Thickness(1),
|
||||
Foreground = Palette("TextDim"),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
|
||||
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
||||
//
|
||||
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
||||
// ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView
|
||||
// — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing
|
||||
// Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next
|
||||
// thing typed on a hardware keyboard went nowhere, and the row went on working because its
|
||||
// buttons are pressed rather than typed into, which is what makes it look like the terminal
|
||||
// had died instead.
|
||||
//
|
||||
// Focusable=false is what a toolbar button is, and it means the focused element never
|
||||
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
||||
Focusable = false,
|
||||
};
|
||||
var key = CreateKey(label);
|
||||
|
||||
if (latches)
|
||||
{
|
||||
@@ -196,6 +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()
|
||||
{
|
||||
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
|
||||
// ^L to a database prompt believing they typed an l.
|
||||
controlKey.Background = Palette(controlLatched ? "Active" : "Panel");
|
||||
controlKey.Foreground = Palette(controlLatched ? "AccentText" : "TextDim");
|
||||
//
|
||||
// A class rather than two brush assignments, so what "latched" looks like is stated once in
|
||||
// Phone.axaml beside what "pressed" looks like — which is where somebody changing either will be.
|
||||
controlKey.Classes.Set("latched", controlLatched);
|
||||
}
|
||||
|
||||
/// <summary>One brush from <c>Theme/Palette.axaml</c>, by key.</summary>
|
||||
/// <remarks>
|
||||
/// The accessory row is built in code because its keys come from a table, so its colours cannot be set in
|
||||
/// XAML with the rest of the screen's. Resolving them by name is the next best thing: a row that named
|
||||
/// its own blues is how the palette ends up with a fifth surface nobody meant to add.
|
||||
/// </remarks>
|
||||
private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush;
|
||||
|
||||
/// <remarks>
|
||||
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
|
||||
/// 0x1A, which is letter & 0x1F. Applied only to letters, because Ctrl with an arrow key is a
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Email}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!--
|
||||
◆ The one fact on this row that decides whether the button at the foot of the screen
|
||||
@@ -336,9 +336,11 @@
|
||||
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.
|
||||
-->
|
||||
<!-- 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"
|
||||
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">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
@@ -984,7 +984,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -1044,9 +1044,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -1140,11 +1140,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,22 @@ public interface ISyncApi
|
||||
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>
|
||||
/// The typed client for one DodoSSH server.
|
||||
/// </summary>
|
||||
@@ -275,7 +291,7 @@ public interface ISyncApi
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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 ConfigurationPath = "/.well-known/dodossh-configuration";
|
||||
@@ -310,6 +326,20 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
|
||||
DodoSshJsonContext.Default.DodoSshConfiguration,
|
||||
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>
|
||||
/// Reads the caller's profile, unlock material and reachable vaults.
|
||||
/// </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.Input.Platform;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Auth;
|
||||
@@ -33,12 +34,77 @@ internal sealed partial class DodoSshApp : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Compose(desktop);
|
||||
ShowSomethingWhileComposing(desktop);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts a window on screen first, and composes the application behind it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The splash <em>is</em> the main window for a moment, which is what makes this work at all.</b> The
|
||||
/// desktop lifetime shows whatever <c>MainWindow</c> holds once this method returns and then runs the
|
||||
/// message loop; a window created here and merely shown would be a window with no lifetime attached, and
|
||||
/// with <c>ShutdownMode.OnLastWindowClose</c> the first close of anything would end the process. Handing
|
||||
/// the property over is one assignment, and Avalonia reads it lazily.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>And composition is posted rather than called.</b> It is synchronous and takes long enough to be
|
||||
/// worth a splash in the first place — which is the whole argument for this method — so running it here
|
||||
/// would block the very dispatcher turn that has to paint the thing. Background priority puts it behind
|
||||
/// rendering, which is what makes this a painted window rather than merely a shown one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void ShowSomethingWhileComposing(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var splash = new SplashWindow();
|
||||
|
||||
desktop.MainWindow = splash;
|
||||
|
||||
// Fully qualified, and it has to be: Application inherits an instance Dispatcher property from
|
||||
// AvaloniaObject, member lookup beats the namespace, and a static method here cannot reach an
|
||||
// instance property — so the unqualified name is a compile error rather than the type. Same below.
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(
|
||||
() => ComposeBehind(desktop, splash),
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The order at the end is not interchangeable. <c>ShutdownMode</c> is <c>OnLastWindowClose</c>, so the
|
||||
/// real window is shown while the splash is still open and the splash is closed afterwards; closing the
|
||||
/// only window there is would end the process instead of handing it over.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failure is caught rather than left to the dispatcher, and the splash is exactly why.</b> Before
|
||||
/// this method existed a composition that threw took the process with it and left nothing behind. Now
|
||||
/// there is something to leave behind — so an exception escaping a posted callback would abandon a window
|
||||
/// saying "Starting…" over a process that had stopped, which is worse than the crash it replaced. What
|
||||
/// arrives instead is the sentence and a way to close it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void ComposeBehind(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
SplashWindow splash)
|
||||
{
|
||||
try
|
||||
{
|
||||
Compose(desktop);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
splash.CouldNotStart(exception.Message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
desktop.MainWindow?.Show();
|
||||
splash.Close();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
|
||||
@@ -72,6 +138,17 @@ internal sealed partial class DodoSshApp : Application
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>Reads this machine's clipboard.</summary>
|
||||
/// <remarks>
|
||||
/// The other direction, looked up per call for the same reason and with the same fall-through. One
|
||||
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
|
||||
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
|
||||
/// </remarks>
|
||||
private static Func<Task<string?>> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) =>
|
||||
async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard }
|
||||
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
|
||||
: null;
|
||||
|
||||
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
|
||||
/// <remarks>
|
||||
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
|
||||
@@ -92,7 +169,10 @@ internal sealed partial class DodoSshApp : Application
|
||||
|
||||
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);
|
||||
|
||||
// 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();
|
||||
|
||||
// Both chosen once, here, because each is a property of the machine rather than of any session. A
|
||||
// computer with a usable TPM gets the store that keeps a device key behind a Windows consent prompt;
|
||||
// anything else gets one that reports itself unavailable, so unlock keeps asking for the passphrase
|
||||
// Windows box with a usable TPM gets a consent prompt, a Mac with a Secure Enclave gets Touch ID, a
|
||||
// Linux box whose TPM this user may open gets a PIN the TPM checks — see PinPrompt — and anything
|
||||
// else gets a store that reports itself unavailable, so unlock keeps asking for the passphrase
|
||||
// (ADR 0007). The update channel answers the same shape of question about how this copy was
|
||||
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0013.
|
||||
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
|
||||
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths, PinPrompt(desktop));
|
||||
var updates = UpdateChannels.ForThisMachine();
|
||||
|
||||
var viewModel = new MainWindowViewModel(
|
||||
var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections,
|
||||
browser, updates);
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
||||
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
||||
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
||||
var others = SingleInstance.Listen(
|
||||
paths,
|
||||
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
|
||||
|
||||
// Started rather than awaited: the framework's initialisation must not block on a schema
|
||||
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||
// discarding the task here is safe rather than merely convenient.
|
||||
_ = viewModel.StartAsync(CancellationToken.None);
|
||||
|
||||
WireShutdown(desktop, viewModel, workspace, caches, others);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the window somebody has just asked for by launching the application again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Un-minimised first, because activating a minimised window leaves it minimised and the launch would
|
||||
/// look ignored. <c>Activate</c> is a request rather than a guarantee — Windows refuses the foreground to
|
||||
/// a process that did not receive the input, and what happens then is a flashing taskbar button, which is
|
||||
/// still an answer. That is the platform's own rule about focus stealing and not a thing to work around.
|
||||
/// </remarks>
|
||||
private static void BringToFront(Window? window)
|
||||
{
|
||||
if (window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.WindowState is WindowState.Minimized)
|
||||
{
|
||||
window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
|
||||
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
|
||||
/// <remarks>
|
||||
/// Split out of <see cref="Compose"/> for length rather than for reuse — it has one caller, and the
|
||||
/// method it came out of is at the limit the analyzers set.
|
||||
/// </remarks>
|
||||
private static MainWindowViewModel BuildShell(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
ClientPaths paths,
|
||||
ClientCacheFactory caches,
|
||||
TerminalWorkspace workspace,
|
||||
VaultKnownHostStore knownHosts,
|
||||
IDeviceKeyStore deviceKeys,
|
||||
SshNetConnectionFactory connections,
|
||||
SystemBrowserLauncher browser,
|
||||
IUpdateChannel updates) =>
|
||||
new(
|
||||
paths,
|
||||
caches,
|
||||
workspace,
|
||||
@@ -137,18 +278,88 @@ internal sealed partial class DodoSshApp : Application
|
||||
.ConfigureAwait(false),
|
||||
|
||||
copyToClipboard: ClipboardWriter(desktop),
|
||||
updates: updates);
|
||||
readClipboard: ClipboardReader(desktop),
|
||||
updates: updates,
|
||||
openInEditor: OpenInDefaultEditorAsync,
|
||||
watchFile: WatchFile);
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
/// <summary>
|
||||
/// Hands a local file to whatever this machine opens that kind of file with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
|
||||
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
|
||||
/// Services binding depending on which desktop this is, and watching a file is a platform object with
|
||||
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
|
||||
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
|
||||
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
|
||||
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
|
||||
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
|
||||
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static Task OpenInDefaultEditorAsync(string path)
|
||||
{
|
||||
using var opened = System.Diagnostics.Process.Start(
|
||||
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
|
||||
// Started rather than awaited: the framework's initialisation must not block on a schema
|
||||
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||
// discarding the task here is safe rather than merely convenient.
|
||||
_ = viewModel.StartAsync(CancellationToken.None);
|
||||
|
||||
WireShutdown(desktop, viewModel, workspace, caches);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches one file for saves, until the handle is disposed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The directory is watched and the name filtered, which is not the same as watching the file.</b>
|
||||
/// A great many editors do not write in place: they write a temporary file beside the original and
|
||||
/// rename it over the top, which to a watcher pointed at an inode is the file disappearing. Watching the
|
||||
/// containing directory for that name catches the rename as a <c>Created</c>, which is what makes this
|
||||
/// work with the editors people actually use.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every kind of change is one callback, and the shell debounces nothing.</b> One save can raise two
|
||||
/// or three events; what the shell does with each is stat the host and compare, which is a round trip
|
||||
/// but a small one, and the alternative — a timer here — is a delay before somebody's edit reaches the
|
||||
/// server for the sake of saving it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static FileSystemWatcher WatchFile(string path, Action onChanged)
|
||||
{
|
||||
var watcher = new FileSystemWatcher(
|
||||
Path.GetDirectoryName(path)!, Path.GetFileName(path))
|
||||
{
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName,
|
||||
};
|
||||
|
||||
watcher.Changed += (_, _) => onChanged();
|
||||
watcher.Created += (_, _) => onChanged();
|
||||
watcher.Renamed += (_, _) => onChanged();
|
||||
|
||||
watcher.EnableRaisingEvents = true;
|
||||
|
||||
return watcher;
|
||||
}
|
||||
|
||||
/// <summary>How the Linux device key store asks for a PIN, and the only thing that ever calls it.</summary>
|
||||
/// <remarks>
|
||||
/// The lifetime is captured rather than the window, so this can be composed before there is one —
|
||||
/// composition runs first, and <c>DevicePinWindow.AskAsync</c> answers null when there is nothing to
|
||||
/// sit over. Windows and macOS never reach it: on those platforms the consent dialog is the operating
|
||||
/// system's and this process is not in the conversation. See ADR 0007.
|
||||
/// </remarks>
|
||||
private static Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> PinPrompt(
|
||||
IClassicDesktopStyleApplicationLifetime desktop) =>
|
||||
(purpose, cancellationToken) =>
|
||||
DevicePinWindow.AskAsync(() => desktop.MainWindow, purpose, cancellationToken);
|
||||
|
||||
/// <remarks>
|
||||
/// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and
|
||||
/// blocking the UI thread on their disposal is how an application comes to take several seconds to close —
|
||||
@@ -158,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
MainWindowViewModel viewModel,
|
||||
TerminalWorkspace workspace,
|
||||
ClientCacheFactory caches)
|
||||
ClientCacheFactory caches,
|
||||
IAsyncDisposable others)
|
||||
{
|
||||
var shuttingDown = false;
|
||||
|
||||
@@ -177,6 +389,11 @@ internal sealed partial class DodoSshApp : Application
|
||||
await viewModel.DisposeAsync().ConfigureAwait(true);
|
||||
await workspace.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
// Awaited rather than dropped, so the pipe name is free before this process is. It matters for
|
||||
// one caller and matters a lot there: an update relaunches this application, and a successor
|
||||
// that found the name still taken would be a second instance refusing itself.
|
||||
await others.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
caches.Dispose();
|
||||
|
||||
desktop.Shutdown();
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 786 B |
Binary file not shown.
|
After Width: | Height: | Size: 1002 B |
@@ -1,13 +1,18 @@
|
||||
# Regenerates dodossh.ico from the same geometry the Android launcher icon draws.
|
||||
# Regenerates dodossh.ico and dodossh.icns from the same geometry the Android launcher icon draws.
|
||||
#
|
||||
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
|
||||
# reason it is a vector is that there is then one geometry to change and no set of PNG densities
|
||||
# to forget one of. Windows will not take a vector: <ApplicationIcon> wants an .ico and nothing
|
||||
# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays
|
||||
# honest: the numbers below are the ones in that XML, and regenerating is the whole edit.
|
||||
# to forget one of. Neither desktop platform will take a vector: <ApplicationIcon> wants an .ico
|
||||
# and nothing else, Window.Icon wants a bitmap, and vpk wants an .icns for the macOS bundle. So
|
||||
# the rasters exist, and this script is how they stay honest: the numbers below are the ones in
|
||||
# that XML, and regenerating is the whole edit.
|
||||
#
|
||||
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
|
||||
#
|
||||
# Both outputs are written every run, deliberately. Two scripts, or one script with a switch,
|
||||
# is how the two files come to be drawn from different geometry — which nobody would notice,
|
||||
# because no one person looks at a Windows taskbar and a macOS Dock on the same afternoon.
|
||||
#
|
||||
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
|
||||
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
|
||||
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
|
||||
@@ -22,37 +27,57 @@ Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5B8CFF') # dodo_accent / AccentColor
|
||||
$ink = [System.Drawing.ColorTranslator]::FromHtml('#0E1220') # AccentInk
|
||||
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5D42DE') # dodo_accent / AccentColor
|
||||
$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
|
||||
# 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.
|
||||
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||
|
||||
function New-MarkPng([int]$size)
|
||||
# $tileFraction is how much of the canvas the accent tile fills, and it is the one number that
|
||||
# differs between the two platforms.
|
||||
#
|
||||
# Windows passes 1.0: the tile bleeds to the edge, because Windows draws application icons at
|
||||
# whatever size they come in and every other icon on the taskbar does the same.
|
||||
#
|
||||
# macOS passes 0.8047, and that is not taste. Apple's icon grid puts a rounded-rect app icon in
|
||||
# an 824-pixel square inside a 1024-pixel canvas — 824/1024 — with the remaining hundred pixels a
|
||||
# side left as air for the Dock's shadow and its magnification. An icon that ignores the grid and
|
||||
# bleeds to the edge does not read as bold; it reads as the one icon in the Dock that is too big,
|
||||
# because it sits beside Finder and Safari which do not.
|
||||
function New-MarkPng([int]$size, [double]$tileFraction = 1.0)
|
||||
{
|
||||
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||
|
||||
# The tile, and the inset that centres it when it does not fill the canvas.
|
||||
$tile = [double]$size * $tileFraction
|
||||
$inset = ([double]$size - $tile) / 2.0
|
||||
|
||||
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
|
||||
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
|
||||
$radius = [double]$size * 0.22
|
||||
#
|
||||
# 0.22 of the tile rather than of the canvas, so the corner keeps its proportion to the shape
|
||||
# it is rounding instead of growing as the air around it does. It is also within a whisker of
|
||||
# the 185/824 Apple's own grid specifies, which is why one radius serves both files.
|
||||
$radius = $tile * 0.22
|
||||
$d = $radius * 2.0
|
||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$path.AddArc(0.0, 0.0, $d, $d, 180, 90)
|
||||
$path.AddArc($size - $d, 0.0, $d, $d, 270, 90)
|
||||
$path.AddArc($size - $d, $size - $d, $d, $d, 0, 90)
|
||||
$path.AddArc(0.0, $size - $d, $d, $d, 90, 90)
|
||||
$path.AddArc($inset, $inset, $d, $d, 180, 90)
|
||||
$path.AddArc($inset + $tile - $d, $inset, $d, $d, 270, 90)
|
||||
$path.AddArc($inset + $tile - $d, $inset + $tile - $d, $d, $d, 0, 90)
|
||||
$path.AddArc($inset, $inset + $tile - $d, $d, $d, 90, 90)
|
||||
$path.CloseFigure()
|
||||
$brush = New-Object System.Drawing.SolidBrush($accent)
|
||||
$g.FillPath($brush, $path)
|
||||
|
||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge.
|
||||
$scale = [double]$size / 72.0
|
||||
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) }
|
||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge. Scaled to the tile and
|
||||
# offset by the inset, so the glyph keeps its place within the tile at either fraction.
|
||||
$scale = $tile / 72.0
|
||||
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale + $inset), (($y - 18.0) * $scale + $inset)) }
|
||||
|
||||
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
|
||||
# difference between a mark and a smudge. The phone's file already bumps this width for the
|
||||
@@ -117,3 +142,120 @@ $target = Join-Path $PSScriptRoot 'dodossh.ico'
|
||||
$w.Dispose(); $out.Dispose()
|
||||
|
||||
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
|
||||
|
||||
# ---- dodossh.icns, for the macOS bundle ----------------------------------------------------------
|
||||
#
|
||||
# Written here rather than by `iconutil` on a Mac, and that is the point of doing it the long way.
|
||||
# iconutil is the documented tool and it exists only on macOS, so an icon that needed it could not
|
||||
# be regenerated on the machine this project is developed on — the geometry above would change and
|
||||
# the .icns would quietly keep the old mark until somebody next opened a Mac. The container format
|
||||
# is a magic word, a length and a run of typed PNG chunks, which is little enough to own.
|
||||
#
|
||||
# ◆ EVERY LENGTH IN THIS FILE IS BIG-ENDIAN, AND BinaryWriter IS NOT.
|
||||
#
|
||||
# The one thing that will catch anybody editing this. A .icns written little-endian is not rejected
|
||||
# with an error — Finder and vpk both just show the placeholder icon, because the first chunk claims
|
||||
# a length of about two billion and the parser walks off the end and gives up. Hence Write-BE32.
|
||||
#
|
||||
# Type codes are Apple's, and the pairs are not redundant. ic08 and ic13 are both 256 pixels because
|
||||
# one is "256 at 1x" and the other is "128 at 2x", and a Retina display asked for the second will not
|
||||
# accept the first. Same for ic09/ic14 at 512. iconutil emits both from an .iconset for this reason,
|
||||
# so this does too.
|
||||
$icnsTypes = @(
|
||||
@{ Type = 'ic11'; Size = 32 } # 16@2x
|
||||
@{ Type = 'ic12'; Size = 64 } # 32@2x
|
||||
@{ Type = 'ic07'; Size = 128 } # 128@1x
|
||||
@{ Type = 'ic13'; Size = 256 } # 128@2x
|
||||
@{ Type = 'ic08'; Size = 256 } # 256@1x
|
||||
@{ Type = 'ic14'; Size = 512 } # 256@2x
|
||||
@{ Type = 'ic09'; Size = 512 } # 512@1x
|
||||
@{ Type = 'ic10'; Size = 1024 } # 512@2x
|
||||
)
|
||||
|
||||
# Apple's icon grid: an 824-pixel shape centred in a 1024-pixel canvas. See New-MarkPng.
|
||||
$macTileFraction = 824.0 / 1024.0
|
||||
|
||||
# Rendered once per distinct pixel size rather than once per type code, so the two 256s and the two
|
||||
# 512s are byte-identical and the file does not carry the same image twice over at different
|
||||
# compression. It also halves the drawing, which at 1024 is not nothing.
|
||||
$rendered = @{}
|
||||
foreach ($size in ($icnsTypes.Size | Sort-Object -Unique))
|
||||
{
|
||||
[byte[]]$png = New-MarkPng $size $macTileFraction
|
||||
$rendered[$size] = $png
|
||||
}
|
||||
|
||||
$icns = New-Object System.IO.MemoryStream
|
||||
|
||||
function Write-BE32([System.IO.Stream]$stream, [uint32]$value)
|
||||
{
|
||||
$bytes = [System.BitConverter]::GetBytes($value)
|
||||
if ([System.BitConverter]::IsLittleEndian) { [array]::Reverse($bytes) }
|
||||
$stream.Write($bytes, 0, 4)
|
||||
}
|
||||
|
||||
function Write-Ascii([System.IO.Stream]$stream, [string]$text)
|
||||
{
|
||||
$bytes = [System.Text.Encoding]::ASCII.GetBytes($text)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
}
|
||||
|
||||
# The header's length field covers the whole file including the header, so it is written last —
|
||||
# eight bytes of nothing now, seeked back to and filled in once the total is known.
|
||||
Write-Ascii $icns 'icns'
|
||||
Write-BE32 $icns 0
|
||||
|
||||
foreach ($entry in $icnsTypes)
|
||||
{
|
||||
$payload = $rendered[$entry.Size]
|
||||
Write-Ascii $icns $entry.Type
|
||||
|
||||
# Length includes this chunk's own eight-byte header, which is the off-by-eight everybody
|
||||
# writes once.
|
||||
Write-BE32 $icns ([uint32]($payload.Length + 8))
|
||||
$icns.Write($payload, 0, $payload.Length)
|
||||
}
|
||||
|
||||
$total = [uint32]$icns.Length
|
||||
$icns.Position = 4
|
||||
Write-BE32 $icns $total
|
||||
|
||||
$icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
|
||||
[System.IO.File]::WriteAllBytes($icnsTarget, $icns.ToArray())
|
||||
$icns.Dispose()
|
||||
|
||||
Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)"
|
||||
|
||||
# ---- 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>
|
||||
|
||||
<!--
|
||||
============ 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
|
||||
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" />
|
||||
</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.
|
||||
|
||||
@@ -102,6 +151,14 @@
|
||||
interface declared in DodoSSH.Client.Session.
|
||||
-->
|
||||
<PackageReference Include="Velopack" />
|
||||
|
||||
<!--
|
||||
Here for the same reason and with the same boundary as Velopack above: LinuxDeviceKeyStore is a
|
||||
Linux-only implementation of an interface declared in DodoSSH.Client.Session, and it lives in
|
||||
Platform/ beside the Windows and macOS stores. The Android head has its own keystore and must
|
||||
never see this one.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.TSS" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,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 Velopack;
|
||||
using Velopack.Sources;
|
||||
@@ -31,7 +32,19 @@ internal static class UpdateChannels
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
@@ -52,17 +65,53 @@ internal static class UpdateChannels
|
||||
return new UnavailableUpdateChannel();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
|
||||
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
|
||||
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
|
||||
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
|
||||
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
|
||||
/// out.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
|
||||
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
|
||||
/// differently. The install root is a constant in two places — here and the packaging script — and
|
||||
/// they are named in each other's comments.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("linux")]
|
||||
private static IUpdateChannel LinuxChannel()
|
||||
{
|
||||
var root = DesktopChannel.IsNightly ? "/opt/DodoSSH.Nightly" : "/opt/DodoSSH";
|
||||
|
||||
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
|
||||
|
||||
return string.Equals(here, root, StringComparison.Ordinal)
|
||||
? new LinuxUpdateChannel(DesktopChannel.Name)
|
||||
: new UnavailableUpdateChannel();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Windows update channel, backed by Velopack against the project's own forge.
|
||||
/// The desktop update channel, backed by Velopack against the project's own forge.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one file in the repository that names Velopack. It lives beside <c>WindowsDeviceKeyStore</c>
|
||||
/// rather than in a project of its own because it is the same kind of thing — a Windows-only
|
||||
/// implementation of an interface declared in <c>DodoSSH.Client.Session</c> — and because
|
||||
/// <c>DodoSSH.Client.Shell</c> is shared with the Android head, which must never acquire an updater.
|
||||
/// The one file in the repository that names Velopack. It lives beside the platform key stores rather
|
||||
/// than in a project of its own because it is the same kind of thing — a desktop-only implementation of
|
||||
/// an interface declared in <c>DodoSSH.Client.Session</c> — and because <c>DodoSSH.Client.Shell</c> is
|
||||
/// shared with the Android head, which must never acquire an updater.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One class for both desktop platforms, where the key stores are one class each.</b> The difference
|
||||
/// is where the platform knowledge sits. A key store is platform knowledge from top to bottom: different
|
||||
/// hardware, different API, different failure modes. Velopack's <c>UpdateManager</c> has already absorbed
|
||||
/// all of that, and what is left over — check, download, apply, restart — is identical on the two. The
|
||||
/// only thing that differs is which string names the feed, and that is <see cref="ChannelFor"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
|
||||
@@ -103,7 +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:
|
||||
/// the check succeeds, finds nothing, and reports that the client is up to date forever.
|
||||
/// </remarks>
|
||||
private const string ReleaseChannel = "win";
|
||||
private const string WindowsReleaseChannel = "win";
|
||||
|
||||
/// <summary>
|
||||
/// The nightly channel, which is a different name rather than the same one on a different tag.
|
||||
/// </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;
|
||||
|
||||
@@ -117,15 +204,37 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// </remarks>
|
||||
private UpdateInfo? found;
|
||||
|
||||
/// <summary>
|
||||
/// The last thing actually fetched, kept separately from the last thing found.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Because the two can disagree, and when they do it is an apply that pays for it.</b> A check
|
||||
/// overwrites <see cref="found"/>, and the shell now checks while a build is already downloaded and
|
||||
/// waiting — that is how it replaces a pending build when the feed has moved on, and how it takes one
|
||||
/// last look on the way to a restart. Either of those can land a newer answer in <c>found</c> and then
|
||||
/// fail to fetch it, or be given up on halfway; the shell's answer is to restart with the build it
|
||||
/// already has, and <see cref="Matched"/> resolving only against <c>found</c> would have thrown at it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Which turned a slow forge into a refused restart: the offer on screen was real, the package on disk
|
||||
/// was real, and the only thing wrong was that this class had forgotten which one it was.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private UpdateInfo? fetched;
|
||||
|
||||
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsSupported => true;
|
||||
|
||||
/// <summary>Always, on this head.</summary>
|
||||
/// <summary>Always, on this head, on either platform.</summary>
|
||||
/// <remarks>
|
||||
/// Velopack's apply runs <c>Update.exe</c> over this installation and restarts it, so the process is
|
||||
/// gone by the time anything could have asked a question. The phone's is the other answer; see
|
||||
/// Velopack's apply hands off to a separate updater process — <c>Update.exe</c> on Windows, the
|
||||
/// <c>UpdateMac</c> helper inside the bundle on macOS — which replaces this installation and
|
||||
/// relaunches it, so the process is gone by the time anything could have asked a question. The
|
||||
/// mechanism differs and the answer does not, which is why this is a constant rather than another
|
||||
/// thing <see cref="ChannelFor"/> would have to decide. The phone's is the other answer; see
|
||||
/// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
|
||||
/// </remarks>
|
||||
public bool ApplyingEndsTheProcess => true;
|
||||
@@ -138,10 +247,63 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// </remarks>
|
||||
public string CurrentVersion => ClientVersion.Current;
|
||||
|
||||
internal static UpdateManager CreateManager() =>
|
||||
new(
|
||||
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: false),
|
||||
new UpdateOptions { ExplicitChannel = ReleaseChannel });
|
||||
/// <summary>
|
||||
/// The updater for this build's channel.
|
||||
/// </summary>
|
||||
/// <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 />
|
||||
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>,
|
||||
// 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 />
|
||||
@@ -192,21 +370,39 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
|
||||
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
|
||||
/// <see cref="CheckAsync"/> returned; a mismatch is a bug in this application rather than anything a
|
||||
/// user did, which is why it throws rather than resolving to some safe-looking default.
|
||||
/// <see cref="CheckAsync"/> returned, or what a download of that then fetched; a mismatch is a bug in
|
||||
/// this application rather than anything a user did, which is why it throws rather than resolving to
|
||||
/// some safe-looking default.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What was fetched is tried before what was found, because that is the one an apply names and the one a
|
||||
/// later check cannot take away. See <see cref="fetched"/> for how the two come to disagree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private UpdateInfo Matched(AvailableUpdate update)
|
||||
{
|
||||
if (found is not { } info
|
||||
|| !string.Equals(info.TargetFullRelease.Version.ToString(), update.Version, StringComparison.Ordinal))
|
||||
if (Names(fetched, update))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No update matching {update.Version} has been found by this channel. "
|
||||
+ "Call CheckAsync and pass back what it returned.");
|
||||
return fetched!;
|
||||
}
|
||||
|
||||
return info;
|
||||
if (Names(found, update))
|
||||
{
|
||||
return found!;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No update matching {update.Version} has been found or fetched by this channel. "
|
||||
+ "Call CheckAsync and pass back what it returned.");
|
||||
}
|
||||
|
||||
private static bool Names(UpdateInfo? info, AvailableUpdate update) =>
|
||||
info is not null
|
||||
&& string.Equals(
|
||||
info.TargetFullRelease.Version.ToString(),
|
||||
update.Version,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
@@ -9,9 +9,26 @@ namespace DodoSSH.Client.App.Platform;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
|
||||
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
|
||||
/// passphrase — which is the honest answer rather than a degraded one.
|
||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware
|
||||
/// gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the passphrase — which is
|
||||
/// the honest answer rather than a degraded one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every real store is asked whether it works rather than told that it does.</b> Each
|
||||
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and discarding it — because on
|
||||
/// all three platforms the provider is present and reports itself present on machines where creating a key
|
||||
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, on macOS also every unsigned
|
||||
/// development build since enclave keys need a signing identity, and on Linux the ordinary case of a
|
||||
/// working TPM whose device node this user may not open. Inferring from the OS would mean each of those
|
||||
/// discovering the truth at the moment somebody tried to unlock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The three are not equally strong, and the ordering here does not imply they are.</b> Windows and
|
||||
/// macOS put the consent gesture inside the platform — a secure desktop and a system Touch ID sheet — so
|
||||
/// the process asking never sees the secret. The Linux store's PIN is typed into a window this
|
||||
/// application draws, and only the <em>checking</em> is the TPM's. That is a real difference, it is
|
||||
/// recorded in ADR 0007 rather than smoothed over, and it is why <see cref="LinuxDeviceKeyStore"/> is
|
||||
/// tried last rather than first among platforms that could in principle overlap.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <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
|
||||
{
|
||||
/// <summary>The best store this machine supports.</summary>
|
||||
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
|
||||
/// <param name="paths">Where a stored key lives.</param>
|
||||
/// <param name="askForPin">
|
||||
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
|
||||
/// raises its own dialog and this process is not in the conversation. A caller with no way to draw one
|
||||
/// may pass a delegate answering null, which makes the Linux store decline every unlock; that is the
|
||||
/// right shape for a headless host and the wrong one for the application, which supplies a real dialog
|
||||
/// in <c>App.axaml.cs</c>.
|
||||
/// </param>
|
||||
public static IDeviceKeyStore ForThisMachine(
|
||||
ClientPaths paths,
|
||||
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(askForPin);
|
||||
|
||||
return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()
|
||||
? new WindowsDeviceKeyStore(paths)
|
||||
: new UnavailableDeviceKeyStore();
|
||||
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new WindowsDeviceKeyStore(paths);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS() && MacDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new MacDeviceKeyStore(paths);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux() && LinuxDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new LinuxDeviceKeyStore(paths, askForPin);
|
||||
}
|
||||
|
||||
return new UnavailableDeviceKeyStore();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
using Velopack;
|
||||
|
||||
@@ -36,6 +37,27 @@ internal static class Program
|
||||
|
||||
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||
|
||||
/*
|
||||
One instance per profile, and nothing above this line is affected by it: the installer's own runs
|
||||
have already exited inside VelopackApp.Run, and the WebView2 environment variable is a property of
|
||||
this process rather than of the application.
|
||||
|
||||
Claimed here rather than after Avalonia is configured, so that a second launch starts nothing — no
|
||||
toolkit, no window, no splash flashing on screen before it thinks better of it. What it does
|
||||
instead is ask the running instance to come forward, which is why this is not simply an exit.
|
||||
|
||||
The handle is held for as long as Main runs, which is as long as the message loop does. See
|
||||
SingleInstance for why it is a file rather than a mutex, and for what the profile scoping buys.
|
||||
*/
|
||||
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||
|
||||
using var claim = SingleInstance.Claim(paths);
|
||||
|
||||
if (claim is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
@@ -69,7 +91,12 @@ internal static class Program
|
||||
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
|
||||
{
|
||||
@@ -87,17 +114,25 @@ internal static class Program
|
||||
|
||||
/// <summary>Used by the designer as well as by <see cref="Main"/>.</summary>
|
||||
/// <remarks>
|
||||
/// <c>WithInterFont</c> registers Inter; it does not make it the default, and until this line the
|
||||
/// application shipped a font it then declined to use — falling back to Segoe UI on Windows and to
|
||||
/// whatever fontconfig offered on Linux. Almost nothing visible moves, because App.axaml sets
|
||||
/// <c>MonoFont</c> on essentially every control that draws text, but the fallback behind those is now
|
||||
/// a font that travels with the build rather than one the machine is assumed to have. The layout
|
||||
/// suite pins the same family, and has to: see HeadlessApp.
|
||||
/// <c>WithInterFont</c> still registers Inter, and it is still what the layout suite pins: see
|
||||
/// HeadlessApp. What changed is which face answers when nothing more specific is asked for. The v5
|
||||
/// design specifies Montserrat, and unlike the substitution this comment used to explain, Montserrat
|
||||
/// now ships embedded in the shell project rather than being requested by a name the machine may not
|
||||
/// have — see DodoSSH.Client.Shell/Assets/Fonts and its csproj entry. Inter stays registered and is
|
||||
/// 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>
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<DodoSshApp>()
|
||||
.UsePlatformDetect()
|
||||
.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();
|
||||
}
|
||||
@@ -15,6 +15,28 @@
|
||||
Showing the last terminal's pane would be a lie, and showing nothing reads as the application having
|
||||
broken, so this says which machine, as whom, and how far along it is.
|
||||
|
||||
── The step list, and why it is amber ───────────────────────────────────────────────────────────────
|
||||
"How far along it is" used to be one line of prose that never changed after the tab was created, which
|
||||
made every slow connection look exactly like every hung one. It is now the five steps of actually
|
||||
getting there, each lit at the moment the handshake reports it — see SshConnectionPhase, which names
|
||||
only the boundaries a client can genuinely observe. A connection that stops therefore stops on a named
|
||||
row, and "the host key is being checked" stops being the same screen as "the host is not answering".
|
||||
|
||||
Amber for the step in flight, and that is the palette's rule rather than an exception to it. Green is
|
||||
what is true and purple is what you can press; a step still happening is neither, and it is precisely
|
||||
the caveat-worth-reading amber exists for — see the remark above Warn in Palette.axaml. Steps behind it
|
||||
go green as they become true, and the one a refusal landed on goes red. Nothing on the list is drawn in
|
||||
the accent, because there is nothing on it to press.
|
||||
|
||||
Nothing here animates, which is the argument the transfer strip makes for its own track in
|
||||
TransfersScreen.axaml, arriving at a screen with more reason to want a spinner. A spinner is furniture
|
||||
invented to fill a state nobody measured; these steps are measured, so the track fills to what has
|
||||
actually finished and then waits there. Waiting is what waiting looks like.
|
||||
|
||||
The list is drawn for both states rather than once per state. A refused connection has the same five
|
||||
rows and the same track — the difference is only that one row is red and the track stops — and drawing
|
||||
it twice would be two templates to keep identical for the sake of a colour.
|
||||
|
||||
It obeys the occlusion rule the whole window obeys: this is Avalonia-drawn content in the WebView's own
|
||||
rectangle, so the shell collapses the terminal while it is up. IsTerminalShowing and IsConnectingShowing
|
||||
are exclusive by construction — a selected tab either has a session or it does not — which is what makes
|
||||
@@ -24,29 +46,138 @@
|
||||
can be laid out by a test: WebView2's adapter refuses the headless session's thread.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
|
||||
<!--
|
||||
A rule per lit state over one quiet default, so that the pending weight is stated once and each state
|
||||
that differs from it is the one line that says how.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepcaption">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The marker beside each caption. Fixed width and centred, because four different characters on a
|
||||
ragged left edge is a list that looks broken; see ConnectionStepViewModel.Mark for which they are.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepmark">
|
||||
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Width" Value="14" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The track over the list. Amber while the attempt is alive and red once it is not, so that the bar says
|
||||
the same thing as the row it stopped on rather than staying the colour of something still being waited
|
||||
for. Chip underneath, matching the transfer strip's track.
|
||||
-->
|
||||
<Style Selector="ProgressBar.steptrack">
|
||||
<Setter Property="Height" Value="5" />
|
||||
<Setter Property="MinHeight" Value="5" />
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="ProgressBar.steptrack.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
</UserControl.Styles>
|
||||
|
||||
<Panel>
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14" MaxWidth="460"
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
||||
Margin="24">
|
||||
|
||||
<!--
|
||||
Which machine, and at what address. Both selectable, for the reason the session column's own address
|
||||
is — see SessionSidebar.axaml: a TextBlock in Avalonia is a drawing of a string rather than a string,
|
||||
and this pair is the one place the shell says what it is dialling *before* anything has answered.
|
||||
That is exactly when the value is wanted somewhere else: in a ticket, in a message to whoever owns
|
||||
the host, in the ssh command being written in the window beside this one. It is also the moment it is
|
||||
least available anywhere else, because there is no prompt yet and may never be one — a refused
|
||||
connection leaves this card holding the only copy on screen.
|
||||
|
||||
Both rather than only the name. They are two lines of one identifier, and a pair where the first can
|
||||
be taken and the second cannot is a worse answer than either.
|
||||
|
||||
The trimming stays, and the tooltips are what make it safe rather than a trap. A drag reaches only
|
||||
the glyphs that were drawn, so a name or an address wearing an ellipsis has to be taken with
|
||||
select-all — Ctrl+A, then Ctrl+C — which takes the whole value, because the ellipsis is a drawing and
|
||||
not a shorter string. The hover says what the whole value is, so nobody copies half of one without
|
||||
having been told there is a half. Same argument, same shape, as the sidebar's.
|
||||
-->
|
||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||
HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding SelectedTab.Label}" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding SelectedTab.Address}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Two states, deliberately different. Waiting is an accent line under the host's name; a refusal is
|
||||
the reason, in the palette's red, because it is the only place the reason will be after the user
|
||||
navigates away from the screen that started the connection.
|
||||
Bound to StepsDone against StepCount rather than to a percentage: five steps and a maximum of five
|
||||
means the bar is a count of things that really finished, and the arithmetic that would turn it into
|
||||
a percentage is exactly the arithmetic that would start inventing one.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="12"
|
||||
Foreground="{StaticResource Accent}" HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" TextAlignment="Center"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}" HorizontalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="7" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="stepmark"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Mark}" />
|
||||
<TextBlock Classes="stepcaption mono"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Caption}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
A refusal is the reason, in the palette's red, because it is the only place the reason will be after
|
||||
the user navigates away from the screen that started the connection. It sits under the list rather
|
||||
than replacing it: which row it stopped on is half the answer and the sentence is the other half.
|
||||
-->
|
||||
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
|
||||
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" TextAlignment="Center"
|
||||
@@ -62,14 +193,36 @@
|
||||
handshake that finishes afterwards is adopted rather than dropped — see
|
||||
MainWindowViewModel.CloseTabAsync. Two buttons rather than one with a converted label, because the
|
||||
two are different decisions and only one of them abandons something still running.
|
||||
-->
|
||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="GIVE UP"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
|
||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="CLOSE TAB"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
Beside each, the logs. The step list is this attempt and the log is every other one, which is the
|
||||
question both a connection taking too long and a connection just refused actually raise — has this
|
||||
machine ever worked. It is the ordinary rail destination reached the ordinary way rather than a
|
||||
second log grown inside this card, and leaving by it does not abandon the handshake: the tab stays
|
||||
in the strip and the card is still here on the way back.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||
<!--
|
||||
TRY AGAIN rather than RECONNECT, because nothing was ever connected: this tab is a handshake that
|
||||
was refused. It runs the same command the disconnect strip's RECONNECT does — the vault's own
|
||||
connect path, into this same tab — and it is worth having here for the reason the commonest
|
||||
refusal is worth retrying at all: a host that was not up yet is a host that will be in a minute.
|
||||
|
||||
Disabled rather than hidden when there is nothing to dial, exactly as the strip's is. See
|
||||
MainWindowViewModel.CanReconnectSelectedTab.
|
||||
-->
|
||||
<Button Classes="ghost" Content="TRY AGAIN" Command="{Binding ReconnectTabCommand}"
|
||||
CommandParameter="{Binding SelectedTab}"
|
||||
IsEnabled="{Binding CanReconnectSelectedTab, FallbackValue=False}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
|
||||
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
|
||||
CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
<Button Classes="ghost" Content="CLOSE TAB" Command="{Binding CloseTabCommand}"
|
||||
CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="DodoSSH.Client.App.Views.DevicePinWindow"
|
||||
Width="420" SizeToContent="Height"
|
||||
CanResize="False" ShowInTaskbar="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource Surface}"
|
||||
Title="DodoSSH">
|
||||
|
||||
<!--
|
||||
The PIN that unseals this machine's device key, on Linux and nowhere else.
|
||||
|
||||
◆ A WINDOW, WHERE EVERY OTHER QUESTION THIS APPLICATION ASKS IS A CARD.
|
||||
|
||||
ConfirmDeleteCard, HostKeyCard and the rest are overlays bound to view-model state, and that is the
|
||||
right shape for a question about the vault: the shell owns the moment and the Android head shows the
|
||||
same one. This is not that kind of question. It belongs to a platform integration — LinuxDeviceKeyStore
|
||||
— and the shell it would otherwise live in is shared with the phone, which has a fingerprint and must
|
||||
never grow a PIN box. Keeping it here keeps DodoSSH.Client.Shell platform-neutral, which is the
|
||||
property IDeviceKeyStore exists to protect.
|
||||
|
||||
◆ AND IT IS DELIBERATELY PLAIN.
|
||||
|
||||
Windows and macOS draw their equivalent themselves — a secure desktop and a system sheet — and this one
|
||||
cannot be either. Dressing it up to look like a system dialog would be teaching people that a
|
||||
convincing DodoSSH PIN box is a system dialog, which is the phishing this platform's design cannot
|
||||
prevent and should not rehearse. It says what it is, it names the application, and it stops there.
|
||||
ADR 0007 records the difference rather than smoothing it over.
|
||||
-->
|
||||
|
||||
<StackPanel Margin="20" Spacing="12">
|
||||
|
||||
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
|
||||
x:Name="Explanation" />
|
||||
|
||||
<!--
|
||||
RevealPassword is off and there is no eye button. A PIN is short enough to retype and this box is
|
||||
open on a screen somebody may be sharing.
|
||||
-->
|
||||
<TextBox x:Name="Pin" PasswordChar="•" PlaceholderText="PIN" FontSize="14" />
|
||||
|
||||
<!--
|
||||
Only when choosing. A mistyped PIN at registration is not recoverable in any useful sense: the key is
|
||||
sealed to it, and the only way back is to withdraw the device and register again. Asking twice costs
|
||||
one box.
|
||||
-->
|
||||
<TextBox x:Name="Confirm" PasswordChar="•" PlaceholderText="PIN again" FontSize="14"
|
||||
IsVisible="False" />
|
||||
|
||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||
x:Name="Problem" IsVisible="False" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||
<Button x:Name="Cancel" Content="CANCEL" />
|
||||
<Button x:Name="Accept" Classes="accent" Content="UNLOCK" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</Window>
|
||||
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