Public Access
Compare commits
88
Commits
8591035170
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f80bded1 | ||
|
|
281f849086 | ||
|
|
25407756c3 | ||
|
|
a763f4b113 | ||
|
|
881562e81b | ||
|
|
93e35a0095 | ||
|
|
b80bf23341 | ||
|
|
8c58e5a558 | ||
|
|
cec73010d3 | ||
|
|
53ff15ba86 | ||
|
|
766fe6aebe | ||
|
|
9f73893e14 | ||
|
|
ccaf7a8e72 | ||
|
|
6fc1e3a7c5 | ||
|
|
8a77b7ca68 | ||
|
|
9755b5f6ae | ||
|
|
afc6a042f1 | ||
|
|
e41eca01a8 | ||
|
|
e96d01aab9 | ||
|
|
7f77539ba6 | ||
|
|
ca081af209 | ||
|
|
890a5f2246 | ||
|
|
8c67fce32c | ||
|
|
0ffd259ccd | ||
|
|
9bc9069425 | ||
|
|
e936ab4646 | ||
|
|
506d2803a2 | ||
|
|
cc8bf37321 | ||
|
|
3f5979d639 | ||
|
|
aaff81272a | ||
|
|
4d1f07f253 | ||
|
|
095774c498 | ||
|
|
3977f68870 | ||
|
|
48ea5e22d5 | ||
|
|
810bc48d3f | ||
|
|
671611a9a0 | ||
|
|
21cf77f64a | ||
|
|
dbf6ce1bcf | ||
|
|
242280ce6b | ||
|
|
de0b5f12ae | ||
|
|
009b35e069 | ||
|
|
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 |
+364
-3
@@ -291,6 +291,7 @@ jobs:
|
|||||||
# so it is not done either. What reaches users is built, installed and walked through Phase 16
|
# so it is not done either. What reaches users is built, installed and walked through Phase 16
|
||||||
# of docs/manual-checks.md by a person first.
|
# of docs/manual-checks.md by a person first.
|
||||||
- name: package the windows desktop client
|
- name: package the windows desktop client
|
||||||
|
id: winpack
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -374,6 +375,93 @@ jobs:
|
|||||||
ls -la "$releases"
|
ls -la "$releases"
|
||||||
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
|
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
|
||||||
|
|
||||||
|
# Handed to the macOS step below rather than worked out again there. The floor logic above
|
||||||
|
# is thirty lines of reasoning about MinVer's pre-first-tag answer, and a second copy of it
|
||||||
|
# is a second thing to keep in step — while two desktop packages built from one commit
|
||||||
|
# carrying different version numbers is precisely the confusion this file spends that
|
||||||
|
# reasoning to avoid.
|
||||||
|
echo "packVersion=$packVersion" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ◆ AND THE macOS BUNDLE IS BUILT HERE, ON LINUX, AND IS ALSO THROWN AWAY.
|
||||||
|
#
|
||||||
|
# Same argument as the Windows step above, one platform along: the failures a release is most
|
||||||
|
# exposed to are the ones only the packager finds, and the person who would otherwise find them
|
||||||
|
# is the one midway through a release on the one Mac that can cut one.
|
||||||
|
#
|
||||||
|
# What this catches that the Windows step cannot: the osx-arm64 restore graph. A native package
|
||||||
|
# that resolves for win-x64 and has no osx-arm64 asset — libsodium and SkiaSharp both ship per
|
||||||
|
# RID — fails here, on every main build, rather than at the first `dotnet publish` of a release
|
||||||
|
# nobody can retry without a Mac.
|
||||||
|
#
|
||||||
|
# ◆ bundle, NOT pack, AND THE DIFFERENCE IS NOT A CHOICE.
|
||||||
|
#
|
||||||
|
# `vpk [osx]` cross-compiling from a non-Mac offers exactly one packaging verb: bundle, which
|
||||||
|
# builds the .app. There is no `[osx] pack` off a Mac, and that is correct rather than a gap —
|
||||||
|
# pack signs with codesign, submits to Apple with notarytool and staples the ticket, all of
|
||||||
|
# which is Apple tooling that exists on no other platform. So this proves the bundle and stops
|
||||||
|
# where the platform does.
|
||||||
|
#
|
||||||
|
# No --plist and no --icon either, deliberately. Both are proved by scripts/release-macos.sh on
|
||||||
|
# the machine that can also check the result; passing a rendered plist here would mean copying
|
||||||
|
# the substitution out of that script to no end, since nothing looks at what this produces.
|
||||||
|
#
|
||||||
|
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
|
||||||
|
#
|
||||||
|
# RUNNER_TEMP, dying with the job. ADR 0013 rule 3 puts the capability to ship somebody a build
|
||||||
|
# on a machine which is not a runner, and an unsigned .app is additionally something no Mac
|
||||||
|
# would open — so publishing it would be handing out a file whose only possible use is confusion.
|
||||||
|
- name: publish and bundle the macos desktop client
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# RestoreLockedMode=false for the RID, exactly as the win-x64 publish above does — see the
|
||||||
|
# long note there for why the committed lock files are deliberately RID-free. This runner's
|
||||||
|
# checkout is thrown away, so the lock files it rewrites go nowhere.
|
||||||
|
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||||
|
--configuration Release --runtime osx-arm64 --self-contained true \
|
||||||
|
-p:RestoreLockedMode=false \
|
||||||
|
--output "$RUNNER_TEMP/osx-arm64"
|
||||||
|
|
||||||
|
# The apphost has no extension on macOS, so this is `DodoSSH` and not `DodoSSH.exe`. Named
|
||||||
|
# rather than globbed, because a publish that produced no apphost at all would otherwise
|
||||||
|
# bundle happily and produce an .app that launches nothing.
|
||||||
|
if [ ! -s "$RUNNER_TEMP/osx-arm64/DodoSSH" ]; then
|
||||||
|
echo "The osx-arm64 publish produced no apphost." >&2
|
||||||
|
ls -la "$RUNNER_TEMP/osx-arm64" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bundles="$RUNNER_TEMP/osx-bundle"
|
||||||
|
|
||||||
|
# The quotes around [osx] are load-bearing, exactly as they are on '[win]' above: unquoted,
|
||||||
|
# the shell reads it as a glob matching any one of o, s and x.
|
||||||
|
dotnet vpk '[osx]' bundle \
|
||||||
|
--skip-updates \
|
||||||
|
--packId DodoSSH.Desktop \
|
||||||
|
--packVersion '${{ steps.winpack.outputs.packVersion }}' \
|
||||||
|
--packDir "$RUNNER_TEMP/osx-arm64" \
|
||||||
|
--packTitle DodoSSH \
|
||||||
|
--packAuthors DodoTech \
|
||||||
|
--mainExe DodoSSH \
|
||||||
|
--bundleId dev.dodotech.dodossh \
|
||||||
|
--runtime osx-arm64 \
|
||||||
|
--channel osx \
|
||||||
|
--outputDir "$bundles"
|
||||||
|
|
||||||
|
# Asked for rather than inferred from an exit code, for the reason the Windows step gives.
|
||||||
|
# The Info.plist is the specific thing worth naming: a bundle missing it is a directory
|
||||||
|
# macOS will not treat as an application at all, and it is the one part of the .app that
|
||||||
|
# vpk composes rather than copies.
|
||||||
|
app="$bundles/DodoSSH.Desktop.app"
|
||||||
|
if [ ! -s "$app/Contents/Info.plist" ]; then
|
||||||
|
echo "vpk reported success and there is no Info.plist at $app/Contents/Info.plist." >&2
|
||||||
|
find "$bundles" -maxdepth 3 >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
|
||||||
|
|
||||||
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
||||||
# server through Testcontainers and runs the API as a child process — so it needs a
|
# server through Testcontainers and runs the API as a child process — so it needs a
|
||||||
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
||||||
@@ -787,6 +875,278 @@ jobs:
|
|||||||
|
|
||||||
echo "Published nightly $VERSION with$names"
|
echo "Published nightly $VERSION with$names"
|
||||||
|
|
||||||
|
desktop:
|
||||||
|
name: desktop nightly
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# ◆ 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
|
||||||
|
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
||||||
|
echo "The release was created but $name is not on it:" >&2
|
||||||
|
printf '%s\n' "$published" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Published nightly desktop $VERSION as $TAG"
|
||||||
|
|
||||||
image:
|
image:
|
||||||
name: api image
|
name: api image
|
||||||
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
||||||
@@ -1067,9 +1427,10 @@ jobs:
|
|||||||
if: always() && github.event_name != 'pull_request'
|
if: always() && github.event_name != 'pull_request'
|
||||||
run: docker logout registry-docker.dodotech.cloud
|
run: docker logout registry-docker.dodotech.cloud
|
||||||
|
|
||||||
# There is no job here that publishes the desktop client, and there is not going to be one. It is built
|
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
|
||||||
# and packaged here — the two steps at the end of the build job — and what is withheld is only the
|
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
||||||
# upload.
|
# install directory and profile — and the distance between those two sentences is the whole design. What
|
||||||
|
# the build job does for the release channel is prove it still builds and packs; the upload is withheld.
|
||||||
#
|
#
|
||||||
# ◆ ONE REASON, WHERE THIS ONCE CLAIMED TWO, AND THE SECOND WAS NOT TRUE.
|
# ◆ ONE REASON, WHERE THIS ONCE CLAIMED TWO, AND THE SECOND WAS NOT TRUE.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE TRIMMER'S VERSION IS PINNED HERE BECAUSE OTHERWISE THE LOCK FILES ARE NOT LOCKED.
|
||||||
|
|
||||||
|
Microsoft.NET.ILLink.Tasks is not referenced by anything in this repository. The SDK adds it
|
||||||
|
on its own to any project that sets IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
|
||||||
|
DodoSSH.Crypto do, and the Android head gets it from trimming being on by default there — and
|
||||||
|
the version it asks for is whatever the running SDK happens to bundle. That version lives in
|
||||||
|
the SDK's own Microsoft.NETCoreSdk.BundledVersions.props, as a KnownILLinkPack item.
|
||||||
|
|
||||||
|
Which makes it a dependency whose version is a property of the toolchain rather than of this
|
||||||
|
repository, and that is the whole problem: packages.lock.json records it as a Direct reference
|
||||||
|
with a requested range, so the lock file silently means "whichever SDK last ran a restore".
|
||||||
|
global.json says rollForward: latestMinor, so CI's setup-dotnet installs the newest 10.x SDK
|
||||||
|
that exists on the day it runs. The moment .NET ships a servicing release, CI's SDK asks for a
|
||||||
|
version the committed lock files do not have, and the locked-mode restore in ci.yml fails with
|
||||||
|
NU1004 before a single file is compiled.
|
||||||
|
|
||||||
|
That is not hypothetical. It closed the whole pipeline: main's run 125 and every open pull
|
||||||
|
request went red together, on
|
||||||
|
|
||||||
|
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||||
|
from [10.0.10, ) to [10.0.11, ).
|
||||||
|
|
||||||
|
with nothing in any of those commits touching a package. .NET had shipped SDK 10.0.400, which
|
||||||
|
bundles ILLink 10.0.11 where 10.0.302 bundled 10.0.10, and setup-dotnet installed it the next
|
||||||
|
time anything ran.
|
||||||
|
|
||||||
|
Worse than the outage is the shape of the repair without this pin. Regenerating the lock files
|
||||||
|
holds only until the next servicing release, and it cannot be done from a machine whose newest
|
||||||
|
SDK is older than the runner's: a restore on 10.0.302 writes 10.0.10 straight back and re-breaks
|
||||||
|
CI, so the recorded version becomes a fact about whoever ran restore last rather than about this
|
||||||
|
repository. That is exactly the state locking exists to prevent, and it is not a hypothetical
|
||||||
|
either — every SDK installed on the machine this pin was written on tops out at 10.0.302.
|
||||||
|
|
||||||
|
Pinning it makes the recorded version a decision this repository made, reviewable in a diff
|
||||||
|
like every other version in Directory.Packages.props, and identical on every machine whatever
|
||||||
|
SDK it has. Moving it is then a deliberate edit here plus a regenerated lock file, which is the
|
||||||
|
same ceremony any other dependency bump gets.
|
||||||
|
|
||||||
|
It is an Update on the SDK's item rather than a PackageVersion in Directory.Packages.props, and
|
||||||
|
it has to be: the reference is implicit, so the SDK supplies the version itself and central
|
||||||
|
package management never gets asked. ProcessFrameworkReferences reads @(KnownILLinkPack) when
|
||||||
|
it runs, which is why this lives in Directory.Build.targets — the item does not exist yet while
|
||||||
|
Directory.Build.props is being evaluated.
|
||||||
|
|
||||||
|
Keep this within a patch or two of the runtime the SDK ships. It is the trimming analyzer and
|
||||||
|
the ILLink task, so a small skew is harmless, but a version far behind the framework being
|
||||||
|
analysed is a real way to miss a trim warning.
|
||||||
|
-->
|
||||||
|
<Target Name="PinTheILLinkPackVersion" BeforeTargets="ProcessFrameworkReferences">
|
||||||
|
<!--
|
||||||
|
Inside a target, and not for tidiness. The SDK ships one KnownILLinkPack per target framework
|
||||||
|
and they all share the identity "Microsoft.NET.ILLink.Tasks", so the TargetFramework metadata
|
||||||
|
is the only thing telling net10.0's entry from net8.0's. A condition on %(...) is item
|
||||||
|
batching, which MSBuild permits in a target and rejects during evaluation with MSB4191 — so
|
||||||
|
an ItemGroup at the top of this file cannot express "only the net10.0 one" at all, and the
|
||||||
|
unconditioned Update it would have to become rewrites every framework's entry.
|
||||||
|
-->
|
||||||
|
<ItemGroup>
|
||||||
|
<KnownILLinkPack Update="Microsoft.NET.ILLink.Tasks"
|
||||||
|
Condition="'%(TargetFramework)' == 'net10.0'"
|
||||||
|
ILLinkPackVersion="10.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -80,6 +80,8 @@ docs/platform-flags.md what differs off Windows, and the gotchas that have c
|
|||||||
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
||||||
docs/android-port.md the Android head: what was decided, what is built, what is left
|
docs/android-port.md the Android head: what was decided, what is built, what is left
|
||||||
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
|
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
|
||||||
|
release-macos.sh — the same, signed and notarized, on a Mac
|
||||||
|
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
||||||
@@ -152,8 +154,49 @@ reinstalling asks for your passphrase rather than starting over. Use **Sign out*
|
|||||||
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
|
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
|
||||||
this machine's device key from your account.
|
this machine's device key from your account.
|
||||||
|
|
||||||
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
|
## Installing on macOS
|
||||||
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
|
|
||||||
|
A `.pkg` on the same release page, for Apple Silicon. Everything above about where a client may come from,
|
||||||
|
about the update check and about uninstalling applies unchanged; what differs is worth three short
|
||||||
|
paragraphs.
|
||||||
|
|
||||||
|
**It is signed and notarized, so there is no warning to click past.** That is not generosity — macOS refuses
|
||||||
|
to open an un-notarized download outright rather than warning about it, so unlike the Windows build there
|
||||||
|
was never an unsigned option. If you *do* see "cannot be opened because Apple cannot check it for malicious
|
||||||
|
software", the file did not come from the project's release page, and that is worth taking literally.
|
||||||
|
|
||||||
|
**Apple Silicon only for now.** An Intel package is a small amount of work and no one here has an Intel Mac
|
||||||
|
to check it on, and this project does not ship desktop builds nobody has run — see
|
||||||
|
[docs/manual-checks.md](docs/manual-checks.md). Under Rosetta the arm64 build will not run; there is no
|
||||||
|
graceful version of that, and the honest answer is that the platform is not covered yet.
|
||||||
|
|
||||||
|
**Touch ID can stand in for your passphrase**, on a Mac with a Secure Enclave. The key that unwraps your
|
||||||
|
device key is generated inside the enclave and never leaves it, and the enclave — not DodoSSH — is what
|
||||||
|
requires your fingerprint or login password before it will use it. Cancel the prompt and you get the
|
||||||
|
passphrase screen, always. The application lives at `/Applications/DodoSSH.Desktop.app` and your vault cache
|
||||||
|
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
|
||||||
|
first never touches the second.
|
||||||
|
|
||||||
|
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
|
||||||
|
`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
|
||||||
|
not only that the runners are Linux.
|
||||||
|
|
||||||
|
### The nightly desktop build
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Installing it means trusting everyone who can write a release on this repository, including CI.** The
|
||||||
|
updater applies what the feed serves without verifying a signature, so anyone who can change a workflow
|
||||||
|
file here can put a build on every nightly machine. That is an acceptable trade for a build you are trying
|
||||||
|
and not one for a build holding your infrastructure credentials, which is the entire reason the two
|
||||||
|
channels exist and cannot see each other. The release build refuses prereleases and reads a different feed,
|
||||||
|
so nothing published here can ever reach it. Same arrangement, and same reasoning, as the phone's nightly —
|
||||||
|
[ADR 0014](docs/adr/0014-android-updates.md), and [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md)
|
||||||
|
decision 9.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
@@ -407,8 +450,28 @@ The two vaults are encrypted under different keys, so a move is a re-seal into o
|
|||||||
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
|
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
|
||||||
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
|
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
|
||||||
What a move cannot do is reach a machine that has already synced the host, which is the same limit
|
What a move cannot do is reach a machine that has already synced the host, which is the same limit
|
||||||
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
|
everything else about revocation has. Buckets take theirs from a standing "new items go to" picker on the
|
||||||
to" picker on the Keychain screen and cannot be moved yet.
|
Keychain screen and cannot be moved yet.
|
||||||
|
|
||||||
|
**The move asks whether the key comes too**, because that is the half a host's move could not settle on its
|
||||||
|
own. A binding resolves across every vault you can read, so the moved host goes on working for *you* either
|
||||||
|
way — but the people you have just shared it with hold one vault's key, and a host whose key stayed in your
|
||||||
|
personal vault is one they cannot connect with. The tick box beside the picker is unticked, and stays that
|
||||||
|
way on purpose: moving a key into a team's vault hands it to everybody who holds that key, and a disclosure
|
||||||
|
is chosen rather than defaulted into. Under it is the count of everything else that authenticates with that
|
||||||
|
key, which is what makes the answer decidable — a key twenty machines use is a different decision from one
|
||||||
|
nothing else touches. Left unticked, the sentence afterwards names the key that is now outside the
|
||||||
|
destination. A key the host only *inherits* from its group counts too, and is written onto the host on the
|
||||||
|
way across: the group stays behind, so a host that arrived naming nothing would authenticate with nothing.
|
||||||
|
|
||||||
|
**A key or a password can also be moved on its own** — MOVE beside EDIT and DELETE on the Keychain screen,
|
||||||
|
on both heads, for keys and passwords only. It is the same re-seal and tombstone, and it takes a new id in
|
||||||
|
the destination, so **everything that named it is re-aimed at where it went**: every host bound to it and
|
||||||
|
every group lending it as a default, across every vault you can write to. Without that the move would be a
|
||||||
|
deletion with extra steps, since a host bound to something its vault no longer holds refuses to connect
|
||||||
|
rather than falling back to a typed password. Anything that cannot be rewritten here — an item from a newer
|
||||||
|
client, or one in a vault you can only read — is left naming the old item and is counted in the sentence
|
||||||
|
afterwards. The panel says what points at the key before you press it, not after.
|
||||||
|
|
||||||
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
|
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
|
||||||
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
|
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
|
||||||
@@ -849,8 +912,18 @@ keychain plus a terminal — and the spike that gates all of it.
|
|||||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
||||||
[Installing on Windows](#installing-on-windows) for what a user sees.
|
[Installing on Windows](#installing-on-windows) for what a user sees.
|
||||||
|
|
||||||
Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the
|
**The macOS half is built on the same machinery**, and signed from the start because Gatekeeper leaves no
|
||||||
first release aimed at strangers), and macOS and Linux packaging.
|
choice: `scripts/release-macos.sh` publishes, signs every native library, notarizes with Apple and staples
|
||||||
|
the ticket before it will hand anything over, and refuses to upload until a person has installed it. The
|
||||||
|
device key is held in the Secure Enclave behind Touch ID. CI publishes `osx-arm64` and builds the `.app`
|
||||||
|
on every main build to prove it still packages, and uploads nothing. See
|
||||||
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
|
||||||
|
[Installing on macOS](#installing-on-macos).
|
||||||
|
|
||||||
|
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
|
||||||
|
certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the
|
||||||
|
manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
|
||||||
|
everything above is verified only as far as the bundle.
|
||||||
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
What the hardened runtime has to be asked to relax before a .NET application will run under it.
|
||||||
|
|
||||||
|
The hardened runtime is not optional: notarization refuses a Developer ID submission without it,
|
||||||
|
and Gatekeeper refuses an un-notarized download. So every entitlement below is the price of being
|
||||||
|
distributable at all, and each one is a hole in a wall that is otherwise worth having. They are
|
||||||
|
listed one at a time, with what breaks without each, because the temptation when notarization
|
||||||
|
fails at eleven at night is to paste in a longer list from somewhere and stop thinking.
|
||||||
|
|
||||||
|
◆ WHAT IS DELIBERATELY NOT HERE.
|
||||||
|
|
||||||
|
com.apple.security.app-sandbox. Developer ID distribution outside the App Store does not require
|
||||||
|
the sandbox, and turning it on would break the product outright: the terminal's data plane is a
|
||||||
|
loopback WebSocket (see DodoSSH.Client.Terminal/TerminalDataPlane.cs), and a sandboxed process
|
||||||
|
needs com.apple.security.network.server to listen at all, plus network.client to reach any host
|
||||||
|
the user asks for. This is the same shape of decision as ruling out MSIX on Windows, which was
|
||||||
|
ruled out for the same loopback reason — docs/platform-flags.md.
|
||||||
|
|
||||||
|
com.apple.security.cs.debugger. Would let this process attach to others. Nothing here debugs
|
||||||
|
anything, and it is the entitlement most worth not having.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!--
|
||||||
|
CoreCLR compiles IL to machine code at runtime and then executes the pages it just wrote. The
|
||||||
|
hardened runtime's default is that no page is both writable and executable, so without this the
|
||||||
|
process does not start — it dies during runtime initialisation, before any of this application's
|
||||||
|
code runs, which means before anything exists that could report it.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The broader form of the same permission, and it is needed as well as allow-jit rather than
|
||||||
|
instead of it. allow-jit covers pages mapped through the MAP_JIT convention; CoreCLR also
|
||||||
|
allocates executable memory outside that path — stubs, precode, and the write-xor-execute
|
||||||
|
fallback it uses when MAP_JIT is unavailable. With only the first, startup gets further and
|
||||||
|
still fails.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Library validation requires every loaded dylib to be signed by the same team as the main
|
||||||
|
binary. This bundle carries native libraries built by other people — libsodium, libSkiaSharp,
|
||||||
|
libHarfBuzzSharp, libe_sqlite3, libAvaloniaNative — and the release script signs each of them
|
||||||
|
with this Developer ID, which would in principle satisfy validation.
|
||||||
|
|
||||||
|
It is disabled anyway, and the reason is the updater. Velopack replaces the bundle in place and
|
||||||
|
relaunches it, and the process doing the replacing is not always signed by the same team as the
|
||||||
|
process being replaced during the changeover. Leaving validation on makes the failure mode of a
|
||||||
|
bad update "the application will not start", with no way to recover except a reinstall the user
|
||||||
|
would have to be told about through some other channel.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The runtime reads DYLD_ variables while resolving its own native dependencies, and Velopack's
|
||||||
|
update path sets them. Without this the hardened runtime strips them silently and the failure
|
||||||
|
surfaces later as a library that cannot be found, naming a file that is plainly present.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
The Info.plist for the macOS bundle, with the version left as a placeholder.
|
||||||
|
|
||||||
|
◆ A TEMPLATE RATHER THAN A FILE, BECAUSE vpk COPIES A CUSTOM PLIST VERBATIM.
|
||||||
|
|
||||||
|
Measured, not assumed: `vpk [osx] bundle --plist` performs no substitution of any kind. It logs
|
||||||
|
"Bundle using provided Info.plist" and copies the bytes. That is also why it refuses --plist and
|
||||||
|
--bundleId together — with a plist supplied, every key is the caller's problem.
|
||||||
|
|
||||||
|
So a committed Info.plist would carry whatever version it was written with into every release
|
||||||
|
afterwards, and the failure is quiet in the worst way: Velopack's own release index would carry the
|
||||||
|
right version, the updater would compare correctly and update correctly, and only the About window,
|
||||||
|
Finder's Get Info panel and any crash report would claim the build was something else. Nobody
|
||||||
|
reads those on the day of a release. scripts/release-macos.sh substitutes @VERSION@ into a copy
|
||||||
|
and passes that.
|
||||||
|
|
||||||
|
◆ WHY A CUSTOM PLIST AT ALL, WHEN vpk WRITES A PERFECTLY GOOD ONE.
|
||||||
|
|
||||||
|
Three keys it does not write, each of which is a real defect without it:
|
||||||
|
|
||||||
|
CFBundleDisplayName The bundle on disk is DodoSSH.Desktop.app, because the pack id must not
|
||||||
|
be DodoSSH — see scripts/release-macos.sh for the directory collision
|
||||||
|
that rule prevents. On Windows the pack id is invisible; on macOS it
|
||||||
|
names the thing in /Applications and in the Dock. This key is what puts
|
||||||
|
"DodoSSH" back in front of a person while the bundle keeps the id.
|
||||||
|
|
||||||
|
LSMinimumSystemVersion Without it macOS will happily launch this on a release the runtime was
|
||||||
|
never built for, and the user gets a dyld crash rather than a sentence.
|
||||||
|
|
||||||
|
NSHumanReadableCopyright Shown in the About panel. Absent, the panel shows a blank line.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!--
|
||||||
|
CFBundleName is what the menu bar shows and is capped at 15 characters by convention;
|
||||||
|
CFBundleDisplayName is what Finder and the Dock show. Both say DodoSSH, and the bundle
|
||||||
|
directory does not. See the note above.
|
||||||
|
-->
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>DodoSSH</string>
|
||||||
|
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>DodoSSH</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Reverse-DNS under the domain this project actually controls. It is the identity Gatekeeper,
|
||||||
|
the notary service and the keychain all key off, so it is as irreversible as the Windows pack
|
||||||
|
id: changing it makes an update a different application, and it orphans anything the previous
|
||||||
|
identifier stored — including the Secure Enclave key MacDeviceKeyStore holds, which is scoped
|
||||||
|
to this identifier and cannot be migrated because its whole point is that it never leaves the
|
||||||
|
enclave.
|
||||||
|
-->
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>dev.dodotech.dodossh</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The apphost the publish produced, named for the product by <AssemblyName> in the csproj rather
|
||||||
|
than for the project. Must match --mainExe or the bundle launches nothing.
|
||||||
|
-->
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>DodoSSH</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Both version keys take the numeric core only — 1.2.3 and never 1.2.3-rc.1 — because Apple
|
||||||
|
defines them as one to three dot-separated integers and notarization rejects what it cannot
|
||||||
|
parse. The full version, prerelease suffix and all, is in Velopack's release index, and that
|
||||||
|
is the one the updater compares. These two are for Finder and for Gatekeeper.
|
||||||
|
|
||||||
|
They are the same value rather than the usual marketing/build split, because there is no build
|
||||||
|
counter here that a release does not already bump.
|
||||||
|
-->
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>@VERSION@</string>
|
||||||
|
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>@VERSION@</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The file name inside Contents/Resources, which is where --icon puts it. With a custom plist
|
||||||
|
nothing rewrites this key, so a rename of the asset that forgets this line produces a bundle
|
||||||
|
showing the generic application icon and no error anywhere.
|
||||||
|
-->
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>dodossh.icns</string>
|
||||||
|
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
12.0, and it is read off the binaries rather than off a support matrix. The apphost and
|
||||||
|
libcoreclr.dylib in a net10.0 osx-arm64 publish both carry LC_BUILD_VERSION with minos 12.0.0,
|
||||||
|
so 12.0 is the oldest release these bytes are built to load on.
|
||||||
|
|
||||||
|
Microsoft's *support* statement for .NET 10 is higher than this, and that difference is
|
||||||
|
deliberate rather than overlooked: this key decides whether macOS refuses to launch the app at
|
||||||
|
all, and refusing on a release where it would in fact have run is the worse of the two errors.
|
||||||
|
A user on an unsupported-but-working macOS gets the application; the support matrix governs
|
||||||
|
what gets fixed if it misbehaves there, which is a different question.
|
||||||
|
-->
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>12.0</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Without this the window is drawn at 1x and scaled up, which on a Retina display turns the
|
||||||
|
terminal — the one surface in this application that is nothing but small text — into a blur.
|
||||||
|
-->
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
False, and stated rather than left out. An agent application has no Dock icon and no menu bar;
|
||||||
|
this one is an ordinary windowed application and the default is already false, but the key
|
||||||
|
being absent is indistinguishable from somebody having removed it.
|
||||||
|
-->
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<false/>
|
||||||
|
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© DodoTech. MIT licensed.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,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.
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# ADR 0007 — What protects the device key on Windows
|
# ADR 0007 — What protects the device key on the desktop
|
||||||
|
|
||||||
**Status:** accepted, 2026-07-30
|
**Status:** accepted, 2026-07-30
|
||||||
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
|
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
|
||||||
@@ -141,6 +141,14 @@ would have become false under DPAPI alone. A gesture is still something the atta
|
|||||||
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
||||||
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
||||||
is therefore required, not a nicety.
|
is therefore required, not a nicety.
|
||||||
|
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
|
||||||
|
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
|
||||||
|
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
|
||||||
|
platform's rather than the process's — which is the entire point of the 2026-07-30 amendment above, and
|
||||||
|
the thing a self-drawn prompt over a protected file would fail to be. The mechanical differences are
|
||||||
|
incidental: P-256 with ECIES because the enclave holds no other kind of key, and no prompt when sealing
|
||||||
|
because the public half needs no consent. See docs/platform-flags.md for the three ordinary Macs where the
|
||||||
|
probe answers no, one of which is every unsigned development build.
|
||||||
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
|
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
|
||||||
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
|
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
|
||||||
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
|
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
|
||||||
|
|||||||
@@ -228,6 +228,94 @@ changes.
|
|||||||
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
||||||
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
||||||
|
|
||||||
|
**This rule is Windows-only, and macOS gets the opposite one.** See decision 10: there is no "unsigned for
|
||||||
|
now" available on that platform at any price, because Gatekeeper refuses rather than warns.
|
||||||
|
|
||||||
|
### 10. macOS is a second desktop platform on the same machinery, signed from the start
|
||||||
|
|
||||||
|
The macOS head is the same application, the same Velopack, and the same two-phase person-run release. Four
|
||||||
|
things differ, and each is forced rather than chosen.
|
||||||
|
|
||||||
|
**Signing is a precondition, not an improvement.** Decision 8's whole argument — one dialog per user per
|
||||||
|
lifetime, buy a certificate when a stranger is invited to install — has no macOS equivalent. An
|
||||||
|
un-notarized download is refused outright, so the Developer ID certificate and the notarization round trip
|
||||||
|
are the price of the package existing. `scripts/release-macos.sh` therefore refuses to run without the
|
||||||
|
signing identities, where the Windows script refuses nothing.
|
||||||
|
|
||||||
|
**The channels are `osx` and `osx-nightly`, and they are separate for decision 9's reason.** Four channels
|
||||||
|
now publish to one repository, and the only thing keeping a Mac from being offered a Windows package is
|
||||||
|
that it never reads that index. The macOS nightly channel is named and has no publisher: CI builds and
|
||||||
|
bundles the macOS head to prove it still builds, and uploads nothing, exactly as it does for the Windows
|
||||||
|
release channel.
|
||||||
|
|
||||||
|
**The pack id is shared with Windows, and on macOS it is visible.** vpk names the bundle after the pack id,
|
||||||
|
so `/Applications` holds `DodoSSH.Desktop.app`. Decision 2's reasoning applies with more force here rather
|
||||||
|
than less: a pack id of `DodoSSH` would put Velopack's install root on `~/Library/Application
|
||||||
|
Support/DodoSSH`, which is `ClientPaths.DataDirectory`, and an uninstall would take the user's un-synced
|
||||||
|
outbox with it. `CFBundleDisplayName` puts the product name back in front of a person; the directory keeps
|
||||||
|
the id.
|
||||||
|
|
||||||
|
**arm64 only, because the check is the scarce thing.** Velopack keys a channel to one architecture, and an
|
||||||
|
Intel package would be the only artefact in this repository reaching users without somebody having walked
|
||||||
|
Phase 18 against it. The engineering for a second channel is small and is described in the release script;
|
||||||
|
what is missing is an Intel Mac to verify on, and shipping blind is the thing this project's manual-check
|
||||||
|
discipline exists to refuse.
|
||||||
|
|
||||||
|
**And one thing that does not differ, which is worth saying because it is the expensive half.** The
|
||||||
|
capability to publish still lives on a person's machine and never in CI. Notarization does not change that:
|
||||||
|
Apple's ticket says this build came from this developer account, and says nothing about whether the build
|
||||||
|
should have been made. Velopack clients still apply what their feed serves. Rule 3 is untouched.
|
||||||
|
|
||||||
|
### 9. There is a second desktop channel, published by CI, and it is a second application
|
||||||
|
|
||||||
|
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
|
||||||
|
none, which left the two heads with different answers to the same question — *how does somebody try what
|
||||||
|
is on main?* — for no reason other than the order the work happened in. This is the desktop's answer, and
|
||||||
|
it is the phone's arrangement with one difference that changes how much of it has to be built.
|
||||||
|
|
||||||
|
**Android gets the separation from the platform. Windows does not.** Two Android channels cannot replace
|
||||||
|
one another because the installer refuses a package signed by a different key; a mistake there is loud.
|
||||||
|
Velopack applies what its feed serves and verifies no signature, so on this head the separation is entirely
|
||||||
|
construction:
|
||||||
|
|
||||||
|
| | release | nightly |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| pack id | `DodoSSH.Desktop` | `DodoSSH.Desktop.Nightly` |
|
||||||
|
| Velopack channel | `win` | `win-nightly` |
|
||||||
|
| feed | newest non-prerelease release | the rolling `nightly-desktop` prerelease |
|
||||||
|
| profile | `%LOCALAPPDATA%\DodoSSH` | `%LOCALAPPDATA%\DodoSSH.Nightly` |
|
||||||
|
| cut by | a person, from a `v*` tag | CI, on every push to main |
|
||||||
|
|
||||||
|
Four separations rather than one, because each closes a different door. The pack id decides the install
|
||||||
|
directory and what an installed client matches an update against, so it is what makes a nightly install
|
||||||
|
*beside* rather than *over*. The channel names the index file on the feed, so neither build ever reads the
|
||||||
|
other's — and the release channel additionally refuses prereleases, which is belt and braces in the one
|
||||||
|
direction that matters: an unsigned CI build must never reach a machine somebody is trusting with their
|
||||||
|
credentials. The profile is the one that is easy to skip and would hurt most: the cache schema is migrated
|
||||||
|
on every launch, before unlock, so a shared profile means a nightly quietly upgrading a database the
|
||||||
|
release build then opens.
|
||||||
|
|
||||||
|
**The prerelease flag is also how the two heads stay out of each other's way.** The phone's release channel
|
||||||
|
reads `releases/latest`, which skips prereleases. A desktop nightly published as a stable release would
|
||||||
|
become the newest release in this repository, and every phone on the release channel would start failing
|
||||||
|
its check against a release carrying no Android manifest. The nightly is published with `--pre` for its own
|
||||||
|
sake and for that one.
|
||||||
|
|
||||||
|
**What this costs, stated where somebody will read it before installing:** whoever can write a release on
|
||||||
|
this repository can put a build on every nightly desktop, because the client applies what the feed serves
|
||||||
|
without verifying a signature. That set includes CI and therefore everyone who can change a workflow file.
|
||||||
|
It is the same trade [ADR 0014](0014-android-updates.md) accepted for the phone's nightly, and it is
|
||||||
|
acceptable for the same reason and only that reason: this is not the channel anybody's real credentials are
|
||||||
|
on. Rule 3 is untouched — the release channel still has no job, no token and no runner.
|
||||||
|
|
||||||
|
**The version gets a floor, on this channel only.** MinVer answers `0.0.0-alpha.0.N` until the first `v*`
|
||||||
|
tag and `vpk` refuses to pack anything below `0.0.1`, so the nightly job lifts the patch digit and keeps
|
||||||
|
the prerelease height. It is applied through `MinVerVersionOverride`, which moves the assembly version too
|
||||||
|
— packing a number the assemblies disagree with would put one version in the installer and another on the
|
||||||
|
preferences screen, which is the screen somebody reads when asked which nightly they are running. Ordering
|
||||||
|
across the floor holds: heights rise within a floored version, and the first real tag moves past all of
|
||||||
|
them. The release script gets no floor and must not have one; there, `0.0.0` should be refused.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
|
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ is the entire reason there are two.
|
|||||||
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
|
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
|
||||||
caches are encrypted under keys held by two package identities the platform keeps apart.
|
caches are encrypted under keys held by two package identities the platform keeps apart.
|
||||||
|
|
||||||
|
◆ **The desktop has since taken this arrangement, and it had to build what Android is given.**
|
||||||
|
[ADR 0013](0013-desktop-distribution-and-updates.md) decision 9 is this ADR applied to Windows: a nightly
|
||||||
|
CI publishes from main, installing beside the release build rather than over it. The difference worth
|
||||||
|
carrying back here is that every separation this ADR gets from the platform — different package identity,
|
||||||
|
signature-checked updates, a per-app data directory — is on Windows a thing somebody had to choose and can
|
||||||
|
therefore undo. The paragraph above about the attack surface applies there word for word, with one line
|
||||||
|
removed: on the desktop the packages are not signed at all, so the feed is the only thing standing between
|
||||||
|
a nightly install and an arbitrary build.
|
||||||
|
|
||||||
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
|
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
|
||||||
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
|
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
|
||||||
that an installed copy can now learn a newer one exists.
|
that an installed copy can now learn a newer one exists.
|
||||||
|
|||||||
+89
-6
@@ -30,7 +30,10 @@ verified is that it compiles, links, packages, and carries the right natives.
|
|||||||
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
||||||
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
|
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
|
||||||
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
|
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
|
||||||
with the foreground service now counting transfers as well as shells.
|
with the foreground service now counting transfers as well as shells — and, since, an idle-but-connected
|
||||||
|
Files session as well, which a transfer count alone was blind to. *Corrected the same round:* the service's
|
||||||
|
other half — a shell's own opening — had never been wired to anything at all, so a shell survived only for
|
||||||
|
as long as the app stayed foreground; see [Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service).
|
||||||
|
|
||||||
**What was actually checked**, so the rest can be read with the right amount of trust:
|
**What was actually checked**, so the rest can be read with the right amount of trust:
|
||||||
|
|
||||||
@@ -235,6 +238,37 @@ The parts that are definitely different are the on-screen keyboard, and the fact
|
|||||||
needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an
|
needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an
|
||||||
accessory key row for this. That is UI work, not porting.
|
accessory key row for this. That is UI work, not porting.
|
||||||
|
|
||||||
|
> **⚠️ Corrected by the build. The data plane assumed a renderer that attaches once and lives forever, and
|
||||||
|
> that assumption is WebView2's truth, not Android's.** Desktop's WebView2 process starts with the window and
|
||||||
|
> dies with it; `TerminalDataPlane` was written to that reality — one socket, attached once,
|
||||||
|
> `Interlocked.Exchange`-guarded against a second attach ever happening at all. On a phone the WebView's own
|
||||||
|
> renderer process is a separate thing from the app process the foreground service above is keeping alive,
|
||||||
|
> and Android kills *that* independently — under memory pressure, or simply for being backgrounded — with no
|
||||||
|
> foreground service able to save it. The page then reloads with a fresh socket, and three things broke on
|
||||||
|
> that reload before this was found: the second attach was refused outright (`409 Conflict`), because a
|
||||||
|
> second valid upgrade could only mean a bug or a hostile second process, never our own page coming back; a
|
||||||
|
> send into the dead first socket threw, and that exception unwound `TerminalSessionPump`'s flush loop,
|
||||||
|
> freezing the still-live shell behind it — `LiveSessionCount` kept counting a session nothing would ever
|
||||||
|
> drain again; and every byte sent while no page was attached had already spent flow-control credit that no
|
||||||
|
> acknowledgement could ever return, so a session outliving 256 KiB of output into a dead page stalled for
|
||||||
|
> good regardless of the other two. Waiting for the old socket to notice it was dead and close on its own
|
||||||
|
> was never going to be enough either — a killed renderer sends no TCP FIN, so the old receive loop could sit
|
||||||
|
> unaware for the whole 30-second keepalive.
|
||||||
|
>
|
||||||
|
> Fixed as a takeover rather than a guard: a second valid upgrade — origin, token and subprotocol all
|
||||||
|
> checked exactly as before — now displaces whatever socket was attached instead of being refused, since
|
||||||
|
> only this app's own page ever knows the token, so a second valid attach *is* that page, back again.
|
||||||
|
> `TerminalDataPlane.SendAsync` no longer lets a dead-socket send escape as a fault; it reads as "nobody
|
||||||
|
> listening," same as no socket being attached at all. `TerminalWorkspace` resets each live session's credit
|
||||||
|
> window on every attach and resends its `SessionOpened` frame, flagged as a replay, so the fresh page
|
||||||
|
> rebuilds the pane and the pump stops waiting on an acknowledgement that was never coming. And
|
||||||
|
> `terminal.js`'s socket now retries itself, forever, with backoff, instead of reporting the connection
|
||||||
|
> failed and stopping — the page dies with the app anyway, so there is no case where retrying is the wrong
|
||||||
|
> call. What is **not** recovered, and says so rather than pretending otherwise: scrollback across a page
|
||||||
|
> reload. It lived in the page's own DOM, and a reloaded page is a new DOM. The replay banner — *"the view
|
||||||
|
> reconnected; earlier output stayed on the host"* — is that honesty put where the person looking at the
|
||||||
|
> terminal will actually read it, not buried in a log.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Decisions taken
|
## Decisions taken
|
||||||
@@ -280,13 +314,50 @@ What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, th
|
|||||||
|
|
||||||
### Sessions survive backgrounding, via a foreground service
|
### Sessions survive backgrounding, via a foreground service
|
||||||
|
|
||||||
A persistent notification for as long as a shell or a transfer is live.
|
A persistent notification for as long as a shell, a transfer, or an idle-but-connected Files session is
|
||||||
|
live.
|
||||||
|
|
||||||
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
|
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
|
||||||
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
|
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
|
||||||
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
|
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
|
||||||
notification is to look at.
|
notification is to look at.
|
||||||
|
|
||||||
|
**Three corrections found after the first cut shipped, all in the wiring rather than the design:**
|
||||||
|
|
||||||
|
- **A shell opening never started the service.** `SessionKeepAlive` heard `TerminalWorkspace.SessionEnded`
|
||||||
|
and refreshed on that, but nothing announced the opposite event — so a user who opened a shell and
|
||||||
|
backgrounded the app immediately had no foreground service at all, and Android was free to kill the
|
||||||
|
process holding it. `MainWindowViewModel.TerminalSessionOpened` is now wired the same way in
|
||||||
|
`App.axaml.cs`'s `ComposeKeepAlive`.
|
||||||
|
- **A connected-but-idle Files session counted as nothing.** A host open on the Files screen with no
|
||||||
|
transfer moving is a live SFTP connection a dying process would sever, and the old two-argument
|
||||||
|
`Reconcile(liveSessions, activeTransfers)` had no way to hear about it. `TransfersViewModel.HasLiveFileSession`
|
||||||
|
— `IsConnected` with a real `ConnectedCipher`, which a bucket never has — is the third fact `Reconcile` now
|
||||||
|
takes.
|
||||||
|
- **Refreshing the notification restarted the service, which throws when backgrounded.** `Reconcile` called
|
||||||
|
`StartForegroundService` on every refresh, including the common case of a service that was already
|
||||||
|
running. On API 31+ that throws `ForegroundServiceStartNotAllowedException` the instant the app is
|
||||||
|
backgrounded — a transfer finishing in the pocket, one of two shells dying — which crashed the process and
|
||||||
|
took every session with it. `SessionForegroundService` now tracks whether it is already running and, when
|
||||||
|
it is, posts the updated notification through `NotificationManager.Notify` instead of asking Android to
|
||||||
|
start anything.
|
||||||
|
|
||||||
|
**The notification permission is requested, not just declared.** API 33+ requires `POST_NOTIFICATIONS` at
|
||||||
|
runtime or the receipt is silently invisible — the service still runs, but nothing on screen says so.
|
||||||
|
`SessionForegroundService.Reconcile` asks for it the first time in this process there is actually something
|
||||||
|
to show, at most once, with no result read back: a refusal costs the notification and nothing else, which is
|
||||||
|
what the manifest's own comment on the permission says.
|
||||||
|
|
||||||
|
**And a fourth correction, found by the notification refusing to come down.** "1 shell connected" outlived
|
||||||
|
the shell, both ways a shell can close. A shell exiting on its own announced `SessionEnded` from inside its
|
||||||
|
run's own finally block — where the run task is by definition not yet complete, so the
|
||||||
|
`LiveSessionCount` the keep-alive reads from that event still counted the session that had just ended, and
|
||||||
|
nothing fired afterwards to correct it. A tab closed by hand announced nothing at all, by a recorded
|
||||||
|
decision that assumed every subscriber was the closer. Both reversed in `TerminalWorkspace`: the end is now
|
||||||
|
announced from a continuation after the run has actually completed, and `CloseSessionAsync` announces too,
|
||||||
|
after its own drain — the event's remark carries the reversal, and `SessionEnded`'s subscribers were all
|
||||||
|
already "reconcile to reality" handlers for which a second announcement is harmless.
|
||||||
|
|
||||||
### Phone first
|
### Phone first
|
||||||
|
|
||||||
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
|
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
|
||||||
@@ -523,7 +594,12 @@ go at 360dp:
|
|||||||
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
|
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
|
||||||
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
|
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
|
||||||
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
|
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
|
||||||
dishonesty the unlock screen's count exists to prevent.
|
dishonesty the unlock screen's count exists to prevent. *Corrected since:* the opened half of a shell's
|
||||||
|
lifecycle was never wired in, so the service could never come up for a shell at all; an idle-but-connected
|
||||||
|
Files session now counts as a third live fact rather than nothing; a refresh while backgrounded updates
|
||||||
|
the notification in place instead of restarting the service, which the API throws on; and
|
||||||
|
`POST_NOTIFICATIONS` is now actually requested rather than merely declared. See
|
||||||
|
[Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service) for all four.
|
||||||
7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design,
|
7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design,
|
||||||
plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
|
plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
|
||||||
choosing a passphrase).
|
choosing a passphrase).
|
||||||
@@ -568,9 +644,16 @@ go at 360dp:
|
|||||||
believing they typed an `l`.
|
believing they typed an `l`.
|
||||||
|
|
||||||
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
|
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
|
||||||
and the bottom bar while a terminal is showing — one binding on `IsShowingPages` each — and the screen
|
and the bottom bar while a shell is showing, and the screen draws a 35-pixel bar in their place: back,
|
||||||
draws a 35-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three
|
the session pills, and a `+` raising a sheet with the three connections there are.
|
||||||
connections there are. That sheet is the head's first control that could be drawn over the renderer, so
|
|
||||||
|
*A shell rather than the surface, and the two parted company once that surface gained a connect page.*
|
||||||
|
With nothing running, Connections is a box, a CONNECT button and the machines connected to before — a
|
||||||
|
page in everything but which enum it is in — so `RefreshChrome` keeps the bar (and, wide, the rail) under
|
||||||
|
it and the Connections entry lights for the first time. It is the one screen reachable by closing your
|
||||||
|
last tab, and collapsing the nav there left the system back gesture as the only route to Hosts or
|
||||||
|
Settings. The header is not part of that: the surface draws its own bar, and a vault header above it is
|
||||||
|
the second row of chrome this head exists to avoid. That sheet is the head's first control that could be drawn over the renderer, so
|
||||||
it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
|
it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
|
||||||
WebView actually composites above Avalonia content is still the unverified question recorded below, and
|
WebView actually composites above Avalonia content is still the unverified question recorded below, and
|
||||||
collapsing is correct under either answer.
|
collapsing is correct under either answer.
|
||||||
|
|||||||
+250
-14
@@ -69,7 +69,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> labelled sidebar and the chrome went from 72 tall to 86, so `880x560` became `1016x574` — leaving every
|
> labelled sidebar and the chrome went from 72 tall to 86, so `880x560` became `1016x574` — leaving every
|
||||||
> screen the same `826x464` it was designed against. Four of the tables stop fitting at 690 wide, so
|
> screen the same `826x464` it was designed against. Four of the tables stop fitting at 690 wide, so
|
||||||
> widening the sidebar without widening the window would have broken them where the layout suite was not
|
> widening the sidebar without widening the window would have broken them where the layout suite was not
|
||||||
> looking.
|
> looking. **v5b grows it again**, to `1081x583` — see that section, below.
|
||||||
>
|
>
|
||||||
> **Buckets became a destination** rather than a toggle inside the files screen, matching the phone: the
|
> **Buckets became a destination** rather than a toggle inside the files screen, matching the phone: the
|
||||||
> `HOST` / `BUCKET` pair is gone and `ShellScreen.Buckets` draws the same `TransfersScreen` with the other
|
> `HOST` / `BUCKET` pair is gone and `ShellScreen.Buckets` draws the same `TransfersScreen` with the other
|
||||||
@@ -102,6 +102,14 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is
|
> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is
|
||||||
> a page test rather than a fourth `ShellSurface`.
|
> a page test rather than a fourth `ShellSurface`.
|
||||||
>
|
>
|
||||||
|
> **v5b reverses the paragraph above, and says so rather than leaving it to be found out of date.** The tab
|
||||||
|
> strip this paragraph describes is retired — see the v5b section, below — and with it the rule that made the
|
||||||
|
> rail conditional at all: the switcher it drew moved onto the nav rail's own head as three segments, the
|
||||||
|
> rail stopped collapsing for any of them, and neither SFTP, S3 nor a terminal gets the window's full width
|
||||||
|
> any longer. `IsVaultsTab` outlived the strip essentially unchanged — it is still a page test rather than a
|
||||||
|
> read of a `ShellSurface`, now over which of the rail's own destinations is showing rather than which of
|
||||||
|
> three tabs was.
|
||||||
|
>
|
||||||
> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar
|
> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar
|
||||||
> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds
|
> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds
|
||||||
> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The
|
> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The
|
||||||
@@ -160,11 +168,214 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
> | **Share this host** | Omitted, as the vault screen's `SHARED WITH · 6` is. A grant is per *vault* and per-item sharing is M5; a button here would imply this one host could be handed over, which is the thing the architecture does not do. |
|
> | **Share this host** | Omitted, as the vault screen's `SHARED WITH · 6` is. A grant is per *vault* and per-item sharing is M5; a button here would imply this one host could be handed over, which is the thing the architecture does not do. |
|
||||||
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
|
> | **Add Telnet**, and **Serial** in the toolbar | Omitted. `ISshConnection` is the only transport there is. This is also why the card subtitle's `ssh` is a constant today rather than a reading — it is stated in `HostRowViewModel.Summary`, which is the one place in this interface where a constant is printed on purpose. |
|
||||||
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
|
> | **+ SSH ID, Certificate, FIDO2** | Omitted. `IDENTITIES` and `CERTIFICATES` have been on this document's list since the first import — neither is even a reserved `SyncEntityType` — and there is no security-key path anywhere in the SSH layer. One control offering three item types that do not exist. |
|
||||||
> | The **Backspace / Default** row | Omitted. It is a terminal setting, and the client has no preferences store and no frame to carry one to the renderer — see the Preferences section. It would be a control whose value could not survive the window closing. |
|
> | The **Backspace / Default** row | Omitted, and it is the one row on this list that could now be built cheaply — a setting, an opcode and a control, the way the text size was. What it lacks is a reason: which byte backspace sends is a fact about the remote's `stty`, so a client-side switch fixes a mismatch by hiding it. See the Preferences section. |
|
||||||
> | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name. A group moves too, from its card's right-click menu, and takes its nested groups and every host filed under them; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. |
|
> | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name. A group moves too, from its card's right-click menu, and takes its nested groups and every host filed under them; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. |
|
||||||
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
|
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
|
||||||
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. |
|
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. |
|
||||||
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
|
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
|
||||||
|
>
|
||||||
|
> ## The desktop's v5 — hosts, restyled, and two reversals
|
||||||
|
>
|
||||||
|
> A fifth pass, and the first that undoes as much of its own predecessors as it adds: a re-theme both heads
|
||||||
|
> share, pinned folders as a full feature, a last-connected time read off the connection log, and two pieces
|
||||||
|
> of earlier passes put back on the user's own instruction rather than kept.
|
||||||
|
>
|
||||||
|
> **The palette went purple, and three fonts are embedded that were only ever named before.** The shared
|
||||||
|
> scheme moves from v3's near-black-and-blue to a purple accent (`#5D42DE`, with a gradient and a glow of its
|
||||||
|
> own) over the same near-black canvas, and both heads recolour together — `Palette.axaml` is shared, and a
|
||||||
|
> colour is not allowed to mean something different on the two of them. What changed on the desktop alone is
|
||||||
|
> the type: Montserrat, JetBrains Mono and Material Icons are embedded as `AvaloniaResource` in the Shell
|
||||||
|
> project rather than requested by name and left to whatever the machine happens to have, the way `WithInterFont`
|
||||||
|
> alone used to leave Inter registered but unused. Montserrat is the default sans now, with Inter kept as its
|
||||||
|
> fallback rather than the whole answer; `MonoFont` gains JetBrains Mono at the front of its own list, ahead of
|
||||||
|
> the system-mono stack that is still behind it for a glyph JetBrains Mono does not cover. **Android recoloured
|
||||||
|
> with the shared palette and kept its own default sans when this paragraph was first written; it does not any
|
||||||
|
> more** — see "the phone catches up", directly below.
|
||||||
|
>
|
||||||
|
> **The phone catches up.** A later reversal than the one above, on the same reasoning: a face is not a
|
||||||
|
> colour, and the user decided the phone should share both rather than only the second. `WithInterFont` gains
|
||||||
|
> the same `FontManagerOptions` block `Program.cs` sets, off the same embedded Montserrat, no new asset
|
||||||
|
> required. `Theme/Phone.axaml`'s six-rung v2 ladder — 4/9/10/11/12/14 — collapses to the desktop's three:
|
||||||
|
> chip or tag 6, button or field 10, card or output 12; `Button.fab`'s 28 and every sheet's 22 survive
|
||||||
|
> unchanged, being geometry rather than ladder rungs. `Button.primary` and `Button.fab` take `AccentGradient`
|
||||||
|
> and `AccentGlow` in place of a flat fill, the same swap `Button.accent` made on the desktop, `BoxShadow`
|
||||||
|
> cleared the same way on disabled. And the phone's own chrome — the vault header, the bottom bar, the shells
|
||||||
|
> strip, `HostActionBar`, the editor headers, the terminal's collapsed bar and every screen's raised selection
|
||||||
|
> bar — moves from `Chrome`/`Sidebar` to `DeepChrome`, joining the desktop's own v5b titlebar-and-rail move;
|
||||||
|
> `PhoneRail`'s hover takes `Track`, which `Palette.axaml` already names for that row. Borders stay keyed.
|
||||||
|
>
|
||||||
|
> **Flat sections replace the grid of group cards and the breadcrumb trail — reversing v3, on the user's own
|
||||||
|
> approval rather than a defect found in it.** v3's grid held one level of the group tree at a time, opened by
|
||||||
|
> a double-click, with a trail above it saying where you were; that model is retired outright rather than
|
||||||
|
> folded into anything; every group is now a heading on one flat board, in label order, exactly the flattening
|
||||||
|
> the phone's `SidebarRows` has always drawn — a headerless board for a groupless keychain, "No group" first
|
||||||
|
> where there is one, a chevron per heading and one Collapse All. Nothing here was found broken; the grid did
|
||||||
|
> what v3 asked for. The user asked for the phone's shape on the desktop too, and decision 2 in
|
||||||
|
> `hosts-v5-design-spec.md` is that request recorded.
|
||||||
|
>
|
||||||
|
> **Tag chips and a mono address line are back on the card — reversing v4, also by approval and not by
|
||||||
|
> defect.** v4 had replaced both with a two-line `ssh, root, pci, eu-west-1` subtitle and moved the address to
|
||||||
|
> the tooltip alone, on the reasoning that a card read while scanning forty machines wants the kind of machine
|
||||||
|
> more than its address. v5's own mock draws chips and an address on every card, and the user chose the mock's
|
||||||
|
> arrangement over v4's when the two were put side by side — so the card now carries both: the full address is
|
||||||
|
> still on the tooltip, as v4 left it, and a truncated mono copy sits under the name as well.
|
||||||
|
>
|
||||||
|
> **Pinned folders are a full feature, not a field with nowhere to be edited.** `HostSecret.PinnedPaths` is an
|
||||||
|
> ordered, deduplicated list merged per path the way `TagIds` is merged per tag, so two machines pinning
|
||||||
|
> different folders on one host both keep theirs. The drawer's QUICK ACCESS section edits it — a row per pin
|
||||||
|
> with a close box, an add field and button, staged on `VaultViewModel.EditorPinnedPaths` the way the tag
|
||||||
|
> picker stages `editorTagIds` — the card shows a pin-count badge once there is at least one, and the detail
|
||||||
|
> pane repeats the list read-only in its own fieldrow idiom. Clicking a pin above a connected terminal opens
|
||||||
|
> the SFTP tab with the remote pane navigated straight to that path, through the same connect path "Browse
|
||||||
|
> files" already used — a second authenticated connection, exactly as every SFTP session here is.
|
||||||
|
>
|
||||||
|
> **Last connected is read off the synced connection log, and it is deliberately narrow about when.**
|
||||||
|
> `max(StartedAt)` per `HostId` is computed on the hosts screen's own activation and again when a session ends,
|
||||||
|
> then restrung into words on a one-minute tick for as long as the screen stays visible — never on the sync
|
||||||
|
> loop, which is the same rule `LogsViewModel` states for the log itself: nobody is watching their own
|
||||||
|
> connection from an hour ago update on a screen they are not looking at. A connected host prints nothing here;
|
||||||
|
> the green dot already answers "right now", and printing an age beside it would be answering a question about
|
||||||
|
> a session that is not the one still running. **It is scoped to the active vault alone**, the same limitation
|
||||||
|
> `LogsViewModel` and `MainWindowViewModel.RecentConnections` already carry — a host filed in a second vault
|
||||||
|
> this session can read gets no ago-text until something on this screen reads more than one vault's connection
|
||||||
|
> history, which nothing does yet. And a session ending can, briefly, still show the previous entry: the
|
||||||
|
> recorder's own write is queued onto a background task rather than made inline, so a re-read landing before
|
||||||
|
> that write drains reads the log as it stood before the session it is reporting on closed. It self-heals on
|
||||||
|
> the next activation or the next session end, and closing the gap outright was judged not worth blocking a
|
||||||
|
> screen transition on the recorder's queue.
|
||||||
|
>
|
||||||
|
> **Quick connect grew to match, and quietly answers a different question than the mock's own row does.** The
|
||||||
|
> card widened from 520 to 640, gained the mock's `>_` prompt styling and a live dot per row the way the hosts
|
||||||
|
> board's own two-state dot works. What it does not draw is the mock's SSH/SFTP kind column — every palette
|
||||||
|
> connection here is SSH, so a constant printed as though it varied would be exactly the kind of decoration
|
||||||
|
> this document has refused before (the terminal's session footer, the snippet's kind badge); the auth word
|
||||||
|
> (credential/key/password) answers a question that is actually different per row instead.
|
||||||
|
>
|
||||||
|
> | v5 element | What ships instead |
|
||||||
|
> | --- | --- |
|
||||||
|
> | The status dot's third, amber state | ◆ **Still two states, on the same reasoning v3 and v4 both gave and v5 was asked to reconsider.** Green means a terminal is open on that host, grey means one is not; nothing here pings a machine, so the amber "reachable but not connected" state has no fact behind it to draw. |
|
||||||
|
> | The relay checkbox's mock copy | The pre-existing sentence, unchanged, "(not wired up yet)" included — restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it) but not reworded. The mock's own copy implies a relay this client can dial; it cannot, for the reason `HostDrawer.axaml`'s own remarks give. |
|
||||||
|
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
|
||||||
|
> | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. |
|
||||||
|
> | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. |
|
||||||
|
> | QUICK ACCESS's editor, on the phone | ◆ **Shipped, over the same shared data the deferral above described.** The phone's host editor draws its own QUICK ACCESS section on the same staged `VaultViewModel.EditorPinnedPaths` the desktop's drawer binds — a row per pin, an add field and button, and a remove target sized to this head's 44dp touch floor rather than the desktop's 22-pixel close box. `AddEditorPinCommand`'s refusals surface through a `Status` line the editor page draws for itself, since that page covers the whole screen and the list behind it draws its own `Status` off-screen for as long as it is open. The pins themselves reach a second surface this head has that the desktop does not need: `TransfersViewModel.ConnectedPinnedPaths` carries them as chips on the Files screen while connected, and tapping one runs `GoRemoteCommand` — the same command the breadcrumb trail already used to navigate. Two deviations from the desktop, both named where they land: the chip row is a snapshot taken at connect rather than a live follow of the vault, so a pin edited mid-session shows up on the next connect rather than this one; and the editor's own hint sentence says the pins appear on the Files screen, not above a terminal — this head has no terminal strip for them to sit above, the same honesty the row below already states for the desktop's own hint. |
|
||||||
|
> | Collapse All beside every section's own collapse chevron | Bound on every heading's view model and shown on only the first — `SidebarGroupHeader.IsFirstBoardSection` is what a virtualised list of sections uses in place of a control of the board's own that would otherwise have to sit above all of them. |
|
||||||
|
> | The QUICK ACCESS hint's claim that pins live in a sidebar | "Pinned folders appear above the terminal for this host." — no sidebar exists on this screen for the sentence to point at, so the shipped hint says where they actually draw. |
|
||||||
|
> | The mock's "Saving to **DodoTech ▾** vault" subtitle, with a picker's chevron inside a sentence | The pre-existing `DrawerSubtitle` wording — the vault's name alone, unchanged by this pass. A chevron inside running text implies the text itself is the control, which it is not: the vault picker is its own element, shown only while creating and only above one writable vault, as it always has been. |
|
||||||
|
> | The design's two deeper text steps, `#6D6F84` and `#5D5F74` | Not added as `Palette.axaml` keys. `TextGhost`, already repicked to `#7C7F98` for v3, is reused everywhere the design reaches for either — a resource nothing yet distinguishes from `TextGhost` is not free to carry, on the same reasoning the border ramp's own remarks give. |
|
||||||
|
>
|
||||||
|
> ## The desktop's v5b — chrome, session shell and SFTP restyled
|
||||||
|
>
|
||||||
|
> A sixth pass, and the first aimed at the chrome and the two screens somebody spends the most time inside
|
||||||
|
> rather than at any one screen on its own. **What shipped:** the titlebar and nav rail redrawn against
|
||||||
|
> `TitleBar.dc.html` and `NavRail.dc.html` — a segmented SSH/SFTP/S3 switcher at the rail's own head, a
|
||||||
|
> mode-dependent first row beneath it, and Vaults and Preferences moved off the rail's list and into a
|
||||||
|
> popover under the user chip at its foot. The terminal and the SFTP surface both gained a session shell — an
|
||||||
|
> in-screen tab row, a host header, a bottom status bar and a 300-pixel QUICK ACCESS/SNIPS sidebar —
|
||||||
|
> replacing the window-wide tab strip v3 built and the pin-chip strip that used to sit above the terminal
|
||||||
|
> alone. The SFTP screen's own two panes and its TRANSFERS strip were restyled against `SFTP.dc.html` — row
|
||||||
|
> grids, quiet Material glyphs, slim progress bars — and three screens that had never had a `.dc.html` of
|
||||||
|
> their own got one each: Keychain, Snippets (titled Snips on screen now, matching the rail) and Logs.
|
||||||
|
>
|
||||||
|
> **The nav rail stopped being conditional furniture, and that is the one structural change underneath the
|
||||||
|
> paint.** v3 drew it only under the Vaults tab, so SFTP, S3 and an open terminal each got the window's full
|
||||||
|
> width; v5b's own fidelity pass puts the rail up beside all three, because the design draws it that way and
|
||||||
|
> because the segmented switcher now living at its head is only worth having if it stays reachable from
|
||||||
|
> everywhere. `MainWindowViewModel.IsVaultsTab` still exists and still answers the question it always did —
|
||||||
|
> which of the rail's own six destinations is on screen, as opposed to SFTP or S3 — but nothing about it gates
|
||||||
|
> the rail's own visibility any more. The window's minimum grew again to hold the difference: `1016x574`
|
||||||
|
> became `1081x583`, nine pixels for the titlebar's 44-to-53 and sixty-five for the rail's 190-to-255, added
|
||||||
|
> straight onto the minimum rather than absorbed by shrinking a screen — see `MainWindow.axaml`'s own remark
|
||||||
|
> and `LayoutHarness.MinimumWidth`/`MinimumHeight`. `ScreenWidth` held at 826 because the rail is the only
|
||||||
|
> thing beside a page that grew; `ScreenHeight` grew by the 42 pixels the retired tab strip used to cost every
|
||||||
|
> screen, because nothing replaced that strip as chrome every screen pays for — the terminal and SFTP
|
||||||
|
> surfaces pay for their own tab row out of their own session-shell budget instead. See
|
||||||
|
> `LayoutHarness.ScreenHeight` and `SessionScreenHeight`.
|
||||||
|
>
|
||||||
|
> | v5b element | What ships instead |
|
||||||
|
> | --- | --- |
|
||||||
|
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
|
||||||
|
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
|
||||||
|
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
|
||||||
|
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
|
||||||
|
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
|
||||||
|
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
|
||||||
|
> | No pins destination in the design at all | **The rail agrees with the design now.** `KnownHostsScreen` is still built and still reachable — from **Host keys** on the Keys screen's own header, which was always the second way in — but the rail's Pins row is gone. It was kept through v5b on the grounds that the mock has no screen for approved host keys, which is a reason for the screen to exist and was never a reason for a rail entry once the keychain had a door to the same place. Two rail rows landing on one screen is a rail that has to be read twice. |
|
||||||
|
> | The popover's Settings and Preferences rows, and the design's own Settings-* family of screens | **Landed in v5c.** What was two doors to one room in v5b — Settings and Preferences both opening the same bare `Preferences` screen — is now two of three doors onto their own settings pages: Settings opens General, Preferences opens Preferences, and a third row, Vaults, opens Vaults. All three are real, distinct pages inside one settings mode; see the v5c section, below. |
|
||||||
|
> | `· Org` after the user chip's name, and a `Primary` tag on a vault row in the popover | Neither. There is no organisation concept behind a vault — only the vault itself — and no vault is distinguished as primary; the popover's vault rows are the existing shown-vaults toggles, restyled. |
|
||||||
|
> | The design's titlebar, which has nowhere for a sync indicator | `SYNCED` stays, on the titlebar's right side, ahead of the window's own minimise/maximise/close buttons — the one thing this titlebar keeps that the design's own does not draw at all. |
|
||||||
|
> | Per-tab SFTP sessions, implied by a tab row shared between the terminal and the SFTP surface | **Not built, and not what shipped instead.** A click on the SFTP tab row runs `MainWindowViewModel.SelectFilesHostAsync`, which opens (or reuses) a second, SFTP-specific connection through the same "Browse files" plumbing a pin click already used — an honest second login, not a channel multiplexed onto the terminal's. `SelectedTab` moves with the click, which is also what keeps the sidebar's QUICK ACCESS in step — that list is keyed to `SelectedTab` on both surfaces, so selecting a different terminal tab afterwards can leave QUICK ACCESS naming a host that is not the one the remote pane is actually browsing. |
|
||||||
|
> | A scroll-to-section affordance behind the sidebar's **+ Pin folder** row | Not built — this application has no way to open the host editor already scrolled to one card inside it. `PinFolderFromSidebarCommand` opens the same three-card editor the hosts screen's own EDIT reaches, at the top, which is the closest honest affordance rather than a new one. |
|
||||||
|
> | The TRANSFERS strip's aggregate throughput readout (`8.4 MB/s` beside an arrow) | Omitted. `TransferRowViewModel.Progress` computes a rate per transfer; nothing sums those into one number for the whole queue, and inventing one would be exactly the fabricated fact this document's honesty rule forbids. |
|
||||||
|
> | Freshness/"hot row" colouring and a selection ring on a finished transfer | Not drawn — there is no tracked notion of how recently a row finished, so nothing distinguishes a transfer that just completed from one that finished an hour ago. |
|
||||||
|
> | The design's Material-Symbols-only "draft" glyph for a file row | `insert_drive_file`, the closest classic Material Icons has — a plain document rather than a page with a folded corner. Recorded as a substitution rather than silently swapped; "draft" is simply not a glyph this font contains. |
|
||||||
|
> | `REMOTE` across the SFTP panes' arrow column | `HOST`/`BUCKET`, kept from before this pass rather than adopted from the design. The pair is the difference between a directory tree and a flat namespace with inferred folders, which is a distinction worth keeping even where the design collapses it. |
|
||||||
|
> | `THIS MACHINE` on the local pane | `LOCAL`, the design's own word, adopted — nothing in this file's own comments ever defended "THIS MACHINE" the way HOST/BUCKET is defended above, so there was no reason to keep it. |
|
||||||
|
> | The Keychain screen's `MODIFIED` column and `FINGERPRINT` box | Both dropped. `VaultItem` carries no timestamp of any kind, and `SshKeySecret` has no fingerprint field — computing one would mean parsing armour the type stores verbatim, an already-recorded gap this pass did not revisit. |
|
||||||
|
> | Five separate add buttons (GENERATE / + SSH KEY / + PASSWORD / + TAG / + BUCKET) | One `+ New key` accent button, opening a menu of the same five actions in the same order — the design draws one button because its own mock has one "add" concept; this application has five, and none of them was dropped to fit. |
|
||||||
|
> | `NEW ITEMS GO TO` on the vault-filing picker | `NEW ITEMS FILE TO`, the design's own wording, adopted — this screen's own comments already called the act "filing", so the label was this application's vocabulary already. |
|
||||||
|
> | The Snippets screen's own on-screen name | **Snips**, matching the rail, which already said so. |
|
||||||
|
> | A modified-date on a snippet card | Omitted — `SnippetSecret` carries no timestamp, the same absence every other item kind has. |
|
||||||
|
> | The design's shorter captions — "Type into terminal", the rationale-only sentence for "runs on insert" | The screen's own longer, more actionable wording kept instead: the insert button still names the destination tab (`TYPE INTO {tab}` / `NO TERMINAL OPEN`), and the "runs on insert" caption still states the operational consequence rather than only the reason the setting exists. Both of the design's sentences are true; the ones already here say more. |
|
||||||
|
> | A delete confirmation for a snippet | **Built, matching the mock.** `SnippetsViewModel.RequestDelete`/`ConfirmDelete`/`CancelDelete` say the same vault-wide-reach, tombstone, no-undo sentence `VaultViewModel.HowFarADeletionGoes` already says for every other item kind — additive beside the existing uncounted `DeleteCommand`, which the phone's own DELETE row still calls. |
|
||||||
|
> | The Logs screen's own footer sentence about encryption | Adopted verbatim — verified against this screen's own header remark and ADR 0001 before shipping it, both true, so it is drawn as literal text rather than reworded. |
|
||||||
|
>
|
||||||
|
> ## The desktop's v5c — a settings area, at last
|
||||||
|
>
|
||||||
|
> An eighth pass, and the first to give the design's own Settings area a home of its own rather than folding
|
||||||
|
> its two real screens into the rail's popover. Settings is a full-window **mode** now, swapped in wholesale
|
||||||
|
> rather than laid over anything: its own 53px titlebar reading "Back to application", a 340px `SettingsNav`
|
||||||
|
> rail — SETTINGS (General, Vaults, Account) above CUSTOMIZE (Security, Preferences, Groups, Tags), Logout
|
||||||
|
> pinned below both — and a content column capped at the design's own 1100 pixels, which is 741 beside the
|
||||||
|
> rail at the window's own minimum rather than the full 1100 (`LayoutHarness.SettingsContentWidth`). Seven
|
||||||
|
> pages, not the design's eight: the ORGANISATION section and its one page are refused outright, below. Two
|
||||||
|
> of the seven are wholly new — Account and Security did not exist as screens before this pass — and the
|
||||||
|
> other five are the old `PreferencesScreen.axaml` and `VaultsScreen.axaml` split apart and restyled into the
|
||||||
|
> design's own card idiom; both files are deleted, and every command either one offered is reachable here
|
||||||
|
> exactly once. The popover's own Settings, Vaults and Preferences rows, and everywhere else in the codebase
|
||||||
|
> that used to navigate to the bare `Preferences` or `Vaults` screen, now land in this mode instead —
|
||||||
|
> `MainWindowViewModel.ShowScreen` redirects at that one point rather than at every caller that used to reach
|
||||||
|
> either screen directly.
|
||||||
|
>
|
||||||
|
> The importer moved inside this same chrome rather than staying a screen of its own: it draws over the
|
||||||
|
> Preferences page as a boolean overlay — `SettingsNav` stays lit on Preferences the whole time it is open,
|
||||||
|
> and the titlebar's own back button reads "Back to preferences" instead of "Back to application" — restyled
|
||||||
|
> to the design's own table (ALIAS/HOSTNAME/USER/PORT/WHAT THIS MEANS, one header tick-all button in place of
|
||||||
|
> the old TICK ALL/TICK NONE pair). Known Hosts was restyled the same pass but kept its own place: it stays a
|
||||||
|
> main-chrome screen, reached from the rail's Keys entry and now also from Security's own "Approved host
|
||||||
|
> keys" row, because the design draws no page for it at all. And the S3/Buckets surface — which got none of
|
||||||
|
> v5b's session shell — picked up that shell's *look* this time without its machinery: a 26-pixel padded,
|
||||||
|
> bordered, radius-12 container and nothing else, since a bucket has no tab row, no host to head a card with,
|
||||||
|
> no status-bar fact to print and no pin for a sidebar to show.
|
||||||
|
>
|
||||||
|
> | v5c element | What ships instead |
|
||||||
|
> | --- | --- |
|
||||||
|
> | The Settings-Organisation page, and the rail's own ORGANISATION section | Refused outright. No organisation entity exists anywhere in this product — a team is the membership list behind a shared vault, and there is exactly one tenant per deployment — so the rail simply has no third section; see `SettingsNav.axaml`'s own remark. |
|
||||||
|
> | General: the update-channel switcher, Launch at login, Reopen tabs, the theme control and the language picker | Refused, and carried on the page's own NOT BUILT YET card rather than left silently missing: which channel a copy follows is fixed when it is built, nothing registers this application with Windows' own startup list or remembers a tab list across a launch, only the one dark theme exists, and there is no i18n anywhere in this client. |
|
||||||
|
> | Vaults: the VAULT DEFAULTS card (auto-lock, require-password-on-unlock, relay) and the RECOVERY card (kit, export) | Both refused outright — none of the three settings exists, sync is always on, and there is no recovery kit and no export. |
|
||||||
|
> | Vaults: "Manage devices", and the "3 devices" count on the sync line | Refused. There is no list-devices endpoint anywhere in this client. |
|
||||||
|
> | Vaults: per-vault UNLOCKED/LOCKED chips, and the "Unlock ⟨vault⟩" modal | Refused. This application locks the keychain as a whole, not one vault at a time, so there is no per-vault state for a chip or a modal to act on; the keychain-level fact is the sync card's own dot instead. |
|
||||||
|
> | Vaults: the magenta "Default" badge | Dropped rather than faked. The app does track a "new items go to" vault, but that lives on a different view model than the row being drawn here, and cross-referencing the two per card would be more machinery than the badge is worth. |
|
||||||
|
> | Vaults: "Sorted by name ▾" | Decorative in the mock, and not drawn — `VaultsViewModel.Vaults` is already ordered personal-first-then-name, and there is no second order to switch to. |
|
||||||
|
> | Vaults: member avatar stacks on every card | Refused on an unselected card — `VaultRowViewModel` carries a member *count*, not the members themselves, and only the selected vault's own member list is actually loaded. A count stands in on the card; avatars are real in the members panel, where the data is. |
|
||||||
|
> | Account: Edit profile, Change passphrase, the DEVICES list, Sign out everywhere | All four refused — no endpoint exists for any of them, and the passphrase is a one-time enrollment choice with no change flow. What is new and real on this page instead: the SIGN-IN card's issuer sentence, `MainWindowViewModel.Issuer` off `MeResponse.Issuer`, cached the same turn the name and email are and surfaced here for the first time — "Signing in proves who you are — it never decrypts a vault." |
|
||||||
|
> | Security: the strict-host-key toggle, the allowed-algorithms chips, the auto-lock link row | Refused — the app always asks on a changed key, there is no algorithm allow-list anywhere in the SSH stack, and there is no auto-lock setting for the link to point at. |
|
||||||
|
> | Security: RECENT SECURITY EVENTS as a list of rows | Shipped as the link alone, "See all activity in Logs." A real list would mean a filtered read over `LogsViewModel`'s two logs, and which entries count as "security" is a judgement call the design does not resolve; the honest link is complete on its own. |
|
||||||
|
> | Preferences: the device-name editor, Font/Cursor/Scrollback/Copy on select/Terminal bell, the clipboard-clear delay, confirm-run-on-insert | All refused, on the page's own carried-over NOT-BUILT idiom — none of the seven has anything behind it. Windows Hello's register/"Stop unlocking here" pair moved off this page to Security instead, one home rather than two. |
|
||||||
|
> | Groups: drag-to-reorder, and the mock's "the order here is the order there" sentence | Refused. `HostGroupRowViewModel` orders by the vault new items go into, then by vault name, then by label — there is no manual order to drag into. The page prints the true sentence instead and draws no drag handles. |
|
||||||
|
> | Tags: the LAST APPLIED column | Refused — no timestamp of when a tag was last put on a host exists anywhere in this client. |
|
||||||
|
> | Tags: a per-tag vault chip | Refused — `TagRowViewModel` carries a label and a host count and nothing else; there is no vault id on the row for a chip to read. |
|
||||||
|
> | Import: the "saving to ⟨vault⟩ ▾" picker | Refused as a control. Every import writes through the same `session.ActiveVaultId` every other bulk write does, so there is no per-import target to choose — the footer prints the vault's name as a fact, `ImportViewModel.SelectionSummary`, rather than as a `▾`. |
|
||||||
|
> | Import: the old ADDRESS/AUTHENTICATION/STATE columns | Folded into the design's own column set — ALIAS/HOSTNAME/USER/PORT — with the authentication text moved onto the alias cell's own tooltip rather than kept as a column of its own. |
|
||||||
|
> | Import: TICK ALL / TICK NONE | Replaced by the design's own header tick-all button, one press that ticks or unticks every row through `ImportRowViewModel.ToggleAllCommand`. |
|
||||||
|
> | Import as a nav-rail destination | Refused. It is a boolean overlay over the Preferences settings page (`MainWindowViewModel.IsImportOpen`), not a page of its own — `SettingsNav` stays lit on Preferences throughout, exactly as `Import.dc.html` draws it. |
|
||||||
|
> | The design's fixed 1100px content column | `MaxWidth="1100"` rather than a fixed width — the column is 741 pixels beside the 340px rail at the window's own minimum, where a fixed 1100 would not fit. |
|
||||||
|
> | The status bar and the update banner, while in settings mode | Both hidden — `MainWindow.axaml`'s bottom two rows read `!IsSettingsMode`. The design's own settings titlebar has no room for either, and there is nowhere honest to draw them instead. |
|
||||||
|
> | The design's sentence-case button copy | Not adopted. Every button on every settings page keeps this application's own ALL-CAPS mono convention — `CHECK NOW`, `SIGN OUT`, `OPEN IMPORTER` — over the mock's own "Check now". |
|
||||||
|
> | The design's assumption that Settings is the only thing on screen | Not followed. The quick-connect palette and an unapproved host key's decision card both still draw over settings mode exactly as they interrupt every other screen — a connection question does not stop mattering because the window happens to be showing Settings; see `MainWindow.axaml`'s own remark on the ordering of its Panel. |
|
||||||
|
> | The importer's own sentence, "This is the only control in DodoSSH that opens key material from a directory you did not point at file by file — nothing is read until Import is pressed." | Adopted verbatim — verified against `ImportViewModel.ScanAsync` and `ImportAsync`: scanning never calls `SshConfigLocator.ReadIdentity`, and `ImportAsync` is the only path that ever does. |
|
||||||
|
> | The tags page's own sentence, "Renaming here is one write and every host follows." | Adopted verbatim — verified against `SaveTagAsync`: a host names a tag by id, never by label, so a rename touches nothing but the tag item itself. |
|
||||||
|
> | The known-hosts screen's own intro, "Every pin is a decision recorded at the moment of connecting. Fingerprints are never trimmed — compare them character by character against what the operator published." | Adopted verbatim, matching the stance the rest of this application already takes on a pin. |
|
||||||
|
|
||||||
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
|
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
|
||||||
project each piece would have to land in, and **what the shipped interface does instead**. That last
|
project each piece would have to land in, and **what the shipped interface does instead**. That last
|
||||||
@@ -173,8 +384,9 @@ it for revocation, for relay session recording and for what locking does not do
|
|||||||
rendered with invented data to make a screenshot look finished. Where a feature does not exist, the
|
rendered with invented data to make a screenshot look finished. Where a feature does not exist, the
|
||||||
interface either omits it or says so.
|
interface either omits it or says so.
|
||||||
|
|
||||||
Read alongside [the milestone plan](../README.md#milestones). Most of what follows is not a defect; it is
|
Read alongside [the milestone plan](../README.md#milestones). Most of what follows is not a defect. M2 and
|
||||||
M2 and M3 arriving in a design before it arrives in the code.
|
M3 have since arrived and their rows say so; what is left is either M5 — per-item content keys, and the
|
||||||
|
sharing that would rest on them — or a decision recorded here not to build the thing at all.
|
||||||
|
|
||||||
## The shape of the problem
|
## The shape of the problem
|
||||||
|
|
||||||
@@ -204,9 +416,12 @@ reason and is gone: membership is granted only to an account that exists, so the
|
|||||||
reservation fails to cover. The status stays reserved, because the column holds it in nobody's database and
|
reservation fails to cover. The status stays reserved, because the column holds it in nobody's database and
|
||||||
a client must not fail on a value a later server might send. See [ADR 0009](adr/0009-team-access-model.md).
|
a client must not fail on a value a later server might send. See [ADR 0009](adr/0009-team-access-model.md).
|
||||||
|
|
||||||
**The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the
|
**The client's settings file holds two things.** It writes three files — `cache.db`, `device.key` and
|
||||||
cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be
|
`settings.json` — and the cache's six tables still hold none of them. `ClientSettings` carries the
|
||||||
saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway.
|
terminal's text size and whether this machine looks for a newer build on its own, and both survive a
|
||||||
|
restart. Everything else on the design's TERMINAL panel is unbuilt rather than unbuildable: the data plane
|
||||||
|
grew `TerminalServerOpcode.FontSize` to carry the one that shipped, so a second option is an opcode, a
|
||||||
|
setting and a control rather than a new mechanism.
|
||||||
|
|
||||||
**Three things the design did not ask for and this build now has.** A key can be generated in the client
|
**Three things the design did not ask for and this build now has.** A key can be generated in the client
|
||||||
rather than pasted in (`SshKeyGenerator`, and the `openssh-key-v1` container is written by hand — see
|
rather than pasted in (`SshKeyGenerator`, and the `openssh-key-v1` container is written by hand — see
|
||||||
@@ -297,7 +512,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
|
|||||||
| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. |
|
| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. |
|
||||||
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
|
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
|
||||||
| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. |
|
| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. |
|
||||||
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | Inter (already embedded) for prose, and the system monospace stack the terminal already names. Named once in `App.axaml` as `MonoFont`, so the substitution is reversible in one place. |
|
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | **Shipped, since v5, and not IBM Plex.** The v5 design asked for Montserrat, JetBrains Mono and Material Icons instead, and all three are embedded as `AvaloniaResource` in the Shell project rather than requested by name and left to whatever the machine happens to have. Montserrat is the default sans, with Inter — the earlier substitution — kept only as its fallback; `MonoFont` puts JetBrains Mono first, ahead of the system-monospace stack the terminal still falls back to. Named once each in `Program.cs` and `Palette.axaml`, so a later design's fonts are still a one-line swap. |
|
||||||
| `⌘K`, `⌥↵` | ui | Nothing; the design is Mac-flavoured. | `CTRL K`. Development is Windows-only today (`docs/platform-flags.md`). |
|
| `⌘K`, `⌥↵` | ui | Nothing; the design is Mac-flavoured. | `CTRL K`. Development is Windows-only today (`docs/platform-flags.md`). |
|
||||||
|
|
||||||
**Also worth knowing:** custom chrome went in as `SystemDecorations="BorderOnly"`, not by extending the
|
**Also worth knowing:** custom chrome went in as `SystemDecorations="BorderOnly"`, not by extending the
|
||||||
@@ -322,11 +537,11 @@ caption buttons and window title drawn on top of the application's own — two s
|
|||||||
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
|
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
|
||||||
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
||||||
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
||||||
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. |
|
| Pane header `aes256-gcm` | client-ssh | **No longer a miss — the plumbing this row used to lack now exists.** `ISshConnection.Cipher` and `TerminalWorkspace.GetSessionFacts` were built for the v5b session shell's status bar; see that row in the v5b section, above. | Still not on a pane header — this pre-v5b element does not exist as its own piece of chrome any more. The fact it wanted to show is drawn instead where v5b moved it: the status bar's `SessionCipher`, beside CONNECTED. The session shell's host header separately shows the account and endpoint actually dialled (v3–v4: the tab strip did; v5b moved that fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
|
||||||
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
||||||
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
||||||
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
||||||
| Terminal font size (`--termfs`, 11–16px) | client-storage | See preferences. | Fixed at the renderer's 13px. |
|
| Terminal font size (`--termfs`, 11–16px) | client-storage | — | **Shipped**, and wider than the design's range: 8–32px from the preferences screen or Ctrl+plus/minus/0 over a terminal, carried by `TerminalServerOpcode.FontSize` and kept in `settings.json`. It resizes the grid rather than magnifying it, so every live pane refits and the remotes are told how many columns they now have. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -350,11 +565,11 @@ connect, and it goes through the same host key gate, the same pin and the same t
|
|||||||
| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | All four. `PosixMode` renders `drwxr-xr-x` from the bits SFTP hands over; setuid, setgid and sticky are not shown, because SSH.NET does not surface them and `rwx` where `rws` is true would be worse than nothing. |
|
| Remote listing with `NAME/SIZE/MODIFIED/PERMS` | client-ssh | All four. `PosixMode` renders `drwxr-xr-x` from the bits SFTP hands over; setuid, setgid and sticky are not shown, because SSH.NET does not surface them and `rwx` where `rws` is true would be worse than nothing. |
|
||||||
| Local listing | client-transfer | `LocalDirectory`, which is where this client's `System.IO` now lives. `PERMS` is blank on the local side rather than filled with a plausible-looking POSIX mode that is not a fact about a file on Windows. |
|
| Local listing | client-transfer | `LocalDirectory`, which is where this client's `System.IO` now lives. `PERMS` is blank on the local side rather than filled with a plausible-looking POSIX mode that is not a fact about a file on Windows. |
|
||||||
| Transfer queue, progress, throughput | client-transfer | `FileTransferQueue`. One transfer at a time, so the rate on a row is the rate of the link rather than a share of it. Throughput is measured over a half-second window, not averaged since the start. |
|
| Transfer queue, progress, throughput | client-transfer | `FileTransferQueue`. One transfer at a time, so the rate on a row is the rate of the link rather than a share of it. Throughput is measured over a half-second window, not averaged since the start. |
|
||||||
| `resume supported` | client-transfer | **Within a run of the application.** Every transfer writes to a `.dodossh-part` file beside its destination and is renamed into place at the end, so an interrupted one can never be mistaken for a finished one, and `RESUME` carries on from the part file's own length. A part file found at startup is *not* resumed: nothing records what wrote it, and resuming on the strength of a name matching is how a corrupt artefact gets delivered with nothing reporting a failure. Making it survive a restart needs the preferences store this client has not got — see below. |
|
| `resume supported` | client-transfer | **Within a run of the application.** Every transfer writes to a `.dodossh-part` file beside its destination and is renamed into place at the end, so an interrupted one can never be mistaken for a finished one, and `RESUME` carries on from the part file's own length. A part file found at startup is *not* resumed: nothing records what wrote it, and resuming on the strength of a name matching is how a corrupt artefact gets delivered with nothing reporting a failure. Making it survive a restart needs somewhere to record *what* wrote each part file — the source, the offset and the run — which is a table rather than a setting; `settings.json` holds two scalars and is not that. |
|
||||||
|
|
||||||
| Design element | Layer | What it would take | What ships instead |
|
| Design element | Layer | What it would take | What ships instead |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Per-host last directory | client-storage | Somewhere to persist two panes' navigation state. There is still no settings table. | The remote pane opens on the account's home directory, which the server canonicalises during the handshake; the local pane opens on the user profile. |
|
| Per-host last directory | client-storage | Somewhere to persist two panes' navigation state per host, which is a table rather than a scalar — `settings.json` holds two of those and is the wrong shape for a row per machine. | The remote pane opens on the account's home directory, which the server canonicalises during the handshake; the local pane opens on the user profile. |
|
||||||
| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. `HostSecret.JumpHostIds` is still stored, synced, merged and read by nothing. | Omitted. |
|
| `sftp over bastion-eu` | client-ssh | Jump hosts, as above. `HostSecret.JumpHostIds` is still stored, synced, merged and read by nothing. | Omitted. |
|
||||||
| Overwriting a file that is already there | ui | A prompt, which means a modal this window has no idiom for. | Refused, with the name that is in the way. The remote pane has DELETE and MKDIR so the refusal is not a dead end. |
|
| Overwriting a file that is already there | ui | A prompt, which means a modal this window has no idiom for. | Refused, with the name that is in the way. The remote pane has DELETE and MKDIR so the refusal is not a dead end. |
|
||||||
| Dragging between the panes | ui | Drag-and-drop between two `ListBox`es, plus a drop target that is a directory rather than a row. | Two arrow buttons between the panes, pointing at the pane the file is going to. |
|
| Dragging between the panes | ui | Drag-and-drop between two `ListBox`es, plus a drop target that is a directory rather than a row. | Two arrow buttons between the panes, pointing at the pane the file is going to. |
|
||||||
@@ -476,8 +691,8 @@ lists the rest as absent rather than omitting it silently.
|
|||||||
|
|
||||||
| Design element | Layer | What it would take |
|
| Design element | Layer | What it would take |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Any preference, saved | client-storage | A seventh table in the client cache, or a preference item type in the vault. Which one matters: the vault syncs, so a preference stored there follows you between machines, and a terminal font size probably should not. |
|
| Any preference, saved | — | **Two are.** `settings.json` beside the cache holds the terminal's text size and whether this machine checks for updates on its own. It is deliberately not in the vault: the vault syncs, so a preference kept there would follow you between machines, and a font size chosen for a laptop screen should not arrive on a desktop. Anything per-host or per-item wants a table instead — see the transfers rows above. |
|
||||||
| Terminal font, size, cursor style, cursor blink, scrollback | client-terminal | The store, **and** a frame to carry it. `TerminalServerOpcode` has four values (`Output`, `SessionOpened`, `SessionClosed`, `SessionActivated`) and none carries an option. |
|
| Terminal font, cursor style, cursor blink, scrollback | client-terminal | A setting, an opcode and a control, in that order — the mechanism is no longer the obstacle. `TerminalServerOpcode.FontSize` proved the path and `ClientSettings` is where the value would live; what none of these four has is somebody deciding it is worth a row on the preferences screen. |
|
||||||
| Font family picker offering IBM Plex Mono | ui | Shipping the font, as above. |
|
| Font family picker offering IBM Plex Mono | ui | Shipping the font, as above. |
|
||||||
| Audible bell | client-terminal + ui | xterm.js 6 removed `bellStyle` and `bellSound`; what remains is an `onBell` event, so the page would have to make the sound itself. |
|
| Audible bell | client-terminal + ui | xterm.js 6 removed `bellStyle` and `bellSound`; what remains is an `onBell` event, so the page would have to make the sound itself. |
|
||||||
| `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. |
|
| `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. |
|
||||||
@@ -505,3 +720,24 @@ grid of cards with a drawer — see above. The split it describes did not change
|
|||||||
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
|
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
|
||||||
sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire,
|
sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire,
|
||||||
`IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected.
|
`IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v5c-4 — two more, asked for after living with v5b
|
||||||
|
|
||||||
|
**The session shell's host header is gone, and it is a deliberate departure from the design.**
|
||||||
|
`Terminal.dc.html` and `SFTP.dc.html` both draw a 60-pixel row above the pane carrying the address on the
|
||||||
|
left and a cross-surface button on the right, and v5b shipped it as `SessionHeader.axaml`. Both of the two
|
||||||
|
facts it held now live at the head of the sidebar beside the pane — the address as its own line, and the
|
||||||
|
button stretched across the column under it — and the pane is 60 pixels taller for it. The reasoning is the
|
||||||
|
one the design cannot see from a mock: this is a window somebody keeps a terminal open in all day, and a
|
||||||
|
full-width strip repeating an address the tab already names was the cheapest 60 pixels in the layout to give
|
||||||
|
back. `LayoutHarness.SessionScreenHeight` no longer subtracts a header, for the same reason it stopped
|
||||||
|
subtracting the retired window-wide tab strip.
|
||||||
|
|
||||||
|
**The sidebar closes, which the design has no state for.** 300 pixels of a 1081-pixel minimum is a lot to
|
||||||
|
spend on a list that is often two rows long, so `MainWindowViewModel.IsSessionSidebarOpen` folds the column
|
||||||
|
to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel
|
||||||
|
that vanishes without trace is one people report as lost. The choice is written through to
|
||||||
|
`ClientSettings.SessionSidebarOpen` rather than held for the session: it is a decision about how much of the
|
||||||
|
window a terminal gets, and one that had to be made again on every launch would not really be on offer.
|
||||||
|
|||||||
+750
-153
File diff suppressed because it is too large
Load Diff
+115
-6
@@ -3,8 +3,18 @@
|
|||||||
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
|
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
|
||||||
have already cost time once. Development is Windows-first, but **the full test suite now runs on
|
have already cost time once. Development is Windows-first, but **the full test suite now runs on
|
||||||
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
|
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
|
||||||
suspicion. **macOS is still untested**, and anything marked *unverified* has not run on the platform
|
suspicion. Anything marked *unverified* has not run on the platform in question and must not be
|
||||||
in question and must not be assumed to work.
|
assumed to work.
|
||||||
|
|
||||||
|
**macOS now builds and packages, and has still never run.** The distinction matters more here than
|
||||||
|
anywhere else on this page, because the two halves are verified in completely different places. The
|
||||||
|
build is measured on every main and tag build: CI publishes `osx-arm64` and runs `vpk [osx] bundle`
|
||||||
|
on a Linux runner, which is enough to catch a restore graph with no macOS native asset and an `.app`
|
||||||
|
that will not compose. Everything past that — whether the window draws, whether the terminal's
|
||||||
|
loopback WebSocket reaches WKWebView, whether the Secure Enclave holds a device key — is verified
|
||||||
|
only by a person walking Phase 18 of [manual-checks.md](manual-checks.md) on a Mac, because **there
|
||||||
|
is no macOS runner in CI**. Treat every macOS runtime claim below as unverified unless it says
|
||||||
|
otherwise.
|
||||||
|
|
||||||
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
|
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
|
||||||
has been verified or made moot — not when it merely stops being convenient.
|
has been verified or made moot — not when it merely stops being convenient.
|
||||||
@@ -17,6 +27,19 @@ docs/crypto.md §1. *Already mitigated* — but if a BCL AEAD path is ever added
|
|||||||
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
|
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
|
||||||
any vault on macOS.
|
any vault on macOS.
|
||||||
|
|
||||||
|
**The Secure Enclave holds P-256 keys and nothing else**, which is why `MacDeviceKeyStore` wraps the
|
||||||
|
device key with ECIES rather than with the RSA-OAEP the Windows store uses. It will not hold an RSA
|
||||||
|
key at any size, so this is not a preference. The useful consequence is that the macOS shape is
|
||||||
|
*better* than the Windows one: `SecKeyCopyPublicKey` works on an enclave key without prompting, so
|
||||||
|
registering a device is silent and only unlock asks — where Windows raises a dialog at key creation
|
||||||
|
too. *Unverified:* no enclave call in this repository has ever run.
|
||||||
|
|
||||||
|
**Three ordinary Macs have no usable enclave**, and `IsSupported` probes rather than infers for that
|
||||||
|
reason: an Intel machine without a T2, a machine with no login password set, and — the one that
|
||||||
|
surprises people — **any build that is not code signed**, because enclave key creation needs a
|
||||||
|
signing identity. So `dotnet run` correctly offers no device key at all. Do not "fix" this by
|
||||||
|
checking the OS instead; the offer would then put a wrap on the server that nothing can ever open.
|
||||||
|
|
||||||
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
|
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
|
||||||
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
|
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
|
||||||
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
|
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
|
||||||
@@ -26,7 +49,11 @@ and the parameters are stored per user at enrollment, so a bad default is a per-
|
|||||||
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
|
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
|
||||||
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
||||||
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
|
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
|
||||||
or notarization fails with an error that does not name the offending file.
|
or notarization fails with an error that does not name the offending file. *Mitigated* in
|
||||||
|
`scripts/release-macos.sh`, which signs every `.dylib` and `createdump` in a loop before vpk touches
|
||||||
|
anything — vpk's own pass uses `codesign --deep`, which is the shape Apple documents as wrong for
|
||||||
|
nested code and is the likeliest source of that unnamed rejection. The loop looks redundant next to
|
||||||
|
`--deep` and is not; do not delete it because a release once succeeded without it.
|
||||||
|
|
||||||
## Desktop client
|
## Desktop client
|
||||||
|
|
||||||
@@ -361,11 +388,55 @@ agent of our own plus ProxyJump covers the real use cases.
|
|||||||
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
|
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
|
||||||
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
|
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
|
||||||
|
|
||||||
|
**That suite has an intermittent `The connection was closed by the remote host`**, on whichever test
|
||||||
|
connects first, within tens of milliseconds. Seen in CI and reproducible locally. *Mitigated, not
|
||||||
|
solved:* `SshServerFixture` now raises sshd's `MaxStartups` from its compiled-in `10:30:100`, which
|
||||||
|
refuses connections at random past ten unauthenticated ones in flight — reachable because xUnit runs
|
||||||
|
test classes in parallel and most of them connect. The fixture comment carries the full argument and
|
||||||
|
is explicit that the cure is unproven.
|
||||||
|
|
||||||
|
**And the reason it is unproven is a measurement trap worth not falling into twice.** Docker
|
||||||
|
throughput on the Windows development machine swings enough to swamp the effect: the identical
|
||||||
|
unmodified suite ran 85/85 clean and, an hour later, failed 13 runs out of 15. Any before/after flake
|
||||||
|
comparison taken there is noise. Measure this class of thing in CI, or make the server say why —
|
||||||
|
raise sshd's `LogLevel`, disable Ryuk so the container outlives the run, and read `docker logs`.
|
||||||
|
|
||||||
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
|
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
|
||||||
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
|
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
|
||||||
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
||||||
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
||||||
|
|
||||||
|
**The App Sandbox is ruled out on macOS for the same reason, and the entitlements say so.** A
|
||||||
|
sandboxed process cannot listen on loopback without `com.apple.security.network.server`, and the
|
||||||
|
terminal is that listener. Developer ID distribution outside the App Store does not require the
|
||||||
|
sandbox, so this costs nothing today — but it does mean the Mac App Store is closed to this
|
||||||
|
application without solving the data plane differently first. See
|
||||||
|
`build/macos/DodoSSH.entitlements`.
|
||||||
|
|
||||||
|
**The hardened runtime is not optional and .NET needs four holes punched in it.** Notarization
|
||||||
|
refuses a Developer ID submission without it, and CoreCLR will not start under it without
|
||||||
|
`allow-jit` and `allow-unsigned-executable-memory` — both, not either, because the runtime allocates
|
||||||
|
executable memory outside the `MAP_JIT` path as well. `disable-library-validation` and
|
||||||
|
`allow-dyld-environment-variables` are needed for Velopack's updater rather than for the runtime.
|
||||||
|
Each is argued individually in the entitlements file; the failure mode for a missing one is a
|
||||||
|
process that dies during runtime initialisation, before anything exists that could report it.
|
||||||
|
|
||||||
|
**`vpk` cross-compiles to macOS only as far as the bundle.** `vpk [osx] bundle` runs anywhere and
|
||||||
|
produces a real `.app`; there is no `[osx] pack` off a Mac, because pack drives `codesign`,
|
||||||
|
`notarytool` and `stapler`. So CI can prove the bundle builds and only a Mac can produce something
|
||||||
|
installable. Note this is the *opposite* of the Windows story, where `vpk [win] pack` builds the
|
||||||
|
whole installer on Linux — the asymmetry is Apple tooling, not a Velopack limitation.
|
||||||
|
|
||||||
|
**A custom `Info.plist` is copied verbatim by vpk, with no substitution whatsoever.** That is why
|
||||||
|
`--plist` and `--bundleId` are mutually exclusive, and why `build/macos/Info.plist.template` is a
|
||||||
|
template the release script renders rather than a committed file. A committed plist would carry one
|
||||||
|
version into every release afterwards, and the symptom is silent: Velopack's index would still be
|
||||||
|
right, the updater would still work, and only Get Info and any crash report would disagree.
|
||||||
|
|
||||||
|
**macOS app icons live on an 824-in-1024 grid.** An icon that bleeds to the edge of its canvas is
|
||||||
|
not bolder, it is the one icon in the Dock that is too big. `dodossh-icon.ps1` draws the `.icns` at
|
||||||
|
that fraction and the `.ico` at full bleed, from one geometry.
|
||||||
|
|
||||||
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
|
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
|
||||||
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
||||||
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
||||||
@@ -595,9 +666,23 @@ targeted, not which RID. Only signing needs Windows tooling, which is why `ci.ym
|
|||||||
without a target *evaluates* the project and runs nothing, while MinVer computes the version inside a
|
without a target *evaluates* the project and runs nothing, while MinVer computes the version inside a
|
||||||
target — so the read comes back as the SDK default on a full checkout with every tag present, which looks
|
target — so the read comes back as the SDK default on a full checkout with every tag present, which looks
|
||||||
exactly like a version that was never configured. `-t:MinVer` makes `-getProperty` report the value after
|
exactly like a version that was never configured. `-t:MinVer` makes `-getProperty` report the value after
|
||||||
that target has run, and both readers of it — the tag check in `ci.yml` and `scripts/release-windows.ps1`
|
that target has run, and every reader of it — the tag check in `ci.yml`, the desktop nightly job, and
|
||||||
— pass it. Neither had, and neither had ever run: the CI check is `if:` a tag ref and there are no tags
|
`scripts/release-windows.ps1` — passes it. Neither of the first two had, and neither had ever run: the CI
|
||||||
yet, so the first release would have been refused by its own guard, which would have blamed `fetch-depth`.
|
check is `if:` a tag ref and there are no tags yet, so the first release would have been refused by its own
|
||||||
|
guard, which would have blamed `fetch-depth`.
|
||||||
|
|
||||||
|
**And naming that target requires a restore first, which is a second failure wearing a very different
|
||||||
|
face.** MinVer arrives as a package, so its target is imported from `obj/*.nuget.g.targets` and does not
|
||||||
|
exist at all on a clean checkout:
|
||||||
|
|
||||||
|
```
|
||||||
|
error MSB4057: The target "MinVer" does not exist in the project.
|
||||||
|
```
|
||||||
|
|
||||||
|
That reads like a typo in the workflow rather than like a missing restore, and it does not reproduce on any
|
||||||
|
machine that has built the project before — which is every developer machine and no fresh runner. The
|
||||||
|
build job's tag check is safe because it runs after that job's own restore; the desktop nightly job and the
|
||||||
|
release script each restore before reading, deliberately and with a comment saying why.
|
||||||
|
|
||||||
**The Android head's lock file is outside the solution, so nothing checks it until the android job runs
|
**The Android head's lock file is outside the solution, so nothing checks it until the android job runs
|
||||||
— and the android job was broken for an unrelated reason for the whole of the release that went stale.**
|
— and the android job was broken for an unrelated reason for the whole of the release that went stale.**
|
||||||
@@ -627,6 +712,30 @@ The lasting hazard is the first paragraph and not the fix. Any change to a share
|
|||||||
to a lock file this repository cannot verify from a machine without the Android workload, and it will go
|
to a lock file this repository cannot verify from a machine without the Android workload, and it will go
|
||||||
on being noticed later than every other one.
|
on being noticed later than every other one.
|
||||||
|
|
||||||
|
**A lock file can go stale with nothing in this repository changing, because `Microsoft.NET.ILLink.Tasks`
|
||||||
|
is versioned by the SDK and `global.json` lets the SDK float.** The reference is implicit — nothing in any
|
||||||
|
`.csproj` asks for it — and its version tracks the runtime patch band, while `global.json` pins only
|
||||||
|
`10.0.100` with `rollForward: latestMinor`. So `setup-dotnet` installs whatever the newest 10.x SDK is on
|
||||||
|
the day, and the moment that SDK's band moves, locked-mode restore stops:
|
||||||
|
|
||||||
|
```
|
||||||
|
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||||
|
from [10.0.10, ) to [10.0.11, ).
|
||||||
|
```
|
||||||
|
|
||||||
|
It named `DodoSSH.Client.Android`, `DodoSSH.Contracts` and `DodoSSH.Crypto` — the three lock files that
|
||||||
|
carry the entry — on a commit that touched none of them and no dependency at all.
|
||||||
|
|
||||||
|
The fix is `--force-evaluate` on those three, **from a machine whose SDK is at least as new as the
|
||||||
|
runner's**, which is the part that is easy to get wrong: a `--force-evaluate` from an older SDK rewrites
|
||||||
|
the lock at the older version, changes nothing, and looks like it worked. Check `dotnet --version` against
|
||||||
|
the version in the error before believing a regeneration.
|
||||||
|
|
||||||
|
This will recur on every SDK patch that moves the band. It is the accepted cost of letting the SDK float:
|
||||||
|
the alternative is pinning an exact SDK in `global.json`, which trades a recurring lock-file bump for a
|
||||||
|
recurring toolchain bump and makes every contributor install one specific SDK. Neither is free, and this
|
||||||
|
repository has chosen the floating side deliberately.
|
||||||
|
|
||||||
**.NET for Android cannot be built on a musl host, and this project's runner is Alpine. Every message the
|
**.NET for Android cannot be built on a musl host, and this project's runner is Alpine. Every message the
|
||||||
toolchain produces on the way to saying so names a missing file that is present.** Three CI rounds went
|
toolchain produces on the way to saying so names a missing file that is present.** Three CI rounds went
|
||||||
into this and the first two fixed symptoms, so the messages are worth reading in the order they arrive.
|
into this and the first two fixed symptoms, so the messages are worth reading in the order they arrive.
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# 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
|
||||||
|
neither of them works.
|
||||||
|
|
||||||
|
- The **relay** pipes raw TCP through the deployment. The server half is built and shipped; the client
|
||||||
|
half does not exist, and both heads offer a checkbox that promises it.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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: step 0 done, the rest planned.**
|
||||||
|
>
|
||||||
|
> | Step | State | Notes |
|
||||||
|
> | --- | --- | --- |
|
||||||
|
> | 0. Stop promising the relay | **Done** | Both heads say the box is not wired up and that ticking it stores the address and changes nothing. Left tickable, so a host already carrying the flag can lose it |
|
||||||
|
> | 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 | Not started | The bridge's only consumer, so it lands with the feature that uses it |
|
||||||
|
> | 4. File transfer parity | Not started | The transfers screen opens its own connection. Already half-done — `OpenSftpAsync` shares `BuildConnectionInfo`, so it honours a proxy today; what is missing is anything passing it one |
|
||||||
|
|
||||||
|
## ◆ 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- ADR 0004 gains a note that its last consequence was built, and how.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Builds, packages and publishes the macOS desktop client.
|
||||||
|
#
|
||||||
|
# The counterpart of scripts/release-windows.ps1, and deliberately the same shape: run by a person, on a
|
||||||
|
# Mac that is not a CI runner, in two phases with the upload withheld until somebody has installed what
|
||||||
|
# phase one built and walked the manual checks. docs/adr/0011-android-distribution.md rule 1 puts the
|
||||||
|
# capability to ship somebody a build on a machine which is not a runner, and
|
||||||
|
# docs/adr/0013-desktop-distribution-and-updates.md explains why the token that writes a Gitea release is
|
||||||
|
# that capability: Velopack clients trust their feed and do not verify a package signature when they apply
|
||||||
|
# it, so whoever can write a release can ship an update every install runs.
|
||||||
|
#
|
||||||
|
# 1. Without --upload: builds, signs, notarizes, packs, and stops. Nothing has left this machine
|
||||||
|
# except the notarization submission, which Apple sees and users do not.
|
||||||
|
# 2. With --upload: asks for the forge token and publishes what phase one produced. It does not
|
||||||
|
# rebuild, so the bytes that reach users are the bytes that were installed and checked.
|
||||||
|
#
|
||||||
|
# ◆ WHAT IS DIFFERENT FROM THE WINDOWS SCRIPT, AND WHY.
|
||||||
|
#
|
||||||
|
# Signing is not optional here. On Windows an unsigned installer costs a SmartScreen dialog once per
|
||||||
|
# user, which is why that script has no --signParams and says so. On macOS an un-notarized download is
|
||||||
|
# refused outright by Gatekeeper — not warned about, refused — so the Developer ID certificate and the
|
||||||
|
# notarization round trip are the price of the package being installable at all, not an improvement to
|
||||||
|
# be bought later.
|
||||||
|
#
|
||||||
|
# ◆ CREDENTIALS COME FROM THE KEYCHAIN AND THE ENVIRONMENT, NOT FROM THIS FILE.
|
||||||
|
#
|
||||||
|
# Three values are read from the environment, and none of them is itself a secret — they name things the
|
||||||
|
# keychain holds, and the keychain is what guards the private key and the App Store Connect credentials:
|
||||||
|
#
|
||||||
|
# DODOSSH_SIGN_APP_IDENTITY e.g. "Developer ID Application: DodoTech (TEAMID)"
|
||||||
|
# DODOSSH_SIGN_INSTALL_IDENTITY e.g. "Developer ID Installer: DodoTech (TEAMID)"
|
||||||
|
# DODOSSH_NOTARY_PROFILE the profile name given to `xcrun notarytool store-credentials`
|
||||||
|
#
|
||||||
|
# `security find-identity -v -p codesigning` lists the first two exactly as codesign wants them. The
|
||||||
|
# third is created once per machine:
|
||||||
|
#
|
||||||
|
# xcrun notarytool store-credentials DodoSSH \
|
||||||
|
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
|
||||||
|
#
|
||||||
|
# The forge token is the one real secret, and it is prompted for rather than read from a file or the
|
||||||
|
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
|
||||||
|
# minutes a credential that can publish an update spends in a shell's memory the better.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash scripts/release-macos.sh
|
||||||
|
# bash scripts/release-macos.sh --upload
|
||||||
|
# bash scripts/release-macos.sh --skip-tests
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
UPLOAD=0
|
||||||
|
SKIP_TESTS=0
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--upload) UPLOAD=1 ;;
|
||||||
|
--skip-tests) SKIP_TESTS=1 ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $arg" >&2
|
||||||
|
echo "Usage: bash scripts/release-macos.sh [--upload] [--skip-tests]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---- The contract with every installed client ---------------------------------------------------------
|
||||||
|
|
||||||
|
# Velopack's identity for this application, and it is effectively irreversible for the reasons the Windows
|
||||||
|
# script states — it is what an installed client matches an update against.
|
||||||
|
#
|
||||||
|
# ◆ THE SAME PACK ID AS WINDOWS, AND ON THIS PLATFORM IT IS VISIBLE.
|
||||||
|
#
|
||||||
|
# vpk names the bundle after the pack id, so this produces DodoSSH.Desktop.app rather than DodoSSH.app,
|
||||||
|
# and that is what somebody sees in /Applications. It is kept anyway, because the alternative is worse:
|
||||||
|
# a pack id of DodoSSH would put Velopack's install and its uninstall on ~/Library/Application Support/
|
||||||
|
# DodoSSH, which is exactly where ClientPaths keeps the encrypted cache, the outbox of changes not yet
|
||||||
|
# pushed and the device key. Sharing that directory would mean an uninstall silently taking a user's
|
||||||
|
# un-synced work with it. The same reasoning, and the same conclusion, as the Windows script.
|
||||||
|
#
|
||||||
|
# What a person actually reads is CFBundleDisplayName, which build/macos/Info.plist.template sets to
|
||||||
|
# DodoSSH. So the bundle keeps the id and the Dock shows the product.
|
||||||
|
PACK_ID='DodoSSH.Desktop'
|
||||||
|
PACK_TITLE='DodoSSH'
|
||||||
|
PACK_AUTHORS='DodoTech'
|
||||||
|
|
||||||
|
# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
|
||||||
|
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
|
||||||
|
# The owner is part of it: Gitea left a 301 at the old organisation's path, which a GET follows and an
|
||||||
|
# upload does not.
|
||||||
|
REPO_URL='https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
|
||||||
|
|
||||||
|
# A contract with VelopackUpdateChannel.MacReleaseChannel. Velopack's macOS default is also "osx", so
|
||||||
|
# leaving it unsaid on both sides would work — but unsaid here and stated there is how a feed goes quiet
|
||||||
|
# with no error at all: the client checks, finds nothing, and reports itself up to date forever.
|
||||||
|
CHANNEL='osx'
|
||||||
|
|
||||||
|
# ◆ ARM64 ONLY, AND THAT IS A DECISION RATHER THAN AN OVERSIGHT.
|
||||||
|
#
|
||||||
|
# Velopack keys a channel to one architecture, so shipping Intel too means a second channel, a second
|
||||||
|
# publish, a second set of deltas and a second thing to keep in step with the client's channel picker.
|
||||||
|
# That is all affordable. What is not currently affordable is testing it: nobody here has an Intel Mac,
|
||||||
|
# and docs/manual-checks.md exists because this project does not ship desktop builds no one has run.
|
||||||
|
# An x64 package built blind and published beside a checked arm64 one would be the only artefact in this
|
||||||
|
# repository that reached users unverified.
|
||||||
|
#
|
||||||
|
# Adding it later is this constant, a second channel name in VelopackUpdateChannel, and a picker keyed on
|
||||||
|
# RuntimeInformation.ProcessArchitecture — which reports X64 for a build running under Rosetta, so an
|
||||||
|
# Intel build correctly stays on the Intel feed. The work is small; the check is the part that is missing.
|
||||||
|
RUNTIME='osx-arm64'
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
PROJECT="$REPO_ROOT/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj"
|
||||||
|
SOLUTION="$REPO_ROOT/DodoSSH.slnx"
|
||||||
|
PUBLISH_DIR="$REPO_ROOT/publish/$RUNTIME"
|
||||||
|
RELEASES_DIR="$REPO_ROOT/Releases"
|
||||||
|
ICON="$REPO_ROOT/src/DodoSSH.Client.App/Assets/dodossh.icns"
|
||||||
|
ENTITLEMENTS="$REPO_ROOT/build/macos/DodoSSH.entitlements"
|
||||||
|
PLIST_TEMPLATE="$REPO_ROOT/build/macos/Info.plist.template"
|
||||||
|
|
||||||
|
write_step() { printf '\n\033[36m==> %s\033[0m\n' "$1"; }
|
||||||
|
stop_with() { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ---- Is this machine able to do the job at all? -------------------------------------------------------
|
||||||
|
|
||||||
|
if [ "$(uname -s)" != 'Darwin' ]; then
|
||||||
|
# codesign, notarytool and stapler are Apple tooling and exist nowhere else. The build and even the
|
||||||
|
# .app bundle cross-compile fine from Windows or Linux — `vpk [osx] bundle` does exactly that, and
|
||||||
|
# ci.yml uses it to prove the bundle still builds — but a signed, notarized, installable package
|
||||||
|
# cannot be produced anywhere but here.
|
||||||
|
stop_with 'This builds a signed macOS package and has to run on macOS.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
for tool in dotnet git xcrun codesign; do
|
||||||
|
command -v "$tool" >/dev/null 2>&1 || stop_with "$tool is not on PATH."
|
||||||
|
done
|
||||||
|
|
||||||
|
# Checked before anything is built rather than at the step that uses them. Notarization is the last thing
|
||||||
|
# this script does and the slowest, and discovering there that a profile name was never exported means
|
||||||
|
# throwing away a full build and test run.
|
||||||
|
for required in DODOSSH_SIGN_APP_IDENTITY DODOSSH_SIGN_INSTALL_IDENTITY DODOSSH_NOTARY_PROFILE; do
|
||||||
|
if [ -z "${!required-}" ]; then
|
||||||
|
stop_with "$required is not set. See the header of this script for what the three are and how to make them."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# ---- What is being released ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Restored before the version is read, and both halves are load-bearing — the same two traps the Windows
|
||||||
|
# script documents. -t:MinVer, because -getProperty alone evaluates the project and runs no targets, while
|
||||||
|
# MinVer sets Version from inside one, so the read would answer the SDK's default 1.0.0 regardless of the
|
||||||
|
# tag. And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
|
||||||
|
# clone where obj/ has no MinVer targets to import yet.
|
||||||
|
write_step 'Restoring the desktop head, so the version can be read'
|
||||||
|
dotnet restore "$PROJECT" --locked-mode || stop_with 'Restore failed.'
|
||||||
|
|
||||||
|
VERSION="$(dotnet msbuild "$PROJECT" -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
|
||||||
|
[ -n "$VERSION" ] || stop_with 'Could not read the version from MSBuild.'
|
||||||
|
|
||||||
|
TAG="v$VERSION"
|
||||||
|
|
||||||
|
# Apple's two version keys take one to three dot-separated integers and nothing else, so a prerelease
|
||||||
|
# version has to have its suffix removed before it reaches the plist. 1.2.3-rc.1 becomes 1.2.3.
|
||||||
|
#
|
||||||
|
# The full version, suffix and all, is what vpk packs and what the release index carries, so the updater
|
||||||
|
# still tells an rc from the release it precedes. These two keys are for Finder and Gatekeeper, which
|
||||||
|
# care that the string parses and not what it says. See build/macos/Info.plist.template.
|
||||||
|
PLIST_VERSION="${VERSION%%-*}"
|
||||||
|
PLIST_VERSION="${PLIST_VERSION%%+*}"
|
||||||
|
|
||||||
|
write_step "DodoSSH $VERSION ($PACK_ID, channel $CHANNEL, $RUNTIME)"
|
||||||
|
|
||||||
|
# ---- Phase 2: publish what phase 1 built --------------------------------------------------------------
|
||||||
|
|
||||||
|
if [ "$UPLOAD" -eq 1 ]; then
|
||||||
|
# The installer package is the artefact a person downloads, so its absence is the honest test of
|
||||||
|
# whether phase one ever ran. A directory holding only a .nupkg is a pack that failed part way.
|
||||||
|
if ! ls "$RELEASES_DIR"/*.pkg >/dev/null 2>&1; then
|
||||||
|
stop_with "Nothing to upload: $RELEASES_DIR has no .pkg. Run this without --upload first."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "About to publish the contents of $RELEASES_DIR to $REPO_URL as $TAG."
|
||||||
|
echo 'Only do this once you have installed it and walked Phase 18 of docs/manual-checks.md.'
|
||||||
|
|
||||||
|
# -s so the token is never echoed and never lands in the shell's history.
|
||||||
|
printf 'Gitea token (write:repository): '
|
||||||
|
read -r -s TOKEN
|
||||||
|
echo
|
||||||
|
|
||||||
|
[ -n "$TOKEN" ] || stop_with 'No token given.'
|
||||||
|
|
||||||
|
# --merge because Gitea already has a release entry for the pushed tag — and on this platform it may
|
||||||
|
# also already hold the Windows package for the same tag, which is the case --merge is really doing
|
||||||
|
# the work for: without it the second platform to publish a given version fails on a release that
|
||||||
|
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
|
||||||
|
UPLOAD_ARGS=(
|
||||||
|
upload gitea
|
||||||
|
--repoUrl "$REPO_URL"
|
||||||
|
--token "$TOKEN"
|
||||||
|
--outputDir "$RELEASES_DIR"
|
||||||
|
--channel "$CHANNEL"
|
||||||
|
--releaseName "$TAG"
|
||||||
|
--tag "$TAG"
|
||||||
|
--merge
|
||||||
|
--publish
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mirrors the rule the docker image job and the Windows script already apply to the same tag, so a
|
||||||
|
# release candidate is a prerelease in every channel or in none.
|
||||||
|
case "$VERSION" in
|
||||||
|
*-*) UPLOAD_ARGS+=(--pre) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
write_step 'Uploading'
|
||||||
|
dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'
|
||||||
|
|
||||||
|
write_step "Published $TAG."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Phase 1: build, sign, notarize, pack -------------------------------------------------------------
|
||||||
|
|
||||||
|
[ -z "$(git status --porcelain)" ] || stop_with 'The working tree is not clean. A release is cut from a commit, not from a desk.'
|
||||||
|
|
||||||
|
HEAD_TAG="$(git describe --exact-match --tags HEAD 2>/dev/null || true)"
|
||||||
|
[ -n "$HEAD_TAG" ] || stop_with "HEAD is not tagged. Tag it $TAG first, or change the version and tag that."
|
||||||
|
|
||||||
|
# Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the day
|
||||||
|
# somebody pins a version by hand this is the guard that notices.
|
||||||
|
[ "$HEAD_TAG" = "$TAG" ] || stop_with "HEAD is tagged $HEAD_TAG but the computed version is $VERSION."
|
||||||
|
|
||||||
|
write_step 'Restoring tools'
|
||||||
|
dotnet tool restore || stop_with 'dotnet tool restore failed.'
|
||||||
|
|
||||||
|
write_step 'Restoring packages (locked, exactly as CI does)'
|
||||||
|
dotnet restore "$SOLUTION" --locked-mode || stop_with 'Restore failed. A lock file that only works on Linux fails here.'
|
||||||
|
|
||||||
|
write_step 'Building'
|
||||||
|
dotnet build "$SOLUTION" --no-restore --configuration Release || stop_with 'Build failed.'
|
||||||
|
|
||||||
|
if [ "$SKIP_TESTS" -eq 0 ]; then
|
||||||
|
# The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
|
||||||
|
# on trust from CI, because a tag is the one build nobody is watching — and on this platform there is
|
||||||
|
# a second reason: CI has no macOS runner, so this is the only place the suite ever runs on a Mac at
|
||||||
|
# all. Everything docs/platform-flags.md lists as unverified on macOS is verified here or nowhere.
|
||||||
|
write_step 'Testing'
|
||||||
|
dotnet test "$SOLUTION" --no-build --configuration Release || stop_with 'Tests failed.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_step "Publishing $RUNTIME"
|
||||||
|
rm -rf "$PUBLISH_DIR"
|
||||||
|
|
||||||
|
# Self-contained, and not single-file, for the reasons the Windows script gives: the native libraries ship
|
||||||
|
# per RID and a self-extracting bundle breaks delta updates.
|
||||||
|
#
|
||||||
|
# RestoreLockedMode=false, and the lock files put back straight afterwards. A RID-specific publish resolves
|
||||||
|
# a graph the committed lock files do not describe, because they are deliberately kept RID-free —
|
||||||
|
# declaring a RID on the head writes a net10.0/<rid> target into every project it references transitively,
|
||||||
|
# including DodoSSH.Contracts and DodoSSH.Crypto, and the API's Dockerfile then restores those with no RID
|
||||||
|
# under locked mode and fails NU1004. Packaging the desktop client would have broken the server's image
|
||||||
|
# build. The gate that matters is the locked solution restore above, which is untouched.
|
||||||
|
dotnet publish "$PROJECT" \
|
||||||
|
--configuration Release \
|
||||||
|
--runtime "$RUNTIME" \
|
||||||
|
--self-contained true \
|
||||||
|
--output "$PUBLISH_DIR" \
|
||||||
|
-p:RestoreLockedMode=false \
|
||||||
|
|| stop_with 'Publish failed.'
|
||||||
|
|
||||||
|
# An unlocked restore rewrites the lock files it walked. Left there, the next commit would carry exactly
|
||||||
|
# the change that breaks the image build. Safe to do bluntly because this script refuses to run on a dirty
|
||||||
|
# tree, so anything modified here is its own.
|
||||||
|
git checkout -- '*packages.lock.json' || stop_with 'Could not restore the lock files after publishing.'
|
||||||
|
|
||||||
|
# Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for an
|
||||||
|
# application that never checks for updates — which looks completely normal until the next release goes out
|
||||||
|
# and nobody receives it.
|
||||||
|
for required in DodoSSH Velopack.dll; do
|
||||||
|
[ -e "$PUBLISH_DIR/$required" ] || stop_with "$required is missing from $PUBLISH_DIR."
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " $(du -sh "$PUBLISH_DIR" | cut -f1) in $(find "$PUBLISH_DIR" -type f | wc -l | tr -d ' ') files"
|
||||||
|
|
||||||
|
# ---- Signing the native libraries, before vpk signs anything ------------------------------------------
|
||||||
|
|
||||||
|
# ◆ THIS LOOP IS WHY NOTARIZATION SUCCEEDS, AND IT LOOKS REDUNDANT.
|
||||||
|
#
|
||||||
|
# vpk signs the finished bundle itself, with `codesign -f -v --timestamp --options runtime --entitlements
|
||||||
|
# <file> --deep`, and --deep is documented by Apple as the wrong way to sign nested code. Apple's guidance
|
||||||
|
# is inside-out: sign each nested binary first, then the bundle around it. --deep does the reverse in one
|
||||||
|
# pass and applies the outer entitlements to everything it touches.
|
||||||
|
#
|
||||||
|
# In practice --deep alone is where the failure recorded in docs/platform-flags.md comes from — a
|
||||||
|
# notarization rejection that does not name the offending file, on a submission that took its time getting
|
||||||
|
# there. Signing each dylib properly first means vpk's pass has nothing left to get wrong, and re-signing
|
||||||
|
# an already correctly signed binary with -f is a no-op in effect.
|
||||||
|
#
|
||||||
|
# No --entitlements here, and that is the difference that matters. Entitlements belong on the main
|
||||||
|
# executable; a dylib carrying allow-jit is at best meaningless and at worst a rejection.
|
||||||
|
write_step 'Signing native libraries'
|
||||||
|
|
||||||
|
# createdump is a Mach-O executable the runtime ships and it is signed like the libraries: a nested
|
||||||
|
# executable that is not signed fails notarization exactly as an unsigned dylib does, and it is the one
|
||||||
|
# people forget because it has no extension to grep for.
|
||||||
|
NATIVE_COUNT=0
|
||||||
|
while IFS= read -r -d '' binary; do
|
||||||
|
codesign --force --verbose=0 --timestamp --options runtime \
|
||||||
|
--sign "$DODOSSH_SIGN_APP_IDENTITY" "$binary" \
|
||||||
|
|| stop_with "codesign failed on $binary"
|
||||||
|
NATIVE_COUNT=$((NATIVE_COUNT + 1))
|
||||||
|
done < <(find "$PUBLISH_DIR" \( -name '*.dylib' -o -name 'createdump' \) -type f -print0)
|
||||||
|
|
||||||
|
[ "$NATIVE_COUNT" -gt 0 ] || stop_with "No native binaries found under $PUBLISH_DIR, which cannot be right for a self-contained publish."
|
||||||
|
echo " signed $NATIVE_COUNT native binaries"
|
||||||
|
|
||||||
|
# ---- The bundle's Info.plist --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Rendered rather than committed, because vpk copies a custom plist verbatim and substitutes nothing —
|
||||||
|
# so a committed one would carry whatever version it was written with into every release afterwards.
|
||||||
|
# See the header of build/macos/Info.plist.template.
|
||||||
|
write_step "Rendering Info.plist for $PLIST_VERSION"
|
||||||
|
RENDERED_PLIST="$(mktemp -t dodossh-plist)"
|
||||||
|
trap 'rm -f "$RENDERED_PLIST"' EXIT
|
||||||
|
|
||||||
|
sed "s/@VERSION@/$PLIST_VERSION/g" "$PLIST_TEMPLATE" > "$RENDERED_PLIST"
|
||||||
|
|
||||||
|
# The placeholder is the whole mechanism, so its absence is checked rather than hoped for. A template
|
||||||
|
# somebody edited into a literal version would otherwise sail through and pin every future release to it.
|
||||||
|
grep -q '@VERSION@' "$PLIST_TEMPLATE" || stop_with "$PLIST_TEMPLATE has no @VERSION@ placeholder left in it."
|
||||||
|
! grep -q '@VERSION@' "$RENDERED_PLIST" || stop_with 'Substitution into the rendered Info.plist did not take.'
|
||||||
|
|
||||||
|
mkdir -p "$RELEASES_DIR"
|
||||||
|
|
||||||
|
# The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
|
||||||
|
# macOS release has no predecessor, and a hard failure here would make cutting it impossible.
|
||||||
|
write_step 'Fetching the previous release, for deltas'
|
||||||
|
if ! dotnet vpk download gitea --repoUrl "$REPO_URL" --outputDir "$RELEASES_DIR" --channel "$CHANNEL"; then
|
||||||
|
echo ' Nothing came down. This package will be full-only, which is right for a first release.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Pack, sign, notarize, staple ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
# One command does the rest, and it is worth knowing what it is doing on your behalf, because the slow
|
||||||
|
# part is not local: it builds the .app from the published files, signs it with the Developer ID
|
||||||
|
# certificate and the entitlements below, submits it to Apple with `xcrun notarytool submit --wait`,
|
||||||
|
# staples the resulting ticket to the package, and then builds the .pkg installer and the release index.
|
||||||
|
#
|
||||||
|
# The notarization wait is the reason this step can take a quarter of an hour and occasionally much
|
||||||
|
# longer — it is a queue at Apple, not a computation here, and vpk's own message says so.
|
||||||
|
#
|
||||||
|
# --signInstallIdentity is a different certificate from --signAppIdentity, and the pair is not
|
||||||
|
# interchangeable: "Developer ID Application" signs the bundle, "Developer ID Installer" signs the .pkg.
|
||||||
|
# Passing one where the other belongs fails with a message about an identity that cannot be found, which
|
||||||
|
# reads like a keychain problem rather than like the wrong certificate.
|
||||||
|
write_step 'Packing, signing and notarizing (the notarization wait is Apple queueing, not this machine)'
|
||||||
|
|
||||||
|
dotnet vpk pack \
|
||||||
|
--packId "$PACK_ID" \
|
||||||
|
--packVersion "$VERSION" \
|
||||||
|
--packDir "$PUBLISH_DIR" \
|
||||||
|
--packTitle "$PACK_TITLE" \
|
||||||
|
--packAuthors "$PACK_AUTHORS" \
|
||||||
|
--mainExe 'DodoSSH' \
|
||||||
|
--icon "$ICON" \
|
||||||
|
--plist "$RENDERED_PLIST" \
|
||||||
|
--entitlements "$ENTITLEMENTS" \
|
||||||
|
--signAppIdentity "$DODOSSH_SIGN_APP_IDENTITY" \
|
||||||
|
--signInstallIdentity "$DODOSSH_SIGN_INSTALL_IDENTITY" \
|
||||||
|
--notaryProfile "$DODOSSH_NOTARY_PROFILE" \
|
||||||
|
--runtime "$RUNTIME" \
|
||||||
|
--channel "$CHANNEL" \
|
||||||
|
--outputDir "$RELEASES_DIR" \
|
||||||
|
|| stop_with 'vpk pack failed.'
|
||||||
|
|
||||||
|
# ---- Did the notarization actually take? --------------------------------------------------------------
|
||||||
|
|
||||||
|
# Asked rather than assumed, and this is the check worth having above all the others. A package whose
|
||||||
|
# ticket did not staple is indistinguishable from a good one on the machine that built it — the Mac that
|
||||||
|
# signed something trusts it locally — and reveals itself only on somebody else's machine, as a refusal
|
||||||
|
# to open at all. spctl assesses it the way Gatekeeper will on a machine that has never seen this
|
||||||
|
# certificate.
|
||||||
|
write_step 'Verifying the notarization the way another Mac will'
|
||||||
|
|
||||||
|
PKG="$(ls -t "$RELEASES_DIR"/*.pkg 2>/dev/null | head -n 1)"
|
||||||
|
[ -n "$PKG" ] || stop_with 'vpk pack reported success but produced no .pkg.'
|
||||||
|
|
||||||
|
if ! spctl --assess --type install --verbose=4 "$PKG"; then
|
||||||
|
stop_with "Gatekeeper rejects $PKG. It is signed but the notarization ticket is missing or stale; do not upload it."
|
||||||
|
fi
|
||||||
|
|
||||||
|
xcrun stapler validate "$PKG" || stop_with "The notarization ticket is not stapled to $PKG."
|
||||||
|
|
||||||
|
write_step 'Built, notarized, and deliberately not uploaded'
|
||||||
|
|
||||||
|
ls -lh "$RELEASES_DIR" | tail -n +2
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Next:
|
||||||
|
1. Install the .pkg above and walk Phase 18 of docs/manual-checks.md.
|
||||||
|
2. Then: bash scripts/release-macos.sh --upload
|
||||||
|
EOF
|
||||||
@@ -71,6 +71,11 @@ $RepoUrl = 'https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
|
|||||||
# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it
|
# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it
|
||||||
# unsaid on both sides would work too — but unsaid here and stated there is how a feed goes quiet with no
|
# unsaid on both sides would work too — but unsaid here and stated there is how a feed goes quiet with no
|
||||||
# error at all: the client checks, finds nothing, and reports itself up to date forever.
|
# error at all: the client checks, finds nothing, and reports itself up to date forever.
|
||||||
|
#
|
||||||
|
# The nightly channel is the same three values with different contents — DodoSSH.Desktop.Nightly, DodoSSH
|
||||||
|
# Nightly, win-nightly — and they live in the `desktop nightly` job in .github/workflows/ci.yml rather than
|
||||||
|
# here, because that build is CI's and this script is a person's. Nothing shares them, deliberately: this
|
||||||
|
# file must keep working if that job is deleted. See ADR 0013 decision 9 for what the separation buys.
|
||||||
$Channel = 'win'
|
$Channel = 'win'
|
||||||
|
|
||||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
@@ -100,11 +105,20 @@ Push-Location $RepoRoot
|
|||||||
try {
|
try {
|
||||||
# ---- What is being released -----------------------------------------------------------------------
|
# ---- What is being released -----------------------------------------------------------------------
|
||||||
|
|
||||||
# -t:MinVer, and it is load-bearing. -getProperty on its own evaluates the project and runs no
|
# Restored before the version is read, and both halves of that sentence are load-bearing.
|
||||||
# targets, while MinVer sets Version from inside one — so this read answered the SDK's default
|
#
|
||||||
# 1.0.0 regardless of the tag, and the check below would then have refused to build anything not
|
# -t:MinVer, because -getProperty on its own evaluates the project and runs no targets, while MinVer
|
||||||
# tagged v1.0.0. CI's tag check had the same line and the same fault; both are fixed, and both
|
# sets Version from inside one — so this read answered the SDK's default 1.0.0 regardless of the tag,
|
||||||
# say so, because this is the version that ends up in the package a client compares against.
|
# and the check below would then have refused to build anything not tagged v1.0.0. CI's tag check had
|
||||||
|
# the same line and the same fault.
|
||||||
|
#
|
||||||
|
# And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
|
||||||
|
# clone, where obj/ has no MinVer targets to import yet. It costs seconds on a machine that has built
|
||||||
|
# before, which is every machine except the one this would otherwise fail on.
|
||||||
|
Write-Step 'Restoring the desktop head, so the version can be read'
|
||||||
|
& dotnet restore $Project --locked-mode
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed.' }
|
||||||
|
|
||||||
$version = (& dotnet msbuild $Project -getProperty:Version -t:MinVer -nologo) -replace '\s', ''
|
$version = (& dotnet msbuild $Project -getProperty:Version -t:MinVer -nologo) -replace '\s', ''
|
||||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||||
Stop-With 'Could not read the version from MSBuild.'
|
Stop-With 'Could not read the version from MSBuild.'
|
||||||
|
|||||||
@@ -90,32 +90,61 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
|
|
||||||
shell.DataContext = viewModel;
|
shell.DataContext = viewModel;
|
||||||
|
|
||||||
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
|
// Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
|
||||||
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
|
// does and why.
|
||||||
//
|
ComposeKeepAlive(workspace, viewModel);
|
||||||
// The transfer count is real now that the document picker gives this head a way to start one, and
|
|
||||||
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
|
|
||||||
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
|
|
||||||
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
|
|
||||||
// moves five files.
|
|
||||||
//
|
|
||||||
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
|
|
||||||
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
|
|
||||||
// because it is subscribed to the workspace, which lives as long as the process.
|
|
||||||
var keepAlive = new SessionKeepAlive(
|
|
||||||
workspace,
|
|
||||||
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
|
|
||||||
|
|
||||||
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
|
||||||
// announces transfers appearing and finishing. Without this the notification would come up when an
|
|
||||||
// upload started and stay up after it finished, which is the failure this class exists to prevent.
|
|
||||||
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
|
|
||||||
|
|
||||||
keepAlive.Refresh();
|
|
||||||
|
|
||||||
return shell;
|
return shell;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wires up the foreground service that makes <c>TerminalWorkspace</c>'s promise — that a shell outlives
|
||||||
|
/// a vault lock — true on a platform that stops backgrounded processes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Split out of <see cref="Compose"/> for length rather than for reuse; there is exactly one caller.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The transfer count is real now that the document picker gives this head a way to start one, and it
|
||||||
|
/// matters exactly when nobody is looking: a shell survives backgrounding because somebody opened it,
|
||||||
|
/// and an upload has to survive precisely when nobody is — the screen is off and the phone is in a
|
||||||
|
/// pocket. Queued counts as active, so putting five files in the queue and locking the phone moves five
|
||||||
|
/// files. <c>holdsFileSession</c> covers the third case a count alone cannot: a host connected on the
|
||||||
|
/// Files screen with no transfer moving is still a live SFTP session that backgrounding would sever, and
|
||||||
|
/// <c>TransfersViewModel.HasLiveFileSession</c> is the existing fact — <c>IsConnected</c> with a real
|
||||||
|
/// cipher, which a bucket never has — that answers whether one is open.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>keepAlive</c> is a local rather than a field, matching the desktop head: an Avalonia Application
|
||||||
|
/// has no disposal hook, so a field holding a disposable would have nowhere honest to release it. It
|
||||||
|
/// stays alive because it is subscribed to the workspace, which lives as long as the process.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void ComposeKeepAlive(TerminalWorkspace workspace, MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
var keepAlive = new SessionKeepAlive(
|
||||||
|
workspace,
|
||||||
|
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
|
||||||
|
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession);
|
||||||
|
|
||||||
|
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
||||||
|
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
|
||||||
|
// Without this the notification would come up when an upload started and stay up after it
|
||||||
|
// finished, which is the failure this class exists to prevent.
|
||||||
|
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
|
||||||
|
|
||||||
|
// The half that was missing until now: a shell opening. SessionKeepAlive already heard the
|
||||||
|
// workspace announce a session ending, but nothing announced the opposite — a user who opened a
|
||||||
|
// shell and backgrounded the app had no foreground service at all, because the only wire in was the
|
||||||
|
// one for taking it down. TerminalSessionOpened is that other half, forwarded from
|
||||||
|
// VaultViewModel.SessionOpened, and without this line the service could never come up for a shell
|
||||||
|
// in the first place, which was precisely the promise this whole arrangement exists to keep.
|
||||||
|
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
|
||||||
|
|
||||||
|
keepAlive.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Writing to this phone's clipboard.
|
/// Writing to this phone's clipboard.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
|
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Android;
|
using Avalonia.Android;
|
||||||
|
using Avalonia.Media;
|
||||||
using DodoSSH.Client.Android.Platform;
|
using DodoSSH.Client.Android.Platform;
|
||||||
using global::Android.App;
|
using global::Android.App;
|
||||||
using global::Android.Runtime;
|
using global::Android.Runtime;
|
||||||
@@ -46,6 +47,30 @@ public sealed class DodoSshAndroidApplication : AvaloniaAndroidApplication<DodoS
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// This used to be the one place the two heads disagreed on purpose: the recorded v2 decision was
|
||||||
|
/// "Android recolours with the shared palette and keeps its own default sans", on the reasoning that a
|
||||||
|
/// phone's system font is a phone's own business. The user has reversed that, this pass — the phone now
|
||||||
|
/// takes the desktop's face as well as its colours, and <c>docs/design-import-gaps.md</c> is corrected
|
||||||
|
/// to say so rather than left asserting a decision that no longer holds.
|
||||||
|
///
|
||||||
|
/// <c>WithInterFont</c> still registers Inter, for the same reason <c>Program.cs</c> keeps it on the
|
||||||
|
/// desktop: it is what the layout suite pins and what a glyph Montserrat does not cover falls back to.
|
||||||
|
/// What changes is which face answers first. Montserrat ships embedded in
|
||||||
|
/// <c>DodoSSH.Client.Shell/Assets/Fonts</c> already — this project references that assembly for
|
||||||
|
/// <c>Theme/Phone.axaml</c>'s own <c>MonoFont</c>, so no csproj or asset work was needed to reach it
|
||||||
|
/// from here, only the same <see cref="FontManagerOptions"/> block the desktop's
|
||||||
|
/// <c>Program.BuildAvaloniaApp</c> sets.
|
||||||
|
/// </remarks>
|
||||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
|
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
|
||||||
base.CustomizeAppBuilder(builder).WithInterFont();
|
base.CustomizeAppBuilder(builder)
|
||||||
|
.WithInterFont()
|
||||||
|
.With(new FontManagerOptions
|
||||||
|
{
|
||||||
|
DefaultFamilyName = "avares://DodoSSH.Client.Shell/Assets/Fonts#Montserrat",
|
||||||
|
FontFallbacks =
|
||||||
|
[
|
||||||
|
new FontFallback { FontFamily = new FontFamily("avares://Avalonia.Fonts.Inter/Assets#Inter") },
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ using global::Android.OS;
|
|||||||
namespace DodoSSH.Client.Android.Platform;
|
namespace DodoSSH.Client.Android.Platform;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Keeps the process alive for as long as a shell or a transfer is live.
|
/// Keeps the process alive for as long as a shell, a transfer, or a connected Files session is live.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -41,6 +41,26 @@ internal sealed class SessionForegroundService : Service
|
|||||||
private const string ChannelId = "dodossh.sessions";
|
private const string ChannelId = "dodossh.sessions";
|
||||||
private const int NotificationId = 1;
|
private const int NotificationId = 1;
|
||||||
|
|
||||||
|
// API 33+ requires the request to name a code the RequestPermissionsResult callback would be handed
|
||||||
|
// back — 1 is fine because this head never implements that callback at all, see
|
||||||
|
// RequestNotificationPermission's own remark for why a result is not worth listening for.
|
||||||
|
private const int NotificationPermissionRequestCode = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether <see cref="OnStartCommand"/> has run for this process without a matching
|
||||||
|
/// <see cref="OnDestroy"/> since — i.e. whether Android currently considers this service foregrounded.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Volatile because <see cref="Reconcile"/> can run on whatever thread called
|
||||||
|
/// <see cref="SessionKeepAlive.Refresh"/>, while this is set from the binder thread Android delivers
|
||||||
|
/// service lifecycle callbacks on — two threads with no other synchronisation between them, and a stale
|
||||||
|
/// read here is the difference between updating a notification in place and calling
|
||||||
|
/// <c>StartForegroundService</c> on a service that is already running, which is what defect 3 was.
|
||||||
|
/// </remarks>
|
||||||
|
private static volatile bool running;
|
||||||
|
|
||||||
|
private static bool notificationPermissionRequested;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
|
/// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
|
||||||
/// wanted here: the point is that they outlive whatever the user does with the interface.
|
/// wanted here: the point is that they outlive whatever the user does with the interface.
|
||||||
@@ -49,7 +69,9 @@ internal sealed class SessionForegroundService : Service
|
|||||||
|
|
||||||
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
|
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
|
||||||
{
|
{
|
||||||
StartForeground(NotificationId, BuildNotification(intent?.GetStringExtra("summary") ?? "Working"));
|
running = true;
|
||||||
|
|
||||||
|
StartForeground(NotificationId, BuildNotification(this, intent?.GetStringExtra("summary") ?? "Working"));
|
||||||
|
|
||||||
// NotSticky: if Android does kill this process, the SSH connections died with it and there is
|
// NotSticky: if Android does kill this process, the SSH connections died with it and there is
|
||||||
// nothing to resume. Restarting the service would produce a notification claiming sessions that no
|
// nothing to resume. Restarting the service would produce a notification claiming sessions that no
|
||||||
@@ -58,14 +80,36 @@ internal sealed class SessionForegroundService : Service
|
|||||||
return StartCommandResult.NotSticky;
|
return StartCommandResult.NotSticky;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// The other half of <see cref="running"/>. Android calls this whether the service stopped itself or
|
||||||
|
/// was stopped from outside — <see cref="Reconcile"/>'s down case calls <c>StopService</c> rather than
|
||||||
|
/// clearing the flag directly, so this override is the one place that actually knows the service has
|
||||||
|
/// gone, matching how <see cref="OnStartCommand"/> is the one place that knows it has come up.
|
||||||
|
/// </remarks>
|
||||||
|
public override void OnDestroy()
|
||||||
|
{
|
||||||
|
running = false;
|
||||||
|
|
||||||
|
base.OnDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android
|
/// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android
|
||||||
/// requires one, and because the user is entitled to know the app is holding connections open. Making
|
/// requires one, and because the user is entitled to know the app is holding connections open. Making
|
||||||
/// it buzz would be a notification about nothing having happened.
|
/// it buzz would be a notification about nothing having happened.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Static, taking the <see cref="Context"/> it needs rather than reading <c>this</c>: the instance path
|
||||||
|
/// through <see cref="OnStartCommand"/> passes the service itself, and the in-place update path through
|
||||||
|
/// <see cref="Reconcile"/> has no service instance at all — only <see cref="PhoneEnvironment.Require"/>
|
||||||
|
/// — because posting to an already-running notification never touches the service's own lifecycle.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private Notification BuildNotification(string summary)
|
private static Notification BuildNotification(Context context, string summary)
|
||||||
{
|
{
|
||||||
var manager = (NotificationManager)GetSystemService(NotificationService)!;
|
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
|
||||||
|
|
||||||
if (OperatingSystem.IsAndroidVersionAtLeast(26))
|
if (OperatingSystem.IsAndroidVersionAtLeast(26))
|
||||||
{
|
{
|
||||||
@@ -79,12 +123,12 @@ internal sealed class SessionForegroundService : Service
|
|||||||
}
|
}
|
||||||
|
|
||||||
var reopen = PendingIntent.GetActivity(
|
var reopen = PendingIntent.GetActivity(
|
||||||
this,
|
context,
|
||||||
0,
|
0,
|
||||||
new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
new Intent(context, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
||||||
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
|
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
|
||||||
|
|
||||||
return new Notification.Builder(this, ChannelId)
|
return new Notification.Builder(context, ChannelId)
|
||||||
.SetContentTitle("DodoSSH")
|
.SetContentTitle("DodoSSH")
|
||||||
.SetContentText(summary)
|
.SetContentText(summary)
|
||||||
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
|
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
|
||||||
@@ -93,37 +137,138 @@ internal sealed class SessionForegroundService : Service
|
|||||||
.Build();
|
.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Starts or stops the service to match what is actually running.</summary>
|
/// <summary>Starts, stops, or refreshes the service's notification to match what is actually running.</summary>
|
||||||
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
||||||
/// <param name="activeTransfers">Transfers still moving bytes.</param>
|
/// <param name="activeTransfers">Transfers still moving bytes.</param>
|
||||||
public static void Reconcile(int liveSessions, int activeTransfers)
|
/// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
|
||||||
|
/// always. Something live and the service not yet running starts it. Something live and the service
|
||||||
|
/// already running is the case that used to call <c>StartForegroundService</c> a second time, which on
|
||||||
|
/// API 31+ throws <c>ForegroundServiceStartNotAllowedException</c> the instant the app is backgrounded
|
||||||
|
/// — a transfer finishing in the pocket, one of two shells dying — crashing the process and taking the
|
||||||
|
/// remaining connections with it. That case — running, and the app backgrounded — now only posts a
|
||||||
|
/// fresh notification through the <see cref="NotificationManager"/> already holding the channel open,
|
||||||
|
/// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
|
||||||
|
/// start even over a service that looks like it is running; the inline remark below is why.
|
||||||
|
/// </remarks>
|
||||||
|
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession)
|
||||||
{
|
{
|
||||||
var context = PhoneEnvironment.Require();
|
var context = PhoneEnvironment.Require();
|
||||||
var intent = new Intent(context, typeof(SessionForegroundService));
|
|
||||||
|
|
||||||
if (liveSessions == 0 && activeTransfers == 0)
|
if (liveSessions == 0 && activeTransfers == 0 && !holdsFileSession)
|
||||||
{
|
{
|
||||||
context.StopService(intent);
|
context.StopService(new Intent(context, typeof(SessionForegroundService)));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The summary says what is actually held, counted rather than generic — the same principle the
|
// The summary says what is actually held, counted rather than generic — the same principle the
|
||||||
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
|
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
|
||||||
intent.PutExtra("summary", Summarise(liveSessions, activeTransfers));
|
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession);
|
||||||
|
|
||||||
context.StartForegroundService(intent);
|
// In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
|
||||||
|
// always allowed and is preferred even when `running` says the service is up: a disconnect followed
|
||||||
|
// by a quick reconnect can land here while the StopService just issued is still in flight, and
|
||||||
|
// posting to that dying service's notification would leave an orphan receipt over an unprotected
|
||||||
|
// process — restarting instead makes the flag's small lag harmless. A start on a service that
|
||||||
|
// really is running only re-delivers OnStartCommand, whose StartForeground updates the same
|
||||||
|
// notification anyway. CurrentActivity is the foreground signal: set on resume, cleared on pause.
|
||||||
|
if (running && PhoneEnvironment.CurrentActivity is null)
|
||||||
|
{
|
||||||
|
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
|
||||||
|
manager.Notify(NotificationId, BuildNotification(context, summary));
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Summarise(int liveSessions, int activeTransfers)
|
RequestNotificationPermission(context);
|
||||||
|
Start(context, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Split out of <see cref="Reconcile"/> for the try/catch alone, which needs its own remark and would
|
||||||
|
/// otherwise crowd the three-way branch above it.
|
||||||
|
/// </remarks>
|
||||||
|
private static void Start(Context context, string summary)
|
||||||
{
|
{
|
||||||
var parts = new List<string>(2);
|
var intent = new Intent(context, typeof(SessionForegroundService));
|
||||||
|
intent.PutExtra("summary", summary);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.StartForegroundService(intent);
|
||||||
|
}
|
||||||
|
catch (Java.Lang.IllegalStateException)
|
||||||
|
{
|
||||||
|
// ForegroundServiceStartNotAllowedException (API 31+) derives from this, and reaching it here
|
||||||
|
// means a start-worthy transition — a shell opening, a Files connection completing, the first
|
||||||
|
// transfer landing in an empty queue — happened while the app was backgrounded, which is
|
||||||
|
// precisely when Android refuses a new foreground start. There is no retry that helps: by the
|
||||||
|
// time this catch runs, the moment such a start would have been allowed has already passed.
|
||||||
|
// Swallowing it is the honest choice and not just the available one — letting the exception
|
||||||
|
// propagate would crash the process and drop the very shells and transfers this service exists
|
||||||
|
// to keep alive. A process that keeps running unprotected outlives one that does not run at all.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks for the receipt notification's own permission, the first time this process actually has
|
||||||
|
/// something to show rather than at launch.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// At most once per process, via <see cref="notificationPermissionRequested"/> — not to work around a
|
||||||
|
/// platform limit, since Android already refuses to show the dialogue twice, but because a second call
|
||||||
|
/// to <c>RequestPermissions</c> after the first is still pending is its own kind of noise. No result is
|
||||||
|
/// read back: there is nothing this class would do differently for a grant versus a refusal, so a
|
||||||
|
/// callback would exist only to be empty. What refusal costs is stated rather than hidden — the
|
||||||
|
/// notification stays invisible — and what it does not cost is the point: the service still starts,
|
||||||
|
/// still holds the process in the foreground, and the shells and transfers it protects are exactly as
|
||||||
|
/// safe as if the user had said yes. See the manifest's own comment on this permission.
|
||||||
|
/// </remarks>
|
||||||
|
private static void RequestNotificationPermission(Context context)
|
||||||
|
{
|
||||||
|
// Isolated as its own guard clause rather than folded into the compound condition below: the
|
||||||
|
// platform-compatibility analyzer only recognises a version check as guarding what follows when it
|
||||||
|
// is the sole condition of its own early return, and PostNotifications is annotated API 33+.
|
||||||
|
if (!OperatingSystem.IsAndroidVersionAtLeast(33))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read into a local rather than referenced again inside the lambda below: the guard clause above
|
||||||
|
// covers a direct call in this method's own body, but the platform-compatibility analyzer treats a
|
||||||
|
// lambda as reachable from anywhere and will not extend the guard across that boundary. Capturing
|
||||||
|
// the already-validated string sidesteps the false positive without weakening the actual check.
|
||||||
|
var postNotifications = global::Android.Manifest.Permission.PostNotifications;
|
||||||
|
|
||||||
|
if (notificationPermissionRequested
|
||||||
|
|| PhoneEnvironment.CurrentActivity is not { } activity
|
||||||
|
|| context.CheckSelfPermission(postNotifications) == Permission.Granted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationPermissionRequested = true;
|
||||||
|
|
||||||
|
activity.RunOnUiThread(() =>
|
||||||
|
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession)
|
||||||
|
{
|
||||||
|
var parts = new List<string>(3);
|
||||||
|
|
||||||
if (liveSessions > 0)
|
if (liveSessions > 0)
|
||||||
{
|
{
|
||||||
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (holdsFileSession)
|
||||||
|
{
|
||||||
|
parts.Add("Files connected");
|
||||||
|
}
|
||||||
|
|
||||||
if (activeTransfers > 0)
|
if (activeTransfers > 0)
|
||||||
{
|
{
|
||||||
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
|
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
|
||||||
|
|||||||
@@ -17,37 +17,53 @@ namespace DodoSSH.Client.Android.Platform;
|
|||||||
/// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which
|
/// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which
|
||||||
/// a counter incremented on open and decremented on close would not.
|
/// a counter incremented on open and decremented on close would not.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Three facts feed <see cref="SessionForegroundService.Reconcile"/>, not one: live shells, moving
|
||||||
|
/// transfers, and an idle-but-connected Files session. The last of those used to be missing entirely —
|
||||||
|
/// a shell survives backgrounding because somebody opened it, but a Files connection with nothing moving
|
||||||
|
/// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that
|
||||||
|
/// gap closed, read the same way the other two facts are: asked, not cached.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed class SessionKeepAlive : IDisposable
|
internal sealed class SessionKeepAlive : IDisposable
|
||||||
{
|
{
|
||||||
private readonly TerminalWorkspace workspace;
|
private readonly TerminalWorkspace workspace;
|
||||||
private readonly Func<int> activeTransfers;
|
private readonly Func<int> activeTransfers;
|
||||||
|
private readonly Func<bool> holdsFileSession;
|
||||||
|
|
||||||
/// <param name="workspace">The live shells.</param>
|
/// <param name="workspace">The live shells.</param>
|
||||||
/// <param name="activeTransfers">
|
/// <param name="activeTransfers">
|
||||||
/// How many transfers are moving bytes. A delegate rather than a queue, because file transfer is out
|
/// How many transfers are moving bytes. A delegate rather than a queue, because ownership of the
|
||||||
/// of this head's first scope — see the decision in docs/android-port.md — and this is the seam it
|
/// transfer queue stays with <c>TransfersViewModel</c> — this class only ever asks it a question.
|
||||||
/// will arrive through rather than a dependency taken before there is anything to depend on.
|
|
||||||
/// </param>
|
/// </param>
|
||||||
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers)
|
/// <param name="holdsFileSession">
|
||||||
|
/// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but-
|
||||||
|
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
|
||||||
|
/// </param>
|
||||||
|
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers, Func<bool> holdsFileSession)
|
||||||
{
|
{
|
||||||
this.workspace = workspace;
|
this.workspace = workspace;
|
||||||
this.activeTransfers = activeTransfers;
|
this.activeTransfers = activeTransfers;
|
||||||
|
this.holdsFileSession = holdsFileSession;
|
||||||
|
|
||||||
// Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is
|
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
|
||||||
// a binder call and needs no particular thread. Nothing here touches the interface.
|
// closer's own — which is fine: starting and stopping a service is a binder call and needs no
|
||||||
|
// particular thread. Nothing here touches the interface. That the announcement waits for the run to
|
||||||
|
// actually complete, and comes for deliberate closes too, is what makes reading LiveSessionCount
|
||||||
|
// from it honest — the event's own remark carries the stuck notification that taught us both.
|
||||||
workspace.SessionEnded += OnSessionEnded;
|
workspace.SessionEnded += OnSessionEnded;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Called after anything that could change either count — opening a shell, closing a tab, a transfer
|
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
|
||||||
/// finishing. Calling it when nothing changed is free: reconciling to the state it is already in is
|
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
|
||||||
/// either a redundant <c>startForegroundService</c> on a running service or a <c>stopService</c> on a
|
/// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
|
||||||
/// stopped one, and Android treats both as no-ops.
|
/// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one,
|
||||||
|
/// and Android treats all of those as no-ops.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public void Refresh() =>
|
public void Refresh() =>
|
||||||
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers());
|
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -56,7 +72,7 @@ internal sealed class SessionKeepAlive : IDisposable
|
|||||||
|
|
||||||
// The notification goes with the composition root. Leaving it up over a process that is shutting
|
// The notification goes with the composition root. Leaving it up over a process that is shutting
|
||||||
// down is how an SSH client acquires a reputation for a notification you cannot get rid of.
|
// down is how an SSH client acquires a reputation for a notification you cannot get rid of.
|
||||||
SessionForegroundService.Reconcile(0, 0);
|
SessionForegroundService.Reconcile(0, 0, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
|
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using global::Android.Views;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hands native focus back to the terminal's WebView after Avalonia chrome took it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> the
|
||||||
|
/// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does
|
||||||
|
/// not own. The accessory row's keys are already <c>Focusable=false</c> — see TerminalScreen — so Avalonia's
|
||||||
|
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>:
|
||||||
|
/// <c>AvaloniaView.DispatchTouchEvent</c> (decompiled from Avalonia.Android 12.1.1) ends every handled
|
||||||
|
/// touch — DOWN and UP alike — with a <c>RequestFocus()</c> for Avalonia's own view. The WebView's input
|
||||||
|
/// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text,
|
||||||
|
/// and the inset churn that follows can leave it sitting on top of the very row that was tapped.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.</b>
|
||||||
|
/// The first version called <c>RequestFocus()</c> from the keys' own Click handlers, which fire
|
||||||
|
/// <em>inside</em> the UP event's dispatch — and the platform's own request runs <em>after</em> dispatch
|
||||||
|
/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
|
||||||
|
/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
|
||||||
|
/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
|
||||||
|
/// about to be made stale by the very mechanism this exists to counter.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining
|
||||||
|
/// native focus re-establishes the same input connection and the keyboard settles back to what it was.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, because the
|
||||||
|
/// control does not expose its platform child and this application only ever has the one WebView — the
|
||||||
|
/// walk's first match is necessarily the terminal. Every step is allowed to be absent, exactly as
|
||||||
|
/// <see cref="SoftKeyboard.Hide"/>'s are: no activity while backgrounded, no WebView while the terminal
|
||||||
|
/// surface has never been shown, and nothing to do in either case.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class TerminalFocus
|
||||||
|
{
|
||||||
|
public static void Return()
|
||||||
|
{
|
||||||
|
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FindWebView(decor) is not { } webView)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
webView.Post(() =>
|
||||||
|
{
|
||||||
|
if (!webView.IsFocused)
|
||||||
|
{
|
||||||
|
webView.RequestFocus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static View? FindWebView(ViewGroup parent)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < parent.ChildCount; i++)
|
||||||
|
{
|
||||||
|
switch (parent.GetChildAt(i))
|
||||||
|
{
|
||||||
|
case global::Android.Webkit.WebView webView:
|
||||||
|
return webView;
|
||||||
|
|
||||||
|
case ViewGroup child when FindWebView(child) is { } found:
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,12 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
<!-- The service's persistent notification. Runtime-requested on API 33+, and refusal is survivable. -->
|
<!--
|
||||||
|
The service's persistent notification. Requested on API 33+ from SessionForegroundService.Reconcile,
|
||||||
|
the first time in this process there is actually something to show — not at launch, where the ask
|
||||||
|
would justify nothing on screen yet. Refusal is survivable: the service still starts and still holds
|
||||||
|
the process in the foreground either way, so a "no" costs the notification and nothing else.
|
||||||
|
-->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
|
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
The launcher mark, and it is the same mark PhoneShell's header and the desktop titlebar draw:
|
The launcher mark, and it is the same mark PhoneShell's header and the desktop titlebar draw:
|
||||||
>_ in the canvas colour on a solid accent tile. Filled rather than outlined since v2.
|
>_ in AccentInk on a solid accent tile. Filled rather than outlined since v2.
|
||||||
|
|
||||||
The tile is not in this file. It is the background layer — @color/dodo_accent, see ic_launcher.xml
|
The tile is not in this file. It is the background layer — @color/dodo_accent, see ic_launcher.xml
|
||||||
— and that is the whole trick of the filled design on Android: the rounding a launcher applies is
|
— and that is the whole trick of the filled design on Android: the rounding a launcher applies is
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
rounded shape inside the first, visibly clipped at the corners on any device whose mask is not the
|
rounded shape inside the first, visibly clipped at the corners on any device whose mask is not the
|
||||||
one it was drawn for.
|
one it was drawn for.
|
||||||
|
|
||||||
#0E1220 is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
|
#FFFFFF is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
|
||||||
Android resource cannot reference a XAML dictionary. It equals Canvas today and is named
|
Android resource cannot reference a XAML dictionary. Through v2 it equalled Canvas and this file
|
||||||
separately in the palette for a reason worth keeping in mind here: this is ink on the accent, not
|
followed it when the two parted: v5's ink on the accent is white, the way every accent surface in
|
||||||
the window behind it, and it follows AccentInk if the two ever part.
|
that palette carries white, while the window behind it went its own way to #05050A.
|
||||||
|
|
||||||
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer 18 on each
|
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer 18 on each
|
||||||
edge is what the launcher eats for masking and parallax. The glyph spans 36.06..71.94, which is
|
edge is what the launcher eats for masking and parallax. The glyph spans 36.06..71.94, which is
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<path
|
<path
|
||||||
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
|
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
|
||||||
android:fillColor="#00000000"
|
android:fillColor="#00000000"
|
||||||
android:strokeColor="#0E1220"
|
android:strokeColor="#FFFFFF"
|
||||||
android:strokeWidth="5.4"
|
android:strokeWidth="5.4"
|
||||||
android:strokeLineCap="round"
|
android:strokeLineCap="round"
|
||||||
android:strokeLineJoin="round" />
|
android:strokeLineJoin="round" />
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<path
|
<path
|
||||||
android:pathData="M54,64.68 L71.94,64.68"
|
android:pathData="M54,64.68 L71.94,64.68"
|
||||||
android:fillColor="#00000000"
|
android:fillColor="#00000000"
|
||||||
android:strokeColor="#0E1220"
|
android:strokeColor="#FFFFFF"
|
||||||
android:strokeWidth="5.4"
|
android:strokeWidth="5.4"
|
||||||
android:strokeLineCap="round" />
|
android:strokeLineCap="round" />
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
Worth shipping rather than leaving out: a launcher with themed icons on and no monochrome
|
Worth shipping rather than leaving out: a launcher with themed icons on and no monochrome
|
||||||
layer to use falls back to the full-colour icon, so the one app on the home screen still
|
layer to use falls back to the full-colour icon, so the one app on the home screen still
|
||||||
drawn in blue is this one.
|
drawn in its own accent is this one.
|
||||||
-->
|
-->
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
|
|||||||
@@ -9,12 +9,12 @@
|
|||||||
between an Android resource and a XAML resource dictionary, so the duplication is stated rather than
|
between an Android resource and a XAML resource dictionary, so the duplication is stated rather than
|
||||||
hidden.
|
hidden.
|
||||||
-->
|
-->
|
||||||
<color name="dodo_window">#0E1220</color>
|
<color name="dodo_window">#05050A</color>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
AccentColor from the same palette, here because the launcher icon's background layer is a colour
|
AccentColor from the same palette, here because the launcher icon's background layer is a colour
|
||||||
and not a drawable. Same hand-kept duplication as above, and the same rule: if the palette moves,
|
and not a drawable. Same hand-kept duplication as above, and the same rule: if the palette moves,
|
||||||
this moves with it.
|
this moves with it.
|
||||||
-->
|
-->
|
||||||
<color name="dodo_accent">#5B8CFF</color>
|
<color name="dodo_accent">#5D42DE</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -12,10 +12,28 @@
|
|||||||
the alternative, and the red one is the one that costs something.
|
the alternative, and the red one is the one that costs something.
|
||||||
|
|
||||||
── v2 ──────────────────────────────────────────────────────────────────────────────────────────────
|
── v2 ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
The second design rounds everything. The corner radii below are the design's own — 4 for a tag, 9 for a
|
The second design rounded everything on its own ladder — 4 for a tag, 9 for a button or a pill, 10 for
|
||||||
button or a pill, 10 for a list row, 11 for the search well, 12 for a card, 14 for a block of
|
a list row, 11 for the search well, 12 for a card, 14 for a block of monospaced output — and the numbers
|
||||||
monospaced output — and they are a ladder rather than a set of preferences: the radius says how big the
|
below carried it for three passes: the radius said how big the thing was, so a 12 on a chip or a 4 on a
|
||||||
thing is, so a 12 on a chip or a 4 on a card reads as the wrong size before it reads as the wrong shape.
|
card read as the wrong size before it read as the wrong shape. Left as a record of that reasoning rather
|
||||||
|
than deleted, because the reasoning still holds; only the numbers it was reasoning about have moved.
|
||||||
|
|
||||||
|
── v5 ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
This pass takes the desktop's v5 ladder instead of v2's own, on the same reversal recorded for the
|
||||||
|
fonts: the phone now matches the desktop's shape as well as its face. Three rungs rather than six —
|
||||||
|
cards and sections stay 12, a button or a field is 10, a chip or a tag is 6 — collapsing v2's 9/10/11
|
||||||
|
into the one value the desktop's buttons and fields already use, and moving its 4 up to 6 and its 14
|
||||||
|
down to 12 to land on the desktop's own chip and card numbers. The ladder still says how big a thing
|
||||||
|
is before it says what shape it is; it is just a shorter ladder now, because the desktop it is copying
|
||||||
|
never drew v2's 11-radius search well or a card any rounder than a chip's neighbour a step away, and the
|
||||||
|
same case that closed the gap between 9, 10 and 11 closes the one between 12 and 14.
|
||||||
|
|
||||||
|
Not every radius on this head belongs to this ladder. The floating action button is 28 — half its own
|
||||||
|
56, which is a circle rather than a ladder rung — and the sheets stay at 22 on their top corners only,
|
||||||
|
which is a phone idiom this codebase's own bottom sheets have used since v2 and the desktop draws
|
||||||
|
nothing like. Both are documented where they are set rather than here, for the reason FAB and sheet
|
||||||
|
radii are always documented locally: a reader who only ever meets one of them should not have to find
|
||||||
|
this paragraph to learn it was deliberate.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Style Selector="Button.primary">
|
<Style Selector="Button.primary">
|
||||||
@@ -23,15 +41,32 @@
|
|||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="12" />
|
<Setter Property="FontSize" Value="12" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ Gradient and glow, replacing a flat Accent fill — the same swap App.axaml's Button.accent made on the
|
||||||
|
desktop, and for the same reason: this design draws every primary button top-to-bottom from a lighter
|
||||||
|
violet into the accent proper, lifted off the surface with a soft violet glow rather than a border. Both
|
||||||
|
live in Palette.axaml as AccentGradient and AccentGlow, already resolvable here because the palette is
|
||||||
|
shared — this is a resource-key swap, not new colour.
|
||||||
|
|
||||||
|
Background and BoxShadow are set here rather than as plain Setters above, because Fluent's default
|
||||||
|
button template only lets a style reach the fill and the glow through the ContentPresenter it draws
|
||||||
|
itself around — the same reason the desktop's rule targets /template/ ContentPresenter rather than the
|
||||||
|
Button. Height, radius and the rest stay ordinary Setters on Button.primary itself; only the two the
|
||||||
|
template intercepts move down here.
|
||||||
|
-->
|
||||||
|
<Style Selector="Button.primary /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||||
|
<Setter Property="BoxShadow" Value="{StaticResource AccentGlow}" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Button.primary:pressed /template/ ContentPresenter">
|
<Style Selector="Button.primary:pressed /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||||
<Setter Property="Opacity" Value="0.82" />
|
<Setter Property="Opacity" Value="0.82" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -39,9 +74,15 @@
|
|||||||
Disabled is drawn as flat and unlit rather than merely dimmed. The design's CONTINUE button on the
|
Disabled is drawn as flat and unlit rather than merely dimmed. The design's CONTINUE button on the
|
||||||
recovery screen is disabled until the checkbox is ticked, and a user who cannot tell it is disabled
|
recovery screen is disabled until the checkbox is ticked, and a user who cannot tell it is disabled
|
||||||
reads the screen as broken rather than as waiting for them.
|
reads the screen as broken rather than as waiting for them.
|
||||||
|
|
||||||
|
BoxShadow has to be cleared here too, as "none" rather than left unset — see the remark on
|
||||||
|
Button.accent:disabled in the desktop's App.axaml for why the literal string is required and an empty
|
||||||
|
BoxShadows is not: the base rule's glow Setter is still in effect wherever a more specific one does not
|
||||||
|
override it, and a disabled primary button lit with a glow would read as wanting to be pressed.
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
|
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||||
|
<Setter Property="BoxShadow" Value="none" />
|
||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
|
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -54,7 +95,7 @@
|
|||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="11.5" />
|
<Setter Property="FontSize" Value="11.5" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
@@ -69,17 +110,33 @@
|
|||||||
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
|
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="10.5" />
|
<Setter Property="FontSize" Value="10.5" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse. -->
|
<!--
|
||||||
|
A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse.
|
||||||
|
|
||||||
|
◆ VerticalContentAlignment, because that height is the whole point of this class and Avalonia's default
|
||||||
|
for content alignment is Stretch — so the content presenter stretched the caption to the full row and a
|
||||||
|
TextBlock draws its line at the TOP of what it is given. Most rows here never showed it, having a
|
||||||
|
StackPanel or a Grid of already-centred children in them, which is what made the four that did look
|
||||||
|
like four unrelated mistakes: the breadcrumb chips and the up-one-directory button on FilesScreen, and
|
||||||
|
TerminalScreen's CLOSE THIS TAB, each a bare TextBlock in a row 36 or 44 tall with no vertical padding.
|
||||||
|
Measured at those numbers, the caption sat flush against the top edge with 21 to 33 pixels below it.
|
||||||
|
|
||||||
|
The desktop head's App.axaml carries the same setter on its own shapes for the same reason, and excludes
|
||||||
|
two of them — see the remark on Button.ghost there. Nothing is excluded here: no row's content depends
|
||||||
|
on being stretched, there being no full-height strip inside any of the thirty-three, and the Grids that
|
||||||
|
stop filling hold only children that already centre themselves, so they land where they always did.
|
||||||
|
-->
|
||||||
<Style Selector="Button.row">
|
<Style Selector="Button.row">
|
||||||
<Setter Property="MinHeight" Value="54" />
|
<Setter Property="MinHeight" Value="54" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
@@ -126,7 +183,17 @@
|
|||||||
|
|
||||||
Accent-filled, which it shares with Button.primary and with nothing else — and it means the same thing
|
Accent-filled, which it shares with Button.primary and with nothing else — and it means the same thing
|
||||||
in both places: the one action on the surface that is not a choice between peers. Circular by radius
|
in both places: the one action on the surface that is not a choice between peers. Circular by radius
|
||||||
rather than by a Path, so the pressed state the template draws is the same shape as the button.
|
rather than by a Path, so the pressed state the template draws is the same shape as the button — 28,
|
||||||
|
exactly half its own 56, which is a geometric constraint rather than a ladder rung and does not move
|
||||||
|
with the rest of this file's radii.
|
||||||
|
|
||||||
|
◆ Gradient and glow since v5, matching Button.primary rather than staying a flat fill once that one
|
||||||
|
moved. The two are the only accent-filled controls on this head and are read as one idea — "the thing
|
||||||
|
this surface wants you to do" — so a flat FAB beside a gradient primary button would be the seam this
|
||||||
|
codebase's palette file keeps warning about, one screenshot over from the button it echoes. The glow
|
||||||
|
reads as well on a floating circle as it does on a bar-anchored rectangle: if anything a control that
|
||||||
|
already floats over content earns a lift more than one sitting in a row of chrome does, so it is kept
|
||||||
|
rather than dropped for the FAB's own shape.
|
||||||
|
|
||||||
Still only on HOSTS. The design puts a second one on S3 and that editor genuinely does not exist yet, so
|
Still only on HOSTS. The design puts a second one on S3 and that editor genuinely does not exist yet, so
|
||||||
the style being here is not permission to draw one there.
|
the style being here is not permission to draw one there.
|
||||||
@@ -135,7 +202,6 @@
|
|||||||
<Setter Property="Width" Value="56" />
|
<Setter Property="Width" Value="56" />
|
||||||
<Setter Property="Height" Value="56" />
|
<Setter Property="Height" Value="56" />
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="28" />
|
<Setter Property="CornerRadius" Value="28" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
@@ -145,8 +211,14 @@
|
|||||||
<Setter Property="FontSize" Value="24" />
|
<Setter Property="FontSize" Value="24" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<!-- See the remark above Button.primary's own /template/ ContentPresenter rule: BoxShadow has no home on
|
||||||
|
a Button itself, so the fill moves down here alongside it rather than staying a plain Setter. -->
|
||||||
|
<Style Selector="Button.fab /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||||
|
<Setter Property="BoxShadow" Value="{StaticResource AccentGlow}" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Button.fab:pressed /template/ ContentPresenter">
|
<Style Selector="Button.fab:pressed /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||||
<Setter Property="Opacity" Value="0.82" />
|
<Setter Property="Opacity" Value="0.82" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -161,7 +233,7 @@
|
|||||||
interface glitching rather than as a tap being received.
|
interface glitching rather than as a tap being received.
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.scrim">
|
<Style Selector="Button.scrim">
|
||||||
<Setter Property="Background" Value="#9E0E1220" />
|
<Setter Property="Background" Value="#9E05050A" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="0" />
|
<Setter Property="CornerRadius" Value="0" />
|
||||||
<Setter Property="Padding" Value="0" />
|
<Setter Property="Padding" Value="0" />
|
||||||
@@ -169,10 +241,10 @@
|
|||||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.scrim:pointerover /template/ ContentPresenter">
|
<Style Selector="Button.scrim:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="#9E0E1220" />
|
<Setter Property="Background" Value="#9E05050A" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.scrim:pressed /template/ ContentPresenter">
|
<Style Selector="Button.scrim:pressed /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="#9E0E1220" />
|
<Setter Property="Background" Value="#9E05050A" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -194,7 +266,7 @@
|
|||||||
-->
|
-->
|
||||||
<Style Selector="Border.output">
|
<Style Selector="Border.output">
|
||||||
<Setter Property="Background" Value="{StaticResource TerminalSurface}" />
|
<Setter Property="Background" Value="{StaticResource TerminalSurface}" />
|
||||||
<Setter Property="CornerRadius" Value="14" />
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
<Setter Property="Padding" Value="12" />
|
<Setter Property="Padding" Value="12" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -205,7 +277,7 @@
|
|||||||
-->
|
-->
|
||||||
<Style Selector="Border.tag">
|
<Style Selector="Border.tag">
|
||||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||||
<Setter Property="CornerRadius" Value="4" />
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
<Setter Property="Padding" Value="7,2" />
|
<Setter Property="Padding" Value="7,2" />
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
@@ -231,7 +303,7 @@
|
|||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="4" />
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
<Setter Property="Padding" Value="11,0" />
|
<Setter Property="Padding" Value="11,0" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
@@ -267,11 +339,17 @@
|
|||||||
Checked is a filled surface with accent *text*, not an accent fill. v2 makes that distinction
|
Checked is a filled surface with accent *text*, not an accent fill. v2 makes that distinction
|
||||||
everywhere — see the remark on AccentText in Palette.axaml — and a chip is where it matters most: a row
|
everywhere — see the remark on AccentText in Palette.axaml — and a chip is where it matters most: a row
|
||||||
of four solid blue lozenges is a row of four things that all look like the primary action.
|
of four solid blue lozenges is a row of four things that all look like the primary action.
|
||||||
|
|
||||||
|
◆ Its own radius rather than Border.tag's or Button.chiptoggle's, despite the name. v2 drew this control
|
||||||
|
on the ladder's button-or-pill rung rather than its tag rung — 9, not 4 — and the v5 pass carries the
|
||||||
|
same rung forward to 10 rather than to Border.tag's 6: it is a much larger control at 34 tall against a
|
||||||
|
tag's line-height, and a radius picked for a 34-pixel chip is not the one a 20-pixel tag needs, whatever
|
||||||
|
the class is called. See the remark on Phone.axaml's own ladder, above.
|
||||||
-->
|
-->
|
||||||
<Style Selector="RadioButton.chip">
|
<Style Selector="RadioButton.chip">
|
||||||
<Setter Property="MinHeight" Value="34" />
|
<Setter Property="MinHeight" Value="34" />
|
||||||
<Setter Property="Padding" Value="13,6" />
|
<Setter Property="Padding" Value="13,6" />
|
||||||
<Setter Property="CornerRadius" Value="9" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
@@ -314,7 +392,7 @@
|
|||||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="11" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="12" />
|
<Setter Property="FontSize" Value="12" />
|
||||||
@@ -358,6 +436,20 @@
|
|||||||
<Setter Property="Fill" Value="{StaticResource Live}" />
|
<Setter Property="Fill" Value="{StaticResource Live}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Amber, and it does not contradict the remark above. That one says green is a fact about a host rather
|
||||||
|
than an accent, and this is the colour for a fact that is not settled yet: green is what is true, purple
|
||||||
|
is what you can press, and a connection still being made is neither. The palette's own rule gives amber
|
||||||
|
to the caveat worth reading, which is exactly what this is.
|
||||||
|
|
||||||
|
Only the tab strips use it, and only for a tab with no shell behind it yet — the same amber, from the
|
||||||
|
same brush, as the track and the running step on the connecting screen, so that a tab and the screen it
|
||||||
|
opens agree about what is happening. See TerminalScreen.axaml.
|
||||||
|
-->
|
||||||
|
<Style Selector="Ellipse.dot.connecting">
|
||||||
|
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
||||||
<Style Selector="TextBlock.mono">
|
<Style Selector="TextBlock.mono">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
the refusal has no continue button here either.
|
the refusal has no continue button here either.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
|
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
|
||||||
|
|
||||||
<!-- ============ header ============ -->
|
<!-- ============ header ============ -->
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||||
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
|
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
||||||
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="9"
|
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
|
||||||
Command="{Binding RemoteUpCommand}">
|
Command="{Binding RemoteUpCommand}">
|
||||||
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
|
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||||
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="9"
|
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="10"
|
||||||
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||||||
CommandParameter="{Binding Path}">
|
CommandParameter="{Binding Path}">
|
||||||
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
|
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
|
||||||
@@ -199,8 +199,48 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- ============ pins ============ -->
|
||||||
|
<!--
|
||||||
|
◆ The phone's answer to the desktop's QUICK ACCESS sidebar — the same PinnedPathList this host was
|
||||||
|
connected with, drawn where this head actually browses files rather than beside a terminal it has no
|
||||||
|
strip for. See TransfersViewModel.ConnectedPinnedPaths for why this is a snapshot taken at connect
|
||||||
|
rather than a live follow of the host row: a pin added or removed mid-session shows up here on the
|
||||||
|
next connect, not while this one is still open.
|
||||||
|
|
||||||
|
Its own row rather than folded into the breadcrumb above: the two scroll independently and mean
|
||||||
|
different things — the breadcrumb is where this pane is, the chips are places it can jump to — and a
|
||||||
|
shared row would make ↑ look like one more pin among several.
|
||||||
|
|
||||||
|
Gone rather than empty when nothing is pinned or nothing is connected, which HasConnectedPins already
|
||||||
|
answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as
|
||||||
|
"this host has nothing pinned".
|
||||||
|
-->
|
||||||
|
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||||
|
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
|
||||||
|
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" Margin="14,0" /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="x:String">
|
||||||
|
<!--
|
||||||
|
Classes="row" at its default 44dp MinHeight rather than the breadcrumb's own 36 — these chips
|
||||||
|
are the destination, not the trail behind it, and a target worth a deliberate tap gets the
|
||||||
|
full touch floor this head holds everything else to. Not trimmed: a chip scrolls sideways with
|
||||||
|
the row rather than being squeezed to fit it, so the full path is always what a tap commits to.
|
||||||
|
-->
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="11,0" CornerRadius="10"
|
||||||
|
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding}" />
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- ============ the listing ============ -->
|
<!-- ============ the listing ============ -->
|
||||||
<Panel Grid.Row="4">
|
<Panel Grid.Row="5">
|
||||||
|
|
||||||
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
||||||
VerticalAlignment="Top" Text="{Binding Status}" />
|
VerticalAlignment="Top" Text="{Binding Status}" />
|
||||||
@@ -265,7 +305,8 @@
|
|||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- ============ what to do with the chosen entry ============ -->
|
<!-- ============ what to do with the chosen entry ============ -->
|
||||||
<Border Grid.Row="5" IsVisible="{Binding IsConnected}" Background="{StaticResource Chrome}"
|
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
|
||||||
|
<Border Grid.Row="6" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||||
<StackPanel Spacing="9">
|
<StackPanel Spacing="9">
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.Android.Views.HostActionBar"
|
||||||
|
x:DataType="vm:VaultViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE CONTEXTUAL ACTION BAR, WHICH IS WHAT THE CONNECT CARD BECAME.
|
||||||
|
|
||||||
|
A long press on a host used to raise a card over the bottom of the list: a password box, CONNECT, EDIT,
|
||||||
|
MOVE and DELETE. It was a menu drawn as a form, in the one part of the screen a list grows into — so it
|
||||||
|
covered rows, it had room for five things and never a sixth, and everything on it was about exactly one
|
||||||
|
machine. What replaced it is a selection and this bar.
|
||||||
|
|
||||||
|
It takes the vault header's place rather than sitting under it, which is the arrangement Android has used
|
||||||
|
for this since contextual action bars existed and is the reason it can be unambiguous: while it is up, the
|
||||||
|
screen is about the ticked hosts and about nothing else. See PhoneShell.ShowsHostSelectionBar, which is
|
||||||
|
where the swap happens and why it is computed rather than bound.
|
||||||
|
|
||||||
|
Left to right: the cross that leaves selection mode, the count, then the two controls. The pencil is out
|
||||||
|
in front of the menu because editing is the thing people reach for most and is worth not opening a menu
|
||||||
|
for; the other six are behind the ⋯, which is a sheet rather than a flyout — see
|
||||||
|
VaultViewModel.IsHostActionSheetOpen.
|
||||||
|
|
||||||
|
── one host or several ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
The pencil is drawn only while exactly one host is ticked, and it is collapsed rather than greyed. Three
|
||||||
|
of the seven actions are like that — edit, connect, and browse — and the reason is the same for all
|
||||||
|
three: a form, a terminal and a file session are each about one machine, and there is no sensible reading
|
||||||
|
of "edit these six". The three that stay are the ones a count makes better rather than worse.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- DeepChrome rather than Chrome, since v5: this bar takes the vault header's own place, so it takes
|
||||||
|
the vault header's own surface too — see the remark on that header in PhoneShell.axaml. -->
|
||||||
|
<Border Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||||
|
BorderThickness="0,0,0,1" Padding="6,0" Height="56">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The cross, and it is the first thing on the bar for the reason back arrows are: the way out of a
|
||||||
|
mode belongs at the edge the thumb reaches without crossing anything it might press by mistake.
|
||||||
|
-->
|
||||||
|
<Button Grid.Column="0" Classes="icon" Content="✕"
|
||||||
|
Command="{Binding ClearHostChoiceCommand}"
|
||||||
|
ToolTip.Tip="Stop choosing hosts" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The count, and only the count. The bar it sits in is already saying what the number is about, and
|
||||||
|
"6 hosts selected" beside a pencil and a menu is the width those two icons need at 360dp.
|
||||||
|
-->
|
||||||
|
<TextBlock Grid.Column="1" Classes="heading" FontSize="17" Margin="8,0"
|
||||||
|
VerticalAlignment="Center" Text="{Binding ChosenHostsLabel}" />
|
||||||
|
|
||||||
|
<Button Grid.Column="2" Classes="icon" Content="✎" FontSize="15"
|
||||||
|
IsVisible="{Binding HasOneChosenHost}"
|
||||||
|
Command="{Binding EditChosenHostCommand}"
|
||||||
|
ToolTip.Tip="Edit this host" />
|
||||||
|
|
||||||
|
<Button Grid.Column="3" Classes="icon" Content="⋯" FontSize="18"
|
||||||
|
Command="{Binding OpenHostActionSheetCommand}"
|
||||||
|
ToolTip.Tip="More things to do with these hosts" />
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
|
/// <summary>The bar that takes the header's place while hosts are chosen.</summary>
|
||||||
|
internal sealed partial class HostActionBar : UserControl
|
||||||
|
{
|
||||||
|
public HostActionBar() => AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@
|
|||||||
and the plan names presenting it as dismissible as the one design mistake that matters here. A phone
|
and the plan names presenting it as dismissible as the one design mistake that matters here. A phone
|
||||||
makes this easy to get wrong, because a bottom sheet is swipe-to-dismiss by convention; this is not a
|
makes this easy to get wrong, because a bottom sheet is swipe-to-dismiss by convention; this is not a
|
||||||
sheet for that reason, it is a full-screen panel.
|
sheet for that reason, it is a full-screen panel.
|
||||||
|
|
||||||
|
◆ WHAT IS BEHIND IT IS NO LONGER ALWAYS THE HOST LIST. The shell used to move to HOSTS before letting a
|
||||||
|
handshake ask this, because the desktop head drew the question as a banner on that screen; both heads draw
|
||||||
|
it over the surface now, so this is raised over whatever the user was on — including the connect box on the
|
||||||
|
Connections surface, which is where a machine that is in no keychain at all is dialled from. Nothing here
|
||||||
|
changes for that, and that is the point: this control never knew which screen it was over. See
|
||||||
|
MainWindowViewModel.OnVaultConnectionFailed and HostKeyCard.axaml on the desktop.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
@@ -29,7 +36,7 @@
|
|||||||
written out because the palette holds no alpha variant of a surface — see QuickConnect on the
|
written out because the palette holds no alpha variant of a surface — see QuickConnect on the
|
||||||
desktop, which carries the same note.
|
desktop, which carries the same note.
|
||||||
-->
|
-->
|
||||||
<Border Background="#9E0E1220" />
|
<Border Background="#9E05050A" />
|
||||||
|
|
||||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||||
@@ -107,8 +114,15 @@
|
|||||||
<!--
|
<!--
|
||||||
The only control on the screen, and it goes back rather than forward. Compare the sheet above,
|
The only control on the screen, and it goes back rather than forward. Compare the sheet above,
|
||||||
where the accent button connects.
|
where the accent button connects.
|
||||||
|
|
||||||
|
◆ IT SAID "BACK TO HOSTS", AND IT NEITHER WENT THERE NOR CAME BACK. Two things were wrong with
|
||||||
|
it at once. RejectHostKey cleared only the pending key, so pressing it left HasHostKeyMismatch
|
||||||
|
set and this opaque panel up over whatever the user navigated to next — including the host editor
|
||||||
|
the paragraph above sends them to. And the shell no longer moves to Hosts to ask the question, so
|
||||||
|
even fixed it does not land there: it lowers this panel and reveals the screen that was already
|
||||||
|
underneath. "BACK" is what that is. See VaultViewModel.RejectHostKey.
|
||||||
-->
|
-->
|
||||||
<Button Classes="secondary" Content="BACK TO HOSTS" Margin="0,24,0,0"
|
<Button Classes="secondary" Content="BACK" Margin="0,24,0,0"
|
||||||
Command="{Binding RejectHostKeyCommand}" />
|
Command="{Binding RejectHostKeyCommand}" />
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,26 @@
|
|||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Android.Views;
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
/// <summary>Design 02 — the host list, and the connect bar that replaces the desktop's right column.</summary>
|
/// <summary>Design 02 — the host list, and the two gestures that are the whole of this file.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b>Two gestures on one list, and this file is the whole of the difference between them.</b> A tap on a
|
/// ◆ <b>A tap connects, a long press chooses, and once anything is chosen a tap ticks instead.</b> The
|
||||||
/// host connects to it; a long press asks about it, which is what raises the bar. Why they were split is on
|
/// branch in the middle of that is why the tap is handled here rather than bound in the markup: which of
|
||||||
/// the screen itself; what is here is the mechanics, and there are two of them worth knowing.
|
/// the two a tap means depends on whether the screen is in selection mode, and Avalonia's bindings cannot
|
||||||
|
/// ask.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain <c>Grid</c>.
|
/// The handlers are on the <c>ListBox</c> rather than on the row, and the row stays a plain
|
||||||
/// A button as the item template swallows the press before the list sees it — <c>FilesScreen</c> writes that
|
/// <c>Border</c>. A button as the item template swallows the press before the list sees it —
|
||||||
/// out at length — leaving nothing selected and every control that reads the selection doing nothing. Both
|
/// <c>FilesScreen</c> writes that out at length — so neither handler would ever run.
|
||||||
/// events fire after the list has moved its selection, which is what lets these read it.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class HostsScreen : UserControl
|
internal sealed partial class HostsScreen : UserControl
|
||||||
@@ -27,18 +30,18 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Avalonia raises <c>Tapped</c> on release whatever the press lasted, so without this a long press
|
/// Avalonia raises <c>Tapped</c> on release whatever the press lasted, so without this a long press
|
||||||
/// would open the bar and then connect — the two gestures firing one after the other on one touch, which
|
/// would tick the row and then the tap it becomes would untick it again — one touch doing a thing and
|
||||||
/// is the one outcome that would make both of them untrustworthy. Set when the hold starts and cleared
|
/// undoing it, which is the one outcome that would make both gestures untrustworthy. Set when the hold
|
||||||
/// by the tap it suppresses, so it never survives the gesture that set it.
|
/// starts and cleared by the tap it suppresses, so it never survives the gesture that set it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private bool held;
|
private bool held;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The long press is attached here rather than in the markup so that it sits beside the property that
|
/// Both gestures are attached here rather than in the markup so that they sit beside the property that
|
||||||
/// makes it fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than assumed:
|
/// makes one of them fire at all. <see cref="InputElement.IsHoldingEnabledProperty"/> is set rather than
|
||||||
/// it is the whole of the gesture, and a default that changed would take it away silently — every tap
|
/// assumed: it is the whole of the long press, and a default that changed would take it away silently —
|
||||||
/// would go on working and nothing would ever open the bar again.
|
/// every tap would go on working and nothing would ever choose a host again.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b><c>FindControl</c> rather than the field the name generator declares for <c>x:Name</c></b>,
|
/// ◆ <b><c>FindControl</c> rather than the field the name generator declares for <c>x:Name</c></b>,
|
||||||
@@ -62,20 +65,20 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Connects to the row that was tapped.
|
/// Connects to the row that was tapped, or ticks it where hosts are already ticked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Guarded on the selection being a host rather than on what was under the finger. A tap on a group
|
/// ◆ <b>The branch is the selection, not the row.</b> Once anything is chosen the screen is in selection
|
||||||
/// heading moves the list's selection and the view model bounces it straight back to whatever was chosen
|
/// mode — the bar across the top says so — and in that mode a tap adds and removes rather than
|
||||||
/// before — see <c>VaultViewModel.SelectedSidebarRow</c> — so reading the selection here answers "a host,
|
/// connecting. That is what every Android list does, and the alternative is worse than a rule to
|
||||||
/// or nothing" without this file needing to know that rule. The cost of getting it wrong is connecting to
|
/// remember: a tap that connected while five machines sat ticked would open a terminal on top of a
|
||||||
/// a machine the user was not pointing at.
|
/// selection somebody was halfway through building.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Fire-and-forget, as the desktop grid's activation is: the command reports its own failures onto the
|
/// Fire-and-forget where it connects, as the desktop grid's activation is: the command reports its own
|
||||||
/// status line — an unknown host key, a refused password — and awaiting it here would be an event handler
|
/// failures onto the status line — an unknown host key, a refused password — and awaiting it here would
|
||||||
/// returning a task nothing observes.
|
/// be an event handler returning a task nothing observes.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnRowTapped(object? sender, TappedEventArgs e)
|
private void OnRowTapped(object? sender, TappedEventArgs e)
|
||||||
@@ -86,25 +89,25 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
|
if (DataContext is not VaultViewModel vault || RowUnder(e) is not { } row)
|
||||||
{
|
{
|
||||||
_ = vault.ConnectToRowCommand.ExecuteAsync(row);
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
if (vault.IsChoosingHosts)
|
||||||
/// Raises the bar about the row that was held.
|
{
|
||||||
/// </summary>
|
vault.ToggleHostChoiceCommand.Execute(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = vault.ConnectToRowCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts a tick against the row that was held, entering selection mode with it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
|
||||||
/// On <see cref="HoldingState.Started"/> rather than on completion, so the bar is up while the finger is
|
/// On <see cref="HoldingState.Started"/> rather than on completion, so the bar is up while the finger is
|
||||||
/// still down. A long press that showed nothing until release would be a gesture with no way to tell it
|
/// still down. A long press that showed nothing until release would be a gesture with no way to tell it
|
||||||
/// had been recognised, and the only feedback available on this list is the thing it does.
|
/// had been recognised, and the only feedback available on this list is the thing it does.
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// It goes through <c>OpenHostPaneCommand</c>, which is the desktop's own "ask about this host" — the two
|
|
||||||
/// heads raise different furniture from one flag rather than keeping a selection rule each.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
|
private void OnRowHeld(object? sender, HoldingRoutedEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -115,9 +118,33 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
|
|
||||||
held = true;
|
held = true;
|
||||||
|
|
||||||
if (DataContext is VaultViewModel { SelectedHost: { } row } vault)
|
if (DataContext is VaultViewModel vault && RowUnder(e) is { } row)
|
||||||
{
|
{
|
||||||
vault.OpenHostPaneCommand.Execute(row);
|
vault.ChooseHostCommand.Execute(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The host the gesture landed on, or null where it landed on something that is not one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Read off the element under the finger rather than off the list's selection, which is what both
|
||||||
|
/// handlers used to do.</b> The selection was defensible while a tap only ever connected: a tap on a
|
||||||
|
/// group heading moves the selection and the view model bounces it straight back — see
|
||||||
|
/// <c>VaultViewModel.SelectedSidebarRow</c> — so reading it answered "a host, or nothing" for free.
|
||||||
|
/// It stops being defensible the moment a tap can tick one: a heading would then bounce the selection to
|
||||||
|
/// whichever host was last chosen and tick <em>that</em>, which is a machine the user was not pointing
|
||||||
|
/// at going into a set they are about to delete.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The ancestor rather than <c>e.Source</c> itself, because the source is whatever leaf the finger
|
||||||
|
/// landed on — a tag chip's <c>TextBlock</c> has a string for a data context, and the row's own
|
||||||
|
/// <c>Ellipse</c> has the row. The <see cref="ListBoxItem"/> is the one element in that chain whose data
|
||||||
|
/// context is always the list's item, whatever kind it is.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static HostRowViewModel? RowUnder(RoutedEventArgs e) =>
|
||||||
|
(e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true)?.DataContext
|
||||||
|
as HostRowViewModel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,8 +149,19 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Button Grid.Column="2" Classes="danger" Height="44" Width="104" Content="DELETE"
|
<!--
|
||||||
|
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}" />
|
Command="{Binding DeleteSelectedItemCommand}" />
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -180,6 +191,47 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ ◆ moving it to another vault ============ -->
|
||||||
|
<!--
|
||||||
|
The desktop's panel, in the place the deletion question uses and never at the same time as it: MOVE
|
||||||
|
disarms a pending deletion on the way in, and the buttons that ask either question are hidden while
|
||||||
|
one is up.
|
||||||
|
|
||||||
|
Both sentences are here rather than only in the status line afterwards, which on a phone is one line at
|
||||||
|
the bottom of a screen somebody has already navigated away from. The second one is the count of what
|
||||||
|
points at this key — every one of them is re-aimed at the vault it moves to, and that is the part
|
||||||
|
nobody can see from a keychain row.
|
||||||
|
-->
|
||||||
|
<Border Grid.Row="3" IsVisible="{Binding IsMovingItem}" Margin="12,4"
|
||||||
|
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||||
|
BorderThickness="1" CornerRadius="12" Padding="14,12">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="label" Text="MOVE TO VAULT" />
|
||||||
|
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Text="{Binding MovingItemLabel}" />
|
||||||
|
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
|
||||||
|
ItemsSource="{Binding MoveItemVaultChoices}"
|
||||||
|
SelectedItem="{Binding SelectedMoveItemVault}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||||
|
Text="It is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody in the vault it leaves can." />
|
||||||
|
<TextBlock Classes="body" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding HasMovingItemUsage}"
|
||||||
|
Text="{Binding MovingItemUsage}" />
|
||||||
|
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||||
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||||
|
Command="{Binding ConfirmMoveItemCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||||
|
Command="{Binding CancelMoveItemCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- ============ the items ============ -->
|
<!-- ============ the items ============ -->
|
||||||
<Panel Grid.Row="4">
|
<Panel Grid.Row="4">
|
||||||
|
|
||||||
|
|||||||
@@ -91,8 +91,8 @@
|
|||||||
|
|
||||||
Not Border.card, and neither are its two counterparts on HOSTS and FILES: that class is a panel,
|
Not Border.card, and neither are its two counterparts on HOSTS and FILES: that class is a panel,
|
||||||
and this is a warning, so the background and the border are the warn pair rather than the chrome
|
and this is a warning, so the background and the border are the warn pair rather than the chrome
|
||||||
one. What it does take is the radius the v2 ladder gives anything card-sized, which is what the
|
one. What it does take is the radius the ladder gives anything card-sized — 12, unchanged from v2
|
||||||
other two already draw.
|
to v5 — which is what the other two already draw.
|
||||||
-->
|
-->
|
||||||
<Border IsVisible="{Binding HasLiveSessions}" Margin="0,22,0,0"
|
<Border IsVisible="{Binding HasLiveSessions}" Margin="0,22,0,0"
|
||||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||||
|
|||||||
@@ -36,6 +36,18 @@
|
|||||||
The .railentry styles live in this file because they are this control's own shape and nothing else wears
|
The .railentry styles live in this file because they are this control's own shape and nothing else wears
|
||||||
them; every colour in them is resolved from Theme/Palette.axaml by key, which is the rule that matters —
|
them; every colour in them is resolved from Theme/Palette.axaml by key, which is the rule that matters —
|
||||||
a rail that named its own blues is how a fifth surface colour ends up in the application.
|
a rail that named its own blues is how a fifth surface colour ends up in the application.
|
||||||
|
|
||||||
|
── DEEP CHROME, SINCE v5 ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
This rail's own surface moves from Sidebar to DeepChrome in this pass, matching the desktop's NavRail —
|
||||||
|
which is exactly what this control is standing in for on a wide phone, and which took the same v5b move
|
||||||
|
itself: Palette.axaml's own remark on DeepChrome names "the rail down the side" as one of the five things
|
||||||
|
that colour is for. Sidebar stays what a card sits on; this is the frame the cards sit inside, the same
|
||||||
|
distinction PhoneShell's header, strip and bottom bar are drawing at the same time.
|
||||||
|
|
||||||
|
The hover fill moves too, from Panel to Track — and Track rather than Hover, because Palette.axaml's own
|
||||||
|
remark on Track names "the rail row a pointer sits over" as one of the three things that colour is for,
|
||||||
|
literally. Panel was never named for this; it read close enough that nobody had gone looking for the
|
||||||
|
seam, which is exactly the kind of gap Track exists to close.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<UserControl.Styles>
|
<UserControl.Styles>
|
||||||
@@ -54,7 +66,7 @@
|
|||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.railentry:pointerover /template/ ContentPresenter">
|
<Style Selector="Button.railentry:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.railentry.active /template/ ContentPresenter">
|
<Style Selector="Button.railentry.active /template/ ContentPresenter">
|
||||||
@@ -96,7 +108,7 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Border Width="188" Background="{StaticResource Sidebar}"
|
<Border Width="188" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -134,15 +146,17 @@
|
|||||||
|
|
||||||
<!--
|
<!--
|
||||||
The terminal is a surface rather than a page, so it goes through its own command — the same
|
The terminal is a surface rather than a page, so it goes through its own command — the same
|
||||||
reason the bottom bar's middle entry does. It is never lit, and deliberately: this rail is
|
reason the bottom bar's middle entry does. It lights on IsTerminalSurface, which on this control
|
||||||
collapsed while a shell is showing, so IsCurrent could only ever read false. What marks that
|
can only mean one thing: the rail stands down for a shell, so the one moment it is drawn beside
|
||||||
destination as current is the surface filling the screen.
|
this surface is Connections with nothing running. That screen is a page in everything but which
|
||||||
|
enum it is in, and a rail sitting beside it with no entry lit would be pointing nowhere.
|
||||||
|
|
||||||
No count, unlike the two above it. The shells strip sits above the bottom of every page on this
|
No count, unlike the two above it. The shells strip sits above the bottom of every page on this
|
||||||
surface and is that count, drawn as the sessions themselves — a number beside this word would be
|
surface and is that count, drawn as the sessions themselves — a number beside this word would be
|
||||||
the same fact said twice, three inches apart.
|
the same fact said twice, three inches apart.
|
||||||
-->
|
-->
|
||||||
<Button Classes="railentry" Command="{Binding ShowTerminalCommand}">
|
<Button Classes="railentry" Classes.active="{Binding IsTerminalSurface}"
|
||||||
|
Command="{Binding ShowTerminalCommand}">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
|
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
|
||||||
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
|
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
|
||||||
|
|||||||
@@ -25,8 +25,9 @@
|
|||||||
|
|
||||||
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
|
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
|
||||||
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
|
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
|
||||||
bottom bar itself. All three are bound on IsShowingPages, which is the same question asked once — the
|
bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
|
||||||
surface is either a page or a terminal, and these are the chrome a page has.
|
computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which
|
||||||
|
is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome.
|
||||||
|
|
||||||
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
|
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
|
||||||
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
|
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
|
||||||
@@ -96,9 +97,18 @@
|
|||||||
the surface. See PhoneShell.ShowsVaultHeader.
|
the surface. See PhoneShell.ShowsVaultHeader.
|
||||||
-->
|
-->
|
||||||
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
|
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
|
||||||
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
<!--
|
||||||
|
◆ DeepChrome rather than Chrome, since v5. The desktop's own titlebar and nav rail made the same
|
||||||
|
move in v5b — one step darker than Chrome, #0B0B14 against #10111E, so the frame reads as what
|
||||||
|
holds the glass rather than as another pane of it — and this header is the phone's equivalent
|
||||||
|
furniture: it is what the desktop's titlebar is, on the surface that has no window to carry one.
|
||||||
|
HostActionBar, which takes this header's own place while hosts are selected, and the editor's
|
||||||
|
header in HostsScreen, which the header stands down for, both move with it for the same reason;
|
||||||
|
see the remark on each.
|
||||||
|
-->
|
||||||
|
<Border Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||||
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
|
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
|
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
|
||||||
@@ -127,30 +137,18 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ THE PENCIL, and it is here rather than on the row for the reason the whole gesture split
|
◆ THE PENCIL WAS HERE, AND IT MOVED INTO THE ACTION BAR.
|
||||||
happened: choosing a host on this head no longer raises a bar carrying EDIT, because that bar
|
|
||||||
was five controls over the bottom of the list in the way of a tap that means "connect". EDIT is
|
|
||||||
the one of the five common enough to be worth a control that is always in the same place, so it
|
|
||||||
is in the header — the phone's only piece of persistent chrome — and the long press still
|
|
||||||
reaches the other four. See HostsScreen.axaml.
|
|
||||||
|
|
||||||
Two conditions, nested rather than combined, because Avalonia's bindings have no "and" and the
|
It was in the header because a long press raised a connect card carrying EDIT, and a card over
|
||||||
two belong to different view models: which screen is showing is the shell's question, and
|
the bottom of the list was the wrong place for the one action people reach for most. Both
|
||||||
whether there is a host to edit is the vault's. That is the same arrangement the header itself
|
halves of that changed at once: a long press now chooses hosts rather than raising a card, and
|
||||||
is wrapped in one level up.
|
the bar it raises takes this header's place — so the pencil is in that bar, beside the count of
|
||||||
|
what it would edit. See HostActionBar.axaml, which is drawn in the Panel below this one.
|
||||||
|
|
||||||
Collapsed rather than disabled when there is nothing chosen. A greyed pencil sitting beside the
|
What is left in this row is the vault's name, the sync light and LOCK: three facts about the
|
||||||
vault's name on every screen would be a permanent reminder of a control that is only ever about
|
keychain, and none of them about a row.
|
||||||
one row.
|
|
||||||
-->
|
-->
|
||||||
<Panel Grid.Column="3" IsVisible="{Binding IsHostsShowing}">
|
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
||||||
<Button Classes="icon" Content="✎" FontSize="15" Margin="4,0,0,0"
|
|
||||||
IsVisible="{Binding Vault.CanEditSelectedHost}"
|
|
||||||
Command="{Binding Vault.EditSelectedHostCommand}"
|
|
||||||
ToolTip.Tip="Edit the selected host" />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Button Grid.Column="4" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
|
|
||||||
ToolTip.Tip="Lock the keychain">
|
ToolTip.Tip="Lock the keychain">
|
||||||
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
|
||||||
Foreground="{StaticResource TextDim}" />
|
Foreground="{StaticResource TextDim}" />
|
||||||
@@ -159,6 +157,21 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ ◆ the contextual action bar ============ -->
|
||||||
|
<!--
|
||||||
|
In the header's row and in its place, never beside it. A bar that stacked under the vault's name
|
||||||
|
would be two rows of chrome above a list at 360dp, and worse, it would leave the keychain's name and
|
||||||
|
LOCK on screen over a list that is no longer about the keychain — see PhoneShell.ShowsHostSelectionBar,
|
||||||
|
which is where the two are made exclusive.
|
||||||
|
|
||||||
|
Wrapped so the data context can be the vault, as every other screen in this file is: what the bar
|
||||||
|
counts and what its four controls do are one vault's business, and the shell's own binding would
|
||||||
|
resolve none of them.
|
||||||
|
-->
|
||||||
|
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsHostSelectionBar}">
|
||||||
|
<views:HostActionBar DataContext="{Binding Vault}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- The screens. Only one draws; which one is the shell's business. -->
|
<!-- The screens. Only one draws; which one is the shell's business. -->
|
||||||
<Panel Grid.Row="1">
|
<Panel Grid.Row="1">
|
||||||
<!--
|
<!--
|
||||||
@@ -252,6 +265,12 @@
|
|||||||
◆ The two host-key decisions, over everything. Outside the screen Panel deliberately: file transfer
|
◆ The two host-key decisions, over everything. Outside the screen Panel deliberately: file transfer
|
||||||
makes its own trust decision on the desktop, and the phone now has that screen — so a sheet nested
|
makes its own trust decision on the desktop, and the phone now has that screen — so a sheet nested
|
||||||
inside the hosts screen would be one the files screen could not raise.
|
inside the hosts screen would be one the files screen could not raise.
|
||||||
|
|
||||||
|
◆ And that arrangement is what the shell now relies on rather than merely permits. It used to move to
|
||||||
|
HOSTS before letting a handshake ask the question, because the desktop head drew it as a banner on that
|
||||||
|
screen; it does not any more, so this sheet is what is over the Connections surface when a machine
|
||||||
|
typed into the connect box by hand turns out to be a first contact. Which is the case the navigation
|
||||||
|
was worst for — it took the box away. See MainWindowViewModel.OnVaultConnectionFailed.
|
||||||
-->
|
-->
|
||||||
<Panel Grid.Row="1">
|
<Panel Grid.Row="1">
|
||||||
<views:HostKeySheet DataContext="{Binding Vault}" />
|
<views:HostKeySheet DataContext="{Binding Vault}" />
|
||||||
@@ -271,9 +290,17 @@
|
|||||||
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
|
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
|
||||||
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
|
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
|
||||||
whether there are any tabs and this one is about which surface is up.
|
whether there are any tabs and this one is about which surface is up.
|
||||||
|
|
||||||
|
◆ And it stands down under the host editor, which is a page rather than a card since the pencil moved
|
||||||
|
into the action bar. A strip of open shells above a form is the same two-rows-of-chrome problem the
|
||||||
|
terminal has, on a screen where the form is the whole point of being there. See
|
||||||
|
PhoneShell.ShowsShellStrip, which is where that "and" is made, Avalonia's bindings having none.
|
||||||
-->
|
-->
|
||||||
<Panel Grid.Row="2" IsVisible="{Binding IsShowingPages}">
|
<Panel Grid.Row="2" IsVisible="{Binding $parent[views:PhoneShell].ShowsShellStrip}">
|
||||||
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
|
<!-- DeepChrome rather than Sidebar, since v5, joining the header and the bar above and below it:
|
||||||
|
the strip is chrome the same way they are — a frame around the screen rather than a pane of
|
||||||
|
it — and Sidebar is what a card sits on, which this row is not. -->
|
||||||
|
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
||||||
@@ -282,7 +309,7 @@
|
|||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||||
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="9"
|
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="10"
|
||||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||||
@@ -293,8 +320,14 @@
|
|||||||
which was true when a tab could not exist without a session; one can now —
|
which was true when a tab could not exist without a session; one can now —
|
||||||
connecting opens the tab first — and a dot that was green before anything had
|
connecting opens the tab first — and a dot that was green before anything had
|
||||||
answered would be the one thing on this strip claiming something untrue.
|
answered would be the one thing on this strip claiming something untrue.
|
||||||
|
|
||||||
|
Amber while it is being made, which is the other half of that correction. Not being
|
||||||
|
green stopped the dot lying, but it left a tab still dialling drawn exactly like a
|
||||||
|
tab whose shell has exited — the two states on this strip with the least in common,
|
||||||
|
one worth waiting for and one over. See Phone.axaml.
|
||||||
-->
|
-->
|
||||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||||
|
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -308,10 +341,15 @@
|
|||||||
|
|
||||||
<!-- ============ navigation ============ -->
|
<!-- ============ navigation ============ -->
|
||||||
<!--
|
<!--
|
||||||
Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's three
|
Gone while a shell is showing, which is the whole of that surface's arrangement: the bar's three
|
||||||
destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's
|
destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's
|
||||||
own bar. See TerminalScreen.axaml.
|
own bar. See TerminalScreen.axaml.
|
||||||
|
|
||||||
|
◆ A shell rather than the surface, which are not the same question — Connections with nothing running
|
||||||
|
is that surface drawing a page, and the bar stays under it. The screen it stands down for is the one
|
||||||
|
that pays for the room; a box asking which machine to connect to does not, and taking the nav away
|
||||||
|
from somebody who has just closed their last tab left them with only the back gesture.
|
||||||
|
|
||||||
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
|
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
|
||||||
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
|
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
|
||||||
|
|
||||||
@@ -321,8 +359,10 @@
|
|||||||
shape of everything else already behind the hub. What is left is the two halves of using this
|
shape of everything else already behind the hub. What is left is the two halves of using this
|
||||||
application, and the drawer holding the rest.
|
application, and the drawer holding the rest.
|
||||||
-->
|
-->
|
||||||
|
<!-- DeepChrome rather than Chrome, since v5 — see the remark on the vault header, above, which this
|
||||||
|
bar is the foot of the same frame the header is the top of. -->
|
||||||
<Border Grid.Row="3" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
|
<Border Grid.Row="3" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
|
||||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||||
BorderThickness="0,1,0,0" Height="64">
|
BorderThickness="0,1,0,0" Height="64">
|
||||||
<Grid ColumnDefinitions="*,*,*">
|
<Grid ColumnDefinitions="*,*,*">
|
||||||
|
|
||||||
@@ -340,12 +380,15 @@
|
|||||||
rather than the door. The enum member stays ShellSurface.Terminal, for the reason the keychain's
|
rather than the door. The enum member stays ShellSurface.Terminal, for the reason the keychain's
|
||||||
tab is not called Vault: the surface is a terminal, and the word a user reads is the product's.
|
tab is not called Vault: the surface is a terminal, and the word a user reads is the product's.
|
||||||
|
|
||||||
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed
|
◆ It lights again, and IsTerminalSurface is the right question rather than a near miss. This bar
|
||||||
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
|
is drawn in exactly two situations — a page, and Connections with nothing running — and that flag
|
||||||
would be a rule about a state this control cannot be in. What marks it as current is the surface
|
separates them: false for every page, true for the one screen where this is where you are. With a
|
||||||
filling the screen.
|
shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was
|
||||||
|
collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the
|
||||||
|
bar can now sit under and not point at is the entry looking broken instead.
|
||||||
-->
|
-->
|
||||||
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
|
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
|
||||||
|
IsCurrent="{Binding IsTerminalSurface}"
|
||||||
Command="{Binding ShowTerminalCommand}" />
|
Command="{Binding ShowTerminalCommand}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
{
|
{
|
||||||
private MainWindowViewModel? shell;
|
private MainWindowViewModel? shell;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The open vault, while there is one, so that this control hears about the hosts screen's own state.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// ◆ <b>A second subscription, and it is the price of the header being swappable.</b> Two of the flags
|
||||||
|
/// below are questions about the vault rather than about the shell — whether hosts are ticked, and
|
||||||
|
/// whether the host editor is filling the screen — and the shell does not forward the vault's
|
||||||
|
/// notifications. Kept in step from <see cref="OnShellChanged"/>, because <c>Vault</c> is replaced on
|
||||||
|
/// every unlock and nulled on every lock; a handler left on a disposed vault would keep it alive.
|
||||||
|
/// </remarks>
|
||||||
|
private VaultViewModel? vault;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Everything the phone draws, which is the element the software keyboard is kept off.
|
/// Everything the phone draws, which is the element the software keyboard is kept off.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -90,10 +102,40 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
TryOfferDeviceUnlock();
|
TryOfferDeviceUnlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FollowTheVault();
|
||||||
RefreshChrome();
|
RefreshChrome();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves this control's second subscription onto whichever vault is open now.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Compared before being swapped, so that the ordinary case — a shell notification about something else
|
||||||
|
/// entirely — costs one reference comparison rather than an unsubscribe and a resubscribe per property
|
||||||
|
/// change on the shell.
|
||||||
|
/// </remarks>
|
||||||
|
private void FollowTheVault()
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(vault, shell?.Vault))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vault is not null)
|
||||||
|
{
|
||||||
|
vault.PropertyChanged -= OnVaultChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
vault = shell?.Vault;
|
||||||
|
|
||||||
|
if (vault is not null)
|
||||||
|
{
|
||||||
|
vault.PropertyChanged += OnVaultChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnVaultChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
|
=> RefreshChrome();
|
||||||
|
|
||||||
/// <summary>Whether this surface is wide enough to be laid out like the desktop.</summary>
|
/// <summary>Whether this surface is wide enough to be laid out like the desktop.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A property of the control rather than of the view model, because it is a fact about the surface and
|
/// A property of the control rather than of the view model, because it is a fact about the surface and
|
||||||
@@ -104,10 +146,19 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
|
||||||
|
|
||||||
/// <summary>Whether the rail down the left edge is drawn.</summary>
|
/// <summary>Whether the rail down the left edge is drawn.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The wide surface's answer to <see cref="ShowsBottomBarProperty"/> and asks the same question about
|
||||||
|
/// which screen is up, so the two move together — including over Connections with nothing running. See
|
||||||
|
/// <see cref="RefreshChrome"/>.
|
||||||
|
/// </remarks>
|
||||||
public static readonly StyledProperty<bool> ShowsRailProperty =
|
public static readonly StyledProperty<bool> ShowsRailProperty =
|
||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
|
||||||
|
|
||||||
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
|
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Not simply the pages: it also stays up on Connections with nothing running, which is the terminal
|
||||||
|
/// surface drawing a page. See <see cref="RefreshChrome"/>.
|
||||||
|
/// </remarks>
|
||||||
public static readonly StyledProperty<bool> ShowsBottomBarProperty =
|
public static readonly StyledProperty<bool> ShowsBottomBarProperty =
|
||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
|
||||||
|
|
||||||
@@ -115,6 +166,14 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
|
public static readonly StyledProperty<bool> ShowsVaultHeaderProperty =
|
||||||
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultHeader));
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsVaultHeader));
|
||||||
|
|
||||||
|
/// <summary>Whether the bar about the chosen hosts is drawn in the header's place.</summary>
|
||||||
|
public static readonly StyledProperty<bool> ShowsHostSelectionBarProperty =
|
||||||
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsHostSelectionBar));
|
||||||
|
|
||||||
|
/// <summary>Whether the strip of open shells above the bottom bar is drawn.</summary>
|
||||||
|
public static readonly StyledProperty<bool> ShowsShellStripProperty =
|
||||||
|
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsShellStrip));
|
||||||
|
|
||||||
/// <inheritdoc cref="IsWideProperty" />
|
/// <inheritdoc cref="IsWideProperty" />
|
||||||
public bool IsWide
|
public bool IsWide
|
||||||
{
|
{
|
||||||
@@ -143,17 +202,40 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
private set => SetValue(ShowsVaultHeaderProperty, value);
|
private set => SetValue(ShowsVaultHeaderProperty, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ShowsHostSelectionBarProperty" />
|
||||||
|
public bool ShowsHostSelectionBar
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsHostSelectionBarProperty);
|
||||||
|
private set => SetValue(ShowsHostSelectionBarProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ShowsShellStripProperty" />
|
||||||
|
public bool ShowsShellStrip
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsShellStripProperty);
|
||||||
|
private set => SetValue(ShowsShellStripProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Works out which chrome this surface should be wearing.
|
/// Works out which chrome this surface should be wearing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b>Three flags computed here rather than three conditions in the markup, because Avalonia's
|
/// ◆ <b>Five flags computed here rather than five conditions in the markup, because Avalonia's
|
||||||
/// bindings have no "and" and none of these is a single question any more.</b> Everywhere else on this
|
/// bindings have no "and" and none of these is a single question any more.</b> Everywhere else on this
|
||||||
/// head that costs a wrapper element; here it would cost two nested ones per row and the header's would
|
/// head that costs a wrapper element; here it would cost two nested ones per row and the header's would
|
||||||
/// have to be an "or", which a wrapper cannot express at all.
|
/// have to be an "or", which a wrapper cannot express at all.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// ◆ <b>The nav stands down for a shell rather than for the terminal surface, and those parted company
|
||||||
|
/// when that surface gained a page.</b> Connections with nothing running is a box, a CONNECT button and
|
||||||
|
/// the machines connected to before — see TerminalScreen.axaml — and none of that is worth the screen a
|
||||||
|
/// shell is worth it for. It is also the one screen somebody can arrive at by closing their last tab,
|
||||||
|
/// which made the collapsed bar a way to end up with no route to Hosts or Settings but the system back
|
||||||
|
/// gesture. The header is deliberately not part of this: the surface draws its own bar with the back
|
||||||
|
/// arrow and the +, and the vault header above that is the second row this head exists to avoid.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
/// <b>The header is the one worth reading twice.</b> Narrow, it stands down behind SETTINGS, because the
|
/// <b>The header is the one worth reading twice.</b> Narrow, it stands down behind SETTINGS, because the
|
||||||
/// screens under that hub draw their own header with a back arrow and two rows of chrome is what this
|
/// screens under that hub draw their own header with a back arrow and two rows of chrome is what this
|
||||||
/// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail
|
/// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail
|
||||||
@@ -161,7 +243,16 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
/// Losing them on the keychain would be losing the only LOCK button on the surface.
|
/// Losing them on the keychain would be losing the only LOCK button on the surface.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Recomputed on every shell notification rather than on a named list of them. Three boolean
|
/// ◆ <b>The header is now a swap rather than a switch, and the editor takes the whole screen.</b> Two
|
||||||
|
/// more flags and two more inputs, both of them the vault's rather than the shell's — see
|
||||||
|
/// <see cref="vault"/>. While hosts are ticked the header stands down and
|
||||||
|
/// <see cref="ShowsHostSelectionBar"/> puts the action bar in its place, which is what makes that bar
|
||||||
|
/// unambiguous: the screen is about the ticked hosts and nothing else. While the host editor is open it
|
||||||
|
/// is a page rather than a card, so all four rows of chrome stand down and the form has the display —
|
||||||
|
/// which is what "opens in a separate page" means on a 360dp screen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Recomputed on every notification from either object rather than on a named list of them. Five boolean
|
||||||
/// comparisons and no allocation is cheaper than being wrong: the properties this reads are computed
|
/// comparisons and no allocation is cheaper than being wrong: the properties this reads are computed
|
||||||
/// ones, and which of them raise a change is a fact about a file in another project that nothing here
|
/// ones, and which of them raise a change is a fact about a file in another project that nothing here
|
||||||
/// would notice going stale.
|
/// would notice going stale.
|
||||||
@@ -172,6 +263,24 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
var wide = body.Bounds.Width >= WideAt;
|
var wide = body.Bounds.Width >= WideAt;
|
||||||
var pages = shell?.IsShowingPages == true;
|
var pages = shell?.IsShowingPages == true;
|
||||||
|
|
||||||
|
// ◆ Connections with nothing running, which is the terminal surface drawing a page: a box, a CONNECT
|
||||||
|
// button and the machines connected to before. The nav stands down for a shell — the whole of the
|
||||||
|
// arrangement below — and there is no shell here to stand down for, so it stays. Taking it away on
|
||||||
|
// this one screen was worst where it was least affordable: somebody who has just closed their last
|
||||||
|
// tab, or who pressed Connections to see what was open and found nothing, was left on a screen whose
|
||||||
|
// only way to Hosts or Settings was the system back gesture.
|
||||||
|
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
|
||||||
|
|
||||||
|
// The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
|
||||||
|
// not the shells strip, and not the way off the screen. Its own header carries the back arrow, which
|
||||||
|
// is the one control it needs and the one the system gesture already maps to.
|
||||||
|
var editing = vault?.IsEditing == true;
|
||||||
|
|
||||||
|
// Only on the hosts screen. The ticks survive a trip to the keychain — the set is not cleared by
|
||||||
|
// navigating — and a bar counting hosts over the transfers screen would be chrome about a list that
|
||||||
|
// is not on the display.
|
||||||
|
var choosing = vault?.IsChoosingHosts == true && shell?.IsHostsShowing == true;
|
||||||
|
|
||||||
// Before the flags, because it changes what one of them reads. Nothing else on this head navigates
|
// Before the flags, because it changes what one of them reads. Nothing else on this head navigates
|
||||||
// in response to a resize, and this is not navigation for its own sake: the hub is a list of the
|
// in response to a resize, and this is not navigation for its own sake: the hub is a list of the
|
||||||
// destinations the rail now carries, so an unfolded device would otherwise sit on a menu of things
|
// destinations the rail now carries, so an unfolded device would otherwise sit on a menu of things
|
||||||
@@ -183,9 +292,11 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
IsWide = wide;
|
IsWide = wide;
|
||||||
ShowsRail = wide && pages;
|
ShowsRail = wide && (pages || connectPage) && !editing;
|
||||||
ShowsBottomBar = !wide && pages;
|
ShowsBottomBar = !wide && (pages || connectPage) && !editing;
|
||||||
ShowsVaultHeader = pages && (wide || shell?.IsMoreSurface != true);
|
ShowsShellStrip = pages && !editing;
|
||||||
|
ShowsHostSelectionBar = pages && choosing && !editing;
|
||||||
|
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
@@ -195,6 +306,7 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FollowTheVault();
|
||||||
RefreshChrome();
|
RefreshChrome();
|
||||||
|
|
||||||
if (e.PropertyName is nameof(MainWindowViewModel.State))
|
if (e.PropertyName is nameof(MainWindowViewModel.State))
|
||||||
@@ -500,16 +612,23 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
|
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Order is the whole of it. The two sheets sit over the list and the two editors sit in place of it, so
|
/// Order is the whole of it. The sheets sit over the list, the panels sit above it and the editors sit
|
||||||
/// a sheet has to go first — closing an editor while a sheet was open would leave the sheet floating
|
/// in place of it, so a sheet has to go first — closing an editor while a sheet was open would leave the
|
||||||
/// over a list nobody asked to see, and the second back would then close the sheet rather than the
|
/// sheet floating over a list nobody asked to see, and the second back would then close the sheet rather
|
||||||
/// editor the user was looking at.
|
/// than the editor the user was looking at.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The editors are cancelled rather than merely hidden. Cancelling is what clears the boxes, and the
|
/// The editors are cancelled rather than merely hidden. Cancelling is what clears the boxes, and the
|
||||||
/// host editor's boxes are the ones worth clearing: leaving a half-typed hostname behind would have the
|
/// host editor's boxes are the ones worth clearing: leaving a half-typed hostname behind would have the
|
||||||
/// next NEW HOST open on somebody else's abandoned draft.
|
/// next NEW HOST open on somebody else's abandoned draft.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Selection mode is last and is still a thing back has to spend itself on.</b> It is a mode rather
|
||||||
|
/// than a surface — the list underneath is fully drawn and the only sign of it is the bar across the top
|
||||||
|
/// — and a gesture that left the application from it would take somebody out of the app because they had
|
||||||
|
/// held a row down. Its panels go before it, in the order they are stacked: the picker or the question is
|
||||||
|
/// what the user is looking at, and the ticks underneath are what it is about.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
|
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
|
||||||
{
|
{
|
||||||
@@ -518,22 +637,71 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return TryLowerASheet(vault) || TryCloseSomethingBehindTheSheets(vault);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Lowers the nearest of the four sheets, which are what sits over everything else.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The four cannot be open at once — each is raised from a control the others hide — so their order
|
||||||
|
/// between themselves decides nothing. What matters is that all of them come before the panels and the
|
||||||
|
/// editors underneath: closing an editor while a sheet was open would leave the sheet floating over a
|
||||||
|
/// list nobody asked to see.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool TryLowerASheet(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
// ◆ The action bar's own menu, first of the four because it is raised from chrome that is already
|
||||||
|
// over everything else.
|
||||||
|
if (vault.IsHostActionSheetOpen)
|
||||||
|
{
|
||||||
|
vault.CloseHostActionSheetCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The password sheet, which is what a tap on a machine that wants one raises. Cancelled rather than
|
||||||
|
// hidden, because cancelling is what empties the box — see VaultViewModel.CancelConnectPassword.
|
||||||
|
if (vault.IsAskingForConnectPassword)
|
||||||
|
{
|
||||||
|
vault.CancelConnectPasswordCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (vault.IsAddSheetOpen)
|
if (vault.IsAddSheetOpen)
|
||||||
{
|
{
|
||||||
vault.CloseAddSheetCommand.Execute(null);
|
vault.CloseAddSheetCommand.Execute(null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The other sheet, and it is checked beside the first rather than after the editors for the same
|
|
||||||
// reason: it is raised over the list, so it is the nearest thing on screen. The two cannot be open
|
|
||||||
// at once — one is raised by the +, the other by a heading, and each hides the list the other's
|
|
||||||
// control is on — so their order between themselves decides nothing.
|
|
||||||
if (vault.GroupSheet is not null)
|
if (vault.GroupSheet is not null)
|
||||||
{
|
{
|
||||||
vault.CloseGroupSheetCommand.Execute(null);
|
vault.CloseGroupSheetCommand.Execute(null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Closes the nearest of the panels, the editors and selection mode itself.</summary>
|
||||||
|
/// <inheritdoc cref="TryCloseAnOpenEditor" path="/remarks" />
|
||||||
|
private static bool TryCloseSomethingBehindTheSheets(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
if (vault.IsSendingChosenHostsToAVault)
|
||||||
|
{
|
||||||
|
vault.CancelSendChosenHostsToAVaultCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vault.IsRegroupingChosenHosts)
|
||||||
|
{
|
||||||
|
vault.CancelRegroupChosenHostsCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vault.IsConfirmingChosenHostDeletion)
|
||||||
|
{
|
||||||
|
vault.CancelDeleteCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (vault.IsEditing)
|
if (vault.IsEditing)
|
||||||
{
|
{
|
||||||
vault.CancelEditCommand.Execute(null);
|
vault.CancelEditCommand.Execute(null);
|
||||||
@@ -546,6 +714,12 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (vault.IsChoosingHosts)
|
||||||
|
{
|
||||||
|
vault.ClearHostChoiceCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,16 @@
|
|||||||
good way to read a code and a bad way to copy one: a user who cannot select it will photograph the
|
good way to read a code and a bad way to copy one: a user who cannot select it will photograph the
|
||||||
screen, and that is a worse home for it than their clipboard.
|
screen, and that is a worse home for it than their clipboard.
|
||||||
-->
|
-->
|
||||||
|
<!--
|
||||||
|
◆ CornerRadius 12, not 6. Its padding, 14,13, is Border.card's own — this box was already drawn to a
|
||||||
|
card's proportions, in every dimension but the corner — so under v2's ladder 6 was an outlier this
|
||||||
|
file never explained. It is worse than unexplained now: v5's ladder gives 6 to a chip, and a
|
||||||
|
full-width box holding the one secret this whole screen exists to show is not a chip wearing a
|
||||||
|
card's padding by coincidence. 12 is what a card-shaped box gets on this ladder, and this one always
|
||||||
|
was one.
|
||||||
|
-->
|
||||||
<Border Margin="0,20,0,0" Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
<Border Margin="0,20,0,0" Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||||
BorderThickness="1" CornerRadius="6" Padding="14,13">
|
BorderThickness="1" CornerRadius="12" Padding="14,13">
|
||||||
<SelectableTextBlock Text="{Binding RecoveryCode}"
|
<SelectableTextBlock Text="{Binding RecoveryCode}"
|
||||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||||
Foreground="{StaticResource Accent}"
|
Foreground="{StaticResource Accent}"
|
||||||
|
|||||||
@@ -20,9 +20,17 @@
|
|||||||
<StackPanel Spacing="10" HorizontalAlignment="Center" Margin="0,48,0,36">
|
<StackPanel Spacing="10" HorizontalAlignment="Center" Margin="0,48,0,36">
|
||||||
<!--
|
<!--
|
||||||
Filled rather than outlined since v2, the same mark the header, the desktop titlebar and the
|
Filled rather than outlined since v2, the same mark the header, the desktop titlebar and the
|
||||||
launcher icon carry. The radius is not picked: the mark runs 6 at 20 and 8 at 26, which is a
|
launcher icon carry. The radius is not picked: the mark runs 6 at 20 and 8 at 26, a third of a
|
||||||
third of a unit per unit of tile and lands on 14 at 44 — and 14 is a rung of Phone.axaml's
|
unit per unit of tile, and lands on 14 at 44.
|
||||||
ladder, the one for a block of monospaced output, which is what this is.
|
|
||||||
|
◆ That used to be a rung of Phone.axaml's own ladder too — 14, the one for a block of monospaced
|
||||||
|
output, which this glyph reads as — and the coincidence was worth a sentence: one fewer number
|
||||||
|
the ladder had to introduce. It is a coincidence no longer. v5's ladder moves output to 12 and
|
||||||
|
never reaches 14 at all, so the mark's own 6/8/14 progression stands on its own now rather than
|
||||||
|
borrowing a card- or output-shaped justification — a self-contained proportion for a badge that
|
||||||
|
answers to nothing but its own three sizes. Left at 14 rather than moved to 12 with everything
|
||||||
|
else: the ladder governs content surfaces, and this is a mark, sized off its own tile rather than
|
||||||
|
off what it holds.
|
||||||
-->
|
-->
|
||||||
<Border Width="44" Height="44" CornerRadius="14" Background="{StaticResource Accent}"
|
<Border Width="44" Height="44" CornerRadius="14" Background="{StaticResource Accent}"
|
||||||
HorizontalAlignment="Center">
|
HorizontalAlignment="Center">
|
||||||
|
|||||||
@@ -64,6 +64,26 @@
|
|||||||
|
|
||||||
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
|
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ Which vault a *new* snippet is filed into. Hidden for an existing one — its vault is not a
|
||||||
|
field of this form, and changing it is MOVE below — and hidden entirely where there is only one
|
||||||
|
vault to choose between, which is where most people stay.
|
||||||
|
-->
|
||||||
|
<StackPanel Spacing="6" IsVisible="{Binding ShowsEditorVaultChoice}">
|
||||||
|
<TextBlock Classes="label" Text="VAULT" />
|
||||||
|
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
|
||||||
|
ItemsSource="{Binding EditorVaultChoices}"
|
||||||
|
SelectedItem="{Binding EditorSelectedVault}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Classes="body"
|
||||||
|
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
|
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
|
||||||
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
|
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
VerticalContentAlignment="Top" Padding="14,10" />
|
VerticalContentAlignment="Top" Padding="14,10" />
|
||||||
@@ -125,10 +145,23 @@
|
|||||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<TextBlock Text="{Binding Badge}" />
|
<TextBlock Text="{Binding Badge}" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ Which vault this one is in, drawn only where there is more than one to be in. A shared
|
||||||
|
snippet is a command the rest of a team can read and insert into their own terminals,
|
||||||
|
and this card is the only place that fact appears before somebody taps EDIT.
|
||||||
|
-->
|
||||||
|
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
|
||||||
|
<TextBlock Text="{Binding VaultBadge}" />
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
|
<!-- The command, on the surface every block of monospace in this design is drawn on.
|
||||||
<Border Classes="output" CornerRadius="9" Padding="11,9">
|
No radius override any more: it diverged from Border.output's own 14 under v2 for a
|
||||||
|
smaller card-nested block, and now that the style's own value has moved to 12 there is
|
||||||
|
no gap left worth a second number for — it takes the class's, like every other output
|
||||||
|
block on this head. -->
|
||||||
|
<Border Classes="output" Padding="11,9">
|
||||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Preview}"
|
<TextBlock Classes="mono" FontSize="11" Text="{Binding Preview}"
|
||||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||||
</Border>
|
</Border>
|
||||||
@@ -145,12 +178,17 @@
|
|||||||
second column to put it in. Both buttons name the terminal — "TYPE INTO pg-primary" — because on a
|
second column to put it in. Both buttons name the terminal — "TYPE INTO pg-primary" — because on a
|
||||||
phone the tab strip may be scrolled away and "whatever is in the terminal receives this" is not a
|
phone the tab strip may be scrolled away and "whatever is in the terminal receives this" is not a
|
||||||
sentence anybody should have to guess the subject of.
|
sentence anybody should have to guess the subject of.
|
||||||
|
|
||||||
|
DeepChrome rather than Chrome, since v5: this bar is chrome furniture in the same sense the bottom
|
||||||
|
nav bar it sits above is — a raised strip of frame rather than a pane of content — and joins the same
|
||||||
|
DeepChrome decision PhoneShell's header, strip and bar made. See the remark there.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="4" IsVisible="{Binding HasSelection}" Background="{StaticResource Chrome}"
|
<Border Grid.Row="4" IsVisible="{Binding HasSelection}" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="14,12"
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="14,12"
|
||||||
IsEnabled="{Binding !IsEditing}">
|
IsEnabled="{Binding !IsEditing}">
|
||||||
<StackPanel Spacing="9">
|
<StackPanel Spacing="9">
|
||||||
|
|
||||||
|
<StackPanel Spacing="9" IsVisible="{Binding ShowsSelectionActions}">
|
||||||
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
|
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
|
||||||
IsEnabled="{Binding CanInsert}" />
|
IsEnabled="{Binding CanInsert}" />
|
||||||
|
|
||||||
@@ -162,9 +200,54 @@
|
|||||||
<Grid ColumnDefinitions="*,8,*">
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
|
||||||
Command="{Binding EditCommand}" />
|
Command="{Binding EditCommand}" />
|
||||||
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
|
<!--
|
||||||
Command="{Binding DeleteCommand}" />
|
◆ Sharing the snippet. Beside EDIT rather than inside it, which is the line the hosts screen
|
||||||
|
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>
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ A row of its own, under the pair rather than beside them, as the hosts screen puts it. A phone
|
||||||
|
has no hover and no tooltip, so the only thing separating a destructive button from an ordinary
|
||||||
|
one is where a thumb lands.
|
||||||
|
-->
|
||||||
|
<Button Classes="danger" Height="44" Content="DELETE" Command="{Binding DeleteCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ MOVING THE SNIPPET TO ANOTHER VAULT, in the place the insert controls were. A picker and two
|
||||||
|
buttons rather than a question with a yes: what is being asked is which vault, and a move is undone
|
||||||
|
by moving it back.
|
||||||
|
|
||||||
|
The sentence is not decoration. Unlike a host, a snippet crosses whole — there is no group and no
|
||||||
|
tag to leave behind — so what there is to say is who can read it afterwards; and on a phone, where
|
||||||
|
the status line is one line at the bottom of a screen somebody has already navigated away from,
|
||||||
|
before the tap is the only place that reliably gets read.
|
||||||
|
-->
|
||||||
|
<StackPanel Spacing="10" IsVisible="{Binding IsMoving}">
|
||||||
|
<TextBlock Classes="label" Text="MOVE TO VAULT" />
|
||||||
|
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
|
||||||
|
ItemsSource="{Binding MoveVaultChoices}"
|
||||||
|
SelectedItem="{Binding SelectedMoveVault}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Classes="body"
|
||||||
|
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
|
||||||
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
|
||||||
|
Command="{Binding ConfirmMoveCommand}" />
|
||||||
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||||
|
Command="{Binding CancelMoveCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,13 @@
|
|||||||
|
|
||||||
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
|
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
|
||||||
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
|
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
|
||||||
four-entry bottom bar are all collapsed by PhoneShell while this surface is up, and what replaces them
|
three-entry bottom bar are all collapsed by PhoneShell while a shell is showing, and what replaces them
|
||||||
is the row below: back, the sessions, and the way to open another one.
|
is the row below: back, the sessions, and the way to open another one.
|
||||||
|
|
||||||
|
With nothing running it is only the header and the strip. This surface is then the connect page below —
|
||||||
|
a box and the machines connected to before — which is not what the screen was being cleared for, and is
|
||||||
|
the one screen reachable by closing your last tab, so the nav stays. See PhoneShell.RefreshChrome.
|
||||||
|
|
||||||
That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this
|
That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this
|
||||||
screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every
|
screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every
|
||||||
one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap
|
one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap
|
||||||
@@ -29,6 +33,85 @@
|
|||||||
gesture does the same thing the arrow does.
|
gesture does the same thing the arrow does.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
<UserControl.Styles>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── the connecting step list ─────────────────────────────────────────────────────────────────────
|
||||||
|
The same five rows the desktop's ConnectingCard draws, from the same reported phases, in this head's
|
||||||
|
own sizes. Kept here rather than in Phone.axaml because nothing else on this head has a step list —
|
||||||
|
the theme file is for what more than one screen shares, and a rule that exists for one control is
|
||||||
|
easier to read beside it.
|
||||||
|
|
||||||
|
Amber for the step in flight, green behind it, red where it stopped. That is the palette's rule
|
||||||
|
rather than an exception to it: green is what is true and purple is what you can press, and a step
|
||||||
|
still happening is neither. See ConnectingCard.axaml for the longer version of this argument, and
|
||||||
|
Palette.axaml for the rule itself.
|
||||||
|
|
||||||
|
A phone needs this more than a desktop does, which is the same thing the connecting block below
|
||||||
|
already says about itself: mobile links are slower and drop more often, so the stretch this describes
|
||||||
|
is longer here and more likely to end badly.
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
Its own FontFamily rather than the row also carrying the mono class, which is this head's convention
|
||||||
|
and not a stylistic preference: Phone.axaml's mono sets a colour and a size along with the family, so
|
||||||
|
a caption wearing both classes would be asking two rules for one Foreground and settling it on style
|
||||||
|
ordering. Every other text class here — body, label, title, detail — names its own family for exactly
|
||||||
|
that reason. The desktop's mono sets the family alone, which is why ConnectingCard composes the two
|
||||||
|
and this does not.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBlock.stepcaption">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepcaption.done">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepcaption.running">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepcaption.stopped">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Fixed width and centred: four different characters on a ragged edge is a list that looks broken. -->
|
||||||
|
<Style Selector="TextBlock.stepmark">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="Width" Value="13" />
|
||||||
|
<Setter Property="TextAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepmark.done">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepmark.running">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepmark.stopped">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
4 rather than the desktop's 5, which is the only deliberate difference between the two heads here:
|
||||||
|
this bar sits in a column 24 from each edge of a 360dp screen rather than under a 460-wide card, so
|
||||||
|
the same height reads as a heavier rule across a narrower span.
|
||||||
|
-->
|
||||||
|
<Style Selector="ProgressBar.steptrack">
|
||||||
|
<Setter Property="Height" Value="4" />
|
||||||
|
<Setter Property="MinHeight" Value="4" />
|
||||||
|
<Setter Property="CornerRadius" Value="2" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ProgressBar.steptrack.stopped">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
@@ -45,7 +128,10 @@
|
|||||||
to 34, the pills to 30 — because a bar that shrank around controls that did not would only have moved
|
to 34, the pills to 30 — because a bar that shrank around controls that did not would only have moved
|
||||||
the clipping somewhere harder to see.
|
the clipping somewhere harder to see.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="0" Height="35" Background="{StaticResource Chrome}"
|
<!-- DeepChrome rather than Chrome, since v5: this bar is what the vault header, the strip and the
|
||||||
|
bottom bar collapse into while a shell is showing, so it takes their surface along with their
|
||||||
|
job — see the remark on the header in PhoneShell.axaml. -->
|
||||||
|
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
@@ -83,15 +169,19 @@
|
|||||||
row where nothing sits above or below it to be hit by mistake.
|
row where nothing sits above or below it to be hit by mistake.
|
||||||
-->
|
-->
|
||||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||||
BorderThickness="1" CornerRadius="9" Height="30">
|
BorderThickness="1" CornerRadius="10" Height="30">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="9"
|
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="10"
|
||||||
VerticalContentAlignment="Center"
|
VerticalContentAlignment="Center"
|
||||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||||
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
|
<!--
|
||||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
Green only while there is a shell behind it, amber while one is being made; see
|
||||||
|
the same dot in PhoneShell, and Phone.axaml for why amber is not a rule broken.
|
||||||
|
-->
|
||||||
|
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||||
|
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||||
Text="{Binding Label}" />
|
Text="{Binding Label}" />
|
||||||
@@ -112,7 +202,7 @@
|
|||||||
It reads as a misprint, which for the control that ends a session is the wrong thing
|
It reads as a misprint, which for the control that ends a session is the wrong thing
|
||||||
to look like.
|
to look like.
|
||||||
-->
|
-->
|
||||||
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,9,9,0"
|
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,10,10,0"
|
||||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
|
||||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||||
@@ -277,11 +367,70 @@
|
|||||||
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
||||||
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||||
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
||||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
|
|
||||||
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
|
<!--
|
||||||
|
Where a single unchanging "connecting…" used to be. The track counts steps that really finished
|
||||||
|
against the five there are — StepsDone over StepCount, never a percentage, because the arithmetic
|
||||||
|
that makes a percentage is the arithmetic that starts inventing one. See TerminalTabViewModel.
|
||||||
|
|
||||||
|
Drawn for both states rather than once per state: a refused connection has the same five rows and
|
||||||
|
the same track, and the only differences are that one row is red and the track stops where it got
|
||||||
|
to. Two templates kept identical for the sake of a colour is how the two drift apart.
|
||||||
|
-->
|
||||||
|
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||||
|
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||||
|
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Spacing="6" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="9">
|
||||||
|
<TextBlock Classes="stepmark"
|
||||||
|
Classes.done="{Binding IsDone}"
|
||||||
|
Classes.running="{Binding IsRunning}"
|
||||||
|
Classes.stopped="{Binding IsStopped}"
|
||||||
|
Text="{Binding Mark}" />
|
||||||
|
<TextBlock Classes="stepcaption"
|
||||||
|
Classes.done="{Binding IsDone}"
|
||||||
|
Classes.running="{Binding IsRunning}"
|
||||||
|
Classes.stopped="{Binding IsStopped}"
|
||||||
|
Text="{Binding Caption}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Only for a refusal now. While a connection is being made this used to be the whole of what this
|
||||||
|
screen said, and it is now the step list's running row said twice — so it is shown for the one
|
||||||
|
state the list cannot put into words: why it stopped.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}"
|
||||||
|
Foreground="{StaticResource Danger}"
|
||||||
|
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Two 44-high targets side by side rather than one, and the second is the logs: the step list is
|
||||||
|
this attempt and the log is every other one, which is the question a connection that is taking too
|
||||||
|
long on a mobile link actually raises — has this machine ever worked from here. Reached the
|
||||||
|
ordinary way, through ShowScreenCommand, exactly as the rail and MORE reach it.
|
||||||
|
-->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Left">
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||||
|
Command="{Binding ShowScreenCommand}"
|
||||||
|
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||||
|
<TextBlock Classes="label" FontSize="9" Text="SHOW LOGS" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -358,7 +507,7 @@
|
|||||||
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
|
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
|
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
|
||||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||||
BorderThickness="1" Focusable="False"
|
BorderThickness="1" Focusable="False"
|
||||||
@@ -367,7 +516,7 @@
|
|||||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
|
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
|
||||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||||
BorderThickness="1" Focusable="False"
|
BorderThickness="1" Focusable="False"
|
||||||
@@ -389,9 +538,10 @@
|
|||||||
of them.
|
of them.
|
||||||
|
|
||||||
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
|
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
|
||||||
but this surface raises it. The scrim reaching only the screen area is not a compromise here the way
|
but this surface raises it. The scrim reaching only the screen area is the full display while a shell
|
||||||
it was there — the bottom bar is collapsed while a terminal is showing, so the screen area is the
|
is showing, because the bottom bar is collapsed for it. Raised from the connect page it is not, and
|
||||||
display.
|
that lands on the add sheet's own answer: the three destinations under the scrim stay tappable, and for
|
||||||
|
a menu of places to connect from, navigating away is a perfectly good way to decide not to.
|
||||||
|
|
||||||
Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu:
|
Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu:
|
||||||
each of the three destinations is a picker, and the shell they open lands back on this surface as a
|
each of the three destinations is a picker, and the shell they open lands back on this surface as a
|
||||||
|
|||||||
@@ -139,9 +139,37 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
{
|
{
|
||||||
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
||||||
|
|
||||||
|
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
|
||||||
|
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
|
||||||
|
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
|
||||||
|
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
|
||||||
|
// length of the press; and the Return is posted past the current dispatch, so its ordering against
|
||||||
|
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
|
||||||
|
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||||
|
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||||
|
|
||||||
foreach (var (label, bytes, latches) in Keys)
|
foreach (var (label, bytes, latches) in Keys)
|
||||||
{
|
{
|
||||||
var key = new Button
|
var key = CreateKey(label);
|
||||||
|
|
||||||
|
if (latches)
|
||||||
|
{
|
||||||
|
controlKey = key;
|
||||||
|
key.Click += (_, _) => ToggleControl();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
key.Click += (_, _) => SendAsync(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.Children.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One key of the accessory row, before its click is wired.</summary>
|
||||||
|
/// <remarks>Split from <see cref="BuildAccessoryRow"/> for length rather than for reuse.</remarks>
|
||||||
|
private static Button CreateKey(string label) =>
|
||||||
|
new()
|
||||||
{
|
{
|
||||||
Content = new TextBlock
|
Content = new TextBlock
|
||||||
{
|
{
|
||||||
@@ -179,23 +207,16 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
//
|
//
|
||||||
// Focusable=false is what a toolbar button is, and it means the focused element never
|
// 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.
|
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
||||||
|
//
|
||||||
|
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
|
||||||
|
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
|
||||||
|
// decides about its element — taking the keyboard's input connection off the terminal and
|
||||||
|
// swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
|
||||||
|
// that half back; see TerminalFocus for the whole story, including why the handing back has
|
||||||
|
// to be posted rather than done inline.
|
||||||
Focusable = false,
|
Focusable = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (latches)
|
|
||||||
{
|
|
||||||
controlKey = key;
|
|
||||||
key.Click += (_, _) => ToggleControl();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
key.Click += (_, _) => SendAsync(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.Children.Add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleControl()
|
private void ToggleControl()
|
||||||
{
|
{
|
||||||
controlLatched = !controlLatched;
|
controlLatched = !controlLatched;
|
||||||
|
|||||||
@@ -336,9 +336,11 @@
|
|||||||
the thing the whole screen is about — a member can only be selected under one, so choosing who is the
|
the thing the whole screen is about — a member can only be selected under one, so choosing who is the
|
||||||
only half left to make.
|
only half left to make.
|
||||||
-->
|
-->
|
||||||
|
<!-- DeepChrome rather than Chrome, since v5 — the same raised-bar decision SnippetsScreen and
|
||||||
|
FilesScreen make for their own selection bars; see the remark on PhoneShell's vault header. -->
|
||||||
<Border Grid.Row="4"
|
<Border Grid.Row="4"
|
||||||
IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}"
|
IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||||
BorderThickness="0,1,0,0" Padding="14,12">
|
BorderThickness="0,1,0,0" Padding="14,12">
|
||||||
<StackPanel Spacing="9">
|
<StackPanel Spacing="9">
|
||||||
|
|
||||||
|
|||||||
@@ -74,9 +74,9 @@
|
|||||||
},
|
},
|
||||||
"Microsoft.NET.ILLink.Tasks": {
|
"Microsoft.NET.ILLink.Tasks": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[10.0.10, )",
|
"requested": "[10.0.11, )",
|
||||||
"resolved": "10.0.10",
|
"resolved": "10.0.11",
|
||||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||||
},
|
},
|
||||||
"MinVer": {
|
"MinVer": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
|
|||||||
+650
-174
File diff suppressed because it is too large
Load Diff
@@ -92,7 +92,10 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
|
|
||||||
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
var paths = ClientPaths.Default;
|
// ForChannel rather than Default, so a nightly keeps its cache, outbox and device key somewhere
|
||||||
|
// the release build never opens. See ClientPaths.ForChannel for why sharing them is the failure
|
||||||
|
// worth spending a directory on.
|
||||||
|
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||||
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
||||||
|
|
||||||
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
|
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
# Regenerates dodossh.ico from the same geometry the Android launcher icon draws.
|
# Regenerates dodossh.ico and dodossh.icns from the same geometry the Android launcher icon draws.
|
||||||
#
|
#
|
||||||
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
|
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
|
||||||
# reason it is a vector is that there is then one geometry to change and no set of PNG densities
|
# reason it is a vector is that there is then one geometry to change and no set of PNG densities
|
||||||
# to forget one of. Windows will not take a vector: <ApplicationIcon> wants an .ico and nothing
|
# to forget one of. Neither desktop platform will take a vector: <ApplicationIcon> wants an .ico
|
||||||
# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays
|
# and nothing else, Window.Icon wants a bitmap, and vpk wants an .icns for the macOS bundle. So
|
||||||
# honest: the numbers below are the ones in that XML, and regenerating is the whole edit.
|
# the rasters exist, and this script is how they stay honest: the numbers below are the ones in
|
||||||
|
# that XML, and regenerating is the whole edit.
|
||||||
#
|
#
|
||||||
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
|
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
|
||||||
#
|
#
|
||||||
|
# Both outputs are written every run, deliberately. Two scripts, or one script with a switch,
|
||||||
|
# is how the two files come to be drawn from different geometry — which nobody would notice,
|
||||||
|
# because no one person looks at a Windows taskbar and a macOS Dock on the same afternoon.
|
||||||
|
#
|
||||||
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
|
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
|
||||||
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
|
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
|
||||||
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
|
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
|
||||||
@@ -22,37 +27,57 @@ Set-StrictMode -Version Latest
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Add-Type -AssemblyName System.Drawing
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5B8CFF') # dodo_accent / AccentColor
|
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5D42DE') # dodo_accent / AccentColor
|
||||||
$ink = [System.Drawing.ColorTranslator]::FromHtml('#0E1220') # AccentInk
|
$ink = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') # AccentInk
|
||||||
|
|
||||||
# Every size Windows asks for: 16 in a titlebar and a tree, 32 on the desktop, 48 in a large-icon
|
# Every size Windows asks for: 16 in a titlebar and a tree, 32 on the desktop, 48 in a large-icon
|
||||||
# view, 256 for the preview pane. Shipping fewer means Windows downsamples one of these to fill
|
# view, 256 for the preview pane. Shipping fewer means Windows downsamples one of these to fill
|
||||||
# the gap, and its downsampler is not kind to a hairline.
|
# the gap, and its downsampler is not kind to a hairline.
|
||||||
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
|
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||||
|
|
||||||
function New-MarkPng([int]$size)
|
# $tileFraction is how much of the canvas the accent tile fills, and it is the one number that
|
||||||
|
# differs between the two platforms.
|
||||||
|
#
|
||||||
|
# Windows passes 1.0: the tile bleeds to the edge, because Windows draws application icons at
|
||||||
|
# whatever size they come in and every other icon on the taskbar does the same.
|
||||||
|
#
|
||||||
|
# macOS passes 0.8047, and that is not taste. Apple's icon grid puts a rounded-rect app icon in
|
||||||
|
# an 824-pixel square inside a 1024-pixel canvas — 824/1024 — with the remaining hundred pixels a
|
||||||
|
# side left as air for the Dock's shadow and its magnification. An icon that ignores the grid and
|
||||||
|
# bleeds to the edge does not read as bold; it reads as the one icon in the Dock that is too big,
|
||||||
|
# because it sits beside Finder and Safari which do not.
|
||||||
|
function New-MarkPng([int]$size, [double]$tileFraction = 1.0)
|
||||||
{
|
{
|
||||||
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||||
$g = [System.Drawing.Graphics]::FromImage($bitmap)
|
$g = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||||
|
|
||||||
|
# The tile, and the inset that centres it when it does not fill the canvas.
|
||||||
|
$tile = [double]$size * $tileFraction
|
||||||
|
$inset = ([double]$size - $tile) / 2.0
|
||||||
|
|
||||||
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
|
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
|
||||||
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
|
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
|
||||||
$radius = [double]$size * 0.22
|
#
|
||||||
|
# 0.22 of the tile rather than of the canvas, so the corner keeps its proportion to the shape
|
||||||
|
# it is rounding instead of growing as the air around it does. It is also within a whisker of
|
||||||
|
# the 185/824 Apple's own grid specifies, which is why one radius serves both files.
|
||||||
|
$radius = $tile * 0.22
|
||||||
$d = $radius * 2.0
|
$d = $radius * 2.0
|
||||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||||
$path.AddArc(0.0, 0.0, $d, $d, 180, 90)
|
$path.AddArc($inset, $inset, $d, $d, 180, 90)
|
||||||
$path.AddArc($size - $d, 0.0, $d, $d, 270, 90)
|
$path.AddArc($inset + $tile - $d, $inset, $d, $d, 270, 90)
|
||||||
$path.AddArc($size - $d, $size - $d, $d, $d, 0, 90)
|
$path.AddArc($inset + $tile - $d, $inset + $tile - $d, $d, $d, 0, 90)
|
||||||
$path.AddArc(0.0, $size - $d, $d, $d, 90, 90)
|
$path.AddArc($inset, $inset + $tile - $d, $d, $d, 90, 90)
|
||||||
$path.CloseFigure()
|
$path.CloseFigure()
|
||||||
$brush = New-Object System.Drawing.SolidBrush($accent)
|
$brush = New-Object System.Drawing.SolidBrush($accent)
|
||||||
$g.FillPath($brush, $path)
|
$g.FillPath($brush, $path)
|
||||||
|
|
||||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge.
|
# 108-viewport units to pixels, with the outer 18 dropped on each edge. Scaled to the tile and
|
||||||
$scale = [double]$size / 72.0
|
# offset by the inset, so the glyph keeps its place within the tile at either fraction.
|
||||||
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) }
|
$scale = $tile / 72.0
|
||||||
|
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale + $inset), (($y - 18.0) * $scale + $inset)) }
|
||||||
|
|
||||||
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
|
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
|
||||||
# difference between a mark and a smudge. The phone's file already bumps this width for the
|
# difference between a mark and a smudge. The phone's file already bumps this width for the
|
||||||
@@ -117,3 +142,86 @@ $target = Join-Path $PSScriptRoot 'dodossh.ico'
|
|||||||
$w.Dispose(); $out.Dispose()
|
$w.Dispose(); $out.Dispose()
|
||||||
|
|
||||||
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
|
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
|
||||||
|
|
||||||
|
# ---- dodossh.icns, for the macOS bundle ----------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Written here rather than by `iconutil` on a Mac, and that is the point of doing it the long way.
|
||||||
|
# iconutil is the documented tool and it exists only on macOS, so an icon that needed it could not
|
||||||
|
# be regenerated on the machine this project is developed on — the geometry above would change and
|
||||||
|
# the .icns would quietly keep the old mark until somebody next opened a Mac. The container format
|
||||||
|
# is a magic word, a length and a run of typed PNG chunks, which is little enough to own.
|
||||||
|
#
|
||||||
|
# ◆ EVERY LENGTH IN THIS FILE IS BIG-ENDIAN, AND BinaryWriter IS NOT.
|
||||||
|
#
|
||||||
|
# The one thing that will catch anybody editing this. A .icns written little-endian is not rejected
|
||||||
|
# with an error — Finder and vpk both just show the placeholder icon, because the first chunk claims
|
||||||
|
# a length of about two billion and the parser walks off the end and gives up. Hence Write-BE32.
|
||||||
|
#
|
||||||
|
# Type codes are Apple's, and the pairs are not redundant. ic08 and ic13 are both 256 pixels because
|
||||||
|
# one is "256 at 1x" and the other is "128 at 2x", and a Retina display asked for the second will not
|
||||||
|
# accept the first. Same for ic09/ic14 at 512. iconutil emits both from an .iconset for this reason,
|
||||||
|
# so this does too.
|
||||||
|
$icnsTypes = @(
|
||||||
|
@{ Type = 'ic11'; Size = 32 } # 16@2x
|
||||||
|
@{ Type = 'ic12'; Size = 64 } # 32@2x
|
||||||
|
@{ Type = 'ic07'; Size = 128 } # 128@1x
|
||||||
|
@{ Type = 'ic13'; Size = 256 } # 128@2x
|
||||||
|
@{ Type = 'ic08'; Size = 256 } # 256@1x
|
||||||
|
@{ Type = 'ic14'; Size = 512 } # 256@2x
|
||||||
|
@{ Type = 'ic09'; Size = 512 } # 512@1x
|
||||||
|
@{ Type = 'ic10'; Size = 1024 } # 512@2x
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apple's icon grid: an 824-pixel shape centred in a 1024-pixel canvas. See New-MarkPng.
|
||||||
|
$macTileFraction = 824.0 / 1024.0
|
||||||
|
|
||||||
|
# Rendered once per distinct pixel size rather than once per type code, so the two 256s and the two
|
||||||
|
# 512s are byte-identical and the file does not carry the same image twice over at different
|
||||||
|
# compression. It also halves the drawing, which at 1024 is not nothing.
|
||||||
|
$rendered = @{}
|
||||||
|
foreach ($size in ($icnsTypes.Size | Sort-Object -Unique))
|
||||||
|
{
|
||||||
|
[byte[]]$png = New-MarkPng $size $macTileFraction
|
||||||
|
$rendered[$size] = $png
|
||||||
|
}
|
||||||
|
|
||||||
|
$icns = New-Object System.IO.MemoryStream
|
||||||
|
|
||||||
|
function Write-BE32([System.IO.Stream]$stream, [uint32]$value)
|
||||||
|
{
|
||||||
|
$bytes = [System.BitConverter]::GetBytes($value)
|
||||||
|
if ([System.BitConverter]::IsLittleEndian) { [array]::Reverse($bytes) }
|
||||||
|
$stream.Write($bytes, 0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ascii([System.IO.Stream]$stream, [string]$text)
|
||||||
|
{
|
||||||
|
$bytes = [System.Text.Encoding]::ASCII.GetBytes($text)
|
||||||
|
$stream.Write($bytes, 0, $bytes.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
# The header's length field covers the whole file including the header, so it is written last —
|
||||||
|
# eight bytes of nothing now, seeked back to and filled in once the total is known.
|
||||||
|
Write-Ascii $icns 'icns'
|
||||||
|
Write-BE32 $icns 0
|
||||||
|
|
||||||
|
foreach ($entry in $icnsTypes)
|
||||||
|
{
|
||||||
|
$payload = $rendered[$entry.Size]
|
||||||
|
Write-Ascii $icns $entry.Type
|
||||||
|
|
||||||
|
# Length includes this chunk's own eight-byte header, which is the off-by-eight everybody
|
||||||
|
# writes once.
|
||||||
|
Write-BE32 $icns ([uint32]($payload.Length + 8))
|
||||||
|
$icns.Write($payload, 0, $payload.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = [uint32]$icns.Length
|
||||||
|
$icns.Position = 4
|
||||||
|
Write-BE32 $icns $total
|
||||||
|
|
||||||
|
$icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
|
||||||
|
[System.IO.File]::WriteAllBytes($icnsTarget, $icns.ToArray())
|
||||||
|
$icns.Dispose()
|
||||||
|
|
||||||
|
Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.6 KiB |
@@ -42,6 +42,45 @@
|
|||||||
-->
|
-->
|
||||||
<ApplicationIcon>Assets/dodossh.ico</ApplicationIcon>
|
<ApplicationIcon>Assets/dodossh.ico</ApplicationIcon>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============ WHICH CHANNEL THIS BUILD BELONGS TO ============
|
||||||
|
|
||||||
|
The same split the Android head has, for the same reason and with one difference worth stating up
|
||||||
|
front: Android gets separation for free, because the platform refuses an update signed by a
|
||||||
|
different key, so its two channels cannot replace one another whatever anybody does. Nothing
|
||||||
|
refuses anything here. Velopack applies what its feed serves, so the separation has to be built:
|
||||||
|
two pack ids, two Velopack channels, two feeds, and a profile directory each.
|
||||||
|
|
||||||
|
release — DodoSSH.Desktop, Velopack channel win, read from the newest non-prerelease release.
|
||||||
|
Cut from a v* tag by scripts/release-windows.ps1, by a person. See ADR 0013 rule 3.
|
||||||
|
nightly — DodoSSH.Desktop.Nightly, Velopack channel win-nightly, read from a prerelease release
|
||||||
|
CI replaces on every push to main.
|
||||||
|
|
||||||
|
Default release, so an unqualified `dotnet build` is the real application and the nightly is the
|
||||||
|
one you have to ask for.
|
||||||
|
|
||||||
|
What this property does *not* decide is the pack id or the title. Those are arguments to vpk and
|
||||||
|
live where the packaging happens — in ci.yml for the nightly and in the release script for the
|
||||||
|
release. Putting them here would suggest the build knows which package it will end up inside,
|
||||||
|
and it does not.
|
||||||
|
-->
|
||||||
|
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
For the macOS keychain interop in Platform/, and for nothing else.
|
||||||
|
|
||||||
|
Set on this project rather than in Directory.Build.props deliberately. The frameworks that hold a
|
||||||
|
Secure Enclave key take CFDictionaries of raw pointers, so building one means pinning arrays and
|
||||||
|
taking their addresses — see MacDeviceKeyStore. Every other project here is managed code with no
|
||||||
|
business doing that, and a solution-wide flag would quietly permit it everywhere, including in the
|
||||||
|
crypto project where a stray pointer is the last thing anybody wants to have been allowed.
|
||||||
|
|
||||||
|
The alternative — GCHandle.Alloc with GCHandleType.Pinned — needs no flag and was considered. It
|
||||||
|
would replace each `fixed` with an allocate/free pair that has to be balanced by hand across the
|
||||||
|
early returns those methods are full of, which trades a compiler-checked scope for a manual one.
|
||||||
|
-->
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
False here, unlike every server project. The root Directory.Build.props sets it true because
|
False here, unlike every server project. The root Directory.Build.props sets it true because
|
||||||
the API is container-hosted, UTC-only and has no business formatting anything for a human.
|
the API is container-hosted, UTC-only and has no business formatting anything for a human.
|
||||||
@@ -60,6 +99,16 @@
|
|||||||
<AvaloniaResource Include="Assets/dodossh.ico" />
|
<AvaloniaResource Include="Assets/dodossh.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!--
|
||||||
|
Which channel this build belongs to, carried in the assembly the same way the Android head carries
|
||||||
|
it. Metadata rather than a compile-time constant for the reason stated there: the updater needs the
|
||||||
|
string rather than a branch, and a value baked into the assembly is one a crash report can be asked
|
||||||
|
for. See DesktopChannel, which is the only thing that reads it.
|
||||||
|
-->
|
||||||
|
<AssemblyMetadata Include="DodoChannel" Value="$(DodoChannel)" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Two native symbol files, and they are the reason a self-contained publish weighed 227 MB.
|
Two native symbol files, and they are the reason a self-contained publish weighed 227 MB.
|
||||||
|
|
||||||
|
|||||||
@@ -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,598 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using static DodoSSH.Client.App.Platform.MacSecurity;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps the device key encrypted to a Secure Enclave key whose use requires the user's presence.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The macOS counterpart of <see cref="WindowsDeviceKeyStore"/>, and the same argument holds it up:
|
||||||
|
/// <b>the consent is enforced by the platform, not by this class</b>. The unwrapping key is generated
|
||||||
|
/// inside the Secure Enclave and never leaves it — there is no code path, privileged or otherwise, that
|
||||||
|
/// turns it into bytes — and it is created under an access control requiring
|
||||||
|
/// <see cref="AccessControlFlags.UserPresence"/>, so Touch ID or the login password is a condition of
|
||||||
|
/// <em>using</em> it. Malware running as the user can ask for a decryption; it cannot answer the prompt,
|
||||||
|
/// and the attempt is visible.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A store that showed its own prompt and then read a protected file would be trivially bypassed, which
|
||||||
|
/// is the mistake ADR 0007 originally described and the Windows store's comment corrects. The correction
|
||||||
|
/// applies here unchanged.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>P-256 and ECIES, where Windows uses RSA-OAEP, and the difference is not a preference.</b> The
|
||||||
|
/// Secure Enclave holds exactly one kind of key: a 256-bit key on the NIST P-256 curve. It will not hold
|
||||||
|
/// an RSA key at any size. So the wrap is <c>eciesEncryptionCofactorX963SHA256AESGCM</c> — an ephemeral
|
||||||
|
/// agreement against the enclave's public half, X9.63-KDF to an AES-GCM key, and the ephemeral public
|
||||||
|
/// key carried in the output. The framework does all of that; what matters here is that the input is 32
|
||||||
|
/// bytes and there is no size limit worth worrying about.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Sealing is silent and unsealing prompts, which is better than the Windows shape rather than merely
|
||||||
|
/// different.</b> On Windows, <c>CngKey.Create</c> with <c>ProtectKey</c> raises a dialog at creation as
|
||||||
|
/// well, because the policy means "protect this key with a PIN" and Windows sets that up there and then.
|
||||||
|
/// Here <see cref="SecKeyCopyPublicKey"/> works on an enclave key without any prompt, so registering a
|
||||||
|
/// device shows nothing and only unlock asks. <see cref="SaveAsync"/> is therefore not user-facing on
|
||||||
|
/// this platform — but it is still called from where the Windows one has to be, and relying on that
|
||||||
|
/// difference would make the shared caller platform-specific for no gain.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What this cannot be tested against, and what follows from that.</b> Every method except
|
||||||
|
/// <see cref="IsAvailableAsync"/> and the empty case of <see cref="TryLoadAsync"/> needs an interactive
|
||||||
|
/// login session and real enclave hardware, so none can be exercised by an automated test — the same
|
||||||
|
/// line the Windows store draws. It also means <see cref="IsSupported"/> must probe rather than infer:
|
||||||
|
/// see its remarks for the three ordinary machines that have no usable enclave and must degrade to the
|
||||||
|
/// passphrase rather than fail at unlock.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
|
public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The keychain tag this application's enclave key is filed under.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Versioned for the reason the Windows key name is: a future change of curve or wrap algorithm can
|
||||||
|
/// create a new key beside the old one rather than failing to open blobs written by a previous
|
||||||
|
/// build. A device that cannot be opened falls back to the passphrase, which is survivable — but
|
||||||
|
/// silently, and a user would only notice their fingerprint had stopped working.
|
||||||
|
///
|
||||||
|
/// Prefixed with the bundle identifier because the keychain is shared across every application the
|
||||||
|
/// user runs, unlike a CNG key name, which is scoped to the user's key store already.
|
||||||
|
/// </remarks>
|
||||||
|
private const string KeyTag = "dev.dodotech.dodossh.devicekey.v1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shown in the Touch ID prompt, so it has to read as a sentence to a person.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// macOS composes it into "DodoSSH is trying to ...", so this is a verb phrase and not a sentence of
|
||||||
|
/// its own. The same words the Windows consent dialog uses.
|
||||||
|
/// </remarks>
|
||||||
|
private const string ConsentPrompt = "unlock your DodoSSH vault";
|
||||||
|
|
||||||
|
private readonly ClientPaths paths;
|
||||||
|
|
||||||
|
/// <summary>Creates the store.</summary>
|
||||||
|
public MacDeviceKeyStore(ClientPaths paths)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
this.paths = paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this Mac has a Secure Enclave that will hold a key for this build.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Probed by creating a throwaway key and deleting it, rather than by asking whether the hardware
|
||||||
|
/// exists. Three ordinary situations answer "no" here and would otherwise only be discovered at the
|
||||||
|
/// moment somebody tried to unlock:
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>An Intel Mac with no T2.</b> Apple Silicon and T2 machines have an enclave; earlier Intel
|
||||||
|
/// models do not, and there is no single attribute that says so.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A build that is not code signed.</b> Enclave key creation requires a signing identity, so
|
||||||
|
/// every <c>dotnet run</c> and every build from an IDE fails here with a missing-entitlement error.
|
||||||
|
/// That is the correct answer rather than a nuisance: a development build should keep asking for the
|
||||||
|
/// passphrase, and this is what makes it do so without a platform check somewhere else.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A machine with no login password set.</b> <see cref="AccessControlFlags.UserPresence"/> has
|
||||||
|
/// nothing to demand, and the framework refuses the access control object rather than silently
|
||||||
|
/// creating a key anybody could use.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The probe uses its own tag and no UI policy, so nothing prompts and nothing collides with the
|
||||||
|
/// real key. It is deleted immediately; a probe key left behind would accumulate one per launch.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static bool IsSupported()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var probe = $"{KeyTag}.probe.{Guid.CreateVersion7():N}";
|
||||||
|
|
||||||
|
using var scope = new CoreFoundationScope();
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
if (!symbols.Complete)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = CreateEnclaveKey(scope, symbols, probe);
|
||||||
|
|
||||||
|
if (key == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discarded deliberately. The question this method answers is whether the enclave will make a
|
||||||
|
// key, and it demonstrably just did; a failure to clean the probe up afterwards leaves one
|
||||||
|
// stray keychain item and does not make the answer no.
|
||||||
|
_ = DeleteKey(symbols, probe);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is DllNotFoundException
|
||||||
|
or EntryPointNotFoundException
|
||||||
|
or BadImageFormatException)
|
||||||
|
{
|
||||||
|
// A macOS without these frameworks is not a thing that exists, so this is really the guard
|
||||||
|
// for the case that does: a future release renaming or removing one of them. The answer is
|
||||||
|
// the same as for hardware that is absent — no device key, ask for the passphrase.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||||
|
ValueTask.FromResult(IsSupported());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask SaveAsync(
|
||||||
|
ReadOnlyMemory<byte> devicePrivateKey,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sealedKey = Seal(devicePrivateKey.Span)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
|
||||||
|
|
||||||
|
paths.EnsureCreated();
|
||||||
|
|
||||||
|
await File.WriteAllBytesAsync(paths.DeviceKeyFile, sealedKey, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!File.Exists(paths.DeviceKeyFile))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Unseal(sealedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (File.Exists(paths.DeviceKeyFile))
|
||||||
|
{
|
||||||
|
File.Delete(paths.DeviceKeyFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
if (symbols.Complete)
|
||||||
|
{
|
||||||
|
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
|
||||||
|
// and the file deleted above is the half that decides whether unlock will try at all. A key
|
||||||
|
// left in the enclave with no ciphertext to open is inert.
|
||||||
|
_ = DeleteKey(symbols, KeyTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
|
||||||
|
/// them is the same — do not offer a device unlock.
|
||||||
|
/// </remarks>
|
||||||
|
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = new CoreFoundationScope();
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
if (!symbols.Complete)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created on first use rather than at registration, so that a device key re-registered after
|
||||||
|
// a ForgetAsync gets a key again without anything having to notice that it had gone.
|
||||||
|
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);
|
||||||
|
|
||||||
|
if (privateKey == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (privateKey == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var publicKey = scope.Keep(SecKeyCopyPublicKey(privateKey));
|
||||||
|
|
||||||
|
if (publicKey == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var plaintext = Data(scope, devicePrivateKey);
|
||||||
|
|
||||||
|
if (plaintext == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ciphertext = scope.Keep(
|
||||||
|
SecKeyCreateEncryptedData(publicKey, symbols.EciesAlgorithm, plaintext, out var error));
|
||||||
|
|
||||||
|
scope.Keep(error);
|
||||||
|
|
||||||
|
return ciphertext == IntPtr.Zero ? null : ToArray(ciphertext);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is DllNotFoundException
|
||||||
|
or EntryPointNotFoundException
|
||||||
|
or BadImageFormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the call that prompts. Every failure becomes null, and the set is wider than it looks:
|
||||||
|
/// the key may be gone, the user may have cancelled or let the prompt time out, the enclave may have
|
||||||
|
/// invalidated it after the login password was reset, or the blob may predate a key that has since
|
||||||
|
/// been replaced. None of them are distinguishable to a user and all have the same remedy, so none
|
||||||
|
/// are worth telling apart here — see <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Blocking, and it blocks on a person. The prompt is modal to the application, so this must not run
|
||||||
|
/// on a thread that is also expected to draw the window behind it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static byte[]? Unseal(byte[] sealedKey)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = new CoreFoundationScope();
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
if (!symbols.Complete)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
|
||||||
|
|
||||||
|
if (privateKey == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ciphertext = Data(scope, sealedKey);
|
||||||
|
|
||||||
|
if (ciphertext == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var plaintext = scope.Keep(
|
||||||
|
SecKeyCreateDecryptedData(privateKey, symbols.EciesAlgorithm, ciphertext, out var error));
|
||||||
|
|
||||||
|
scope.Keep(error);
|
||||||
|
|
||||||
|
return plaintext == IntPtr.Zero ? null : ToArray(plaintext);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is DllNotFoundException
|
||||||
|
or EntryPointNotFoundException
|
||||||
|
or BadImageFormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a key inside the Secure Enclave, filed under <paramref name="tag"/>. Owned by the scope.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The attribute dictionary is the whole security decision, so it is worth reading rather than
|
||||||
|
/// pattern-matching. <c>TokenID = SecureEnclave</c> is what puts the private half in hardware;
|
||||||
|
/// without it this silently generates an ordinary software key that behaves identically in every
|
||||||
|
/// visible way and protects nothing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>AccessibleWhenUnlockedThisDeviceOnly</c> rather than any of the migratable classes, because a
|
||||||
|
/// device key that could be restored onto another machine from a backup would no longer mean "this
|
||||||
|
/// machine". The enclave already makes that impossible; saying it as well means the intent survives
|
||||||
|
/// a future change of storage.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>UseDataProtectionKeychain</c> is the macOS-specific one and the easiest to omit. Without it,
|
||||||
|
/// macOS routes this to the older file-based keychain, which does not understand access control
|
||||||
|
/// objects or the enclave, and the call fails with a parameter error that says nothing about the
|
||||||
|
/// missing key.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr CreateEnclaveKey(CoreFoundationScope scope, MacSymbols symbols, string tag)
|
||||||
|
{
|
||||||
|
var access = scope.Keep(SecAccessControlCreateWithFlags(
|
||||||
|
IntPtr.Zero,
|
||||||
|
symbols.AccessibleWhenUnlockedThisDeviceOnly,
|
||||||
|
AccessControlFlags.PrivateKeyUsage | AccessControlFlags.UserPresence,
|
||||||
|
out var accessError));
|
||||||
|
|
||||||
|
scope.Keep(accessError);
|
||||||
|
|
||||||
|
if (access == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var privateAttrs = Dictionary(
|
||||||
|
scope,
|
||||||
|
[symbols.AttrIsPermanent, symbols.AttrApplicationTag, symbols.AttrAccessControl],
|
||||||
|
[symbols.True, TagData(scope, tag), access]);
|
||||||
|
|
||||||
|
if (privateAttrs == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keySize = Number(scope, 256);
|
||||||
|
|
||||||
|
var parameters = Dictionary(
|
||||||
|
scope,
|
||||||
|
[
|
||||||
|
symbols.AttrKeyType,
|
||||||
|
symbols.AttrKeySizeInBits,
|
||||||
|
symbols.AttrTokenId,
|
||||||
|
symbols.UseDataProtectionKeychain,
|
||||||
|
symbols.PrivateKeyAttrs,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
symbols.KeyTypeEcSecPrimeRandom,
|
||||||
|
keySize,
|
||||||
|
symbols.TokenIdSecureEnclave,
|
||||||
|
symbols.True,
|
||||||
|
privateAttrs,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (parameters == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = scope.Keep(SecKeyCreateRandomKey(parameters, out var error));
|
||||||
|
|
||||||
|
scope.Keep(error);
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks the enclave key up by tag. Owned by the scope; zero when there is none.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <paramref name="prompt"/> is attached here and consumed later: the lookup itself does not raise
|
||||||
|
/// anything, because a handle to an enclave key is not a use of it. The words reach the user at the
|
||||||
|
/// decrypt, which is the operation the access control actually guards.
|
||||||
|
///
|
||||||
|
/// <c>UseOperationPrompt</c> is deprecated in favour of an <c>LAContext</c>, and is used anyway. An
|
||||||
|
/// LAContext would mean binding LocalAuthentication as well for one string, and the deprecated key
|
||||||
|
/// still works; the day it stops, this call fails and the store degrades to the passphrase, which is
|
||||||
|
/// the failure this whole class is built to degrade into.
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr FindKey(CoreFoundationScope scope, MacSymbols symbols, string tag, string? prompt)
|
||||||
|
{
|
||||||
|
List<IntPtr> keys =
|
||||||
|
[
|
||||||
|
symbols.Class,
|
||||||
|
symbols.AttrApplicationTag,
|
||||||
|
symbols.AttrKeyType,
|
||||||
|
symbols.UseDataProtectionKeychain,
|
||||||
|
symbols.ReturnRef,
|
||||||
|
];
|
||||||
|
|
||||||
|
List<IntPtr> values =
|
||||||
|
[
|
||||||
|
symbols.ClassKey,
|
||||||
|
TagData(scope, tag),
|
||||||
|
symbols.KeyTypeEcSecPrimeRandom,
|
||||||
|
symbols.True,
|
||||||
|
symbols.True,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (prompt is not null)
|
||||||
|
{
|
||||||
|
keys.Add(symbols.UseOperationPrompt);
|
||||||
|
values.Add(scope.Keep(CFString(prompt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = Dictionary(scope, [.. keys], [.. values]);
|
||||||
|
|
||||||
|
if (query == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = SecItemCopyMatching(query, out var result);
|
||||||
|
|
||||||
|
// errSecItemNotFound is the ordinary answer on a machine that has never registered a device, and
|
||||||
|
// it is not distinguished from any other failure for the reason the class remarks give.
|
||||||
|
return status == Success ? scope.Keep(result) : IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes the key with this tag from the keychain.</summary>
|
||||||
|
/// <returns>Whether the keychain now has no key under this tag.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>ItemNotFound</c> counts as success, and that is the common case rather than an edge: it is
|
||||||
|
/// what a machine that never registered a device answers, and what the second of two
|
||||||
|
/// <see cref="ForgetAsync"/> calls answers. Treating it as a failure would make forgetting a device
|
||||||
|
/// twice report a problem that does not exist.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool DeleteKey(MacSymbols symbols, string tag)
|
||||||
|
{
|
||||||
|
using var scope = new CoreFoundationScope();
|
||||||
|
|
||||||
|
var query = Dictionary(
|
||||||
|
scope,
|
||||||
|
[symbols.Class, symbols.AttrApplicationTag, symbols.UseDataProtectionKeychain],
|
||||||
|
[symbols.ClassKey, TagData(scope, tag), symbols.True]);
|
||||||
|
|
||||||
|
if (query == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = SecItemDelete(query);
|
||||||
|
|
||||||
|
return status is Success or ItemNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Small CoreFoundation conveniences ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The arrays are pinned for the duration of the call and not beyond it, which is correct because
|
||||||
|
/// <c>CFDictionaryCreate</c> copies them: the dictionary retains each key and value, and never reads
|
||||||
|
/// the arrays again.
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr Dictionary(CoreFoundationScope scope, IntPtr[] keys, IntPtr[] values)
|
||||||
|
{
|
||||||
|
// A zero anywhere means one of the constants did not resolve or an earlier allocation failed.
|
||||||
|
// Passing it on produces a dictionary with a null key, which CFDictionaryCreate does not reject
|
||||||
|
// — it crashes inside the callback table instead.
|
||||||
|
if (Array.IndexOf(keys, IntPtr.Zero) >= 0 || Array.IndexOf(values, IntPtr.Zero) >= 0)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (IntPtr* keyPtr = keys)
|
||||||
|
fixed (IntPtr* valuePtr = values)
|
||||||
|
{
|
||||||
|
return scope.Keep(CFDictionaryCreate(
|
||||||
|
IntPtr.Zero,
|
||||||
|
(IntPtr)keyPtr,
|
||||||
|
(IntPtr)valuePtr,
|
||||||
|
keys.Length,
|
||||||
|
symbols.TypeDictionaryKeyCallBacks,
|
||||||
|
symbols.TypeDictionaryValueCallBacks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Copies bytes into a CFData. Owned by the scope.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The pin lasts only as long as the call, which is correct: <c>CFDataCreate</c> copies, so the
|
||||||
|
/// CFData does not reference this memory afterwards. <c>CFDataCreateWithBytesNoCopy</c> would not,
|
||||||
|
/// and is not used for exactly that reason — it would hand the framework a pointer into the managed
|
||||||
|
/// heap and rely on the object staying where the collector first put it.
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr Data(CoreFoundationScope scope, ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* pointer = bytes)
|
||||||
|
{
|
||||||
|
return scope.Keep(CFDataCreate(IntPtr.Zero, (IntPtr)pointer, bytes.Length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// UTF-8 rather than any other encoding, and it only has to be consistent with itself: the tag is an
|
||||||
|
/// opaque blob the keychain matches byte for byte, so what matters is that a lookup encodes it the
|
||||||
|
/// same way the creation did. It is written once, here, for exactly that reason.
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr TagData(CoreFoundationScope scope, string tag) =>
|
||||||
|
Data(scope, Encoding.UTF8.GetBytes(tag));
|
||||||
|
|
||||||
|
private static IntPtr Number(CoreFoundationScope scope, int value)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
return scope.Keep(CFNumberCreate(IntPtr.Zero, (nint)CFNumberIntType, (IntPtr)(&value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a CFString from a managed string. Owned, so the caller tracks it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Built explicitly rather than left to the marshaller, because these calls take a
|
||||||
|
/// <c>CFStringRef</c> and not a C string — the runtime's default marshalling would hand over a
|
||||||
|
/// <c>char*</c>, which CoreFoundation reads as an object pointer and follows into nothing.
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr CFString(string value)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(value);
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* pointer = bytes)
|
||||||
|
{
|
||||||
|
// kCFStringEncodingUTF8 is 0x08000100, spelled out rather than named because it is the
|
||||||
|
// only encoding constant this file uses.
|
||||||
|
return CFStringCreateWithBytes(IntPtr.Zero, (IntPtr)pointer, bytes.Length, 0x08000100, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
private static partial IntPtr CFStringCreateWithBytes(
|
||||||
|
IntPtr allocator,
|
||||||
|
IntPtr bytes,
|
||||||
|
nint numBytes,
|
||||||
|
uint encoding,
|
||||||
|
[MarshalAs(UnmanagedType.U1)] bool isExternalRepresentation);
|
||||||
|
|
||||||
|
private static byte[] ToArray(IntPtr data)
|
||||||
|
{
|
||||||
|
var length = (int)CFDataGetLength(data);
|
||||||
|
var pointer = CFDataGetBytePtr(data);
|
||||||
|
|
||||||
|
if (length <= 0 || pointer == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new byte[length];
|
||||||
|
Marshal.Copy(pointer, result, 0, length);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The pieces of CoreFoundation and Security.framework <see cref="MacDeviceKeyStore"/> needs.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Separated from the store itself because it is a different kind of code with a different kind of
|
||||||
|
/// review: nothing here makes a decision, and everything here is a translation of a C declaration that
|
||||||
|
/// is either right or wrong. Mixing the two would mean the security argument in
|
||||||
|
/// <see cref="MacDeviceKeyStore"/> had to be read past two hundred lines of marshalling to find.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Every Create or Copy returns an object this process owns.</b> That is CoreFoundation's Create
|
||||||
|
/// Rule, and it is the thing here that goes wrong silently: the enclave key handle is small, so a leak
|
||||||
|
/// shows up as nothing at all until a long-running process has done a few thousand unlocks.
|
||||||
|
/// <see cref="CoreFoundationScope"/> exists so ownership is tracked by construction rather than by
|
||||||
|
/// remembering, and every function below that returns a handle says whether it is owned.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The integer widths are the part worth checking against the headers rather than skimming.</b>
|
||||||
|
/// <c>CFIndex</c>, <c>CFOptionFlags</c> and <c>CFNumberType</c> are all pointer-width on a 64-bit Mac,
|
||||||
|
/// not 32-bit, and getting one wrong does not fail cleanly — it shifts every argument after it, so the
|
||||||
|
/// call receives plausible rubbish and returns a parameter error that names nothing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
|
internal static partial class MacSecurity
|
||||||
|
{
|
||||||
|
internal const string SecurityFramework =
|
||||||
|
"/System/Library/Frameworks/Security.framework/Security";
|
||||||
|
|
||||||
|
internal const string CoreFoundation =
|
||||||
|
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The access control flags <c>SecAccessControlCreateWithFlags</c> takes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>ulong</c> because the parameter is a <c>CFOptionFlags</c>, which is an <c>unsigned long</c>.
|
||||||
|
/// Only the two flags that are used are listed; the full set is large, and copying it in would
|
||||||
|
/// invite somebody to reach for one without reading what it does to the prompt — <c>Biometry</c>
|
||||||
|
/// alone, for instance, leaves a Mac with no Touch ID unable to unlock at all rather than falling
|
||||||
|
/// back to the login password.
|
||||||
|
/// </remarks>
|
||||||
|
[Flags]
|
||||||
|
internal enum AccessControlFlags : ulong
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Touch ID if the machine has it, the login password if not.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The forgiving one, deliberately. <c>BiometryCurrentSet</c> would additionally invalidate the
|
||||||
|
/// key whenever a fingerprint is added or removed, which sounds stricter and here buys nothing:
|
||||||
|
/// this key wraps a device key whose loss already means "ask for the passphrase", so the only
|
||||||
|
/// effect would be users being sent back to their passphrase by an unrelated Settings change
|
||||||
|
/// they would never connect to it.
|
||||||
|
/// </remarks>
|
||||||
|
UserPresence = 1ul << 0,
|
||||||
|
|
||||||
|
/// <summary>Required for any key that lives in the Secure Enclave.</summary>
|
||||||
|
PrivateKeyUsage = 1ul << 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The CFNumberType code for a 32-bit int, from CFNumber.h.</summary>
|
||||||
|
internal const long CFNumberIntType = 9;
|
||||||
|
|
||||||
|
/// <summary>errSecSuccess.</summary>
|
||||||
|
internal const int Success = 0;
|
||||||
|
|
||||||
|
/// <summary>errSecItemNotFound, which is an answer rather than a failure.</summary>
|
||||||
|
internal const int ItemNotFound = -25300;
|
||||||
|
|
||||||
|
// ---- CoreFoundation ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Releases an owned handle.</summary>
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial void CFRelease(IntPtr handle);
|
||||||
|
|
||||||
|
/// <summary>Copies bytes into a new CFData. Owned.</summary>
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial IntPtr CFDataCreate(IntPtr allocator, IntPtr bytes, nint length);
|
||||||
|
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial IntPtr CFDataGetBytePtr(IntPtr data);
|
||||||
|
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial nint CFDataGetLength(IntPtr data);
|
||||||
|
|
||||||
|
/// <summary>Boxes a value as a CFNumber. Owned.</summary>
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial IntPtr CFNumberCreate(IntPtr allocator, nint theType, IntPtr valuePtr);
|
||||||
|
|
||||||
|
/// <summary>Builds an immutable dictionary. Owned.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The key and value arrays are passed as raw pointers to memory the caller pins, rather than as
|
||||||
|
/// managed arrays. Source-generated interop wants an explicit element count for a marshalled array,
|
||||||
|
/// and supplying one here would mean stating the length twice — once for the marshaller and once as
|
||||||
|
/// <paramref name="numValues"/> — which is exactly the pair that drifts.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The two callback tables are what make the dictionary retain its keys and values, which is why
|
||||||
|
/// they are passed rather than left null: with null callbacks the dictionary stores raw pointers and
|
||||||
|
/// keeps nothing alive, and the resulting use-after-free is intermittent by nature.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial IntPtr CFDictionaryCreate(
|
||||||
|
IntPtr allocator,
|
||||||
|
IntPtr keys,
|
||||||
|
IntPtr values,
|
||||||
|
nint numValues,
|
||||||
|
IntPtr keyCallBacks,
|
||||||
|
IntPtr valueCallBacks);
|
||||||
|
|
||||||
|
// ---- Security.framework --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Builds the access policy a Secure Enclave key is created under. Owned.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial IntPtr SecAccessControlCreateWithFlags(
|
||||||
|
IntPtr allocator,
|
||||||
|
IntPtr protection,
|
||||||
|
AccessControlFlags flags,
|
||||||
|
out IntPtr error);
|
||||||
|
|
||||||
|
/// <summary>Creates a key pair from an attribute dictionary. Owned.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial IntPtr SecKeyCreateRandomKey(IntPtr parameters, out IntPtr error);
|
||||||
|
|
||||||
|
/// <summary>The public half of a key. Owned.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Available even for an enclave key, and that asymmetry is the whole reason this design works: the
|
||||||
|
/// public half is an ordinary key this process can hold and use, while the private half is a handle
|
||||||
|
/// to something inside the enclave that never becomes bytes. So sealing is silent and unsealing is
|
||||||
|
/// the thing the user is asked about.
|
||||||
|
/// </remarks>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial IntPtr SecKeyCopyPublicKey(IntPtr key);
|
||||||
|
|
||||||
|
/// <summary>Encrypts with a public key. Owned.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial IntPtr SecKeyCreateEncryptedData(
|
||||||
|
IntPtr key,
|
||||||
|
IntPtr algorithm,
|
||||||
|
IntPtr plaintext,
|
||||||
|
out IntPtr error);
|
||||||
|
|
||||||
|
/// <summary>Decrypts with a private key, prompting for whatever guards it. Owned.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial IntPtr SecKeyCreateDecryptedData(
|
||||||
|
IntPtr key,
|
||||||
|
IntPtr algorithm,
|
||||||
|
IntPtr ciphertext,
|
||||||
|
out IntPtr error);
|
||||||
|
|
||||||
|
/// <summary>Finds a keychain item. The out handle is owned when the result is <see cref="Success"/>.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial int SecItemCopyMatching(IntPtr query, out IntPtr result);
|
||||||
|
|
||||||
|
/// <summary>Deletes every keychain item matching the query.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial int SecItemDelete(IntPtr query);
|
||||||
|
|
||||||
|
// ---- The framework constants ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads one of a framework's global CFString constants, or zero if it is not exported.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The keys these dictionaries take are not strings this code may spell for itself. They are
|
||||||
|
/// pointer-comparable constants exported by the framework, and a CFString built here with the same
|
||||||
|
/// characters is a different object — the lookups would miss and the call would fail with a
|
||||||
|
/// parameter error naming nothing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Dereferenced once, because the exported symbol is the variable rather than its value.</b>
|
||||||
|
/// <c>TryGetExport</c> answers the address of the global; the CFStringRef is what that address
|
||||||
|
/// holds. Missing the indirection produces a pointer that is stable, plausible and wrong, which is
|
||||||
|
/// the worst of the three available outcomes.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Zero on a missing symbol rather than an exception, because the caller's answer to every failure
|
||||||
|
/// is the same one — report the store unavailable and let unlock ask for the passphrase — and a
|
||||||
|
/// constant that has been renamed by a future macOS should reach that answer rather than a crash.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static IntPtr Constant(IntPtr library, string symbol) =>
|
||||||
|
NativeLibrary.TryGetExport(library, symbol, out var address)
|
||||||
|
? Marshal.ReadIntPtr(address)
|
||||||
|
: IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases every CoreFoundation handle put into it, in reverse order, exactly once.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The alternative is a try/finally per handle, and the operations here need six or seven at a time — a
|
||||||
|
/// dictionary holding a nested dictionary holding an access control object holding a CFData tag. Finallys
|
||||||
|
/// nested that deep stop being read, and a handle released twice is a crash rather than a leak.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Keep"/> returns what it was given, so a handle can be tracked in the same expression that
|
||||||
|
/// produces it and the call sites read as ordinary code.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
|
internal sealed class CoreFoundationScope : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<IntPtr> owned = [];
|
||||||
|
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
/// <summary>Takes ownership of a handle and hands it straight back.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Zero is ignored rather than rejected. Every CoreFoundation call here answers zero on failure, so
|
||||||
|
/// accepting it lets a caller track the result in the expression that produces it and check it on
|
||||||
|
/// the next line, instead of writing the check twice.
|
||||||
|
/// </remarks>
|
||||||
|
internal IntPtr Keep(IntPtr handle)
|
||||||
|
{
|
||||||
|
if (handle != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
owned.Add(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disposed = true;
|
||||||
|
|
||||||
|
// Reverse order, so a container is released before the things it retains. CoreFoundation does not
|
||||||
|
// require it — retain counts make the order irrelevant — but it keeps the lifetimes readable in a
|
||||||
|
// debugger, where a released container that still lists its contents is a confusing thing to meet.
|
||||||
|
for (var i = owned.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
MacSecurity.CFRelease(owned[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
owned.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The framework constants <see cref="MacDeviceKeyStore"/> passes to CoreFoundation and Security.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every field here is a pointer read out of a loaded framework rather than a value this code could
|
||||||
|
/// write down. The dictionaries these go into are matched by pointer identity, so a CFString built with
|
||||||
|
/// the same characters is a different key and the lookup misses — see <see cref="MacSecurity.Constant"/>
|
||||||
|
/// for the indirection that trips people.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Resolved once and cached, and the caching is what makes the failure survivable.</b> Two frameworks
|
||||||
|
/// and nineteen symbols is a lot of things to be wrong about, and the useful property is that being
|
||||||
|
/// wrong about any one of them shows up here — as <see cref="Complete"/> being false — rather than
|
||||||
|
/// three calls later as a parameter error. A store that reports itself unavailable sends the user back
|
||||||
|
/// to their passphrase; a store that half works corrupts the moment somebody registers a device.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Lazy{T}"/> rather than a static constructor, because a type initialiser that throws
|
||||||
|
/// poisons the type for the life of the process and turns a missing symbol into a
|
||||||
|
/// <c>TypeInitializationException</c> at every later call site. The load is done inside a try instead,
|
||||||
|
/// and its failure is a value.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
|
internal sealed class MacSymbols
|
||||||
|
{
|
||||||
|
private static readonly Lazy<MacSymbols> Cached = new(Load, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||||
|
|
||||||
|
private MacSymbols()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether every symbol resolved.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Checked by every caller before any of the pointers are used. It is one check rather than
|
||||||
|
/// nineteen, which is the only reason the call sites in <see cref="MacDeviceKeyStore"/> are
|
||||||
|
/// readable.
|
||||||
|
///
|
||||||
|
/// Computed rather than stored, so that the instance returned when a framework will not load at all
|
||||||
|
/// — every field left at zero — answers false without that having to be set anywhere. One rule,
|
||||||
|
/// applied to the only state there is.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool Complete => AllResolved();
|
||||||
|
|
||||||
|
// CoreFoundation.
|
||||||
|
internal IntPtr True { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr TypeDictionaryKeyCallBacks { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr TypeDictionaryValueCallBacks { get; private init; }
|
||||||
|
|
||||||
|
// Security: item classes and query keys.
|
||||||
|
internal IntPtr Class { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr ClassKey { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr ReturnRef { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr UseDataProtectionKeychain { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr UseOperationPrompt { get; private init; }
|
||||||
|
|
||||||
|
// Security: key attributes.
|
||||||
|
internal IntPtr AttrKeyType { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AttrKeySizeInBits { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AttrTokenId { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AttrIsPermanent { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AttrApplicationTag { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AttrAccessControl { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr PrivateKeyAttrs { get; private init; }
|
||||||
|
|
||||||
|
// Security: attribute values.
|
||||||
|
internal IntPtr KeyTypeEcSecPrimeRandom { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr TokenIdSecureEnclave { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AccessibleWhenUnlockedThisDeviceOnly { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The one algorithm the Secure Enclave's P-256 keys support for encryption, and the reason this
|
||||||
|
/// store wraps rather than signs. The long name spells out the whole construction: an ephemeral
|
||||||
|
/// key agreed against the enclave's public half with cofactor ECDH, run through the X9.63 KDF with
|
||||||
|
/// SHA-256, used as an AES-GCM key. The ephemeral public key travels in the output, which is why the
|
||||||
|
/// ciphertext is larger than the 32 bytes going in and why nothing else has to be stored beside it.
|
||||||
|
/// </remarks>
|
||||||
|
internal IntPtr EciesAlgorithm { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>The resolved symbols, loaded once.</summary>
|
||||||
|
internal static MacSymbols Resolve() => Cached.Value;
|
||||||
|
|
||||||
|
private static MacSymbols Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!NativeLibrary.TryLoad(MacSecurity.CoreFoundation, out var cf)
|
||||||
|
|| !NativeLibrary.TryLoad(MacSecurity.SecurityFramework, out var sec))
|
||||||
|
{
|
||||||
|
// Every pointer left at zero, which AllResolved reads as incomplete.
|
||||||
|
return new MacSymbols();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two callback tables are structs rather than object pointers, so what is wanted is the
|
||||||
|
// address of the export itself and not what it holds. Every other symbol here is a CFTypeRef
|
||||||
|
// global and needs the dereference; these two do not, and mixing them up produces a
|
||||||
|
// dictionary that does not retain its contents.
|
||||||
|
var keyCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryKeyCallBacks", out var k)
|
||||||
|
? k
|
||||||
|
: IntPtr.Zero;
|
||||||
|
|
||||||
|
var valueCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryValueCallBacks", out var v)
|
||||||
|
? v
|
||||||
|
: IntPtr.Zero;
|
||||||
|
|
||||||
|
return new MacSymbols
|
||||||
|
{
|
||||||
|
True = MacSecurity.Constant(cf, "kCFBooleanTrue"),
|
||||||
|
TypeDictionaryKeyCallBacks = keyCallBacks,
|
||||||
|
TypeDictionaryValueCallBacks = valueCallBacks,
|
||||||
|
|
||||||
|
Class = MacSecurity.Constant(sec, "kSecClass"),
|
||||||
|
ClassKey = MacSecurity.Constant(sec, "kSecClassKey"),
|
||||||
|
ReturnRef = MacSecurity.Constant(sec, "kSecReturnRef"),
|
||||||
|
UseDataProtectionKeychain = MacSecurity.Constant(sec, "kSecUseDataProtectionKeychain"),
|
||||||
|
UseOperationPrompt = MacSecurity.Constant(sec, "kSecUseOperationPrompt"),
|
||||||
|
|
||||||
|
AttrKeyType = MacSecurity.Constant(sec, "kSecAttrKeyType"),
|
||||||
|
AttrKeySizeInBits = MacSecurity.Constant(sec, "kSecAttrKeySizeInBits"),
|
||||||
|
AttrTokenId = MacSecurity.Constant(sec, "kSecAttrTokenID"),
|
||||||
|
AttrIsPermanent = MacSecurity.Constant(sec, "kSecAttrIsPermanent"),
|
||||||
|
AttrApplicationTag = MacSecurity.Constant(sec, "kSecAttrApplicationTag"),
|
||||||
|
AttrAccessControl = MacSecurity.Constant(sec, "kSecAttrAccessControl"),
|
||||||
|
PrivateKeyAttrs = MacSecurity.Constant(sec, "kSecPrivateKeyAttrs"),
|
||||||
|
|
||||||
|
KeyTypeEcSecPrimeRandom = MacSecurity.Constant(sec, "kSecAttrKeyTypeECSECPrimeRandom"),
|
||||||
|
TokenIdSecureEnclave = MacSecurity.Constant(sec, "kSecAttrTokenIDSecureEnclave"),
|
||||||
|
AccessibleWhenUnlockedThisDeviceOnly =
|
||||||
|
MacSecurity.Constant(sec, "kSecAttrAccessibleWhenUnlockedThisDeviceOnly"),
|
||||||
|
|
||||||
|
EciesAlgorithm = MacSecurity.Constant(
|
||||||
|
sec,
|
||||||
|
"kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is DllNotFoundException or BadImageFormatException)
|
||||||
|
{
|
||||||
|
return new MacSymbols();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool AllResolved() =>
|
||||||
|
True != IntPtr.Zero
|
||||||
|
&& TypeDictionaryKeyCallBacks != IntPtr.Zero
|
||||||
|
&& TypeDictionaryValueCallBacks != IntPtr.Zero
|
||||||
|
&& Class != IntPtr.Zero
|
||||||
|
&& ClassKey != IntPtr.Zero
|
||||||
|
&& ReturnRef != IntPtr.Zero
|
||||||
|
&& UseDataProtectionKeychain != IntPtr.Zero
|
||||||
|
&& UseOperationPrompt != IntPtr.Zero
|
||||||
|
&& AttrKeyType != IntPtr.Zero
|
||||||
|
&& AttrKeySizeInBits != IntPtr.Zero
|
||||||
|
&& AttrTokenId != IntPtr.Zero
|
||||||
|
&& AttrIsPermanent != IntPtr.Zero
|
||||||
|
&& AttrApplicationTag != IntPtr.Zero
|
||||||
|
&& AttrAccessControl != IntPtr.Zero
|
||||||
|
&& PrivateKeyAttrs != IntPtr.Zero
|
||||||
|
&& KeyTypeEcSecPrimeRandom != IntPtr.Zero
|
||||||
|
&& TokenIdSecureEnclave != IntPtr.Zero
|
||||||
|
&& AccessibleWhenUnlockedThisDeviceOnly != IntPtr.Zero
|
||||||
|
&& EciesAlgorithm != IntPtr.Zero;
|
||||||
|
}
|
||||||
@@ -31,7 +31,12 @@ internal static class UpdateChannels
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static IUpdateChannel ForThisMachine()
|
internal static IUpdateChannel ForThisMachine()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
// Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches
|
||||||
|
// this too. Velopack has a Linux path — AppImage — but this repository does not build one, so a
|
||||||
|
// Linux build is a checkout somebody ran, and handing it an UpdateManager would have it poll a
|
||||||
|
// feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future
|
||||||
|
// AppImage an addition here rather than a thing that silently already half-happened.
|
||||||
|
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
|
||||||
{
|
{
|
||||||
return new UnavailableUpdateChannel();
|
return new UnavailableUpdateChannel();
|
||||||
}
|
}
|
||||||
@@ -55,14 +60,21 @@ internal static class UpdateChannels
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The Windows update channel, backed by Velopack against the project's own forge.
|
/// The desktop update channel, backed by Velopack against the project's own forge.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The one file in the repository that names Velopack. It lives beside <c>WindowsDeviceKeyStore</c>
|
/// The one file in the repository that names Velopack. It lives beside the platform key stores rather
|
||||||
/// rather than in a project of its own because it is the same kind of thing — a Windows-only
|
/// than in a project of its own because it is the same kind of thing — a desktop-only implementation of
|
||||||
/// implementation of an interface declared in <c>DodoSSH.Client.Session</c> — and because
|
/// an interface declared in <c>DodoSSH.Client.Session</c> — and because <c>DodoSSH.Client.Shell</c> is
|
||||||
/// <c>DodoSSH.Client.Shell</c> is shared with the Android head, which must never acquire an updater.
|
/// shared with the Android head, which must never acquire an updater.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One class for both desktop platforms, where the key stores are one class each.</b> The difference
|
||||||
|
/// is where the platform knowledge sits. A key store is platform knowledge from top to bottom: different
|
||||||
|
/// hardware, different API, different failure modes. Velopack's <c>UpdateManager</c> has already absorbed
|
||||||
|
/// all of that, and what is left over — check, download, apply, restart — is identical on the two. The
|
||||||
|
/// only thing that differs is which string names the feed, and that is <see cref="ChannelFor"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
|
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
|
||||||
@@ -103,7 +115,45 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// but unsaid on one side and stated on the other is how a feed goes quiet with no error anywhere:
|
/// but unsaid on one side and stated on the other is how a feed goes quiet with no error anywhere:
|
||||||
/// the check succeeds, finds nothing, and reports that the client is up to date forever.
|
/// the check succeeds, finds nothing, and reports that the client is up to date forever.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const string ReleaseChannel = "win";
|
private const string WindowsReleaseChannel = "win";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The nightly channel, which is a different name rather than the same one on a different tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A contract with the <c>desktop nightly</c> job in <c>.github/workflows/ci.yml</c>, which passes
|
||||||
|
/// this word to both <c>vpk pack</c> and <c>vpk upload</c>. The name reaches the wire: Velopack
|
||||||
|
/// publishes its index as <c>releases.win-nightly.json</c> and looks for exactly that file, so a
|
||||||
|
/// disagreement between the two sides is a channel that answers nothing, forever, without an error.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Two names rather than one name on two tags, and that is the part doing the work.</b> Both
|
||||||
|
/// channels are published to the same repository, so a client that read the other's index could be
|
||||||
|
/// offered a package built under a different pack id. Velopack would refuse it, but at the far end of
|
||||||
|
/// a download somebody watched. A channel each means neither ever sees the other's releases at all.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private const string WindowsNightlyChannel = "win-nightly";
|
||||||
|
|
||||||
|
/// <summary>The macOS release channel, and Velopack's own default there.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A contract with <c>scripts/release-macos.sh</c>, exactly as the Windows pair is one with the
|
||||||
|
/// PowerShell script. Stated for the same reason, which applies with more force here: the four
|
||||||
|
/// channels all publish to one repository, so the only thing keeping a Mac from being offered a
|
||||||
|
/// <c>win</c> package is that it never reads that index.
|
||||||
|
/// </remarks>
|
||||||
|
private const string MacReleaseChannel = "osx";
|
||||||
|
|
||||||
|
/// <summary>The macOS nightly channel.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Named here and not yet published by anything. The CI job for the macOS head builds and bundles
|
||||||
|
/// and deliberately uploads nothing — see the packaging step in <c>ci.yml</c> — so a nightly macOS
|
||||||
|
/// build checking this feed finds an empty channel and reports itself up to date, which is the
|
||||||
|
/// correct behaviour for a channel with no publisher. The name exists so that turning the publisher
|
||||||
|
/// on later is one job rather than a job plus a rename that has to reach every installed client.
|
||||||
|
/// </remarks>
|
||||||
|
private const string MacNightlyChannel = "osx-nightly";
|
||||||
|
|
||||||
private readonly UpdateManager manager;
|
private readonly UpdateManager manager;
|
||||||
|
|
||||||
@@ -122,10 +172,13 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsSupported => true;
|
public bool IsSupported => true;
|
||||||
|
|
||||||
/// <summary>Always, on this head.</summary>
|
/// <summary>Always, on this head, on either platform.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Velopack's apply runs <c>Update.exe</c> over this installation and restarts it, so the process is
|
/// Velopack's apply hands off to a separate updater process — <c>Update.exe</c> on Windows, the
|
||||||
/// gone by the time anything could have asked a question. The phone's is the other answer; see
|
/// <c>UpdateMac</c> helper inside the bundle on macOS — which replaces this installation and
|
||||||
|
/// relaunches it, so the process is gone by the time anything could have asked a question. The
|
||||||
|
/// mechanism differs and the answer does not, which is why this is a constant rather than another
|
||||||
|
/// thing <see cref="ChannelFor"/> would have to decide. The phone's is the other answer; see
|
||||||
/// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
|
/// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool ApplyingEndsTheProcess => true;
|
public bool ApplyingEndsTheProcess => true;
|
||||||
@@ -138,10 +191,63 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string CurrentVersion => ClientVersion.Current;
|
public string CurrentVersion => ClientVersion.Current;
|
||||||
|
|
||||||
internal static UpdateManager CreateManager() =>
|
/// <summary>
|
||||||
new(
|
/// The updater for this build's channel.
|
||||||
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: false),
|
/// </summary>
|
||||||
new UpdateOptions { ExplicitChannel = ReleaseChannel });
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The prerelease flag is the half Velopack cannot work out for itself.</b> The channel could be
|
||||||
|
/// left to the installed layout — Velopack records what a package was built with — but whether to
|
||||||
|
/// consider prereleases is a property of the feed rather than of the install, and it decides more
|
||||||
|
/// than it looks. The nightly is published as a prerelease deliberately: the Android head's release
|
||||||
|
/// channel reads <c>releases/latest</c>, which skips prereleases, so a desktop nightly published as a
|
||||||
|
/// stable release would become the newest release in this repository and the phone's release channel
|
||||||
|
/// would start finding no Android manifest on it. One flag here keeps the two heads out of each
|
||||||
|
/// other's way.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The release channel takes <c>false</c>, so it cannot see the nightly at all — which is the property
|
||||||
|
/// that matters most, because that is the direction where a mistake would put an unsigned CI build on
|
||||||
|
/// a machine somebody trusts with their credentials.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static UpdateManager CreateManager()
|
||||||
|
{
|
||||||
|
var nightly = DesktopChannel.IsNightly;
|
||||||
|
|
||||||
|
return new UpdateManager(
|
||||||
|
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
|
||||||
|
new UpdateOptions { ExplicitChannel = ChannelFor(nightly) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one of the four channel names this build belongs to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Two independent axes — which platform, and which of that platform's two channels — and they are
|
||||||
|
/// resolved in one place so that neither can be answered differently somewhere else. The platform
|
||||||
|
/// half is the running OS rather than anything recorded in the build, because a package can only
|
||||||
|
/// ever be applied on the platform it was built for; the channel half comes from assembly metadata,
|
||||||
|
/// because a release build and a nightly are the same bytes on the same OS and only the metadata
|
||||||
|
/// tells them apart.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Windows is the fallback rather than a third branch. Only Windows and macOS reach here at all —
|
||||||
|
/// <see cref="UpdateChannels.ForThisMachine"/> is the gate — so the alternative would be an
|
||||||
|
/// unreachable throw, and an unreachable throw in the middle of the updater is a thing somebody
|
||||||
|
/// later has to reason about to discover it cannot happen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static string ChannelFor(bool nightly)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
return nightly ? MacNightlyChannel : MacReleaseChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nightly ? WindowsNightlyChannel : WindowsReleaseChannel;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -9,9 +9,17 @@ namespace DodoSSH.Client.App.Platform;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
|
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware,
|
||||||
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
|
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
|
||||||
/// passphrase — which is the honest answer rather than a degraded one.
|
/// keeps asking for the passphrase — which is the honest answer rather than a degraded one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Both real stores are asked whether they work rather than told that they do.</b> Each
|
||||||
|
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and deleting it — because on
|
||||||
|
/// both platforms the provider is present and reports itself present on machines where creating a key
|
||||||
|
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, and on macOS also every
|
||||||
|
/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would
|
||||||
|
/// mean each of those discovering the truth at the moment somebody tried to unlock.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
||||||
@@ -29,9 +37,17 @@ public static class DesktopDeviceKeyStores
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()
|
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||||
? new WindowsDeviceKeyStore(paths)
|
{
|
||||||
: new UnavailableDeviceKeyStore();
|
return new WindowsDeviceKeyStore(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsMacOS() && MacDeviceKeyStore.IsSupported())
|
||||||
|
{
|
||||||
|
return new MacDeviceKeyStore(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UnavailableDeviceKeyStore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using DodoSSH.Client.App.Platform;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
using Velopack;
|
using Velopack;
|
||||||
|
|
||||||
@@ -69,7 +70,12 @@ internal static class Program
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var folder = Path.Combine(ClientPaths.Default.DataDirectory, "WebView2");
|
// The same profile directory the rest of the application resolves, channel and all — a nightly
|
||||||
|
// pointing WebView2 at the release build's profile would put two browser profiles in one folder
|
||||||
|
// and hand the pair of them whichever process opened first.
|
||||||
|
var folder = Path.Combine(
|
||||||
|
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
|
||||||
|
"WebView2");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -87,17 +93,25 @@ internal static class Program
|
|||||||
|
|
||||||
/// <summary>Used by the designer as well as by <see cref="Main"/>.</summary>
|
/// <summary>Used by the designer as well as by <see cref="Main"/>.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <c>WithInterFont</c> registers Inter; it does not make it the default, and until this line the
|
/// <c>WithInterFont</c> still registers Inter, and it is still what the layout suite pins: see
|
||||||
/// application shipped a font it then declined to use — falling back to Segoe UI on Windows and to
|
/// HeadlessApp. What changed is which face answers when nothing more specific is asked for. The v5
|
||||||
/// whatever fontconfig offered on Linux. Almost nothing visible moves, because App.axaml sets
|
/// design specifies Montserrat, and unlike the substitution this comment used to explain, Montserrat
|
||||||
/// <c>MonoFont</c> on essentially every control that draws text, but the fallback behind those is now
|
/// now ships embedded in the shell project rather than being requested by a name the machine may not
|
||||||
/// a font that travels with the build rather than one the machine is assumed to have. The layout
|
/// have — see DodoSSH.Client.Shell/Assets/Fonts and its csproj entry. Inter stays registered and is
|
||||||
/// suite pins the same family, and has to: see HeadlessApp.
|
/// named as a fallback, so a glyph Montserrat does not cover still resolves to a font that travels
|
||||||
|
/// with the build rather than to whatever the platform's default sans happens to be.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static AppBuilder BuildAvaloniaApp() =>
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
AppBuilder.Configure<DodoSshApp>()
|
AppBuilder.Configure<DodoSshApp>()
|
||||||
.UsePlatformDetect()
|
.UsePlatformDetect()
|
||||||
.WithInterFont()
|
.WithInterFont()
|
||||||
.With(new FontManagerOptions { DefaultFamilyName = "avares://Avalonia.Fonts.Inter/Assets#Inter" })
|
.With(new FontManagerOptions
|
||||||
|
{
|
||||||
|
DefaultFamilyName = "avares://DodoSSH.Client.Shell/Assets/Fonts#Montserrat",
|
||||||
|
FontFallbacks =
|
||||||
|
[
|
||||||
|
new FontFallback { FontFamily = new FontFamily("avares://Avalonia.Fonts.Inter/Assets#Inter") },
|
||||||
|
],
|
||||||
|
})
|
||||||
.LogToTrace();
|
.LogToTrace();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,28 @@
|
|||||||
Showing the last terminal's pane would be a lie, and showing nothing reads as the application having
|
Showing the last terminal's pane would be a lie, and showing nothing reads as the application having
|
||||||
broken, so this says which machine, as whom, and how far along it is.
|
broken, so this says which machine, as whom, and how far along it is.
|
||||||
|
|
||||||
|
── The step list, and why it is amber ───────────────────────────────────────────────────────────────
|
||||||
|
"How far along it is" used to be one line of prose that never changed after the tab was created, which
|
||||||
|
made every slow connection look exactly like every hung one. It is now the five steps of actually
|
||||||
|
getting there, each lit at the moment the handshake reports it — see SshConnectionPhase, which names
|
||||||
|
only the boundaries a client can genuinely observe. A connection that stops therefore stops on a named
|
||||||
|
row, and "the host key is being checked" stops being the same screen as "the host is not answering".
|
||||||
|
|
||||||
|
Amber for the step in flight, and that is the palette's rule rather than an exception to it. Green is
|
||||||
|
what is true and purple is what you can press; a step still happening is neither, and it is precisely
|
||||||
|
the caveat-worth-reading amber exists for — see the remark above Warn in Palette.axaml. Steps behind it
|
||||||
|
go green as they become true, and the one a refusal landed on goes red. Nothing on the list is drawn in
|
||||||
|
the accent, because there is nothing on it to press.
|
||||||
|
|
||||||
|
Nothing here animates, which is the argument the transfer strip makes for its own track in
|
||||||
|
TransfersScreen.axaml, arriving at a screen with more reason to want a spinner. A spinner is furniture
|
||||||
|
invented to fill a state nobody measured; these steps are measured, so the track fills to what has
|
||||||
|
actually finished and then waits there. Waiting is what waiting looks like.
|
||||||
|
|
||||||
|
The list is drawn for both states rather than once per state. A refused connection has the same five
|
||||||
|
rows and the same track — the difference is only that one row is red and the track stops — and drawing
|
||||||
|
it twice would be two templates to keep identical for the sake of a colour.
|
||||||
|
|
||||||
It obeys the occlusion rule the whole window obeys: this is Avalonia-drawn content in the WebView's own
|
It obeys the occlusion rule the whole window obeys: this is Avalonia-drawn content in the WebView's own
|
||||||
rectangle, so the shell collapses the terminal while it is up. IsTerminalShowing and IsConnectingShowing
|
rectangle, so the shell collapses the terminal while it is up. IsTerminalShowing and IsConnectingShowing
|
||||||
are exclusive by construction — a selected tab either has a session or it does not — which is what makes
|
are exclusive by construction — a selected tab either has a session or it does not — which is what makes
|
||||||
@@ -24,8 +46,69 @@
|
|||||||
can be laid out by a test: WebView2's adapter refuses the headless session's thread.
|
can be laid out by a test: WebView2's adapter refuses the headless session's thread.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
<UserControl.Styles>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A rule per lit state over one quiet default, so that the pending weight is stated once and each state
|
||||||
|
that differs from it is the one line that says how.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBlock.stepcaption">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepcaption.done">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepcaption.running">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepcaption.stopped">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The marker beside each caption. Fixed width and centred, because four different characters on a
|
||||||
|
ragged left edge is a list that looks broken; see ConnectionStepViewModel.Mark for which they are.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBlock.stepmark">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Width" Value="14" />
|
||||||
|
<Setter Property="TextAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepmark.done">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepmark.running">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.stepmark.stopped">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The track over the list. Amber while the attempt is alive and red once it is not, so that the bar says
|
||||||
|
the same thing as the row it stopped on rather than staying the colour of something still being waited
|
||||||
|
for. Chip underneath, matching the transfer strip's track.
|
||||||
|
-->
|
||||||
|
<Style Selector="ProgressBar.steptrack">
|
||||||
|
<Setter Property="Height" Value="5" />
|
||||||
|
<Setter Property="MinHeight" Value="5" />
|
||||||
|
<Setter Property="CornerRadius" Value="3" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ProgressBar.steptrack.stopped">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14" MaxWidth="460"
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
||||||
Margin="24">
|
Margin="24">
|
||||||
|
|
||||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||||
@@ -38,15 +121,43 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Two states, deliberately different. Waiting is an accent line under the host's name; a refusal is
|
Bound to StepsDone against StepCount rather than to a percentage: five steps and a maximum of five
|
||||||
the reason, in the palette's red, because it is the only place the reason will be after the user
|
means the bar is a count of things that really finished, and the arithmetic that would turn it into
|
||||||
navigates away from the screen that started the connection.
|
a percentage is exactly the arithmetic that would start inventing one.
|
||||||
-->
|
-->
|
||||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="12"
|
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||||
Foreground="{StaticResource Accent}" HorizontalAlignment="Center"
|
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||||
TextWrapping="Wrap" TextAlignment="Center"
|
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}" HorizontalAlignment="Center">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Spacing="7" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="stepmark"
|
||||||
|
Classes.done="{Binding IsDone}"
|
||||||
|
Classes.running="{Binding IsRunning}"
|
||||||
|
Classes.stopped="{Binding IsStopped}"
|
||||||
|
Text="{Binding Mark}" />
|
||||||
|
<TextBlock Classes="stepcaption mono"
|
||||||
|
Classes.done="{Binding IsDone}"
|
||||||
|
Classes.running="{Binding IsRunning}"
|
||||||
|
Classes.stopped="{Binding IsStopped}"
|
||||||
|
Text="{Binding Caption}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A refusal is the reason, in the palette's red, because it is the only place the reason will be after
|
||||||
|
the user navigates away from the screen that started the connection. It sits under the list rather
|
||||||
|
than replacing it: which row it stopped on is half the answer and the sentence is the other half.
|
||||||
|
-->
|
||||||
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
|
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
|
||||||
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
|
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
|
||||||
TextWrapping="Wrap" TextAlignment="Center"
|
TextWrapping="Wrap" TextAlignment="Center"
|
||||||
@@ -62,14 +173,23 @@
|
|||||||
handshake that finishes afterwards is adopted rather than dropped — see
|
handshake that finishes afterwards is adopted rather than dropped — see
|
||||||
MainWindowViewModel.CloseTabAsync. Two buttons rather than one with a converted label, because the
|
MainWindowViewModel.CloseTabAsync. Two buttons rather than one with a converted label, because the
|
||||||
two are different decisions and only one of them abandons something still running.
|
two are different decisions and only one of them abandons something still running.
|
||||||
-->
|
|
||||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="GIVE UP"
|
|
||||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
|
||||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
|
||||||
|
|
||||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="CLOSE TAB"
|
Beside each, the logs. The step list is this attempt and the log is every other one, which is the
|
||||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
question both a connection taking too long and a connection just refused actually raise — has this
|
||||||
|
machine ever worked. It is the ordinary rail destination reached the ordinary way rather than a
|
||||||
|
second log grown inside this card, and leaving by it does not abandon the handshake: the tab stays
|
||||||
|
in the strip and the card is still here on the way back.
|
||||||
|
-->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||||
|
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
|
||||||
|
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
|
||||||
|
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
|
||||||
|
CommandParameter="{Binding SelectedTab}"
|
||||||
|
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||||
|
<Button Classes="ghost" Content="CLOSE TAB" Command="{Binding CloseTabCommand}"
|
||||||
|
CommandParameter="{Binding SelectedTab}"
|
||||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -31,16 +31,23 @@
|
|||||||
could disagree: IsShowingHostDetail is defined as "neither editor is open and the pane has been asked
|
could disagree: IsShowingHostDetail is defined as "neither editor is open and the pane has been asked
|
||||||
for", so no two of these can be true at once. The whole control collapses when none of them is — see
|
for", so no two of these can be true at once. The whole control collapses when none of them is — see
|
||||||
IsDrawerOpen, which the hosts screen binds — rather than standing there empty, because an empty
|
IsDrawerOpen, which the hosts screen binds — rather than standing there empty, because an empty
|
||||||
300-pixel column beside a grid is 300 pixels the grid could have had.
|
320-pixel column beside a grid is 320 pixels the grid could have had.
|
||||||
|
|
||||||
◆ AND IT IS ASKED FOR NOW, rather than arriving with a selection. Touching a card used to open this,
|
◆ AND IT IS ASKED FOR NOW, rather than arriving with a selection. Touching a card used to open this,
|
||||||
which charged the width of the pane for the act of choosing; the pencil on a card is the asking. See
|
which charged the width of the pane for the act of choosing; the pencil on a card is the asking. See
|
||||||
VaultViewModel.IsHostPaneOpen and the item template in HostsScreen.axaml.
|
VaultViewModel.IsHostPaneOpen and the item template in HostsScreen.axaml.
|
||||||
|
|
||||||
── THE THREE PANELS ARE THE SAME THREE CARDS ────────────────────────────────────────────────────────
|
── THE TWO PANELS ARE THE SAME CARDS ────────────────────────────────────────────────────────────────
|
||||||
Address, General, Connection — first as rows stating what the host is, then as boxes for changing it.
|
Address, General, Connection, and — since v5 — Quick access: first as rows stating what the host is,
|
||||||
The detail pane's rows are buttons that open the editor, so a box that looks editable turns out to be,
|
then as boxes for changing it. QUICK ACCESS is the one card the detail pane draws conditionally rather
|
||||||
one step along; see Button.fieldrow in App.axaml for why they are not inputs that save as you type.
|
than always: a host with no pinned paths has nothing to state, where an empty Address or General would
|
||||||
|
be a fact about the host rather than an absent one. The detail pane's rows are buttons that open the
|
||||||
|
editor, so a box that looks editable turns out to be, one step along; see Button.fieldrow in App.axaml
|
||||||
|
for why they are not inputs that save as you type.
|
||||||
|
|
||||||
|
The editor's four cards carry v5's own section labels — ADDRESS, GENERAL, CONNECTION, QUICK ACCESS — the
|
||||||
|
mock's tracked-out capitals rather than TextBlock.sectionhead's sentence case; see TextBlock.sectionlabel
|
||||||
|
in App.axaml and the remark on the editor's StackPanel below for why the detail pane did not move too.
|
||||||
|
|
||||||
Its data context is the VaultViewModel, so every binding here is a property of the vault. The hosts
|
Its data context is the VaultViewModel, so every binding here is a property of the vault. The hosts
|
||||||
screen hands it over.
|
screen hands it over.
|
||||||
@@ -62,7 +69,7 @@
|
|||||||
move is a re-seal into one and a tombstone in the other, and the host takes a new id.
|
move is a re-seal into one and a tombstone in the other, and the host takes a new id.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Width="304" Background="{StaticResource Sidebar}"
|
<Border Width="320" Background="{StaticResource Sidebar}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
@@ -79,14 +86,19 @@
|
|||||||
menu instead, next to the two other things that happen to a whole host. Choosing the vault at the
|
menu instead, next to the two other things that happen to a whole host. Choosing the vault at the
|
||||||
moment a host is created is a different question, and it is in the editor beside the name.
|
moment a host is created is a different question, and it is in the editor beside the name.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}"
|
<Border Grid.Row="0" Padding="22,16" Background="{StaticResource Panel}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||||
|
|
||||||
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="1">
|
<!--
|
||||||
<TextBlock Text="{Binding DrawerTitle}" FontSize="14" FontWeight="SemiBold"
|
Title 20 bold and subtitle 12.5, the mock's own header sizes. What the subtitle says is
|
||||||
|
unchanged — see DrawerSubtitle's own remarks below the header for why it is the vault's name
|
||||||
|
rather than the mock's "Saving to X vault" sentence with a picker's chevron inside it.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Column="0" VerticalAlignment="Center" Spacing="2">
|
||||||
|
<TextBlock Text="{Binding DrawerTitle}" FontSize="20" FontWeight="Bold"
|
||||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||||
<TextBlock Text="{Binding DrawerSubtitle}" FontSize="11"
|
<TextBlock Text="{Binding DrawerSubtitle}" FontSize="12.5"
|
||||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis"
|
||||||
IsVisible="{Binding DrawerSubtitle,
|
IsVisible="{Binding DrawerSubtitle,
|
||||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
@@ -138,7 +150,7 @@
|
|||||||
<Panel>
|
<Panel>
|
||||||
|
|
||||||
<!-- ============ WHAT THIS HOST IS ============ -->
|
<!-- ============ WHAT THIS HOST IS ============ -->
|
||||||
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
|
<StackPanel Margin="16,14" Spacing="10" IsVisible="{Binding IsShowingHostDetail}">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
============ ADDRESS ============
|
============ ADDRESS ============
|
||||||
@@ -155,7 +167,12 @@
|
|||||||
Foreground="{StaticResource AccentText}"
|
Foreground="{StaticResource AccentText}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Border>
|
</Border>
|
||||||
<Button Grid.Column="1" Classes="fieldrow" Margin="10,0,0,0"
|
<!--
|
||||||
|
Classes="address" is the v5 restyle: 42 tall and bordered in accent rather than the
|
||||||
|
ordinary hairline, matched by TextBox.address in App.axaml, which the editor's own
|
||||||
|
hostname box below wears too — the two are the same field, one step apart.
|
||||||
|
-->
|
||||||
|
<Button Grid.Column="1" Classes="fieldrow address" Margin="10,0,0,0"
|
||||||
Command="{Binding EditSelectedHostCommand}"
|
Command="{Binding EditSelectedHostCommand}"
|
||||||
ToolTip.Tip="Opens this host's editor.">
|
ToolTip.Tip="Opens this host's editor.">
|
||||||
<TextBlock Classes="mono" Text="{Binding SelectedHost.Host.Hostname}" FontSize="12"
|
<TextBlock Classes="mono" Text="{Binding SelectedHost.Host.Hostname}" FontSize="12"
|
||||||
@@ -338,6 +355,36 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============ QUICK ACCESS (read-only) ============
|
||||||
|
The folders pinned on this host, in the pane's own fieldrow idiom rather than the editor's rows
|
||||||
|
with a close box on each one: one Button opens the editor on a press, same as every other row
|
||||||
|
here, and what is inside it is a list rather than a single fact — the same shape the tags row
|
||||||
|
above takes for the same reason. Hidden entirely rather than shown empty, for the reason Notes
|
||||||
|
is: a card titled QUICK ACCESS with nothing under it would answer a question nobody asked here.
|
||||||
|
-->
|
||||||
|
<Border Classes="section" IsVisible="{Binding SelectedHost.HasPins}">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="sectionhead" Text="Quick access" Margin="0,0,0,2" />
|
||||||
|
|
||||||
|
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="13"
|
||||||
|
Foreground="{StaticResource TextFaint}" Width="16" VerticalAlignment="Top"
|
||||||
|
Margin="0,3,0,0" Text="" />
|
||||||
|
<ItemsControl Grid.Column="1" ItemsSource="{Binding SelectedHost.Host.PinnedPaths}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="x:String">
|
||||||
|
<TextBlock Classes="mono" Text="{Binding}" FontSize="12"
|
||||||
|
TextTrimming="CharacterEllipsis" Margin="0,0,0,2" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||||
Text="Double-clicking the card does the same as CONNECT. The terminal opens as a tab in the strip above and stays there while you look at anything else." />
|
Text="Double-clicking the card does the same as CONNECT. The terminal opens as a tab in the strip above and stays there while you look at anything else." />
|
||||||
|
|
||||||
@@ -345,22 +392,35 @@
|
|||||||
|
|
||||||
<!-- ============ THE HOST EDITOR ============ -->
|
<!-- ============ THE HOST EDITOR ============ -->
|
||||||
<!--
|
<!--
|
||||||
The editor doubles as the "add" form; there is no separate dialog. Same three cards as the pane
|
The editor doubles as the "add" form; there is no separate dialog. Same four cards as the pane
|
||||||
above, with the boxes real — which is what makes pressing a row up there lead somewhere that
|
above, with the boxes real — which is what makes pressing a row up there lead somewhere that
|
||||||
looks like where it was pressed.
|
looks like where it was pressed. QUICK ACCESS is the one card that always draws here even when
|
||||||
|
the pane above hid it for having nothing to show: an empty editor section is where a first pin
|
||||||
|
gets added, so it cannot be conditional on already having one.
|
||||||
-->
|
-->
|
||||||
<StackPanel Margin="12" Spacing="10" IsVisible="{Binding IsEditing}">
|
<StackPanel Margin="16,14" Spacing="10" IsVisible="{Binding IsEditing}">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
v5's own section labels — ADDRESS, GENERAL, CONNECTION, QUICK ACCESS — replace sentence-case
|
||||||
|
headings on this one panel, the editor, because it is the panel the mock actually draws; the
|
||||||
|
detail pane above keeps TextBlock.sectionhead, its own established idiom, since the mock has no
|
||||||
|
read-only view to restyle it against. See TextBlock.sectionlabel in App.axaml.
|
||||||
|
-->
|
||||||
<Border Classes="section">
|
<Border Classes="section">
|
||||||
<StackPanel Spacing="8">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Classes="sectionhead" Text="Address" />
|
<TextBlock Classes="sectionlabel" Text="ADDRESS" />
|
||||||
<Grid ColumnDefinitions="Auto,*">
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
|
<Border Grid.Column="0" Classes="tileicon" Background="{StaticResource Chip}">
|
||||||
<TextBlock Classes="mono" Text=">_" FontSize="11" FontWeight="Bold"
|
<TextBlock Classes="mono" Text=">_" FontSize="11" FontWeight="Bold"
|
||||||
Foreground="{StaticResource AccentText}"
|
Foreground="{StaticResource AccentText}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Border>
|
</Border>
|
||||||
<TextBox Grid.Column="1" Margin="10,0,0,0" Text="{Binding EditorHostname}"
|
<!--
|
||||||
|
No drawn caret: this TextBox has a real one, and the mock's blinking bar is what a real
|
||||||
|
caret looks like in a screenshot that cannot show motion. Classes="address" is the 42-tall,
|
||||||
|
accent-bordered restyle shared with the detail pane's own Hostname row above.
|
||||||
|
-->
|
||||||
|
<TextBox Grid.Column="1" Classes="address" Margin="10,0,0,0" Text="{Binding EditorHostname}"
|
||||||
PlaceholderText="hostname or address" />
|
PlaceholderText="hostname or address" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -368,9 +428,9 @@
|
|||||||
|
|
||||||
<Border Classes="section">
|
<Border Classes="section">
|
||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
<TextBlock Classes="sectionhead" Text="General" Margin="0,0,0,2" />
|
<TextBlock Classes="sectionlabel" Text="GENERAL" Margin="0,0,0,2" />
|
||||||
|
|
||||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
|
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" Height="40" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it decides who can read it and
|
◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it decides who can read it and
|
||||||
@@ -413,7 +473,7 @@
|
|||||||
-->
|
-->
|
||||||
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
|
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
|
||||||
SelectedItem="{Binding EditorSelectedGroup}"
|
SelectedItem="{Binding EditorSelectedGroup}"
|
||||||
HorizontalAlignment="Stretch">
|
HorizontalAlignment="Stretch" Height="40">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:GroupChoice">
|
<DataTemplate x:DataType="vm:GroupChoice">
|
||||||
<TextBlock Text="{Binding Label}" />
|
<TextBlock Text="{Binding Label}" />
|
||||||
@@ -453,37 +513,48 @@
|
|||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Add button is 40x40 and a glyph rather than the word ADD, matched to the field beside it —
|
||||||
|
the v5 mock draws every add affordance in this drawer as a square icon button; QUICK ACCESS's
|
||||||
|
own add row below is the same idiom at 36x36, one size down for its shorter field.
|
||||||
|
-->
|
||||||
<Grid ColumnDefinitions="*,6,Auto">
|
<Grid ColumnDefinitions="*,6,Auto">
|
||||||
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag">
|
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag"
|
||||||
|
Height="40">
|
||||||
<TextBox.KeyBindings>
|
<TextBox.KeyBindings>
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
|
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
|
||||||
</TextBox.KeyBindings>
|
</TextBox.KeyBindings>
|
||||||
</TextBox>
|
</TextBox>
|
||||||
<Button Grid.Column="2" Classes="ghost" Content="ADD"
|
<Button Grid.Column="2" Classes="ghost" Content="+" Width="40" Height="40"
|
||||||
Command="{Binding AddEditorTagCommand}" />
|
FontSize="16" HorizontalContentAlignment="Center"
|
||||||
|
Command="{Binding AddEditorTagCommand}" ToolTip.Tip="Adds this tag" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||||
Height="56" TextWrapping="Wrap" />
|
Height="58" TextWrapping="Wrap" />
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Classes="section">
|
<Border Classes="section">
|
||||||
<StackPanel Spacing="6">
|
<StackPanel Spacing="6">
|
||||||
<TextBlock Classes="sectionhead" Text="Connection" Margin="0,0,0,2" />
|
<TextBlock Classes="sectionlabel" Text="CONNECTION" Margin="0,0,0,2" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Both boxes are allowed to be empty, and empty means "take the group's" rather than "unset".
|
Username first and port 78 wide second, the mock's own order — reversed from the field's
|
||||||
The watermark is what the host will actually use if it is left that way, which is why it is
|
earlier "port, username" layout, which put the number that is usually left blank ahead of
|
||||||
bound rather than literal: it changes when the group picker above moves.
|
the one that usually is not. Both boxes are still allowed to be empty, and empty still means
|
||||||
|
"take the group's" rather than "unset"; the watermark is what the host will actually use if
|
||||||
|
it is left that way, which is why it is bound rather than literal — it changes when the
|
||||||
|
group picker above moves.
|
||||||
-->
|
-->
|
||||||
<Grid ColumnDefinitions="*,8,*">
|
<Grid ColumnDefinitions="*,8,78">
|
||||||
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
|
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
|
||||||
FormatString="0" ShowButtonSpinner="False"
|
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" />
|
||||||
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
||||||
<TextBox Grid.Column="2" Text="{Binding EditorUsername}"
|
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
||||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
|
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
||||||
|
Height="40" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -500,7 +571,7 @@
|
|||||||
-->
|
-->
|
||||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||||
HorizontalAlignment="Stretch">
|
HorizontalAlignment="Stretch" Height="40">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
@@ -513,15 +584,92 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
|
||||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
|
|
||||||
Content="Connect through the server relay" />
|
|
||||||
<!--
|
<!--
|
||||||
Stated at the moment the decision is made, which is the only place it means anything. With
|
Making a credential without leaving the host. The moment one is wanted is this one: somebody
|
||||||
relay off the server stores no address at all; with it on the server must be able to
|
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
||||||
resolve the target, or it becomes an authenticated open proxy into the operator's network.
|
sending them to the other screen to add it would lose the half-typed host they are standing
|
||||||
|
in. Same argument as the new-tag box further down, same immediate write, same honest
|
||||||
|
consequence — the credential stays if this editor is cancelled, because a host can only name
|
||||||
|
an id that exists.
|
||||||
|
|
||||||
|
A button beside the picker rather than an entry inside it. Every row of that list is a
|
||||||
|
binding the host can have; "make a new one" is an action, and as an entry it would sit in the
|
||||||
|
box afterwards describing a state no host can be in.
|
||||||
-->
|
-->
|
||||||
<TextBlock Classes="hint" FontSize="11"
|
<Button Classes="ghost" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
|
||||||
Text="The relay stores this host's address on the server in plain text. Everything else stays encrypted." />
|
FontSize="10.5" Height="28" Padding="10,0"
|
||||||
|
IsVisible="{Binding !IsAddingEditorCredential}"
|
||||||
|
Command="{Binding BeginEditorCredentialCommand}"
|
||||||
|
ToolTip.Tip="Adds a credential to the keychain and binds this host to it" />
|
||||||
|
|
||||||
|
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
|
||||||
|
IsVisible="{Binding IsAddingEditorCredential}">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="label" Text="NEW CREDENTIAL" FontSize="10" />
|
||||||
|
<TextBox Text="{Binding EditorNewCredentialLabel}" PlaceholderText="name" Height="36" />
|
||||||
|
<!--
|
||||||
|
Optional, and what makes a credential worth being its own item: one account on twenty
|
||||||
|
machines is rotated in one place. Left blank, this host's own username is used.
|
||||||
|
-->
|
||||||
|
<TextBox Text="{Binding EditorNewCredentialUsername}" Height="36"
|
||||||
|
PlaceholderText="username (blank: use this host's own)" />
|
||||||
|
<!-- Masked, on the reasoning the keychain's own password box carries. -->
|
||||||
|
<TextBox Text="{Binding EditorNewCredentialPassword}" PlaceholderText="password"
|
||||||
|
PasswordChar="•" Height="36">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding AddEditorCredentialCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<TextBox Text="{Binding EditorNewCredentialNotes}" PlaceholderText="notes"
|
||||||
|
AcceptsReturn="True" Height="44" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
Text="Added to the keychain as soon as you press ADD, so it stays even if you cancel this host. Renaming and deleting are on the keychain screen." />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Classes="accent" Content="ADD"
|
||||||
|
Command="{Binding AddEditorCredentialCommand}" />
|
||||||
|
<Button Classes="ghost" Content="CANCEL"
|
||||||
|
Command="{Binding CancelEditorCredentialCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
|
||||||
|
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
||||||
|
exactly what it was, "(not wired up yet)" included: the design's own wording implies a relay
|
||||||
|
this client can dial, and it cannot. See the remark under the hint below, which is unchanged
|
||||||
|
and still the reason this box says what it says.
|
||||||
|
-->
|
||||||
|
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<CheckBox IsChecked="{Binding EditorRelayEnabled}" VerticalContentAlignment="Center">
|
||||||
|
<TextBlock Text="Connect through the server relay (not wired up yet)" FontSize="13"
|
||||||
|
FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</CheckBox>
|
||||||
|
<!--
|
||||||
|
Stated at the moment the decision is made, which is the only place it means anything.
|
||||||
|
With relay off the server stores no address at all; with it on the server must be able
|
||||||
|
to resolve the target, or it becomes an authenticated open proxy into the operator's
|
||||||
|
network.
|
||||||
|
|
||||||
|
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay
|
||||||
|
is built and this client has no path to it: VaultViewModel dials the address directly
|
||||||
|
whether this is ticked or not. So the box as it stood spent the one deliberate privacy
|
||||||
|
concession in the design — the address in plaintext columns — and delivered a
|
||||||
|
connection that failed exactly as it had before, with nothing saying why.
|
||||||
|
|
||||||
|
Left tickable rather than disabled, because a host that already carries the flag has to
|
||||||
|
be able to lose it, and a disabled control would trap the concession on. See
|
||||||
|
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
|
||||||
|
removable.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="hint" FontSize="11.5"
|
||||||
|
Text="Not built yet: this client always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it does work, the relay will dial on your behalf — which is why the address and port have to be stored in plain text. Everything else about the host stays encrypted either way." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
||||||
@@ -539,6 +687,66 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============ QUICK ACCESS ============
|
||||||
|
The folders pinned on this host. Staged on VaultViewModel.EditorPinnedPaths the way the tag
|
||||||
|
picker stages editorTagIds — see that field's own remarks — populated when the editor opens,
|
||||||
|
read back by BuildHost on Save, and left alone by CANCEL for the same reason every other field
|
||||||
|
here is: the whole editor is abandoned together.
|
||||||
|
-->
|
||||||
|
<Border Classes="section">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="sectionlabel" Text="QUICK ACCESS" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding EditorPinnedPaths}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate><StackPanel Spacing="6" /></ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="x:String">
|
||||||
|
<Border Height="33" CornerRadius="9" Background="{StaticResource Field}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="8,0">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||||
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||||
|
Text="" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding}" FontSize="12.5"
|
||||||
|
Margin="8,0" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<!--
|
||||||
|
$parent[ItemsControl] rather than #Board: this ItemsControl's own DataContext is
|
||||||
|
already the VaultViewModel — HostDrawer's root x:DataType is that type directly —
|
||||||
|
so one level up is enough, the same single-hop case ToggleEditorTagCommand above
|
||||||
|
already uses.
|
||||||
|
-->
|
||||||
|
<Button Grid.Column="2" Classes="flat paneicon" Content="✕" Width="22" Height="22"
|
||||||
|
FontSize="11"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).RemoveEditorPinCommand}"
|
||||||
|
CommandParameter="{Binding}" ToolTip.Tip="Unpins this path" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,6,Auto">
|
||||||
|
<TextBox Grid.Column="0" FontFamily="{StaticResource MonoFont}"
|
||||||
|
Text="{Binding EditorNewPin}"
|
||||||
|
PlaceholderText="/path/to/folder" Height="36">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding AddEditorPinCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<Button Grid.Column="2" Classes="ghost" Content="+" Width="36" Height="36" FontSize="15"
|
||||||
|
HorizontalContentAlignment="Center"
|
||||||
|
Command="{Binding AddEditorPinCommand}" ToolTip.Tip="Pins this path" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||||
|
Text="Pinned folders appear above the terminal for this host." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- ============ THE GROUP EDITOR ============ -->
|
<!-- ============ THE GROUP EDITOR ============ -->
|
||||||
@@ -689,6 +897,26 @@
|
|||||||
</ComboBox>
|
</ComboBox>
|
||||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||||
Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." />
|
Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE KEY, WHICH IS THE HALF THE SENTENCE ABOVE CANNOT PROMISE. A binding resolves across
|
||||||
|
vaults, so the host goes on working here either way — but the colleagues it has just joined
|
||||||
|
hold one vault's key, and a host whose key stayed behind is one they cannot connect with.
|
||||||
|
|
||||||
|
Unticked, and it has to be: moving a key into a team's vault hands it to everybody who holds
|
||||||
|
that key. The note under it is the count, because a key twenty machines authenticate with is a
|
||||||
|
different decision from one nothing else uses, and neither is visible from here otherwise.
|
||||||
|
-->
|
||||||
|
<CheckBox IsChecked="{Binding BringsTheBindingAlong}"
|
||||||
|
IsVisible="{Binding HasABindingToBring}"
|
||||||
|
ToolTip.Tip="Moves the key or password itself into the same vault, and re-aims every host and group that used it at where it has gone.">
|
||||||
|
<TextBlock Text="{Binding BindingToBringQuestion}" Classes="hint" FontSize="12"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</CheckBox>
|
||||||
|
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding HasABindingToBring}"
|
||||||
|
Text="{Binding BindingToBringNote}" />
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
|
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
@@ -696,10 +924,17 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditing}">
|
<!--
|
||||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
|
Save host flex-grows and Cancel is a fixed 92, the mock's own footer proportions — a Grid rather
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
|
than the StackPanel every other footer row uses, because a StackPanel measures each child at its
|
||||||
</StackPanel>
|
own size and has nothing that means "the rest of the row".
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,8,92" IsVisible="{Binding IsEditing}">
|
||||||
|
<Button Grid.Column="0" Classes="accent" Content="Save host" Height="44"
|
||||||
|
HorizontalContentAlignment="Center" Command="{Binding SaveHostCommand}" />
|
||||||
|
<Button Grid.Column="2" Classes="ghost" Content="Cancel" Height="44"
|
||||||
|
HorizontalContentAlignment="Center" Command="{Binding CancelEditCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditingGroup}">
|
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditingGroup}">
|
||||||
<Button Classes="accent" Content="{Binding GroupSaveLabel}"
|
<Button Classes="accent" Content="{Binding GroupSaveLabel}"
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.HostKeyCard"
|
||||||
|
x:DataType="vm:VaultViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The two host-key decisions, over whatever the window happens to be showing. The desktop counterpart of
|
||||||
|
the phone's HostKeySheet, and one control for both answers for the same reason: they are one decision
|
||||||
|
point, the view models make them mutually exclusive, and the shell must not be able to show both.
|
||||||
|
|
||||||
|
── IT WAS TWO BANNERS AT THE TOP OF THE HOSTS SCREEN. ───────────────────────────────────────────────
|
||||||
|
Which is why the shell used to navigate there before letting the vault raise the question: a connection
|
||||||
|
can be started from the palette on any screen, and a prompt behind the screen somebody is looking at is a
|
||||||
|
prompt nobody can answer. The navigation covered that, and paid for it everywhere else — it took the user
|
||||||
|
off the screen they were working on, and for the one connection that has no host at all it judged a
|
||||||
|
machine typed in by hand on the list it is deliberately not on. See MainWindowViewModel's own note on
|
||||||
|
OnVaultConnectionFailed.
|
||||||
|
|
||||||
|
Drawn over the surface instead, so nothing has to move and the palette's case needs no special one.
|
||||||
|
|
||||||
|
── THE OCCLUSION RULE, AND THIS IS THE CONTROL THAT MOST NEEDS IT. ──────────────────────────────────
|
||||||
|
A second connection can be made while a first one is open, so this card is routinely raised over a live
|
||||||
|
terminal — and that rectangle is a native child window which composites above everything Avalonia draws.
|
||||||
|
A card layered over it would be sliced at its left edge with TRUST AND CONNECT unreachable, which for the
|
||||||
|
most safety-critical question in the product is the worst place for that class of bug to land. The shell
|
||||||
|
collapses the WebView while this is up; see MainWindowViewModel.IsHostKeyDecisionShowing.
|
||||||
|
|
||||||
|
── ◆ WHAT IS DELIBERATELY DIFFERENT BETWEEN THE TWO HALVES. ─────────────────────────────────────────
|
||||||
|
First contact offers TRUST AND CONNECT, because deciding whether a fingerprint is the one the operator
|
||||||
|
published is a judgement a person is entitled to make and is the only moment they can make it.
|
||||||
|
|
||||||
|
A changed key offers no way forward at all: no continue, no "connect anyway", and nothing that turns
|
||||||
|
dismissing it into a connection. The only way past it is FORGET HOST KEY in the host's own editor, which
|
||||||
|
is a deliberate act performed somewhere else — a button next to this warning would be "continue anyway"
|
||||||
|
with two clicks instead of one. The phone's sheet carries the same note, and calls presenting this as
|
||||||
|
dismissible the one design mistake here that matters.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A wash with no press handler, and the omission is the point: every other overlay in this window closes by
|
||||||
|
clicking away from it, and a question with two named answers must not be answerable by missing. The
|
||||||
|
palette's own backdrop is the control to compare with — see QuickConnect.
|
||||||
|
|
||||||
|
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
|
||||||
|
variant of a surface; the pre-multiplied ones there are accent washes.
|
||||||
|
-->
|
||||||
|
<Border Background="#CC05050A">
|
||||||
|
<Panel>
|
||||||
|
|
||||||
|
<!-- ============ UNKNOWN HOST KEY ============ -->
|
||||||
|
<Border Classes="card" IsVisible="{Binding HasPendingHostKey}"
|
||||||
|
BorderBrush="{StaticResource WarnSoft}">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
|
||||||
|
<TextBlock Classes="label" Text="UNKNOWN HOST KEY" Foreground="{StaticResource Warn}" />
|
||||||
|
|
||||||
|
<TextBlock Foreground="{StaticResource WarnText}" TextWrapping="Wrap">
|
||||||
|
<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." />
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<TextBlock Classes="label" FontSize="10" Text="{Binding PendingHostKey.Algorithm}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
In full, wrapping rather than trimmed, and selectable. A fingerprint exists to be compared
|
||||||
|
character by character against one an operator published, and the two things that ruin that are
|
||||||
|
an ellipsis in the middle and a line you cannot copy.
|
||||||
|
-->
|
||||||
|
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||||
|
BorderThickness="1" CornerRadius="4" Padding="10,8">
|
||||||
|
<SelectableTextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource Warn}"
|
||||||
|
Text="{Binding PendingHostKey.Fingerprint}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="hint" FontSize="12"
|
||||||
|
Text="Compare it with the fingerprint the server's operator published before trusting it. Trusting pins it for this keychain, on every machine." />
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||||
|
Command="{Binding TrustHostKeyCommand}" />
|
||||||
|
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ CHANGED HOST KEY ============ -->
|
||||||
|
<Border Classes="card" IsVisible="{Binding HasHostKeyMismatch}"
|
||||||
|
BorderBrush="{StaticResource DangerSoft}">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
|
||||||
|
<TextBlock Classes="label" Text="HOST KEY CHANGED" Foreground="{StaticResource Danger}" />
|
||||||
|
|
||||||
|
<TextBlock Foreground="{StaticResource Danger}" FontWeight="SemiBold" TextWrapping="Wrap"
|
||||||
|
Text="The host key changed and the connection was refused." />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The whole explanation comes from the view model, which already composes it — which host, both
|
||||||
|
fingerprints, and which of them this keychain has pinned. Rewriting it here would be a second
|
||||||
|
copy of the most safety-critical sentence in the product, in a second place to forget.
|
||||||
|
-->
|
||||||
|
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||||
|
BorderThickness="1" CornerRadius="4" Padding="10,8">
|
||||||
|
<SelectableTextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource DangerText}"
|
||||||
|
Text="{Binding HostKeyMismatch}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="hint" FontSize="12" Foreground="{StaticResource WarnText}"
|
||||||
|
Text="If this server was rebuilt on purpose, edit the host and choose FORGET HOST KEY, then connect again. There is deliberately no way to continue from here." />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One button, and it goes nowhere. It puts the refusal away so the host's editor can be reached; it
|
||||||
|
is not an answer, because this half of the control has none to give. Compare the accent button
|
||||||
|
above, which connects.
|
||||||
|
-->
|
||||||
|
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
||||||
|
Command="{Binding RejectHostKeyCommand}" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Panel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The two host-key decisions, drawn over whatever the window is showing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Its data context is the <c>VaultViewModel</c>, so every binding in the markup is a property of the vault;
|
||||||
|
/// which of the two halves draws is decided there and the two are mutually exclusive. Whether it is on screen
|
||||||
|
/// at all is the shell's business — see <c>MainWindowViewModel.IsHostKeyDecisionShowing</c>, which is also
|
||||||
|
/// what collapses the terminal's WebView underneath it.
|
||||||
|
///
|
||||||
|
/// In its own file rather than in the window, like every other card here, because nothing inside that window
|
||||||
|
/// can be laid out by a test: WebView2's adapter refuses the headless session's thread.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class HostKeyCard : UserControl
|
||||||
|
{
|
||||||
|
public HostKeyCard() => InitializeComponent();
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
@@ -8,119 +9,128 @@ using DodoSSH.Client.Shell.ViewModels;
|
|||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The grid of groups and hosts, and everything on it that is a gesture rather than a binding.
|
/// The flat board of host cards, and everything on it that is a gesture rather than a binding.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// All of this was <c>HostSidebar</c>'s until the host list became a grid of cards. It moved with the list
|
/// ◆ <b>v5: THE GRID OF GROUP CARDS AND THE BREADCRUMB TRAIL ARE GONE, AND SO IS THE DRAG.</b> The desktop
|
||||||
/// rather than staying with the editor: every handler here is about the thing that was clicked, dragged or
|
/// used to hold one level of the group tree at a time — a wrap of group cards above the hosts, opened by a
|
||||||
/// right-clicked, and the drawer beside the grid has none of those. See <see cref="HostDrawer"/>.
|
/// double-click, with a trail above it saying where you were — the way a directory pane holds one directory.
|
||||||
|
/// That model is retired outright rather than folded into the new board: every group is now a heading, in
|
||||||
|
/// label order, all of them at once, exactly the flattening the phone's <c>SidebarRows</c> has always drawn.
|
||||||
|
/// A host is filed under a group through the group's own picker in its editor, or through the "Change
|
||||||
|
/// group…" entry the chosen-hosts menu already carried — dragging a card onto another card had exactly one
|
||||||
|
/// destination, a group card, and that card is what left. See <c>VaultViewModel.HostSections</c>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is, so every binding in the
|
/// ◆ <b>ONE SECTION, ONE <c>ListBox</c>.</b> The two shapes worth weighing were a single mixed list of
|
||||||
/// markup is a property of the vault. The window hands it over; see <see cref="MainWindow"/>. The drawer
|
/// header rows and host rows in one <c>WrapPanel</c>, or an <c>ItemsControl</c> of sections each holding its
|
||||||
/// beside the grid inherits the same one.
|
/// own card list. The first is what the phone's single flat list already is, and reusing it here would have
|
||||||
|
/// meant a header row pretending to be as wide as the row it sits on so the wrap panel breaks a line before
|
||||||
|
/// and after it — a real technique, and a fragile one to get right at every width this window can be resized
|
||||||
|
/// to. The second costs a real thing: there is no longer one <c>ListBox</c> owning one <c>SelectedItem</c>,
|
||||||
|
/// so multi-select and the marquee have to be taught to reach across however many section lists are on
|
||||||
|
/// screen. That is what most of this file now does. It was chosen anyway, because a <c>WrapPanel</c> that
|
||||||
|
/// only ever holds cards of one kind is the ordinary, well-trodden case, and "reach across N lists instead
|
||||||
|
/// of one" is a bounded, mechanical problem — enumerate every list, not the fixed one named in the markup —
|
||||||
|
/// where "make a wrap panel break a line for one specific child" is a harder one to be sure of without a
|
||||||
|
/// custom panel this application does not otherwise need.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>SELECTION IS ONE-WAY INTO EVERY SECTION AND WRITTEN BY HAND ON THE WAY OUT.</b> Each section's
|
||||||
|
/// <c>ListBox</c> binds <c>SelectedItem</c> from <c>VaultViewModel.SelectedSidebarRow</c> so the right card
|
||||||
|
/// lights up wherever it lives, but not back — a two-way binding shared by several independently-rebuilt
|
||||||
|
/// lists would have each list's own <c>Reset</c> (one per filter keystroke, one per background sync) racing
|
||||||
|
/// to null the one shared property, which is the same hazard <c>VaultViewModel.RebuildVisibleHosts</c>
|
||||||
|
/// already documents for a single list and multiplies it by the section count. So a card being pressed is
|
||||||
|
/// what actually moves the selection, here in code, exactly as a Ctrl or Shift modifier already had to be —
|
||||||
|
/// see <see cref="OnPointerPressed"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>THERE ARE STILL TWO SELECTIONS ON THIS SCREEN, AND THEY ARE STILL NOT THE SAME THING.</b>
|
||||||
|
/// <c>SelectedHost</c> is the card the drawer, CONNECT and the ordinary menu are about; the chosen set is
|
||||||
|
/// what Ctrl, Shift and the band build on top of it, as <c>IsChosen</c> on the row. Every handler below that
|
||||||
|
/// reads a modifier is about keeping the two from being confused for one another, on the same terms this
|
||||||
|
/// screen has always used — see <c>VaultViewModel.ChooseHosts</c> and Android's <c>HostsScreen</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is. The window hands it
|
||||||
|
/// over; see <see cref="MainWindow"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class HostsScreen : UserControl
|
internal sealed partial class HostsScreen : UserControl
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>How far the pointer has to travel before a press on the empty space becomes a band.</summary>
|
||||||
/// How a host travels from the card it was picked up on to the group card it is dropped on.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// An in-process format carrying the row itself, rather than text carrying an id. The drag never leaves
|
/// A threshold, because a press on the space around the cards is nearly always a click that drops the
|
||||||
/// this window — there is nothing outside it that could accept a host — and the row is what the drop
|
/// chosen set — see <see cref="OnBandPressed"/>. Starting a band on the press itself would turn every one
|
||||||
/// needs: it knows which vault the edit has to return to, which an id on its own does not.
|
/// of those into a rectangle nobody asked for.
|
||||||
/// </remarks>
|
|
||||||
private static readonly DataFormat<HostRowViewModel> HostFormat =
|
|
||||||
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
|
|
||||||
|
|
||||||
/// <summary>How far the pointer has to travel before a press becomes a drag.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// A threshold, because a press on this grid is nearly always a click: selecting a host, or the first
|
|
||||||
/// half of the double-click that connects. Starting a drag on the press itself would turn every one of
|
|
||||||
/// those into a drag gesture the user never asked for.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const double DragThreshold = 5;
|
private const double DragThreshold = 5;
|
||||||
|
|
||||||
/// <summary>How close to the top or bottom of the grid a drag has to be held to scroll it.</summary>
|
/// <summary>Where a Shift-click measures its run from: the last card pressed without one.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Deeper than a card's own margin, because the band has to be reachable while the pointer is still
|
/// Kept here rather than in the vault because it is a fact about the gesture rather than about the
|
||||||
/// carrying something the user is looking at — a band the width of a hairline would only be found by
|
/// keychain — it is what the pointer last touched, and it means nothing to the phone or to any command.
|
||||||
/// accident, and only by somebody who did not need it.
|
/// Null until something has been pressed, which is what a Shift-click into an untouched board falls back
|
||||||
|
/// to the selected card for.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const double EdgeBand = 48;
|
private HostRowViewModel? anchor;
|
||||||
|
|
||||||
/// <summary>How far one drag event inside that band moves the grid.</summary>
|
/// <summary>Whether a band is being dragged out over the board right now.</summary>
|
||||||
|
private bool banding;
|
||||||
|
|
||||||
|
/// <summary>The corner it was started from, in the scroller's own coordinates.</summary>
|
||||||
|
private Point bandFrom;
|
||||||
|
|
||||||
|
/// <summary>Whether that band adds to the set rather than being the whole of it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Roughly a third of a card, so a pointer moving inside the band travels the grid at about the speed it
|
/// Read once, when the band starts, rather than per move. A modifier picked up halfway through a drag
|
||||||
/// is moving. A step of a whole card would jump the target out from under the pointer between two events.
|
/// would change what the rectangle already crossed means, which is a selection nobody could predict.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const double EdgeStep = 24;
|
private bool bandAdds;
|
||||||
|
|
||||||
/// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
|
|
||||||
/// gesture belongs to the pointer that went down, and the platform needs that event to hand the drag
|
|
||||||
/// over to the operating system.
|
|
||||||
/// </remarks>
|
|
||||||
private PointerPressedEventArgs? press;
|
|
||||||
|
|
||||||
private HostRowViewModel? pickedUp;
|
|
||||||
|
|
||||||
private Point origin;
|
|
||||||
|
|
||||||
/// <summary>The group card the pointer is currently over, while a drag is in flight.</summary>
|
|
||||||
private ListBoxItem? marked;
|
|
||||||
|
|
||||||
public HostsScreen()
|
public HostsScreen()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
|
||||||
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
|
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
|
||||||
// the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
|
// the transfers screen opens a directory too. Attached to the board rather than to any one section's
|
||||||
// every other client of this kind does, and CONNECT stays: it is the one in the drawer with the
|
// list, since double-tapping a card in any of them means the same thing and DoubleTapped bubbles.
|
||||||
// password box above it, and a host that asks for a password still needs it typed first.
|
Board.DoubleTapped += OnHostActivated;
|
||||||
HostGrid.DoubleTapped += OnHostActivated;
|
|
||||||
|
|
||||||
// And a group card opens the group, on the same gesture, for the same reason: going inside
|
// Tunnelled, so the card under the pointer is read before whichever section's ListBox has answered
|
||||||
// something by double-clicking it is what the transfers screen's directories do and what every file
|
// the press itself. Bubbling would work for a plain click but not for the menu or for Ctrl/Shift: by
|
||||||
// manager does. One click used to open a group, which made the card that names a group and the
|
// the time either reaches this control the list has already decided what it thinks is selected.
|
||||||
// control that narrows the grid to it the same press — so there was no way to select a group in
|
Board.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
||||||
// order to rename it without also losing sight of every host outside it.
|
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
||||||
GroupGrid.DoubleTapped += OnGroupActivated;
|
|
||||||
|
|
||||||
// Tunnelled, so the card under the pointer is read before the ListBox has answered the press itself.
|
WireTheBand();
|
||||||
// Bubbling would work for the drag but not for the menu: by then the control has already decided
|
}
|
||||||
// what is selected, and the menu is about to open against it.
|
|
||||||
HostGrid.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
|
||||||
HostGrid.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
|
||||||
|
|
||||||
// And the group cards answer a right click the same way, for the same reason: their menu's commands
|
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
|
||||||
// read the vault's group selection, and without this they would act on whichever card was selected
|
/// <remarks>
|
||||||
// before — or, with none, on the group the trail ends with, which is not on screen at all.
|
/// Split out of the constructor rather than sitting in it, and only because the constructor is at the
|
||||||
GroupGrid.AddHandler(ContextRequestedEvent, OnGroupContextRequested, RoutingStrategies.Tunnel);
|
/// length this repository's analyser allows for one. Everything here arrived together: it is the whole
|
||||||
|
/// of choosing more than one card with a pointer.
|
||||||
|
/// </remarks>
|
||||||
|
private void WireTheBand()
|
||||||
|
{
|
||||||
|
// Esc and Ctrl+A, on the board rather than on the window: both are ordinary editing keys that mean
|
||||||
|
// something else everywhere else, and Ctrl+A in the find box above has to go on selecting the text
|
||||||
|
// in it. The board takes focus on a press, so the keys work from the moment anything has been
|
||||||
|
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
|
||||||
|
Board.KeyDown += OnGridKey;
|
||||||
|
|
||||||
HostGrid.PointerMoved += OnPointerMoved;
|
// ◆ The band is the scroller's rather than any one section list's, because the space it is dragged
|
||||||
HostGrid.PointerReleased += OnPointerReleased;
|
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
|
||||||
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
|
// between sections and below the last one belong to the stack around them. Tunnelled for the reason
|
||||||
|
// the board's own press is, and it runs first, so it has to recognise a press on a card and leave it
|
||||||
// The host grid is where a drag starts and the group cards are where it lands. They used to be the
|
// alone.
|
||||||
// same control: the target was a heading among the cards, and with the headings gone the group cards
|
Scroll.AddHandler(PointerPressedEvent, OnBandPressed, RoutingStrategies.Tunnel);
|
||||||
// are the only thing on this screen that names a group. A card dropped onto another card is refused
|
Scroll.PointerMoved += OnBandMoved;
|
||||||
// rather than filed beside it — in a grid with no headings there is nothing to say which group that
|
Scroll.PointerReleased += OnBandReleased;
|
||||||
// would be, and a gesture whose result you cannot see before you let go is one that files machines
|
Scroll.PointerCaptureLost += OnBandCaptureLost;
|
||||||
// somewhere the user did not intend.
|
|
||||||
DragDrop.AddDragOverHandler(GroupGrid, OnDragOver);
|
|
||||||
DragDrop.AddDragLeaveHandler(GroupGrid, OnDragLeave);
|
|
||||||
DragDrop.AddDropHandler(GroupGrid, OnDrop);
|
|
||||||
|
|
||||||
// Everywhere else the pointer can be during a drag, and it is a handler rather than the absence of
|
|
||||||
// one because AllowDrop is an inherited property: it is set on the scroller so that a drag anywhere
|
|
||||||
// over the grid is reported at all, and that makes every card inside it a drop target as far as the
|
|
||||||
// platform is concerned. This is where all of them but a group card are turned down — and where a
|
|
||||||
// drag held at the top or bottom edge pulls the grid towards the target.
|
|
||||||
DragDrop.AddDragOverHandler(Scroll, OnDragOverScroll);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -140,15 +150,13 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// It has to be a control the keyboard can actually go to. <c>Focus()</c> on a collapsed control is
|
/// It has to be a control the keyboard can actually go to. <c>Focus()</c> on a collapsed control is
|
||||||
/// measurably a no-op and is not replayed when the control is revealed, so handing the keyboard to
|
/// measurably a no-op and is not replayed when the control is revealed, so handing the keyboard to
|
||||||
/// something that is not there would swallow it: the terminal would let go and nothing would take it.
|
/// something that is not there would swallow it. <c>Board</c> is focusable and holds every section, so it
|
||||||
/// The grid no longer folds away as the sidebar's list could, but an empty grid is still a
|
/// answers as long as one card is on it; an empty board is what a filter matching nothing produces, and
|
||||||
/// <c>ListBox</c> with no item to take focus — and an empty grid is exactly what a filter that matches
|
/// the find box is the answer then — it is where they were typing.
|
||||||
/// nothing produces, which is a state somebody typing is very likely to be in. The find box is the
|
|
||||||
/// answer then, and it is a good one: it is where they were typing.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal IInputElement KeyboardTarget =>
|
internal IInputElement KeyboardTarget =>
|
||||||
Vault is { HasVisibleHosts: true } ? HostGrid : HostFilter;
|
Vault is { HasHostBoardEntries: true } ? Board : HostFilter;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
|
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
|
||||||
@@ -157,45 +165,35 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnHostActivated(object? sender, TappedEventArgs e)
|
private void OnHostActivated(object? sender, TappedEventArgs e)
|
||||||
{
|
{
|
||||||
// Only over a card. A double-tap on the space around them must not connect to whichever host was
|
// Only over a card. A double-tap on the space around them, or on a section heading, must not connect
|
||||||
// selected before — which is what an unguarded handler would do, on a machine the user is not even
|
// to whichever host was selected before — which is what an unguarded handler would do, on a machine
|
||||||
// pointing at.
|
// the user is not even pointing at.
|
||||||
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
|
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
|
||||||
{
|
{
|
||||||
_ = vault.ConnectCommand.ExecuteAsync(null);
|
_ = vault.ConnectCommand.ExecuteAsync(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens the group card that was double-clicked.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Guarded over the space around the cards exactly as the host grid's is, and it is the same mistake
|
|
||||||
/// being guarded against: an unguarded handler would open whichever group happened to be selected when
|
|
||||||
/// somebody double-clicked the gap beside it, throwing every host outside that group off the screen.
|
|
||||||
/// </remarks>
|
|
||||||
private void OnGroupActivated(object? sender, TappedEventArgs e)
|
|
||||||
{
|
|
||||||
if (Vault is { } vault && RowUnder(e.Source) is HostGroupRowViewModel group)
|
|
||||||
{
|
|
||||||
vault.OpenGroupCommand.Execute(group);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Points the menu at whatever was right-clicked.
|
/// Points the menu at whatever was right-clicked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The menu's three commands all read the vault's host selection, and a right click does not move it —
|
/// The menu's commands read the vault's host selection, and a right click does not move it — which would
|
||||||
/// which would mean a menu that quietly acted on whichever host happened to be selected instead of the
|
/// mean a menu that quietly acted on whichever host happened to be selected instead of the one under the
|
||||||
/// one under the pointer. Deleting the wrong machine is the version of that mistake worth designing
|
/// pointer. Deleting the wrong machine is the version of that mistake worth designing against.
|
||||||
/// against.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Cancelled outright over the space around the cards. That is not a host, and a menu offering Connect,
|
/// ◆ <b>A right click outside the ticked set drops the set, and one inside it keeps it.</b> That is the
|
||||||
/// Edit and Delete over it would be three buttons that either do nothing or act on something else
|
/// same rule as the plain press below, and it is what makes the two halves of the menu safe to draw from
|
||||||
/// entirely.
|
/// one markup: the entries about a set and the entries about a selection are never both meaningful, so
|
||||||
|
/// Delete… can never be a question about the card under the pointer asked while six others sit ticked
|
||||||
|
/// behind the menu.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Cancelled outright over the space around the cards, or on a section heading. Neither is a host, and a
|
||||||
|
/// menu offering Connect, Edit and Delete over either would be entries that either do nothing or act on
|
||||||
|
/// something else entirely.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||||
@@ -206,281 +204,303 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
vault.SelectedSidebarRow = row;
|
if (vault.IsChoosingHosts && !row.IsChosen)
|
||||||
|
{
|
||||||
|
vault.ClearHostChoiceCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vault.IsChoosingHosts)
|
||||||
|
{
|
||||||
|
vault.SelectedHost = row;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Points the group menu at whatever was right-clicked.
|
/// Ticks cards, or moves the selection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The host grid's rule, applied to the cards above it — see <see cref="OnContextRequested"/>. What is
|
/// ◆ <b>Ctrl and Shift are answered here and go no further.</b> Both mark the event handled, which is
|
||||||
/// different is what an unaimed menu would have done: <c>GroupTarget</c> falls back to the open group
|
/// what stops whichever section's <c>ListBox</c> the press landed in from moving its own selection onto
|
||||||
/// when no card is selected, so Edit and Delete over a card would have been offered about the group whose
|
/// the card: a Ctrl-click that also selected would light the card it had just unticked, and the drawer
|
||||||
/// contents are showing rather than the one the pointer is on. This menu is the only way to either of
|
/// would open on a machine the user was removing from a set. Handled on the way down is the only place
|
||||||
/// them now, so aiming it is the whole of aiming them.
|
/// that can be said — by the time the press bubbles the control has already answered it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Cancelled outright over the space around the cards, as the host grid's is. That is not a group, and
|
/// <b>A plain press moves the selection and drops the chosen set</b>, which is every file manager's rule
|
||||||
/// the fallback is exactly what would make the menu look like it worked there.
|
/// and the reason the two selections on this screen can never disagree: after an ordinary click there is
|
||||||
|
/// exactly one card in play. Not marked handled — the section list's own native selection is left to
|
||||||
|
/// follow along, which is harmless now that it is only <c>OneWay</c> bound: whatever it shows locally is
|
||||||
|
/// overwritten the moment <see cref="VaultViewModel.SelectedHost"/>'s change reaches every section again.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Nothing here refuses while the drawer's editor is open, and the phone's own <c>ChooseHost</c> does.
|
||||||
|
/// The difference is real: there a sheet is over the list and the row under the finger is not what was
|
||||||
|
/// aimed at, where the board sits beside the editor in plain view. Ticking is free anyway — the seven
|
||||||
|
/// things that can then be done to a set each refuse for themselves, and say so.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
|
||||||
private void OnGroupContextRequested(object? sender, ContextRequestedEventArgs e)
|
|
||||||
{
|
|
||||||
if (Vault is not { } vault || RowUnder(e.Source) is not HostGroupRowViewModel row)
|
|
||||||
{
|
|
||||||
e.Handled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
vault.SelectedGroup = row;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// Remembered rather than acted on. Whether this press is a click or the start of a drag is not known
|
|
||||||
/// until the pointer moves, so this is the point at which both are still possible.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
press = null;
|
if (Vault is not { } vault
|
||||||
pickedUp = null;
|
|| !e.GetCurrentPoint(Board).Properties.IsLeftButtonPressed
|
||||||
|
|
||||||
if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed
|
|
||||||
|| RowUnder(e.Source) is not HostRowViewModel row)
|
|| RowUnder(e.Source) is not HostRowViewModel row)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
press = e;
|
var control = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
||||||
pickedUp = row;
|
|
||||||
origin = e.GetPosition(HostGrid);
|
if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||||
|
{
|
||||||
|
vault.ChooseHostRun(anchor ?? vault.SelectedHost, row, replacing: !control);
|
||||||
|
|
||||||
|
Board.Focus();
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (control)
|
||||||
|
{
|
||||||
|
vault.ToggleHostChoiceCommand.Execute(row);
|
||||||
|
anchor = row;
|
||||||
|
|
||||||
|
Board.Focus();
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor = row;
|
||||||
|
|
||||||
|
if (vault.IsChoosingHosts)
|
||||||
|
{
|
||||||
|
vault.ClearHostChoiceCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.SelectedHost = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Esc drops the set, and Ctrl+A is every card on the board.
|
||||||
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The drag is started from the remembered press once the pointer has travelled far enough — see
|
/// <para>
|
||||||
/// <see cref="DragThreshold"/>. Fire-and-forget, because the drag loop runs for as long as the user holds
|
/// Every card <em>on the board</em>: <see cref="VaultViewModel.HostBoardOrder"/>, which is every section
|
||||||
/// the button and an event handler cannot wait on that; what happens after it is only clearing the mark.
|
/// with whatever the find box and the two toolbar flyouts have already narrowed it to, and every card a
|
||||||
|
/// fold has not hidden. Ctrl+A over a filtered board that quietly ticked machines it is not showing would
|
||||||
|
/// be the worst possible input to Delete.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Handled only when they did something. Esc has other jobs on this window — it closes the palette, and
|
||||||
|
/// it cancels the terminal's own things — and swallowing it here while nothing is ticked would take it
|
||||||
|
/// away from whichever of those the user meant.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
private void OnGridKey(object? sender, KeyEventArgs e)
|
||||||
{
|
{
|
||||||
if (press is not { } pressed || pickedUp is not { } row)
|
if (Vault is not { } vault)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed)
|
if (e.Key is Key.Escape && vault.IsChoosingHosts)
|
||||||
{
|
{
|
||||||
Forget();
|
vault.ClearHostChoiceCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var moved = e.GetPosition(HostGrid) - origin;
|
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
|
||||||
|
|
||||||
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
|
|
||||||
{
|
{
|
||||||
return;
|
var order = vault.HostBoardOrder.ToList();
|
||||||
}
|
|
||||||
|
|
||||||
Forget();
|
vault.ChooseHosts(order, replacing: true);
|
||||||
|
anchor = order[0];
|
||||||
_ = DragAsync(pressed, row);
|
e.Handled = true;
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => Forget();
|
|
||||||
|
|
||||||
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();
|
|
||||||
|
|
||||||
/// <summary>Carries one host for as long as the user holds it.</summary>
|
|
||||||
private async Task DragAsync(PointerPressedEventArgs pressed, HostRowViewModel row)
|
|
||||||
{
|
|
||||||
var carried = new DataTransfer();
|
|
||||||
carried.Add(DataTransferItem.Create(HostFormat, row));
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// ConfigureAwait(true): what follows touches the grid's own containers, and those are the UI
|
|
||||||
// thread's.
|
|
||||||
await DragDrop
|
|
||||||
.DoDragDropAsync(pressed, carried, DragDropEffects.Move)
|
|
||||||
.ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// Whatever the drop did or did not do. A mark left behind would be a card that looks like a
|
|
||||||
// target for a drag that ended somewhere else entirely.
|
|
||||||
Unmark();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Says whether what is under the pointer would take this host, and marks it if it would.
|
/// Starts a band on the empty space between and below the cards.
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// A host over the card of the group it is already in is refused, which is not pedantry:
|
|
||||||
/// <c>DragDropEffects.None</c> is what turns the cursor into the "no" one, and a drag that looks like it
|
|
||||||
/// would do something and then does nothing is worse than one that says so while it is still in the air.
|
|
||||||
/// </remarks>
|
|
||||||
private void OnDragOver(object? sender, DragEventArgs e)
|
|
||||||
{
|
|
||||||
e.Handled = true;
|
|
||||||
|
|
||||||
if (Target(e) is not { } target)
|
|
||||||
{
|
|
||||||
e.DragEffects = DragDropEffects.None;
|
|
||||||
Unmark();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.DragEffects = DragDropEffects.Move;
|
|
||||||
Mark(target.Container);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Carries the grid under a drag that is over the cards rather than over a group.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Without this the gesture is only available to whoever can see both ends of it.</b> The group cards
|
/// ◆ <b>Only where there is nothing to press.</b> The scroller holds every section heading, every card
|
||||||
/// are the first thing in the scrolling stack and the host being filed may be the fortieth card down, and
|
/// list and whichever panel the set's menu raised, and a rectangle dragged out from inside any of those
|
||||||
/// a drag cannot use the wheel — the pointer button is held. So a drag held near the top edge pulls the
|
/// would be a gesture aimed at hosts started on something that is not one. <see cref="TakesThePress"/> is
|
||||||
/// grid down towards the target, which is what every file manager does with a drag near the edge of a
|
/// the guard, and it is ancestor-based rather than naming one control, which is what lets it work
|
||||||
/// list.
|
/// regardless of how many section lists happen to be realized.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A step per event rather than a timer, and that is a real limit rather than a simplification: a
|
/// The pointer is captured, because a band is nearly always dragged past the edge of the control it
|
||||||
/// stationary pointer receives no drag events on any platform this runs on, so the scroll follows the
|
/// started in — down onto the status bar, or off the window entirely — and without capture the moves
|
||||||
/// pointer moving inside the band and stops when it stops. A timer would scroll on its own and would then
|
/// stop arriving and the release lands somewhere else, leaving a rectangle painted over the board with
|
||||||
/// need cancelling on the drop, on the leave, and on the drag that ends outside the window entirely.
|
/// nothing left to take it down.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Reached only when the group cards did not handle the event first, which is what makes refusing the
|
/// Nothing is ticked or unticked here. A press is not yet a band and may never become one; what a click
|
||||||
/// drop here correct: the space around the cards is not a target, and saying so keeps the "no" cursor on
|
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
|
||||||
/// everything that is not a group.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnDragOverScroll(object? sender, DragEventArgs e)
|
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.DataTransfer.TryGetValue(HostFormat) is null)
|
EndBand();
|
||||||
|
|
||||||
|
if (Vault is null
|
||||||
|
|| !e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed
|
||||||
|
|| TakesThePress(e.Source))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
e.Handled = true;
|
banding = true;
|
||||||
e.DragEffects = DragDropEffects.None;
|
bandFrom = e.GetPosition(Scroll);
|
||||||
Unmark();
|
bandAdds = e.KeyModifiers.HasFlag(KeyModifiers.Control);
|
||||||
|
|
||||||
var at = e.GetPosition(Scroll).Y;
|
e.Pointer.Capture(Scroll);
|
||||||
var height = Scroll.Bounds.Height;
|
Board.Focus();
|
||||||
|
|
||||||
var step = at switch
|
|
||||||
{
|
|
||||||
_ when at < EdgeBand => -EdgeStep,
|
|
||||||
_ when at > height - EdgeBand => EdgeStep,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (step == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var furthest = Math.Max(0, Scroll.Extent.Height - Scroll.Viewport.Height);
|
|
||||||
|
|
||||||
Scroll.Offset = Scroll.Offset.WithY(Math.Clamp(Scroll.Offset.Y + step, 0, furthest));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
|
/// The rectangle is drawn and the set is rewritten on every move, so what is ticked is what the band is
|
||||||
/// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
|
/// over at that moment — including the cards it has just been pulled back off. A band that only ever
|
||||||
/// task nothing observes.
|
/// added would make overshooting it unrecoverable without starting again. Ctrl is the exception and is
|
||||||
|
/// the reason it is a mode read once: with it held the band adds to what was already ticked, which is how
|
||||||
|
/// a second run is picked up without losing the first.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnDrop(object? sender, DragEventArgs e)
|
private void OnBandMoved(object? sender, PointerEventArgs e)
|
||||||
{
|
{
|
||||||
e.Handled = true;
|
if (!banding || Vault is not { } vault)
|
||||||
Unmark();
|
|
||||||
|
|
||||||
if (Vault is not { } vault || Target(e) is not { } target)
|
|
||||||
{
|
{
|
||||||
e.DragEffects = DragDropEffects.None;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
e.DragEffects = DragDropEffects.Move;
|
if (!e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed)
|
||||||
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Host, target.GroupId));
|
{
|
||||||
|
EndBand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var at = e.GetPosition(Scroll);
|
||||||
|
|
||||||
|
var box = new Rect(
|
||||||
|
Math.Min(bandFrom.X, at.X),
|
||||||
|
Math.Min(bandFrom.Y, at.Y),
|
||||||
|
Math.Abs(at.X - bandFrom.X),
|
||||||
|
Math.Abs(at.Y - bandFrom.Y));
|
||||||
|
|
||||||
|
if (box.Width < DragThreshold && box.Height < DragThreshold)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Band.Margin = new Thickness(box.X, box.Y, 0, 0);
|
||||||
|
Band.Width = box.Width;
|
||||||
|
Band.Height = box.Height;
|
||||||
|
Band.IsVisible = true;
|
||||||
|
|
||||||
|
vault.ChooseHosts(CardsIn(box), replacing: !bandAdds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Where a drag currently is, or null if it is over nothing that would take it.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// A press and a release with no band between them is a click on the space around the cards, and that
|
||||||
/// One kind of target: a group card. It is the control that already answers "which group", it is drawn
|
/// drops the set — the same thing a plain click on a card does, and the way out of selection mode for
|
||||||
/// at the top of the screen where a drag can reach it from anywhere in the grid, and what it does when
|
/// anybody who never finds Esc. Where a band <em>was</em> drawn the moves have already said what is
|
||||||
/// dropped on is what it says on it. The space around the cards takes nothing.
|
/// ticked, and re-applying it here would only repeat the last one.
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// A group the vault no longer has is read as no group at all, which is what the list already does with
|
|
||||||
/// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
|
|
||||||
/// rather than here, so the rule has one home.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static DropTarget? Target(DragEventArgs e)
|
private void OnBandReleased(object? sender, PointerReleasedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
|
if (!banding)
|
||||||
|| Container(e.Source) is not { DataContext: HostGroupRowViewModel group } container
|
|
||||||
|| dragged.Host.GroupId == group.EntityId)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new DropTarget(dragged, group.EntityId, container);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Mark(ListBoxItem container)
|
|
||||||
{
|
|
||||||
if (ReferenceEquals(marked, container))
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Unmark();
|
var drawn = Band.IsVisible;
|
||||||
|
|
||||||
marked = container;
|
EndBand();
|
||||||
marked.Classes.Add("droptarget");
|
e.Pointer.Capture(null);
|
||||||
}
|
|
||||||
|
|
||||||
private void Unmark()
|
if (!drawn && Vault is { IsChoosingHosts: true } vault)
|
||||||
{
|
{
|
||||||
marked?.Classes.Remove("droptarget");
|
vault.ClearHostChoiceCommand.Execute(null);
|
||||||
marked = null;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Lets go of a press that turned out not to be a drag, or has become one.</summary>
|
/// <remarks>
|
||||||
private void Forget()
|
/// The band is taken down and nothing is decided. Capture is lost when a menu opens over the drag, when
|
||||||
|
/// the window is deactivated, and when the platform simply takes it — none of which is the user saying
|
||||||
|
/// what they wanted; leaving the rectangle up would be the only visible consequence.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnBandCaptureLost(object? sender, PointerCaptureLostEventArgs e) => EndBand();
|
||||||
|
|
||||||
|
private void EndBand()
|
||||||
{
|
{
|
||||||
press = null;
|
banding = false;
|
||||||
pickedUp = null;
|
Band.IsVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The view model of the grid item an event happened on, if it happened on one.</summary>
|
/// <summary>The hosts whose cards a rectangle over the scroller touches.</summary>
|
||||||
private static object? RowUnder(object? source) => Container(source)?.DataContext;
|
/// <remarks>
|
||||||
|
/// Touches rather than contains, which is what makes a band usable at all: a rectangle that had to
|
||||||
|
/// swallow a card whole would tick nothing for a band drawn down the middle of a column. Gathered from
|
||||||
|
/// every section's own list — see <see cref="AllCardContainers"/> — rather than one named control, since
|
||||||
|
/// a band drawn across two sections has to touch cards in both.
|
||||||
|
/// </remarks>
|
||||||
|
private List<HostRowViewModel> CardsIn(Rect box)
|
||||||
|
{
|
||||||
|
var hit = new List<HostRowViewModel>();
|
||||||
|
|
||||||
|
foreach (var container in AllCardContainers())
|
||||||
|
{
|
||||||
|
if (container.DataContext is HostRowViewModel row
|
||||||
|
&& container.TranslatePoint(default, Scroll) is { } corner
|
||||||
|
&& box.Intersects(new Rect(corner, container.Bounds.Size)))
|
||||||
|
{
|
||||||
|
hit.Add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every realized card, across every section's own list.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A section is a <c>ListBox</c> of its own — see the type's remarks — so there is no single control
|
||||||
|
/// whose <c>GetRealizedContainers()</c> would answer for the whole board. Found by class rather than by
|
||||||
|
/// name, because how many sections exist is a fact about the keychain rather than the markup: a flat
|
||||||
|
/// keychain draws one, a filed one draws several, and neither shape is known until the vault says so.
|
||||||
|
/// </remarks>
|
||||||
|
private List<ListBoxItem> AllCardContainers() =>
|
||||||
|
[.. this.GetVisualDescendants()
|
||||||
|
.OfType<ListBox>()
|
||||||
|
.Where(list => list.Classes.Contains("sectioncards"))
|
||||||
|
.SelectMany(list => list.GetRealizedContainers())
|
||||||
|
.OfType<ListBoxItem>()];
|
||||||
|
|
||||||
|
/// <summary>Whether what was pressed is something that answers a press itself.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything a band must not start from, asked as "is this inside one" rather than by hit-testing a
|
||||||
|
/// rectangle: the cards, the scrollbar, and the controls on whichever panel is up or in a section's own
|
||||||
|
/// heading. Missing the scrollbar is the one that would be felt every day — dragging the thumb would
|
||||||
|
/// paint a band down the board and tick everything it passed.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool TakesThePress(object? source) => source is Visual visual
|
||||||
|
&& visual.GetSelfAndVisualAncestors().Any(element =>
|
||||||
|
element is ListBoxItem or ScrollBar or Button or ComboBox or TextBox);
|
||||||
|
|
||||||
|
/// <summary>The view model of the grid item an event happened on, if it happened on a host card.</summary>
|
||||||
|
private static HostRowViewModel? RowUnder(object? source) =>
|
||||||
|
Container(source)?.DataContext as HostRowViewModel;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Walks up from whatever was actually hit — a text block, a border, the card's own grid — because that
|
/// Walks up from whatever was actually hit — a text block, a border, the card's own grid — because that
|
||||||
/// is what an event's source is. Anything not inside an item, which is the space around the cards,
|
/// is what an event's source is. Anything not inside a card, which includes a section's own heading and
|
||||||
/// yields null.
|
/// the space around them, yields null.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static ListBoxItem? Container(object? source) => source is Visual visual
|
private static ListBoxItem? Container(object? source) => source is Visual visual
|
||||||
? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
|
? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
/// <summary>A drag in flight, and where it would land.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The group is a <see cref="Guid"/> rather than a nullable one, which it was while the ungrouped
|
|
||||||
/// heading was also a target. Every target is now a group card and every group card has an id; the way
|
|
||||||
/// out of a group is the host's own editor, which is the one place "no group" can be said in words.
|
|
||||||
/// </remarks>
|
|
||||||
private sealed record DropTarget(HostRowViewModel Host, Guid GroupId, ListBoxItem Container);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
forty entries for machines that stopped existing years ago. So scanning writes nothing and the list
|
forty entries for machines that stopped existing years ago. So scanning writes nothing and the list
|
||||||
says what each entry means; importing is a separate press on a set somebody has looked at.
|
says what each entry means; importing is a separate press on a set somebody has looked at.
|
||||||
|
|
||||||
Reachable from the preferences screen and not from the nav rail. It is a task rather than a
|
v5c-3: restyled into Import.dc.html's own table over SettingsView's content column — SettingsNav stays
|
||||||
destination — done once, or once a year — and a seventh rail entry would cost every screen a slot for
|
lit on Preferences while this is up, and the titlebar says "Back to preferences"; see
|
||||||
something almost nobody is looking at.
|
MainWindowViewModel.IsImportOpen. No longer reachable from the nav rail, exactly as before: it is a task
|
||||||
|
done once or once a year, reached from the Preferences page's own "OPEN IMPORTER" row.
|
||||||
|
|
||||||
── ◆ THE ONE TICK THAT READS PRIVATE KEYS ─────────────────────────────────────────────────────────────
|
── ◆ THE ONE TICK THAT READS PRIVATE KEYS ─────────────────────────────────────────────────────────────
|
||||||
Below the list, off, and drawn only where the scan actually found an IdentityFile. It is the only control
|
Below the list, off, and drawn only where the scan actually found an IdentityFile. It is the only control
|
||||||
@@ -26,144 +27,212 @@
|
|||||||
What comes back afterwards is the report under the list: one line per key file, saying which were stored,
|
What comes back afterwards is the report under the list: one line per key file, saying which were stored,
|
||||||
which are protected by a passphrase this cannot know, and which were not there at all. That is reported
|
which are protected by a passphrase this cannot know, and which were not there at all. That is reported
|
||||||
rather than previewed for the same reason — previewing would mean reading them.
|
rather than previewed for the same reason — previewing would mean reading them.
|
||||||
|
|
||||||
|
── ◆ WHAT THIS MEANS ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
One chip per row rather than the old separate AUTHENTICATION/STATE columns, mapped off the two facts a
|
||||||
|
row actually carries: ImportRowViewModel.AlreadyPresent and HasWarnings. A skipped Host pattern (a
|
||||||
|
wildcard block) never becomes a row at all — see SshConfigImport.SkippedPatterns — so there is no third,
|
||||||
|
"skipped" state to draw here; a warned row is the amber case instead, and it wins over "already here"
|
||||||
|
because the warning is the more actionable of the two facts. See ImportRowViewModel.Meaning.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
|
<UserControl.Styles>
|
||||||
|
<!-- The row's own hover fill, matching the design's per-row style-hover — Track, the same as every other table this application draws. -->
|
||||||
|
<Style Selector="Border.importrow:pointerover">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.new">
|
||||||
|
<Setter Property="Background" Value="{StaticResource LiveWash}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.new > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.exists">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.exists > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.warn">
|
||||||
|
<Setter Property="Background" Value="{StaticResource WarnWash}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.meaningchip.warn > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||||
|
</Style>
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
<Grid MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch" RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
<!-- ============ HEADER ============ -->
|
||||||
<TextBlock Grid.Column="0" Classes="mono" Text="IMPORT SSH CONFIG" FontSize="12"
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
<StackPanel Grid.Column="0" Spacing="10">
|
||||||
VerticalAlignment="Center" />
|
<TextBlock Classes="settingstitle" Text="Import SSH config" />
|
||||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ConfigPath}" FontSize="10.5"
|
<TextBlock Classes="mono" Text="{Binding HeaderStatus}" FontSize="12"
|
||||||
Foreground="{StaticResource TextFaint}" Margin="10,0" VerticalAlignment="Center"
|
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
|
||||||
TextTrimming="CharacterEllipsis" />
|
</StackPanel>
|
||||||
<Button Grid.Column="2" Classes="ghost" Content="SCAN" Command="{Binding ScanCommand}"
|
<Button Grid.Column="1" Classes="ghost" Height="40" VerticalAlignment="Top" Content="SCAN AGAIN"
|
||||||
IsEnabled="{Binding !IsBusy}"
|
Command="{Binding ScanCommand}" IsEnabled="{Binding !IsBusy}"
|
||||||
ToolTip.Tip="Reads the file and shows what it found. Nothing is stored." />
|
ToolTip.Tip="Reads the file again and shows what it found. Nothing is stored." />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="14,12,14,0"
|
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="0,10,0,0"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
What could not be honoured, above the list rather than beside it. Every one of these is a way the
|
What could not be honoured, at document level. Every one of these is a way the import is quieter than
|
||||||
import is quieter than the file — an ignored Match block, a dropped ProxyCommand — and a person
|
the file — an ignored Match block, a dropped ProxyCommand — and a person comparing the two needs to be
|
||||||
comparing the two needs to be told before they conclude the parser lost something.
|
told before they conclude the parser lost something.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="2" Margin="14,12,14,0" Padding="10,8" CornerRadius="4"
|
<Border Grid.Row="2" Margin="0,14,0,0" Padding="14,10" CornerRadius="10"
|
||||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||||
BorderThickness="1" IsVisible="{Binding HasWarnings}">
|
BorderThickness="1" IsVisible="{Binding HasWarnings}">
|
||||||
<ItemsControl ItemsSource="{Binding Warnings}">
|
<ItemsControl ItemsSource="{Binding Warnings}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="x:String">
|
<DataTemplate x:DataType="x:String">
|
||||||
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11"
|
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11.5"
|
||||||
TextWrapping="Wrap" Margin="0,2" />
|
TextWrapping="Wrap" Margin="0,2" />
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Grid Grid.Row="3" RowDefinitions="Auto,*" Margin="0,12,0,0" IsVisible="{Binding HasRows}">
|
<!-- ============ THE TABLE ============ -->
|
||||||
|
<Border Grid.Row="3" Margin="0,18,0,0" CornerRadius="12" Background="{StaticResource Pane}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" ClipToBounds="True"
|
||||||
|
IsVisible="{Binding HasRows}">
|
||||||
|
<Grid RowDefinitions="Auto,*">
|
||||||
|
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="14,0,14,6">
|
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1" />
|
BorderThickness="0,0,0,1">
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
|
<Grid ColumnDefinitions="24,1*,1.4*,0.7*,0.5*,1.3*">
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="AUTHENTICATION" FontSize="9.5" LetterSpacing="1" />
|
<!--
|
||||||
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
|
The header tick-all box. A plain Button rather than a CheckBox — ImportRowViewModel's own
|
||||||
|
ToggleAllCommand is "tick everything, or untick everything" in one press, which is a command
|
||||||
|
rather than a two-way bound bool, and a CheckBox has no Command of its own to hang that on.
|
||||||
|
-->
|
||||||
|
<Button Grid.Column="0" Classes="flat" Width="18" Height="18" Padding="0"
|
||||||
|
VerticalAlignment="Center" Command="{Binding ToggleAllCommand}"
|
||||||
|
ToolTip.Tip="Tick or untick every row">
|
||||||
|
<Panel Width="18" Height="18">
|
||||||
|
<Border CornerRadius="5" Background="{StaticResource Accent}" IsVisible="{Binding AllTicked}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="13"
|
||||||
|
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<Border CornerRadius="5" BorderBrush="{StaticResource BorderMid}" BorderThickness="1.5"
|
||||||
|
IsVisible="{Binding !AllTicked}" />
|
||||||
|
</Panel>
|
||||||
|
</Button>
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" Text="ALIAS" FontSize="9.5" LetterSpacing="1"
|
||||||
|
Margin="14,0,0,0" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="label" Text="HOSTNAME" FontSize="9.5" LetterSpacing="1" />
|
||||||
|
<TextBlock Grid.Column="3" Classes="label" Text="USER" FontSize="9.5" LetterSpacing="1" />
|
||||||
|
<TextBlock Grid.Column="4" Classes="label" Text="PORT" FontSize="9.5" LetterSpacing="1" />
|
||||||
|
<TextBlock Grid.Column="5" Classes="label" Text="WHAT THIS MEANS" FontSize="9.5" LetterSpacing="1" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
<ScrollViewer Grid.Row="1">
|
||||||
<ItemsControl ItemsSource="{Binding Rows}">
|
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
||||||
<StackPanel Margin="14,0">
|
<Border Classes="importrow" CornerRadius="8">
|
||||||
<Grid ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="0,7">
|
<Grid ColumnDefinitions="24,1*,1.4*,0.7*,0.5*,1.3*" Margin="12" MinHeight="20">
|
||||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
|
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="12"
|
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="12.5"
|
||||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
|
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="14,0,10,0"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||||
|
ToolTip.Tip="{Binding Authentication}" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Hostname}" FontSize="11.5"
|
||||||
|
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="10.5"
|
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding User}" FontSize="11.5"
|
||||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Authentication}" FontSize="10.5"
|
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Port}" FontSize="11.5"
|
||||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
<Border Grid.Column="5" Classes="meaningchip" CornerRadius="5" Padding="8,3"
|
||||||
<Border Grid.Column="4" Classes="chip" HorizontalAlignment="Left"
|
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||||
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
|
Classes.new="{Binding IsMeaningNew}" Classes.exists="{Binding IsMeaningExisting}"
|
||||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
Classes.warn="{Binding IsMeaningWarned}" ToolTip.Tip="{Binding Meaning}">
|
||||||
|
<TextBlock Text="{Binding Meaning}" FontSize="10" FontWeight="SemiBold"
|
||||||
|
MaxWidth="230" TextTrimming="CharacterEllipsis" />
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Classes="hint" Text="{Binding Warnings}" FontSize="10.5" Margin="34,0,0,8"
|
</Border>
|
||||||
TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
|
||||||
IsVisible="{Binding HasWarnings}" />
|
|
||||||
</StackPanel>
|
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Border Grid.Row="4" Padding="14,10" Background="{StaticResource Panel}"
|
</Grid>
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
</Border>
|
||||||
IsVisible="{Binding HasRows}">
|
|
||||||
<StackPanel Spacing="8">
|
<!-- ============ FOOTER ============ -->
|
||||||
|
<StackPanel Grid.Row="4" Margin="0,16,0,0" Spacing="16" IsVisible="{Binding HasRows}">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Said before the button, not after. Whether the key material comes with the host is the difference
|
Said before the button, not after. Whether the key material comes with the host is the difference
|
||||||
between a bookmark that connects and one that asks for a password, and somebody who is not told
|
between a bookmark that connects and one that asks for a password, and somebody who is not told
|
||||||
will conclude the import was broken.
|
will conclude the import was broken.
|
||||||
-->
|
-->
|
||||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap" IsVisible="{Binding !ImportsKeys}"
|
||||||
IsVisible="{Binding !ImportsKeys}"
|
|
||||||
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain." />
|
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain." />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ THE TICK. Hidden entirely where the scan found no IdentityFile anywhere — an offer to read ~/.ssh
|
◆ THE OPT-IN CARD. Hidden entirely where the scan found no IdentityFile anywhere — an offer to read
|
||||||
on a screen where it would read nothing is a control that teaches people to ignore it.
|
~/.ssh on a screen where it would read nothing is a control that teaches people to ignore it.
|
||||||
|
|
||||||
The warning sentence appears only when it is on, and it is the one place this application says out
|
|
||||||
loud that it is about to open private keys. It names the directory rather than saying "your keys",
|
|
||||||
because what somebody is agreeing to is a read of that directory.
|
|
||||||
-->
|
-->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding HasKeyFiles}">
|
<Border CornerRadius="12" Background="{StaticResource Raised}" BorderBrush="{StaticResource WarnSoft}"
|
||||||
<CheckBox IsChecked="{Binding ImportsKeys}">
|
BorderThickness="1" Padding="18,16" IsVisible="{Binding HasKeyFiles}">
|
||||||
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||||
Text="Also import the private keys these hosts point at" />
|
<CheckBox VerticalAlignment="Top" Margin="0,3,0,0" IsChecked="{Binding ImportsKeys}" />
|
||||||
</CheckBox>
|
<StackPanel Spacing="6">
|
||||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
<TextBlock Text="Also store the private keys these entries point at" FontSize="13.5"
|
||||||
IsVisible="{Binding ImportsKeys}" Foreground="{StaticResource WarnText}"
|
FontWeight="SemiBold" Foreground="{StaticResource Text}" />
|
||||||
|
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
|
||||||
|
Text="{Binding KeyMaterialIntro}" />
|
||||||
|
<!--
|
||||||
|
The warning sentence appears only when the tick is on, and it is the one place this application
|
||||||
|
says out loud that it is about to open private keys.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" IsVisible="{Binding ImportsKeys}"
|
||||||
|
Foreground="{StaticResource WarnText}"
|
||||||
Text="Pressing IMPORT will read each host's first IdentityFile out of ~/.ssh, store it in this vault encrypted, and bind the host to it. One key is stored per file however many hosts name it, and a file already in your keychain is bound to rather than stored twice. A key protected by a passphrase comes in without one — nothing on disk says what it is — and the report below will name it." />
|
Text="Pressing IMPORT will read each host's first IdentityFile out of ~/.ssh, store it in this vault encrypted, and bind the host to it. One key is stored per file however many hosts name it, and a file already in your keychain is bound to rather than stored twice. A key protected by a passphrase comes in without one — nothing on disk says what it is — and the report below will name it." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<Button Classes="accent" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
|
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
|
||||||
<Button Classes="ghost" Content="TICK ALL / NONE" Command="{Binding ToggleAllCommand}" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ What became of each key file, after the fact. Below the button because it is the answer rather
|
◆ What became of each key file, after the fact. Capped with a scroll viewer because a config with
|
||||||
than the offer, and capped with a scroll viewer because a config with thirty keyed hosts would
|
thirty keyed hosts would otherwise push IMPORT off the window — the one control this screen must
|
||||||
otherwise push IMPORT off the window — the one control this screen must never lose.
|
never lose.
|
||||||
-->
|
-->
|
||||||
<Border IsVisible="{Binding HasKeyReport}" Padding="10,8" CornerRadius="4"
|
<Border IsVisible="{Binding HasKeyReport}" Padding="12,10" CornerRadius="10"
|
||||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}" BorderThickness="1">
|
||||||
BorderThickness="1">
|
|
||||||
<ScrollViewer MaxHeight="120">
|
<ScrollViewer MaxHeight="120">
|
||||||
<ItemsControl ItemsSource="{Binding KeyReport}">
|
<ItemsControl ItemsSource="{Binding KeyReport}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="x:String">
|
<DataTemplate x:DataType="x:String">
|
||||||
<TextBlock Text="{Binding}" Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
<TextBlock Text="{Binding}" Classes="hint" FontSize="10.5" TextWrapping="Wrap" Margin="0,2" />
|
||||||
Margin="0,2" />
|
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- "N of M entries selected · saving to {vault}", Cancel, Import N hosts. -->
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="hint" FontSize="12" VerticalAlignment="Center"
|
||||||
|
Text="{Binding SelectionSummary}" />
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
|
||||||
|
<Button Classes="ghost" Height="44" Content="CANCEL" Command="{Binding CancelCommand}"
|
||||||
|
ToolTip.Tip="Back to Preferences. Nothing is stored." />
|
||||||
|
<Button Classes="accent" Height="44" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
@@ -10,7 +9,11 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
/// Importing hosts from <c>~/.ssh/config</c>.
|
/// Importing hosts from <c>~/.ssh/config</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A task rather than a destination, which is why it is reached from preferences and not from the nav rail.
|
/// A task rather than a destination, which is why it is reached from Preferences rather than from the nav
|
||||||
|
/// rail. v5c-3 moved it inside settings mode as an overlay over the Preferences page — it no longer has a
|
||||||
|
/// keyboard target of its own to hand back: <c>MainWindow.axaml.cs</c>'s <c>KeyboardHome</c> falls back to
|
||||||
|
/// the window for settings mode as a whole, the same way it already does for the account and logs screens,
|
||||||
|
/// so a <c>KeyboardTarget</c> property here would be dead code nothing reads.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class ImportScreen : UserControl
|
internal sealed partial class ImportScreen : UserControl
|
||||||
{
|
{
|
||||||
@@ -24,9 +27,6 @@ internal sealed partial class ImportScreen : UserControl
|
|||||||
AddHandler(ToggleButton.IsCheckedChangedEvent, OnTickChanged, RoutingStrategies.Bubble);
|
AddHandler(ToggleButton.IsCheckedChangedEvent, OnTickChanged, RoutingStrategies.Bubble);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
|
||||||
internal IInputElement KeyboardTarget => this;
|
|
||||||
|
|
||||||
private void OnTickChanged(object? sender, RoutedEventArgs e)
|
private void OnTickChanged(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is ImportViewModel import)
|
if (DataContext is ImportViewModel import)
|
||||||
|
|||||||
@@ -26,17 +26,117 @@
|
|||||||
when somebody is in a team — but it is still not a selector, because every table on this screen already
|
when somebody is in a team — but it is still not a selector, because every table on this screen already
|
||||||
spans every keychain this session holds a key for and each row names its own. What it carries instead is
|
spans every keychain this session holds a key for and each row names its own. What it carries instead is
|
||||||
the one keychain question with an answer: where a new item is filed.
|
the one keychain question with an answer: where a new item is filed.
|
||||||
|
|
||||||
|
── v5b — Keychain.dc.html ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
A fidelity pass over the shape above, not a new one. What moved:
|
||||||
|
|
||||||
|
◆ THE HEADER. "Keychain" 33 bold, a count chip, and on the right a "Host keys" ghost button — the
|
||||||
|
design's own doorway to the pins screen, wired to the shell's existing ShowScreenCommand via
|
||||||
|
$parent[Window] since this screen's own DataContext is the vault rather than the shell — and one
|
||||||
|
"+ New key" accent button rather than the five GENERATE / + SSH KEY / + PASSWORD / + TAG / + BUCKET
|
||||||
|
buttons the toolbar used to spread across the table's own header. ◆ DECIDED DEVIATION: the design draws
|
||||||
|
one button because its mock has one "add" concept; this application has five, and folding five capabilities
|
||||||
|
into one visible button without losing any of them means the button opens a menu naming all five, in the
|
||||||
|
same order the old toolbar had them, rather than guessing which one the design's single button "really"
|
||||||
|
meant. GENERATE keeps its own tooltip; the strip's own essay comment about why it lost the word KEY is now
|
||||||
|
moot — the width pressure that produced it left with the buttons.
|
||||||
|
|
||||||
|
◆ THE RAIL. 200px, Sidebar-bg, KEYCHAIN tracked label, category rows restyled to Button.cat's own idiom
|
||||||
|
(unchanged binding, new width). Below the divider: SCOPES stays — this session's current vault, named,
|
||||||
|
since that fact is real and the design's mock is a single-vault sample with nothing to show it — and then
|
||||||
|
NEW ITEMS FILE TO, the design's own wording for the picker this screen already had as "NEW ITEMS GO TO".
|
||||||
|
Adopted rather than kept: nothing in this file's own essay comments ever defended "GO TO" over "FILE TO",
|
||||||
|
and the comment above the picker already says "where a new item is filed" — the design's word was this
|
||||||
|
screen's own vocabulary already.
|
||||||
|
|
||||||
|
◆ THE TABLE. A 44px sub-toolbar (the section summary, left; a 240px filter box, right — this table had no
|
||||||
|
filter box before, and the design's has one) and tracked column headers. The design's own five are
|
||||||
|
NAME/TYPE/VAULT/USED BY/MODIFIED; MODIFIED is dropped — VaultItem carries no timestamp of any kind (id,
|
||||||
|
secret, version, three sync flags — see docs/design-import-gaps.md) — and USED BY is real for a key or a
|
||||||
|
credential (which hosts authenticate with it, the same fact VaultViewModel.HostsBoundTo already computes
|
||||||
|
for the deletion warning) and empty for a tag (its own host count already covers the same ground) or a
|
||||||
|
bucket (nothing in this codebase resolves a host's authentication to an object store). Rows: a type glyph,
|
||||||
|
the mono name, the type word, and the vault chip — VaultItemRowViewModel.VaultBadge, empty except where
|
||||||
|
more than one vault is in play, the same convention every other list in this application follows.
|
||||||
|
|
||||||
|
◆ THE DETAIL PANE, 300px. PUBLIC KEY draws when the selected key has one stored. FINGERPRINT is not
|
||||||
|
drawn at all — this codebase has never computed one; see docs/design-import-gaps.md's own recorded gap,
|
||||||
|
"no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim."
|
||||||
|
USED BY draws real host rows — the same HostsBoundTo scan, with each host's own two-state dot — only for
|
||||||
|
a key or a credential, and only while at least one host actually authenticates with the selected item; the
|
||||||
|
"in use · N hosts" chip beside the vault chip is gated the same way. The action buttons keep their
|
||||||
|
existing commands and their existing honesty: COPY PUBLIC KEY only for a key, MOVE only where
|
||||||
|
CanMoveSelectedItem says there is somewhere to move to, DELETE always. "Choose something on the left…"
|
||||||
|
stays as the empty state, restyled.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid ColumnDefinitions="176,*,244">
|
<Grid RowDefinitions="Auto,*" Margin="26">
|
||||||
|
|
||||||
<!-- Categories and scopes -->
|
<!-- ============ THE HEADER ============ -->
|
||||||
|
<Grid Grid.Row="0" Margin="0,0,0,20" ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="0" Text="Keychain" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
|
||||||
|
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<Border Grid.Column="1" Margin="12,0,0,0" MinWidth="34" Height="30" CornerRadius="9" Padding="8,0"
|
||||||
|
Background="{StaticResource Chip}" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" Text="{Binding TotalItemCount}" FontSize="12.5"
|
||||||
|
Foreground="{StaticResource TextDim}" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ "Host keys", to the pins screen. $parent[Window] is what reaches the shell from here: this
|
||||||
|
control's own DataContext is the vault, not MainWindowViewModel, and Window is the nearest ancestor
|
||||||
|
whose DataContext is the shell — see MainWindow.axaml, which sets this screen's DataContext to
|
||||||
|
{Binding Vault} rather than putting IsKeychainScreen and the vault on one element.
|
||||||
|
-->
|
||||||
|
<Button Grid.Column="3" Classes="ghost" Height="40" Margin="0,0,10,0"
|
||||||
|
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
|
||||||
|
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
||||||
|
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text=""
|
||||||
|
Foreground="{StaticResource TextFaint}" />
|
||||||
|
<TextBlock Text="Host keys" FontSize="13.5" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource TextDim}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ "+ New key": one button standing in for the five this screen can still do. See the file-level
|
||||||
|
remark above for why a menu rather than a guess at which one the design meant.
|
||||||
|
-->
|
||||||
|
<Button Grid.Column="4" Classes="accent" Height="40" FontSize="13.5" Content="+ New key">
|
||||||
|
<Button.Flyout>
|
||||||
|
<MenuFlyout Placement="BottomEdgeAlignedRight">
|
||||||
|
<MenuItem Header="Generate a key…" Command="{Binding NewGeneratedKeyCommand}"
|
||||||
|
ToolTip.Tip="Makes a new key pair here, so the private half never becomes a file on this disk." />
|
||||||
|
<MenuItem Header="Add SSH key…" Command="{Binding NewKeyCommand}"
|
||||||
|
ToolTip.Tip="Pastes in a key you already have." />
|
||||||
|
<MenuItem Header="Add password…" Command="{Binding NewCredentialCommand}" />
|
||||||
|
<MenuItem Header="Add tag…" Command="{Binding NewTagCommand}"
|
||||||
|
ToolTip.Tip="A name to put on hosts. Usually made from a host's editor instead; this is for setting a scheme up before there is anything to put it on." />
|
||||||
|
<MenuItem Header="Add bucket…" Command="{Binding NewObjectStoreCommand}"
|
||||||
|
ToolTip.Tip="An S3-compatible bucket, to browse beside a host on the Files screen." />
|
||||||
|
</MenuFlyout>
|
||||||
|
</Button.Flyout>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ============ THE BORDERED BODY ============ -->
|
||||||
|
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<Grid ColumnDefinitions="200,*,300">
|
||||||
|
|
||||||
|
<!-- ============ Categories and scopes ============ -->
|
||||||
<Border Grid.Column="0" Background="{StaticResource Sidebar}"
|
<Border Grid.Column="0" Background="{StaticResource Sidebar}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Margin="0,12">
|
<StackPanel Margin="10,16">
|
||||||
|
|
||||||
<TextBlock Classes="label" Text="KEYCHAIN" Margin="14,0,14,8" />
|
<TextBlock Classes="label" Text="KEYCHAIN" Margin="12,0,12,10" FontSize="10" />
|
||||||
|
|
||||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||||
CommandParameter="{x:Static vm:VaultSection.All}"
|
CommandParameter="{x:Static vm:VaultSection.All}"
|
||||||
@@ -44,7 +144,7 @@
|
|||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<Border Grid.Column="0" Classes="rowmark catmark" />
|
<Border Grid.Column="0" Classes="rowmark catmark" />
|
||||||
<TextBlock Grid.Column="1" Text="ALL" Margin="12,0,0,0" />
|
<TextBlock Grid.Column="1" Text="ALL" Margin="12,0,0,0" />
|
||||||
<TextBlock Grid.Column="2" Text="{Binding TotalItemCount}"
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding TotalItemCount}"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
Foreground="{StaticResource TextFaint}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -55,7 +155,7 @@
|
|||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<Border Grid.Column="0" Classes="rowmark catmark" />
|
<Border Grid.Column="0" Classes="rowmark catmark" />
|
||||||
<TextBlock Grid.Column="1" Text="SSH KEYS" Margin="12,0,0,0" />
|
<TextBlock Grid.Column="1" Text="SSH KEYS" Margin="12,0,0,0" />
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Keys.Count}"
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Keys.Count}"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
Foreground="{StaticResource TextFaint}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -66,21 +166,21 @@
|
|||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<Border Grid.Column="0" Classes="rowmark catmark" />
|
<Border Grid.Column="0" Classes="rowmark catmark" />
|
||||||
<TextBlock Grid.Column="1" Text="PASSWORDS" Margin="12,0,0,0" />
|
<TextBlock Grid.Column="1" Text="PASSWORDS" Margin="12,0,0,0" />
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Credentials.Count}"
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Credentials.Count}"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
Foreground="{StaticResource TextFaint}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Buckets. A category here rather than a screen of its own, unlike the approved host keys: a bucket
|
Buckets. A category here rather than a screen of its own, unlike the approved host keys: a
|
||||||
is something somebody creates, edits and keeps a secret for, which is what the other two
|
bucket is something somebody creates, edits and keeps a secret for, which is what the other
|
||||||
categories are. A pin is a decision recorded at connect time and is not.
|
two categories are. A pin is a decision recorded at connect time and is not.
|
||||||
-->
|
-->
|
||||||
<!--
|
<!--
|
||||||
Tags. The odd category: it is the only one holding nothing secret — a tag is a name. It is here
|
Tags. The odd category: it is the only one holding nothing secret — a tag is a name. It is
|
||||||
because the reason a tag is an item at all is that renaming it should be one write instead of
|
here because the reason a tag is an item at all is that renaming it should be one write
|
||||||
twenty, and a rename needs somewhere to happen; so does deleting, or the host editor's picker
|
instead of twenty, and a rename needs somewhere to happen; so does deleting, or the host
|
||||||
fills with names nobody uses and never empties.
|
editor's picker fills with names nobody uses and never empties.
|
||||||
-->
|
-->
|
||||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||||
CommandParameter="{x:Static vm:VaultSection.Tags}"
|
CommandParameter="{x:Static vm:VaultSection.Tags}"
|
||||||
@@ -88,7 +188,7 @@
|
|||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<Border Grid.Column="0" Classes="rowmark catmark" />
|
<Border Grid.Column="0" Classes="rowmark catmark" />
|
||||||
<TextBlock Grid.Column="1" Text="TAGS" Margin="12,0,0,0" />
|
<TextBlock Grid.Column="1" Text="TAGS" Margin="12,0,0,0" />
|
||||||
<TextBlock Grid.Column="2" Text="{Binding Tags.Count}"
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Tags.Count}"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
Foreground="{StaticResource TextFaint}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -99,33 +199,35 @@
|
|||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<Border Grid.Column="0" Classes="rowmark catmark" />
|
<Border Grid.Column="0" Classes="rowmark catmark" />
|
||||||
<TextBlock Grid.Column="1" Text="BUCKETS" Margin="12,0,0,0" />
|
<TextBlock Grid.Column="1" Text="BUCKETS" Margin="12,0,0,0" />
|
||||||
<TextBlock Grid.Column="2" Text="{Binding ObjectStores.Count}"
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding ObjectStores.Count}"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
Foreground="{StaticResource TextFaint}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="14,10" />
|
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="12,12" />
|
||||||
|
|
||||||
<TextBlock Classes="label" Text="SCOPES" Margin="14,0,14,8" />
|
<TextBlock Classes="label" Text="SCOPES" Margin="12,0,12,8" FontSize="10" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Still not a selector. Every list on this screen now spans every vault this session holds a key
|
Still not a selector. Every list on this screen now spans every vault this session holds a
|
||||||
for, and each row names its own vault — so there is nothing to switch to. What the picker below
|
key for, and each row names its own vault — so there is nothing to switch to. What the
|
||||||
chooses is where a *new* item is filed, which is a different question and the only one that has
|
picker below chooses is where a *new* item is filed, which is a different question and the
|
||||||
an answer worth asking for.
|
only one that has an answer worth asking for.
|
||||||
-->
|
-->
|
||||||
<StackPanel Orientation="Horizontal" Margin="14,2" Spacing="7">
|
<StackPanel Orientation="Horizontal" Margin="12,2" Spacing="7">
|
||||||
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
|
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||||
<TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="11"
|
<TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="11"
|
||||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Hidden at one vault, which is where most people stay. A control offering a single option is a
|
Hidden at one vault, which is where most people stay. A control offering a single option is
|
||||||
question with no answer.
|
a question with no answer. "NEW ITEMS FILE TO" — the design's own wording, adopted: this
|
||||||
|
screen's own comments already call the act "filing" (see above), so the design's label was
|
||||||
|
this application's own vocabulary already.
|
||||||
-->
|
-->
|
||||||
<StackPanel Margin="14,10,14,0" Spacing="4" IsVisible="{Binding HasVaultChoice}">
|
<StackPanel Margin="12,14,12,0" Spacing="4" IsVisible="{Binding HasVaultChoice}">
|
||||||
<TextBlock Classes="label" Text="NEW ITEMS GO TO" />
|
<TextBlock Classes="label" Text="NEW ITEMS FILE TO" FontSize="10" />
|
||||||
<ComboBox ItemsSource="{Binding TargetVaults}"
|
<ComboBox ItemsSource="{Binding TargetVaults}"
|
||||||
SelectedItem="{Binding SelectedTargetVault}"
|
SelectedItem="{Binding SelectedTargetVault}"
|
||||||
HorizontalAlignment="Stretch">
|
HorizontalAlignment="Stretch">
|
||||||
@@ -140,11 +242,11 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Items that would not decrypt. Shown here rather than only in the status line because this is the
|
Items that would not decrypt. Shown here rather than only in the status line because this is
|
||||||
screen the number is about, and because a non-zero count after a rekey is the signal that new
|
the screen the number is about, and because a non-zero count after a rekey is the signal that
|
||||||
grants are needed rather than a transient.
|
new grants are needed rather than a transient.
|
||||||
-->
|
-->
|
||||||
<Border Classes="chip warn" Margin="14,12,14,0" HorizontalAlignment="Left"
|
<Border Classes="chip warn" Margin="12,14,12,0" HorizontalAlignment="Left"
|
||||||
IsVisible="{Binding HasUnreadableItems}">
|
IsVisible="{Binding HasUnreadableItems}">
|
||||||
<TextBlock Text="{Binding UnreadableSummary}" />
|
<TextBlock Text="{Binding UnreadableSummary}" />
|
||||||
</Border>
|
</Border>
|
||||||
@@ -153,99 +255,67 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- The table -->
|
<!-- ============ The table ============ -->
|
||||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*">
|
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}">
|
||||||
|
|
||||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
<Border Grid.Row="0" Padding="16,0" Height="44"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||||
<!--
|
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||||
◆ THE SUMMARY IS THE COLUMN THAT GIVES WAY, and that is what stops this header overflowing again.
|
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SectionSummary}" FontSize="10.5"
|
||||||
|
Foreground="{StaticResource TextFaint}" Margin="0,0,10,0" VerticalAlignment="Center"
|
||||||
It used to be Auto,Auto,*,Auto — a fixed title, a fixed summary, slack, then the buttons — so the
|
|
||||||
slack column was the only thing absorbing a change of width, and the strip fell off the right edge
|
|
||||||
the moment the five buttons wanted more than it had. That is not hypothetical: it is why GENERATE
|
|
||||||
lost the word KEY (see below), and it happened again the moment the type scale went up a point.
|
|
||||||
Buying pixels by shortening a caption fixes one instance of a shape that keeps producing them.
|
|
||||||
|
|
||||||
So the summary sits in the star column and trims, and the buttons are Auto and always get their
|
|
||||||
full width. That is the rule worth encoding rather than the pixels: a trimmed summary is a fact
|
|
||||||
you can read by widening the window, and a clipped button is a dead end. The titlebar's search box
|
|
||||||
is arranged this way for the same reason.
|
|
||||||
-->
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
|
||||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SectionTitle}" FontSize="12"
|
|
||||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
|
||||||
VerticalAlignment="Center" />
|
|
||||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding SectionSummary}" FontSize="10.5"
|
|
||||||
Foreground="{StaticResource TextFaint}" Margin="10,0,10,0" VerticalAlignment="Center"
|
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
<TextBox Grid.Column="1" x:Name="ItemFilterBox" Text="{Binding ItemFilter}" Width="240"
|
||||||
<!--
|
Height="30" CornerRadius="9" PlaceholderText="filter items"
|
||||||
Always offered. Every category left on this screen is one things can be added to — the one
|
VerticalAlignment="Center" />
|
||||||
that was not, HOST KEYS, is now its own screen, and a pin still cannot be typed in there
|
|
||||||
either. See KnownHostsScreen.
|
|
||||||
|
|
||||||
GENERATE carries no KEY, which it lost when a fifth button arrived and the strip could still
|
|
||||||
overflow. The arrangement above is what keeps it inside now; the short caption stays because
|
|
||||||
its tooltip carries what the word did and the two key buttons are adjacent, so which one
|
|
||||||
generates is not in doubt.
|
|
||||||
-->
|
|
||||||
<Button Classes="ghost" Content="GENERATE" Command="{Binding NewGeneratedKeyCommand}"
|
|
||||||
ToolTip.Tip="Makes a new key pair here, so the private half never becomes a file on this disk." />
|
|
||||||
<Button Classes="ghost" Content="+ SSH KEY" Command="{Binding NewKeyCommand}"
|
|
||||||
ToolTip.Tip="Pastes in a key you already have." />
|
|
||||||
<Button Classes="ghost" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}" />
|
|
||||||
<Button Classes="ghost" Content="+ TAG" Command="{Binding NewTagCommand}"
|
|
||||||
ToolTip.Tip="A name to put on hosts. Usually made from a host's editor instead; this is for setting a scheme up before there is anything to put it on." />
|
|
||||||
<Button Classes="accent" Content="+ BUCKET" Command="{Binding NewObjectStoreCommand}"
|
|
||||||
ToolTip.Tip="An S3-compatible bucket, to browse beside a host on the Files screen." />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The design's columns are NAME / TYPE / FINGERPRINT / SCOPE / ACCESS / LAST. Three of those six have
|
The design's columns are NAME / TYPE / VAULT / USED BY / MODIFIED. MODIFIED is not here — no
|
||||||
nothing behind them: there is one scope, no roles, and no item carries a last-used or modified time —
|
item this application stores carries a timestamp; see the file-level remark above and
|
||||||
VaultItem is (id, secret, version, three sync flags) and nothing else. What replaces them is the one
|
docs/design-import-gaps.md.
|
||||||
thing this build does know and the design had no column for: whether a change is still sitting in
|
|
||||||
this machine's outbox.
|
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="2,1.3*,74,*,88" Margin="0,6,14,6"
|
<Grid Grid.Row="1" ColumnDefinitions="2,2.2*,1*,1*,1.4*" Margin="0,10,16,6"
|
||||||
IsVisible="{Binding HasVaultItems}">
|
IsVisible="{Binding HasVaultItems}">
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
|
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
|
||||||
Margin="12,0,8,0" />
|
Margin="12,0,8,0" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="DETAIL" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1" />
|
||||||
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ListBox Grid.Row="2" x:Name="ItemList" Focusable="True"
|
<ListBox Grid.Row="2" x:Name="ItemList" Classes="filerows" Focusable="True"
|
||||||
ItemsSource="{Binding VaultItems}"
|
ItemsSource="{Binding VaultItems}"
|
||||||
SelectedItem="{Binding SelectedVaultItem}">
|
SelectedItem="{Binding SelectedVaultItem}"
|
||||||
|
Margin="8,0,8,10">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||||||
<Grid ColumnDefinitions="2,1.3*,74,*,88" Margin="0,7,14,7">
|
<Grid ColumnDefinitions="2,2.2*,1*,1*,1.4*" Height="40" Margin="6,0,10,0">
|
||||||
<Border Grid.Column="0" Classes="rowmark" />
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" Margin="10,0,8,0"
|
||||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12"
|
VerticalAlignment="Center">
|
||||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||||
TextTrimming="CharacterEllipsis" />
|
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
|
||||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10"
|
VerticalAlignment="Center" />
|
||||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="12.5" FontWeight="Medium"
|
||||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Detail}" FontSize="10.5"
|
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
</StackPanel>
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
|
||||||
<Border Grid.Column="4" Classes="chip warn" HorizontalAlignment="Left"
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||||
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
|
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left"
|
||||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
|
||||||
|
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
|
||||||
</Border>
|
</Border>
|
||||||
|
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding UsedBySummary}" FontSize="10.5"
|
||||||
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasUsedBySummary}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The empty state says which category is empty and what to do about it, rather than showing an empty
|
The empty state says which category is empty and what to do about it, rather than showing an
|
||||||
grid that reads as a list still loading.
|
empty grid that reads as a list still loading.
|
||||||
-->
|
-->
|
||||||
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptySectionMessage}" FontSize="12"
|
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptySectionMessage}" FontSize="12"
|
||||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
@@ -254,71 +324,158 @@
|
|||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- The detail pane, and the editors -->
|
<!-- ============ The detail pane, and the editors ============ -->
|
||||||
<Border Grid.Column="2" Background="{StaticResource Sidebar}"
|
<Border Grid.Column="2" Background="{StaticResource Sidebar}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Margin="14,16">
|
<StackPanel Margin="18,20" Spacing="14">
|
||||||
|
|
||||||
<!-- Nothing selected. -->
|
<!-- Nothing selected. -->
|
||||||
<TextBlock Classes="hint" FontSize="12"
|
<TextBlock Classes="hint" FontSize="12"
|
||||||
Text="Choose something on the left to see what is known about it."
|
Text="Choose something on the left to see what is known about it."
|
||||||
IsVisible="{Binding !HasSelectedVaultItem}" />
|
IsVisible="{Binding !HasSelectedVaultItem}" />
|
||||||
|
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding HasSelectedVaultItem}">
|
<StackPanel Spacing="14" IsVisible="{Binding HasSelectedVaultItem}">
|
||||||
<TextBlock Classes="mono" Text="{Binding SelectedVaultItem.Name}" FontSize="13"
|
|
||||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||||
|
<Border Width="36" Height="36" CornerRadius="10" Background="{StaticResource AccentWash}"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="17"
|
||||||
|
Text="{Binding SelectedVaultItem.IconGlyph}"
|
||||||
|
Foreground="{StaticResource AccentText}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel Spacing="4" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" Text="{Binding SelectedVaultItem.Name}" FontSize="13.5"
|
||||||
|
FontWeight="Bold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Classes="mono" Text="{Binding SelectedVaultItem.Detail}" FontSize="10.5"
|
||||||
|
Foreground="{StaticResource TextGhost}" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Border Classes="chip">
|
<Border Classes="chip">
|
||||||
<TextBlock Text="{Binding SelectedVaultItem.Type}" />
|
<TextBlock Text="{Binding SelectedVaultItem.Type}" />
|
||||||
</Border>
|
</Border>
|
||||||
<Border Classes="chip accent">
|
<!--
|
||||||
<TextBlock Text="{Binding HostsHeading}" />
|
"in use · N hosts" — real only for a key or a credential with at least one host actually
|
||||||
|
bound to it, off the same VaultViewModel.HostsBoundTo scan the USED BY list below reads.
|
||||||
|
-->
|
||||||
|
<Border CornerRadius="5" Background="{StaticResource LiveWash}" Padding="9,0" Height="21"
|
||||||
|
VerticalAlignment="Center" IsVisible="{Binding HasSelectedItemInUseSummary}">
|
||||||
|
<TextBlock Text="{Binding SelectedItemInUseSummary}" FontSize="11" FontWeight="Medium"
|
||||||
|
Foreground="{StaticResource Live}" VerticalAlignment="Center" />
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="label" Text="{Binding SelectedDetailHeading}" Margin="0,12,0,4" />
|
<!--
|
||||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
PUBLIC KEY, when the selected key has one stored — never the private half. FINGERPRINT is
|
||||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
not drawn: this codebase has never computed one, and computing it here would mean parsing
|
||||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedVaultItem.Detail}"
|
armour the type stores verbatim; see docs/design-import-gaps.md's own recorded gap.
|
||||||
FontSize="10.5" Foreground="{StaticResource TextDim}"
|
-->
|
||||||
TextWrapping="Wrap" />
|
<StackPanel Spacing="6" IsVisible="{Binding SelectedItemIsKey}">
|
||||||
|
<TextBlock Classes="label" Text="PUBLIC KEY" FontSize="10" />
|
||||||
|
<Border CornerRadius="10" Background="{StaticResource Pane}"
|
||||||
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" Padding="12,10"
|
||||||
|
IsVisible="{Binding SelectedKey.Key.PublicKey, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}">
|
||||||
|
<SelectableTextBlock Classes="mono" Text="{Binding SelectedKey.Key.PublicKey}"
|
||||||
|
FontSize="10.5" LineHeight="17"
|
||||||
|
Foreground="{StaticResource TextGhost}" TextWrapping="Wrap" />
|
||||||
</Border>
|
</Border>
|
||||||
|
<TextBlock Classes="hint" FontSize="10.5"
|
||||||
|
Text="No public half is stored for this key."
|
||||||
|
IsVisible="{Binding !SelectedKey.Key.PublicKey, FallbackValue=False}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
What the design puts here — who added it, when, who it is shared with, and a TEST CONNECT
|
USED BY: real host rows, off the same scan the "in use" chip above reads, with each host's
|
||||||
button — has nothing behind it. Items carry no author, no timestamps and no sharing, and
|
own two-state dot — never a third colour, since nothing here pings anything.
|
||||||
nothing can exercise a credential without a host to exercise it against. Rather than five
|
|
||||||
empty rows, this says what is missing in one line.
|
|
||||||
-->
|
-->
|
||||||
<TextBlock Classes="hint" FontSize="10.5" Margin="0,12,0,0"
|
<StackPanel Spacing="6" IsVisible="{Binding HasSelectedItemUsedByHosts}">
|
||||||
Text="Keychain items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
|
<TextBlock Classes="label" Text="USED BY" FontSize="10" />
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<ItemsControl ItemsSource="{Binding SelectedItemUsedByHosts}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:UsedByHostRowViewModel">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="9" Height="28">
|
||||||
|
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
<!--
|
||||||
IsVisible="{Binding ShowsItemActions}">
|
What the design puts here beyond what is above — who added it, when, and a TEST CONNECT
|
||||||
|
button — has nothing behind it: items carry no author or timestamp, and nothing can
|
||||||
|
exercise a credential without a host to exercise it against.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="hint" FontSize="10.5"
|
||||||
|
Text="Keychain items record no author or timestamp yet, so there is nothing more to show here." />
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding ShowsItemActions}">
|
||||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
|
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
|
||||||
|
<!--
|
||||||
|
Only where there is somewhere to move to, unlike EDIT beside it, which is the same rule
|
||||||
|
the host's MOVE follows on the phone: a button that answers with "this is the only vault
|
||||||
|
you can write to" is a button that should not have been drawn. Keys and passwords only —
|
||||||
|
a tag and a bucket are read from the active vault alone, so "another vault" is not a
|
||||||
|
question they have.
|
||||||
|
-->
|
||||||
|
<Button Classes="ghost" Content="MOVE" Command="{Binding MoveSelectedItemCommand}"
|
||||||
|
IsVisible="{Binding CanMoveSelectedItem}"
|
||||||
|
ToolTip.Tip="Re-encrypts this under another vault's key, and re-aims every host and group that used it at where it has gone." />
|
||||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The public half only, and there is no button for the other one. Installing a key means pasting
|
The public half only, and there is no button for the other one. Installing a key means
|
||||||
this line into a host's authorized_keys; a private key on the clipboard is a private key in
|
pasting this line into a host's authorized_keys; a private key on the clipboard is a
|
||||||
every application on the machine.
|
private key in every application on the machine.
|
||||||
-->
|
-->
|
||||||
<Button Classes="ghost" Content="COPY PUBLIC KEY" Margin="0,6,0,0"
|
<Button Classes="ghost" Content="Copy public key" HorizontalAlignment="Left"
|
||||||
HorizontalAlignment="Left"
|
|
||||||
IsVisible="{Binding SelectedItemIsKey}"
|
IsVisible="{Binding SelectedItemIsKey}"
|
||||||
Command="{Binding CopyPublicKeyCommand}"
|
Command="{Binding CopyPublicKeyCommand}"
|
||||||
ToolTip.Tip="Copies the authorized_keys line for this key, which is what a host needs to let it in." />
|
ToolTip.Tip="Copies the authorized_keys line for this key, which is what a host needs to let it in." />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The question DELETE asks, in the place those two buttons were. Here rather than over the
|
◆ MOVING THE ITEM TO ANOTHER VAULT, in the place those buttons were. See HostDrawer.axaml —
|
||||||
screen, because this pane is where the item being deleted is described: the name, the kind and
|
the thing a shared vault could not do until now: a key typed into a personal vault before
|
||||||
what is stored are all still on screen above it, which is most of what somebody checks before
|
the team existed was stuck there, and the only way across was to paste the private half
|
||||||
answering. See ConfirmDeleteCard.
|
into a second item and delete the first.
|
||||||
|
-->
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding IsMovingItem}">
|
||||||
|
<TextBlock Classes="label" Text="MOVE TO VAULT" FontSize="10" />
|
||||||
|
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveItemVaultChoices}"
|
||||||
|
SelectedItem="{Binding SelectedMoveItemVault}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Classes="hint" FontSize="10.5" 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="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding HasMovingItemUsage}"
|
||||||
|
Text="{Binding MovingItemUsage}" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveItemCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveItemCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The question DELETE asks, in the place the buttons above were. Here rather than over the
|
||||||
|
screen, because this pane is where the item being deleted is described.
|
||||||
-->
|
-->
|
||||||
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||||
BorderThickness="1" CornerRadius="4" Padding="10" Margin="0,14,0,0"
|
BorderThickness="1" CornerRadius="10" Padding="12"
|
||||||
IsVisible="{Binding IsConfirmingDeletion}">
|
IsVisible="{Binding IsConfirmingDeletion}">
|
||||||
<views:ConfirmDeleteCard />
|
<views:ConfirmDeleteCard />
|
||||||
</Border>
|
</Border>
|
||||||
@@ -326,19 +483,13 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Making a key, as opposed to pasting one in. A step of its own and a short one: an algorithm, a
|
Making a key, as opposed to pasting one in. A step of its own and a short one: an algorithm,
|
||||||
comment, and a button. What it produces lands in the editor below, unsaved — so there is still
|
a comment, and a button.
|
||||||
exactly one thing on this screen that writes a key, and it is still SAVE.
|
|
||||||
-->
|
-->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding IsGeneratingKey}">
|
<StackPanel Spacing="6" IsVisible="{Binding IsGeneratingKey}">
|
||||||
<TextBlock Classes="label" Text="NEW SSH KEY" Margin="0,0,0,4" />
|
<TextBlock Classes="label" Text="NEW SSH KEY" FontSize="10" />
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<!--
|
|
||||||
Buttons and a command rather than a selector bound to the algorithm, which is the same
|
|
||||||
choice the category rail makes and for the same reason: a selector moves its own highlight
|
|
||||||
before anything can refuse, so it can end up showing a choice nobody made.
|
|
||||||
-->
|
|
||||||
<Button Classes="flat choice" Content="ED25519"
|
<Button Classes="flat choice" Content="ED25519"
|
||||||
Classes.active="{Binding GeneratesEd25519}"
|
Classes.active="{Binding GeneratesEd25519}"
|
||||||
Command="{Binding ChooseKeyAlgorithmCommand}"
|
Command="{Binding ChooseKeyAlgorithmCommand}"
|
||||||
@@ -355,11 +506,6 @@
|
|||||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||||
Text="This is what the key is called here and what is written into it, so the line on a host says where it came from." />
|
Text="This is what the key is called here and what is written into it, so the line on a host says where it came from." />
|
||||||
|
|
||||||
<!--
|
|
||||||
Said plainly rather than left to be discovered. Writing an encrypted openssh-key-v1 file needs
|
|
||||||
bcrypt_pbkdf, which .NET has no primitive for — and the defence it buys is one this product
|
|
||||||
already makes: a passphrase protects a key file on a disk, and this key is never on one.
|
|
||||||
-->
|
|
||||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" Margin="0,4,0,0"
|
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||||
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read." />
|
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read." />
|
||||||
|
|
||||||
@@ -372,12 +518,12 @@
|
|||||||
|
|
||||||
<!-- The key editor. -->
|
<!-- The key editor. -->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditingKey}">
|
<StackPanel Spacing="6" IsVisible="{Binding IsEditingKey}">
|
||||||
<TextBlock Classes="label" Text="SSH KEY" Margin="0,0,0,4" />
|
<TextBlock Classes="label" Text="SSH KEY" FontSize="10" />
|
||||||
<TextBox Text="{Binding KeyEditorLabel}" PlaceholderText="name" />
|
<TextBox Text="{Binding KeyEditorLabel}" PlaceholderText="name" />
|
||||||
<!--
|
<!--
|
||||||
Not a password box. The armour has to be visible to be pasted and checked — a masked
|
Not a password box. The armour has to be visible to be pasted and checked — a masked
|
||||||
multi-line box makes "did the whole key arrive?" unanswerable — and the mistake this actually
|
multi-line box makes "did the whole key arrive?" unanswerable — and the mistake this
|
||||||
prevents is pasting the .pub file, which SshKeySecret.TryValidate rejects by name.
|
actually prevents is pasting the .pub file, which SshKeySecret.TryValidate rejects by name.
|
||||||
-->
|
-->
|
||||||
<TextBox Text="{Binding KeyEditorPrivateKey}"
|
<TextBox Text="{Binding KeyEditorPrivateKey}"
|
||||||
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
|
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||||
@@ -398,19 +544,19 @@
|
|||||||
|
|
||||||
<!-- The password editor. -->
|
<!-- The password editor. -->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditingCredential}">
|
<StackPanel Spacing="6" IsVisible="{Binding IsEditingCredential}">
|
||||||
<TextBlock Classes="label" Text="PASSWORD" Margin="0,0,0,4" />
|
<TextBlock Classes="label" Text="PASSWORD" FontSize="10" />
|
||||||
<TextBox Text="{Binding CredentialEditorLabel}" PlaceholderText="name" />
|
<TextBox Text="{Binding CredentialEditorLabel}" PlaceholderText="name" />
|
||||||
<!--
|
<!--
|
||||||
Optional, and the reason a credential is worth being its own item rather than two more fields on
|
Optional, and the reason a credential is worth being its own item rather than two more
|
||||||
a host: one account on twenty machines is described once and rotated once. Left blank, each host
|
fields on a host: one account on twenty machines is described once and rotated once. Left
|
||||||
supplies its own username and only the password is shared.
|
blank, each host supplies its own username and only the password is shared.
|
||||||
-->
|
-->
|
||||||
<TextBox Text="{Binding CredentialEditorUsername}"
|
<TextBox Text="{Binding CredentialEditorUsername}"
|
||||||
PlaceholderText="username (blank: use each host's own)" />
|
PlaceholderText="username (blank: use each host's own)" />
|
||||||
<!--
|
<!--
|
||||||
Masked, unlike the private key box, and the difference is not inconsistency. A key's armour has
|
Masked, unlike the private key box, and the difference is not inconsistency. A key's armour
|
||||||
to be visible to be checked for truncation after a paste; a password is short, usually typed,
|
has to be visible to be checked for truncation after a paste; a password is short, usually
|
||||||
and shoulder-surfing is the likelier problem.
|
typed, and shoulder-surfing is the likelier problem.
|
||||||
-->
|
-->
|
||||||
<TextBox Text="{Binding CredentialEditorPassword}" PlaceholderText="password" PasswordChar="•" />
|
<TextBox Text="{Binding CredentialEditorPassword}" PlaceholderText="password" PasswordChar="•" />
|
||||||
<TextBox Text="{Binding CredentialEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
<TextBox Text="{Binding CredentialEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||||
@@ -424,12 +570,12 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The tag editor, and the whole of it is one box. What it does not have is the point: renaming a
|
The tag editor, and the whole of it is one box. What it does not have is the point: renaming
|
||||||
tag touches no host, because every host wearing it names its id. That is the entire reason a tag
|
a tag touches no host, because every host wearing it names its id. That is the entire reason
|
||||||
is an item rather than a string repeated inside twenty payloads.
|
a tag is an item rather than a string repeated inside twenty payloads.
|
||||||
-->
|
-->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditingTag}">
|
<StackPanel Spacing="6" IsVisible="{Binding IsEditingTag}">
|
||||||
<TextBlock Classes="label" Text="TAG" Margin="0,0,0,4" />
|
<TextBlock Classes="label" Text="TAG" FontSize="10" />
|
||||||
<TextBox Text="{Binding TagEditorLabel}" PlaceholderText="name">
|
<TextBox Text="{Binding TagEditorLabel}" PlaceholderText="name">
|
||||||
<TextBox.KeyBindings>
|
<TextBox.KeyBindings>
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding SaveTagCommand}" />
|
<KeyBinding Gesture="Enter" Command="{Binding SaveTagCommand}" />
|
||||||
@@ -445,20 +591,21 @@
|
|||||||
|
|
||||||
<!-- The bucket editor. -->
|
<!-- The bucket editor. -->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditingObjectStore}">
|
<StackPanel Spacing="6" IsVisible="{Binding IsEditingObjectStore}">
|
||||||
<TextBlock Classes="label" Text="BUCKET" Margin="0,0,0,4" />
|
<TextBlock Classes="label" Text="BUCKET" FontSize="10" />
|
||||||
<TextBox Text="{Binding BucketEditorLabel}" PlaceholderText="name" />
|
<TextBox Text="{Binding BucketEditorLabel}" PlaceholderText="name" />
|
||||||
<TextBox Text="{Binding BucketEditorBucket}" PlaceholderText="bucket" />
|
<TextBox Text="{Binding BucketEditorBucket}" PlaceholderText="bucket" />
|
||||||
<TextBox Text="{Binding BucketEditorAccessKeyId}" PlaceholderText="access key id" />
|
<TextBox Text="{Binding BucketEditorAccessKeyId}" PlaceholderText="access key id" />
|
||||||
<!--
|
<!--
|
||||||
Masked, like a password and for the same reason: a secret access key is one. The access key id
|
Masked, like a password and for the same reason: a secret access key is one. The access
|
||||||
beside it is an identifier and is shown, which is also why the two are separate boxes.
|
key id beside it is an identifier and is shown, which is also why the two are separate
|
||||||
|
boxes.
|
||||||
-->
|
-->
|
||||||
<TextBox Text="{Binding BucketEditorSecretAccessKey}" PlaceholderText="secret access key"
|
<TextBox Text="{Binding BucketEditorSecretAccessKey}" PlaceholderText="secret access key"
|
||||||
PasswordChar="•" />
|
PasswordChar="•" />
|
||||||
<TextBox Text="{Binding BucketEditorRegion}" PlaceholderText="region (e.g. eu-west-1)" />
|
<TextBox Text="{Binding BucketEditorRegion}" PlaceholderText="region (e.g. eu-west-1)" />
|
||||||
<!--
|
<!--
|
||||||
Blank means Amazon, and then the region resolves the host. Anything else is a full URL, which
|
Blank means Amazon, and then the region resolves the host. Anything else is a full URL,
|
||||||
is what makes this work against a self-hosted service.
|
which is what makes this work against a self-hosted service.
|
||||||
-->
|
-->
|
||||||
<TextBox Text="{Binding BucketEditorEndpoint}"
|
<TextBox Text="{Binding BucketEditorEndpoint}"
|
||||||
PlaceholderText="endpoint (blank: Amazon S3)" />
|
PlaceholderText="endpoint (blank: Amazon S3)" />
|
||||||
@@ -466,7 +613,8 @@
|
|||||||
Content="Address the bucket as a path" />
|
Content="Address the bucket as a path" />
|
||||||
<!--
|
<!--
|
||||||
Said where the decision is made. Getting this wrong produces a DNS failure whose message
|
Said where the decision is made. Getting this wrong produces a DNS failure whose message
|
||||||
mentions neither buckets nor this setting, which is the worst kind of thing to leave to a guess.
|
mentions neither buckets nor this setting, which is the worst kind of thing to leave to a
|
||||||
|
guess.
|
||||||
-->
|
-->
|
||||||
<TextBlock Classes="hint" FontSize="10.5"
|
<TextBlock Classes="hint" FontSize="10.5"
|
||||||
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
|
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
|
||||||
@@ -485,5 +633,8 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -15,34 +15,78 @@
|
|||||||
|
|
||||||
The data layer did not move and did not change. Every pin is still a vault item, still end-to-end
|
The data layer did not move and did not change. Every pin is still a vault item, still end-to-end
|
||||||
encrypted, still synced; see KnownHostSecret. What is here is a screen over VaultViewModel.KnownHostPins.
|
encrypted, still synced; see KnownHostSecret. What is here is a screen over VaultViewModel.KnownHostPins.
|
||||||
|
|
||||||
|
v5c-3: restyled against KnownHosts.dc.html — still a main-chrome screen (the rail's own Keys item stays
|
||||||
|
the way in), a 40px back arrow to Keychain rather than the old link inside it, a bordered radius-12
|
||||||
|
container in place of the plain list-and-sidebar split, and a Copy fingerprint button beside Withdraw
|
||||||
|
pin. Everything the screen could do before still can: Filter, Selected, ForgetSelectedCommand and the
|
||||||
|
provenance notes below are unchanged, just repainted.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,244">
|
<UserControl.Styles>
|
||||||
|
<!--
|
||||||
|
A row, not a card: Track fill on hover, Track fill plus an accent ring on the selected one, matching
|
||||||
|
the design's own "selected = Track bg + accent ring, hover Track" — the same idiom SnippetsScreen's
|
||||||
|
own ListBox.snipcards uses, at this design's own 8px radius rather than that one's 10.
|
||||||
|
-->
|
||||||
|
<Style Selector="ListBox.pinrows > ListBoxItem /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.pinrows > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.pinrows > ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.pinrows > ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||||
|
</Style>
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,*">
|
<Grid RowDefinitions="Auto,Auto,*" Margin="26">
|
||||||
|
|
||||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
<!-- ============ HEADER ============ -->
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,Auto,*,Auto">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
<Button Grid.Column="0" Classes="ghost" Width="40" Height="40" Padding="0"
|
||||||
<TextBlock Grid.Column="0" Classes="mono" Text="HOST KEYS" FontSize="12"
|
Command="{Binding BackCommand}" ToolTip.Tip="Back to Keychain">
|
||||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="17"
|
||||||
|
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Summary}" FontSize="10.5"
|
</Button>
|
||||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
|
<TextBlock Grid.Column="1" Text="Host keys" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
Foreground="{StaticResource Text}" VerticalAlignment="Center" Margin="14,0,0,0" />
|
||||||
|
<Border Grid.Column="2" MinWidth="30" Height="26" CornerRadius="8" Padding="8,0" Margin="12,0,0,0"
|
||||||
|
Background="{StaticResource Chip}" VerticalAlignment="Center" IsVisible="{Binding HasPins}">
|
||||||
|
<TextBlock Classes="mono" Text="{Binding Count}" FontSize="12.5" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource TextDim}" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
<!--
|
<!--
|
||||||
Matches fingerprints as well as host names, which is the point of it. What somebody does with
|
Matches fingerprints as well as host names, which is the point of it. What somebody does with
|
||||||
this screen is check whether a published SHA256:… is the one they approved, and searching only
|
this screen is check whether a published SHA256:… is the one they approved, and searching only
|
||||||
by name would answer a different question.
|
by name would answer a different question.
|
||||||
-->
|
-->
|
||||||
<TextBox Grid.Column="2" x:Name="PinFilter" Text="{Binding Filter}" Width="240"
|
<TextBox Grid.Column="4" x:Name="PinFilter" Text="{Binding Filter}" Width="320" Height="40"
|
||||||
PlaceholderText="filter by host or fingerprint" VerticalAlignment="Center" />
|
PlaceholderText="filter by host or fingerprint" VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,6,14,6"
|
<TextBlock Grid.Row="1" Classes="hint" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
|
||||||
IsVisible="{Binding HasVisiblePins}">
|
Text="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." />
|
||||||
|
|
||||||
|
<!-- ============ THE TABLE ============ -->
|
||||||
|
<Border Grid.Row="2" Margin="0,18,0,0" CornerRadius="12" BorderBrush="{StaticResource Border}"
|
||||||
|
BorderThickness="1" ClipToBounds="True">
|
||||||
|
<Grid ColumnDefinitions="*,320">
|
||||||
|
|
||||||
|
<Grid Grid.Column="0" Background="{StaticResource Pane}" RowDefinitions="Auto,*">
|
||||||
|
|
||||||
|
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
|
||||||
|
BorderThickness="0,0,0,1" IsVisible="{Binding HasVisiblePins}">
|
||||||
|
<Grid ColumnDefinitions="2,1.4*,58,104,*,96">
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="9.5" LetterSpacing="1"
|
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="9.5" LetterSpacing="1"
|
||||||
Margin="12,0,8,0" />
|
Margin="12,0,8,0" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="9.5" LetterSpacing="1" />
|
||||||
@@ -50,89 +94,124 @@
|
|||||||
<TextBlock Grid.Column="4" Classes="label" Text="FINGERPRINT" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="4" Classes="label" Text="FINGERPRINT" FontSize="9.5" LetterSpacing="1" />
|
||||||
<TextBlock Grid.Column="5" Classes="label" Text="APPROVED" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="5" Classes="label" Text="APPROVED" FontSize="9.5" LetterSpacing="1" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<ListBox Grid.Row="2" x:Name="PinList" Focusable="True"
|
<ListBox Grid.Row="1" x:Name="PinList" Classes="pinrows" Focusable="True" Margin="12,8"
|
||||||
ItemsSource="{Binding VisiblePins}"
|
ItemsSource="{Binding VisiblePins}" SelectedItem="{Binding Selected}">
|
||||||
SelectedItem="{Binding Selected}">
|
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
|
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
|
||||||
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,7,14,7">
|
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="12">
|
||||||
<Border Grid.Column="0" Classes="rowmark" />
|
<Border Grid.Column="0" Classes="rowmark" />
|
||||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="12"
|
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="12.5"
|
||||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
|
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="10.5"
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="11"
|
||||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="10"
|
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
<!--
|
<!--
|
||||||
Never trimmed, and this column is why the table is laid out the way it is. The only thing
|
Never trimmed, and this column is why the table is laid out the way it is. The only thing
|
||||||
anybody does with a fingerprint is compare it character by character against one an operator
|
anybody does with a fingerprint is compare it character by character against one an
|
||||||
published; an ellipsis in the middle turns that into a glance, which is the habit the whole
|
operator published; an ellipsis in the middle turns that into a glance, which is the habit
|
||||||
mechanism exists to replace.
|
the whole mechanism exists to replace.
|
||||||
-->
|
-->
|
||||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="10.5"
|
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="10"
|
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
|
||||||
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
|
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
|
||||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
TextAlignment="Center" MaxWidth="340"
|
TextAlignment="Center" MaxWidth="340" IsVisible="{Binding !HasVisiblePins}" />
|
||||||
IsVisible="{Binding !HasVisiblePins}" />
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ============ DETAIL SIDEBAR ============ -->
|
||||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Margin="14,16" Spacing="6">
|
<StackPanel Margin="20,22" Spacing="14">
|
||||||
|
|
||||||
<TextBlock Classes="hint" FontSize="12"
|
<TextBlock Classes="hint" FontSize="12"
|
||||||
Text="Choose a pinned key to see it in full, and to withdraw it."
|
Text="Choose a pinned key to see it in full, and to withdraw it."
|
||||||
IsVisible="{Binding !HasSelection}" />
|
IsVisible="{Binding !HasSelection}" />
|
||||||
|
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
|
<StackPanel Spacing="14" IsVisible="{Binding HasSelection}">
|
||||||
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
|
|
||||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
|
||||||
|
|
||||||
<Border Classes="chip warn" HorizontalAlignment="Left"
|
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||||
|
<Border Width="36" Height="36" CornerRadius="10" Background="{StaticResource AccentSoft}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="17"
|
||||||
|
Foreground="{StaticResource AccentText}" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel Spacing="4" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" Text="{Binding Selected.Host}" FontSize="13.5" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="10.5"
|
||||||
|
Foreground="{StaticResource TextDim}" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border Classes="chip warn" Background="{StaticResource WarnWash}" HorizontalAlignment="Left"
|
||||||
IsVisible="{Binding !Selected.IsDialledByAHost}">
|
IsVisible="{Binding !Selected.IsDialledByAHost}">
|
||||||
<TextBlock Text="no host uses this" />
|
<TextBlock Text="no host uses this" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Classes="label" Text="FINGERPRINT" Margin="0,12,0,4" />
|
<StackPanel Spacing="6">
|
||||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
<TextBlock Classes="label" Text="FINGERPRINT" />
|
||||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
<Border Background="{StaticResource Pane}" BorderBrush="{StaticResource Border}"
|
||||||
|
BorderThickness="1" CornerRadius="10" Padding="12,10">
|
||||||
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
|
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
|
||||||
FontSize="10.5" Foreground="{StaticResource TextDim}"
|
FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" LineHeight="16" />
|
||||||
</Border>
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="label" Text="APPROVED" Margin="0,12,0,4" />
|
<StackPanel Spacing="6">
|
||||||
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="11"
|
<TextBlock Classes="label" Text="APPROVED" />
|
||||||
Foreground="{StaticResource TextDim}" />
|
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="11.5"
|
||||||
|
Foreground="{StaticResource TextDim}" TextWrapping="Wrap" />
|
||||||
<!--
|
<!--
|
||||||
Said rather than implied. No vault item carries a timestamp, so this date is read back out of
|
Said rather than implied. No vault item carries a timestamp, so this date is read back out
|
||||||
the item's own version 7 id — which records when the pin was created and knows nothing about
|
of the item's own version 7 id — which records when the pin was created and knows nothing
|
||||||
it being re-approved since. Presenting that as "last used" would be inventing a fact.
|
about it being re-approved since. Presenting that as "last used" would be inventing a fact.
|
||||||
-->
|
-->
|
||||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||||
Text="Taken from the item's identifier, so it is when this key was first approved — not when it was last checked. Nothing here records that." />
|
Text="Taken from the item's identifier, so it is when this key was first approved — not when it was last checked. Nothing here records that." />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,12,0,0"
|
<!--
|
||||||
|
◆ VAULT. Real, not decorative — KnownHostRowViewModel.VaultName comes off the same
|
||||||
|
VaultId/VaultName pair HostRowViewModel carries, so this chip never names a vault the pin
|
||||||
|
is not actually stored in.
|
||||||
|
-->
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="label" Text="VAULT" />
|
||||||
|
<Border Classes="chip" HorizontalAlignment="Left">
|
||||||
|
<TextBlock Text="{Binding Selected.VaultName}" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||||
Text="A pin outlives whatever it was approved for: deleting a host leaves it, and so does changing a host's address. That is deliberate — trust is about the endpoint, not the bookmark." />
|
Text="A pin outlives whatever it was approved for: deleting a host leaves it, and so does changing a host's address. That is deliberate — trust is about the endpoint, not the bookmark." />
|
||||||
|
|
||||||
<Button Classes="danger" Content="FORGET THIS HOST KEY" Margin="0,12,0,0"
|
<StackPanel Spacing="8" Margin="0,10,0,0">
|
||||||
HorizontalAlignment="Left"
|
<Button Classes="ghost" Height="38" HorizontalAlignment="Stretch"
|
||||||
Command="{Binding ForgetSelectedCommand}"
|
Content="COPY FINGERPRINT" Command="{Binding CopyFingerprintCommand}"
|
||||||
|
ToolTip.Tip="Fingerprints are public. Puts it on the clipboard in full." />
|
||||||
|
<Button Classes="danger" Height="38" HorizontalAlignment="Stretch"
|
||||||
|
Content="WITHDRAW PIN" Command="{Binding ForgetSelectedCommand}"
|
||||||
ToolTip.Tip="Withdraws trust. The next connection to this endpoint asks you to check the fingerprint again, which is the safe direction to be wrong in — and it is the way back from a server that was legitimately rebuilt." />
|
ToolTip.Tip="Withdraws trust. The next connection to this endpoint asks you to check the fingerprint again, which is the safe direction to be wrong in — and it is the way back from a server that was legitimately rebuilt." />
|
||||||
|
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" TextAlignment="Center"
|
||||||
|
Text="The next connection will ask you to approve this host's key again." />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -140,5 +219,8 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -18,39 +18,114 @@
|
|||||||
The connections list shows anything still open at the top, marked "still open" rather than with a dash. A
|
The connections list shows anything still open at the top, marked "still open" rather than with a dash. A
|
||||||
dash would read as a missing recording, and the two are opposite facts — an entry is written once, when a
|
dash would read as a missing recording, and the two are opposite facts — an entry is written once, when a
|
||||||
connection closes, so a live session is deliberately not in the vault yet.
|
connection closes, so a live session is deliberately not in the vault yet.
|
||||||
|
|
||||||
|
── v5b — Logs.dc.html ──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
A fidelity pass, not a new shape. What moved:
|
||||||
|
|
||||||
|
◆ THE HEADER. "Logs" 33 bold plus the two-segment CONNECTIONS/KEYCHAIN control, restyled onto
|
||||||
|
Border.navtrack/Button.navseg — the same h31 track the rail's own SSH/SFTP/S3 switcher already uses,
|
||||||
|
reused rather than redrawn, since it is the identical shape at the identical size. Two segments and not
|
||||||
|
three: LOGS is the screen's own title, not a tab, so the design's own two — CONNECTIONS and KEYCHAIN —
|
||||||
|
are the whole of what this screen switches between, exactly matching LogSection's own two values. No
|
||||||
|
deviation to record here. On the right: a mono status sentence — LogsViewModel.HeaderStatusLine, which
|
||||||
|
shows a refresh error when there is one and otherwise the fact this screen's own header comment already
|
||||||
|
states about whichever log is showing — and REFRESH, unchanged.
|
||||||
|
|
||||||
|
◆ THE BODY. A bordered radius-12 container on Pane bg wraps each table now, in place of the plain
|
||||||
|
background either one drew before. Tracked column headers are unchanged — HOST/ADDRESS/LASTED/KIND/
|
||||||
|
STARTED/FROM for connections, ITEM/TYPE/WHAT/FIELDS/WHEN for keychain activity, both already matching the
|
||||||
|
design exactly. Rows keep their real two-state host dot (green only for a session genuinely open right
|
||||||
|
now, per HasVisible on ConnectionLogRowViewModel.IsLive — never a third colour) and their existing
|
||||||
|
"failed"/"host key refused" amber badges. WHAT, on the activity table, is now a small coloured chip rather
|
||||||
|
than plain text — green for created, purple for a plain change, red for deleted — reading the same
|
||||||
|
ActivityOperation this screen already resolved to a word; a chip rather than a fabricated new fact.
|
||||||
|
|
||||||
|
◆ THE FOOTER. Logs.dc.html's own lock-glyph sentence — "Both logs are ordinary synced keychain items —
|
||||||
|
end-to-end encrypted. The server learns only that rows exist and when they were written." — verified
|
||||||
|
against this file's own header remark above and ADR 0001 before shipping it: both are true, so the
|
||||||
|
sentence is drawn as literal text rather than reworded.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*">
|
<UserControl.Styles>
|
||||||
|
<Style Selector="Border.logtable">
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource Pane}" />
|
||||||
|
<Setter Property="ClipToBounds" Value="True" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.logrows > ListBoxItem /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.logrows > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
|
</Style>
|
||||||
|
<!--
|
||||||
|
WHAT, on the activity table: created/changed/deleted, coloured the way the item badges are. "changed"
|
||||||
|
is the base rule's own colour — it is the default ActivityLogRowViewModel.Operation falls through to —
|
||||||
|
and .created/.deleted are declared after it so they win; Avalonia has no specificity and settles two
|
||||||
|
matching rules by declaration order, the same trap this application's other screens record.
|
||||||
|
-->
|
||||||
|
<Style Selector="Border.opchip">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentWash}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.opchip > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.opchip.created">
|
||||||
|
<Setter Property="Background" Value="{StaticResource LiveWash}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.opchip.created > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.opchip.deleted">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DangerWash}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.opchip.deleted > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
|
</Style>
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
<Grid RowDefinitions="Auto,*" Margin="26">
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
|
||||||
<Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto" VerticalAlignment="Center">
|
|
||||||
<TextBlock Grid.Column="0" Classes="mono" Text="LOGS" FontSize="12" FontWeight="SemiBold"
|
|
||||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
|
||||||
Margin="0,0,14,0" />
|
|
||||||
|
|
||||||
<Button Grid.Column="1" Classes="flat cat" Content="CONNECTIONS"
|
<!-- ============ THE HEADER ============ -->
|
||||||
Classes.active="{Binding ShowsConnections}"
|
<Grid Grid.Row="0" Margin="0,0,0,20" ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||||||
Command="{Binding ShowSectionCommand}"
|
|
||||||
|
<TextBlock Grid.Column="0" Text="Logs" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
|
||||||
|
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<Border Grid.Column="1" Classes="navtrack" Margin="14,0,0,0" Width="200" VerticalAlignment="Center">
|
||||||
|
<Grid ColumnDefinitions="*,*">
|
||||||
|
<Button Grid.Column="0" Classes="navseg" Classes.active="{Binding ShowsConnections}"
|
||||||
|
Content="CONNECTIONS" Command="{Binding ShowSectionCommand}"
|
||||||
CommandParameter="{x:Static vm:LogSection.Connections}" />
|
CommandParameter="{x:Static vm:LogSection.Connections}" />
|
||||||
<Button Grid.Column="2" Classes="flat cat" Content="KEYCHAIN"
|
<Button Grid.Column="1" Classes="navseg" Classes.active="{Binding ShowsActivity}"
|
||||||
Classes.active="{Binding ShowsActivity}"
|
Content="KEYCHAIN" Command="{Binding ShowSectionCommand}"
|
||||||
Command="{Binding ShowSectionCommand}"
|
|
||||||
CommandParameter="{x:Static vm:LogSection.Activity}" />
|
CommandParameter="{x:Static vm:LogSection.Activity}" />
|
||||||
|
|
||||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="10.5"
|
|
||||||
Foreground="{StaticResource TextFaint}" Margin="14,0,0,0"
|
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
|
||||||
|
|
||||||
<Button Grid.Column="4" Classes="ghost" Content="REFRESH" Command="{Binding RefreshCommand}"
|
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ============ Connections ============ -->
|
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding HeaderStatusLine}" FontSize="10.5"
|
||||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsConnections}">
|
Foreground="{StaticResource TextFaint}" Margin="0,0,14,0"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="14,6,14,6"
|
<Button Grid.Column="4" Classes="ghost" Height="40"
|
||||||
|
Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text=""
|
||||||
|
Foreground="{StaticResource TextFaint}" />
|
||||||
|
<TextBlock Text="Refresh" FontSize="13.5" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource TextDim}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- ============ Connections ============ -->
|
||||||
|
<Border Grid.Row="1" Classes="logtable" IsVisible="{Binding ShowsConnections}">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
|
<Grid Grid.Row="0" ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="20,14,20,10"
|
||||||
IsVisible="{Binding HasConnections}">
|
IsVisible="{Binding HasConnections}">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="HOST" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="0" Classes="label" Text="HOST" FontSize="9.5" LetterSpacing="1" />
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="1" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
|
||||||
@@ -60,37 +135,35 @@
|
|||||||
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="9.5" LetterSpacing="1" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ListBox Grid.Row="1" x:Name="ConnectionList" Focusable="True"
|
<ListBox Grid.Row="1" x:Name="ConnectionList" Classes="logrows" Focusable="True"
|
||||||
ItemsSource="{Binding Connections}">
|
ItemsSource="{Binding Connections}" Margin="12,0,12,10">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||||
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="0,6,14,6">
|
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Height="40" Margin="8,0,10,0">
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="14,0,8,0">
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="9" Margin="12,0,8,0"
|
||||||
|
VerticalAlignment="Center">
|
||||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" VerticalAlignment="Center" />
|
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" VerticalAlignment="Center" />
|
||||||
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="12" FontWeight="Medium"
|
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="12.5" FontWeight="Medium"
|
||||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
|
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||||
VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="10.5"
|
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="11"
|
||||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
Foreground="{StaticResource TextGhost}" Margin="0,0,8,0"
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Duration}" FontSize="11"
|
||||||
<TextBlock Classes="mono" Text="{Binding Duration}" FontSize="10.5"
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||||
Foreground="{StaticResource TextDim}" />
|
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="10.5"
|
||||||
</StackPanel>
|
|
||||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="10"
|
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="10"
|
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||||
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="10"
|
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextFaint}"
|
Foreground="{StaticResource TextFaint}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
<!--
|
<!--
|
||||||
Only when there is something to say. A connection that opened and closed says nothing
|
Only when there is something to say. A connection that opened and closed says nothing
|
||||||
here; one that was refused says so, and that is the row worth finding in a long list.
|
here; one that was refused says so, and that is the row worth finding in a long list.
|
||||||
-->
|
-->
|
||||||
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding HasOutcome}">
|
<Border Classes="chip warn" Padding="7,0" Height="18" IsVisible="{Binding HasOutcome}">
|
||||||
<TextBlock Text="{Binding Outcome}" FontSize="9.5" />
|
<TextBlock Text="{Binding Outcome}" FontSize="9.5" />
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -103,12 +176,27 @@
|
|||||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
TextAlignment="Center" MaxWidth="420"
|
TextAlignment="Center" MaxWidth="420"
|
||||||
IsVisible="{Binding !HasConnections}" />
|
IsVisible="{Binding !HasConnections}" />
|
||||||
|
|
||||||
|
<!-- ◆ THE FOOTER. Verified against this file's own header remark and ADR 0001; see the file-level note. -->
|
||||||
|
<Border Grid.Row="2" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||||
|
Padding="20,12">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text=""
|
||||||
|
Foreground="{StaticResource TextFaint}" />
|
||||||
|
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="Both logs are ordinary synced keychain items — end-to-end encrypted. The server learns only that rows exist and when they were written." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- ============ Keychain changes ============ -->
|
<!-- ============ Keychain changes ============ -->
|
||||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsActivity}">
|
<Border Grid.Row="1" Classes="logtable" IsVisible="{Binding ShowsActivity}">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6"
|
<Grid Grid.Row="0" ColumnDefinitions="1.4*,0.8*,0.8*,1.6*,0.8*" Margin="20,14,20,10"
|
||||||
IsVisible="{Binding HasActivity}">
|
IsVisible="{Binding HasActivity}">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="ITEM" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="0" Classes="label" Text="ITEM" FontSize="9.5" LetterSpacing="1" />
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="1" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
||||||
@@ -117,26 +205,26 @@
|
|||||||
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="9.5" LetterSpacing="1" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ListBox Grid.Row="1" x:Name="ActivityList" Focusable="True" ItemsSource="{Binding Activity}">
|
<ListBox Grid.Row="1" x:Name="ActivityList" Classes="logrows" Focusable="True"
|
||||||
|
ItemsSource="{Binding Activity}" Margin="12,0,12,10">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
|
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
|
||||||
<Grid ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6">
|
<Grid ColumnDefinitions="1.4*,0.8*,0.8*,1.6*,0.8*" Height="40" Margin="8,0,10,0">
|
||||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="12"
|
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="12.5"
|
||||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
|
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="4,0,8,0"
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="10"
|
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Operation}" FontSize="10.5"
|
<Border Grid.Column="2" Classes="opchip" Classes.created="{Binding IsCreated}"
|
||||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
Classes.deleted="{Binding IsDeleted}" Height="19" CornerRadius="5" Padding="8,0"
|
||||||
<!--
|
HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||||
The names of the fields that changed, and never what they changed to. A log that recorded
|
<TextBlock Text="{Binding Operation}" FontSize="10" FontWeight="SemiBold" />
|
||||||
an old password would be a plaintext credential store with a vault drawn around it.
|
</Border>
|
||||||
-->
|
|
||||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="10.5"
|
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||||
IsVisible="{Binding HasChangedFields}" />
|
IsVisible="{Binding HasChangedFields}" />
|
||||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="10"
|
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -147,7 +235,20 @@
|
|||||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
TextAlignment="Center" MaxWidth="420"
|
TextAlignment="Center" MaxWidth="420"
|
||||||
IsVisible="{Binding !HasActivity}" />
|
IsVisible="{Binding !HasActivity}" />
|
||||||
|
|
||||||
|
<Border Grid.Row="2" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||||
|
Padding="20,12">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text=""
|
||||||
|
Foreground="{StaticResource TextFaint}" />
|
||||||
|
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="Both logs are ordinary synced keychain items — end-to-end encrypted. The server learns only that rows exist and when they were written." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,14 @@
|
|||||||
Icon="/Assets/dodossh.ico"
|
Icon="/Assets/dodossh.ico"
|
||||||
Width="1180"
|
Width="1180"
|
||||||
Height="760"
|
Height="760"
|
||||||
MinWidth="1016"
|
MinWidth="1081"
|
||||||
MinHeight="574"
|
MinHeight="583"
|
||||||
Background="{StaticResource Canvas}"
|
Background="{StaticResource Canvas}"
|
||||||
SystemDecorations="BorderOnly"
|
SystemDecorations="BorderOnly"
|
||||||
Focusable="True">
|
Focusable="True">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The shell window: a titlebar it draws itself, a sidebar, a tab strip, one surface at a time, and a
|
The shell window: a titlebar it draws itself, a nav rail, one surface at a time, and a status bar.
|
||||||
status bar.
|
|
||||||
|
|
||||||
THE MINIMUM GREW, and it grew by exactly what v2 added rather than by a round number somebody liked.
|
THE MINIMUM GREW, and it grew by exactly what v2 added rather than by a round number somebody liked.
|
||||||
The sidebar went from 54 pixels to 190 and the chrome from 72 tall to 86, so 880x560 became 1016x574 —
|
The sidebar went from 54 pixels to 190 and the chrome from 72 tall to 86, so 880x560 became 1016x574 —
|
||||||
@@ -26,6 +25,17 @@
|
|||||||
fitting at 690 wide. Widening the sidebar without widening the window would have quietly broken them
|
fitting at 690 wide. Widening the sidebar without widening the window would have quietly broken them
|
||||||
somewhere nobody was looking.
|
somewhere nobody was looking.
|
||||||
|
|
||||||
|
v5b moves it again, by exactly the same reasoning: the titlebar's fidelity pass takes it from 44 to 53
|
||||||
|
and the rail's from 190 to 255, so 1016x574 becomes 1081x583 — nine pixels and sixty-five pixels, added
|
||||||
|
straight onto the minimum rather than absorbed by shrinking a screen. <c>LayoutHarness.ScreenWidth</c>
|
||||||
|
stays unchanged at 826, because the rail is the only thing beside a page that grew.
|
||||||
|
|
||||||
|
<c>ScreenHeight</c> did move, and in the other direction: v5b's own fidelity pass also retires the
|
||||||
|
window-wide tab strip this comment used to describe — see the paragraph below — and every full-bleed
|
||||||
|
page gets that 42 pixels back rather than the window losing height to compensate. A screen this suite
|
||||||
|
measures is 826 pixels wide and taller than it was, by exactly the strip's own height; see
|
||||||
|
<c>LayoutHarness</c>'s own remark on the budget for the arithmetic.
|
||||||
|
|
||||||
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
|
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
|
||||||
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
|
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
|
||||||
near-black application is the one part of the window that would look borrowed.
|
near-black application is the one part of the window that would look borrowed.
|
||||||
@@ -37,12 +47,19 @@
|
|||||||
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
||||||
chrome worth having.
|
chrome worth having.
|
||||||
|
|
||||||
TWO SURFACES, ONE RECTANGLE.
|
TWO SURFACES, ONE RECTANGLE — AND EACH ONE OWNS ITS OWN TAB ROW NOW.
|
||||||
|
|
||||||
The tab strip is above everything the nav rail leads to, so a terminal opened from any screen stays
|
v5b retires the tab strip this file used to draw above the whole window — Vaults, SFTP and S3 left it
|
||||||
visible and reachable from every other one. What that costs is that the terminal and the pages now share
|
for the rail's own switcher in an earlier pass, and this one moves the remaining pills, one per open
|
||||||
the area beneath the strip, and exactly one of them may occupy it. That is the whole of ShellSurface: an
|
terminal, off the window's own chrome entirely. Each of the two screens that carries a tab row —
|
||||||
enum rather than two flags, so there is no way to write the state where both are showing.
|
<c>SessionTabRow.axaml</c> — draws its own, 38 pixels, inside its own 26-pixel padded column, per the
|
||||||
|
design; see the terminal surface's own Grid below and the SFTP one inside the pages Panel. A tab still
|
||||||
|
survives navigating away from either screen — that is what makes the terminal reachable from anywhere —
|
||||||
|
it simply is not drawn as chrome above every screen while it does.
|
||||||
|
|
||||||
|
The terminal and the pages still share the one rectangle beside the rail, and exactly one of them may
|
||||||
|
occupy it at a time: that is the whole of ShellSurface, an enum rather than two flags, so there is no way
|
||||||
|
to write the state where both are showing.
|
||||||
|
|
||||||
THE OCCLUSION RULE, which every arrangement in this file obeys.
|
THE OCCLUSION RULE, which every arrangement in this file obeys.
|
||||||
|
|
||||||
@@ -67,39 +84,27 @@
|
|||||||
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
|
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
|
||||||
tree, so every unlock would pay a cold start. Hoisting the binding to an ancestor looks tidier and is
|
tree, so every unlock would pay a cold start. Hoisting the binding to an ancestor looks tidier and is
|
||||||
unverified: NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push
|
unverified: NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push
|
||||||
fires only for its own IsVisible.
|
fires only for its own IsVisible. v5b nests the WebView three levels deeper than it used to sit, inside
|
||||||
|
the terminal surface's own session shell — see that Grid's own remark below for why the rule still holds
|
||||||
|
with the control that much further from the Panel that used to be its only parent.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
<views:TitleBar Grid.Row="0" />
|
<views:TitleBar Grid.Row="0" IsVisible="{Binding !IsSettingsMode}" />
|
||||||
|
|
||||||
<Panel Grid.Row="1">
|
<Panel Grid.Row="1">
|
||||||
|
|
||||||
<!-- The unlocked application. -->
|
<!-- The unlocked application. -->
|
||||||
<Grid RowDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
|
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}"
|
||||||
|
IsEnabled="{Binding !IsSettingsMode}">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ THE STRIP IS ABOVE THE RAIL, and it used to be beside it.
|
The window's own furniture, at 255 pixels — see NavRail.axaml's own v5b remark. It no longer sits
|
||||||
|
under a strip: v5b retired the window-wide tab strip entirely, and with it the row this Grid used
|
||||||
It was the other way round for a reason that stopped being true: while every tab was a terminal,
|
to give up its own first row to. See the remark below on where a session's tabs live now.
|
||||||
the strip navigated only the area to the right of a full-height rail, and putting it over the rail
|
|
||||||
would have been a row of tabs above a column of destinations they had nothing to do with.
|
|
||||||
|
|
||||||
The three fixed tabs are what changed that. The rail is now one tab's contents rather than the
|
|
||||||
window's own furniture — Vaults owns it, SFTP and S3 do not have it, and a terminal does not
|
|
||||||
either — so a rail drawn beside the strip would outrank the thing that decides whether it is
|
|
||||||
showing at all. Above and full width is the arrangement that matches what selects what.
|
|
||||||
-->
|
-->
|
||||||
<views:TerminalTabs Grid.Row="0" />
|
<views:NavRail Grid.Column="0" />
|
||||||
|
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The Vaults tab's own navigation, and it collapses with that tab. Its width is 190 either way, so
|
|
||||||
SFTP, S3 and a terminal each get the full window rather than the 826 a page gets.
|
|
||||||
-->
|
|
||||||
<views:NavRail Grid.Column="0" IsVisible="{Binding IsVaultsTab}" />
|
|
||||||
|
|
||||||
<Panel Grid.Column="1">
|
<Panel Grid.Column="1">
|
||||||
|
|
||||||
@@ -119,22 +124,53 @@
|
|||||||
|
|
||||||
<!-- ============ SFTP ============ -->
|
<!-- ============ SFTP ============ -->
|
||||||
<!--
|
<!--
|
||||||
Inside this Panel although it is a tab rather than a rail screen, and that is not an
|
◆ v5b's session shell, wrapping this screen's existing content rather than replacing it — the
|
||||||
oversight. IsShowingPages means "the Avalonia page area, not the WebView", which is the
|
two-pane grid and the queue inside TransfersScreen.axaml are untouched; wave C restyles their
|
||||||
occlusion question and is true of all three fixed tabs; which of them is showing is the
|
internals. What is new here is everything design-notes/v5b-fidelity-notes.md calls the session
|
||||||
separate question each child below answers. Keeping the two apart is what lets the terminal
|
shell: a 26px padded column, an in-screen tab row, a bordered rounded-bottom container holding
|
||||||
stay collapsed under one rule rather than under four.
|
a host header and a status bar around the screen's own content, and a 300px sidebar.
|
||||||
|
|
||||||
What differs from a rail screen is only the rail: NavRail collapses on IsVaultsTab above, so
|
Gated on IsTransfersScreen exactly as before — IsShowingPages means "the Avalonia page area,
|
||||||
this screen is laid out at the full window width.
|
not the WebView", which is the occlusion question, and which mode the switcher is on is this
|
||||||
|
wrapper's own separate question.
|
||||||
|
|
||||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
The tab row's own click does not select a terminal tab — there is no per-tab SFTP session in
|
||||||
visibility is the shell's business and its data context is the transfers view model, and
|
this application, and building one is out of this wave's scope; see the notes' own open
|
||||||
putting both on one element resolves IsVisible against that view model, where
|
question and MainWindowViewModel.SelectFilesHostCommand for how this resolves it: a click
|
||||||
IsTransfersScreen does not exist.
|
reuses the same "Browse files" plumbing a pin click already does, honestly opening (or
|
||||||
|
reusing) a second, SFTP-specific connection to that tab's host rather than pretending a session
|
||||||
|
exists that does not.
|
||||||
-->
|
-->
|
||||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
<Grid RowDefinitions="Auto,*" Margin="26">
|
||||||
|
<views:SessionTabRow Grid.Row="0" Classes="sftp"
|
||||||
|
TabCommand="{Binding SelectFilesHostCommand}" />
|
||||||
|
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<!--
|
||||||
|
v5c-4: two rows rather than three. The 60-pixel host header that used to sit above this
|
||||||
|
screen is gone; the address and the "Open terminal" button it carried are in the
|
||||||
|
sidebar now — see SessionSidebar.axaml — and the pane keeps the height. Its third
|
||||||
|
binding, the Transfers.Status line it printed while no host was open, is not moved
|
||||||
|
either: TransfersScreen draws that same string itself, both in its own empty state and
|
||||||
|
beside the remote pane's DISCONNECT once something is open.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Column="0" RowDefinitions="*,Auto">
|
||||||
|
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
|
||||||
|
<views:SessionStatusBar Grid.Row="1" />
|
||||||
|
</Grid>
|
||||||
|
<!--
|
||||||
|
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
|
||||||
|
drawn: the SFTP surface reaches this Panel before a host is chosen, and a sidebar with
|
||||||
|
an empty QUICK ACCESS and no host name to print would be furniture with nothing to say.
|
||||||
|
Collapsing frees the "Auto" column it sits in, so the pane column takes the width back.
|
||||||
|
-->
|
||||||
|
<views:SessionSidebar Grid.Column="1"
|
||||||
|
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- ============ S3 ============ -->
|
<!-- ============ S3 ============ -->
|
||||||
@@ -142,10 +178,22 @@
|
|||||||
The same screen as SFTP above, over the same view model, because an object store and an
|
The same screen as SFTP above, over the same view model, because an object store and an
|
||||||
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
|
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
|
||||||
What differs is which picker is offered, and that is decided by the destination rather than
|
What differs is which picker is offered, and that is decided by the destination rather than
|
||||||
by a toggle inside the screen — see ShowFiles, and the tab in the strip that calls it.
|
by a toggle inside the screen — see ShowFiles, and the rail's own switcher segment that
|
||||||
|
calls it now; see NavRail.axaml.
|
||||||
|
|
||||||
|
Deliberately not given the full session shell above: a bucket is not a host, has no terminal tab
|
||||||
|
to be the other end of a cross-surface button, and pins nothing the sidebar's QUICK ACCESS
|
||||||
|
could show — so no tab row, no host header, no status bar and no sidebar. Wave B's own scope
|
||||||
|
was the terminal and SFTP surfaces and left this plain; v5c-3 gives it the session shell's own
|
||||||
|
LOOK without its machinery — a 26px padded column and the same bordered, radius-12 container —
|
||||||
|
since design-notes/v5c-fidelity-notes.md calls for the fidelity pass here too, and there is
|
||||||
|
nothing this screen does that needs the parts still withheld.
|
||||||
-->
|
-->
|
||||||
<Panel IsVisible="{Binding IsBucketsScreen}">
|
<Panel IsVisible="{Binding IsBucketsScreen}">
|
||||||
|
<Border Margin="26" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||||
|
ClipToBounds="True">
|
||||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||||
|
</Border>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- ============ KEYCHAIN ============ -->
|
<!-- ============ KEYCHAIN ============ -->
|
||||||
@@ -180,60 +228,116 @@
|
|||||||
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- ============ VAULTS ============ -->
|
|
||||||
<!--
|
<!--
|
||||||
Wrapped, for the reason the keychain and transfers screens are: the visibility is the shell's
|
v5c-2: the VAULTS panel that used to live here is gone. ShellScreen.Vaults now only ever shows
|
||||||
business and the data context is the vaults view model, and both on one element would resolve
|
through settings mode's own SettingsVaultsPage — see MainWindowViewModel.ShowScreen, which
|
||||||
IsVaultsScreen against a type that does not have it.
|
redirects it into EnterSettings before Screen is ever set — so IsVaultsScreen being true also
|
||||||
|
means IsSettingsMode is true, and this whole page-area Panel (bound to !IsSettingsMode, above)
|
||||||
|
is already hidden behind SettingsView by then. A Panel here would never have been drawn.
|
||||||
|
|
||||||
Bound to Vaults, which is the vaults themselves and the people in them — not to Vault, which
|
v5c-3: IMPORT left this Panel the same way, and for the same reason. Import.dc.html draws the
|
||||||
is one vault's contents and is what the keychain and hosts screens above draw.
|
importer inside the settings chrome — SettingsNav lit on Preferences, a "Back to preferences"
|
||||||
|
titlebar rather than the ordinary rail and titlebar this Panel belongs to — so ImportScreen now
|
||||||
|
lives in SettingsView.axaml behind MainWindowViewModel.IsImportOpen, beside the seven settings
|
||||||
|
pages rather than among the screens here. See ShowScreen's own translation of
|
||||||
|
ShellScreen.Import for what still reaches it: the Preferences page's own "OPEN IMPORTER" row,
|
||||||
|
and anything else that names the same destination.
|
||||||
-->
|
-->
|
||||||
<Panel IsVisible="{Binding IsVaultsScreen}">
|
|
||||||
<views:VaultsScreen DataContext="{Binding Vaults}" />
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- ============ PREFERENCES ============ -->
|
<!-- ============ TERMINAL ============ -->
|
||||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
|
||||||
|
|
||||||
<!-- ============ IMPORT ============ -->
|
|
||||||
<!--
|
<!--
|
||||||
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
|
◆ v5b's session shell for the terminal surface, built the same way SFTP's own wrapper above is —
|
||||||
the others whose data context is their own view model.
|
around the real terminal control rather than around a page.
|
||||||
|
|
||||||
|
Gated on IsTerminalSurface rather than on IsShowingPages: the two are always exclusive, because
|
||||||
|
Surface is a single ShellSurface value, so this Grid and the pages Panel above it are never both
|
||||||
|
visible at once. That is what keeps the occlusion rule intact with the WebView now nested inside
|
||||||
|
a padded column, a bordered container and a header row rather than sitting directly beside the
|
||||||
|
page area the way it used to.
|
||||||
|
|
||||||
|
THE RULE ITSELF DID NOT MOVE. NativeWebView's own IsVisible binding, below, is unchanged and is
|
||||||
|
not hoisted to this Grid — hoisting it is the one alternative the pin-strip era of this file
|
||||||
|
called out as unverified, and nesting the control deeper without touching its own binding is not
|
||||||
|
that: IsTerminalShowing already depends on IsTerminalSurface, so this Grid's own visibility and
|
||||||
|
the WebView's own visibility flip together on every surface change, driven by the same property
|
||||||
|
change rather than one waiting on the other.
|
||||||
|
|
||||||
|
The tab row's own click selects a terminal tab exactly as the old window-wide strip's did; see
|
||||||
|
SelectTabCommand. "+" keeps its current meaning, quick connect, on both this row and SFTP's own.
|
||||||
-->
|
-->
|
||||||
<Panel IsVisible="{Binding IsImportScreen}">
|
<Grid RowDefinitions="Auto,*" Margin="26" IsVisible="{Binding IsTerminalSurface}">
|
||||||
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
|
<views:SessionTabRow Grid.Row="0" TabCommand="{Binding SelectTabCommand}" />
|
||||||
</Panel>
|
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<!--
|
||||||
|
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
|
||||||
|
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
|
||||||
|
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
|
||||||
|
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
||||||
|
answers for itself in the tab row's own "+" and in the connecting card below.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Column="0" RowDefinitions="*,Auto">
|
||||||
|
|
||||||
</Panel>
|
<Panel Grid.Row="0" Background="{StaticResource Pane}">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The other thing that can be in the terminal's rectangle: a tab whose session does not exist
|
The other thing that can be in the terminal's rectangle: a tab whose session does not
|
||||||
yet, or never will. Exclusive with the WebView below by construction — a selected tab either
|
exist yet, or never will. Exclusive with the WebView below by construction — a
|
||||||
has a session or it does not — which is what makes drawing it here safe under the occlusion
|
selected tab either has a session or it does not — which is what makes drawing it here
|
||||||
rule, the same way the page area is. See ConnectingCard.axaml.
|
safe under the occlusion rule, the same way the page area is. See ConnectingCard.axaml.
|
||||||
-->
|
-->
|
||||||
<views:ConnectingCard x:Name="ConnectingPane"
|
<views:ConnectingCard x:Name="ConnectingPane"
|
||||||
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
|
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
|
||||||
process tree, so twenty tabs would cost twenty of them.
|
browser process tree, so twenty tabs would cost twenty of them.
|
||||||
|
|
||||||
A sibling of the page area rather than a child of any screen, which is the structural half of
|
FallbackValue, because a compiled binding with no DataContext yields UnsetValue,
|
||||||
the tab rework: the terminal belongs to the window now, not to the hosts screen.
|
IsVisible then falls back to its default of true, and the occlusion comes back
|
||||||
|
silently. Not reachable at runtime — the DataContext is set before the window is shown
|
||||||
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
|
— but it is what the previewer does.
|
||||||
then falls back to its default of true, and the occlusion comes back silently. Not reachable
|
|
||||||
at runtime — the DataContext is set before the window is shown — but it is what the previewer
|
|
||||||
does.
|
|
||||||
-->
|
-->
|
||||||
<NativeWebView x:Name="Terminal"
|
<NativeWebView x:Name="Terminal"
|
||||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<views:SessionStatusBar Grid.Row="1" ShowsEncoding="True" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<!--
|
||||||
|
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
|
||||||
|
the terminal surface is reachable with no tab open at all (the rail's own SSH segment), and
|
||||||
|
a sidebar naming no host would be furniture with nothing to say. Collapsing frees the
|
||||||
|
"Auto" column it sits in, so the pane column takes the width back.
|
||||||
|
-->
|
||||||
|
<views:SessionSidebar Grid.Column="1" ShowsSnips="True"
|
||||||
|
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Panel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
v5c: the settings mode, which replaces the titlebar above and everything in this Panel below it with
|
||||||
|
its own — see SettingsView.axaml and TitleBar's own IsVisible next to it in this file. Later in the
|
||||||
|
Panel than the unlocked Grid, on the same reasoning the setup Border below gives for its own
|
||||||
|
position: Avalonia z-order draws a later sibling over an earlier one, and SettingsView's own
|
||||||
|
Background is opaque, so it covers the rail and the page area completely rather than needing them
|
||||||
|
hidden out from under it. The unlocked Grid keeps IsEnabled bound to the negation above so a Tab
|
||||||
|
press cannot walk keyboard focus into a control this mode has covered.
|
||||||
|
|
||||||
|
Earlier than the palette and the host-key decision, both further down this same Panel, so either can
|
||||||
|
still interrupt settings mode exactly as it already interrupts every other screen — a host key that
|
||||||
|
changes while the sync loop is running does not stop being worth answering because the window
|
||||||
|
happens to be showing Settings.
|
||||||
|
-->
|
||||||
|
<views:SettingsView IsVisible="{Binding IsSettingsMode}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Setup and unlock. Later in the Panel, so it is above the application content in Avalonia's z-order —
|
Setup and unlock. Later in the Panel, so it is above the application content in Avalonia's z-order —
|
||||||
@@ -348,6 +452,22 @@
|
|||||||
-->
|
-->
|
||||||
<views:QuickConnect x:Name="Palette" IsVisible="{Binding IsSearching}" />
|
<views:QuickConnect x:Name="Palette" IsVisible="{Binding IsSearching}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ Later still, and the order between these two is a ranking rather than an accident: the palette is
|
||||||
|
something the user opened and can close, and a host-key decision is a connection waiting on them. It
|
||||||
|
used to be two banners at the top of the hosts screen, which is why the shell navigated there before
|
||||||
|
the question could be asked — see HostKeyCard.axaml, and MainWindowViewModel.OnVaultConnectionFailed
|
||||||
|
for what that cost every other screen.
|
||||||
|
|
||||||
|
Wrapped, like every other child here whose data context is the vault: the visibility is the shell's
|
||||||
|
business — it is the flag that also collapses the WebView — and IsHostKeyDecisionShowing does not
|
||||||
|
exist on a VaultViewModel, which with compiled bindings is a build error rather than a card that
|
||||||
|
silently never appears.
|
||||||
|
-->
|
||||||
|
<Panel IsVisible="{Binding IsHostKeyDecisionShowing, FallbackValue=False}">
|
||||||
|
<views:HostKeyCard x:Name="HostKeyPane" DataContext="{Binding Vault}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -360,14 +480,34 @@
|
|||||||
buttons and a version string of unknown length is exactly the shape that arranges one of them off the
|
buttons and a version string of unknown length is exactly the shape that arranges one of them off the
|
||||||
edge. See UpdateBanner.axaml.
|
edge. See UpdateBanner.axaml.
|
||||||
|
|
||||||
|
◆ THE DATA CONTEXT IS SET HERE, and the banner did nothing at all until it was.
|
||||||
|
|
||||||
|
Unlike the titlebar and the status bar, which are typed to this window's own view model and inherit its
|
||||||
|
context, the banner is typed to UpdateViewModel — it is one screen's control and its layout suite hosts
|
||||||
|
it over that view model alone. Inheriting the shell's context instead left every compiled binding
|
||||||
|
inside it resolving against the wrong type and failing silently: no headline, and both Commands null,
|
||||||
|
so the strip appeared, hovered and pressed like a real banner and neither button did anything.
|
||||||
|
|
||||||
|
IsVisible is unqualified because the context is set on this same element, which resolves it against
|
||||||
|
UpdateViewModel too — the rule the page area's wrappers above are wrapped for. It needs no wrapper: the
|
||||||
|
flag it binds is the banner's own, unlike IsHostsScreen and its siblings, which belong to the shell.
|
||||||
|
|
||||||
FallbackValue, for the reason the WebView and the connecting card carry one: a compiled binding with
|
FallbackValue, for the reason the WebView and the connecting card carry one: a compiled binding with
|
||||||
no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner
|
no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner
|
||||||
announcing an update that does not exist.
|
announcing an update that does not exist.
|
||||||
-->
|
-->
|
||||||
<views:UpdateBanner Grid.Row="2"
|
<!--
|
||||||
IsVisible="{Binding Updates.IsBannerShowing, FallbackValue=False}" />
|
v5c: wrapped rather than given a third condition of its own — UpdateBanner sets its own DataContext to
|
||||||
|
UpdateViewModel on this same element, so a binding against IsSettingsMode has to live on an ancestor
|
||||||
|
whose context is still the shell. The design's settings titlebar has no room for this strip; hiding it
|
||||||
|
here rather than teaching the banner about a mode it otherwise has no reason to know about.
|
||||||
|
-->
|
||||||
|
<Panel Grid.Row="2" IsVisible="{Binding !IsSettingsMode}">
|
||||||
|
<views:UpdateBanner DataContext="{Binding Updates}"
|
||||||
|
IsVisible="{Binding IsBannerShowing, FallbackValue=False}" />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<views:StatusBar Grid.Row="3" />
|
<views:StatusBar Grid.Row="3" IsVisible="{Binding !IsSettingsMode}" />
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ internal sealed partial class MainWindow : Window
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Colours the system-drawn frame the moment there is a handle to colour it on.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>OnOpened</c> and not the constructor: the window has no platform handle until it is shown, and
|
||||||
|
/// <see cref="NativeWindowFrame"/> does nothing without one. See that class for what the frame is and
|
||||||
|
/// why <c>BorderOnly</c> still has one.
|
||||||
|
/// </remarks>
|
||||||
|
protected override void OnOpened(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnOpened(e);
|
||||||
|
|
||||||
|
NativeWindowFrame.MatchTo(this);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Asks the Linux backend for the one mode it can actually draw inside this window.
|
/// Asks the Linux backend for the one mode it can actually draw inside this window.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -111,11 +126,13 @@ internal sealed partial class MainWindow : Window
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private IInputElement KeyboardHome => shell switch
|
private IInputElement KeyboardHome => shell switch
|
||||||
{
|
{
|
||||||
|
// v5c: settings mode has no keyboard-focused control of its own yet — its pages are read-only prose
|
||||||
|
// and buttons, the same shape the account and logs screens already fall back to the window for.
|
||||||
|
{ IsSettingsMode: true } => this,
|
||||||
{ IsTerminalShowing: true } => Terminal,
|
{ IsTerminalShowing: true } => Terminal,
|
||||||
{ Screen: ShellScreen.Keychain } => VaultPane.KeyboardTarget,
|
{ Screen: ShellScreen.Keychain } => VaultPane.KeyboardTarget,
|
||||||
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
|
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
|
||||||
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
|
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
|
||||||
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
|
|
||||||
{ Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
|
{ Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
|
||||||
{ Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
|
{ Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
|
||||||
_ => this,
|
_ => this,
|
||||||
@@ -213,6 +230,25 @@ internal sealed partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
Palette.HandleKey(e);
|
Palette.HandleKey(e);
|
||||||
}
|
}
|
||||||
|
// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape
|
||||||
|
// already follows one branch up. Checked after the palette rather than before it: the two states
|
||||||
|
// are mutually exclusive in practice — opening the palette does not enter settings mode and entering
|
||||||
|
// settings does not open the palette — but an Escape while both were somehow true should close the
|
||||||
|
// thing drawn on top, which is the palette.
|
||||||
|
//
|
||||||
|
// v5c: with the importer up, Escape closes only that — the same "closest thing first" rule, and the
|
||||||
|
// same one the titlebar's own back button follows by showing "Back to preferences" rather than
|
||||||
|
// "Back to application" while IsImportOpen is true.
|
||||||
|
else if (e.Key == Key.Escape && viewModel.IsImportOpen)
|
||||||
|
{
|
||||||
|
viewModel.CloseImportCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
|
||||||
|
{
|
||||||
|
viewModel.LeaveSettingsCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
base.OnKeyDown(e);
|
base.OnKeyDown(e);
|
||||||
}
|
}
|
||||||
@@ -247,6 +283,7 @@ internal sealed partial class MainWindow : Window
|
|||||||
if (shell is { } previous)
|
if (shell is { } previous)
|
||||||
{
|
{
|
||||||
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
|
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
|
||||||
|
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +302,7 @@ internal sealed partial class MainWindow : Window
|
|||||||
wasUnlocked = viewModel.IsUnlocked;
|
wasUnlocked = viewModel.IsUnlocked;
|
||||||
|
|
||||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||||
|
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||||
viewModel.PropertyChanged += OnShellPropertyChanged;
|
viewModel.PropertyChanged += OnShellPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +315,15 @@ internal sealed partial class MainWindow : Window
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The same call for a session that was already open and has just been typed into from the sidebar —
|
||||||
|
/// see <see cref="MainWindowViewModel.TerminalFocusRequested"/>. Posted like every other path here,
|
||||||
|
/// although nothing was revealed this turn: the post also re-checks that a terminal is still showing,
|
||||||
|
/// which is what keeps this from stealing the keyboard if the insert landed the user on the snippets
|
||||||
|
/// screen instead.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnTerminalFocusRequested(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
|
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
|
||||||
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
|
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns a host card's <c>MonogramHue</c> string into the background or foreground brush the mock pairs it
|
||||||
|
/// with.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The four pairs are the design's own and are not in <c>Palette.axaml</c>: that file is another wave's, and
|
||||||
|
/// none of the shared palette's existing keys is this hue ramp — a monogram's colour is a property of the
|
||||||
|
/// four-way hash in <c>HostRowViewModel.MonogramHue</c>, not a fact the rest of the theme has any use for.
|
||||||
|
/// Held here, beside the one screen that reads it, rather than invented as four new shared resources for a
|
||||||
|
/// single card.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Two instances rather than one converter taking a "which half" parameter, so the markup names each with a
|
||||||
|
/// <c>StaticResource</c>-shaped reference instead of a converter parameter that means nothing without
|
||||||
|
/// reading this file.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class MonogramBrushConverter(bool background) : IValueConverter
|
||||||
|
{
|
||||||
|
/// <summary>The fill behind the two letters.</summary>
|
||||||
|
internal static MonogramBrushConverter Background { get; } = new(background: true);
|
||||||
|
|
||||||
|
/// <summary>The colour of the two letters themselves.</summary>
|
||||||
|
internal static MonogramBrushConverter Foreground { get; } = new(background: false);
|
||||||
|
|
||||||
|
/// <summary>violet, green, amber, gray — the four pairs the mock assigns a monogram.</summary>
|
||||||
|
private static readonly Dictionary<string, (string Background, string Foreground)> Pairs = new(
|
||||||
|
StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["violet"] = ("#241C4F", "#A78BFA"),
|
||||||
|
["green"] = ("#103A2F", "#34D399"),
|
||||||
|
["amber"] = ("#423211", "#F5B942"),
|
||||||
|
["gray"] = ("#1E1E2C", "#9C9EB4"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
var pair = value is string hue && Pairs.TryGetValue(hue, out var found) ? found : Pairs["gray"];
|
||||||
|
|
||||||
|
return new SolidColorBrush(Color.Parse(background ? pair.Background : pair.Foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
throw new NotSupportedException("The monogram hue is read-only.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Media;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Paints the frame Windows still draws around a <c>BorderOnly</c> window in the application's own
|
||||||
|
/// colour, so the top edge stops reading as a leftover system titlebar.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The symptom this exists for:</b> a pale strip across the very top of the window, a few pixels
|
||||||
|
/// tall and plainly not part of the application — most obvious on a machine with "show accent colour
|
||||||
|
/// on title bars and window borders" turned on, where it comes out blue against a near-black shell.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It is not <c>TitleBar.axaml</c> leaking and it is not a margin. It is DWM, and the reason it is
|
||||||
|
/// there is visible in Avalonia's own Win32 backend: <c>WindowImpl.UpdateWindowProperties</c> gives a
|
||||||
|
/// <see cref="WindowDecorations.BorderOnly"/> window <c>WS_BORDER | WS_THICKFRAME</c> and then calls
|
||||||
|
/// <c>DwmExtendFrameIntoClientArea</c> with one-pixel margins on all four sides. So the compositor
|
||||||
|
/// owns a hairline of every edge of this window, and it fills that hairline with the system's caption
|
||||||
|
/// and border colours — which are chosen by the user's personalisation settings and have no reason to
|
||||||
|
/// resemble <c>CanvasColor</c>. The window is the wrong place to look for the pixels; they were never
|
||||||
|
/// painted by anything in this tree.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The fix is to tell DWM what colour to use rather than to try to cover it. <c>DWMWA_BORDER_COLOR</c>
|
||||||
|
/// and <c>DWMWA_CAPTION_COLOR</c> arrived in Windows 11 21H2 and are exactly that; both are set to the
|
||||||
|
/// window's own background, so the hairline still exists — the resize grip is on it, and the drop
|
||||||
|
/// shadow hangs off it — and simply cannot be seen. Deliberately <em>not</em> <c>DWMWA_COLOR_NONE</c>,
|
||||||
|
/// which removes the border outright: on a dark desktop that leaves a near-black window with no edge
|
||||||
|
/// at all, which trades one visual defect for another.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Windows 10 gets the dark-mode attribute and nothing else, and that is the whole of what is
|
||||||
|
/// available there: the two colour attributes are unsupported, <c>DwmSetWindowAttribute</c> answers
|
||||||
|
/// <c>E_INVALIDARG</c>, and the calls do nothing. Hence the ignored return values — every attribute
|
||||||
|
/// here is an improvement where it lands and a no-op where it does not, so there is nothing for a
|
||||||
|
/// caller to handle and nothing worth logging on a path that runs once at startup.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class NativeWindowFrame
|
||||||
|
{
|
||||||
|
/// <summary>Windows 11 21H2 and later: the colour of the frame border.</summary>
|
||||||
|
private const int BorderColorAttribute = 34;
|
||||||
|
|
||||||
|
/// <summary>Windows 11 21H2 and later: the colour of the caption, including the extended frame.</summary>
|
||||||
|
private const int CaptionColorAttribute = 35;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows 10 1903 and later: draw the frame in the dark palette.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Redundant on Windows 11, where the two colour attributes above name the colours outright, and it
|
||||||
|
/// is set anyway because it is the only one of the three that Windows 10 honours. The build before
|
||||||
|
/// 1903 used attribute 19 for this; that is not chased here, because a border on an OS release that
|
||||||
|
/// left support in 2020 is not worth a second interop call.
|
||||||
|
/// </remarks>
|
||||||
|
private const int DarkModeAttribute = 20;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Matches <paramref name="window"/>'s system-drawn frame to the colour it paints itself.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Call once the window has a handle — <c>OnOpened</c> is the first such moment. Calling earlier
|
||||||
|
/// finds no platform handle and silently does nothing, which is the defect this replaced: the strip
|
||||||
|
/// is only visible once the window is on screen, so a call that ran too early looks like a fix that
|
||||||
|
/// does not work rather than a fix that never ran.
|
||||||
|
/// </remarks>
|
||||||
|
internal static void MatchTo(Window window)
|
||||||
|
{
|
||||||
|
// Every attribute below is a DWM one, and DWM is Windows. Elsewhere the frame is drawn by the
|
||||||
|
// platform's own compositor and there is nothing here to say to it.
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.TryGetPlatformHandle()?.Handle is not { } handle || handle == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set(handle, DarkModeAttribute, 1);
|
||||||
|
|
||||||
|
// The window's own Background rather than a named resource, so the frame cannot drift from the
|
||||||
|
// canvas when the palette moves. A brush that is not solid — a gradient, or nothing set at all —
|
||||||
|
// has no single colour to match, and leaving the system's own is better than inventing one.
|
||||||
|
if (window.Background is not ISolidColorBrush { Color: var canvas })
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reference = ColorRef(canvas);
|
||||||
|
Set(handle, BorderColorAttribute, reference);
|
||||||
|
Set(handle, CaptionColorAttribute, reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets one integer-valued DWM attribute, and discards the answer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The discard is the point of this method existing rather than being three call sites. Every
|
||||||
|
/// attribute here is unsupported on some Windows this application runs on, and unsupported means
|
||||||
|
/// <c>E_INVALIDARG</c> and no change — which is the intended outcome on that OS, not a failure, so
|
||||||
|
/// there is nothing for the caller to do with the <c>HRESULT</c> and nothing worth logging once at
|
||||||
|
/// startup. Written once, with the reasoning, rather than left implicit at each call.
|
||||||
|
/// </remarks>
|
||||||
|
private static void Set(IntPtr window, int attribute, int value) =>
|
||||||
|
_ = DwmSetWindowAttribute(window, attribute, ref value, sizeof(int));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Packs <paramref name="color"/> into a Win32 <c>COLORREF</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>0x00BBGGRR</c> — blue in the high byte, not red, and the alpha byte must be zero. Getting the
|
||||||
|
/// order wrong produces a plausible-looking wrong colour rather than an error, which is the kind of
|
||||||
|
/// bug that survives a glance at the window.
|
||||||
|
/// </remarks>
|
||||||
|
private static int ColorRef(Color color) => color.R | (color.G << 8) | (color.B << 16);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>DllImport</c> rather than <c>LibraryImport</c>, for the reason
|
||||||
|
/// <see cref="NativeKeyboardFocus"/> gives at its own P/Invoke: the generated form needs
|
||||||
|
/// <c>AllowUnsafeBlocks</c> across a project that handles key material, and this signature is
|
||||||
|
/// blittable, so there is no marshalling for it to improve.
|
||||||
|
/// </remarks>
|
||||||
|
#pragma warning disable SYSLIB1054
|
||||||
|
[DllImport("dwmapi.dll")]
|
||||||
|
private static extern int DwmSetWindowAttribute(IntPtr window, int attribute, ref int value, int size);
|
||||||
|
#pragma warning restore SYSLIB1054
|
||||||
|
}
|
||||||
@@ -7,159 +7,308 @@
|
|||||||
<!--
|
<!--
|
||||||
The window's destinations, down the left edge.
|
The window's destinations, down the left edge.
|
||||||
|
|
||||||
Still called NavRail although v2 makes it 190 pixels wide and gives every entry a word: the type is
|
── v5b ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
named in MainWindow and in the layout suite, and "the list of places this window goes" is what it was
|
Redrawn against NavRail.dc.html, which changes more here than a fresh coat of paint. 190 pixels became
|
||||||
called when it was 54 pixels and is what it still is. The width is the thing that changed, not the job.
|
255. The counts v2 added beside every row are gone — the mock's own row is an icon and a word, nothing
|
||||||
|
else, and this pass follows it rather than keeping a feature the mock never had; nowhere else on screen
|
||||||
|
states the number instead, so it is simply not drawn any more. And two things this rail used to answer
|
||||||
|
to the tab strip now answer to the rail itself:
|
||||||
|
|
||||||
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
|
── THE SWITCHER, AT THE RAIL'S OWN HEAD. ─────────────────────────────────────────────────────────────
|
||||||
Three things the extra 136 pixels buy, and they are why the design widened it rather than a matter of
|
SSH, SFTP and S3 were the strip's three fixed tabs — Vaults, SFTP, S3 — until this pass moved the choice
|
||||||
taste. The five-character abbreviations are gone — PINS and SNIPS were a width constraint and are now
|
here as a segmented control, which is where the mock always drew it. Vaults did not come with it: the
|
||||||
Pins and Snippets. Each entry carries a glyph, so the list can be scanned by shape as well as read. And
|
rail's own item list below is what that tab used to gate, so a fourth segment naming it would have been
|
||||||
each carries a count, which is the one genuinely new fact: how many hosts, how many keys, how many pins
|
a second way to reach exactly what six rows underneath already reach. SSH, SFTP and S3 are a true
|
||||||
is a question you would otherwise have to open the screen to answer.
|
three-way here rather than two live segments and one dimmed — the mock leaves S3 unstyled as future
|
||||||
|
work, and this application already has bucket browsing, so it is wired like its two neighbours. See
|
||||||
|
<c>MainWindowViewModel.IsSshShowing</c>, <c>IsTransfersShowing</c> and <c>IsBucketsShowing</c>.
|
||||||
|
|
||||||
A count is drawn only where one is real. Logs and Preferences have none — a log has no total until it is
|
── THE RAIL IS NO LONGER DRAWN ONLY UNDER ONE TAB. ───────────────────────────────────────────────────
|
||||||
read, and preferences are not counted — so those two show nothing rather than a zero. The design draws a
|
It used to collapse whenever the strip was on SFTP, S3 or a terminal — see the version of this remark
|
||||||
number on every row; a zero beside Logs would be a fact this application never computed.
|
the v3 file carried, and <c>MainWindowViewModel.IsVaultsTab</c>, which still exists and still answers
|
||||||
|
the question it always did. What changed is <c>MainWindow.axaml</c>: the rail is part of the window's
|
||||||
|
own furniture now, the same as the titlebar and the status bar, so it stays up beside SFTP and S3 and
|
||||||
|
beside an open terminal — which is exactly what makes the switcher above worth having here at all.
|
||||||
|
|
||||||
── SFTP AND S3 ARE NOT HERE, and that is the tab strip's doing. ──────────────────────────────────────
|
── THE FIRST ROW IS MODE-DEPENDENT, exactly as the mock's own <c>mode</c> prop is. ───────────────────
|
||||||
Both were rail entries until the strip grew fixed tabs for them. They are the two destinations that are
|
One row rather than three shown and hidden by turn: its icon, its label and what it runs all come from
|
||||||
not about the keychain — they are a place you leave the keychain to work in, and you stay there while a
|
<c>MainWindowViewModel.FirstRailItemIcon</c>/<c>FirstRailItemLabel</c>/<c>ShowFirstRailItemCommand</c>,
|
||||||
transfer runs — which is exactly what a tab is for and what a rail entry is not. The rail is drawn only
|
which read the same three flags the switcher above lights — so the row and the segment can never name
|
||||||
under the Vaults tab now, so an entry here for either of them would be a route out of the tab it lives
|
two different modes between them.
|
||||||
in. See MainWindowViewModel.IsVaultsTab.
|
|
||||||
|
|
||||||
What this costs is that the S3 count has nowhere to go: the strip's tabs are one word each, and the rail
|
── Vaults and Preferences left the rail's own list for the user chip's popover, at the foot. ─────────
|
||||||
was where "how many buckets" was printed. It is on the S3 screen itself, which is where somebody
|
Both are still one click away; see the popover below the user chip. The chip itself carries the signed-
|
||||||
counting buckets is going anyway.
|
in identity this application actually has — a display name and, where the server sent one, an email —
|
||||||
|
which is also new: the titlebar drew an account name and a vault chip before this pass and does not any
|
||||||
|
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the one deviation this rail still
|
||||||
|
keeps on purpose: the S3 segment above. Pins was the other, and it is gone — see the remark where that
|
||||||
|
row used to sit, between Keys and Snips.
|
||||||
|
|
||||||
The last entry was TEAMS and is now VAULTS, which is a change of subject rather than of destination: the
|
Buttons rather than a TabStrip or a ListBox, still, for the reason the v3 remark gave: all three hold
|
||||||
screen behind it lists vaults and the people in each, where it used to list teams that owned vaults. See
|
the selection themselves, so a click would move the highlight before the shell decided anything, and a
|
||||||
VaultsViewModel. It shares its word with the tab strip's first tab; the two are different levels of the
|
Button carries no state to disagree with the screen that is actually showing.
|
||||||
window, and the button's own comment says which is which.
|
|
||||||
|
|
||||||
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
|
|
||||||
of those hold the selection themselves, so a click moves the highlight before the shell can decide
|
|
||||||
anything. Buttons carry no state and cannot disagree with the screen that is showing.
|
|
||||||
|
|
||||||
Lit from IsXShowing and not from IsXScreen, which are different questions now that the tab strip spans
|
|
||||||
every screen. A terminal opened from here leaves Screen on Hosts — deliberately, so closing the tab comes
|
|
||||||
back — and an entry lit while a terminal filled the window would be pointing at a screen that is not
|
|
||||||
showing. So nothing here is lit at all while a terminal is up: the selected tab already carries that
|
|
||||||
mark, in the strip, and two "you are here" marks is one too many.
|
|
||||||
|
|
||||||
The design pins a "Team vault" card to the foot of this list, beside a Team entry in the list itself.
|
|
||||||
Two routes to one screen, one of them carrying a seat count nothing here can produce, so what sits at
|
|
||||||
the foot is Preferences — which is where it already was, and which is the one entry that is about the
|
|
||||||
machine rather than about the keychain.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Width="190" Background="{StaticResource Sidebar}"
|
<Border Width="255" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||||
<DockPanel LastChildFill="False">
|
<!--
|
||||||
|
Border rather than Chip for the right-hand rule, although the mock's own value — rgb(26,26,40) — is
|
||||||
|
Chip's exact #1A1A28. Chip is the fill behind a tag, and reusing it here as a line would answer a
|
||||||
|
later "why does a rail border share a key with a bucket chip" with "it doesn't, they just happen to
|
||||||
|
match" — where Border, at #1E1E2C, is one shade off and reads identically at one pixel wide.
|
||||||
|
-->
|
||||||
|
<DockPanel LastChildFill="False" Margin="14">
|
||||||
|
|
||||||
<StackPanel DockPanel.Dock="Top" Margin="8,10,8,0" Spacing="2">
|
<StackPanel DockPanel.Dock="Top" Spacing="18">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The segmented switcher. Three equal columns in a Grid rather than a StackPanel with Width="*" on
|
||||||
|
each child — Avalonia gives a StackPanel's children their desired size, not an even split, and the
|
||||||
|
mock's three segments are exactly a third each.
|
||||||
|
-->
|
||||||
|
<Border Classes="navtrack">
|
||||||
|
<Grid ColumnDefinitions="*,*,*">
|
||||||
|
<Button Grid.Column="0" Classes="navseg" Classes.active="{Binding IsSshShowing}"
|
||||||
|
Command="{Binding ShowTerminalCommand}"
|
||||||
|
ToolTip.Tip="The terminal, and every shell you have open">
|
||||||
|
<TextBlock Text="SSH" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="1" Classes="navseg" Classes.active="{Binding IsTransfersShowing}"
|
||||||
|
Command="{Binding ShowFilesCommand}"
|
||||||
|
CommandParameter="{x:Static vm:RemoteKind.Host}"
|
||||||
|
ToolTip.Tip="Move files to and from a host over SFTP">
|
||||||
|
<TextBlock Text="SFTP" />
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2" Classes="navseg" Classes.active="{Binding IsBucketsShowing}"
|
||||||
|
Command="{Binding ShowFilesCommand}"
|
||||||
|
CommandParameter="{x:Static vm:RemoteKind.Bucket}"
|
||||||
|
ToolTip.Tip="Objects in an S3-compatible bucket from your keychain">
|
||||||
|
<TextBlock Text="S3" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The mode-dependent first row: Terminal, Files or Buckets, matching whichever segment above is
|
||||||
|
lit. Active follows !IsVaultsTab rather than a property of its own — that is already exactly
|
||||||
|
"the terminal surface, or the files screen, or the buckets screen", which is what this row is.
|
||||||
|
-->
|
||||||
|
<Button Classes="flat nav" Classes.active="{Binding !IsVaultsTab}"
|
||||||
|
Command="{Binding ShowFirstRailItemCommand}"
|
||||||
|
ToolTip.Tip="The terminal while SSH is selected, or whichever file picker SFTP or S3 chose">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" Text="{Binding FirstRailItemIcon}" />
|
||||||
|
<TextBlock Classes="navlabel" Text="{Binding FirstRailItemLabel}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsHostsShowing}"
|
<Button Classes="flat nav" Classes.active="{Binding IsHostsShowing}"
|
||||||
Command="{Binding ShowScreenCommand}"
|
Command="{Binding ShowScreenCommand}"
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
|
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
|
||||||
ToolTip.Tip="Your hosts, and what is known about the one you have selected">
|
ToolTip.Tip="Your hosts, and what is known about the one you have selected">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="▤" />
|
<TextBlock Classes="navicon" Text="" />
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Hosts" />
|
<TextBlock Classes="navlabel" Text="Hosts" />
|
||||||
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.Hosts.Count}" />
|
</StackPanel>
|
||||||
</Grid>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
TotalItemCount is keys plus passwords and deliberately excludes buckets, which used to disagree
|
Keys, not Keychain — the mock's own word for this screen, which still holds SSH keys and stored
|
||||||
with the list under it. It no longer does: buckets have their own entry above, so this number and
|
passwords; see ShellScreen.Keychain for the name that did not move with the label.
|
||||||
this screen now count the same things.
|
|
||||||
-->
|
-->
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsKeychainShowing}"
|
<Button Classes="flat nav" Classes.active="{Binding IsKeychainShowing}"
|
||||||
Command="{Binding ShowScreenCommand}"
|
Command="{Binding ShowScreenCommand}"
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Keychain}"
|
CommandParameter="{x:Static vm:ShellScreen.Keychain}"
|
||||||
ToolTip.Tip="Your keychain: SSH keys and stored passwords">
|
ToolTip.Tip="Your keychain: SSH keys and stored passwords">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="⚿" />
|
<TextBlock Classes="navicon" Text="" />
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Keychain" />
|
<TextBlock Classes="navlabel" Text="Keys" />
|
||||||
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.TotalItemCount}" />
|
</StackPanel>
|
||||||
</Grid>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
|
|
||||||
Command="{Binding ShowScreenCommand}"
|
|
||||||
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
|
||||||
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="◈" />
|
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Pins" />
|
|
||||||
<!--
|
<!--
|
||||||
The vault's pins, not the screen's VisiblePins — that one is the filtered list, so a sidebar
|
◆ NO Pins ROW. The pins screen is still here and still reached in one click — from "Host keys"
|
||||||
bound to it would count what the Pins screen's own filter box happens to match and would
|
on the Keys screen's own header, which is where a list of approved host keys belongs: they are
|
||||||
change as somebody typed in it. Every other count here is a total; this one has to be too.
|
keychain material, and that button was already the second way to reach them. Two rail rows away
|
||||||
|
from each other, both landing on the same screen, is a rail that has to be read twice.
|
||||||
|
|
||||||
|
It is also the last of the rail's own deviations from the mock to go. The row was kept in v5b on
|
||||||
|
the grounds that the design has no screen for approved host keys at all — see the file-level
|
||||||
|
remark — which is true of the design and was never a reason for a rail entry once the keychain
|
||||||
|
had a door to the same place.
|
||||||
-->
|
-->
|
||||||
<TextBlock Grid.Column="2" Classes="navcount"
|
|
||||||
Text="{Binding Vault.KnownHostPins.Count}" />
|
|
||||||
</Grid>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
|
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
|
||||||
Command="{Binding ShowScreenCommand}"
|
Command="{Binding ShowScreenCommand}"
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
|
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
|
||||||
ToolTip.Tip="Commands you have saved, and how to put one into a terminal">
|
ToolTip.Tip="Commands you have saved, and how to put one into a terminal">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="❯" />
|
<TextBlock Classes="navicon" Text="" />
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Snippets" />
|
<TextBlock Classes="navlabel" Text="Snips" />
|
||||||
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.Snippets.Count}" />
|
</StackPanel>
|
||||||
</Grid>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsLogsShowing}"
|
<Button Classes="flat nav" Classes.active="{Binding IsLogsShowing}"
|
||||||
Command="{Binding ShowScreenCommand}"
|
Command="{Binding ShowScreenCommand}"
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Logs}"
|
CommandParameter="{x:Static vm:ShellScreen.Logs}"
|
||||||
ToolTip.Tip="What has been connected to, and what has been changed in this keychain">
|
ToolTip.Tip="What has been connected to, and what has been changed in this keychain">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="≡" />
|
<TextBlock Classes="navicon" Text="" />
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Logs" />
|
<TextBlock Classes="navlabel" Text="Logs" />
|
||||||
</Grid>
|
</StackPanel>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
◆ THIS ENTRY SAID Teams UNTIL THE SCREEN BEHIND IT STOPPED BEING ABOUT THEM. A team is still what
|
|
||||||
the server authorises against; it is no longer something anybody has to make, name or think about,
|
|
||||||
so the rail names the thing people came for. See VaultsViewModel.
|
|
||||||
|
|
||||||
It shares a word with the tab strip's first tab, which is a different level of the window: that
|
|
||||||
tab is "this application rather than SFTP or S3", and this is one of the nine screens under it.
|
|
||||||
-->
|
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsVaultsShowing}"
|
|
||||||
Command="{Binding ShowScreenCommand}"
|
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Vaults}"
|
|
||||||
ToolTip.Tip="Your vaults, the people in each one, and who holds a key">
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="◎" />
|
|
||||||
<!--
|
|
||||||
No count. The vault list is the session's and could be counted here — but who is in each one
|
|
||||||
is read from the server when the screen is opened, not on unlock, and a number naming only
|
|
||||||
half of what the screen is about would be the one figure on this list that has to be
|
|
||||||
explained.
|
|
||||||
-->
|
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Vaults" />
|
|
||||||
</Grid>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Button DockPanel.Dock="Bottom" Classes="flat nav" Margin="8,0,8,10"
|
</StackPanel>
|
||||||
Classes.active="{Binding IsPreferencesShowing}"
|
|
||||||
Command="{Binding ShowScreenCommand}"
|
<!--
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}"
|
The rail's foot: the signed-in identity, and everything the strip's old Vaults tab used to gate
|
||||||
ToolTip.Tip="Preferences, and this machine's device key">
|
behind a caret. A Flyout is safe here without any ordering games: it opens inside the rail's own
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
255-pixel column, which the terminal's native child window never occupies — there is no rectangle
|
||||||
<TextBlock Grid.Column="0" Classes="navicon" Text="⚙" />
|
here a popup could be composited underneath, unlike the window-wide tab strip this rail's own
|
||||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Preferences" />
|
switcher replaced, which sat directly above that rectangle and had to select a page before opening
|
||||||
|
one for exactly that reason.
|
||||||
|
-->
|
||||||
|
<Button x:Name="UserChip" DockPanel.Dock="Bottom" Classes="flat navuser" Click="OnUserChipPressed">
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<Border Grid.Column="0" Width="20" Height="20" CornerRadius="60"
|
||||||
|
Background="{StaticResource AvatarGradient}">
|
||||||
|
<TextBlock Text="{Binding AvatarInitials}" FontWeight="Bold" FontSize="7.5" LetterSpacing="0.2"
|
||||||
|
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="10,0,0,0" Spacing="4">
|
||||||
|
<TextBlock FontWeight="Bold" FontSize="10.5" LetterSpacing="0.1"
|
||||||
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding AccountName}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock FontSize="8" Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||||
|
Text="▼" />
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<FlyoutBase.AttachedFlyout>
|
||||||
|
<!--
|
||||||
|
FlyoutPresenterClasses, because a Flyout's own panel is not in this markup's visual tree to be
|
||||||
|
styled from here — see FlyoutPresenter.poppanel in App.axaml for what the class carries and why
|
||||||
|
the shared popup rule was not simply widened to cover it.
|
||||||
|
-->
|
||||||
|
<Flyout Placement="TopEdgeAlignedLeft" FlyoutPresenterClasses="poppanel">
|
||||||
|
<StackPanel Width="227" Spacing="4">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The real email, when the server sent one — verified against MainWindowViewModel.Email rather
|
||||||
|
than assumed, and simply absent from the popover when it has not. No " · Org" suffix: there
|
||||||
|
is no organisation concept behind a vault, only the vault itself, which the rows below name.
|
||||||
|
-->
|
||||||
|
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1" Margin="11,4,11,6"
|
||||||
|
Foreground="{StaticResource TextGhost}"
|
||||||
|
Text="{Binding Email}" TextTrimming="CharacterEllipsis"
|
||||||
|
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One row per readable vault — the strip's old "SHOW ITEMS FROM" chips, restyled: a 14-pixel
|
||||||
|
initial square (Chip's own fill, since no per-vault colour exists to draw honestly) and a
|
||||||
|
magenta check where the vault's items are shown. Toggling one leaves the Flyout open, the
|
||||||
|
same as the chips it replaces did — this is a switch to flip, not a place to leave from.
|
||||||
|
-->
|
||||||
|
<ItemsControl ItemsSource="{Binding VaultToggles}" IsVisible="{Binding HasVaultSwitches}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultToggleViewModel">
|
||||||
|
<Button Classes="poprow"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ToggleVaultCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
<Border Grid.Column="0" Width="14" Height="14" CornerRadius="2"
|
||||||
|
Background="{StaticResource Chip}">
|
||||||
|
<TextBlock Text="{Binding Initial}" FontWeight="Bold" FontSize="9"
|
||||||
|
Foreground="{StaticResource TextDim}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Column="1" Margin="10,0" FontSize="10"
|
||||||
|
Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding Display}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<Border Grid.Column="2" Classes="vaultcheck" IsVisible="{Binding IsShown}">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="10"
|
||||||
|
Foreground="White"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<Button Classes="poprow" Click="OnPopoverNewVaultPressed"
|
||||||
|
ToolTip.Tip="Names a vault you can share, and opens it on the Vaults screen so you can add people to it and give them roles">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Text="New vault" FontSize="10" Foreground="{StaticResource TextGhost}" />
|
||||||
|
<TextBlock Grid.Column="1" FontFamily="{StaticResource IconFont}" Text=""
|
||||||
|
FontSize="12" Foreground="{StaticResource TextGhost}" />
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
|
||||||
|
see MainWindowViewModel.EnterSettings and SettingsView.axaml. Settings opens on General, the
|
||||||
|
mode's own default landing page; Vaults and Preferences open directly on the page they name,
|
||||||
|
which is also what anything that used to navigate to ShellScreen.Vaults or
|
||||||
|
ShellScreen.Preferences now does — see ShowScreen. Three handlers rather than the two rows
|
||||||
|
sharing one before this wave: the mock's Settings area was a family of screens that did not
|
||||||
|
exist yet, and now that it does, "Settings" and "Preferences" are no longer the same click.
|
||||||
|
-->
|
||||||
|
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextGhost}" />
|
||||||
|
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE SAME TREATMENT AS SETTINGS ABOVE AND LOGOUT BELOW, which these two did not have: their
|
||||||
|
label was TextGhost where the other two rows' was Text, so a menu of five equally live
|
||||||
|
destinations drew two of them in the colour this window uses for something switched off. The
|
||||||
|
icons stay one step quieter than the words — the idiom the nav rail's own rows already follow
|
||||||
|
— but "quieter than the word beside it" and "dimmed" are not the same statement.
|
||||||
|
-->
|
||||||
|
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextGhost}" />
|
||||||
|
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextGhost}" />
|
||||||
|
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The existing sign-out flow, with its own confirm card — see
|
||||||
|
MainWindowViewModel.SignOutFromPopover for why this goes through the Account settings page
|
||||||
|
rather than calling SignOutCommand directly from wherever the popover happened to be opened.
|
||||||
|
-->
|
||||||
|
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
|
Foreground="{StaticResource TextGhost}" />
|
||||||
|
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Flyout>
|
||||||
|
</FlyoutBase.AttachedFlyout>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -1,9 +1,97 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Primitives;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
/// <summary>The five destinations down the left edge of the unlocked window.</summary>
|
/// <summary>The window's destinations, down the left edge — the switcher, the six rail rows and the user chip.</summary>
|
||||||
internal sealed partial class NavRail : UserControl
|
internal sealed partial class NavRail : UserControl
|
||||||
{
|
{
|
||||||
public NavRail() => InitializeComponent();
|
public NavRail() => InitializeComponent();
|
||||||
|
|
||||||
|
/// <summary>Opens the user popover.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A handler rather than relying on the click that opening a <c>Flyout</c> answers to on its own: a
|
||||||
|
/// named method is a thing a test can call directly, where an implicit open is not.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnUserChipPressed(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Control chip)
|
||||||
|
{
|
||||||
|
FlyoutBase.ShowAttachedFlyout(chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hides the popover, whatever handler is about to navigate.</summary>
|
||||||
|
private void ClosePopover()
|
||||||
|
{
|
||||||
|
if (this.FindControl<Button>("UserChip") is { } chip)
|
||||||
|
{
|
||||||
|
FlyoutBase.GetAttachedFlyout(chip)?.Hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Leaves for the vaults screen with the new-vault form open, shutting the popover behind it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The popover is closed first: the command navigates, and a popup left open would be hanging over a
|
||||||
|
/// screen it has nothing to do with. A <c>Flyout</c> does not close on its own when something inside it
|
||||||
|
/// is pressed — which is what the vault switches above it want, and not what this wants.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnPopoverNewVaultPressed(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ClosePopover();
|
||||||
|
|
||||||
|
if (DataContext is MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
shell.ShowNewVaultCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens settings mode on its default landing page — see the remark in the markup.</summary>
|
||||||
|
private void OnPopoverSettingsPressed(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ClosePopover();
|
||||||
|
|
||||||
|
if (DataContext is MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
shell.EnterSettingsCommand.Execute(SettingsPage.General);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens settings mode on its Preferences page.</summary>
|
||||||
|
private void OnPopoverPreferencesPressed(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ClosePopover();
|
||||||
|
|
||||||
|
if (DataContext is MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens settings mode on its Vaults page.</summary>
|
||||||
|
private void OnPopoverVaultsPressed(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ClosePopover();
|
||||||
|
|
||||||
|
if (DataContext is MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
shell.EnterSettingsCommand.Execute(SettingsPage.Vaults);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a sign-out, through the Account settings page so the confirmation card has somewhere to be
|
||||||
|
/// seen — see <see cref="MainWindowViewModel.SignOutFromPopover"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void OnPopoverLogoutPressed(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
ClosePopover();
|
||||||
|
|
||||||
|
if (DataContext is MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
shell.SignOutFromPopoverCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,275 +0,0 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
|
||||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
|
||||||
x:Class="DodoSSH.Client.App.Views.PreferencesScreen"
|
|
||||||
x:DataType="vm:MainWindowViewModel">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Preferences.
|
|
||||||
|
|
||||||
The design's rail has six sections and its TERMINAL panel has six settings. One of those six is now
|
|
||||||
real: text size. It needed all three of the things this comment used to record as missing — somewhere
|
|
||||||
to keep a preference (settings.json beside the cache, outside it deliberately, so it can be read on a
|
|
||||||
launch that never unlocks anything), a frame carrying a terminal option (TerminalServerOpcode.FontSize),
|
|
||||||
and a way for the page's own chords to reach the host that owns the value
|
|
||||||
(TerminalClientOpcode.FontSizeStep). The rule that kept it off this screen until then still stands and
|
|
||||||
is why the storage came first: a stepper that reset on every launch is worse than no stepper.
|
|
||||||
|
|
||||||
So this screen ships what is real, which is not nothing: this machine's device key is a genuine
|
|
||||||
preference with a genuine effect, and it is the one thing on the design's SECURITY panel that exists.
|
|
||||||
The two commands behind it were already in the shell; they were merely homeless, wedged into the old
|
|
||||||
account bar because there was nowhere else to put them.
|
|
||||||
|
|
||||||
Everything else is listed as absent rather than omitted, because a preferences screen that is silent
|
|
||||||
about the settings it has not got reads as a product with six preferences.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<ScrollViewer>
|
|
||||||
<StackPanel MaxWidth="620" Margin="28,26" HorizontalAlignment="Left">
|
|
||||||
|
|
||||||
<TextBlock Classes="mono" Text="THIS MACHINE" FontSize="14" FontWeight="SemiBold"
|
|
||||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
|
||||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
|
||||||
<TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="13"
|
|
||||||
FontWeight="Medium" />
|
|
||||||
<TextBlock Classes="hint" FontSize="11"
|
|
||||||
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
|
|
||||||
</StackPanel>
|
|
||||||
<Button Grid.Column="1" Classes="accent" Content="REGISTER"
|
|
||||||
Command="{Binding RegisterDeviceCommand}"
|
|
||||||
IsEnabled="{Binding !IsBusy}"
|
|
||||||
IsVisible="{Binding CanRegisterDevice}" />
|
|
||||||
<!--
|
|
||||||
The withdrawal, in the place the offer was. Its own flag rather than the negation of that one: a
|
|
||||||
machine with no TPM and a machine that is already registered are both "cannot register", and only
|
|
||||||
the second has anything to take back.
|
|
||||||
-->
|
|
||||||
<Button Grid.Column="1" Classes="danger" Content="STOP UNLOCKING HERE"
|
|
||||||
Command="{Binding ForgetDeviceCommand}"
|
|
||||||
IsEnabled="{Binding !IsBusy}"
|
|
||||||
IsVisible="{Binding CanForgetDevice}"
|
|
||||||
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Neither flag is set on a machine that cannot keep a key at all, and that is worth saying. -->
|
|
||||||
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
|
||||||
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
|
||||||
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
|
||||||
|
|
||||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
|
||||||
|
|
||||||
<TextBlock Classes="mono" Text="UPDATES" FontSize="14" FontWeight="SemiBold"
|
|
||||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Not on the design at all, unlike everything else here. It arrived with packaging: an installed
|
|
||||||
client can replace itself, and the moment that is true the question of where a replacement comes
|
|
||||||
from stops being theoretical. The answer is the security content of this section rather than a
|
|
||||||
footnote to it, which is why it is printed under the version instead of hidden in a tooltip.
|
|
||||||
-->
|
|
||||||
<TextBlock Classes="mono" Text="{Binding Updates.CurrentVersion}" FontSize="12" Margin="0,8,0,0"
|
|
||||||
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
|
|
||||||
<TextBlock Classes="hint" FontSize="11" Margin="0,4,0,0"
|
|
||||||
Text="Builds come from the project's own release page, and never from the server you sign in to. That is deliberate: whoever hands you the client can hand you a client that copies your passphrase, and the operator of a DodoSSH deployment is the party the trust model is about. A deployment may tell you where to get it. It is not where it comes from." />
|
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
|
||||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
|
||||||
<TextBlock Text="Check for updates" Foreground="{StaticResource Text}" FontSize="13"
|
|
||||||
FontWeight="Medium" />
|
|
||||||
<TextBlock Classes="hint" FontSize="11"
|
|
||||||
Text="Asks the release page whether there is a newer build, and downloads it if there is. Nothing is ever installed while you are using it — a downloaded update waits for a restart you ask for, or for the next time you start DodoSSH." />
|
|
||||||
</StackPanel>
|
|
||||||
<Button x:Name="CheckNowButton" Grid.Column="1" Classes="ghost" Content="CHECK NOW"
|
|
||||||
Command="{Binding Updates.CheckNowCommand}"
|
|
||||||
IsEnabled="{Binding Updates.CanCheckNow}" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
|
||||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
|
||||||
<TextBlock Text="Check on its own" Foreground="{StaticResource Text}" FontSize="13"
|
|
||||||
FontWeight="Medium" />
|
|
||||||
<TextBlock Classes="hint" FontSize="11"
|
|
||||||
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
|
|
||||||
</StackPanel>
|
|
||||||
<CheckBox x:Name="AutomaticUpdatesToggle" Grid.Column="1" VerticalAlignment="Top"
|
|
||||||
IsChecked="{Binding Updates.IsAutomatic}"
|
|
||||||
IsEnabled="{Binding Updates.IsSupported}" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- The only other ProgressBar in the application is the transfers one; same height, same brushes. -->
|
|
||||||
<ProgressBar Height="4" Minimum="0" Maximum="100" Margin="0,12,0,0"
|
|
||||||
Value="{Binding Updates.DownloadPercent}"
|
|
||||||
Foreground="{StaticResource Accent}" Background="{StaticResource Raised}"
|
|
||||||
IsVisible="{Binding Updates.IsDownloading}" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The restart, with the sentence the banner only has room for in a tooltip. This screen scrolls, so
|
|
||||||
this is where the warning can be as long as it needs to be — and it needs to be, because this
|
|
||||||
application has spent a lot of words teaching that locking keeps shells running.
|
|
||||||
-->
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0" IsVisible="{Binding Updates.IsReady}">
|
|
||||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
|
||||||
<TextBlock Text="{Binding Updates.ReadyHeadline}" Foreground="{StaticResource Text}"
|
|
||||||
FontSize="13" FontWeight="Medium" TextWrapping="Wrap" />
|
|
||||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding Updates.RestartWarning}" />
|
|
||||||
</StackPanel>
|
|
||||||
<Button Grid.Column="1" Classes="accent" Content="RESTART NOW"
|
|
||||||
Command="{Binding Updates.RestartNowCommand}" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
|
||||||
Text="{Binding Updates.Status}"
|
|
||||||
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The HasNoDeviceKeyOption precedent, one section up: a machine that gets none of the above is told
|
|
||||||
why rather than shown three controls that cannot do anything.
|
|
||||||
-->
|
|
||||||
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
|
||||||
Text="This copy of DodoSSH cannot replace itself, so none of the above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
|
||||||
IsVisible="{Binding Updates.IsUnsupported}" />
|
|
||||||
|
|
||||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
|
||||||
|
|
||||||
<TextBlock Classes="mono" Text="TERMINAL" FontSize="14" FontWeight="SemiBold"
|
|
||||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Here as well as on the chord, and not because the chord is in doubt. Ctrl+plus can only be heard
|
|
||||||
while a terminal has focus, since that is where the keyboard is being read — so somebody who has
|
|
||||||
not opened one yet, or who has just made the text too small to find anything in, has nowhere else
|
|
||||||
to look. This is that place, and it names the chord so the screen teaches it rather than replacing
|
|
||||||
it.
|
|
||||||
-->
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
|
||||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
|
||||||
<TextBlock Text="Text size" Foreground="{StaticResource Text}" FontSize="13"
|
|
||||||
FontWeight="Medium" />
|
|
||||||
<TextBlock Classes="hint" FontSize="11"
|
|
||||||
Text="How large a terminal draws, in pixels. Ctrl+plus and Ctrl+minus do the same while a terminal has focus, and Ctrl+0 puts it back. It resizes the grid rather than magnifying it, so the remote is told how many columns it now has — which is also why it stops before the columns run out." />
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" VerticalAlignment="Top">
|
|
||||||
<Button Classes="ghost" Content="A−" Command="{Binding ShrinkTerminalFontCommand}"
|
|
||||||
IsEnabled="{Binding CanShrinkTerminalFont}"
|
|
||||||
ToolTip.Tip="Smaller · Ctrl+minus" />
|
|
||||||
<TextBlock Classes="mono" FontSize="13" MinWidth="26" VerticalAlignment="Center"
|
|
||||||
TextAlignment="Center" Foreground="{StaticResource Text}"
|
|
||||||
Text="{Binding TerminalFontSize}" />
|
|
||||||
<Button Classes="ghost" Content="A+" Command="{Binding EnlargeTerminalFontCommand}"
|
|
||||||
IsEnabled="{Binding CanEnlargeTerminalFont}"
|
|
||||||
ToolTip.Tip="Larger · Ctrl+plus" />
|
|
||||||
<Button Classes="ghost" Content="RESET" Command="{Binding ResetTerminalFontCommand}"
|
|
||||||
ToolTip.Tip="Back to the size it ships at · Ctrl+0" />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
|
||||||
|
|
||||||
<TextBlock Classes="mono" Text="KEYCHAIN" FontSize="14" FontWeight="SemiBold"
|
|
||||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
|
||||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
|
||||||
<TextBlock Text="Lock the keychain" Foreground="{StaticResource Text}" FontSize="13"
|
|
||||||
FontWeight="Medium" />
|
|
||||||
<TextBlock Classes="hint" FontSize="11"
|
|
||||||
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
|
|
||||||
</StackPanel>
|
|
||||||
<Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
|
||||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
|
||||||
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="13"
|
|
||||||
FontWeight="Medium" />
|
|
||||||
<TextBlock Classes="hint" FontSize="11"
|
|
||||||
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
|
||||||
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
|
|
||||||
IsVisible="{Binding !IsOnline}"
|
|
||||||
ToolTip.Tip="Opens your browser. Only needed when there is no remembered session to resume — after signing out, or once your identity provider stops accepting the one this machine held." />
|
|
||||||
<Button Classes="ghost" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
|
||||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
|
||||||
<TextBlock Text="Import from ~/.ssh/config" Foreground="{StaticResource Text}" FontSize="13"
|
|
||||||
FontWeight="Medium" />
|
|
||||||
<TextBlock Classes="hint" FontSize="11"
|
|
||||||
Text="Reads this machine's OpenSSH configuration and offers what it finds. It shows you the list first and stores nothing until you say so, and it does not read any private key — where a key file is named, the path is recorded as a note." />
|
|
||||||
</StackPanel>
|
|
||||||
<Button Grid.Column="1" Classes="ghost" Content="IMPORT HOSTS"
|
|
||||||
Command="{Binding ShowScreenCommand}"
|
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Import}" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
|
||||||
|
|
||||||
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="14" FontWeight="SemiBold"
|
|
||||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
|
||||||
|
|
||||||
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="12" Margin="0,8,0,0"
|
|
||||||
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
|
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
|
||||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
|
||||||
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="13"
|
|
||||||
FontWeight="Medium" />
|
|
||||||
<TextBlock Classes="hint" FontSize="11"
|
|
||||||
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
|
||||||
</StackPanel>
|
|
||||||
<!--
|
|
||||||
Hidden rather than disabled while the confirmation is up, because the card below carries the
|
|
||||||
button that actually does it and two sign-out buttons on one screen is one too many.
|
|
||||||
-->
|
|
||||||
<Button Grid.Column="1" Classes="danger" Content="SIGN OUT"
|
|
||||||
Command="{Binding SignOutCommand}"
|
|
||||||
IsEnabled="{Binding !IsBusy}"
|
|
||||||
IsVisible="{Binding !IsConfirmingSignOut}" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
|
||||||
BorderThickness="1" CornerRadius="6" Padding="14" Margin="0,14,0,0"
|
|
||||||
IsVisible="{Binding IsConfirmingSignOut}">
|
|
||||||
<views:SignOutCard />
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
|
||||||
|
|
||||||
<TextBlock Classes="mono" Text="NOT BUILT YET" FontSize="14" FontWeight="SemiBold"
|
|
||||||
LetterSpacing="1" Foreground="{StaticResource TextDim}" />
|
|
||||||
<TextBlock Classes="hint" FontSize="12" Margin="0,8,0,0"
|
|
||||||
Text="These are on the design and have nothing behind them. They are listed rather than left out, so that what this screen does not do is as legible as what it does. The full list, and what each would take, is in docs/design-import-gaps.md." />
|
|
||||||
|
|
||||||
<ItemsControl Margin="0,12,0,0">
|
|
||||||
<ItemsControl.Styles>
|
|
||||||
<Style Selector="TextBlock.gap">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
|
||||||
<Setter Property="FontSize" Value="11" />
|
|
||||||
<Setter Property="TextWrapping" Value="Wrap" />
|
|
||||||
<Setter Property="Margin" Value="0,0,0,7" />
|
|
||||||
</Style>
|
|
||||||
</ItemsControl.Styles>
|
|
||||||
<TextBlock Classes="gap"
|
|
||||||
Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." />
|
|
||||||
<TextBlock Classes="gap"
|
|
||||||
Text="A beta channel — there is one release channel, and a switch offering a second would be a preference with nothing behind it." />
|
|
||||||
<TextBlock Classes="gap"
|
|
||||||
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
|
|
||||||
<TextBlock Classes="gap"
|
|
||||||
Text="Per-use approval before a key signs — keys are handed to the SSH stack whole at connect time, so there is no per-signature moment to interrupt." />
|
|
||||||
<TextBlock Classes="gap"
|
|
||||||
Text="SSO and organisation policy — the server has endpoints for membership and none for policy, so there is nothing for this screen to show." />
|
|
||||||
<TextBlock Classes="gap"
|
|
||||||
Text="Keyboard shortcuts — the window binds one chord, and the terminal keeps the rest for the remote." />
|
|
||||||
</ItemsControl>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
</UserControl>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
|
||||||
|
|
||||||
/// <summary>Preferences: what this build can actually change, and a list of what it cannot.</summary>
|
|
||||||
internal sealed partial class PreferencesScreen : UserControl
|
|
||||||
{
|
|
||||||
public PreferencesScreen() => InitializeComponent();
|
|
||||||
}
|
|
||||||
@@ -27,20 +27,26 @@
|
|||||||
from "inside": a press anywhere on the card below reports that control as its source and bubbles through
|
from "inside": a press anywhere on the card below reports that control as its source and bubbles through
|
||||||
here on its way to the window.
|
here on its way to the window.
|
||||||
-->
|
-->
|
||||||
<!-- 80% of Canvas. Written out because a scrim is a brush with an alpha, and the palette holds no alpha
|
<!-- 60% of Canvas. Written out because a scrim is a brush with an alpha, and the palette holds no alpha
|
||||||
variant of a surface — see Palette.axaml, where the only pre-multiplied brushes are the accent washes. -->
|
variant of a surface — see Palette.axaml, where the only pre-multiplied brushes are the accent washes. -->
|
||||||
<Border x:Name="Backdrop" Background="#CC0E1220" PointerPressed="OnBackdropPressed">
|
<Border x:Name="Backdrop" Background="#9905050A" PointerPressed="OnBackdropPressed">
|
||||||
<Border Width="520" VerticalAlignment="Top" Margin="0,90,0,0"
|
<Border Width="640" VerticalAlignment="Top" Margin="0,160,0,0"
|
||||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
|
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
|
||||||
BorderThickness="1" CornerRadius="6">
|
BorderThickness="1" CornerRadius="14">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|
||||||
<Border Padding="12,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
<!--
|
||||||
|
">_" rather than the bare ">" v4 drew: the mock's own prompt glyph, and the underscore is what
|
||||||
|
reads as a cursor waiting for the first keystroke rather than a stray angle bracket. There is
|
||||||
|
still no drawn caret in the field beside it, for the reason TextBox.address in this file gives —
|
||||||
|
it already has a real one.
|
||||||
|
-->
|
||||||
|
<Border Padding="16,14" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||||
<Grid ColumnDefinitions="Auto,*">
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
<TextBlock Grid.Column="0" Classes="mono" Text=">" FontSize="13"
|
<TextBlock Grid.Column="0" Classes="mono" Text=">_" FontSize="14" FontWeight="Bold"
|
||||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||||
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
|
<TextBox Grid.Column="1" x:Name="Query" Text="{Binding SearchText}"
|
||||||
PlaceholderText="search hosts" Margin="8,0,0,0"
|
PlaceholderText="search hosts" Margin="10,0,0,0"
|
||||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||||
Background="Transparent" BorderThickness="0" />
|
Background="Transparent" BorderThickness="0" />
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -54,32 +60,50 @@
|
|||||||
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
|
looks like it does — the keyboard route already treats choosing a row and connecting to it as one
|
||||||
act, and a pointer that only highlighted would leave the palette open over a choice already made.
|
act, and a pointer that only highlighted would leave the palette open over a choice already made.
|
||||||
The gesture is wired in the code-behind, as the sidebar's double-click is.
|
The gesture is wired in the code-behind, as the sidebar's double-click is.
|
||||||
|
|
||||||
|
Classes="tiles" clears the theme's full-bleed hover and selection wash the way the hosts grid's own
|
||||||
|
cards do — see the remark on Border.qcrow in App.axaml, which is what paints every state here
|
||||||
|
instead. Rows rather than cards, but the same reason applies: a square wash behind a rounded shape
|
||||||
|
shows its corners.
|
||||||
-->
|
-->
|
||||||
<ListBox x:Name="Results" MaxHeight="280" Focusable="False"
|
<ListBox x:Name="Results" Classes="tiles" MaxHeight="320" Focusable="False"
|
||||||
ItemsSource="{Binding SearchResults}"
|
ItemsSource="{Binding SearchResults}"
|
||||||
SelectedItem="{Binding SelectedSearchResult}">
|
SelectedItem="{Binding SelectedSearchResult}">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,8,12,8">
|
<Border Classes="qcrow" Height="44" CornerRadius="9" Padding="10,0" Margin="8,2">
|
||||||
<Border Grid.Column="0" Classes="rowmark" />
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<StackPanel Grid.Column="1" Spacing="1" Margin="12,0,0,0">
|
<!--
|
||||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="13" FontWeight="Medium"
|
Green when this host has a terminal open right now, grey otherwise — the same two-state
|
||||||
Foreground="{StaticResource Text}" />
|
dot the hosts grid draws, and the same reason: nothing here pings a machine, so a third
|
||||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="10.5"
|
colour would be a claim this application never checks. See Ellipse.dot in App.axaml.
|
||||||
Foreground="{StaticResource TextFaint}" />
|
-->
|
||||||
|
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
|
||||||
|
Width="8" Height="8" VerticalAlignment="Center" />
|
||||||
|
<StackPanel Grid.Column="1" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono qcrow-text" Text="{Binding Label}" FontSize="13"
|
||||||
|
FontWeight="Medium" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Address}" FontSize="11.5"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Authentication}" FontSize="10"
|
<!--
|
||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
||||||
|
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
||||||
|
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
||||||
|
-->
|
||||||
|
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||||
|
FontSize="11" Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
|
||||||
<TextBlock Classes="hint" FontSize="12" Margin="14,16"
|
<TextBlock Classes="hint" FontSize="12" Margin="18,16"
|
||||||
Text="No host matches that."
|
Text="No host matches that."
|
||||||
IsVisible="{Binding !HasSearchResults}" />
|
IsVisible="{Binding !HasSearchResults}" />
|
||||||
|
|
||||||
<Border Padding="12,7" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
<Border Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||||
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
<TextBlock Classes="mono" FontSize="10" Foreground="{StaticResource TextFaint}"
|
||||||
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
|
Text="↑ ↓ to choose · ENTER or click to connect · ESC to close" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SessionSidebar"
|
||||||
|
x:Name="Root"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── v5b's session sidebar ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
300 pixels, Sidebar background, a 1px left border — the design's own right-hand column on both the
|
||||||
|
terminal and the SFTP screen. It replaces the pin chip strip that used to sit above the terminal: see
|
||||||
|
MainWindowViewModel.ActiveTabPinnedPaths and OpenPinnedPathCommand, both reused here unchanged, and
|
||||||
|
MainWindow.axaml for where the old strip's Border used to live.
|
||||||
|
|
||||||
|
QUICK ACCESS is on both surfaces. SNIPS is the terminal's own — gated on <see cref="ShowsSnips"/>, which
|
||||||
|
the terminal usage in MainWindow.axaml sets true and the SFTP usage leaves false, rather than a second
|
||||||
|
copy of this file: the two sections share nothing surface-specific except which one is drawn at all.
|
||||||
|
|
||||||
|
Every row here is a command the shell already exposes for exactly this purpose — see
|
||||||
|
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
|
||||||
|
so this control carries no logic of its own beyond the list it draws and the click it forwards.
|
||||||
|
|
||||||
|
── v5c-4: THE SESSION BLOCK AT THE HEAD, AND THE HEADER ROW THAT IS GONE ─────────────────────────────────
|
||||||
|
The 60-pixel host header that used to sit above the pane on both surfaces has been retired, and its two
|
||||||
|
contents moved up here: the address it printed, and the cross-surface button — "Open SFTP" from a
|
||||||
|
terminal, "Open terminal" from SFTP. Which of the two words it is and which command it runs are resolved
|
||||||
|
by the shell now rather than handed in from the two usage sites; see
|
||||||
|
MainWindowViewModel.SessionCrossSurfaceLabel and OpenOtherSurfaceCommand. The pane keeps that height.
|
||||||
|
|
||||||
|
The address is the fact the header row existed for, so it moves rather than disappears. It sits where the
|
||||||
|
QUICK ACCESS heading used to print the selected tab's short label — that label said less than the address
|
||||||
|
does and would be the same word twice beside it.
|
||||||
|
|
||||||
|
── AND THE COLUMN CLOSES ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
300 pixels is a lot of a 1180-pixel window to give a list that is often two rows long, so the column
|
||||||
|
folds to a 34-pixel rail carrying the way back. A rail rather than nothing: a panel that vanishes without
|
||||||
|
trace is one people report as lost. Both halves live in this control and swap on
|
||||||
|
MainWindowViewModel.IsSessionSidebarOpen, so MainWindow.axaml's own "Auto" column takes whichever width
|
||||||
|
is showing without knowing anything about the state — and the pane beside it grows into what is freed.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Panel>
|
||||||
|
|
||||||
|
<!-- ============ THE RAIL, WHEN THE COLUMN IS CLOSED ============ -->
|
||||||
|
<!--
|
||||||
|
Painted and bordered like the open column so the closing reads as the same surface narrowing rather
|
||||||
|
than as one piece of furniture being swapped for another.
|
||||||
|
-->
|
||||||
|
<Border Width="34" Background="{StaticResource Sidebar}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
||||||
|
IsVisible="{Binding !IsSessionSidebarOpen}">
|
||||||
|
<Button Classes="flat sidebargrip" VerticalAlignment="Top" Margin="0,20,0,0"
|
||||||
|
Command="{Binding ToggleSessionSidebarCommand}"
|
||||||
|
ToolTip.Tip="Show quick access, snips and the way across to the other surface">
|
||||||
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="18"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ============ THE COLUMN ============ -->
|
||||||
|
<Border Width="300" Background="{StaticResource Sidebar}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
||||||
|
IsVisible="{Binding IsSessionSidebarOpen}">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel Spacing="6" Margin="16,20">
|
||||||
|
|
||||||
|
<!-- ============ THE SESSION ============ -->
|
||||||
|
<!--
|
||||||
|
The address, and the button that closes the column. Both on one row, and the address is the
|
||||||
|
trimming one: a long account@host:port is exactly the string that would otherwise push the
|
||||||
|
close button off the edge of a panel whose whole point is that it can be got rid of.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="8,0,0,0">
|
||||||
|
<TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
|
||||||
|
FontSize="12.5" Foreground="{StaticResource AccentText}"
|
||||||
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}" />
|
||||||
|
<Button Grid.Column="1" Classes="flat sidebargrip"
|
||||||
|
Command="{Binding ToggleSessionSidebarCommand}"
|
||||||
|
ToolTip.Tip="Close this column. The terminal takes the width, and the rail it leaves behind brings it back.">
|
||||||
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="18"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The cross-surface button, stretched across the column rather than sized to its own caption: it
|
||||||
|
is the one action in this panel that is not a list row, and a 90-pixel button floating at the
|
||||||
|
left of a 300-pixel column would read as unfinished.
|
||||||
|
-->
|
||||||
|
<Button Classes="headerghost" HorizontalAlignment="Stretch" Margin="0,4,0,10"
|
||||||
|
Content="{Binding SessionCrossSurfaceLabel}"
|
||||||
|
Command="{Binding OpenOtherSurfaceCommand}" />
|
||||||
|
|
||||||
|
<!-- ============ QUICK ACCESS ============ -->
|
||||||
|
<TextBlock Classes="label" Text="QUICK ACCESS" FontSize="10" Margin="8,0" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="x:String">
|
||||||
|
<!--
|
||||||
|
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin
|
||||||
|
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up
|
||||||
|
from the path's string DataContext lands on it.
|
||||||
|
-->
|
||||||
|
<Button Classes="sidebarrow"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip.Tip="{Binding}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||||
|
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
|
||||||
|
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
|
||||||
|
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- ============ SNIPS (the terminal surface only) ============ -->
|
||||||
|
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
|
||||||
|
|
||||||
|
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||||
|
<Button Classes="sidebarrow"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip.Tip="{Binding Snippet.Command}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
|
||||||
|
FontSize="11" Foreground="{StaticResource AccentText}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}"
|
||||||
|
ToolTip.Tip="Opens the snippet editor.">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="14"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v5b session sidebar: QUICK ACCESS on both surfaces, SNIPS on the terminal's own. See the remark at
|
||||||
|
/// the top of SessionSidebar.axaml.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed partial class SessionSidebar : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>Whether the SNIPS section draws below QUICK ACCESS.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Set from the usage site rather than inferred from a surface flag on the shell, for the same reason
|
||||||
|
/// <see cref="SessionTabRow.TabCommand"/> is: which sections a particular instance of this control shows
|
||||||
|
/// is a fact about where it was placed in <c>MainWindow.axaml</c>, not one this control can read off its
|
||||||
|
/// own <c>DataContext</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal static readonly StyledProperty<bool> ShowsSnipsProperty =
|
||||||
|
AvaloniaProperty.Register<SessionSidebar, bool>(nameof(ShowsSnips));
|
||||||
|
|
||||||
|
public SessionSidebar() => InitializeComponent();
|
||||||
|
|
||||||
|
internal bool ShowsSnips
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsSnipsProperty);
|
||||||
|
set => SetValue(ShowsSnipsProperty, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SessionStatusBar"
|
||||||
|
x:Name="Root"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── v5b's session shell status bar ───────────────────────────────────────────────────────────────────────
|
||||||
|
37px, DeepChrome, a 1px top border — the foot of the bordered container both the terminal and the SFTP
|
||||||
|
surface share. The design's own row also carries a negotiated cipher and a host-key-plus-identity run
|
||||||
|
(`ed25519 · acme-deploy-key`, clickable to a pin-details modal); both are now real and bound, plumbed all
|
||||||
|
the way from SSH.NET through TerminalWorkspace.GetSessionFacts and TransfersViewModel to
|
||||||
|
MainWindowViewModel's own surface-aware properties — see SessionCipher, SessionHostKeyAlgorithm,
|
||||||
|
SessionIdentityLabel and the composed SessionIdentityText.
|
||||||
|
|
||||||
|
Three honest deviations from the mock. The algorithm prints exactly as negotiated — "ssh-ed25519", not
|
||||||
|
the design's shortened "ed25519" — because trimming it would be a cosmetic edit to a string this client
|
||||||
|
did not choose. The run is plain text, not a button: this application has no pin-details modal for a
|
||||||
|
session that is already open, and drawing a click target for a screen that does not exist would be the
|
||||||
|
fabrication this project's honesty rule forbids, not an honest omission of one. And a typed-password
|
||||||
|
session — nothing filed in the keychain to name — shows the algorithm alone, with no " · " after it,
|
||||||
|
because there is no item behind the dot.
|
||||||
|
|
||||||
|
What is real and bound: the CONNECTED word and dot, off IsSessionConnected, shown only while there is a
|
||||||
|
session/host context to report on at all — SessionAddress null means nothing here has anything to say,
|
||||||
|
the same state the header answers with its own empty-state sentence; the cipher and the host-key/identity
|
||||||
|
run, each collapsed rather than shown empty or fabricated when the fact behind it is not there — a bucket
|
||||||
|
connection has neither, and a session whose facts could not be read back at the instant they were asked
|
||||||
|
for has neither either; the elapsed timer, off SessionElapsedText, which is null and therefore absent
|
||||||
|
whenever there is nothing timed; and, only on the terminal surface — see <see cref="ShowsEncoding"/> —
|
||||||
|
"UTF-8", which is a true fact about this client's own renderer and write path (see TerminalWorkspace's
|
||||||
|
terminal.js and SshShellSessionExtensions.WriteTextAsync) rather than a negotiated session parameter, and
|
||||||
|
is worded plainly rather than as a claim the remote agreed to it.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Border Height="37" Background="{StaticResource DeepChrome}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="24,0">
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
|
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||||
|
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
|
||||||
|
IsVisible="{Binding IsSessionConnected}" />
|
||||||
|
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
|
||||||
|
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
|
||||||
|
IsVisible="{Binding !IsSessionConnected}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||||
|
VerticalAlignment="Center" Text="{Binding SessionCipher}"
|
||||||
|
IsVisible="{Binding SessionCipher, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||||
|
VerticalAlignment="Center" Text="{Binding SessionIdentityText}"
|
||||||
|
IsVisible="{Binding SessionIdentityText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||||
|
VerticalAlignment="Center" Text="{Binding SessionElapsedText}"
|
||||||
|
IsVisible="{Binding SessionElapsedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||||
|
VerticalAlignment="Center" Text="UTF-8"
|
||||||
|
IsVisible="{Binding #Root.ShowsEncoding}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v5b session shell's status bar: CONNECTED and its dot, the negotiated cipher, the host key's algorithm
|
||||||
|
/// and — where one authenticated — the identity that did, the elapsed timer, and — only where it is true —
|
||||||
|
/// UTF-8. See the remark at the top of SessionStatusBar.axaml.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed partial class SessionStatusBar : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Whether "UTF-8" is drawn on the right.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Set true only by the terminal surface's own usage in <c>MainWindow.axaml</c>. It is a fact about this
|
||||||
|
/// client's renderer and its write path — see the remark on <c>SessionStatusBar.axaml</c> — and has
|
||||||
|
/// nothing to do with an SFTP session, which moves bytes rather than decoded text.
|
||||||
|
/// </remarks>
|
||||||
|
internal static readonly StyledProperty<bool> ShowsEncodingProperty =
|
||||||
|
AvaloniaProperty.Register<SessionStatusBar, bool>(nameof(ShowsEncoding));
|
||||||
|
|
||||||
|
public SessionStatusBar() => InitializeComponent();
|
||||||
|
|
||||||
|
internal bool ShowsEncoding
|
||||||
|
{
|
||||||
|
get => GetValue(ShowsEncodingProperty);
|
||||||
|
set => SetValue(ShowsEncodingProperty, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SessionTabRow"
|
||||||
|
x:Name="Root"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── v5b's in-screen tab row, the replacement for TerminalTabs ───────────────────────────────────────────
|
||||||
|
One pill per open terminal and the button that opens another — the same list <c>TerminalTabs</c> drew
|
||||||
|
above the whole window — now drawn inside each of the two screens the design gives a tab row: the
|
||||||
|
terminal surface and the SFTP surface. See <c>MainWindow.axaml</c> for where the strip itself went and
|
||||||
|
<c>Palette.axaml</c>'s v5b remark for why the row is 38 tall rather than the old strip's 42.
|
||||||
|
|
||||||
|
One control rather than two copies of the same markup, because the two rows share everything except two
|
||||||
|
things: which colour marks the active tab, and what a click on a tab actually does. Both are handed in
|
||||||
|
from the usage site rather than branched on a mode property here.
|
||||||
|
|
||||||
|
◆ THE COLOUR is <c>Classes="sftp"</c> on this control's own usage in <c>MainWindow.axaml</c> — unmarked
|
||||||
|
is the terminal row and reads <c>TerminalTabAccent</c>; <c>.sftp</c> reads <c>Magenta</c>. Both are
|
||||||
|
<c>App.axaml</c> selectors keyed off that class on this element, which is why the two rows need no
|
||||||
|
binding of their own for it: <c>Button.sesstab.active</c> and <c>.sftp Button.sesstab.active</c> are
|
||||||
|
the whole of it.
|
||||||
|
|
||||||
|
◆ THE CLICK is <see cref="TabCommand"/>, a plain <c>ICommand</c> this control exposes rather than reads
|
||||||
|
off the shell — the terminal row binds it to <c>SelectTabCommand</c> and the SFTP row to
|
||||||
|
<c>SelectFilesHostCommand</c>, and neither of those is a decision this control has any business making.
|
||||||
|
Every tab button's own <c>CommandParameter</c> is the tab itself, exactly as the strip's was.
|
||||||
|
|
||||||
|
The close box, the middle-click gesture and the "+" are not parameterised: closing a tab ends its shell
|
||||||
|
regardless of which screen it was clicked from, and "+" always opens the same palette. See
|
||||||
|
<c>MainWindowViewModel.CloseTabCommand</c> and <c>ToggleSearchCommand</c>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Border Height="38">
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Tabs}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Marked on IsSelected rather than IsShowing, unlike the old strip: IsShowing is "the terminal
|
||||||
|
surface is showing this tab's pane", which the SFTP row's own active tab is never true of. A
|
||||||
|
selected tab survives navigating away from the terminal surface — that is the whole point of
|
||||||
|
a selection outliving a screen — so both rows agree on what "active" means without either
|
||||||
|
one needing a flag scoped to just one surface. See App.axaml's own remark on Button.sesstab.
|
||||||
|
-->
|
||||||
|
<Button Classes="sesstab"
|
||||||
|
Classes.active="{Binding IsSelected}"
|
||||||
|
Command="{Binding #Root.TabCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
PointerPressed="OnTabPointerPressed"
|
||||||
|
ToolTip.Tip="{Binding Address}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
|
||||||
|
<!--
|
||||||
|
Three states now, where there were two. Green while the shell behind this tab is running
|
||||||
|
and grey once it has ended, as the strip's dots always were — and amber while it is
|
||||||
|
connecting, which used to be grey as well.
|
||||||
|
|
||||||
|
The design's amber had no meaning here while nothing in this application knew how far a
|
||||||
|
connection had got; that changed with the step list, and the note this comment used to
|
||||||
|
carry — that amber is for a host merely reachable, so it is not drawn — is answered
|
||||||
|
rather than ignored. It is not being reachable that is amber, it is being underway. See
|
||||||
|
ConnectingCard.axaml, whose track and running step are the same colour for the same
|
||||||
|
reason, and design-notes/v5b-fidelity-notes.md for the state this is not.
|
||||||
|
|
||||||
|
Worth the third colour because the two it replaces were the same one: a tab still
|
||||||
|
dialling and a tab whose shell has exited both drew grey, which are the two states in
|
||||||
|
this strip with the least in common — one is worth waiting for and the other is over.
|
||||||
|
-->
|
||||||
|
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
|
||||||
|
Classes.connecting="{Binding IsConnecting}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
|
||||||
|
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Opens the quick-connect palette, on both rows: "the existing new-connection/quick-connect meaning"
|
||||||
|
the notes ask this button to keep. See TerminalTabs' own remark, carried over unchanged, on why
|
||||||
|
this is a palette and never a flyout menu over the terminal's own rectangle.
|
||||||
|
-->
|
||||||
|
<Button Classes="sesstab plus"
|
||||||
|
Command="{Binding ToggleSearchCommand}"
|
||||||
|
ToolTip.Tip="Open a connection · Ctrl+K">
|
||||||
|
<TextBlock Text="+" FontSize="15" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Windows.Input;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v5b in-screen tab row: one pill per open terminal, and the "+" that opens another — parameterised by
|
||||||
|
/// <see cref="TabCommand"/> so the terminal surface and the SFTP surface can each wire a click to a different
|
||||||
|
/// meaning over the same list. See the remark at the top of SessionTabRow.axaml.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed partial class SessionTabRow : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>What a left click on a tab runs, with the tab itself as the command parameter.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A plain <see cref="ICommand"/> rather than a bound property read off the shell, because which command
|
||||||
|
/// that is is the one thing this control cannot decide for itself — the terminal surface wants
|
||||||
|
/// <c>SelectTabCommand</c> and the SFTP surface wants <c>SelectFilesHostCommand</c>, and only the caller
|
||||||
|
/// in <c>MainWindow.axaml</c> knows which screen this instance is on.
|
||||||
|
/// </remarks>
|
||||||
|
internal static readonly StyledProperty<ICommand?> TabCommandProperty =
|
||||||
|
AvaloniaProperty.Register<SessionTabRow, ICommand?>(nameof(TabCommand));
|
||||||
|
|
||||||
|
public SessionTabRow() => InitializeComponent();
|
||||||
|
|
||||||
|
internal ICommand? TabCommand
|
||||||
|
{
|
||||||
|
get => GetValue(TabCommandProperty);
|
||||||
|
set => SetValue(TabCommandProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes a tab on a middle click. See the identical remark on the strip this control replaced,
|
||||||
|
/// <c>TerminalTabs.axaml.cs</c>, for why this is <c>PointerUpdateKind</c> rather than
|
||||||
|
/// <c>IsMiddleButtonPressed</c>, why it fires on press rather than release, and why it is wired on the
|
||||||
|
/// tab's own template root rather than on the row.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Not parameterised like <see cref="TabCommand"/>: closing a tab ends its shell regardless of which
|
||||||
|
/// screen the middle click landed on, so both rows want the same answer — <c>CloseTabCommand</c>, read
|
||||||
|
/// directly off this control's own <see cref="StyledElement.DataContext"/>, which is the shell on both.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|
||||||
|
|| DataContext is not MainWindowViewModel shell)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
|
||||||
|
is not PointerUpdateKind.MiddleButtonPressed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
shell.CloseTabCommand.Execute(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SettingsAccountPage"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
v5c: Account, against Settings-Account.dc.html.
|
||||||
|
|
||||||
|
The profile card is read-only, unlike the design's own — there is no "Edit profile" row here because
|
||||||
|
there is no endpoint to send an edit to. Name, email and avatar all come from MeResponse and nothing in
|
||||||
|
this client can change any of the three.
|
||||||
|
|
||||||
|
The SIGN-IN card keeps one row rather than the design's two. The issuer sentence is real:
|
||||||
|
MainWindowViewModel.Issuer is MeResponse.Issuer, cached the same turn AccountName and Email are and
|
||||||
|
surfaced here for the first time — see the remark on that property. "Master passphrase" and its "Change
|
||||||
|
passphrase" button are refused outright: there is no change-passphrase flow, only the one enrollment-time
|
||||||
|
choice, and a button that opened nothing would be worse than no button.
|
||||||
|
|
||||||
|
DEVICES and "Sign out everywhere" are both refused. There is no list-devices endpoint and no
|
||||||
|
sign-out-everywhere endpoint; the one honest device fact this client has — this machine's own Windows
|
||||||
|
Hello registration — is drawn on the Security page, not duplicated here as a one-row list.
|
||||||
|
|
||||||
|
SignOutCard is the same control the old PreferencesScreen used, restyled into this page's card idiom
|
||||||
|
rather than rewritten — see that control's own remark on why it is a bare StackPanel and not a card of
|
||||||
|
its own, and why one control serves both this screen and the unlock screen's copy of it.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||||
|
|
||||||
|
<TextBlock Classes="settingstitle" Text="Account" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard" Margin="0,26,0,0" Padding="24,22">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="18">
|
||||||
|
<Border Width="64" Height="64" CornerRadius="60" Background="{StaticResource AvatarGradient}">
|
||||||
|
<TextBlock Text="{Binding AvatarInitials}" FontWeight="Bold" FontSize="20" LetterSpacing="0.5"
|
||||||
|
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel Spacing="7" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding AccountName}" FontSize="20" FontWeight="Bold" LetterSpacing="-0.25"
|
||||||
|
Foreground="{StaticResource Text}" />
|
||||||
|
<TextBlock Classes="mono" FontSize="12.5" Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="{Binding Email}"
|
||||||
|
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingssection" Text="SIGN-IN" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard">
|
||||||
|
<StackPanel>
|
||||||
|
<Border Classes="settingsrow last">
|
||||||
|
<StackPanel Spacing="6" MaxWidth="640">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Single sign-on" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Signing in proves who you are — it never decrypts a vault." />
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Margin="0,4,0,0" Foreground="{StaticResource Live}"
|
||||||
|
Text="{Binding Issuer, StringFormat='You sign in through {0}.'}"
|
||||||
|
IsVisible="{Binding Issuer, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingssection" Text="THIS MACHINE" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard" Margin="0,12,0,40">
|
||||||
|
<Panel>
|
||||||
|
<Border Classes="settingsrow last" IsVisible="{Binding !IsConfirmingSignOut}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Sign out of this machine" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="danger" Height="38" Content="SIGN OUT"
|
||||||
|
Command="{Binding SignOutCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Padding="24,20" IsVisible="{Binding IsConfirmingSignOut}">
|
||||||
|
<views:SignOutCard />
|
||||||
|
</Border>
|
||||||
|
</Panel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>Settings mode's Account page: the read-only profile, the sign-in fact, and signing out.</summary>
|
||||||
|
internal sealed partial class SettingsAccountPage : UserControl
|
||||||
|
{
|
||||||
|
public SettingsAccountPage() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SettingsGeneralPage"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
v5c: General, against Settings-General.dc.html.
|
||||||
|
|
||||||
|
The design's page has three cards — STARTUP, APPEARANCE, UPDATES — and this one has one. STARTUP
|
||||||
|
(launch at login, reopen tabs) and APPEARANCE (theme, language) are refused outright: nothing tracks
|
||||||
|
whether Windows starts this application, nothing remembers a tab list across a launch, only the one dark
|
||||||
|
theme exists, and there is no i18n anywhere in the client. Building either card would be two rows of
|
||||||
|
controls that toggle a field nothing reads. See design-notes/v5c-fidelity-notes.md.
|
||||||
|
|
||||||
|
UPDATES is the whole page's real content, moved here from the old PreferencesScreen.axaml verbatim —
|
||||||
|
same UpdateViewModel, same states, same warnings, restyled into this page's row idiom. Its own
|
||||||
|
update-channel switcher is refused the same way STARTUP and APPEARANCE are: which channel a copy follows
|
||||||
|
is fixed when it is built, so this screen cannot offer to switch it.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||||
|
|
||||||
|
<TextBlock Classes="settingstitle" Text="General" />
|
||||||
|
|
||||||
|
<TextBlock Classes="settingssection" Text="UPDATES" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard">
|
||||||
|
<StackPanel>
|
||||||
|
|
||||||
|
<Border Classes="settingsrow">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Version" />
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}"
|
||||||
|
TextTrimming="CharacterEllipsis" Text="{Binding Updates.CurrentVersion}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="ghost" Height="38" Content="CHECK NOW"
|
||||||
|
Command="{Binding Updates.CheckNowCommand}"
|
||||||
|
IsEnabled="{Binding Updates.CanCheckNow}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settingsrow">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Check on its own" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox Grid.Column="1" VerticalAlignment="Top"
|
||||||
|
IsChecked="{Binding Updates.IsAutomatic}"
|
||||||
|
IsEnabled="{Binding Updates.IsSupported}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settingsrow" IsVisible="{Binding Updates.IsDownloading}">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Downloading…" />
|
||||||
|
<ProgressBar Height="4" Minimum="0" Maximum="100"
|
||||||
|
Value="{Binding Updates.DownloadPercent}"
|
||||||
|
Foreground="{StaticResource Accent}" Background="{StaticResource Chip}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settingsrow" IsVisible="{Binding Updates.IsReady}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="{Binding Updates.ReadyHeadline}" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Classes="settingsrowcaption" Text="{Binding Updates.RestartWarning}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="accent" Height="38" Content="RESTART NOW"
|
||||||
|
Command="{Binding Updates.RestartNowCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settingsrow last">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Builds come from the project's own release page, and never from the server you sign in to. Whoever hands you the client can hand you a client that copies your passphrase, and the operator of a DodoSSH deployment is the party the trust model is about. A deployment may tell you where to get it. It is not where it comes from." />
|
||||||
|
<TextBlock Classes="settingsrowcaption" Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="{Binding Updates.Status}"
|
||||||
|
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
||||||
|
IsVisible="{Binding Updates.IsUnsupported}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingssection" Text="NOT BUILT YET" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard">
|
||||||
|
<StackPanel Margin="24,20" Spacing="10">
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="These are on the design and have nothing behind them here. They are listed rather than left out, so what this page does not do is as legible as what it does. The full list, and what each would take, is in docs/design-import-gaps.md." />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Update channel — there is a nightly as well as a release, but which one a copy follows is fixed when it is built, so moving between them means installing the other one." />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Launch at login — nothing registers this application with Windows' own startup list." />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Reopen tabs from last session — nothing remembers which tabs were open across a launch, and every connection would need to authenticate again regardless." />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Theme and language — only the one dark theme exists, and there is no translation anywhere in this client." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>Settings mode's General page: the Updates card, and the refused items as an essay.</summary>
|
||||||
|
internal sealed partial class SettingsGeneralPage : UserControl
|
||||||
|
{
|
||||||
|
public SettingsGeneralPage() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SettingsGroupsPage"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
x:Name="Root">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
v5c-2: Groups, against Settings-Groups.dc.html — a real management page over VaultViewModel's own group
|
||||||
|
commands (Groups, NewGroup, EditGroup, DeleteGroup), given its own row here rather than only living inside
|
||||||
|
the hosts board's sidebar. DataContext.Vault (VaultViewModel) is nullable — locked, or the settings mode
|
||||||
|
reached before an unlock — so every binding below is {Binding Vault.*}, and the empty-vault caption covers
|
||||||
|
the null case the same way the vaults page's own does.
|
||||||
|
|
||||||
|
── THE INTRO SENTENCE IS NOT THE MOCK'S ────────────────────────────────────────────────────────────────
|
||||||
|
The design says "the order here is the order there" and draws drag handles to back it up. Neither is true:
|
||||||
|
HostGroupRowViewModel — see VaultViewModel.RebuildGroups — orders groups by the vault new items go into
|
||||||
|
first, then by vault name, then by label; there is no manual order to drag into and nothing this page
|
||||||
|
could do would make one real. The sentence below says what is actually true instead, and there are no
|
||||||
|
drag handles.
|
||||||
|
|
||||||
|
── EDIT AND DELETE ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
EditGroupCommand and DeleteGroupCommand already take the row as their argument — see
|
||||||
|
VaultViewModel.EditGroup and VaultViewModel.EditGroupFromHeading's own remark on why — so this card's
|
||||||
|
buttons bind straight to them with CommandParameter="{Binding}"; no wrapper commands were needed the way
|
||||||
|
the tags page needs one. Editing opens the same drawer form the hosts board's own GROUPS section does
|
||||||
|
(HostDrawer.axaml's IsEditingGroup panel); it is not reproduced here as a second form, on the reasoning
|
||||||
|
the vaults page gives for reusing VaultsViewModel's commands rather than rebuilding them: one set of
|
||||||
|
fields, one place they can drift from what SaveGroupCommand actually writes.
|
||||||
|
|
||||||
|
── THE "NO GROUP" FOOTER ────────────────────────────────────────────────────────────────────────────────
|
||||||
|
Real, all three facts: VaultViewModel.UngroupedHostCount counts hosts whose group has gone or was never
|
||||||
|
set, "always listed first" is true of both FlattenIntoSections call sites (RebuildSidebarRows passes
|
||||||
|
ungroupedFirst: false — it is drawn last there — but the desktop's own board, RebuildHostSections, passes
|
||||||
|
true), and it genuinely cannot be renamed or deleted: there is no HostGroupSecret behind it for either
|
||||||
|
command to act on.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="settingstitle" Text="Groups" />
|
||||||
|
<Border Background="{StaticResource Chip}" CornerRadius="9" MinWidth="34" Height="30"
|
||||||
|
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<TextBlock Text="{Binding Vault.Groups.Count}" FontSize="13.5" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource TextFaint}" Margin="8,0"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="accent" Height="40" Content="+ NEW GROUP"
|
||||||
|
Command="{Binding Vault.NewGroupCommand}"
|
||||||
|
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingsrowcaption" Margin="0,12,0,0" TextWrapping="Wrap"
|
||||||
|
Text="Groups order by which vault new items go into, then by vault, then by label — there is no manual order to set." />
|
||||||
|
|
||||||
|
<!-- The group editor, in place: HostDrawer's own IsEditingGroup form, restyled into this page's card
|
||||||
|
idiom rather than duplicated. Opened by + NEW GROUP above or an EDIT icon below. -->
|
||||||
|
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
|
||||||
|
IsVisible="{Binding Vault.IsEditingGroup}">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="{Binding Vault.DrawerTitle}" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="A heading for the hosts board, and the defaults every host under it inherits when it says nothing itself." />
|
||||||
|
<TextBox PlaceholderText="group name" Text="{Binding Vault.GroupEditorLabel}" />
|
||||||
|
<StackPanel Spacing="4" IsVisible="{Binding Vault.ShowsGroupEditorVaultChoice}">
|
||||||
|
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Vault.GroupEditorVaultChoices}"
|
||||||
|
SelectedItem="{Binding Vault.GroupEditorSelectedVault}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||||
|
<TextBlock Text="{Binding Display}" FontSize="12" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Vault.GroupEditorParentChoices}"
|
||||||
|
SelectedItem="{Binding Vault.GroupEditorSelectedParent}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:GroupChoice">
|
||||||
|
<TextBlock Text="{Binding Label}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<Grid ColumnDefinitions="*,8,*">
|
||||||
|
<NumericUpDown Grid.Column="0" Value="{Binding Vault.GroupEditorDefaultPort}" Minimum="1"
|
||||||
|
Maximum="65535" FormatString="0" ShowButtonSpinner="False"
|
||||||
|
PlaceholderText="default port" />
|
||||||
|
<TextBox Grid.Column="2" Text="{Binding Vault.GroupEditorDefaultUsername}"
|
||||||
|
PlaceholderText="default username" />
|
||||||
|
</Grid>
|
||||||
|
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Vault.GroupEditorAuthenticationChoices}"
|
||||||
|
SelectedItem="{Binding Vault.GroupEditorSelectedAuthentication}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||||
|
<TextBlock Text="{Binding Label}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="accent" Height="36" Content="{Binding Vault.GroupSaveLabel}"
|
||||||
|
Command="{Binding Vault.SaveGroupCommand}" />
|
||||||
|
<Button Classes="ghost" Height="36" Content="CANCEL"
|
||||||
|
Command="{Binding Vault.CancelGroupEditCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- The delete confirmation, in place. The same control the hosts board's GROUPS section and the
|
||||||
|
vaults page both reuse. -->
|
||||||
|
<Border Margin="0,20,0,0" Padding="20" CornerRadius="12"
|
||||||
|
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||||
|
BorderThickness="1"
|
||||||
|
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Margin="0,24,0,40" Spacing="10">
|
||||||
|
<ItemsControl ItemsSource="{Binding Vault.Groups}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Spacing="10" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
|
||||||
|
<Border Classes="settingscard" Padding="20,16">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
|
<Border Grid.Column="0" Width="38" Height="38" CornerRadius="10"
|
||||||
|
Background="{StaticResource AvatarGradient}">
|
||||||
|
<TextBlock Text="{Binding Initial}" FontWeight="Bold" FontSize="14"
|
||||||
|
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Spacing="5" Margin="14,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Label}" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
|
||||||
|
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="{Binding Description}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border Grid.Column="2" Classes="chip" Margin="0,0,14,0" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding HasVaultBadge}">
|
||||||
|
<TextBlock Text="{Binding VaultBadge}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
|
<Button Classes="paneicon" Width="34" Height="34" FontSize="15"
|
||||||
|
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditGroupCommand}"
|
||||||
|
CommandParameter="{Binding}" ToolTip.Tip="Rename this group or change what its hosts inherit.">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="paneicon danger" Width="34" Height="34" FontSize="15"
|
||||||
|
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteGroupCommand}"
|
||||||
|
CommandParameter="{Binding}" ToolTip.Tip="Delete this group. The hosts filed under it are asked about separately.">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- The "No group" footer row. Always drawn, even with zero groups, because it is true whether or
|
||||||
|
not any group exists — it is just as often the only row on this page. -->
|
||||||
|
<Border Classes="settingscard" Padding="20,16" Opacity="0.7"
|
||||||
|
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="17" Text=""
|
||||||
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||||
|
<StackPanel Spacing="5">
|
||||||
|
<TextBlock Text="No group" FontSize="14" FontWeight="SemiBold" LetterSpacing="-0.2"
|
||||||
|
Foreground="{StaticResource TextFaint}" />
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
|
||||||
|
Text="{Binding Vault.UngroupedHostCount, StringFormat='{}{0} hosts · always listed first · cannot be renamed or deleted'}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
|
||||||
|
Text="Unlock your keychain to see and manage your groups." />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>Groups: every group, and the hosts filed under each — see the remark at the top of the markup.</summary>
|
||||||
|
internal sealed partial class SettingsGroupsPage : UserControl
|
||||||
|
{
|
||||||
|
public SettingsGroupsPage() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SettingsNav"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
v5c: settings mode's own 340px rail — SettingsNav.dc.html. Two sections rather than the design's three:
|
||||||
|
the design's ORGANISATION section and its one row (Organisation settings) are 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. See design-notes/v5c-fidelity-notes.md.
|
||||||
|
|
||||||
|
v5c-2: Groups and Tags joined CUSTOMIZE, after Preferences — the design's own order (Security,
|
||||||
|
Preferences, Groups, Tags). Each opens a real management page over the existing group and tag commands;
|
||||||
|
see SettingsGroupsPage.axaml and SettingsTagsPage.axaml.
|
||||||
|
|
||||||
|
Button.nav and its two TextBlock helpers are the exact rows NavRail.axaml already draws at 255px — height
|
||||||
|
35, radius 8, a 19px glyph and a 10.5 semibold label, accent fill with white text when active. Reused
|
||||||
|
rather than restyled: this design states the same three numbers for this rail's own rows, and a second
|
||||||
|
copy of one style is how the two rails drift apart the day only one of them is touched again.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Border Width="340" Background="{StaticResource DeepChrome}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||||
|
<DockPanel LastChildFill="False" Margin="18,24">
|
||||||
|
|
||||||
|
<StackPanel DockPanel.Dock="Top" Spacing="8">
|
||||||
|
|
||||||
|
<TextBlock Text="SETTINGS" Margin="11,0,0,6" FontSize="10" FontWeight="SemiBold"
|
||||||
|
LetterSpacing="1.5" Foreground="{StaticResource TextGhost}" />
|
||||||
|
|
||||||
|
<Button Classes="flat nav" Classes.active="{Binding IsSettingsGeneralPage}"
|
||||||
|
Command="{Binding EnterSettingsCommand}"
|
||||||
|
CommandParameter="{x:Static vm:SettingsPage.General}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" Text="" />
|
||||||
|
<TextBlock Classes="navlabel" Text="General" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="flat nav" Classes.active="{Binding IsSettingsVaultsPage}"
|
||||||
|
Command="{Binding EnterSettingsCommand}"
|
||||||
|
CommandParameter="{x:Static vm:SettingsPage.Vaults}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" Text="" />
|
||||||
|
<TextBlock Classes="navlabel" Text="Vaults" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="flat nav" Classes.active="{Binding IsSettingsAccountPage}"
|
||||||
|
Command="{Binding EnterSettingsCommand}"
|
||||||
|
CommandParameter="{x:Static vm:SettingsPage.Account}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" Text="" />
|
||||||
|
<TextBlock Classes="navlabel" Text="Account" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<TextBlock Text="CUSTOMIZE" Margin="11,14,0,6" FontSize="10" FontWeight="SemiBold"
|
||||||
|
LetterSpacing="1.5" Foreground="{StaticResource TextGhost}" />
|
||||||
|
|
||||||
|
<Button Classes="flat nav" Classes.active="{Binding IsSettingsSecurityPage}"
|
||||||
|
Command="{Binding EnterSettingsCommand}"
|
||||||
|
CommandParameter="{x:Static vm:SettingsPage.Security}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" Text="" />
|
||||||
|
<TextBlock Classes="navlabel" Text="Security" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="flat nav" Classes.active="{Binding IsSettingsPreferencesPage}"
|
||||||
|
Command="{Binding EnterSettingsCommand}"
|
||||||
|
CommandParameter="{x:Static vm:SettingsPage.Preferences}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" Text="" />
|
||||||
|
<TextBlock Classes="navlabel" Text="Preferences" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="flat nav" Classes.active="{Binding IsSettingsGroupsPage}"
|
||||||
|
Command="{Binding EnterSettingsCommand}"
|
||||||
|
CommandParameter="{x:Static vm:SettingsPage.Groups}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" Text="" />
|
||||||
|
<TextBlock Classes="navlabel" Text="Groups" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Classes="flat nav" Classes.active="{Binding IsSettingsTagsPage}"
|
||||||
|
Command="{Binding EnterSettingsCommand}"
|
||||||
|
CommandParameter="{x:Static vm:SettingsPage.Tags}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" Text="" />
|
||||||
|
<TextBlock Classes="navlabel" Text="Tags" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Wired to the same command the rail's own user popover Logout row calls — see
|
||||||
|
MainWindowViewModel.SignOutFromPopover, which now lands on the Account page rather than on the old
|
||||||
|
bare Preferences screen, so there is exactly one place the confirmation card is drawn.
|
||||||
|
-->
|
||||||
|
<Button DockPanel.Dock="Bottom" Classes="flat nav"
|
||||||
|
Command="{Binding SignOutFromPopoverCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
|
<TextBlock Classes="navicon" FontSize="17" Text="" />
|
||||||
|
<TextBlock Classes="navlabel" Text="Logout" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>Settings mode's own 340px rail — General/Vaults/Account, Security/Preferences, and Logout.</summary>
|
||||||
|
internal sealed partial class SettingsNav : UserControl
|
||||||
|
{
|
||||||
|
public SettingsNav() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SettingsPreferencesPage"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
v5c: Preferences, against Settings-Preferences.dc.html — the old PreferencesScreen.axaml's real content,
|
||||||
|
restyled into the settings page idiom and split across two pages rather than one.
|
||||||
|
|
||||||
|
THIS MACHINE moved to the Security page whole: Windows Hello's register/"stop unlocking here" pair and
|
||||||
|
the no-TPM explanation are Security's UNLOCKING card now, one home rather than two — see the cross-
|
||||||
|
reference row at the foot of this page. Device name is not one of the rows that moved; it never existed
|
||||||
|
here at all, because there is no device-name setting to move. Logs record the machine name on their own.
|
||||||
|
|
||||||
|
TERMINAL keeps its one real setting, text size, and drops the design's other five — font, cursor,
|
||||||
|
scrollback, copy on select, terminal bell — which the renderer hard-codes. KEYCHAIN keeps Lock now,
|
||||||
|
Sync now/Sign in, and the importer row exactly as the old screen had them; the design's own SECURITY
|
||||||
|
card (clipboard-clear delay, confirm-run-on-insert) has nothing behind either row and is not drawn here.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||||
|
|
||||||
|
<TextBlock Classes="settingstitle" Text="Preferences" />
|
||||||
|
|
||||||
|
<TextBlock Classes="settingssection" Text="TERMINAL" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard">
|
||||||
|
<StackPanel>
|
||||||
|
|
||||||
|
<Border Classes="settingsrow last">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Text size" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="How large a terminal draws, in pixels. Ctrl+plus and Ctrl+minus do the same while a terminal has focus, and Ctrl+0 puts it back. It resizes the grid rather than magnifying it, so the remote is told how many columns it now has — which is also why it stops before the columns run out." />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Top">
|
||||||
|
<Button Width="36" Height="36" Classes="ghost" Padding="0"
|
||||||
|
Command="{Binding ShrinkTerminalFontCommand}"
|
||||||
|
IsEnabled="{Binding CanShrinkTerminalFont}"
|
||||||
|
ToolTip.Tip="Smaller · Ctrl+minus">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="15" />
|
||||||
|
</Button>
|
||||||
|
<TextBlock Classes="mono" FontSize="13.5" MinWidth="46" VerticalAlignment="Center"
|
||||||
|
TextAlignment="Center" Foreground="{StaticResource Text}"
|
||||||
|
Text="{Binding TerminalFontSize, StringFormat={}{0} px}" />
|
||||||
|
<Button Width="36" Height="36" Classes="ghost" Padding="0"
|
||||||
|
Command="{Binding EnlargeTerminalFontCommand}"
|
||||||
|
IsEnabled="{Binding CanEnlargeTerminalFont}"
|
||||||
|
ToolTip.Tip="Larger · Ctrl+plus">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="15" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="ghost" Height="36" Content="RESET"
|
||||||
|
Command="{Binding ResetTerminalFontCommand}"
|
||||||
|
ToolTip.Tip="Back to the size it ships at · Ctrl+0" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingssection" Text="KEYCHAIN" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard">
|
||||||
|
<StackPanel>
|
||||||
|
|
||||||
|
<Border Classes="settingsrow">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Lock the keychain" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="ghost" Height="38" Content="LOCK NOW"
|
||||||
|
Command="{Binding LockCommand}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settingsrow">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Synchronise" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open." />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="ghost" Height="38" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||||
|
IsVisible="{Binding !IsOnline}"
|
||||||
|
ToolTip.Tip="Opens your browser. Only needed when there is no remembered session to resume." />
|
||||||
|
<Button Classes="ghost" Height="38" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settingsrow last">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Import SSH config" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Reads this machine's ~/.ssh/config, shows what it found, and imports only what you approve. Nothing is stored during the scan, and no private key is read — where a key file is named, the path is recorded as a note." />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN IMPORTER"
|
||||||
|
Command="{Binding ShowScreenCommand}"
|
||||||
|
CommandParameter="{x:Static vm:ShellScreen.Import}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingssection" Text="NOT BUILT YET" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard">
|
||||||
|
<StackPanel Margin="24,20" Spacing="10">
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Unlocking with Windows Hello moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Copy on select and the terminal bell — neither is wired to anything the renderer does." />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Clearing the clipboard after copying a secret, and confirming a snippet that runs on insert — neither exists; a copied secret stays on the clipboard until something else replaces it." />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="A device name — there is no such setting. Logs record this machine's own name on their own." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>Settings mode's Preferences page: the Terminal and Keychain cards.</summary>
|
||||||
|
internal sealed partial class SettingsPreferencesPage : UserControl
|
||||||
|
{
|
||||||
|
public SettingsPreferencesPage() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SettingsSecurityPage"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
v5c: Security, against Settings-Security.dc.html.
|
||||||
|
|
||||||
|
The end-to-end card's sentence is ADR 0001's own claim in the design's words: "The server stores
|
||||||
|
ciphertext and never holds a key" and "the relay operator ... see ciphertext only" — see docs/adr/0001-e2ee-trust-model.md.
|
||||||
|
|
||||||
|
UNLOCKING is Windows Hello's register/"Stop unlocking here" pair, moved here whole from the old
|
||||||
|
PreferencesScreen.axaml — same RegisterDeviceCommand, ForgetDeviceCommand and the no-TPM explanation
|
||||||
|
(HasNoDeviceKeyOption). One home for this fact rather than two; see the cross-reference on the
|
||||||
|
Preferences page. The design's own auto-lock link row is refused: there is nothing to link to, since
|
||||||
|
auto-lock and per-vault unlock rules do not exist anywhere in this client.
|
||||||
|
|
||||||
|
CONNECTING keeps exactly one row, Approved host keys. Strict host-key checking and the allowed-algorithm
|
||||||
|
chips are both refused — the app always asks on a changed key, and there is no algorithm allow-list
|
||||||
|
anywhere in the SSH stack. The count and the "N that no host dials" clause both come straight off
|
||||||
|
KnownHostsViewModel.Summary, the same sentence the known-hosts screen's own header prints, so this row
|
||||||
|
can never say a different number than the screen it sends you to. "Open host keys" leaves settings mode
|
||||||
|
on purpose — known-hosts is a MAIN-chrome screen, and there is no honest way to show it without leaving;
|
||||||
|
ShowScreenCommand already does that for any target that is not Preferences or Vaults.
|
||||||
|
|
||||||
|
RECENT SECURITY EVENTS is the link row alone, not the design's own list of rows. A real list would mean
|
||||||
|
building a filtered read over LogsViewModel's activity and connection logs — which entries count as
|
||||||
|
"security" is itself a judgement call the design does not resolve — and the honest link is complete on
|
||||||
|
its own: Logs already holds the whole story, and this row says so rather than half-repeating it.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||||
|
|
||||||
|
<TextBlock Classes="settingstitle" Text="Security" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="16">
|
||||||
|
<Border Width="44" Height="44" CornerRadius="12" Background="#103A2F">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="20"
|
||||||
|
Foreground="{StaticResource Live}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel Spacing="6" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
|
||||||
|
Foreground="{StaticResource Text}" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingssection" Text="UNLOCKING" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard">
|
||||||
|
<StackPanel>
|
||||||
|
<Border Classes="settingsrow last">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Windows Hello on this machine" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working."
|
||||||
|
IsVisible="{Binding CanRegisterDevice}" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Registered · the TPM holds a device key that can open the keychain with a Windows confirmation."
|
||||||
|
IsVisible="{Binding CanForgetDevice}" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
||||||
|
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
|
||||||
|
Command="{Binding RegisterDeviceCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
IsVisible="{Binding CanRegisterDevice}" />
|
||||||
|
<Button Grid.Column="1" Classes="danger" Height="38" Content="STOP UNLOCKING HERE"
|
||||||
|
Command="{Binding ForgetDeviceCommand}"
|
||||||
|
IsEnabled="{Binding !IsBusy}"
|
||||||
|
IsVisible="{Binding CanForgetDevice}"
|
||||||
|
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingssection" Text="CONNECTING" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard">
|
||||||
|
<StackPanel>
|
||||||
|
<Border Classes="settingsrow last">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Approved host keys" />
|
||||||
|
<TextBlock Classes="settingsrowcaption" Text="{Binding KnownHostsScreen.Summary}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN HOST KEYS"
|
||||||
|
Command="{Binding ShowScreenCommand}"
|
||||||
|
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingssection" Text="RECENT SECURITY EVENTS" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard" Margin="0,12,0,40">
|
||||||
|
<StackPanel>
|
||||||
|
<Border Classes="settingsrow last">
|
||||||
|
<Button Classes="flat" Padding="0" HorizontalAlignment="Left"
|
||||||
|
Command="{Binding ShowScreenCommand}"
|
||||||
|
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text="See all activity in Logs" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource AccentText}" />
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="14"
|
||||||
|
Foreground="{StaticResource AccentText}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>Settings mode's Security page: the E2E explainer, Windows Hello, and approved host keys.</summary>
|
||||||
|
internal sealed partial class SettingsSecurityPage : UserControl
|
||||||
|
{
|
||||||
|
public SettingsSecurityPage() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SettingsTagsPage"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
x:Name="Root">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
v5c-2: Tags, against Settings-Tags.dc.html — a real management page over VaultViewModel's own tag
|
||||||
|
commands (Tags, NewTag, EditTagRow, DeleteTagRow), which used to be reachable only from the keychain
|
||||||
|
screen's TAGS category. Both doors stay open — the keychain screen's own panel is untouched — because
|
||||||
|
they lead to the same commands and the same rows; nothing here is a second implementation.
|
||||||
|
|
||||||
|
── THE INTRO SENTENCE IS TRUE, AND IT IS THE DESIGN'S OWN ─────────────────────────────────────────────
|
||||||
|
"Renaming here is one write and every host follows" — verified against SaveTagAsync and TagRowViewModel's
|
||||||
|
own remark: a host names a tag's id in its TagSet, never its label, so renaming touches nothing but the
|
||||||
|
tag item itself. Kept rather than rewritten.
|
||||||
|
|
||||||
|
── EDIT AND DELETE ARE WRAPPER COMMANDS, UNLIKE THE GROUPS PAGE'S ──────────────────────────────────────
|
||||||
|
EditTagCommand and DeleteTagCommand read VaultViewModel.SelectedTag rather than taking a row argument —
|
||||||
|
they were built for the keychain screen's own ListBox selection, which this page has no equivalent of.
|
||||||
|
VaultViewModel.EditTagRow/DeleteTagRow (v5c-2, additive) select the row and then call the real command, so
|
||||||
|
every guard and every sentence either one already has is still the one that runs.
|
||||||
|
|
||||||
|
── WHAT THE DESIGN DREW AND THIS PAGE DOES NOT ─────────────────────────────────────────────────────────
|
||||||
|
The LAST APPLIED column: no timestamp of when a tag was last put on a host exists anywhere in this
|
||||||
|
client, and a column of "just now" / "2 min ago" would be inventing one.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="settingstitle" Text="Tags" />
|
||||||
|
<Border Background="{StaticResource Chip}" CornerRadius="9" MinWidth="34" Height="30"
|
||||||
|
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<TextBlock Text="{Binding Vault.Tags.Count}" FontSize="13.5" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource TextFaint}" Margin="8,0"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="accent" Height="40" Content="+ NEW TAG"
|
||||||
|
Command="{Binding Vault.NewTagCommand}"
|
||||||
|
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingsrowcaption" Margin="0,12,0,0" TextWrapping="Wrap"
|
||||||
|
Text="A tag is a name shared by every host that carries it. Renaming here is one write — every host, filter and snippet rule follows." />
|
||||||
|
|
||||||
|
<!-- The tag editor, in place — the same one-box form the keychain screen's own panel draws, restyled
|
||||||
|
into this page's card idiom. -->
|
||||||
|
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
|
||||||
|
IsVisible="{Binding Vault.IsEditingTag}">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Tag" />
|
||||||
|
<TextBox PlaceholderText="name" Text="{Binding Vault.TagEditorLabel}">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding Vault.SaveTagCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="Renaming a tag changes it everywhere at once. No host is rewritten — each one names this tag rather than repeating its name." />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="accent" Height="36" Content="SAVE" Command="{Binding Vault.SaveTagCommand}" />
|
||||||
|
<Button Classes="ghost" Height="36" Content="CANCEL" Command="{Binding Vault.CancelTagEditCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- The delete confirmation, in place — the same shared control the groups and vaults pages reuse. -->
|
||||||
|
<Border Margin="0,20,0,0" Padding="20" CornerRadius="12"
|
||||||
|
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||||
|
BorderThickness="1"
|
||||||
|
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="settingscard" Margin="0,24,0,40" Padding="0"
|
||||||
|
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<StackPanel>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="1.4*,*,*,Auto" Margin="20,14,20,10">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="TAG" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
|
||||||
|
<TextBlock Grid.Column="2" Classes="label" Text="VAULT" />
|
||||||
|
<TextBlock Grid.Column="3" Classes="label" Text=" " />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Vault.Tags}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:TagRowViewModel">
|
||||||
|
<Border Classes="settingsrow last" Padding="20,10">
|
||||||
|
<Grid ColumnDefinitions="1.4*,*,*,Auto">
|
||||||
|
|
||||||
|
<Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding Label}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" Classes="mono" FontSize="11.5"
|
||||||
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding Description}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
No vault chip here — unlike a group, TagRowViewModel does not carry which vault it lives
|
||||||
|
in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark),
|
||||||
|
so there is no second vault this column could honestly name.
|
||||||
|
-->
|
||||||
|
<TextBlock Grid.Column="2" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
|
<Button Classes="paneicon" Width="30" Height="30" FontSize="13"
|
||||||
|
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
|
||||||
|
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="paneicon danger" Width="30" Height="30" FontSize="13"
|
||||||
|
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteTagRowCommand}"
|
||||||
|
CommandParameter="{Binding}" ToolTip.Tip="Delete this tag. Hosts wearing it simply stop showing the chip.">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingsrowcaption" Margin="20,14,20,18" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding !Vault.HasTagItems}"
|
||||||
|
Text="No tags yet. Add one from here, or from a host's own editor." />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingsrowcaption" Margin="0,20,0,40" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
|
||||||
|
Text="Unlock your keychain to see and manage your tags." />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>Tags: every tag, and how many hosts wear each — see the remark at the top of the markup.</summary>
|
||||||
|
internal sealed partial class SettingsTagsPage : UserControl
|
||||||
|
{
|
||||||
|
public SettingsTagsPage() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SettingsTitleBar"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
v5c: settings mode's own titlebar — SettingsNav.dc.html and the four Settings-*.dc.html sources all draw
|
||||||
|
the same 53px bar: "Back to application" on the left, in place of the wordmark and the search box, and
|
||||||
|
the same three window-control glyphs on the right TitleBar.axaml already draws.
|
||||||
|
|
||||||
|
A separate control rather than a variant of TitleBar itself, on the same reasoning SessionStatusBar and
|
||||||
|
SessionSidebar are their own files: nothing here can be measured by a test that hosts the real window,
|
||||||
|
and a control that is either "the wordmark bar" or "the settings bar" depending on a bound flag would be
|
||||||
|
two controls wearing one name. The dragging, maximising and closing logic is duplicated from TitleBar's
|
||||||
|
own code-behind rather than shared through a base class — four short handlers, and the day one of the two
|
||||||
|
bars needs its own window behaviour a shared base would have to be pulled apart first.
|
||||||
|
|
||||||
|
v5c-3: two back buttons rather than one whose text and command a converter swaps, toggled by
|
||||||
|
MainWindowViewModel.IsImportOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in
|
||||||
|
place of "Back to application" while the importer is up, and CloseImportCommand closes it without leaving
|
||||||
|
settings mode, unlike LeaveSettingsCommand.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||||
|
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
||||||
|
|
||||||
|
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
||||||
|
IsVisible="{Binding !IsImportOpen}"
|
||||||
|
Command="{Binding LeaveSettingsCommand}"
|
||||||
|
ToolTip.Tip="Back to the screen you were on before opening Settings">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="17"
|
||||||
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="Back to application" FontSize="13" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
|
||||||
|
IsVisible="{Binding IsImportOpen}"
|
||||||
|
Command="{Binding CloseImportCommand}"
|
||||||
|
ToolTip.Tip="Back to preferences, without leaving Settings">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="17"
|
||||||
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="Back to preferences" FontSize="13" FontWeight="SemiBold"
|
||||||
|
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- The window controls, identical to TitleBar's own — see the remark above on why they are repeated. -->
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="2">
|
||||||
|
<Button Classes="flat" Width="26" Height="24" Click="OnMinimise"
|
||||||
|
ToolTip.Tip="Minimise">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="14"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="flat" Width="26" Height="24" Click="OnToggleMaximised"
|
||||||
|
ToolTip.Tip="Maximise">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="13"
|
||||||
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
<Button Classes="flat close" Width="26" Height="24" Click="OnClose"
|
||||||
|
ToolTip.Tip="Close DodoSSH. This ends every shell it has open.">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="14"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>Settings mode's own titlebar — see the remark in the markup for why it is not TitleBar itself.</summary>
|
||||||
|
internal sealed partial class SettingsTitleBar : UserControl
|
||||||
|
{
|
||||||
|
public SettingsTitleBar() => InitializeComponent();
|
||||||
|
|
||||||
|
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
||||||
|
|
||||||
|
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
|
||||||
|
private void OnDrag(object? sender, PointerPressedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Handled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Host?.BeginMoveDrag(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMinimise(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (Host is { } window)
|
||||||
|
{
|
||||||
|
window.WindowState = WindowState.Minimized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
|
||||||
|
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (Host is not { } window)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.WindowState = window.WindowState == WindowState.Maximized
|
||||||
|
? WindowState.Normal
|
||||||
|
: WindowState.Maximized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClose(object? sender, RoutedEventArgs e) => Host?.Close();
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user