Public Access
Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
881562e81b | ||
|
|
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 |
@@ -291,6 +291,7 @@ jobs:
|
||||
# so it is not done either. What reaches users is built, installed and walked through Phase 16
|
||||
# of docs/manual-checks.md by a person first.
|
||||
- name: package the windows desktop client
|
||||
id: winpack
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -374,6 +375,93 @@ jobs:
|
||||
ls -la "$releases"
|
||||
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
|
||||
|
||||
# Handed to the macOS step below rather than worked out again there. The floor logic above
|
||||
# is thirty lines of reasoning about MinVer's pre-first-tag answer, and a second copy of it
|
||||
# is a second thing to keep in step — while two desktop packages built from one commit
|
||||
# carrying different version numbers is precisely the confusion this file spends that
|
||||
# reasoning to avoid.
|
||||
echo "packVersion=$packVersion" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ◆ AND THE macOS BUNDLE IS BUILT HERE, ON LINUX, AND IS ALSO THROWN AWAY.
|
||||
#
|
||||
# Same argument as the Windows step above, one platform along: the failures a release is most
|
||||
# exposed to are the ones only the packager finds, and the person who would otherwise find them
|
||||
# is the one midway through a release on the one Mac that can cut one.
|
||||
#
|
||||
# What this catches that the Windows step cannot: the osx-arm64 restore graph. A native package
|
||||
# that resolves for win-x64 and has no osx-arm64 asset — libsodium and SkiaSharp both ship per
|
||||
# RID — fails here, on every main build, rather than at the first `dotnet publish` of a release
|
||||
# nobody can retry without a Mac.
|
||||
#
|
||||
# ◆ bundle, NOT pack, AND THE DIFFERENCE IS NOT A CHOICE.
|
||||
#
|
||||
# `vpk [osx]` cross-compiling from a non-Mac offers exactly one packaging verb: bundle, which
|
||||
# builds the .app. There is no `[osx] pack` off a Mac, and that is correct rather than a gap —
|
||||
# pack signs with codesign, submits to Apple with notarytool and staples the ticket, all of
|
||||
# which is Apple tooling that exists on no other platform. So this proves the bundle and stops
|
||||
# where the platform does.
|
||||
#
|
||||
# No --plist and no --icon either, deliberately. Both are proved by scripts/release-macos.sh on
|
||||
# the machine that can also check the result; passing a rendered plist here would mean copying
|
||||
# the substitution out of that script to no end, since nothing looks at what this produces.
|
||||
#
|
||||
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
|
||||
#
|
||||
# RUNNER_TEMP, dying with the job. ADR 0013 rule 3 puts the capability to ship somebody a build
|
||||
# on a machine which is not a runner, and an unsigned .app is additionally something no Mac
|
||||
# would open — so publishing it would be handing out a file whose only possible use is confusion.
|
||||
- name: publish and bundle the macos desktop client
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# RestoreLockedMode=false for the RID, exactly as the win-x64 publish above does — see the
|
||||
# long note there for why the committed lock files are deliberately RID-free. This runner's
|
||||
# checkout is thrown away, so the lock files it rewrites go nowhere.
|
||||
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||
--configuration Release --runtime osx-arm64 --self-contained true \
|
||||
-p:RestoreLockedMode=false \
|
||||
--output "$RUNNER_TEMP/osx-arm64"
|
||||
|
||||
# The apphost has no extension on macOS, so this is `DodoSSH` and not `DodoSSH.exe`. Named
|
||||
# rather than globbed, because a publish that produced no apphost at all would otherwise
|
||||
# bundle happily and produce an .app that launches nothing.
|
||||
if [ ! -s "$RUNNER_TEMP/osx-arm64/DodoSSH" ]; then
|
||||
echo "The osx-arm64 publish produced no apphost." >&2
|
||||
ls -la "$RUNNER_TEMP/osx-arm64" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bundles="$RUNNER_TEMP/osx-bundle"
|
||||
|
||||
# The quotes around [osx] are load-bearing, exactly as they are on '[win]' above: unquoted,
|
||||
# the shell reads it as a glob matching any one of o, s and x.
|
||||
dotnet vpk '[osx]' bundle \
|
||||
--skip-updates \
|
||||
--packId DodoSSH.Desktop \
|
||||
--packVersion '${{ steps.winpack.outputs.packVersion }}' \
|
||||
--packDir "$RUNNER_TEMP/osx-arm64" \
|
||||
--packTitle DodoSSH \
|
||||
--packAuthors DodoTech \
|
||||
--mainExe DodoSSH \
|
||||
--bundleId dev.dodotech.dodossh \
|
||||
--runtime osx-arm64 \
|
||||
--channel osx \
|
||||
--outputDir "$bundles"
|
||||
|
||||
# Asked for rather than inferred from an exit code, for the reason the Windows step gives.
|
||||
# The Info.plist is the specific thing worth naming: a bundle missing it is a directory
|
||||
# macOS will not treat as an application at all, and it is the one part of the .app that
|
||||
# vpk composes rather than copies.
|
||||
app="$bundles/DodoSSH.Desktop.app"
|
||||
if [ ! -s "$app/Contents/Info.plist" ]; then
|
||||
echo "vpk reported success and there is no Info.plist at $app/Contents/Info.plist." >&2
|
||||
find "$bundles" -maxdepth 3 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
|
||||
|
||||
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
||||
# server through Testcontainers and runs the API as a child process — so it needs a
|
||||
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
||||
|
||||
@@ -80,6 +80,8 @@ docs/platform-flags.md what differs off Windows, and the gotchas that have c
|
||||
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
||||
docs/android-port.md the Android head: what was decided, what is built, what is left
|
||||
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
|
||||
release-macos.sh — the same, signed and notarized, on a Mac
|
||||
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
|
||||
```
|
||||
|
||||
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
||||
@@ -152,8 +154,32 @@ reinstalling asks for your passphrase rather than starting over. Use **Sign out*
|
||||
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
|
||||
this machine's device key from your account.
|
||||
|
||||
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
|
||||
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
|
||||
## Installing on macOS
|
||||
|
||||
A `.pkg` on the same release page, for Apple Silicon. Everything above about where a client may come from,
|
||||
about the update check and about uninstalling applies unchanged; what differs is worth three short
|
||||
paragraphs.
|
||||
|
||||
**It is signed and notarized, so there is no warning to click past.** That is not generosity — macOS refuses
|
||||
to open an un-notarized download outright rather than warning about it, so unlike the Windows build there
|
||||
was never an unsigned option. If you *do* see "cannot be opened because Apple cannot check it for malicious
|
||||
software", the file did not come from the project's release page, and that is worth taking literally.
|
||||
|
||||
**Apple Silicon only for now.** An Intel package is a small amount of work and no one here has an Intel Mac
|
||||
to check it on, and this project does not ship desktop builds nobody has run — see
|
||||
[docs/manual-checks.md](docs/manual-checks.md). Under Rosetta the arm64 build will not run; there is no
|
||||
graceful version of that, and the honest answer is that the platform is not covered yet.
|
||||
|
||||
**Touch ID can stand in for your passphrase**, on a Mac with a Secure Enclave. The key that unwraps your
|
||||
device key is generated inside the enclave and never leaves it, and the enclave — not DodoSSH — is what
|
||||
requires your fingerprint or login password before it will use it. Cancel the prompt and you get the
|
||||
passphrase screen, always. The application lives at `/Applications/DodoSSH.Desktop.app` and your vault cache
|
||||
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
|
||||
first never touches the second.
|
||||
|
||||
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
|
||||
`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
|
||||
not only that the runners are Linux.
|
||||
|
||||
### The nightly desktop build
|
||||
|
||||
@@ -886,8 +912,18 @@ keychain plus a terminal — and the spike that gates all of it.
|
||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
||||
[Installing on Windows](#installing-on-windows) for what a user sees.
|
||||
|
||||
Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the
|
||||
first release aimed at strangers), and macOS and Linux packaging.
|
||||
**The macOS half is built on the same machinery**, and signed from the start because Gatekeeper leaves no
|
||||
choice: `scripts/release-macos.sh` publishes, signs every native library, notarizes with Apple and staples
|
||||
the ticket before it will hand anything over, and refuses to upload until a person has installed it. The
|
||||
device key is held in the Secure Enclave behind Touch ID. CI publishes `osx-arm64` and builds the `.app`
|
||||
on every main build to prove it still packages, and uploads nothing. See
|
||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
|
||||
[Installing on macOS](#installing-on-macos).
|
||||
|
||||
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
|
||||
certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the
|
||||
manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
|
||||
everything above is verified only as far as the bundle.
|
||||
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
||||
|
||||
## Licence
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
What the hardened runtime has to be asked to relax before a .NET application will run under it.
|
||||
|
||||
The hardened runtime is not optional: notarization refuses a Developer ID submission without it,
|
||||
and Gatekeeper refuses an un-notarized download. So every entitlement below is the price of being
|
||||
distributable at all, and each one is a hole in a wall that is otherwise worth having. They are
|
||||
listed one at a time, with what breaks without each, because the temptation when notarization
|
||||
fails at eleven at night is to paste in a longer list from somewhere and stop thinking.
|
||||
|
||||
◆ WHAT IS DELIBERATELY NOT HERE.
|
||||
|
||||
com.apple.security.app-sandbox. Developer ID distribution outside the App Store does not require
|
||||
the sandbox, and turning it on would break the product outright: the terminal's data plane is a
|
||||
loopback WebSocket (see DodoSSH.Client.Terminal/TerminalDataPlane.cs), and a sandboxed process
|
||||
needs com.apple.security.network.server to listen at all, plus network.client to reach any host
|
||||
the user asks for. This is the same shape of decision as ruling out MSIX on Windows, which was
|
||||
ruled out for the same loopback reason — docs/platform-flags.md.
|
||||
|
||||
com.apple.security.cs.debugger. Would let this process attach to others. Nothing here debugs
|
||||
anything, and it is the entitlement most worth not having.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
CoreCLR compiles IL to machine code at runtime and then executes the pages it just wrote. The
|
||||
hardened runtime's default is that no page is both writable and executable, so without this the
|
||||
process does not start — it dies during runtime initialisation, before any of this application's
|
||||
code runs, which means before anything exists that could report it.
|
||||
-->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
|
||||
<!--
|
||||
The broader form of the same permission, and it is needed as well as allow-jit rather than
|
||||
instead of it. allow-jit covers pages mapped through the MAP_JIT convention; CoreCLR also
|
||||
allocates executable memory outside that path — stubs, precode, and the write-xor-execute
|
||||
fallback it uses when MAP_JIT is unavailable. With only the first, startup gets further and
|
||||
still fails.
|
||||
-->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
|
||||
<!--
|
||||
Library validation requires every loaded dylib to be signed by the same team as the main
|
||||
binary. This bundle carries native libraries built by other people — libsodium, libSkiaSharp,
|
||||
libHarfBuzzSharp, libe_sqlite3, libAvaloniaNative — and the release script signs each of them
|
||||
with this Developer ID, which would in principle satisfy validation.
|
||||
|
||||
It is disabled anyway, and the reason is the updater. Velopack replaces the bundle in place and
|
||||
relaunches it, and the process doing the replacing is not always signed by the same team as the
|
||||
process being replaced during the changeover. Leaving validation on makes the failure mode of a
|
||||
bad update "the application will not start", with no way to recover except a reinstall the user
|
||||
would have to be told about through some other channel.
|
||||
-->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
|
||||
<!--
|
||||
The runtime reads DYLD_ variables while resolving its own native dependencies, and Velopack's
|
||||
update path sets them. Without this the hardened runtime strips them silently and the failure
|
||||
surfaces later as a library that cannot be found, naming a file that is plainly present.
|
||||
-->
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,125 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
The Info.plist for the macOS bundle, with the version left as a placeholder.
|
||||
|
||||
◆ A TEMPLATE RATHER THAN A FILE, BECAUSE vpk COPIES A CUSTOM PLIST VERBATIM.
|
||||
|
||||
Measured, not assumed: `vpk [osx] bundle --plist` performs no substitution of any kind. It logs
|
||||
"Bundle using provided Info.plist" and copies the bytes. That is also why it refuses --plist and
|
||||
--bundleId together — with a plist supplied, every key is the caller's problem.
|
||||
|
||||
So a committed Info.plist would carry whatever version it was written with into every release
|
||||
afterwards, and the failure is quiet in the worst way: Velopack's own release index would carry the
|
||||
right version, the updater would compare correctly and update correctly, and only the About window,
|
||||
Finder's Get Info panel and any crash report would claim the build was something else. Nobody
|
||||
reads those on the day of a release. scripts/release-macos.sh substitutes @VERSION@ into a copy
|
||||
and passes that.
|
||||
|
||||
◆ WHY A CUSTOM PLIST AT ALL, WHEN vpk WRITES A PERFECTLY GOOD ONE.
|
||||
|
||||
Three keys it does not write, each of which is a real defect without it:
|
||||
|
||||
CFBundleDisplayName The bundle on disk is DodoSSH.Desktop.app, because the pack id must not
|
||||
be DodoSSH — see scripts/release-macos.sh for the directory collision
|
||||
that rule prevents. On Windows the pack id is invisible; on macOS it
|
||||
names the thing in /Applications and in the Dock. This key is what puts
|
||||
"DodoSSH" back in front of a person while the bundle keeps the id.
|
||||
|
||||
LSMinimumSystemVersion Without it macOS will happily launch this on a release the runtime was
|
||||
never built for, and the user gets a dyld crash rather than a sentence.
|
||||
|
||||
NSHumanReadableCopyright Shown in the About panel. Absent, the panel shows a blank line.
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
CFBundleName is what the menu bar shows and is capped at 15 characters by convention;
|
||||
CFBundleDisplayName is what Finder and the Dock show. Both say DodoSSH, and the bundle
|
||||
directory does not. See the note above.
|
||||
-->
|
||||
<key>CFBundleName</key>
|
||||
<string>DodoSSH</string>
|
||||
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DodoSSH</string>
|
||||
|
||||
<!--
|
||||
Reverse-DNS under the domain this project actually controls. It is the identity Gatekeeper,
|
||||
the notary service and the keychain all key off, so it is as irreversible as the Windows pack
|
||||
id: changing it makes an update a different application, and it orphans anything the previous
|
||||
identifier stored — including the Secure Enclave key MacDeviceKeyStore holds, which is scoped
|
||||
to this identifier and cannot be migrated because its whole point is that it never leaves the
|
||||
enclave.
|
||||
-->
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>dev.dodotech.dodossh</string>
|
||||
|
||||
<!--
|
||||
The apphost the publish produced, named for the product by <AssemblyName> in the csproj rather
|
||||
than for the project. Must match --mainExe or the bundle launches nothing.
|
||||
-->
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>DodoSSH</string>
|
||||
|
||||
<!--
|
||||
Both version keys take the numeric core only — 1.2.3 and never 1.2.3-rc.1 — because Apple
|
||||
defines them as one to three dot-separated integers and notarization rejects what it cannot
|
||||
parse. The full version, prerelease suffix and all, is in Velopack's release index, and that
|
||||
is the one the updater compares. These two are for Finder and for Gatekeeper.
|
||||
|
||||
They are the same value rather than the usual marketing/build split, because there is no build
|
||||
counter here that a release does not already bump.
|
||||
-->
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>@VERSION@</string>
|
||||
|
||||
<key>CFBundleVersion</key>
|
||||
<string>@VERSION@</string>
|
||||
|
||||
<!--
|
||||
The file name inside Contents/Resources, which is where --icon puts it. With a custom plist
|
||||
nothing rewrites this key, so a rename of the asset that forgets this line produces a bundle
|
||||
showing the generic application icon and no error anywhere.
|
||||
-->
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>dodossh.icns</string>
|
||||
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
|
||||
<!--
|
||||
12.0, and it is read off the binaries rather than off a support matrix. The apphost and
|
||||
libcoreclr.dylib in a net10.0 osx-arm64 publish both carry LC_BUILD_VERSION with minos 12.0.0,
|
||||
so 12.0 is the oldest release these bytes are built to load on.
|
||||
|
||||
Microsoft's *support* statement for .NET 10 is higher than this, and that difference is
|
||||
deliberate rather than overlooked: this key decides whether macOS refuses to launch the app at
|
||||
all, and refusing on a release where it would in fact have run is the worse of the two errors.
|
||||
A user on an unsupported-but-working macOS gets the application; the support matrix governs
|
||||
what gets fixed if it misbehaves there, which is a different question.
|
||||
-->
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
|
||||
<!--
|
||||
Without this the window is drawn at 1x and scaled up, which on a Retina display turns the
|
||||
terminal — the one surface in this application that is nothing but small text — into a blur.
|
||||
-->
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
|
||||
<!--
|
||||
False, and stated rather than left out. An agent application has no Dock icon and no menu bar;
|
||||
this one is an ordinary windowed application and the default is already false, but the key
|
||||
being absent is indistinguishable from somebody having removed it.
|
||||
-->
|
||||
<key>LSUIElement</key>
|
||||
<false/>
|
||||
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© DodoTech. MIT licensed.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,4 +1,4 @@
|
||||
# ADR 0007 — What protects the device key on Windows
|
||||
# ADR 0007 — What protects the device key on the desktop
|
||||
|
||||
**Status:** accepted, 2026-07-30
|
||||
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
|
||||
@@ -141,6 +141,14 @@ would have become false under DPAPI alone. A gesture is still something the atta
|
||||
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
||||
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
||||
is therefore required, not a nicety.
|
||||
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
|
||||
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
|
||||
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
|
||||
platform's rather than the process's — which is the entire point of the 2026-07-30 amendment above, and
|
||||
the thing a self-drawn prompt over a protected file would fail to be. The mechanical differences are
|
||||
incidental: P-256 with ECIES because the enclave holds no other kind of key, and no prompt when sealing
|
||||
because the public half needs no consent. See docs/platform-flags.md for the three ordinary Macs where the
|
||||
probe answers no, one of which is every unsigned development build.
|
||||
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
|
||||
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
|
||||
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
|
||||
|
||||
@@ -228,6 +228,44 @@ changes.
|
||||
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
||||
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
||||
|
||||
**This rule is Windows-only, and macOS gets the opposite one.** See decision 10: there is no "unsigned for
|
||||
now" available on that platform at any price, because Gatekeeper refuses rather than warns.
|
||||
|
||||
### 10. macOS is a second desktop platform on the same machinery, signed from the start
|
||||
|
||||
The macOS head is the same application, the same Velopack, and the same two-phase person-run release. Four
|
||||
things differ, and each is forced rather than chosen.
|
||||
|
||||
**Signing is a precondition, not an improvement.** Decision 8's whole argument — one dialog per user per
|
||||
lifetime, buy a certificate when a stranger is invited to install — has no macOS equivalent. An
|
||||
un-notarized download is refused outright, so the Developer ID certificate and the notarization round trip
|
||||
are the price of the package existing. `scripts/release-macos.sh` therefore refuses to run without the
|
||||
signing identities, where the Windows script refuses nothing.
|
||||
|
||||
**The channels are `osx` and `osx-nightly`, and they are separate for decision 9's reason.** Four channels
|
||||
now publish to one repository, and the only thing keeping a Mac from being offered a Windows package is
|
||||
that it never reads that index. The macOS nightly channel is named and has no publisher: CI builds and
|
||||
bundles the macOS head to prove it still builds, and uploads nothing, exactly as it does for the Windows
|
||||
release channel.
|
||||
|
||||
**The pack id is shared with Windows, and on macOS it is visible.** vpk names the bundle after the pack id,
|
||||
so `/Applications` holds `DodoSSH.Desktop.app`. Decision 2's reasoning applies with more force here rather
|
||||
than less: a pack id of `DodoSSH` would put Velopack's install root on `~/Library/Application
|
||||
Support/DodoSSH`, which is `ClientPaths.DataDirectory`, and an uninstall would take the user's un-synced
|
||||
outbox with it. `CFBundleDisplayName` puts the product name back in front of a person; the directory keeps
|
||||
the id.
|
||||
|
||||
**arm64 only, because the check is the scarce thing.** Velopack keys a channel to one architecture, and an
|
||||
Intel package would be the only artefact in this repository reaching users without somebody having walked
|
||||
Phase 18 against it. The engineering for a second channel is small and is described in the release script;
|
||||
what is missing is an Intel Mac to verify on, and shipping blind is the thing this project's manual-check
|
||||
discipline exists to refuse.
|
||||
|
||||
**And one thing that does not differ, which is worth saying because it is the expensive half.** The
|
||||
capability to publish still lives on a person's machine and never in CI. Notarization does not change that:
|
||||
Apple's ticket says this build came from this developer account, and says nothing about whether the build
|
||||
should have been made. Velopack clients still apply what their feed serves. Rule 3 is untouched.
|
||||
|
||||
### 9. There is a second desktop channel, published by CI, and it is a second application
|
||||
|
||||
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
|
||||
|
||||
+79
-3
@@ -30,7 +30,10 @@ verified is that it compiles, links, packages, and carries the right natives.
|
||||
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
|
||||
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
|
||||
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
|
||||
with the foreground service now counting transfers as well as shells.
|
||||
with the foreground service now counting transfers as well as shells — and, since, an idle-but-connected
|
||||
Files session as well, which a transfer count alone was blind to. *Corrected the same round:* the service's
|
||||
other half — a shell's own opening — had never been wired to anything at all, so a shell survived only for
|
||||
as long as the app stayed foreground; see [Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service).
|
||||
|
||||
**What was actually checked**, so the rest can be read with the right amount of trust:
|
||||
|
||||
@@ -235,6 +238,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
|
||||
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
|
||||
@@ -280,13 +314,50 @@ What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, th
|
||||
|
||||
### Sessions survive backgrounding, via a foreground service
|
||||
|
||||
A persistent notification for as long as a shell or a transfer is live.
|
||||
A persistent notification for as long as a shell, a transfer, or an idle-but-connected Files session is
|
||||
live.
|
||||
|
||||
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
|
||||
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
|
||||
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
|
||||
notification is to look at.
|
||||
|
||||
**Three corrections found after the first cut shipped, all in the wiring rather than the design:**
|
||||
|
||||
- **A shell opening never started the service.** `SessionKeepAlive` heard `TerminalWorkspace.SessionEnded`
|
||||
and refreshed on that, but nothing announced the opposite event — so a user who opened a shell and
|
||||
backgrounded the app immediately had no foreground service at all, and Android was free to kill the
|
||||
process holding it. `MainWindowViewModel.TerminalSessionOpened` is now wired the same way in
|
||||
`App.axaml.cs`'s `ComposeKeepAlive`.
|
||||
- **A connected-but-idle Files session counted as nothing.** A host open on the Files screen with no
|
||||
transfer moving is a live SFTP connection a dying process would sever, and the old two-argument
|
||||
`Reconcile(liveSessions, activeTransfers)` had no way to hear about it. `TransfersViewModel.HasLiveFileSession`
|
||||
— `IsConnected` with a real `ConnectedCipher`, which a bucket never has — is the third fact `Reconcile` now
|
||||
takes.
|
||||
- **Refreshing the notification restarted the service, which throws when backgrounded.** `Reconcile` called
|
||||
`StartForegroundService` on every refresh, including the common case of a service that was already
|
||||
running. On API 31+ that throws `ForegroundServiceStartNotAllowedException` the instant the app is
|
||||
backgrounded — a transfer finishing in the pocket, one of two shells dying — which crashed the process and
|
||||
took every session with it. `SessionForegroundService` now tracks whether it is already running and, when
|
||||
it is, posts the updated notification through `NotificationManager.Notify` instead of asking Android to
|
||||
start anything.
|
||||
|
||||
**The notification permission is requested, not just declared.** API 33+ requires `POST_NOTIFICATIONS` at
|
||||
runtime or the receipt is silently invisible — the service still runs, but nothing on screen says so.
|
||||
`SessionForegroundService.Reconcile` asks for it the first time in this process there is actually something
|
||||
to show, at most once, with no result read back: a refusal costs the notification and nothing else, which is
|
||||
what the manifest's own comment on the permission says.
|
||||
|
||||
**And a fourth correction, found by the notification refusing to come down.** "1 shell connected" outlived
|
||||
the shell, both ways a shell can close. A shell exiting on its own announced `SessionEnded` from inside its
|
||||
run's own finally block — where the run task is by definition not yet complete, so the
|
||||
`LiveSessionCount` the keep-alive reads from that event still counted the session that had just ended, and
|
||||
nothing fired afterwards to correct it. A tab closed by hand announced nothing at all, by a recorded
|
||||
decision that assumed every subscriber was the closer. Both reversed in `TerminalWorkspace`: the end is now
|
||||
announced from a continuation after the run has actually completed, and `CloseSessionAsync` announces too,
|
||||
after its own drain — the event's remark carries the reversal, and `SessionEnded`'s subscribers were all
|
||||
already "reconcile to reality" handlers for which a second announcement is harmless.
|
||||
|
||||
### Phone first
|
||||
|
||||
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
|
||||
@@ -523,7 +594,12 @@ go at 360dp:
|
||||
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
|
||||
truth deliberately: it already knows that a session whose shell exited is not live, which a counter
|
||||
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
|
||||
dishonesty the unlock screen's count exists to prevent.
|
||||
dishonesty the unlock screen's count exists to prevent. *Corrected since:* the opened half of a shell's
|
||||
lifecycle was never wired in, so the service could never come up for a shell at all; an idle-but-connected
|
||||
Files session now counts as a third live fact rather than nothing; a refresh while backgrounded updates
|
||||
the notification in place instead of restarting the service, which the API throws on; and
|
||||
`POST_NOTIFICATIONS` is now actually requested rather than merely declared. See
|
||||
[Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service) for all four.
|
||||
7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design,
|
||||
plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
|
||||
choosing a passphrase).
|
||||
|
||||
+160
-6
@@ -69,7 +69,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> labelled sidebar and the chrome went from 72 tall to 86, so `880x560` became `1016x574` — leaving every
|
||||
> screen the same `826x464` it was designed against. Four of the tables stop fitting at 690 wide, so
|
||||
> widening the sidebar without widening the window would have broken them where the layout suite was not
|
||||
> looking.
|
||||
> looking. **v5b grows it again**, to `1081x583` — see that section, below.
|
||||
>
|
||||
> **Buckets became a destination** rather than a toggle inside the files screen, matching the phone: the
|
||||
> `HOST` / `BUCKET` pair is gone and `ShellScreen.Buckets` draws the same `TransfersScreen` with the other
|
||||
@@ -102,6 +102,14 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is
|
||||
> a page test rather than a fourth `ShellSurface`.
|
||||
>
|
||||
> **v5b reverses the paragraph above, and says so rather than leaving it to be found out of date.** The tab
|
||||
> strip this paragraph describes is retired — see the v5b section, below — and with it the rule that made the
|
||||
> rail conditional at all: the switcher it drew moved onto the nav rail's own head as three segments, the
|
||||
> rail stopped collapsing for any of them, and neither SFTP, S3 nor a terminal gets the window's full width
|
||||
> any longer. `IsVaultsTab` outlived the strip essentially unchanged — it is still a page test rather than a
|
||||
> read of a `ShellSurface`, now over which of the rail's own destinations is showing rather than which of
|
||||
> three tabs was.
|
||||
>
|
||||
> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar
|
||||
> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds
|
||||
> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The
|
||||
@@ -180,9 +188,21 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> 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 recolours
|
||||
> with the shared palette and keeps its own default sans — fonts are per-head by decision, only colour is
|
||||
> shared.
|
||||
> 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
|
||||
@@ -238,11 +258,124 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
|
||||
> | 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 | **Deferred; the data is not.** `HostSecret.PinnedPaths` is shared, synced and merged on both heads, so a pin made on the desktop reaches the phone and back — Android just has nowhere yet to add or remove one itself. |
|
||||
> | QUICK ACCESS's editor, on the phone | ◆ **Shipped, over the same shared data the deferral above described.** The phone's host editor draws its own QUICK ACCESS section on the same staged `VaultViewModel.EditorPinnedPaths` the desktop's drawer binds — a row per pin, an add field and button, and a remove target sized to this head's 44dp touch floor rather than the desktop's 22-pixel close box. `AddEditorPinCommand`'s refusals surface through a `Status` line the editor page draws for itself, since that page covers the whole screen and the list behind it draws its own `Status` off-screen for as long as it is open. The pins themselves reach a second surface this head has that the desktop does not need: `TransfersViewModel.ConnectedPinnedPaths` carries them as chips on the Files screen while connected, and tapping one runs `GoRemoteCommand` — the same command the breadcrumb trail already used to navigate. Two deviations from the desktop, both named where they land: the chip row is a snapshot taken at connect rather than a live follow of the vault, so a pin edited mid-session shows up on the next connect rather than this one; and the editor's own hint sentence says the pins appear on the Files screen, not above a terminal — this head has no terminal strip for them to sit above, the same honesty the row below already states for the desktop's own hint. |
|
||||
> | Collapse All beside every section's own collapse chevron | Bound on every heading's view model and shown on only the first — `SidebarGroupHeader.IsFirstBoardSection` is what a virtualised list of sections uses in place of a control of the board's own that would otherwise have to sit above all of them. |
|
||||
> | The QUICK ACCESS hint's claim that pins live in a sidebar | "Pinned folders appear above the terminal for this host." — no sidebar exists on this screen for the sentence to point at, so the shipped hint says where they actually draw. |
|
||||
> | The mock's "Saving to **DodoTech ▾** vault" subtitle, with a picker's chevron inside a sentence | The pre-existing `DrawerSubtitle` wording — the vault's name alone, unchanged by this pass. A chevron inside running text implies the text itself is the control, which it is not: the vault picker is its own element, shown only while creating and only above one writable vault, as it always has been. |
|
||||
> | The design's two deeper text steps, `#6D6F84` and `#5D5F74` | Not added as `Palette.axaml` keys. `TextGhost`, already repicked to `#7C7F98` for v3, is reused everywhere the design reaches for either — a resource nothing yet distinguishes from `TextGhost` is not free to carry, on the same reasoning the border ramp's own remarks give. |
|
||||
>
|
||||
> ## The desktop's v5b — chrome, session shell and SFTP restyled
|
||||
>
|
||||
> A sixth pass, and the first aimed at the chrome and the two screens somebody spends the most time inside
|
||||
> rather than at any one screen on its own. **What shipped:** the titlebar and nav rail redrawn against
|
||||
> `TitleBar.dc.html` and `NavRail.dc.html` — a segmented SSH/SFTP/S3 switcher at the rail's own head, a
|
||||
> mode-dependent first row beneath it, and Vaults and Preferences moved off the rail's list and into a
|
||||
> popover under the user chip at its foot. The terminal and the SFTP surface both gained a session shell — an
|
||||
> in-screen tab row, a host header, a bottom status bar and a 300-pixel QUICK ACCESS/SNIPS sidebar —
|
||||
> replacing the window-wide tab strip v3 built and the pin-chip strip that used to sit above the terminal
|
||||
> alone. The SFTP screen's own two panes and its TRANSFERS strip were restyled against `SFTP.dc.html` — row
|
||||
> grids, quiet Material glyphs, slim progress bars — and three screens that had never had a `.dc.html` of
|
||||
> their own got one each: Keychain, Snippets (titled Snips on screen now, matching the rail) and Logs.
|
||||
>
|
||||
> **The nav rail stopped being conditional furniture, and that is the one structural change underneath the
|
||||
> paint.** v3 drew it only under the Vaults tab, so SFTP, S3 and an open terminal each got the window's full
|
||||
> width; v5b's own fidelity pass puts the rail up beside all three, because the design draws it that way and
|
||||
> because the segmented switcher now living at its head is only worth having if it stays reachable from
|
||||
> everywhere. `MainWindowViewModel.IsVaultsTab` still exists and still answers the question it always did —
|
||||
> which of the rail's own six destinations is on screen, as opposed to SFTP or S3 — but nothing about it gates
|
||||
> the rail's own visibility any more. The window's minimum grew again to hold the difference: `1016x574`
|
||||
> became `1081x583`, nine pixels for the titlebar's 44-to-53 and sixty-five for the rail's 190-to-255, added
|
||||
> straight onto the minimum rather than absorbed by shrinking a screen — see `MainWindow.axaml`'s own remark
|
||||
> and `LayoutHarness.MinimumWidth`/`MinimumHeight`. `ScreenWidth` held at 826 because the rail is the only
|
||||
> thing beside a page that grew; `ScreenHeight` grew by the 42 pixels the retired tab strip used to cost every
|
||||
> screen, because nothing replaced that strip as chrome every screen pays for — the terminal and SFTP
|
||||
> surfaces pay for their own tab row out of their own session-shell budget instead. See
|
||||
> `LayoutHarness.ScreenHeight` and `SessionScreenHeight`.
|
||||
>
|
||||
> | v5b element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
|
||||
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
|
||||
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
|
||||
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
|
||||
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
|
||||
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
|
||||
> | No pins destination in the design at all | **The rail agrees with the design now.** `KnownHostsScreen` is still built and still reachable — from **Host keys** on the Keys screen's own header, which was always the second way in — but the rail's Pins row is gone. It was kept through v5b on the grounds that the mock has no screen for approved host keys, which is a reason for the screen to exist and was never a reason for a rail entry once the keychain had a door to the same place. Two rail rows landing on one screen is a rail that has to be read twice. |
|
||||
> | The popover's Settings and Preferences rows, and the design's own Settings-* family of screens | **Landed in v5c.** What was two doors to one room in v5b — Settings and Preferences both opening the same bare `Preferences` screen — is now two of three doors onto their own settings pages: Settings opens General, Preferences opens Preferences, and a third row, Vaults, opens Vaults. All three are real, distinct pages inside one settings mode; see the v5c section, below. |
|
||||
> | `· Org` after the user chip's name, and a `Primary` tag on a vault row in the popover | Neither. There is no organisation concept behind a vault — only the vault itself — and no vault is distinguished as primary; the popover's vault rows are the existing shown-vaults toggles, restyled. |
|
||||
> | The design's titlebar, which has nowhere for a sync indicator | `SYNCED` stays, on the titlebar's right side, ahead of the window's own minimise/maximise/close buttons — the one thing this titlebar keeps that the design's own does not draw at all. |
|
||||
> | Per-tab SFTP sessions, implied by a tab row shared between the terminal and the SFTP surface | **Not built, and not what shipped instead.** A click on the SFTP tab row runs `MainWindowViewModel.SelectFilesHostAsync`, which opens (or reuses) a second, SFTP-specific connection through the same "Browse files" plumbing a pin click already used — an honest second login, not a channel multiplexed onto the terminal's. `SelectedTab` moves with the click, which is also what keeps the sidebar's QUICK ACCESS in step — that list is keyed to `SelectedTab` on both surfaces, so selecting a different terminal tab afterwards can leave QUICK ACCESS naming a host that is not the one the remote pane is actually browsing. |
|
||||
> | A scroll-to-section affordance behind the sidebar's **+ Pin folder** row | Not built — this application has no way to open the host editor already scrolled to one card inside it. `PinFolderFromSidebarCommand` opens the same three-card editor the hosts screen's own EDIT reaches, at the top, which is the closest honest affordance rather than a new one. |
|
||||
> | The TRANSFERS strip's aggregate throughput readout (`8.4 MB/s` beside an arrow) | Omitted. `TransferRowViewModel.Progress` computes a rate per transfer; nothing sums those into one number for the whole queue, and inventing one would be exactly the fabricated fact this document's honesty rule forbids. |
|
||||
> | Freshness/"hot row" colouring and a selection ring on a finished transfer | Not drawn — there is no tracked notion of how recently a row finished, so nothing distinguishes a transfer that just completed from one that finished an hour ago. |
|
||||
> | The design's Material-Symbols-only "draft" glyph for a file row | `insert_drive_file`, the closest classic Material Icons has — a plain document rather than a page with a folded corner. Recorded as a substitution rather than silently swapped; "draft" is simply not a glyph this font contains. |
|
||||
> | `REMOTE` across the SFTP panes' arrow column | `HOST`/`BUCKET`, kept from before this pass rather than adopted from the design. The pair is the difference between a directory tree and a flat namespace with inferred folders, which is a distinction worth keeping even where the design collapses it. |
|
||||
> | `THIS MACHINE` on the local pane | `LOCAL`, the design's own word, adopted — nothing in this file's own comments ever defended "THIS MACHINE" the way HOST/BUCKET is defended above, so there was no reason to keep it. |
|
||||
> | The Keychain screen's `MODIFIED` column and `FINGERPRINT` box | Both dropped. `VaultItem` carries no timestamp of any kind, and `SshKeySecret` has no fingerprint field — computing one would mean parsing armour the type stores verbatim, an already-recorded gap this pass did not revisit. |
|
||||
> | Five separate add buttons (GENERATE / + SSH KEY / + PASSWORD / + TAG / + BUCKET) | One `+ New key` accent button, opening a menu of the same five actions in the same order — the design draws one button because its own mock has one "add" concept; this application has five, and none of them was dropped to fit. |
|
||||
> | `NEW ITEMS GO TO` on the vault-filing picker | `NEW ITEMS FILE TO`, the design's own wording, adopted — this screen's own comments already called the act "filing", so the label was this application's vocabulary already. |
|
||||
> | The Snippets screen's own on-screen name | **Snips**, matching the rail, which already said so. |
|
||||
> | A modified-date on a snippet card | Omitted — `SnippetSecret` carries no timestamp, the same absence every other item kind has. |
|
||||
> | The design's shorter captions — "Type into terminal", the rationale-only sentence for "runs on insert" | The screen's own longer, more actionable wording kept instead: the insert button still names the destination tab (`TYPE INTO {tab}` / `NO TERMINAL OPEN`), and the "runs on insert" caption still states the operational consequence rather than only the reason the setting exists. Both of the design's sentences are true; the ones already here say more. |
|
||||
> | A delete confirmation for a snippet | **Built, matching the mock.** `SnippetsViewModel.RequestDelete`/`ConfirmDelete`/`CancelDelete` say the same vault-wide-reach, tombstone, no-undo sentence `VaultViewModel.HowFarADeletionGoes` already says for every other item kind — additive beside the existing uncounted `DeleteCommand`, which the phone's own DELETE row still calls. |
|
||||
> | The Logs screen's own footer sentence about encryption | Adopted verbatim — verified against this screen's own header remark and ADR 0001 before shipping it, both true, so it is drawn as literal text rather than reworded. |
|
||||
>
|
||||
> ## The desktop's v5c — a settings area, at last
|
||||
>
|
||||
> An eighth pass, and the first to give the design's own Settings area a home of its own rather than folding
|
||||
> its two real screens into the rail's popover. Settings is a full-window **mode** now, swapped in wholesale
|
||||
> rather than laid over anything: its own 53px titlebar reading "Back to application", a 340px `SettingsNav`
|
||||
> rail — SETTINGS (General, Vaults, Account) above CUSTOMIZE (Security, Preferences, Groups, Tags), Logout
|
||||
> pinned below both — and a content column capped at the design's own 1100 pixels, which is 741 beside the
|
||||
> rail at the window's own minimum rather than the full 1100 (`LayoutHarness.SettingsContentWidth`). Seven
|
||||
> pages, not the design's eight: the ORGANISATION section and its one page are refused outright, below. Two
|
||||
> of the seven are wholly new — Account and Security did not exist as screens before this pass — and the
|
||||
> other five are the old `PreferencesScreen.axaml` and `VaultsScreen.axaml` split apart and restyled into the
|
||||
> design's own card idiom; both files are deleted, and every command either one offered is reachable here
|
||||
> exactly once. The popover's own Settings, Vaults and Preferences rows, and everywhere else in the codebase
|
||||
> that used to navigate to the bare `Preferences` or `Vaults` screen, now land in this mode instead —
|
||||
> `MainWindowViewModel.ShowScreen` redirects at that one point rather than at every caller that used to reach
|
||||
> either screen directly.
|
||||
>
|
||||
> The importer moved inside this same chrome rather than staying a screen of its own: it draws over the
|
||||
> Preferences page as a boolean overlay — `SettingsNav` stays lit on Preferences the whole time it is open,
|
||||
> and the titlebar's own back button reads "Back to preferences" instead of "Back to application" — restyled
|
||||
> to the design's own table (ALIAS/HOSTNAME/USER/PORT/WHAT THIS MEANS, one header tick-all button in place of
|
||||
> the old TICK ALL/TICK NONE pair). Known Hosts was restyled the same pass but kept its own place: it stays a
|
||||
> main-chrome screen, reached from the rail's Keys entry and now also from Security's own "Approved host
|
||||
> keys" row, because the design draws no page for it at all. And the S3/Buckets surface — which got none of
|
||||
> v5b's session shell — picked up that shell's *look* this time without its machinery: a 26-pixel padded,
|
||||
> bordered, radius-12 container and nothing else, since a bucket has no tab row, no host to head a card with,
|
||||
> no status-bar fact to print and no pin for a sidebar to show.
|
||||
>
|
||||
> | v5c element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The Settings-Organisation page, and the rail's own ORGANISATION section | Refused outright. No organisation entity exists anywhere in this product — a team is the membership list behind a shared vault, and there is exactly one tenant per deployment — so the rail simply has no third section; see `SettingsNav.axaml`'s own remark. |
|
||||
> | General: the update-channel switcher, Launch at login, Reopen tabs, the theme control and the language picker | Refused, and carried on the page's own NOT BUILT YET card rather than left silently missing: which channel a copy follows is fixed when it is built, nothing registers this application with Windows' own startup list or remembers a tab list across a launch, only the one dark theme exists, and there is no i18n anywhere in this client. |
|
||||
> | Vaults: the VAULT DEFAULTS card (auto-lock, require-password-on-unlock, relay) and the RECOVERY card (kit, export) | Both refused outright — none of the three settings exists, sync is always on, and there is no recovery kit and no export. |
|
||||
> | Vaults: "Manage devices", and the "3 devices" count on the sync line | Refused. There is no list-devices endpoint anywhere in this client. |
|
||||
> | Vaults: per-vault UNLOCKED/LOCKED chips, and the "Unlock ⟨vault⟩" modal | Refused. This application locks the keychain as a whole, not one vault at a time, so there is no per-vault state for a chip or a modal to act on; the keychain-level fact is the sync card's own dot instead. |
|
||||
> | Vaults: the magenta "Default" badge | Dropped rather than faked. The app does track a "new items go to" vault, but that lives on a different view model than the row being drawn here, and cross-referencing the two per card would be more machinery than the badge is worth. |
|
||||
> | Vaults: "Sorted by name ▾" | Decorative in the mock, and not drawn — `VaultsViewModel.Vaults` is already ordered personal-first-then-name, and there is no second order to switch to. |
|
||||
> | Vaults: member avatar stacks on every card | Refused on an unselected card — `VaultRowViewModel` carries a member *count*, not the members themselves, and only the selected vault's own member list is actually loaded. A count stands in on the card; avatars are real in the members panel, where the data is. |
|
||||
> | Account: Edit profile, Change passphrase, the DEVICES list, Sign out everywhere | All four refused — no endpoint exists for any of them, and the passphrase is a one-time enrollment choice with no change flow. What is new and real on this page instead: the SIGN-IN card's issuer sentence, `MainWindowViewModel.Issuer` off `MeResponse.Issuer`, cached the same turn the name and email are and surfaced here for the first time — "Signing in proves who you are — it never decrypts a vault." |
|
||||
> | Security: the strict-host-key toggle, the allowed-algorithms chips, the auto-lock link row | Refused — the app always asks on a changed key, there is no algorithm allow-list anywhere in the SSH stack, and there is no auto-lock setting for the link to point at. |
|
||||
> | Security: RECENT SECURITY EVENTS as a list of rows | Shipped as the link alone, "See all activity in Logs." A real list would mean a filtered read over `LogsViewModel`'s two logs, and which entries count as "security" is a judgement call the design does not resolve; the honest link is complete on its own. |
|
||||
> | Preferences: the device-name editor, Font/Cursor/Scrollback/Copy on select/Terminal bell, the clipboard-clear delay, confirm-run-on-insert | All refused, on the page's own carried-over NOT-BUILT idiom — none of the seven has anything behind it. Windows Hello's register/"Stop unlocking here" pair moved off this page to Security instead, one home rather than two. |
|
||||
> | Groups: drag-to-reorder, and the mock's "the order here is the order there" sentence | Refused. `HostGroupRowViewModel` orders by the vault new items go into, then by vault name, then by label — there is no manual order to drag into. The page prints the true sentence instead and draws no drag handles. |
|
||||
> | Tags: the LAST APPLIED column | Refused — no timestamp of when a tag was last put on a host exists anywhere in this client. |
|
||||
> | Tags: a per-tag vault chip | Refused — `TagRowViewModel` carries a label and a host count and nothing else; there is no vault id on the row for a chip to read. |
|
||||
> | Import: the "saving to ⟨vault⟩ ▾" picker | Refused as a control. Every import writes through the same `session.ActiveVaultId` every other bulk write does, so there is no per-import target to choose — the footer prints the vault's name as a fact, `ImportViewModel.SelectionSummary`, rather than as a `▾`. |
|
||||
> | Import: the old ADDRESS/AUTHENTICATION/STATE columns | Folded into the design's own column set — ALIAS/HOSTNAME/USER/PORT — with the authentication text moved onto the alias cell's own tooltip rather than kept as a column of its own. |
|
||||
> | Import: TICK ALL / TICK NONE | Replaced by the design's own header tick-all button, one press that ticks or unticks every row through `ImportRowViewModel.ToggleAllCommand`. |
|
||||
> | Import as a nav-rail destination | Refused. It is a boolean overlay over the Preferences settings page (`MainWindowViewModel.IsImportOpen`), not a page of its own — `SettingsNav` stays lit on Preferences throughout, exactly as `Import.dc.html` draws it. |
|
||||
> | The design's fixed 1100px content column | `MaxWidth="1100"` rather than a fixed width — the column is 741 pixels beside the 340px rail at the window's own minimum, where a fixed 1100 would not fit. |
|
||||
> | The status bar and the update banner, while in settings mode | Both hidden — `MainWindow.axaml`'s bottom two rows read `!IsSettingsMode`. The design's own settings titlebar has no room for either, and there is nowhere honest to draw them instead. |
|
||||
> | The design's sentence-case button copy | Not adopted. Every button on every settings page keeps this application's own ALL-CAPS mono convention — `CHECK NOW`, `SIGN OUT`, `OPEN IMPORTER` — over the mock's own "Check now". |
|
||||
> | The design's assumption that Settings is the only thing on screen | Not followed. The quick-connect palette and an unapproved host key's decision card both still draw over settings mode exactly as they interrupt every other screen — a connection question does not stop mattering because the window happens to be showing Settings; see `MainWindow.axaml`'s own remark on the ordering of its Panel. |
|
||||
> | The importer's own sentence, "This is the only control in DodoSSH that opens key material from a directory you did not point at file by file — nothing is read until Import is pressed." | Adopted verbatim — verified against `ImportViewModel.ScanAsync` and `ImportAsync`: scanning never calls `SshConfigLocator.ReadIdentity`, and `ImportAsync` is the only path that ever does. |
|
||||
> | The tags page's own sentence, "Renaming here is one write and every host follows." | Adopted verbatim — verified against `SaveTagAsync`: a host names a tag by id, never by label, so a rename touches nothing but the tag item itself. |
|
||||
> | The known-hosts screen's own intro, "Every pin is a decision recorded at the moment of connecting. Fingerprints are never trimmed — compare them character by character against what the operator published." | Adopted verbatim, matching the stance the rest of this application already takes on a pin. |
|
||||
|
||||
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
|
||||
project each piece would have to land in, and **what the shipped interface does instead**. That last
|
||||
@@ -404,7 +537,7 @@ caption buttons and window title drawn on top of the application's own — two s
|
||||
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
|
||||
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
|
||||
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
|
||||
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. |
|
||||
| Pane header `aes256-gcm` | client-ssh | **No longer a miss — the plumbing this row used to lack now exists.** `ISshConnection.Cipher` and `TerminalWorkspace.GetSessionFacts` were built for the v5b session shell's status bar; see that row in the v5b section, above. | Still not on a pane header — this pre-v5b element does not exist as its own piece of chrome any more. The fact it wanted to show is drawn instead where v5b moved it: the status bar's `SessionCipher`, beside CONNECTED. The session shell's host header separately shows the account and endpoint actually dialled (v3–v4: the tab strip did; v5b moved that fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
|
||||
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
||||
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
||||
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
||||
@@ -587,3 +720,24 @@ grid of cards with a drawer — see above. The split it describes did not change
|
||||
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
|
||||
sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire,
|
||||
`IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected.
|
||||
|
||||
---
|
||||
|
||||
## v5c-4 — two more, asked for after living with v5b
|
||||
|
||||
**The session shell's host header is gone, and it is a deliberate departure from the design.**
|
||||
`Terminal.dc.html` and `SFTP.dc.html` both draw a 60-pixel row above the pane carrying the address on the
|
||||
left and a cross-surface button on the right, and v5b shipped it as `SessionHeader.axaml`. Both of the two
|
||||
facts it held now live at the head of the sidebar beside the pane — the address as its own line, and the
|
||||
button stretched across the column under it — and the pane is 60 pixels taller for it. The reasoning is the
|
||||
one the design cannot see from a mock: this is a window somebody keeps a terminal open in all day, and a
|
||||
full-width strip repeating an address the tab already names was the cheapest 60 pixels in the layout to give
|
||||
back. `LayoutHarness.SessionScreenHeight` no longer subtracts a header, for the same reason it stopped
|
||||
subtracting the retired window-wide tab strip.
|
||||
|
||||
**The sidebar closes, which the design has no state for.** 300 pixels of a 1081-pixel minimum is a lot to
|
||||
spend on a list that is often two rows long, so `MainWindowViewModel.IsSessionSidebarOpen` folds the column
|
||||
to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel
|
||||
that vanishes without trace is one people report as lost. The choice is written through to
|
||||
`ClientSettings.SessionSidebarOpen` rather than held for the session: it is a decision about how much of the
|
||||
window a terminal gets, and one that had to be made again on every launch would not really be on offer.
|
||||
|
||||
+487
-117
@@ -17,22 +17,25 @@ Three constraints put things on this list, and they are worth knowing before add
|
||||
Each item says what to do, what a pass looks like, and what a failure would mean.
|
||||
|
||||
**On the numbering.** A check keeps its number for life, because code comments and other documents cite them
|
||||
— `HostGridTests` sends a reader to 7.6, `platform-flags.md` to 3.6–3.8. A check inserted later therefore
|
||||
— `platform-flags.md` sends a reader to 3.6–3.8, ADR 0013 to 16.4. A check inserted later therefore
|
||||
takes a letter rather than pushing its neighbours along: 3.2a and 3.2b sit between 3.2 and 3.3 and always
|
||||
will. Add in the same way, and keep each one next to the check it belongs beside; a gap in the numbers means
|
||||
a phase had nothing left for a person to do, which is the good outcome rather than an omission.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — the shell and the tab strip
|
||||
## Phase 1 — the shell and the nav rail
|
||||
|
||||
### 1.1 No screen is sliced at the WebView's left edge · **the important one**
|
||||
|
||||
Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Vaults,
|
||||
Preferences — and both of the fixed tabs, SFTP and S3.
|
||||
Open two terminals, then visit every nav rail entry in turn — Hosts, Keys, Snips, Logs — and both of
|
||||
the switcher's other two segments, SFTP and S3, at the rail's own head. The pins screen is no longer a rail
|
||||
entry; reach it from **Host keys** on the Keys screen's header and check it the same way.
|
||||
|
||||
**Pass:** each screen draws whole, its buttons all clickable, and the tab strip stays across the top of all
|
||||
nine. The nav rail is there for the seven and gone for the two, because it belongs to the Vaults tab.
|
||||
**Pass:** each screen draws whole, its buttons all clickable, and the nav rail stays up the left edge for
|
||||
every one of them. Since v5b's chrome pass the rail is permanent furniture — it no longer collapses for
|
||||
SFTP, S3 or an open terminal the way it did while it belonged to a Vaults tab; see
|
||||
`MainWindowViewModel.IsVaultsTab`'s own remark for what changed and what stayed the same under that name.
|
||||
|
||||
**Failure means:** a screen is not collapsing while the terminal shows. The terminal is a native child
|
||||
window and composites above everything Avalonia paints, so the symptom is a screen cut off at the WebView's
|
||||
@@ -41,8 +44,8 @@ bound to `IsShowingPages` in `MainWindow.axaml` is what should make it impossibl
|
||||
|
||||
### 1.2 A tab clicked from another screen takes the keyboard
|
||||
|
||||
Go to FILES with a terminal open. Click the tab. **Start typing immediately, without clicking anything
|
||||
else.**
|
||||
Go to SFTP with a terminal open, then click the SSH segment at the rail's head to return to it. **Start
|
||||
typing immediately, without clicking anything else.**
|
||||
|
||||
**Pass:** every character reaches the shell, including the first.
|
||||
|
||||
@@ -53,7 +56,7 @@ be typed immediately rather than after a pause.
|
||||
|
||||
### 1.3 Leaving a terminal gives the keyboard back
|
||||
|
||||
With a terminal focused, click FILES. Type into the filter box.
|
||||
With a terminal focused, click SFTP at the rail's head. Type into the filter box.
|
||||
|
||||
**Pass:** the characters appear in the box.
|
||||
|
||||
@@ -64,10 +67,10 @@ strip rework and is now on the hot path. See `docs/platform-flags.md`.
|
||||
|
||||
### 1.4 The middle click closes tabs and only tabs
|
||||
|
||||
Middle-click a tab (closes it), the strip background to the right of the last tab (closes nothing), and the
|
||||
`+` button (closes nothing, opens nothing).
|
||||
On the terminal's own in-screen tab row: middle-click a tab (closes it), the row's background to the right
|
||||
of the last tab (closes nothing), and the `+` button (closes nothing, opens nothing).
|
||||
|
||||
**Pass:** as described. Covered by `TerminalTabsTests` headlessly, so this is a confirmation that headless
|
||||
**Pass:** as described. Covered by `SessionTabRowTests` headlessly, so this is a confirmation that headless
|
||||
pointer input matches a real mouse rather than a first look.
|
||||
|
||||
### 1.5 Connecting from the palette while on another screen
|
||||
@@ -82,28 +85,28 @@ Approving connects; CANCEL leaves you on FILES.
|
||||
question that cannot be reached — or the window has jumped to HOSTS, which is what it used to do and what
|
||||
cost the palette its whole point.
|
||||
|
||||
### 1.6 The vault menu draws above the terminal's rectangle · **the one with a precedent**
|
||||
### 1.6 The user-chip popover draws above the terminal's rectangle · **the one with a precedent**
|
||||
|
||||
With a terminal open and showing, press the `⌄` beside the Vaults tab.
|
||||
With a terminal open and showing, press the user chip at the foot of the nav rail.
|
||||
|
||||
**Pass:** the window leaves the terminal for the Vaults tab as the menu opens, and the menu is drawn whole
|
||||
over the screen underneath it — no part of it clipped along the WebView's edge.
|
||||
**Pass:** the popover opens without leaving the terminal, and is drawn whole over the screen underneath it —
|
||||
no part of it clipped along the WebView's edge.
|
||||
|
||||
**Failure means:** the popup is being composited under the renderer's native child window, and the guard
|
||||
this design relies on has stopped working. It is not supposed to be possible: `OnVaultMenuPressed` selects
|
||||
the Vaults tab *before* opening the flyout, and a page surface is one where the renderer is not drawn — the
|
||||
same move QuickConnect makes. `OpeningTheVaultMenu_SelectsTheVaultsTabSoTheTerminalIsNotUnderIt` asserts
|
||||
the ordering headlessly, which is as far as a headless test can go: it has no native window, so it cannot
|
||||
see what is painted over what. This check is the other half.
|
||||
**Failure means:** the popup is being composited under the renderer's native child window. Unlike the v3–v5
|
||||
tab strip's own vault menu, which sat directly above the terminal's own rectangle and had to select the
|
||||
Vaults tab before opening for exactly that reason, the rail's popover should not need that guard at all: it
|
||||
opens inside the rail's own 255-pixel column — see `NavRail.axaml`'s own remark — which the terminal's
|
||||
native child window never occupies, so there should be no rectangle here for a popup to be composited
|
||||
under. If this fails, that assumption is the thing to re-examine, not the ordering of two calls. No headless
|
||||
test can see this either way — it has no native window, so it cannot see what is painted over what.
|
||||
|
||||
It is on this list rather than assumed because the note beside the `+` button in `TerminalTabs.axaml`
|
||||
refuses a flyout on exactly this reasoning, and `docs/platform-flags.md` records what this project has
|
||||
already paid for treating a rendering claim as settled without looking.
|
||||
It is on this list because `docs/platform-flags.md` records what this project has already paid for treating
|
||||
a rendering claim as settled without looking.
|
||||
|
||||
### 1.7 Switching a vault off does not switch it out
|
||||
|
||||
With a team vault holding at least one host: press `⌄` beside Vaults, switch the team vault off, and check
|
||||
the hosts screen, the keychain and the pins.
|
||||
With a team vault holding at least one host: press the user chip at the foot of the nav rail, switch the
|
||||
team vault off in the popover, and check the hosts screen, the keychain and the pins.
|
||||
|
||||
**Pass:** that vault's hosts, keys and pins are gone from all three; the vault is still in the "file this
|
||||
into" picker on a host editor; the sync indicator still settles rather than stalling; and a host in another
|
||||
@@ -113,6 +116,61 @@ vault that authenticates with a key filed in the switched-off one still connects
|
||||
`VaultViewModel.IsVaultShown` for the list, and `VaultVisibilityTests` for the same assertions made against
|
||||
view models — this check is the version with a real connection on the end of it.
|
||||
|
||||
### 1.8 Entering and leaving settings mode
|
||||
|
||||
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings**. Then repeat from a
|
||||
fresh open, choosing **Vaults**, and again choosing **Preferences**.
|
||||
|
||||
**Pass:** each of the three swaps the whole window's chrome — the titlebar becomes the 53px "Back to
|
||||
application" bar, the ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the
|
||||
update banner (if either was showing) are both gone. **Settings** lands on General; **Vaults** and
|
||||
**Preferences** land on their own pages, each lit in SettingsNav's own list — General/Vaults/Account under
|
||||
SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below both. Click every one of the
|
||||
seven rows in turn: each swaps the content column without leaving settings mode or touching the titlebar.
|
||||
|
||||
Now open a terminal, leave it showing, and open Settings from the user chip again. Press **Back to
|
||||
application** — or, without touching anything else, press Esc.
|
||||
|
||||
**Pass:** both return to exactly the screen that was showing before Settings was opened — the terminal,
|
||||
still running, rather than HOSTS or anywhere else. Switching between settings pages first (General → Vaults
|
||||
→ Preferences) does not change what "back" goes back to; only the screen open the moment Settings was
|
||||
**first** entered does.
|
||||
|
||||
**Failure means:** a return to HOSTS regardless of where Settings was opened from is
|
||||
`MainWindowViewModel.settingsReturnScreen` being recaptured on every `EnterSettings` call rather than only on
|
||||
the way in from outside settings mode — see that field's own remark. A status bar or update banner still
|
||||
visible in settings mode is `MainWindow.axaml`'s two bottom rows no longer reading `!IsSettingsMode`. Esc
|
||||
doing nothing is `MainWindow.axaml.cs`'s `OnKeyDown` no longer checking `IsSettingsMode` after the palette's
|
||||
own branch.
|
||||
|
||||
### 1.9 The importer's own home, and what still reaches through settings mode
|
||||
|
||||
From Settings → Preferences, press **OPEN IMPORTER**.
|
||||
|
||||
**Pass:** the titlebar's back button relabels itself "Back to preferences" — not "Back to application" — and
|
||||
SettingsNav stays lit on **Preferences** the whole time; the importer is drawn over the Preferences page
|
||||
rather than being a destination of its own. Press SCAN AGAIN and let it read your `~/.ssh/config`.
|
||||
|
||||
Press **Back to preferences** (or Esc). **Pass:** the Preferences page is back, still inside settings mode,
|
||||
and the titlebar's label reverts. Press Esc a second time (or **Back to application**): now settings mode
|
||||
itself closes, back to whatever screen was open before Settings was entered — the same two-deep
|
||||
"closest thing first" order the titlebar's own two buttons and `MainWindowViewModel.OpenImport`/`CloseImport`
|
||||
follow.
|
||||
|
||||
**Then, with the importer open, press Ctrl+K** and connect to a host from the palette.
|
||||
|
||||
**Pass:** it works exactly as it does anywhere else in the application — the palette opens over the
|
||||
importer, and connecting leaves settings mode outright for the new terminal, the same way choosing any
|
||||
ordinary nav rail destination does through `MainWindowViewModel.ShowScreen`. An unapproved host's key card
|
||||
comes up over settings mode the same way it would over any other screen.
|
||||
|
||||
**Failure means:** a back button that never changes label is `SettingsTitleBar.axaml`'s two buttons both
|
||||
bound to the same side of `IsImportOpen`. SettingsNav lighting a different row while the importer is up is
|
||||
`MainWindowViewModel.IsSettingsPreferencesPage` reading `IsImportOpen` when it must not —
|
||||
`Import.dc.html` draws the rail unmoved on purpose; see the remark on `IsImportOpen`. Ctrl+K doing nothing
|
||||
over the importer would be a guard added to `OnKeyDown` for `IsSettingsMode` that the design never asked for
|
||||
and this application's own quick-connect card was built to reach past.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Known Hosts as its own page
|
||||
@@ -120,7 +178,8 @@ view models — this check is the version with a real connection on the end of i
|
||||
### 2.1 The fingerprint column is readable end to end
|
||||
|
||||
Connect to two or three hosts, approving each fingerprint. Go to Pins and narrow the window to its minimum
|
||||
(1016px since the v2 sidebar; it was 880 while the rail was 54 wide), then widen it to something ordinary.
|
||||
(1081px since v5b's titlebar and rail fidelity pass; it was 1016 with the v2 sidebar, and 880 while the rail
|
||||
was 54 wide), then widen it to something ordinary.
|
||||
|
||||
**Pass:** the full `SHA256:…` is on screen at both sizes, never cut off and never ellipsised.
|
||||
|
||||
@@ -197,7 +256,7 @@ The parser has 22 cases over the shapes a real file contains, and the end-to-end
|
||||
|
||||
### 2.7 Scan your own `~/.ssh/config` and read the preview against the file
|
||||
|
||||
Preferences → IMPORT HOSTS → SCAN. Do not press import yet.
|
||||
Settings → Preferences → OPEN IMPORTER, then SCAN AGAIN. Do not press Import N hosts yet.
|
||||
|
||||
**Pass:** every entry you would expect is listed, with the address and port you expect, and the warnings
|
||||
above the table account for anything missing.
|
||||
@@ -361,61 +420,81 @@ card, nothing saying the hosts are unfiled.
|
||||
**Failure means:** the "invisible until used" property is gone, and every existing user gets a section they
|
||||
did not ask for. `HasGroups` is what hides the row of group cards; `HostRowViewModel.HasGroup` hides the chip.
|
||||
|
||||
### 3.2 Filing hosts, and the grid being one level at a time
|
||||
### 3.2 Filing hosts, and the board drawing every group at once
|
||||
|
||||
Make two groups and file some hosts into each through the host editor, leaving at least one host unfiled.
|
||||
Make two groups — the Group ▾ flyout's "New group…" — and file some hosts into each, once through a host's
|
||||
own editor and its GROUP picker, and once by ticking several hosts and choosing **Change group…** from the
|
||||
right-click menu. Leave at least one host unfiled.
|
||||
|
||||
**Pass:** the filed hosts **leave the grid** as they are filed — a group is a place, not a label, and the
|
||||
grid holds one level of it the way a directory pane holds one directory. What is left is the unfiled hosts.
|
||||
**There is no heading and no fold on the desktop** — the headings, their chevrons and UNGROUPED are the
|
||||
phone's, whose list has no room for a row of group cards and draws the whole tree flat instead. With every
|
||||
host filed, the grid says so in a sentence rather than sitting empty.
|
||||
**Pass:** every group is a heading on the board **at once** — in label order, "No group" first where it is
|
||||
occupied — with its own hosts underneath. There is no double-click, no card to open a group into and no
|
||||
breadcrumb trail: v3's one-level-at-a-time grid is gone outright, and every section is on screen from the
|
||||
moment the board is. A host filed under `production` is drawn once, under that heading, and nowhere else on
|
||||
the board.
|
||||
|
||||
**Then press a group card once.** It is marked as chosen and **nothing else happens** — the grid is still the
|
||||
level it was, and no buttons appear beside the GROUPS heading: editing and deleting a group are on the card's
|
||||
own right-click menu, which is 7.9. **Then double-press it.** The group opens: its hosts
|
||||
are the grid, the trail above the cards reads `ALL HOSTS › <name> ›`, each card carrying the group's name as
|
||||
an accent chip, and the card grid shows what is *inside* that group rather than every group in the keychain.
|
||||
Pressing ALL HOSTS goes back to the outermost level.
|
||||
**Then press the chevron beside one heading.** It folds — the heading stays, with its live count, and its
|
||||
cards go — and pressing it again brings the cards back. **Then press "Collapse all"**, drawn on the first
|
||||
heading only. It folds every section at once and its own label swaps to "Expand all"; opening any one heading
|
||||
by hand swaps the label back to "Collapse all".
|
||||
|
||||
**Then type a filed host's name into the find box at the top.** It is found from the outermost level,
|
||||
wherever it was filed and however deep, with the chip on its card saying which group it came out of. Clearing
|
||||
the box puts it away again. Inside a group the same box reaches that group and everything under it, and the
|
||||
empty-grid sentence there offers ALL HOSTS as the way to widen it.
|
||||
**Then type a filed host's name into the find box at the top, with its heading folded.** **Pass:** the
|
||||
heading's own count narrows to match — the count is read off the set the find box has already narrowed,
|
||||
before the fold is looked at — but the cards themselves stay hidden until that heading is reopened; open it
|
||||
and the narrowed set is there. Clear the box and every count returns.
|
||||
|
||||
**Failure means:** if one press still narrows the grid, the card `ListBox` is bound to the wrong property —
|
||||
`SelectedItem` is `SelectedGroup`, and only `OpenGroupCommand` writes `GroupFilter`. Filed hosts still on the
|
||||
outermost level is `Matches` no longer comparing the host's group to the open one. A search that cannot find
|
||||
a filed host is worse than either: it is the box answering "no host matches that" about a machine the
|
||||
keychain has got. A full-width bar with a chevron between the cards is the old grouping coming back through
|
||||
`SidebarRows`; the desktop grid binds `VisibleHosts`. See `HostsScreen.axaml`.
|
||||
**Failure means:** a host drawn under a heading that is not its own, or under two at once, is
|
||||
`VaultViewModel.AddFlatSection`'s membership test reading the wrong field for that section —
|
||||
`host.Host.GroupId == group.EntityId` for a named heading, everything else for "No group". A chevron that
|
||||
folds every section rather than the one it sits beside is `ToggleGroupCommand` being handed the board rather
|
||||
than the single `SidebarGroupHeader` it was pressed on. A count that does not move with the find box is
|
||||
`MatchesHostBoardFilters` not running before `HostSections` is rebuilt — see
|
||||
`VaultViewModel.RebuildHostSections` and `OnHostFilterChanged`.
|
||||
|
||||
### 3.2a A group inside a group, and the way back out
|
||||
### 3.2a A group nested under another, and what a host filed under it inherits
|
||||
|
||||
Make two groups and file one under the other with the parent picker in the group editor.
|
||||
Make an outer group with a default port and no default username, then make a second group whose editor's
|
||||
PARENT picker points at the outer one, and give this inner group no default port of its own. File a host
|
||||
under the inner group with nothing set on the host itself.
|
||||
|
||||
**Pass:** only the outer group has a card to start with. Double-press it and the inner one is the only card
|
||||
shown, with the trail reading `ALL HOSTS › <outer> ›`. Double-press that, and the cards disappear entirely —
|
||||
it has nothing inside it — while the trail stays. Pressing the **middle** crumb goes back one level rather
|
||||
than all the way out, which is also how a group with nothing inside it is renamed: back out to the level
|
||||
where it has a card, and right-click that.
|
||||
**Pass:** both groups are their own headings on the board, side by side — nesting is never drawn on the board
|
||||
itself, only carried in the group editor's own PARENT field. The inner heading's count is only the hosts
|
||||
filed directly under it, and the outer's is only the hosts filed directly under the outer one; the host filed
|
||||
under the inner group is not counted on the outer heading. Connect the host anyway: it dials the **outer**
|
||||
group's port. `HostInheritance.Chain` walks past the inner group's own silence on that field rather than
|
||||
stopping at the nearest group regardless of whether it answered. Give the inner group its own default port
|
||||
and reconnect: the host now dials that one instead, because the nearer group's answer wins once there is one.
|
||||
|
||||
**Failure means:** cards for groups that are not at this level is `VisibleGroups` having been bound past —
|
||||
the flat `Groups` is the phone's and the lookups'. A group that cannot be reached at all is worse and is the
|
||||
case `EffectiveParents` promotes: see 3.4a.
|
||||
**Then open the inner group's own editor and look at its PARENT picker.** The outer group is offered. **Then
|
||||
open the outer group's own editor and look at its PARENT picker.** The inner group is **not** offered — a
|
||||
group found by walking down from the outer one is refused as its own parent, which is what keeps this one
|
||||
machine, acting alone, from building a cycle. (Two machines can still build one offline between them; that is
|
||||
3.4a, below.)
|
||||
|
||||
### 3.2b Making something while standing inside a group
|
||||
**Failure means:** a host that dials 22 with the outer group's port set is the chain stopping at the first
|
||||
group above the host rather than reading each field independently from the nearest group that states it — see
|
||||
`HostInheritance.Resolve`'s own remarks. A parent picker offering a group's own descendant is
|
||||
`VaultViewModel.BuildGroupParentChoices` walking the wrong direction: each candidate has to be walked
|
||||
*upward*, through `HostInheritance.Chain`, to see whether it passes through the group being edited — a
|
||||
downward index of children is what the alternative would need, and this view model keeps none.
|
||||
|
||||
Open a group, then press **+ NEW HOST**, and afterwards **+ NEW GROUP**.
|
||||
### 3.2b Making a host or a group inherits nothing from where the board is scrolled
|
||||
|
||||
**Pass:** the host editor opens with that group already chosen in its group picker, and the saved host is on
|
||||
the screen it was made on rather than somewhere the trail is not. The group editor likewise opens with that
|
||||
group as its parent, so the new group is a card inside the one that is open.
|
||||
Scroll the board so one group's section fills the window, or fold every other section away, then press
|
||||
**+ New host**. Afterwards, open the Group ▾ flyout and press **New group…**.
|
||||
|
||||
**Failure means:** anything created inside a group disappearing the moment it is saved. That is the papercut
|
||||
a level-at-a-time grid comes with, and `NewHost` / `NewGroup` are where it is answered. Note the deliberate
|
||||
difference between them: the host editor also takes a merely *selected* card as its group, the group editor
|
||||
takes only the group that is open.
|
||||
**Pass:** the host editor opens with its GROUP picker on **"No group"**, and the group editor opens with its
|
||||
PARENT on **"No group"** too — neither reads anything from which heading happens to be on screen or scrolled
|
||||
to. This is deliberate: the level-at-a-time grid these two commands used to inherit an "open group" from is
|
||||
gone, and nothing on the flat board replaced that context. Pick a group by hand in either picker and it
|
||||
stays picked — the host or the new group lands there once saved.
|
||||
|
||||
**Failure means:** an editor that opens already filed under whichever section happened to be on screen is
|
||||
`VaultViewModel.GroupTarget` — the fallback both `NewHost` and `NewGroup` still read — having been wired to a
|
||||
live selection again. Read that property's own remarks before treating this as a regression: it is an alias
|
||||
for the single-select `GroupFilter`, which nothing in the current toolbar ever assigns any more — the Group ▾
|
||||
flyout ticks a set, `checkedGroupFilterIds`, a different field entirely — so today the fallback is dead code
|
||||
rather than a path either `+` button takes. Restoring it as live board context would make both commands
|
||||
context-sensitive in a way nothing on this board signals before the fact.
|
||||
|
||||
### 3.3 Deleting a group with hosts in it
|
||||
|
||||
@@ -852,52 +931,66 @@ Start 7.1's slow connection and press GIVE UP (or the tab's cross) while it is s
|
||||
opened — it is a real shell, and one running with nothing naming it would be worse than one that comes
|
||||
back.
|
||||
|
||||
### 7.6 Dragging a host onto a group card · **least covered, like all drag and drop**
|
||||
### 7.6 Filing a ticked host through "Change group…" · **the drag's replacement**
|
||||
|
||||
Make two groups and file a host into one. Drag a host card up onto the other group's card.
|
||||
The gesture this number used to describe — a host card dragged onto a group card — went with the group cards
|
||||
themselves in v5's flat-sections rework: every group is a heading now, there is nothing on the board to drop
|
||||
onto, and no gesture replaced the drag. Filing from the board is the chosen-hosts menu's **Change group…**,
|
||||
the phone's route become both heads' — see `VaultViewModel.ConfirmRegroupChosenHostsAsync`'s own remarks.
|
||||
(A single host can also still be filed through its own editor's GROUP picker; 3.2 walks that route.)
|
||||
|
||||
**Pass:** the group card under the pointer takes a two-pixel accent border while the pointer is over it, the
|
||||
cursor shows a move rather than a refusal, and the drop files the host — **the card leaves the grid**, going
|
||||
inside the group it was dropped on, the host counts under both group cards change, and the status line says
|
||||
where it went. That sentence is the only thing left saying so, which is why it is worth reading: the card
|
||||
itself is on the level below now, and nothing is selected once it has gone.
|
||||
Make two groups and file a host into one. Ctrl-click that host so it takes its ✓, right-click it, and choose
|
||||
**Change group…**.
|
||||
|
||||
**Also check three refusals**, each of which must show the "no" cursor and mark nothing: over the card of the
|
||||
group the host is *already* in — type its name into the find box first, which is what brings a filed card
|
||||
back to this level; over another **host** card, which is deliberately not a target now that there are no
|
||||
headings to say which group it would mean; and over the empty space around the cards.
|
||||
**Pass:** the entry is on the menu only while something is ticked — right-click an unticked card first and
|
||||
the menu is the ordinary four (Connect, Details…, Edit…, Delete…) with no filing entry among them. Choosing
|
||||
it opens the CHANGE GROUP panel **above the board, not over it** — the ticked card stays in view, "1 chosen"
|
||||
sits beside the panel's heading — and the ComboBox reads **"No group"**, not the group the host is already
|
||||
in; drop it down and every group in this keychain is listed after it.
|
||||
|
||||
**The targets are the cards on screen, which are one level** — see 3.2a. Filing into a group nested under
|
||||
another means opening the outer one first, exactly as moving a file into a subfolder does.
|
||||
**Then pick the other group and press FILE.** **Pass:** the panel folds, the card is drawn under the other
|
||||
heading and the headings' counts follow, the tick is off, and the status line says where it went — `Filed 1
|
||||
host(s) under …`.
|
||||
|
||||
**And getting a host back out** is the host's own editor — pick "No group" in its picker. There is no
|
||||
UNGROUPED target on the desktop any more, because there is no UNGROUPED heading for it to be.
|
||||
**Then take it back out:** tick it again, open **Change group…**, and press FILE with the ComboBox untouched.
|
||||
**Pass:** the host is unfiled, back under the "No group" heading. The picker opening on "No group" is a
|
||||
decision rather than an oversight — unfiling a run of machines is exactly as common as filing them, and the
|
||||
box says what FILE will do before it is pressed — but it is also why the box has to be *read*: FILE never
|
||||
means "keep things as they are". CANCEL, tried once, folds the panel and keeps the ticks.
|
||||
|
||||
**And a drag held near the top or bottom edge of the grid scrolls it**, which is what makes this usable at
|
||||
all with forty machines: the group cards are the first thing in the scroller, and a drag cannot use the
|
||||
wheel. The pointer has to keep moving inside the band — a stationary pointer gets no drag events.
|
||||
**Failure means:** the write and its guards are all covered headlessly — the filing by
|
||||
`ShellFlowTests.ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce`, the refusal under an open editor by
|
||||
`RegroupingTheChosenHosts_IsRefusedWhileTheEditorIsOpen`, the panels' one-at-a-time rule by
|
||||
`TheActionBarsPanels_TakeEachOthersPlaceRatherThanStacking`, and the panel's fit by
|
||||
`ScreenLayoutTests.TheHostsScreenFitsWithTheChosenHostsGroupPanelOpen`. What none of them can see is a real
|
||||
popup: the menu entry and the ComboBox's dropdown both live in popups, reaching the vault through the same
|
||||
`#Board` indirection 7.9 explains. A menu missing the entry, or a FILE that files nothing, is that wiring —
|
||||
7.9's class of failure, on the one entry that opens a panel rather than acting at once.
|
||||
|
||||
**Failure means:** headless Avalonia cannot synthesise a platform drag, so the picking up, the cursor and
|
||||
the drop are covered by nothing. What *is* automated is the decision each drag event takes —
|
||||
`HostGridTests.TheGroupCardsAreWhatAcceptsADroppedHost` raises a real `DragOver` over both kinds of card —
|
||||
and the write at the end, `ShellFlowTests.MovingAHostToAGroup_FilesItAndTakesItOffTheLevelItCameFrom`.
|
||||
### 7.6a Filing a whole set at once, and the one refusal · **the refusal needs a second vault**
|
||||
|
||||
### 7.6a Dragging a whole set onto a group card · **also uncovered, and the same reason**
|
||||
Tick three hosts (7.7a is how), right-click one of the three, choose **Change group…**, pick a group, and
|
||||
press FILE.
|
||||
|
||||
Tick three hosts (7.7a is how), then pick one of the three up and drag it onto a group card.
|
||||
**Pass:** the same panel, its heading now saying "3 chosen", and the write moves **all three** — every card
|
||||
under the picked heading, the status line counting them, and the ticks gone once it is done. A host that was
|
||||
never ticked stays exactly where it was.
|
||||
|
||||
**Pass:** the card marks itself exactly as it does for one host, and the drop files **all three** — the
|
||||
status line says how many, and all three leave the level. Ticking nothing and dragging a single card still
|
||||
files that one card, which is what this gesture has always done.
|
||||
**Then, with a second vault:** tick one host from each keychain and choose **Change group…** again.
|
||||
|
||||
**And a card that is not in the set** drags alone: press one of the unticked cards and the ticks come off
|
||||
before the drag starts, so what lands is the one machine that was under the pointer.
|
||||
**Pass:** no panel opens, and the status line says the hosts are in more than one keychain and a group
|
||||
belongs to one. The refusal is whole and it is early — raised when the picker is asked for, over the whole
|
||||
set, rather than after a group was picked from a list that could only ever have been one keychain's. Filing
|
||||
across that line would leave everyone else in the shared vault seeing a machine filed under nothing.
|
||||
|
||||
**Failure means:** a drag that filed one of three is the payload having been built from the card rather than
|
||||
from the set — the thing this gesture must never do quietly, since the other two stay behind looking filed.
|
||||
The write is `ShellFlowTests.DroppingTheChosenHostsOnAGroupCard_FilesEveryOneOfThem` and the drag event's
|
||||
answer is `HostGridTests.AGroupCardTakesAWholeSetOfDraggedHosts`; the platform's half of it is covered by
|
||||
nothing, as 7.6 explains.
|
||||
**Failure means:** the set's write is `ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce` again — one
|
||||
command reads the whole set, so "filed one of three" has no half-gesture to hide in the way the old drag's
|
||||
payload did. The refusal is covered headlessly too:
|
||||
`RegroupingHostsChosenAcrossTwoKeychains_IsRefusedBeforeThePickerOpens` ticks a host in each of two
|
||||
keychains and asserts no picker opens and the sentence is on the status line. What is left for the eye is
|
||||
7.9's wiring, as in 7.6 — and a panel that does open over a mixed set is the worse half: it would offer one
|
||||
keychain's groups for another keychain's machines, which is the half-filed set the refusal exists to
|
||||
prevent.
|
||||
|
||||
### 7.7 A click still selects, and a double click still connects
|
||||
|
||||
@@ -1009,7 +1102,7 @@ time)" — is the worse failure of the two: it rebinds a host as a side effect o
|
||||
|
||||
## Phase 8 — Adding and removing on the phone's host list
|
||||
|
||||
Thirteen checks, and the reason there are thirteen rather than none is worth stating: **the layout suite
|
||||
Twenty-one checks, and the reason there are twenty-one rather than none is worth stating: **the layout suite
|
||||
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
||||
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
||||
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
||||
@@ -1326,6 +1419,60 @@ destinations, so switching under a live one would show a screen titled S3 listin
|
||||
the row — that screen keeps its own copy of the host list, so it has to be re-found there by entity id
|
||||
rather than handed the vault's object.
|
||||
|
||||
### 8.18 QUICK ACCESS in the phone's host editor
|
||||
|
||||
Open a host's editor and scroll to QUICK ACCESS.
|
||||
|
||||
**Pass:** an empty list, an add field and an ADD button. Type `/var/www/app` and press ADD.
|
||||
|
||||
**Pass:** a row appears carrying that path and a ✕ at least 44dp on a side. Type the same path again and
|
||||
press ADD.
|
||||
|
||||
**Pass:** nothing is added, and a sentence appears on the page saying the path is already pinned — this page
|
||||
covers the whole screen while it is open, so that sentence is this page's own `Status` line rather than the
|
||||
one the host list draws above HOSTS, which is off-screen right now. Clear the box and press ADD with nothing
|
||||
typed.
|
||||
|
||||
**Pass:** a sentence saying a pinned path cannot be blank, in the same place.
|
||||
|
||||
Press the ✕ on the pinned row, then SAVE.
|
||||
|
||||
**Pass:** back on HOSTS with the pin gone. Open the editor on that host again.
|
||||
|
||||
**Pass:** QUICK ACCESS is empty — the removal was saved, not merely staged. Re-pin `/var/www/app`, SAVE, and
|
||||
check the same host on the desktop.
|
||||
|
||||
**Pass:** the pin is there. `HostSecret.PinnedPaths` is shared and merged like every other field on a host,
|
||||
so nothing about this page keeps its own copy.
|
||||
|
||||
**Failure means:** a refusal that changes nothing on screen is `AddEditorPinCommand` writing to `Status`
|
||||
with nothing on this page bound to it — the honesty rule broken silently, since the command still behaves
|
||||
correctly and only the telling of it is missing. A pin gone after SAVE-then-reopen but present on the
|
||||
desktop is `BuildHost` not reading `EditorPinnedPaths`, or `EditSelectedHost` not loading it back in.
|
||||
|
||||
### 8.19 Pin chips on the Files screen
|
||||
|
||||
Pin a folder on a host, then connect to it on the files screen (SFTP), either directly or via **Connect via
|
||||
SFTP**.
|
||||
|
||||
**Pass:** once connected, a row of chips appears between the breadcrumb and the listing, one per pin, each
|
||||
at least 44dp tall. Tap one.
|
||||
|
||||
**Pass:** the listing navigates straight to that directory, the same as tapping a breadcrumb crumb does.
|
||||
Disconnect, then connect to a host with nothing pinned.
|
||||
|
||||
**Pass:** no chip row at all — not an empty one. Connect to a bucket instead.
|
||||
|
||||
**Pass:** still no chip row, on any bucket. A bucket has no `HostSecret` underneath it and so nothing to
|
||||
pin.
|
||||
|
||||
**Failure means:** chips that do not move the listing are the row's `GoRemoteCommand` binding pointed at the
|
||||
wrong `DataContext` — see the `$parent[views:FilesScreen]` escape every other command in this file uses. A
|
||||
chip row surviving a disconnect, or appearing under a bucket, is `TransfersViewModel.ConnectedPinnedPaths`
|
||||
not being cleared in `CloseSessionAsync` or `OpenBucketAsync`. A chip row missing a pin added *after* this
|
||||
connect is not a bug — see `ConnectedPinnedPaths`'s own remark on why this is a snapshot rather than a live
|
||||
follow, and try disconnecting and reconnecting instead.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Tag chips and the picker
|
||||
@@ -1599,6 +1746,57 @@ is a terminal that answers the buttons and ignores the keyboard: it reads as the
|
||||
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
|
||||
tap of an arrow key.
|
||||
|
||||
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
|
||||
|
||||
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
|
||||
keep typing on the software keyboard.
|
||||
|
||||
**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
|
||||
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
|
||||
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
|
||||
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
|
||||
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
|
||||
|
||||
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
|
||||
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
|
||||
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
|
||||
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
|
||||
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
|
||||
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
|
||||
|
||||
### 11.11 Closing a connection and opening a new one both take you somewhere real
|
||||
|
||||
Open a shell, close its tab, then open a different one from HOSTS.
|
||||
|
||||
**Pass:** the new terminal renders and takes input straight away — no stuck "Connecting…" status, no blank
|
||||
pane that never receives the prompt.
|
||||
|
||||
**Failure means:** `TerminalDataPlane` refused the page's reattach. The renderer's `WebSocket` does not
|
||||
survive a tab going from one to zero and back to one on every device, and a host that answers a second valid
|
||||
upgrade with `409 Conflict` instead of taking the socket over leaves every terminal after the first
|
||||
permanently unreachable — see the correction in `docs/android-port.md`'s terminal section.
|
||||
|
||||
### 11.12 A backgrounded shell survives its renderer being killed · **needs several minutes, or developer tooling**
|
||||
|
||||
With a shell open and something worth reading in its scrollback, background the app (home button, not back)
|
||||
for several minutes — long enough for Android to consider reclaiming it — then return. If the device exposes
|
||||
it, forcing a stop of the WebView renderer process from Developer Options while backgrounded is the more
|
||||
reliable way to trigger the same thing on demand rather than waiting on the OS's own judgement. Either way,
|
||||
type something once you are back.
|
||||
|
||||
**Pass:** one of two honest outcomes, both good. Either the pane is exactly as it was — the renderer process
|
||||
survived, so nothing needed to happen — or the pane is empty but for a dim line reading `── the view
|
||||
reconnected; earlier output stayed on the host ──`, meaning the page reloaded and reattached. In both cases
|
||||
what is typed now reaches the shell, and the shell is still the same one — not a new tab, not a reconnect
|
||||
sheet, no "Connecting…" status stuck on screen.
|
||||
|
||||
**Failure means:** if the status stays stuck or nothing typed arrives, the renderer's socket did not retry
|
||||
itself — see `terminal.js`'s `connect()` and its backoff. If the pane came back empty with **no** banner, a
|
||||
session that survived a reload is being shown as though its scrollback had too, which is not true and is
|
||||
worse than saying nothing: the banner exists so this is never silently wrong. If typing does nothing but the
|
||||
banner is there, the session's credit window was not reset on reattach and the shell is frozen behind it —
|
||||
see `TerminalWorkspace.ReplayAfterAttachAsync`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Shared vaults: the operations that span two accounts
|
||||
@@ -1615,7 +1813,7 @@ newcomer **must not have signed in to this deployment before** — 12.1 is about
|
||||
|
||||
### 12.1 An address with no account is refused, and joins nothing when it later signs in · **the one worth the most care**
|
||||
|
||||
1. Sign in as `alice`, make a vault on the VAULTS screen, and select it.
|
||||
1. Sign in as `alice`, go to Settings → Vaults, make a vault there, and select it.
|
||||
2. Add `bob@example.com` as a Member, with Bob having never signed in here.
|
||||
3. **Pass:** it is refused. The status line names the address and says to ask them to sign in to this
|
||||
server once and then add them. **Nothing on the screen should suggest anything is pending** — no
|
||||
@@ -1713,7 +1911,7 @@ vault is now flagged for rekey, the transfer is removing the outgoing owner rath
|
||||
|
||||
Look for a way to remove a vault, on both heads.
|
||||
|
||||
**Pass:** there is none, and the VAULTS screen says why in a sentence: nothing in this product removes a
|
||||
**Pass:** there is none, and Settings → Vaults says why in a sentence: nothing in this product removes a
|
||||
vault, and the server refuses to archive the membership list behind one while it exists. Archiving that
|
||||
list is still reachable over the API, and the endpoint suite drives both its refusal and its success — what
|
||||
is being checked here is that no button offers it.
|
||||
@@ -1724,12 +1922,12 @@ button that always refuses is the milder failure and is still worth removing.
|
||||
|
||||
### 12.8 Renaming a vault reaches every place its name is drawn · **needs two accounts**
|
||||
|
||||
Rename a shared vault from the VAULTS screen.
|
||||
Rename a shared vault from Settings → Vaults.
|
||||
|
||||
**Pass:** the new name is on the vault list, on the badge of every host card in that vault, in the keychain
|
||||
screen's "new items go to" picker, in the host editor's vault picker, and in the tab strip's vault menu —
|
||||
and on the second account after a refresh. Nothing in the vault needs re-encrypting and everybody's key
|
||||
still opens it.
|
||||
screen's "new items file to" picker, in the host editor's vault picker, and in the nav rail's user-chip
|
||||
popover — and on the second account after a refresh. Nothing in the vault needs re-encrypting and
|
||||
everybody's key still opens it.
|
||||
|
||||
**Failure means:** a name that moved in one place and not another is the shape this rename is most likely to
|
||||
fail in, because several screens read it separately from a cached vault row. A vault that stops opening
|
||||
@@ -1929,9 +2127,63 @@ Queue several files in each direction, put the phone to sleep with the screen of
|
||||
notification goes away when the last one does — with no shell open. With a shell open it stays, because that
|
||||
is what it was already for.
|
||||
|
||||
Now, separately: open a shell to the host, press the home button (backgrounding rather than sleeping — the
|
||||
distinction matters, because backgrounded is the state in which Android is free to kill a process no
|
||||
foreground service is protecting), wait thirty seconds with the shell doing nothing, and return.
|
||||
|
||||
**Pass:** the notification stayed up the whole time, and the shell is exactly where it was — same scrollback,
|
||||
same prompt — with typing reaching the host immediately. Exit the shell.
|
||||
|
||||
**Pass:** the notification goes with it, once nothing else is open. Open another shell and close it from the
|
||||
shells strip's ✕ instead of exiting — the notification comes down for that route too, which is the route
|
||||
that used to leave it up: a deliberate close announced nothing to the keep-alive at all, and a shell exiting
|
||||
on its own was announced while the count still included it.
|
||||
|
||||
**Failure means:** an upload that stalls with the screen off is the count not reaching
|
||||
`SessionForegroundService`, and Android has stopped the process mid-transfer. A notification left up
|
||||
afterwards is `ActivityChanged` not being subscribed — the other end of the same wire.
|
||||
afterwards is `ActivityChanged` not being subscribed — the other end of the same wire. A shell that has
|
||||
disconnected on return is `MainWindowViewModel.TerminalSessionOpened` never reaching `SessionKeepAlive` — the
|
||||
service only ever heard about a shell *ending*, so it never came up for one in the first place. A
|
||||
notification still saying "1 shell connected" after the shell is gone — by either route — is
|
||||
`TerminalWorkspace.SessionEnded` firing before the run completed, or a close not announcing; see
|
||||
`AnnounceEndedAsync` and the event's own remark.
|
||||
|
||||
### 14.6a A Files connection with nothing moving still survives backgrounding
|
||||
|
||||
Connect to a host on the Files screen with no transfer queued — just browse to somewhere and stop. Note the
|
||||
directory shown, then background the app, wait thirty seconds, and return.
|
||||
|
||||
**Pass:** the notification stayed up the whole time (check the shade if the return is too quick to see it
|
||||
directly), and the pane is exactly where it was — the same listing, the same breadcrumb — with no reconnect
|
||||
needed.
|
||||
|
||||
**Failure means:** `TransfersViewModel.HasLiveFileSession` not reaching `SessionKeepAlive`, so an idle but
|
||||
still-open SFTP connection read as nothing running at all and the process was free to die under it.
|
||||
|
||||
### 14.6b The notification permission is asked for once, at the first thing worth showing · **needs Android 13+**
|
||||
|
||||
On a device running Android 13 or later, on a fresh install that has never connected to anything, open a
|
||||
shell or the Files screen for the first time.
|
||||
|
||||
**Pass:** a system dialogue asking to allow notifications appears at that moment — not at launch, and not
|
||||
before this first connect. Answer it either way; the connection completes regardless, and background the app
|
||||
afterwards to confirm nothing else changed about it.
|
||||
|
||||
**Failure means:** the dialogue appearing at launch is asking before there is anything on screen to justify
|
||||
it. Never appearing at all on API 33+ is the harder failure to notice, because nothing else surfaces it —
|
||||
the service still starts and still holds the process open, only the receipt is invisible. See
|
||||
`SessionForegroundService.RequestNotificationPermission`.
|
||||
|
||||
### 14.6c Refusing the permission costs the notification and nothing else
|
||||
|
||||
Continuing from 14.6b: choose **Don't allow** on the system dialogue. Queue a transfer, or open a shell, and
|
||||
background the app.
|
||||
|
||||
**Pass:** no notification appears anywhere, but the transfer still finishes, or the shell is still there on
|
||||
return, exactly as in 14.1–14.6a.
|
||||
|
||||
**Failure means:** anything disconnecting or failing here is the permission refusal being read as though it
|
||||
had refused the service itself, rather than only the notification Android draws for it.
|
||||
|
||||
### 14.7 SAVE FILE writes where you pointed it, and the file opens
|
||||
|
||||
@@ -2146,10 +2398,11 @@ fifteen seconds comes from.
|
||||
|
||||
### 16.5 The version on screen is the version that was built
|
||||
|
||||
Right-click `DodoSSH.exe` → Properties → Details, and open PREFERENCES → UPDATES.
|
||||
Right-click `DodoSSH.exe` → Properties → Details, and open Settings → General.
|
||||
|
||||
**Pass:** File version reads the tag (`0.1.0.0`), product **DodoSSH**, company **DodoTech**, and the
|
||||
preferences screen prints the same number.
|
||||
General page's UPDATES card prints the same number — the block moved there from Preferences in v5c, and
|
||||
Preferences itself carries no version line any more.
|
||||
|
||||
**Failure means:** `0.0.0.0` is MinVer never seeing a tag — a shallow clone, or `fetch-depth` having been
|
||||
dropped from a checkout. `1.0.0.0` is somebody having wired the app manifest's inert `assemblyIdentity`
|
||||
@@ -2171,7 +2424,7 @@ script warns rather than failing when that is legitimate, which is the first rel
|
||||
### 16.7 The update arrives, and the restart lands in it · **the whole point of the work**
|
||||
|
||||
With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on PREFERENCES rather than waiting six hours.
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours.
|
||||
|
||||
**Pass:** the progress bar moves, the banner appears above the status bar, and — the part to actually watch
|
||||
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
|
||||
@@ -2225,7 +2478,7 @@ there is one install directory, the pack ids collide and the nightly has replace
|
||||
outright — which is the thing ADR 0013 decision 9 is constructed to make impossible, so it means one of the
|
||||
four separations has been undone.
|
||||
|
||||
**And the direction that matters most:** on the release build, PREFERENCES → UPDATES → CHECK NOW must not
|
||||
**And the direction that matters most:** on the release build, Settings → General → CHECK NOW must not
|
||||
offer a nightly, ever, however many have been published since. It reads a different index and refuses
|
||||
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
|
||||
rather than as a bug in the nightly.
|
||||
@@ -2344,3 +2597,120 @@ package manager will not offer to.
|
||||
|
||||
**Failure means:** the channels are not separate, and a public key is signing the application people keep
|
||||
their credentials in.
|
||||
|
||||
## Phase 18 — Installing the macOS client, and being updated by it
|
||||
|
||||
The macOS counterpart of phase 16, and it needs a Mac with a Secure Enclave — an Apple Silicon machine or
|
||||
an Intel one with a T2. Every check here is structurally unreachable by a test for the reasons phase 16
|
||||
gives, plus one this platform adds: **CI has no macOS runner at all**, so this phase is the only place the
|
||||
suite and the application ever run on macOS. Anything `docs/platform-flags.md` marks as unverified on macOS
|
||||
is verified here or nowhere.
|
||||
|
||||
Run `bash scripts/release-macos.sh` first. It stops after packing and notarizing, on purpose, so that
|
||||
everything below happens before anything reaches a user. Phase 16.0 — the feed being readable without
|
||||
credentials — applies unchanged and is not repeated.
|
||||
|
||||
### 18.1 Gatekeeper accepts it on a machine that did not build it · **do this one first**
|
||||
|
||||
The Mac that signed a package trusts it locally whatever happened, so the build machine cannot answer this
|
||||
question about itself. Copy the `.pkg` to a second Mac — or at minimum download it through a browser, which
|
||||
is what applies the quarantine attribute — and open it.
|
||||
|
||||
**Pass:** it installs with no warning beyond the ordinary installer prompts.
|
||||
|
||||
**Failure means:** "cannot be opened because Apple cannot check it for malicious software" is notarization
|
||||
that did not happen or a ticket that did not staple. The script's `spctl --assess` and `xcrun stapler
|
||||
validate` should have caught it before this point, so reaching here means one of those two checks was
|
||||
removed or skipped. Do not distribute the package.
|
||||
|
||||
### 18.2 The Dock shows the product and not the pack id
|
||||
|
||||
Look at the installed application in `/Applications`, in the Dock, and in the menu bar while it runs.
|
||||
|
||||
**Pass:** the menu bar says **DodoSSH**. Finder shows **DodoSSH**. The bundle on disk is
|
||||
`DodoSSH.Desktop.app` and that is expected — see the pack id note in `scripts/release-macos.sh`.
|
||||
|
||||
**Failure means:** "DodoSSH.Desktop" in the menu bar is `CFBundleName` not reaching the bundle, which means
|
||||
the rendered `Info.plist` did not get used. Since vpk copies a custom plist verbatim and substitutes
|
||||
nothing, check the same bundle's `CFBundleShortVersionString` — if it reads `@VERSION@`, the template was
|
||||
passed through unrendered.
|
||||
|
||||
### 18.3 The icon is the mark, at every size
|
||||
|
||||
Look at it in the Dock, in Finder's icon view at a large size, and in `⌘I` Get Info.
|
||||
|
||||
**Pass:** the accent tile and the `>_` mark, crisp at 1024, with the same air around it that Finder and
|
||||
Safari have.
|
||||
|
||||
**Failure means:** a generic application icon is `CFBundleIconFile` naming a file that is not in
|
||||
`Contents/Resources`. An icon that fills its square edge to edge, larger than its neighbours, is
|
||||
`New-MarkPng` having been called with the Windows tile fraction — see `dodossh-icon.ps1`.
|
||||
|
||||
### 18.4 Touch ID guards the device key, and the enclave enforces it
|
||||
|
||||
Register a device key from the security settings page, then lock the vault and unlock it again.
|
||||
|
||||
**Pass:** registering shows **no** prompt at all — sealing uses only the public half — and unlocking raises
|
||||
the system Touch ID sheet saying DodoSSH is trying to *unlock your DodoSSH vault*. The vault opens on a
|
||||
successful touch.
|
||||
|
||||
**Failure means:** a prompt at registration is not a failure of correctness but says the key was not created
|
||||
in the enclave; check that `kSecAttrTokenID` reached the attributes. **No prompt at unlock, with the vault
|
||||
opening anyway, is the serious one** — it means the key is a software key and the access control did nothing,
|
||||
which is precisely the "a gate inside the process is not a gate" mistake `WindowsDeviceKeyStore` documents.
|
||||
|
||||
### 18.5 Declining the fingerprint falls back to the passphrase
|
||||
|
||||
Repeat 18.4 and cancel the Touch ID sheet.
|
||||
|
||||
**Pass:** the unlock screen asks for the passphrase, and it works.
|
||||
|
||||
**Failure means:** an error dialog, or a stuck screen, is `TryLoadAsync` throwing rather than answering
|
||||
null. Every failure it can meet — cancelled, timed out, key invalidated by a password reset — is meant to
|
||||
be indistinguishable and to land on the passphrase.
|
||||
|
||||
### 18.6 A development build offers no device key at all
|
||||
|
||||
Run the application with `dotnet run` rather than from the installed bundle, and open the security settings
|
||||
page.
|
||||
|
||||
**Pass:** registering a device key is not offered.
|
||||
|
||||
**Failure means:** being offered it is `IsSupported` having inferred availability from the OS rather than
|
||||
probing. An unsigned build cannot create an enclave key, so accepting the offer would put a wrap on the
|
||||
server that nothing can ever open and list a capability this machine does not have.
|
||||
|
||||
### 18.7 The terminal works, which is the WKWebView question
|
||||
|
||||
Connect to a host and use the shell: type, run something that scrolls, resize the window.
|
||||
|
||||
**Pass:** the terminal attaches within a second or two and behaves as it does on Windows.
|
||||
|
||||
**Failure means:** a blank pane that reports a renderer timeout after fifteen seconds is the loopback
|
||||
WebSocket not reaching WKWebView. This is the check that most needs walking, because the data plane has
|
||||
never run against this backend — see `TerminalDataPlane`. If it fails, the App Sandbox is the first thing to
|
||||
rule out: the entitlements deliberately do not enable it, and a sandboxed process cannot listen on loopback
|
||||
without `com.apple.security.network.server`.
|
||||
|
||||
### 18.8 An update is offered, downloaded and applied
|
||||
|
||||
With the release installed, cut a second release with a higher version and publish it, then leave the first
|
||||
running.
|
||||
|
||||
**Pass:** the banner appears, downloads, and on applying the application closes and reopens on the new
|
||||
version. The vault's contents and the known hosts survive.
|
||||
|
||||
**Failure means:** an update that never arrives is usually the channel — `osx` here and `osx` in
|
||||
`VelopackUpdateChannel.MacReleaseChannel`, with no error anywhere when they disagree. An update that
|
||||
downloads and fails to apply, leaving the application unable to restart, is library validation: check that
|
||||
`com.apple.security.cs.disable-library-validation` survived into the entitlements.
|
||||
|
||||
### 18.9 Uninstalling does not take the vault with it
|
||||
|
||||
Register a device, sync something, then remove the application.
|
||||
|
||||
**Pass:** `~/Library/Application Support/DodoSSH` still holds the cache and the outbox afterwards.
|
||||
|
||||
**Failure means:** an empty directory is the pack id having been changed to `DodoSSH`, which puts Velopack's
|
||||
install root on top of `ClientPaths.DataDirectory` and makes an uninstall delete a user's un-synced work.
|
||||
This is the single reason the bundle is named `DodoSSH.Desktop.app`.
|
||||
|
||||
+98
-3
@@ -3,8 +3,18 @@
|
||||
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
|
||||
have already cost time once. Development is Windows-first, but **the full test suite now runs on
|
||||
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
|
||||
suspicion. **macOS is still untested**, and anything marked *unverified* has not run on the platform
|
||||
in question and must not be assumed to work.
|
||||
suspicion. Anything marked *unverified* has not run on the platform in question and must not be
|
||||
assumed to work.
|
||||
|
||||
**macOS now builds and packages, and has still never run.** The distinction matters more here than
|
||||
anywhere else on this page, because the two halves are verified in completely different places. The
|
||||
build is measured on every main and tag build: CI publishes `osx-arm64` and runs `vpk [osx] bundle`
|
||||
on a Linux runner, which is enough to catch a restore graph with no macOS native asset and an `.app`
|
||||
that will not compose. Everything past that — whether the window draws, whether the terminal's
|
||||
loopback WebSocket reaches WKWebView, whether the Secure Enclave holds a device key — is verified
|
||||
only by a person walking Phase 18 of [manual-checks.md](manual-checks.md) on a Mac, because **there
|
||||
is no macOS runner in CI**. Treat every macOS runtime claim below as unverified unless it says
|
||||
otherwise.
|
||||
|
||||
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
|
||||
has been verified or made moot — not when it merely stops being convenient.
|
||||
@@ -17,6 +27,19 @@ docs/crypto.md §1. *Already mitigated* — but if a BCL AEAD path is ever added
|
||||
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
|
||||
any vault on macOS.
|
||||
|
||||
**The Secure Enclave holds P-256 keys and nothing else**, which is why `MacDeviceKeyStore` wraps the
|
||||
device key with ECIES rather than with the RSA-OAEP the Windows store uses. It will not hold an RSA
|
||||
key at any size, so this is not a preference. The useful consequence is that the macOS shape is
|
||||
*better* than the Windows one: `SecKeyCopyPublicKey` works on an enclave key without prompting, so
|
||||
registering a device is silent and only unlock asks — where Windows raises a dialog at key creation
|
||||
too. *Unverified:* no enclave call in this repository has ever run.
|
||||
|
||||
**Three ordinary Macs have no usable enclave**, and `IsSupported` probes rather than infers for that
|
||||
reason: an Intel machine without a T2, a machine with no login password set, and — the one that
|
||||
surprises people — **any build that is not code signed**, because enclave key creation needs a
|
||||
signing identity. So `dotnet run` correctly offers no device key at all. Do not "fix" this by
|
||||
checking the OS instead; the offer would then put a wrap on the server that nothing can ever open.
|
||||
|
||||
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
|
||||
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
|
||||
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
|
||||
@@ -26,7 +49,11 @@ and the parameters are stored per user at enrollment, so a bad default is a per-
|
||||
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
|
||||
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
||||
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
|
||||
or notarization fails with an error that does not name the offending file.
|
||||
or notarization fails with an error that does not name the offending file. *Mitigated* in
|
||||
`scripts/release-macos.sh`, which signs every `.dylib` and `createdump` in a loop before vpk touches
|
||||
anything — vpk's own pass uses `codesign --deep`, which is the shape Apple documents as wrong for
|
||||
nested code and is the likeliest source of that unnamed rejection. The loop looks redundant next to
|
||||
`--deep` and is not; do not delete it because a release once succeeded without it.
|
||||
|
||||
## Desktop client
|
||||
|
||||
@@ -361,11 +388,55 @@ agent of our own plus ProxyJump covers the real use cases.
|
||||
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
|
||||
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
|
||||
|
||||
**That suite has an intermittent `The connection was closed by the remote host`**, on whichever test
|
||||
connects first, within tens of milliseconds. Seen in CI and reproducible locally. *Mitigated, not
|
||||
solved:* `SshServerFixture` now raises sshd's `MaxStartups` from its compiled-in `10:30:100`, which
|
||||
refuses connections at random past ten unauthenticated ones in flight — reachable because xUnit runs
|
||||
test classes in parallel and most of them connect. The fixture comment carries the full argument and
|
||||
is explicit that the cure is unproven.
|
||||
|
||||
**And the reason it is unproven is a measurement trap worth not falling into twice.** Docker
|
||||
throughput on the Windows development machine swings enough to swamp the effect: the identical
|
||||
unmodified suite ran 85/85 clean and, an hour later, failed 13 runs out of 15. Any before/after flake
|
||||
comparison taken there is noise. Measure this class of thing in CI, or make the server say why —
|
||||
raise sshd's `LogLevel`, disable Ryuk so the container outlives the run, and read `docker logs`.
|
||||
|
||||
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
|
||||
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
|
||||
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
||||
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
||||
|
||||
**The App Sandbox is ruled out on macOS for the same reason, and the entitlements say so.** A
|
||||
sandboxed process cannot listen on loopback without `com.apple.security.network.server`, and the
|
||||
terminal is that listener. Developer ID distribution outside the App Store does not require the
|
||||
sandbox, so this costs nothing today — but it does mean the Mac App Store is closed to this
|
||||
application without solving the data plane differently first. See
|
||||
`build/macos/DodoSSH.entitlements`.
|
||||
|
||||
**The hardened runtime is not optional and .NET needs four holes punched in it.** Notarization
|
||||
refuses a Developer ID submission without it, and CoreCLR will not start under it without
|
||||
`allow-jit` and `allow-unsigned-executable-memory` — both, not either, because the runtime allocates
|
||||
executable memory outside the `MAP_JIT` path as well. `disable-library-validation` and
|
||||
`allow-dyld-environment-variables` are needed for Velopack's updater rather than for the runtime.
|
||||
Each is argued individually in the entitlements file; the failure mode for a missing one is a
|
||||
process that dies during runtime initialisation, before anything exists that could report it.
|
||||
|
||||
**`vpk` cross-compiles to macOS only as far as the bundle.** `vpk [osx] bundle` runs anywhere and
|
||||
produces a real `.app`; there is no `[osx] pack` off a Mac, because pack drives `codesign`,
|
||||
`notarytool` and `stapler`. So CI can prove the bundle builds and only a Mac can produce something
|
||||
installable. Note this is the *opposite* of the Windows story, where `vpk [win] pack` builds the
|
||||
whole installer on Linux — the asymmetry is Apple tooling, not a Velopack limitation.
|
||||
|
||||
**A custom `Info.plist` is copied verbatim by vpk, with no substitution whatsoever.** That is why
|
||||
`--plist` and `--bundleId` are mutually exclusive, and why `build/macos/Info.plist.template` is a
|
||||
template the release script renders rather than a committed file. A committed plist would carry one
|
||||
version into every release afterwards, and the symptom is silent: Velopack's index would still be
|
||||
right, the updater would still work, and only Get Info and any crash report would disagree.
|
||||
|
||||
**macOS app icons live on an 824-in-1024 grid.** An icon that bleeds to the edge of its canvas is
|
||||
not bolder, it is the one icon in the Dock that is too big. `dodossh-icon.ps1` draws the `.icns` at
|
||||
that fraction and the `.ico` at full bleed, from one geometry.
|
||||
|
||||
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
|
||||
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
||||
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
||||
@@ -641,6 +712,30 @@ The lasting hazard is the first paragraph and not the fix. Any change to a share
|
||||
to a lock file this repository cannot verify from a machine without the Android workload, and it will go
|
||||
on being noticed later than every other one.
|
||||
|
||||
**A lock file can go stale with nothing in this repository changing, because `Microsoft.NET.ILLink.Tasks`
|
||||
is versioned by the SDK and `global.json` lets the SDK float.** The reference is implicit — nothing in any
|
||||
`.csproj` asks for it — and its version tracks the runtime patch band, while `global.json` pins only
|
||||
`10.0.100` with `rollForward: latestMinor`. So `setup-dotnet` installs whatever the newest 10.x SDK is on
|
||||
the day, and the moment that SDK's band moves, locked-mode restore stops:
|
||||
|
||||
```
|
||||
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||
from [10.0.10, ) to [10.0.11, ).
|
||||
```
|
||||
|
||||
It named `DodoSSH.Client.Android`, `DodoSSH.Contracts` and `DodoSSH.Crypto` — the three lock files that
|
||||
carry the entry — on a commit that touched none of them and no dependency at all.
|
||||
|
||||
The fix is `--force-evaluate` on those three, **from a machine whose SDK is at least as new as the
|
||||
runner's**, which is the part that is easy to get wrong: a `--force-evaluate` from an older SDK rewrites
|
||||
the lock at the older version, changes nothing, and looks like it worked. Check `dotnet --version` against
|
||||
the version in the error before believing a regeneration.
|
||||
|
||||
This will recur on every SDK patch that moves the band. It is the accepted cost of letting the SDK float:
|
||||
the alternative is pinning an exact SDK in `global.json`, which trades a recurring lock-file bump for a
|
||||
recurring toolchain bump and makes every contributor install one specific SDK. Neither is free, and this
|
||||
repository has chosen the floating side deliberately.
|
||||
|
||||
**.NET for Android cannot be built on a musl host, and this project's runner is Alpine. Every message the
|
||||
toolchain produces on the way to saying so names a missing file that is present.** Three CI rounds went
|
||||
into this and the first two fixed symptoms, so the messages are worth reading in the order they arrive.
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Builds, packages and publishes the macOS desktop client.
|
||||
#
|
||||
# The counterpart of scripts/release-windows.ps1, and deliberately the same shape: run by a person, on a
|
||||
# Mac that is not a CI runner, in two phases with the upload withheld until somebody has installed what
|
||||
# phase one built and walked the manual checks. docs/adr/0011-android-distribution.md rule 1 puts the
|
||||
# capability to ship somebody a build on a machine which is not a runner, and
|
||||
# docs/adr/0013-desktop-distribution-and-updates.md explains why the token that writes a Gitea release is
|
||||
# that capability: Velopack clients trust their feed and do not verify a package signature when they apply
|
||||
# it, so whoever can write a release can ship an update every install runs.
|
||||
#
|
||||
# 1. Without --upload: builds, signs, notarizes, packs, and stops. Nothing has left this machine
|
||||
# except the notarization submission, which Apple sees and users do not.
|
||||
# 2. With --upload: asks for the forge token and publishes what phase one produced. It does not
|
||||
# rebuild, so the bytes that reach users are the bytes that were installed and checked.
|
||||
#
|
||||
# ◆ WHAT IS DIFFERENT FROM THE WINDOWS SCRIPT, AND WHY.
|
||||
#
|
||||
# Signing is not optional here. On Windows an unsigned installer costs a SmartScreen dialog once per
|
||||
# user, which is why that script has no --signParams and says so. On macOS an un-notarized download is
|
||||
# refused outright by Gatekeeper — not warned about, refused — so the Developer ID certificate and the
|
||||
# notarization round trip are the price of the package being installable at all, not an improvement to
|
||||
# be bought later.
|
||||
#
|
||||
# ◆ CREDENTIALS COME FROM THE KEYCHAIN AND THE ENVIRONMENT, NOT FROM THIS FILE.
|
||||
#
|
||||
# Three values are read from the environment, and none of them is itself a secret — they name things the
|
||||
# keychain holds, and the keychain is what guards the private key and the App Store Connect credentials:
|
||||
#
|
||||
# DODOSSH_SIGN_APP_IDENTITY e.g. "Developer ID Application: DodoTech (TEAMID)"
|
||||
# DODOSSH_SIGN_INSTALL_IDENTITY e.g. "Developer ID Installer: DodoTech (TEAMID)"
|
||||
# DODOSSH_NOTARY_PROFILE the profile name given to `xcrun notarytool store-credentials`
|
||||
#
|
||||
# `security find-identity -v -p codesigning` lists the first two exactly as codesign wants them. The
|
||||
# third is created once per machine:
|
||||
#
|
||||
# xcrun notarytool store-credentials DodoSSH \
|
||||
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
|
||||
#
|
||||
# The forge token is the one real secret, and it is prompted for rather than read from a file or the
|
||||
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
|
||||
# minutes a credential that can publish an update spends in a shell's memory the better.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/release-macos.sh
|
||||
# bash scripts/release-macos.sh --upload
|
||||
# bash scripts/release-macos.sh --skip-tests
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
UPLOAD=0
|
||||
SKIP_TESTS=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--upload) UPLOAD=1 ;;
|
||||
--skip-tests) SKIP_TESTS=1 ;;
|
||||
*)
|
||||
echo "Unknown argument: $arg" >&2
|
||||
echo "Usage: bash scripts/release-macos.sh [--upload] [--skip-tests]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- The contract with every installed client ---------------------------------------------------------
|
||||
|
||||
# Velopack's identity for this application, and it is effectively irreversible for the reasons the Windows
|
||||
# script states — it is what an installed client matches an update against.
|
||||
#
|
||||
# ◆ THE SAME PACK ID AS WINDOWS, AND ON THIS PLATFORM IT IS VISIBLE.
|
||||
#
|
||||
# vpk names the bundle after the pack id, so this produces DodoSSH.Desktop.app rather than DodoSSH.app,
|
||||
# and that is what somebody sees in /Applications. It is kept anyway, because the alternative is worse:
|
||||
# a pack id of DodoSSH would put Velopack's install and its uninstall on ~/Library/Application Support/
|
||||
# DodoSSH, which is exactly where ClientPaths keeps the encrypted cache, the outbox of changes not yet
|
||||
# pushed and the device key. Sharing that directory would mean an uninstall silently taking a user's
|
||||
# un-synced work with it. The same reasoning, and the same conclusion, as the Windows script.
|
||||
#
|
||||
# What a person actually reads is CFBundleDisplayName, which build/macos/Info.plist.template sets to
|
||||
# DodoSSH. So the bundle keeps the id and the Dock shows the product.
|
||||
PACK_ID='DodoSSH.Desktop'
|
||||
PACK_TITLE='DodoSSH'
|
||||
PACK_AUTHORS='DodoTech'
|
||||
|
||||
# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
|
||||
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
|
||||
# The owner is part of it: Gitea left a 301 at the old organisation's path, which a GET follows and an
|
||||
# upload does not.
|
||||
REPO_URL='https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
|
||||
|
||||
# A contract with VelopackUpdateChannel.MacReleaseChannel. Velopack's macOS default is also "osx", so
|
||||
# leaving it unsaid on both sides would work — but unsaid here and stated there is how a feed goes quiet
|
||||
# with no error at all: the client checks, finds nothing, and reports itself up to date forever.
|
||||
CHANNEL='osx'
|
||||
|
||||
# ◆ ARM64 ONLY, AND THAT IS A DECISION RATHER THAN AN OVERSIGHT.
|
||||
#
|
||||
# Velopack keys a channel to one architecture, so shipping Intel too means a second channel, a second
|
||||
# publish, a second set of deltas and a second thing to keep in step with the client's channel picker.
|
||||
# That is all affordable. What is not currently affordable is testing it: nobody here has an Intel Mac,
|
||||
# and docs/manual-checks.md exists because this project does not ship desktop builds no one has run.
|
||||
# An x64 package built blind and published beside a checked arm64 one would be the only artefact in this
|
||||
# repository that reached users unverified.
|
||||
#
|
||||
# Adding it later is this constant, a second channel name in VelopackUpdateChannel, and a picker keyed on
|
||||
# RuntimeInformation.ProcessArchitecture — which reports X64 for a build running under Rosetta, so an
|
||||
# Intel build correctly stays on the Intel feed. The work is small; the check is the part that is missing.
|
||||
RUNTIME='osx-arm64'
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PROJECT="$REPO_ROOT/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj"
|
||||
SOLUTION="$REPO_ROOT/DodoSSH.slnx"
|
||||
PUBLISH_DIR="$REPO_ROOT/publish/$RUNTIME"
|
||||
RELEASES_DIR="$REPO_ROOT/Releases"
|
||||
ICON="$REPO_ROOT/src/DodoSSH.Client.App/Assets/dodossh.icns"
|
||||
ENTITLEMENTS="$REPO_ROOT/build/macos/DodoSSH.entitlements"
|
||||
PLIST_TEMPLATE="$REPO_ROOT/build/macos/Info.plist.template"
|
||||
|
||||
write_step() { printf '\n\033[36m==> %s\033[0m\n' "$1"; }
|
||||
stop_with() { printf '\n\033[31m%s\033[0m\n' "$1" >&2; exit 1; }
|
||||
|
||||
# ---- Is this machine able to do the job at all? -------------------------------------------------------
|
||||
|
||||
if [ "$(uname -s)" != 'Darwin' ]; then
|
||||
# codesign, notarytool and stapler are Apple tooling and exist nowhere else. The build and even the
|
||||
# .app bundle cross-compile fine from Windows or Linux — `vpk [osx] bundle` does exactly that, and
|
||||
# ci.yml uses it to prove the bundle still builds — but a signed, notarized, installable package
|
||||
# cannot be produced anywhere but here.
|
||||
stop_with 'This builds a signed macOS package and has to run on macOS.'
|
||||
fi
|
||||
|
||||
for tool in dotnet git xcrun codesign; do
|
||||
command -v "$tool" >/dev/null 2>&1 || stop_with "$tool is not on PATH."
|
||||
done
|
||||
|
||||
# Checked before anything is built rather than at the step that uses them. Notarization is the last thing
|
||||
# this script does and the slowest, and discovering there that a profile name was never exported means
|
||||
# throwing away a full build and test run.
|
||||
for required in DODOSSH_SIGN_APP_IDENTITY DODOSSH_SIGN_INSTALL_IDENTITY DODOSSH_NOTARY_PROFILE; do
|
||||
if [ -z "${!required-}" ]; then
|
||||
stop_with "$required is not set. See the header of this script for what the three are and how to make them."
|
||||
fi
|
||||
done
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# ---- What is being released ---------------------------------------------------------------------------
|
||||
|
||||
# Restored before the version is read, and both halves are load-bearing — the same two traps the Windows
|
||||
# script documents. -t:MinVer, because -getProperty alone evaluates the project and runs no targets, while
|
||||
# MinVer sets Version from inside one, so the read would answer the SDK's default 1.0.0 regardless of the
|
||||
# tag. And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
|
||||
# clone where obj/ has no MinVer targets to import yet.
|
||||
write_step 'Restoring the desktop head, so the version can be read'
|
||||
dotnet restore "$PROJECT" --locked-mode || stop_with 'Restore failed.'
|
||||
|
||||
VERSION="$(dotnet msbuild "$PROJECT" -getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
|
||||
[ -n "$VERSION" ] || stop_with 'Could not read the version from MSBuild.'
|
||||
|
||||
TAG="v$VERSION"
|
||||
|
||||
# Apple's two version keys take one to three dot-separated integers and nothing else, so a prerelease
|
||||
# version has to have its suffix removed before it reaches the plist. 1.2.3-rc.1 becomes 1.2.3.
|
||||
#
|
||||
# The full version, suffix and all, is what vpk packs and what the release index carries, so the updater
|
||||
# still tells an rc from the release it precedes. These two keys are for Finder and Gatekeeper, which
|
||||
# care that the string parses and not what it says. See build/macos/Info.plist.template.
|
||||
PLIST_VERSION="${VERSION%%-*}"
|
||||
PLIST_VERSION="${PLIST_VERSION%%+*}"
|
||||
|
||||
write_step "DodoSSH $VERSION ($PACK_ID, channel $CHANNEL, $RUNTIME)"
|
||||
|
||||
# ---- Phase 2: publish what phase 1 built --------------------------------------------------------------
|
||||
|
||||
if [ "$UPLOAD" -eq 1 ]; then
|
||||
# The installer package is the artefact a person downloads, so its absence is the honest test of
|
||||
# whether phase one ever ran. A directory holding only a .nupkg is a pack that failed part way.
|
||||
if ! ls "$RELEASES_DIR"/*.pkg >/dev/null 2>&1; then
|
||||
stop_with "Nothing to upload: $RELEASES_DIR has no .pkg. Run this without --upload first."
|
||||
fi
|
||||
|
||||
echo "About to publish the contents of $RELEASES_DIR to $REPO_URL as $TAG."
|
||||
echo 'Only do this once you have installed it and walked Phase 18 of docs/manual-checks.md.'
|
||||
|
||||
# -s so the token is never echoed and never lands in the shell's history.
|
||||
printf 'Gitea token (write:repository): '
|
||||
read -r -s TOKEN
|
||||
echo
|
||||
|
||||
[ -n "$TOKEN" ] || stop_with 'No token given.'
|
||||
|
||||
# --merge because Gitea already has a release entry for the pushed tag — and on this platform it may
|
||||
# also already hold the Windows package for the same tag, which is the case --merge is really doing
|
||||
# the work for: without it the second platform to publish a given version fails on a release that
|
||||
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
|
||||
UPLOAD_ARGS=(
|
||||
upload gitea
|
||||
--repoUrl "$REPO_URL"
|
||||
--token "$TOKEN"
|
||||
--outputDir "$RELEASES_DIR"
|
||||
--channel "$CHANNEL"
|
||||
--releaseName "$TAG"
|
||||
--tag "$TAG"
|
||||
--merge
|
||||
--publish
|
||||
)
|
||||
|
||||
# Mirrors the rule the docker image job and the Windows script already apply to the same tag, so a
|
||||
# release candidate is a prerelease in every channel or in none.
|
||||
case "$VERSION" in
|
||||
*-*) UPLOAD_ARGS+=(--pre) ;;
|
||||
esac
|
||||
|
||||
write_step 'Uploading'
|
||||
dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'
|
||||
|
||||
write_step "Published $TAG."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- Phase 1: build, sign, notarize, pack -------------------------------------------------------------
|
||||
|
||||
[ -z "$(git status --porcelain)" ] || stop_with 'The working tree is not clean. A release is cut from a commit, not from a desk.'
|
||||
|
||||
HEAD_TAG="$(git describe --exact-match --tags HEAD 2>/dev/null || true)"
|
||||
[ -n "$HEAD_TAG" ] || stop_with "HEAD is not tagged. Tag it $TAG first, or change the version and tag that."
|
||||
|
||||
# Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the day
|
||||
# somebody pins a version by hand this is the guard that notices.
|
||||
[ "$HEAD_TAG" = "$TAG" ] || stop_with "HEAD is tagged $HEAD_TAG but the computed version is $VERSION."
|
||||
|
||||
write_step 'Restoring tools'
|
||||
dotnet tool restore || stop_with 'dotnet tool restore failed.'
|
||||
|
||||
write_step 'Restoring packages (locked, exactly as CI does)'
|
||||
dotnet restore "$SOLUTION" --locked-mode || stop_with 'Restore failed. A lock file that only works on Linux fails here.'
|
||||
|
||||
write_step 'Building'
|
||||
dotnet build "$SOLUTION" --no-restore --configuration Release || stop_with 'Build failed.'
|
||||
|
||||
if [ "$SKIP_TESTS" -eq 0 ]; then
|
||||
# The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
|
||||
# on trust from CI, because a tag is the one build nobody is watching — and on this platform there is
|
||||
# a second reason: CI has no macOS runner, so this is the only place the suite ever runs on a Mac at
|
||||
# all. Everything docs/platform-flags.md lists as unverified on macOS is verified here or nowhere.
|
||||
write_step 'Testing'
|
||||
dotnet test "$SOLUTION" --no-build --configuration Release || stop_with 'Tests failed.'
|
||||
fi
|
||||
|
||||
write_step "Publishing $RUNTIME"
|
||||
rm -rf "$PUBLISH_DIR"
|
||||
|
||||
# Self-contained, and not single-file, for the reasons the Windows script gives: the native libraries ship
|
||||
# per RID and a self-extracting bundle breaks delta updates.
|
||||
#
|
||||
# RestoreLockedMode=false, and the lock files put back straight afterwards. A RID-specific publish resolves
|
||||
# a graph the committed lock files do not describe, because they are deliberately kept RID-free —
|
||||
# declaring a RID on the head writes a net10.0/<rid> target into every project it references transitively,
|
||||
# including DodoSSH.Contracts and DodoSSH.Crypto, and the API's Dockerfile then restores those with no RID
|
||||
# under locked mode and fails NU1004. Packaging the desktop client would have broken the server's image
|
||||
# build. The gate that matters is the locked solution restore above, which is untouched.
|
||||
dotnet publish "$PROJECT" \
|
||||
--configuration Release \
|
||||
--runtime "$RUNTIME" \
|
||||
--self-contained true \
|
||||
--output "$PUBLISH_DIR" \
|
||||
-p:RestoreLockedMode=false \
|
||||
|| stop_with 'Publish failed.'
|
||||
|
||||
# An unlocked restore rewrites the lock files it walked. Left there, the next commit would carry exactly
|
||||
# the change that breaks the image build. Safe to do bluntly because this script refuses to run on a dirty
|
||||
# tree, so anything modified here is its own.
|
||||
git checkout -- '*packages.lock.json' || stop_with 'Could not restore the lock files after publishing.'
|
||||
|
||||
# Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for an
|
||||
# application that never checks for updates — which looks completely normal until the next release goes out
|
||||
# and nobody receives it.
|
||||
for required in DodoSSH Velopack.dll; do
|
||||
[ -e "$PUBLISH_DIR/$required" ] || stop_with "$required is missing from $PUBLISH_DIR."
|
||||
done
|
||||
|
||||
echo " $(du -sh "$PUBLISH_DIR" | cut -f1) in $(find "$PUBLISH_DIR" -type f | wc -l | tr -d ' ') files"
|
||||
|
||||
# ---- Signing the native libraries, before vpk signs anything ------------------------------------------
|
||||
|
||||
# ◆ THIS LOOP IS WHY NOTARIZATION SUCCEEDS, AND IT LOOKS REDUNDANT.
|
||||
#
|
||||
# vpk signs the finished bundle itself, with `codesign -f -v --timestamp --options runtime --entitlements
|
||||
# <file> --deep`, and --deep is documented by Apple as the wrong way to sign nested code. Apple's guidance
|
||||
# is inside-out: sign each nested binary first, then the bundle around it. --deep does the reverse in one
|
||||
# pass and applies the outer entitlements to everything it touches.
|
||||
#
|
||||
# In practice --deep alone is where the failure recorded in docs/platform-flags.md comes from — a
|
||||
# notarization rejection that does not name the offending file, on a submission that took its time getting
|
||||
# there. Signing each dylib properly first means vpk's pass has nothing left to get wrong, and re-signing
|
||||
# an already correctly signed binary with -f is a no-op in effect.
|
||||
#
|
||||
# No --entitlements here, and that is the difference that matters. Entitlements belong on the main
|
||||
# executable; a dylib carrying allow-jit is at best meaningless and at worst a rejection.
|
||||
write_step 'Signing native libraries'
|
||||
|
||||
# createdump is a Mach-O executable the runtime ships and it is signed like the libraries: a nested
|
||||
# executable that is not signed fails notarization exactly as an unsigned dylib does, and it is the one
|
||||
# people forget because it has no extension to grep for.
|
||||
NATIVE_COUNT=0
|
||||
while IFS= read -r -d '' binary; do
|
||||
codesign --force --verbose=0 --timestamp --options runtime \
|
||||
--sign "$DODOSSH_SIGN_APP_IDENTITY" "$binary" \
|
||||
|| stop_with "codesign failed on $binary"
|
||||
NATIVE_COUNT=$((NATIVE_COUNT + 1))
|
||||
done < <(find "$PUBLISH_DIR" \( -name '*.dylib' -o -name 'createdump' \) -type f -print0)
|
||||
|
||||
[ "$NATIVE_COUNT" -gt 0 ] || stop_with "No native binaries found under $PUBLISH_DIR, which cannot be right for a self-contained publish."
|
||||
echo " signed $NATIVE_COUNT native binaries"
|
||||
|
||||
# ---- The bundle's Info.plist --------------------------------------------------------------------------
|
||||
|
||||
# Rendered rather than committed, because vpk copies a custom plist verbatim and substitutes nothing —
|
||||
# so a committed one would carry whatever version it was written with into every release afterwards.
|
||||
# See the header of build/macos/Info.plist.template.
|
||||
write_step "Rendering Info.plist for $PLIST_VERSION"
|
||||
RENDERED_PLIST="$(mktemp -t dodossh-plist)"
|
||||
trap 'rm -f "$RENDERED_PLIST"' EXIT
|
||||
|
||||
sed "s/@VERSION@/$PLIST_VERSION/g" "$PLIST_TEMPLATE" > "$RENDERED_PLIST"
|
||||
|
||||
# The placeholder is the whole mechanism, so its absence is checked rather than hoped for. A template
|
||||
# somebody edited into a literal version would otherwise sail through and pin every future release to it.
|
||||
grep -q '@VERSION@' "$PLIST_TEMPLATE" || stop_with "$PLIST_TEMPLATE has no @VERSION@ placeholder left in it."
|
||||
! grep -q '@VERSION@' "$RENDERED_PLIST" || stop_with 'Substitution into the rendered Info.plist did not take.'
|
||||
|
||||
mkdir -p "$RELEASES_DIR"
|
||||
|
||||
# The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
|
||||
# macOS release has no predecessor, and a hard failure here would make cutting it impossible.
|
||||
write_step 'Fetching the previous release, for deltas'
|
||||
if ! dotnet vpk download gitea --repoUrl "$REPO_URL" --outputDir "$RELEASES_DIR" --channel "$CHANNEL"; then
|
||||
echo ' Nothing came down. This package will be full-only, which is right for a first release.'
|
||||
fi
|
||||
|
||||
# ---- Pack, sign, notarize, staple ---------------------------------------------------------------------
|
||||
|
||||
# One command does the rest, and it is worth knowing what it is doing on your behalf, because the slow
|
||||
# part is not local: it builds the .app from the published files, signs it with the Developer ID
|
||||
# certificate and the entitlements below, submits it to Apple with `xcrun notarytool submit --wait`,
|
||||
# staples the resulting ticket to the package, and then builds the .pkg installer and the release index.
|
||||
#
|
||||
# The notarization wait is the reason this step can take a quarter of an hour and occasionally much
|
||||
# longer — it is a queue at Apple, not a computation here, and vpk's own message says so.
|
||||
#
|
||||
# --signInstallIdentity is a different certificate from --signAppIdentity, and the pair is not
|
||||
# interchangeable: "Developer ID Application" signs the bundle, "Developer ID Installer" signs the .pkg.
|
||||
# Passing one where the other belongs fails with a message about an identity that cannot be found, which
|
||||
# reads like a keychain problem rather than like the wrong certificate.
|
||||
write_step 'Packing, signing and notarizing (the notarization wait is Apple queueing, not this machine)'
|
||||
|
||||
dotnet vpk pack \
|
||||
--packId "$PACK_ID" \
|
||||
--packVersion "$VERSION" \
|
||||
--packDir "$PUBLISH_DIR" \
|
||||
--packTitle "$PACK_TITLE" \
|
||||
--packAuthors "$PACK_AUTHORS" \
|
||||
--mainExe 'DodoSSH' \
|
||||
--icon "$ICON" \
|
||||
--plist "$RENDERED_PLIST" \
|
||||
--entitlements "$ENTITLEMENTS" \
|
||||
--signAppIdentity "$DODOSSH_SIGN_APP_IDENTITY" \
|
||||
--signInstallIdentity "$DODOSSH_SIGN_INSTALL_IDENTITY" \
|
||||
--notaryProfile "$DODOSSH_NOTARY_PROFILE" \
|
||||
--runtime "$RUNTIME" \
|
||||
--channel "$CHANNEL" \
|
||||
--outputDir "$RELEASES_DIR" \
|
||||
|| stop_with 'vpk pack failed.'
|
||||
|
||||
# ---- Did the notarization actually take? --------------------------------------------------------------
|
||||
|
||||
# Asked rather than assumed, and this is the check worth having above all the others. A package whose
|
||||
# ticket did not staple is indistinguishable from a good one on the machine that built it — the Mac that
|
||||
# signed something trusts it locally — and reveals itself only on somebody else's machine, as a refusal
|
||||
# to open at all. spctl assesses it the way Gatekeeper will on a machine that has never seen this
|
||||
# certificate.
|
||||
write_step 'Verifying the notarization the way another Mac will'
|
||||
|
||||
PKG="$(ls -t "$RELEASES_DIR"/*.pkg 2>/dev/null | head -n 1)"
|
||||
[ -n "$PKG" ] || stop_with 'vpk pack reported success but produced no .pkg.'
|
||||
|
||||
if ! spctl --assess --type install --verbose=4 "$PKG"; then
|
||||
stop_with "Gatekeeper rejects $PKG. It is signed but the notarization ticket is missing or stale; do not upload it."
|
||||
fi
|
||||
|
||||
xcrun stapler validate "$PKG" || stop_with "The notarization ticket is not stapled to $PKG."
|
||||
|
||||
write_step 'Built, notarized, and deliberately not uploaded'
|
||||
|
||||
ls -lh "$RELEASES_DIR" | tail -n +2
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Next:
|
||||
1. Install the .pkg above and walk Phase 18 of docs/manual-checks.md.
|
||||
2. Then: bash scripts/release-macos.sh --upload
|
||||
EOF
|
||||
@@ -90,32 +90,61 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
||||
|
||||
shell.DataContext = viewModel;
|
||||
|
||||
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
|
||||
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
|
||||
//
|
||||
// The transfer count is real now that the document picker gives this head a way to start one, and
|
||||
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
|
||||
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
|
||||
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
|
||||
// moves five files.
|
||||
//
|
||||
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
|
||||
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
|
||||
// because it is subscribed to the workspace, which lives as long as the process.
|
||||
var keepAlive = new SessionKeepAlive(
|
||||
workspace,
|
||||
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
|
||||
|
||||
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
||||
// announces transfers appearing and finishing. Without this the notification would come up when an
|
||||
// upload started and stay up after it finished, which is the failure this class exists to prevent.
|
||||
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
|
||||
|
||||
keepAlive.Refresh();
|
||||
// Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
|
||||
// does and why.
|
||||
ComposeKeepAlive(workspace, viewModel);
|
||||
|
||||
return shell;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires up the foreground service that makes <c>TerminalWorkspace</c>'s promise — that a shell outlives
|
||||
/// a vault lock — true on a platform that stops backgrounded processes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Split out of <see cref="Compose"/> for length rather than for reuse; there is exactly one caller.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The transfer count is real now that the document picker gives this head a way to start one, and it
|
||||
/// matters exactly when nobody is looking: a shell survives backgrounding because somebody opened it,
|
||||
/// and an upload has to survive precisely when nobody is — the screen is off and the phone is in a
|
||||
/// pocket. Queued counts as active, so putting five files in the queue and locking the phone moves five
|
||||
/// files. <c>holdsFileSession</c> covers the third case a count alone cannot: a host connected on the
|
||||
/// Files screen with no transfer moving is still a live SFTP session that backgrounding would sever, and
|
||||
/// <c>TransfersViewModel.HasLiveFileSession</c> is the existing fact — <c>IsConnected</c> with a real
|
||||
/// cipher, which a bucket never has — that answers whether one is open.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>keepAlive</c> is a local rather than a field, matching the desktop head: an Avalonia Application
|
||||
/// has no disposal hook, so a field holding a disposable would have nowhere honest to release it. It
|
||||
/// stays alive because it is subscribed to the workspace, which lives as long as the process.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void ComposeKeepAlive(TerminalWorkspace workspace, MainWindowViewModel viewModel)
|
||||
{
|
||||
var keepAlive = new SessionKeepAlive(
|
||||
workspace,
|
||||
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
|
||||
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession);
|
||||
|
||||
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
||||
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
|
||||
// Without this the notification would come up when an upload started and stay up after it
|
||||
// finished, which is the failure this class exists to prevent.
|
||||
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
|
||||
|
||||
// The half that was missing until now: a shell opening. SessionKeepAlive already heard the
|
||||
// workspace announce a session ending, but nothing announced the opposite — a user who opened a
|
||||
// shell and backgrounded the app had no foreground service at all, because the only wire in was the
|
||||
// one for taking it down. TerminalSessionOpened is that other half, forwarded from
|
||||
// VaultViewModel.SessionOpened, and without this line the service could never come up for a shell
|
||||
// in the first place, which was precisely the promise this whole arrangement exists to keep.
|
||||
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
|
||||
|
||||
keepAlive.Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writing to this phone's clipboard.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// See the note at the top of MainActivity for why the platform namespaces are reached through `global::`.
|
||||
using Avalonia;
|
||||
using Avalonia.Android;
|
||||
using Avalonia.Media;
|
||||
using DodoSSH.Client.Android.Platform;
|
||||
using global::Android.App;
|
||||
using global::Android.Runtime;
|
||||
@@ -46,6 +47,30 @@ public sealed class DodoSshAndroidApplication : AvaloniaAndroidApplication<DodoS
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// This used to be the one place the two heads disagreed on purpose: the recorded v2 decision was
|
||||
/// "Android recolours with the shared palette and keeps its own default sans", on the reasoning that a
|
||||
/// phone's system font is a phone's own business. The user has reversed that, this pass — the phone now
|
||||
/// takes the desktop's face as well as its colours, and <c>docs/design-import-gaps.md</c> is corrected
|
||||
/// to say so rather than left asserting a decision that no longer holds.
|
||||
///
|
||||
/// <c>WithInterFont</c> still registers Inter, for the same reason <c>Program.cs</c> keeps it on the
|
||||
/// desktop: it is what the layout suite pins and what a glyph Montserrat does not cover falls back to.
|
||||
/// What changes is which face answers first. Montserrat ships embedded in
|
||||
/// <c>DodoSSH.Client.Shell/Assets/Fonts</c> already — this project references that assembly for
|
||||
/// <c>Theme/Phone.axaml</c>'s own <c>MonoFont</c>, so no csproj or asset work was needed to reach it
|
||||
/// from here, only the same <see cref="FontManagerOptions"/> block the desktop's
|
||||
/// <c>Program.BuildAvaloniaApp</c> sets.
|
||||
/// </remarks>
|
||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) =>
|
||||
base.CustomizeAppBuilder(builder).WithInterFont();
|
||||
base.CustomizeAppBuilder(builder)
|
||||
.WithInterFont()
|
||||
.With(new FontManagerOptions
|
||||
{
|
||||
DefaultFamilyName = "avares://DodoSSH.Client.Shell/Assets/Fonts#Montserrat",
|
||||
FontFallbacks =
|
||||
[
|
||||
new FontFallback { FontFamily = new FontFamily("avares://Avalonia.Fonts.Inter/Assets#Inter") },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using global::Android.OS;
|
||||
namespace DodoSSH.Client.Android.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the process alive for as long as a shell or a transfer is live.
|
||||
/// Keeps the process alive for as long as a shell, a transfer, or a connected Files session is live.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -41,6 +41,26 @@ internal sealed class SessionForegroundService : Service
|
||||
private const string ChannelId = "dodossh.sessions";
|
||||
private const int NotificationId = 1;
|
||||
|
||||
// API 33+ requires the request to name a code the RequestPermissionsResult callback would be handed
|
||||
// back — 1 is fine because this head never implements that callback at all, see
|
||||
// RequestNotificationPermission's own remark for why a result is not worth listening for.
|
||||
private const int NotificationPermissionRequestCode = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Whether <see cref="OnStartCommand"/> has run for this process without a matching
|
||||
/// <see cref="OnDestroy"/> since — i.e. whether Android currently considers this service foregrounded.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Volatile because <see cref="Reconcile"/> can run on whatever thread called
|
||||
/// <see cref="SessionKeepAlive.Refresh"/>, while this is set from the binder thread Android delivers
|
||||
/// service lifecycle callbacks on — two threads with no other synchronisation between them, and a stale
|
||||
/// read here is the difference between updating a notification in place and calling
|
||||
/// <c>StartForegroundService</c> on a service that is already running, which is what defect 3 was.
|
||||
/// </remarks>
|
||||
private static volatile bool running;
|
||||
|
||||
private static bool notificationPermissionRequested;
|
||||
|
||||
/// <remarks>
|
||||
/// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
|
||||
/// wanted here: the point is that they outlive whatever the user does with the interface.
|
||||
@@ -49,7 +69,9 @@ internal sealed class SessionForegroundService : Service
|
||||
|
||||
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
|
||||
{
|
||||
StartForeground(NotificationId, BuildNotification(intent?.GetStringExtra("summary") ?? "Working"));
|
||||
running = true;
|
||||
|
||||
StartForeground(NotificationId, BuildNotification(this, intent?.GetStringExtra("summary") ?? "Working"));
|
||||
|
||||
// NotSticky: if Android does kill this process, the SSH connections died with it and there is
|
||||
// nothing to resume. Restarting the service would produce a notification claiming sessions that no
|
||||
@@ -58,14 +80,36 @@ internal sealed class SessionForegroundService : Service
|
||||
return StartCommandResult.NotSticky;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The other half of <see cref="running"/>. Android calls this whether the service stopped itself or
|
||||
/// was stopped from outside — <see cref="Reconcile"/>'s down case calls <c>StopService</c> rather than
|
||||
/// clearing the flag directly, so this override is the one place that actually knows the service has
|
||||
/// gone, matching how <see cref="OnStartCommand"/> is the one place that knows it has come up.
|
||||
/// </remarks>
|
||||
public override void OnDestroy()
|
||||
{
|
||||
running = false;
|
||||
|
||||
base.OnDestroy();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android
|
||||
/// requires one, and because the user is entitled to know the app is holding connections open. Making
|
||||
/// it buzz would be a notification about nothing having happened.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Static, taking the <see cref="Context"/> it needs rather than reading <c>this</c>: the instance path
|
||||
/// through <see cref="OnStartCommand"/> passes the service itself, and the in-place update path through
|
||||
/// <see cref="Reconcile"/> has no service instance at all — only <see cref="PhoneEnvironment.Require"/>
|
||||
/// — because posting to an already-running notification never touches the service's own lifecycle.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private Notification BuildNotification(string summary)
|
||||
private static Notification BuildNotification(Context context, string summary)
|
||||
{
|
||||
var manager = (NotificationManager)GetSystemService(NotificationService)!;
|
||||
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
|
||||
|
||||
if (OperatingSystem.IsAndroidVersionAtLeast(26))
|
||||
{
|
||||
@@ -79,12 +123,12 @@ internal sealed class SessionForegroundService : Service
|
||||
}
|
||||
|
||||
var reopen = PendingIntent.GetActivity(
|
||||
this,
|
||||
context,
|
||||
0,
|
||||
new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
||||
new Intent(context, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
|
||||
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
|
||||
|
||||
return new Notification.Builder(this, ChannelId)
|
||||
return new Notification.Builder(context, ChannelId)
|
||||
.SetContentTitle("DodoSSH")
|
||||
.SetContentText(summary)
|
||||
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
|
||||
@@ -93,37 +137,138 @@ internal sealed class SessionForegroundService : Service
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>Starts or stops the service to match what is actually running.</summary>
|
||||
/// <summary>Starts, stops, or refreshes the service's notification to match what is actually running.</summary>
|
||||
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
||||
/// <param name="activeTransfers">Transfers still moving bytes.</param>
|
||||
public static void Reconcile(int liveSessions, int activeTransfers)
|
||||
/// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
|
||||
/// <remarks>
|
||||
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
|
||||
/// always. Something live and the service not yet running starts it. Something live and the service
|
||||
/// already running is the case that used to call <c>StartForegroundService</c> a second time, which on
|
||||
/// API 31+ throws <c>ForegroundServiceStartNotAllowedException</c> the instant the app is backgrounded
|
||||
/// — a transfer finishing in the pocket, one of two shells dying — crashing the process and taking the
|
||||
/// remaining connections with it. That case — running, and the app backgrounded — now only posts a
|
||||
/// fresh notification through the <see cref="NotificationManager"/> already holding the channel open,
|
||||
/// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
|
||||
/// start even over a service that looks like it is running; the inline remark below is why.
|
||||
/// </remarks>
|
||||
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession)
|
||||
{
|
||||
var context = PhoneEnvironment.Require();
|
||||
var intent = new Intent(context, typeof(SessionForegroundService));
|
||||
|
||||
if (liveSessions == 0 && activeTransfers == 0)
|
||||
if (liveSessions == 0 && activeTransfers == 0 && !holdsFileSession)
|
||||
{
|
||||
context.StopService(intent);
|
||||
context.StopService(new Intent(context, typeof(SessionForegroundService)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// The summary says what is actually held, counted rather than generic — the same principle the
|
||||
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
|
||||
intent.PutExtra("summary", Summarise(liveSessions, activeTransfers));
|
||||
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession);
|
||||
|
||||
context.StartForegroundService(intent);
|
||||
// In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
|
||||
// always allowed and is preferred even when `running` says the service is up: a disconnect followed
|
||||
// by a quick reconnect can land here while the StopService just issued is still in flight, and
|
||||
// posting to that dying service's notification would leave an orphan receipt over an unprotected
|
||||
// process — restarting instead makes the flag's small lag harmless. A start on a service that
|
||||
// really is running only re-delivers OnStartCommand, whose StartForeground updates the same
|
||||
// notification anyway. CurrentActivity is the foreground signal: set on resume, cleared on pause.
|
||||
if (running && PhoneEnvironment.CurrentActivity is null)
|
||||
{
|
||||
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
|
||||
manager.Notify(NotificationId, BuildNotification(context, summary));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
RequestNotificationPermission(context);
|
||||
Start(context, summary);
|
||||
}
|
||||
|
||||
private static string Summarise(int liveSessions, int activeTransfers)
|
||||
/// <remarks>
|
||||
/// Split out of <see cref="Reconcile"/> for the try/catch alone, which needs its own remark and would
|
||||
/// otherwise crowd the three-way branch above it.
|
||||
/// </remarks>
|
||||
private static void Start(Context context, string summary)
|
||||
{
|
||||
var parts = new List<string>(2);
|
||||
var intent = new Intent(context, typeof(SessionForegroundService));
|
||||
intent.PutExtra("summary", summary);
|
||||
|
||||
try
|
||||
{
|
||||
context.StartForegroundService(intent);
|
||||
}
|
||||
catch (Java.Lang.IllegalStateException)
|
||||
{
|
||||
// ForegroundServiceStartNotAllowedException (API 31+) derives from this, and reaching it here
|
||||
// means a start-worthy transition — a shell opening, a Files connection completing, the first
|
||||
// transfer landing in an empty queue — happened while the app was backgrounded, which is
|
||||
// precisely when Android refuses a new foreground start. There is no retry that helps: by the
|
||||
// time this catch runs, the moment such a start would have been allowed has already passed.
|
||||
// Swallowing it is the honest choice and not just the available one — letting the exception
|
||||
// propagate would crash the process and drop the very shells and transfers this service exists
|
||||
// to keep alive. A process that keeps running unprotected outlives one that does not run at all.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks for the receipt notification's own permission, the first time this process actually has
|
||||
/// something to show rather than at launch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// At most once per process, via <see cref="notificationPermissionRequested"/> — not to work around a
|
||||
/// platform limit, since Android already refuses to show the dialogue twice, but because a second call
|
||||
/// to <c>RequestPermissions</c> after the first is still pending is its own kind of noise. No result is
|
||||
/// read back: there is nothing this class would do differently for a grant versus a refusal, so a
|
||||
/// callback would exist only to be empty. What refusal costs is stated rather than hidden — the
|
||||
/// notification stays invisible — and what it does not cost is the point: the service still starts,
|
||||
/// still holds the process in the foreground, and the shells and transfers it protects are exactly as
|
||||
/// safe as if the user had said yes. See the manifest's own comment on this permission.
|
||||
/// </remarks>
|
||||
private static void RequestNotificationPermission(Context context)
|
||||
{
|
||||
// Isolated as its own guard clause rather than folded into the compound condition below: the
|
||||
// platform-compatibility analyzer only recognises a version check as guarding what follows when it
|
||||
// is the sole condition of its own early return, and PostNotifications is annotated API 33+.
|
||||
if (!OperatingSystem.IsAndroidVersionAtLeast(33))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Read into a local rather than referenced again inside the lambda below: the guard clause above
|
||||
// covers a direct call in this method's own body, but the platform-compatibility analyzer treats a
|
||||
// lambda as reachable from anywhere and will not extend the guard across that boundary. Capturing
|
||||
// the already-validated string sidesteps the false positive without weakening the actual check.
|
||||
var postNotifications = global::Android.Manifest.Permission.PostNotifications;
|
||||
|
||||
if (notificationPermissionRequested
|
||||
|| PhoneEnvironment.CurrentActivity is not { } activity
|
||||
|| context.CheckSelfPermission(postNotifications) == Permission.Granted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
notificationPermissionRequested = true;
|
||||
|
||||
activity.RunOnUiThread(() =>
|
||||
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
|
||||
}
|
||||
|
||||
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession)
|
||||
{
|
||||
var parts = new List<string>(3);
|
||||
|
||||
if (liveSessions > 0)
|
||||
{
|
||||
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
||||
}
|
||||
|
||||
if (holdsFileSession)
|
||||
{
|
||||
parts.Add("Files connected");
|
||||
}
|
||||
|
||||
if (activeTransfers > 0)
|
||||
{
|
||||
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
|
||||
|
||||
@@ -17,37 +17,53 @@ namespace DodoSSH.Client.Android.Platform;
|
||||
/// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which
|
||||
/// a counter incremented on open and decremented on close would not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Three facts feed <see cref="SessionForegroundService.Reconcile"/>, not one: live shells, moving
|
||||
/// transfers, and an idle-but-connected Files session. The last of those used to be missing entirely —
|
||||
/// a shell survives backgrounding because somebody opened it, but a Files connection with nothing moving
|
||||
/// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that
|
||||
/// gap closed, read the same way the other two facts are: asked, not cached.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class SessionKeepAlive : IDisposable
|
||||
{
|
||||
private readonly TerminalWorkspace workspace;
|
||||
private readonly Func<int> activeTransfers;
|
||||
private readonly Func<bool> holdsFileSession;
|
||||
|
||||
/// <param name="workspace">The live shells.</param>
|
||||
/// <param name="activeTransfers">
|
||||
/// How many transfers are moving bytes. A delegate rather than a queue, because file transfer is out
|
||||
/// of this head's first scope — see the decision in docs/android-port.md — and this is the seam it
|
||||
/// will arrive through rather than a dependency taken before there is anything to depend on.
|
||||
/// How many transfers are moving bytes. A delegate rather than a queue, because ownership of the
|
||||
/// transfer queue stays with <c>TransfersViewModel</c> — this class only ever asks it a question.
|
||||
/// </param>
|
||||
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers)
|
||||
/// <param name="holdsFileSession">
|
||||
/// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but-
|
||||
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
|
||||
/// </param>
|
||||
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers, Func<bool> holdsFileSession)
|
||||
{
|
||||
this.workspace = workspace;
|
||||
this.activeTransfers = activeTransfers;
|
||||
this.holdsFileSession = holdsFileSession;
|
||||
|
||||
// Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is
|
||||
// a binder call and needs no particular thread. Nothing here touches the interface.
|
||||
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
|
||||
// closer's own — which is fine: starting and stopping a service is a binder call and needs no
|
||||
// particular thread. Nothing here touches the interface. That the announcement waits for the run to
|
||||
// actually complete, and comes for deliberate closes too, is what makes reading LiveSessionCount
|
||||
// from it honest — the event's own remark carries the stuck notification that taught us both.
|
||||
workspace.SessionEnded += OnSessionEnded;
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
||||
/// <remarks>
|
||||
/// Called after anything that could change either count — opening a shell, closing a tab, a transfer
|
||||
/// finishing. Calling it when nothing changed is free: reconciling to the state it is already in is
|
||||
/// either a redundant <c>startForegroundService</c> on a running service or a <c>stopService</c> on a
|
||||
/// stopped one, and Android treats both as no-ops.
|
||||
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
|
||||
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
|
||||
/// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
|
||||
/// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one,
|
||||
/// and Android treats all of those as no-ops.
|
||||
/// </remarks>
|
||||
public void Refresh() =>
|
||||
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers());
|
||||
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
@@ -56,7 +72,7 @@ internal sealed class SessionKeepAlive : IDisposable
|
||||
|
||||
// The notification goes with the composition root. Leaving it up over a process that is shutting
|
||||
// down is how an SSH client acquires a reputation for a notification you cannot get rid of.
|
||||
SessionForegroundService.Reconcile(0, 0);
|
||||
SessionForegroundService.Reconcile(0, 0, false);
|
||||
}
|
||||
|
||||
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
|
||||
|
||||
@@ -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_DATA_SYNC" />
|
||||
|
||||
<!-- The service's persistent notification. Runtime-requested on API 33+, and refusal is survivable. -->
|
||||
<!--
|
||||
The service's persistent notification. Requested on API 33+ from SessionForegroundService.Reconcile,
|
||||
the first time in this process there is actually something to show — not at launch, where the ask
|
||||
would justify nothing on screen yet. Refusal is survivable: the service still starts and still holds
|
||||
the process in the foreground either way, so a "no" costs the notification and nothing else.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Releases the device key. See AndroidDeviceKeyStore. -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The launcher mark, and it is the same mark PhoneShell's header and the desktop titlebar draw:
|
||||
>_ in the canvas colour on a solid accent tile. Filled rather than outlined since v2.
|
||||
>_ in AccentInk on a solid accent tile. Filled rather than outlined since v2.
|
||||
|
||||
The tile is not in this file. It is the background layer — @color/dodo_accent, see ic_launcher.xml
|
||||
— and that is the whole trick of the filled design on Android: the rounding a launcher applies is
|
||||
@@ -10,10 +10,10 @@
|
||||
rounded shape inside the first, visibly clipped at the corners on any device whose mask is not the
|
||||
one it was drawn for.
|
||||
|
||||
#0E1220 is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
|
||||
Android resource cannot reference a XAML dictionary. It equals Canvas today and is named
|
||||
separately in the palette for a reason worth keeping in mind here: this is ink on the accent, not
|
||||
the window behind it, and it follows AccentInk if the two ever part.
|
||||
#FFFFFF is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
|
||||
Android resource cannot reference a XAML dictionary. Through v2 it equalled Canvas and this file
|
||||
followed it when the two parted: v5's ink on the accent is white, the way every accent surface in
|
||||
that palette carries white, while the window behind it went its own way to #05050A.
|
||||
|
||||
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer 18 on each
|
||||
edge is what the launcher eats for masking and parallax. The glyph spans 36.06..71.94, which is
|
||||
@@ -35,7 +35,7 @@
|
||||
<path
|
||||
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#0E1220"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.4"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
@@ -44,7 +44,7 @@
|
||||
<path
|
||||
android:pathData="M54,64.68 L71.94,64.68"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#0E1220"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.4"
|
||||
android:strokeLineCap="round" />
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
Worth shipping rather than leaving out: a launcher with themed icons on and no monochrome
|
||||
layer to use falls back to the full-colour icon, so the one app on the home screen still
|
||||
drawn in blue is this one.
|
||||
drawn in its own accent is this one.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
between an Android resource and a XAML resource dictionary, so the duplication is stated rather than
|
||||
hidden.
|
||||
-->
|
||||
<color name="dodo_window">#0E1220</color>
|
||||
<color name="dodo_window">#05050A</color>
|
||||
|
||||
<!--
|
||||
AccentColor from the same palette, here because the launcher icon's background layer is a colour
|
||||
and not a drawable. Same hand-kept duplication as above, and the same rule: if the palette moves,
|
||||
this moves with it.
|
||||
-->
|
||||
<color name="dodo_accent">#5B8CFF</color>
|
||||
<color name="dodo_accent">#5D42DE</color>
|
||||
</resources>
|
||||
|
||||
@@ -12,10 +12,28 @@
|
||||
the alternative, and the red one is the one that costs something.
|
||||
|
||||
── v2 ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||
The second design rounds everything. The corner radii below are the design's own — 4 for a tag, 9 for a
|
||||
button or a pill, 10 for a list row, 11 for the search well, 12 for a card, 14 for a block of
|
||||
monospaced output — and they are a ladder rather than a set of preferences: the radius says how big the
|
||||
thing is, so a 12 on a chip or a 4 on a card reads as the wrong size before it reads as the wrong shape.
|
||||
The second design rounded everything on its own ladder — 4 for a tag, 9 for a button or a pill, 10 for
|
||||
a list row, 11 for the search well, 12 for a card, 14 for a block of monospaced output — and the numbers
|
||||
below carried it for three passes: the radius said how big the thing was, so a 12 on a chip or a 4 on a
|
||||
card read as the wrong size before it read as the wrong shape. Left as a record of that reasoning rather
|
||||
than deleted, because the reasoning still holds; only the numbers it was reasoning about have moved.
|
||||
|
||||
── v5 ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||
This pass takes the desktop's v5 ladder instead of v2's own, on the same reversal recorded for the
|
||||
fonts: the phone now matches the desktop's shape as well as its face. Three rungs rather than six —
|
||||
cards and sections stay 12, a button or a field is 10, a chip or a tag is 6 — collapsing v2's 9/10/11
|
||||
into the one value the desktop's buttons and fields already use, and moving its 4 up to 6 and its 14
|
||||
down to 12 to land on the desktop's own chip and card numbers. The ladder still says how big a thing
|
||||
is before it says what shape it is; it is just a shorter ladder now, because the desktop it is copying
|
||||
never drew v2's 11-radius search well or a card any rounder than a chip's neighbour a step away, and the
|
||||
same case that closed the gap between 9, 10 and 11 closes the one between 12 and 14.
|
||||
|
||||
Not every radius on this head belongs to this ladder. The floating action button is 28 — half its own
|
||||
56, which is a circle rather than a ladder rung — and the sheets stay at 22 on their top corners only,
|
||||
which is a phone idiom this codebase's own bottom sheets have used since v2 and the desktop draws
|
||||
nothing like. Both are documented where they are set rather than here, for the reason FAB and sheet
|
||||
radii are always documented locally: a reader who only ever meets one of them should not have to find
|
||||
this paragraph to learn it was deliberate.
|
||||
-->
|
||||
|
||||
<Style Selector="Button.primary">
|
||||
@@ -23,15 +41,32 @@
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ Gradient and glow, replacing a flat Accent fill — the same swap App.axaml's Button.accent made on the
|
||||
desktop, and for the same reason: this design draws every primary button top-to-bottom from a lighter
|
||||
violet into the accent proper, lifted off the surface with a soft violet glow rather than a border. Both
|
||||
live in Palette.axaml as AccentGradient and AccentGlow, already resolvable here because the palette is
|
||||
shared — this is a resource-key swap, not new colour.
|
||||
|
||||
Background and BoxShadow are set here rather than as plain Setters above, because Fluent's default
|
||||
button template only lets a style reach the fill and the glow through the ContentPresenter it draws
|
||||
itself around — the same reason the desktop's rule targets /template/ ContentPresenter rather than the
|
||||
Button. Height, radius and the rest stay ordinary Setters on Button.primary itself; only the two the
|
||||
template intercepts move down here.
|
||||
-->
|
||||
<Style Selector="Button.primary /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||
<Setter Property="BoxShadow" Value="{StaticResource AccentGlow}" />
|
||||
</Style>
|
||||
<Style Selector="Button.primary:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||
<Setter Property="Opacity" Value="0.82" />
|
||||
</Style>
|
||||
|
||||
@@ -39,9 +74,15 @@
|
||||
Disabled is drawn as flat and unlit rather than merely dimmed. The design's CONTINUE button on the
|
||||
recovery screen is disabled until the checkbox is ticked, and a user who cannot tell it is disabled
|
||||
reads the screen as broken rather than as waiting for them.
|
||||
|
||||
BoxShadow has to be cleared here too, as "none" rather than left unset — see the remark on
|
||||
Button.accent:disabled in the desktop's App.axaml for why the literal string is required and an empty
|
||||
BoxShadows is not: the base rule's glow Setter is still in effect wherever a more specific one does not
|
||||
override it, and a disabled primary button lit with a glow would read as wanting to be pressed.
|
||||
-->
|
||||
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
<Setter Property="BoxShadow" Value="none" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
@@ -54,7 +95,7 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
@@ -69,17 +110,33 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<!-- A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse. -->
|
||||
<!--
|
||||
A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse.
|
||||
|
||||
◆ VerticalContentAlignment, because that height is the whole point of this class and Avalonia's default
|
||||
for content alignment is Stretch — so the content presenter stretched the caption to the full row and a
|
||||
TextBlock draws its line at the TOP of what it is given. Most rows here never showed it, having a
|
||||
StackPanel or a Grid of already-centred children in them, which is what made the four that did look
|
||||
like four unrelated mistakes: the breadcrumb chips and the up-one-directory button on FilesScreen, and
|
||||
TerminalScreen's CLOSE THIS TAB, each a bare TextBlock in a row 36 or 44 tall with no vertical padding.
|
||||
Measured at those numbers, the caption sat flush against the top edge with 21 to 33 pixels below it.
|
||||
|
||||
The desktop head's App.axaml carries the same setter on its own shapes for the same reason, and excludes
|
||||
two of them — see the remark on Button.ghost there. Nothing is excluded here: no row's content depends
|
||||
on being stretched, there being no full-height strip inside any of the thirty-three, and the Grids that
|
||||
stop filling hold only children that already centre themselves, so they land where they always did.
|
||||
-->
|
||||
<Style Selector="Button.row">
|
||||
<Setter Property="MinHeight" Value="54" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
@@ -126,7 +183,17 @@
|
||||
|
||||
Accent-filled, which it shares with Button.primary and with nothing else — and it means the same thing
|
||||
in both places: the one action on the surface that is not a choice between peers. Circular by radius
|
||||
rather than by a Path, so the pressed state the template draws is the same shape as the button.
|
||||
rather than by a Path, so the pressed state the template draws is the same shape as the button — 28,
|
||||
exactly half its own 56, which is a geometric constraint rather than a ladder rung and does not move
|
||||
with the rest of this file's radii.
|
||||
|
||||
◆ Gradient and glow since v5, matching Button.primary rather than staying a flat fill once that one
|
||||
moved. The two are the only accent-filled controls on this head and are read as one idea — "the thing
|
||||
this surface wants you to do" — so a flat FAB beside a gradient primary button would be the seam this
|
||||
codebase's palette file keeps warning about, one screenshot over from the button it echoes. The glow
|
||||
reads as well on a floating circle as it does on a bar-anchored rectangle: if anything a control that
|
||||
already floats over content earns a lift more than one sitting in a row of chrome does, so it is kept
|
||||
rather than dropped for the FAB's own shape.
|
||||
|
||||
Still only on HOSTS. The design puts a second one on S3 and that editor genuinely does not exist yet, so
|
||||
the style being here is not permission to draw one there.
|
||||
@@ -135,7 +202,6 @@
|
||||
<Setter Property="Width" Value="56" />
|
||||
<Setter Property="Height" Value="56" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="28" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
@@ -145,8 +211,14 @@
|
||||
<Setter Property="FontSize" Value="24" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<!-- See the remark above Button.primary's own /template/ ContentPresenter rule: BoxShadow has no home on
|
||||
a Button itself, so the fill moves down here alongside it rather than staying a plain Setter. -->
|
||||
<Style Selector="Button.fab /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||
<Setter Property="BoxShadow" Value="{StaticResource AccentGlow}" />
|
||||
</Style>
|
||||
<Style Selector="Button.fab:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Background" Value="{StaticResource AccentGradient}" />
|
||||
<Setter Property="Opacity" Value="0.82" />
|
||||
</Style>
|
||||
|
||||
@@ -161,7 +233,7 @@
|
||||
interface glitching rather than as a tap being received.
|
||||
-->
|
||||
<Style Selector="Button.scrim">
|
||||
<Setter Property="Background" Value="#9E0E1220" />
|
||||
<Setter Property="Background" Value="#9E05050A" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
@@ -169,10 +241,10 @@
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
<Style Selector="Button.scrim:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#9E0E1220" />
|
||||
<Setter Property="Background" Value="#9E05050A" />
|
||||
</Style>
|
||||
<Style Selector="Button.scrim:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#9E0E1220" />
|
||||
<Setter Property="Background" Value="#9E05050A" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
@@ -194,7 +266,7 @@
|
||||
-->
|
||||
<Style Selector="Border.output">
|
||||
<Setter Property="Background" Value="{StaticResource TerminalSurface}" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="12" />
|
||||
</Style>
|
||||
|
||||
@@ -205,7 +277,7 @@
|
||||
-->
|
||||
<Style Selector="Border.tag">
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="7,2" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
@@ -231,7 +303,7 @@
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="11,0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
@@ -267,11 +339,17 @@
|
||||
Checked is a filled surface with accent *text*, not an accent fill. v2 makes that distinction
|
||||
everywhere — see the remark on AccentText in Palette.axaml — and a chip is where it matters most: a row
|
||||
of four solid blue lozenges is a row of four things that all look like the primary action.
|
||||
|
||||
◆ Its own radius rather than Border.tag's or Button.chiptoggle's, despite the name. v2 drew this control
|
||||
on the ladder's button-or-pill rung rather than its tag rung — 9, not 4 — and the v5 pass carries the
|
||||
same rung forward to 10 rather than to Border.tag's 6: it is a much larger control at 34 tall against a
|
||||
tag's line-height, and a radius picked for a 34-pixel chip is not the one a 20-pixel tag needs, whatever
|
||||
the class is called. See the remark on Phone.axaml's own ladder, above.
|
||||
-->
|
||||
<Style Selector="RadioButton.chip">
|
||||
<Setter Property="MinHeight" Value="34" />
|
||||
<Setter Property="Padding" Value="13,6" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
@@ -314,7 +392,7 @@
|
||||
<Setter Property="Background" Value="{StaticResource Field}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="11" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
@@ -358,6 +436,20 @@
|
||||
<Setter Property="Fill" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Amber, and it does not contradict the remark above. That one says green is a fact about a host rather
|
||||
than an accent, and this is the colour for a fact that is not settled yet: green is what is true, purple
|
||||
is what you can press, and a connection still being made is neither. The palette's own rule gives amber
|
||||
to the caveat worth reading, which is exactly what this is.
|
||||
|
||||
Only the tab strips use it, and only for a tab with no shell behind it yet — the same amber, from the
|
||||
same brush, as the track and the running step on the connecting screen, so that a tab and the screen it
|
||||
opens agree about what is happening. See TerminalScreen.axaml.
|
||||
-->
|
||||
<Style Selector="Ellipse.dot.connecting">
|
||||
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
|
||||
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
||||
<Style Selector="TextBlock.mono">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
the refusal has no continue button here either.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<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"
|
||||
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
||||
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="9"
|
||||
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="10"
|
||||
Command="{Binding RemoteUpCommand}">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
|
||||
</Button>
|
||||
@@ -188,7 +188,7 @@
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="9"
|
||||
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="10"
|
||||
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||||
CommandParameter="{Binding Path}">
|
||||
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
|
||||
@@ -199,8 +199,48 @@
|
||||
</StackPanel>
|
||||
</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 ============ -->
|
||||
<Panel Grid.Row="4">
|
||||
<Panel Grid.Row="5">
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
||||
VerticalAlignment="Top" Text="{Binding Status}" />
|
||||
@@ -265,7 +305,8 @@
|
||||
</Panel>
|
||||
|
||||
<!-- ============ 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">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
of "edit these six". The three that stay are the ones a count makes better rather than worse.
|
||||
-->
|
||||
|
||||
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
<!-- 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">
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
written out because the palette holds no alpha variant of a surface — see QuickConnect on the
|
||||
desktop, which carries the same note.
|
||||
-->
|
||||
<Border Background="#9E0E1220" />
|
||||
<Border Background="#9E05050A" />
|
||||
|
||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
of a selection and ticking it is a thing this screen supports rather than merely survives.
|
||||
-->
|
||||
<Border Grid.Row="0" Margin="14,10,14,4" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="11" Height="44">
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="10" Height="44">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Text="⌕" Foreground="{StaticResource TextFaint}" FontSize="13"
|
||||
Margin="13,0,0,0" VerticalAlignment="Center" />
|
||||
@@ -974,7 +974,10 @@
|
||||
<Grid IsVisible="{Binding IsEditing}" RowDefinitions="Auto,*"
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<Border Grid.Row="0" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
<!-- DeepChrome rather than Chrome, since v5: "its header carries what the surrounding chrome no longer
|
||||
can" below is still true, and now the header itself carries the darker chrome the rest of this
|
||||
head's frame does — see the remark on PhoneShell's vault header. -->
|
||||
<Border Grid.Row="0" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1" Padding="6,0" Height="56">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Button Grid.Column="0" Classes="icon" Content="←" Command="{Binding CancelEditCommand}"
|
||||
@@ -1026,6 +1029,48 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
|
||||
moment one is wanted is while deciding how a host authenticates, and this head has no keychain
|
||||
editor for credentials at all — so without this a phone could bind a host to a credential but
|
||||
never make one. Writes to the keychain the instant ADD is pressed, exactly as the new-tag box
|
||||
below does and for the same reason: a host can only name an id that exists.
|
||||
-->
|
||||
<Button Classes="secondary" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
|
||||
MinHeight="40" Padding="14,0"
|
||||
IsVisible="{Binding !IsAddingEditorCredential}"
|
||||
Command="{Binding BeginEditorCredentialCommand}" />
|
||||
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
|
||||
IsVisible="{Binding IsAddingEditorCredential}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="NEW CREDENTIAL" />
|
||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialLabel}"
|
||||
PlaceholderText="name" />
|
||||
<!--
|
||||
Optional, and what makes a credential its own item: one account on twenty machines is
|
||||
rotated in one place. Left blank, this host's own username is used.
|
||||
-->
|
||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialUsername}"
|
||||
PlaceholderText="username (blank: this host's own)" />
|
||||
<TextBox Classes="field secret" Text="{Binding EditorNewCredentialPassword}"
|
||||
PlaceholderText="password" />
|
||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialNotes}"
|
||||
PlaceholderText="notes" />
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||
Text="Added to the keychain as soon as you press ADD, so it stays even if you leave this host without saving." />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Content="ADD" MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding AddEditorCredentialCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Content="CANCEL" MinHeight="44"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
||||
Command="{Binding CancelEditorCredentialCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than
|
||||
one vault that can be written to, exactly as on the desktop — an existing host's vault is not a
|
||||
@@ -1095,6 +1140,60 @@
|
||||
Command="{Binding AddEditorTagCommand}" />
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ QUICK ACCESS. Staged on the shared VaultViewModel.EditorPinnedPaths the way EditorTagChoices
|
||||
stages tags above — populated when the editor opens, read back by BuildHost on SAVE, left alone
|
||||
by CANCEL. Nothing here is phone-only: a pin added on this page is the same PinnedPathList entry
|
||||
the desktop's drawer stages under the same field, so it syncs to every other client the moment
|
||||
this vault does, exactly as a tag would.
|
||||
|
||||
No folder glyph in the row, unlike the desktop's — this head carries MonoFont and a heading font
|
||||
(see Theme/Phone.axaml) and no icon font, so the desktop's  would draw as a tofu box
|
||||
rather than a folder here. The path text carries the row on its own, trimmed in a Grid's star
|
||||
column rather than a StackPanel's — a StackPanel measures its children at infinity, so
|
||||
TextTrimming never actually engages inside one; this is the same fix TransfersScreen's pane
|
||||
headers needed for the same reason.
|
||||
|
||||
The remove button is Classes="icon" at its default 44x44, not the desktop's 22x22 close box —
|
||||
see this file's touch-floor rule at the top of the class: a target sized for a mouse pointer is
|
||||
not one a thumb can reliably land on.
|
||||
|
||||
See surface B, the Files screen's pin chips, for where these pins actually get used on this
|
||||
head — the hint sentence below names it rather than the desktop's terminal strip, which does
|
||||
not exist here.
|
||||
-->
|
||||
<TextBlock Classes="label" Text="QUICK ACCESS" Margin="0,4,0,0" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding EditorPinnedPaths}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<Border MinHeight="44" CornerRadius="10" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" Padding="12,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5" Text="{Binding}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="1" Classes="icon" Content="✕"
|
||||
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).RemoveEditorPinCommand}"
|
||||
CommandParameter="{Binding}" ToolTip.Tip="Unpins this path" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,Auto">
|
||||
<TextBox Grid.Column="0" Classes="field" Text="{Binding EditorNewPin}"
|
||||
PlaceholderText="/path/to/folder" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Width="72" Content="ADD"
|
||||
Command="{Binding AddEditorPinCommand}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="body"
|
||||
Text="Pins show up as chips on the Files screen while you are connected to this host — tap one to jump straight there." />
|
||||
|
||||
<!--
|
||||
◆ The one control here that publishes something. Turning it on copies this host's address and
|
||||
port into plaintext columns the server can read, which is the single deliberate concession in
|
||||
@@ -1114,6 +1213,18 @@
|
||||
<TextBlock Classes="body"
|
||||
Text="Not built yet: this app always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it works, the relay will dial on your behalf — which is why the address and port have to be stored in the clear. Everything else about the host stays encrypted either way, and a relayed host needs a port of its own rather than its group's." />
|
||||
|
||||
<!--
|
||||
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
||||
own rather than relying on the one the list behind it draws at the top of HOSTS (see this file's
|
||||
other Classes="detail" Text="{Binding Status}") — that TextBlock is off-screen for as long as
|
||||
IsEditing is true. AddEditorTagCommand and AddEditorPinCommand both refuse silently into Status
|
||||
rather than throwing, so without a line to draw it here the honesty rule would be broken: a
|
||||
refusal that writes a message nothing on screen shows is the same, to whoever pressed ADD, as no
|
||||
refusal at all.
|
||||
-->
|
||||
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
|
||||
Command="{Binding SaveHostCommand}" />
|
||||
|
||||
@@ -91,8 +91,8 @@
|
||||
|
||||
Not Border.card, and neither are its two counterparts on HOSTS and FILES: that class is a panel,
|
||||
and this is a warning, so the background and the border are the warn pair rather than the chrome
|
||||
one. What it does take is the radius the v2 ladder gives anything card-sized, which is what the
|
||||
other two already draw.
|
||||
one. What it does take is the radius the ladder gives anything card-sized — 12, unchanged from v2
|
||||
to v5 — which is what the other two already draw.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasLiveSessions}" Margin="0,22,0,0"
|
||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||
|
||||
@@ -36,6 +36,18 @@
|
||||
The .railentry styles live in this file because they are this control's own shape and nothing else wears
|
||||
them; every colour in them is resolved from Theme/Palette.axaml by key, which is the rule that matters —
|
||||
a rail that named its own blues is how a fifth surface colour ends up in the application.
|
||||
|
||||
── DEEP CHROME, SINCE v5 ───────────────────────────────────────────────────────────────────────────────
|
||||
This rail's own surface moves from Sidebar to DeepChrome in this pass, matching the desktop's NavRail —
|
||||
which is exactly what this control is standing in for on a wide phone, and which took the same v5b move
|
||||
itself: Palette.axaml's own remark on DeepChrome names "the rail down the side" as one of the five things
|
||||
that colour is for. Sidebar stays what a card sits on; this is the frame the cards sit inside, the same
|
||||
distinction PhoneShell's header, strip and bottom bar are drawing at the same time.
|
||||
|
||||
The hover fill moves too, from Panel to Track — and Track rather than Hover, because Palette.axaml's own
|
||||
remark on Track names "the rail row a pointer sits over" as one of the three things that colour is for,
|
||||
literally. Panel was never named for this; it read close enough that nobody had gone looking for the
|
||||
seam, which is exactly the kind of gap Track exists to close.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
@@ -54,7 +66,7 @@
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.railentry:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.railentry.active /template/ ContentPresenter">
|
||||
@@ -96,7 +108,7 @@
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Border Width="188" Background="{StaticResource Sidebar}"
|
||||
<Border Width="188" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||
|
||||
<!--
|
||||
|
||||
@@ -97,7 +97,16 @@
|
||||
the surface. See PhoneShell.ShowsVaultHeader.
|
||||
-->
|
||||
<Panel Grid.Row="0" IsVisible="{Binding $parent[views:PhoneShell].ShowsVaultHeader}">
|
||||
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
<!--
|
||||
◆ DeepChrome rather than Chrome, since v5. The desktop's own titlebar and nav rail made the same
|
||||
move in v5b — one step darker than Chrome, #0B0B14 against #10111E, so the frame reads as what
|
||||
holds the glass rather than as another pane of it — and this header is the phone's equivalent
|
||||
furniture: it is what the desktop's titlebar is, on the surface that has no window to carry one.
|
||||
HostActionBar, which takes this header's own place while hosts are selected, and the editor's
|
||||
header in HostsScreen, which the header stands down for, both move with it for the same reason;
|
||||
see the remark on each.
|
||||
-->
|
||||
<Border Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1" Padding="14,0" Height="56">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
@@ -288,7 +297,10 @@
|
||||
PhoneShell.ShowsShellStrip, which is where that "and" is made, Avalonia's bindings having none.
|
||||
-->
|
||||
<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">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
|
||||
@@ -297,7 +309,7 @@
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="9"
|
||||
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="10"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
@@ -308,8 +320,14 @@
|
||||
which was true when a tab could not exist without a session; one can now —
|
||||
connecting opens the tab first — and a dot that was green before anything had
|
||||
answered would be the one thing on this strip claiming something untrue.
|
||||
|
||||
Amber while it is being made, which is the other half of that correction. Not being
|
||||
green stopped the dot lying, but it left a tab still dialling drawn exactly like a
|
||||
tab whose shell has exited — the two states on this strip with the least in common,
|
||||
one worth waiting for and one over. See Phone.axaml.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||
</StackPanel>
|
||||
@@ -341,8 +359,10 @@
|
||||
shape of everything else already behind the hub. What is left is the two halves of using this
|
||||
application, and the drawer holding the rest.
|
||||
-->
|
||||
<!-- DeepChrome rather than Chrome, since v5 — see the remark on the vault header, above, which this
|
||||
bar is the foot of the same frame the header is the top of. -->
|
||||
<Border Grid.Row="3" IsVisible="{Binding $parent[views:PhoneShell].ShowsBottomBar}"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Height="64">
|
||||
<Grid ColumnDefinitions="*,*,*">
|
||||
|
||||
|
||||
@@ -36,8 +36,16 @@
|
||||
good way to read a code and a bad way to copy one: a user who cannot select it will photograph the
|
||||
screen, and that is a worse home for it than their clipboard.
|
||||
-->
|
||||
<!--
|
||||
◆ CornerRadius 12, not 6. Its padding, 14,13, is Border.card's own — this box was already drawn to a
|
||||
card's proportions, in every dimension but the corner — so under v2's ladder 6 was an outlier this
|
||||
file never explained. It is worse than unexplained now: v5's ladder gives 6 to a chip, and a
|
||||
full-width box holding the one secret this whole screen exists to show is not a chip wearing a
|
||||
card's padding by coincidence. 12 is what a card-shaped box gets on this ladder, and this one always
|
||||
was one.
|
||||
-->
|
||||
<Border Margin="0,20,0,0" Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14,13">
|
||||
BorderThickness="1" CornerRadius="12" Padding="14,13">
|
||||
<SelectableTextBlock Text="{Binding RecoveryCode}"
|
||||
FontFamily="{StaticResource MonoFont}" FontSize="14"
|
||||
Foreground="{StaticResource Accent}"
|
||||
|
||||
@@ -20,9 +20,17 @@
|
||||
<StackPanel Spacing="10" HorizontalAlignment="Center" Margin="0,48,0,36">
|
||||
<!--
|
||||
Filled rather than outlined since v2, the same mark the header, the desktop titlebar and the
|
||||
launcher icon carry. The radius is not picked: the mark runs 6 at 20 and 8 at 26, which is a
|
||||
third of a unit per unit of tile and lands on 14 at 44 — and 14 is a rung of Phone.axaml's
|
||||
ladder, the one for a block of monospaced output, which is what this is.
|
||||
launcher icon carry. The radius is not picked: the mark runs 6 at 20 and 8 at 26, a third of a
|
||||
unit per unit of tile, and lands on 14 at 44.
|
||||
|
||||
◆ That used to be a rung of Phone.axaml's own ladder too — 14, the one for a block of monospaced
|
||||
output, which this glyph reads as — and the coincidence was worth a sentence: one fewer number
|
||||
the ladder had to introduce. It is a coincidence no longer. v5's ladder moves output to 12 and
|
||||
never reaches 14 at all, so the mark's own 6/8/14 progression stands on its own now rather than
|
||||
borrowing a card- or output-shaped justification — a self-contained proportion for a badge that
|
||||
answers to nothing but its own three sizes. Left at 14 rather than moved to 12 with everything
|
||||
else: the ladder governs content surfaces, and this is a mark, sized off its own tile rather than
|
||||
off what it holds.
|
||||
-->
|
||||
<Border Width="44" Height="44" CornerRadius="14" Background="{StaticResource Accent}"
|
||||
HorizontalAlignment="Center">
|
||||
|
||||
@@ -156,8 +156,12 @@
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
|
||||
<Border Classes="output" CornerRadius="9" Padding="11,9">
|
||||
<!-- The command, on the surface every block of monospace in this design is drawn on.
|
||||
No radius override any more: it diverged from Border.output's own 14 under v2 for a
|
||||
smaller card-nested block, and now that the style's own value has moved to 12 there is
|
||||
no gap left worth a second number for — it takes the class's, like every other output
|
||||
block on this head. -->
|
||||
<Border Classes="output" Padding="11,9">
|
||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Preview}"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
@@ -174,8 +178,12 @@
|
||||
second column to put it in. Both buttons name the terminal — "TYPE INTO pg-primary" — because on a
|
||||
phone the tab strip may be scrolled away and "whatever is in the terminal receives this" is not a
|
||||
sentence anybody should have to guess the subject of.
|
||||
|
||||
DeepChrome rather than Chrome, since v5: this bar is chrome furniture in the same sense the bottom
|
||||
nav bar it sits above is — a raised strip of frame rather than a pane of content — and joins the same
|
||||
DeepChrome decision PhoneShell's header, strip and bar made. See the remark there.
|
||||
-->
|
||||
<Border Grid.Row="4" IsVisible="{Binding HasSelection}" Background="{StaticResource Chrome}"
|
||||
<Border Grid.Row="4" IsVisible="{Binding HasSelection}" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="14,12"
|
||||
IsEnabled="{Binding !IsEditing}">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
@@ -33,6 +33,85 @@
|
||||
gesture does the same thing the arrow does.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
|
||||
<!--
|
||||
── the connecting step list ─────────────────────────────────────────────────────────────────────
|
||||
The same five rows the desktop's ConnectingCard draws, from the same reported phases, in this head's
|
||||
own sizes. Kept here rather than in Phone.axaml because nothing else on this head has a step list —
|
||||
the theme file is for what more than one screen shares, and a rule that exists for one control is
|
||||
easier to read beside it.
|
||||
|
||||
Amber for the step in flight, green behind it, red where it stopped. That is the palette's rule
|
||||
rather than an exception to it: green is what is true and purple is what you can press, and a step
|
||||
still happening is neither. See ConnectingCard.axaml for the longer version of this argument, and
|
||||
Palette.axaml for the rule itself.
|
||||
|
||||
A phone needs this more than a desktop does, which is the same thing the connecting block below
|
||||
already says about itself: mobile links are slower and drop more often, so the stretch this describes
|
||||
is longer here and more likely to end badly.
|
||||
-->
|
||||
<!--
|
||||
Its own FontFamily rather than the row also carrying the mono class, which is this head's convention
|
||||
and not a stylistic preference: Phone.axaml's mono sets a colour and a size along with the family, so
|
||||
a caption wearing both classes would be asking two rules for one Foreground and settling it on style
|
||||
ordering. Every other text class here — body, label, title, detail — names its own family for exactly
|
||||
that reason. The desktop's mono sets the family alone, which is why ConnectingCard composes the two
|
||||
and this does not.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepcaption">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||
</Style>
|
||||
|
||||
<!-- Fixed width and centred: four different characters on a ragged edge is a list that looks broken. -->
|
||||
<Style Selector="TextBlock.stepmark">
|
||||
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Width" Value="13" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
4 rather than the desktop's 5, which is the only deliberate difference between the two heads here:
|
||||
this bar sits in a column 24 from each edge of a 360dp screen rather than under a 460-wide card, so
|
||||
the same height reads as a heavier rule across a narrower span.
|
||||
-->
|
||||
<Style Selector="ProgressBar.steptrack">
|
||||
<Setter Property="Height" Value="4" />
|
||||
<Setter Property="MinHeight" Value="4" />
|
||||
<Setter Property="CornerRadius" Value="2" />
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="ProgressBar.steptrack.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
</UserControl.Styles>
|
||||
|
||||
<Panel>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
@@ -49,7 +128,10 @@
|
||||
to 34, the pills to 30 — because a bar that shrank around controls that did not would only have moved
|
||||
the clipping somewhere harder to see.
|
||||
-->
|
||||
<Border Grid.Row="0" Height="35" Background="{StaticResource Chrome}"
|
||||
<!-- DeepChrome rather than Chrome, since v5: this bar is what the vault header, the strip and the
|
||||
bottom bar collapse into while a shell is showing, so it takes their surface along with their
|
||||
job — see the remark on the header in PhoneShell.axaml. -->
|
||||
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
@@ -87,15 +169,19 @@
|
||||
row where nothing sits above or below it to be hit by mistake.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="9" Height="30">
|
||||
BorderThickness="1" CornerRadius="10" Height="30">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="9"
|
||||
<Button Classes="row" MinHeight="28" Padding="11,0" CornerRadius="10"
|
||||
VerticalContentAlignment="Center"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||
<!--
|
||||
Green only while there is a shell behind it, amber while one is being made; see
|
||||
the same dot in PhoneShell, and Phone.axaml for why amber is not a rule broken.
|
||||
-->
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||
Text="{Binding Label}" />
|
||||
@@ -116,7 +202,7 @@
|
||||
It reads as a misprint, which for the control that ends a session is the wrong thing
|
||||
to look like.
|
||||
-->
|
||||
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,9,9,0"
|
||||
<Button Classes="row" MinHeight="28" Width="44" Padding="0" CornerRadius="0,10,10,0"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
|
||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
@@ -281,11 +367,70 @@
|
||||
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
||||
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Where a single unchanging "connecting…" used to be. The track counts steps that really finished
|
||||
against the five there are — StepsDone over StepCount, never a percentage, because the arithmetic
|
||||
that makes a percentage is the arithmetic that starts inventing one. See TerminalTabViewModel.
|
||||
|
||||
Drawn for both states rather than once per state: a refused connection has the same five rows and
|
||||
the same track, and the only differences are that one row is red and the track stops where it got
|
||||
to. Two templates kept identical for the sake of a colour is how the two drift apart.
|
||||
-->
|
||||
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="6" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="9">
|
||||
<TextBlock Classes="stepmark"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Mark}" />
|
||||
<TextBlock Classes="stepcaption"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Caption}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
Only for a refusal now. While a connection is being made this used to be the whole of what this
|
||||
screen said, and it is now the step list's running row said twice — so it is shown for the one
|
||||
state the list cannot put into words: why it stopped.
|
||||
-->
|
||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}"
|
||||
Foreground="{StaticResource Danger}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
|
||||
<!--
|
||||
Two 44-high targets side by side rather than one, and the second is the logs: the step list is
|
||||
this attempt and the log is every other one, which is the question a connection that is taking too
|
||||
long on a mobile link actually raises — has this machine ever worked from here. Reached the
|
||||
ordinary way, through ShowScreenCommand, exactly as the rail and MORE reach it.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Left">
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||
</Button>
|
||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
||||
<TextBlock Classes="label" FontSize="9" Text="SHOW LOGS" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
@@ -362,7 +507,7 @@
|
||||
<Border Width="1" Height="18" Background="{StaticResource Border}" Margin="0,0,3,0"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="9"
|
||||
<Button Classes="row" MinHeight="30" Height="30" MinWidth="40" Padding="0" CornerRadius="10"
|
||||
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" Focusable="False"
|
||||
@@ -371,7 +516,7 @@
|
||||
<TextBlock Classes="mono" FontSize="13" Text="A−" />
|
||||
</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"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" Focusable="False"
|
||||
|
||||
@@ -139,48 +139,18 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
{
|
||||
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
||||
|
||||
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
|
||||
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
|
||||
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
|
||||
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
|
||||
// length of the press; and the Return is posted past the current dispatch, so its ordering against
|
||||
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
|
||||
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||
|
||||
foreach (var (label, bytes, latches) in Keys)
|
||||
{
|
||||
var key = new Button
|
||||
{
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
|
||||
// 44 wide, and that is the number that matters: the design draws these flexed across the
|
||||
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
|
||||
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
|
||||
// costs nothing a width does: the keys are a single row with the terminal above and the
|
||||
// system's gesture bar below, so there is no neighbour a short press can land on instead.
|
||||
MinWidth = 44,
|
||||
Height = 30,
|
||||
Padding = new Thickness(10, 0),
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Background = Palette("Panel"),
|
||||
BorderBrush = Palette("BorderMid"),
|
||||
BorderThickness = new Thickness(1),
|
||||
Foreground = Palette("TextDim"),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
|
||||
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
||||
//
|
||||
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
||||
// ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView
|
||||
// — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing
|
||||
// Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next
|
||||
// thing typed on a hardware keyboard went nowhere, and the row went on working because its
|
||||
// buttons are pressed rather than typed into, which is what makes it look like the terminal
|
||||
// had died instead.
|
||||
//
|
||||
// Focusable=false is what a toolbar button is, and it means the focused element never
|
||||
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
||||
Focusable = false,
|
||||
};
|
||||
var key = CreateKey(label);
|
||||
|
||||
if (latches)
|
||||
{
|
||||
@@ -196,6 +166,57 @@ internal sealed partial class TerminalScreen : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One key of the accessory row, before its click is wired.</summary>
|
||||
/// <remarks>Split from <see cref="BuildAccessoryRow"/> for length rather than for reuse.</remarks>
|
||||
private static Button CreateKey(string label) =>
|
||||
new()
|
||||
{
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontFamily = (FontFamily)Application.Current!.FindResource("MonoFont")!,
|
||||
FontSize = 11,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
|
||||
// 44 wide, and that is the number that matters: the design draws these flexed across the
|
||||
// width, which at 360dp with ten keys is 32 pixels each — under every thumb-target
|
||||
// guideline there is. The height came down with the row it sits in, from 38 to 30, and it
|
||||
// costs nothing a width does: the keys are a single row with the terminal above and the
|
||||
// system's gesture bar below, so there is no neighbour a short press can land on instead.
|
||||
MinWidth = 44,
|
||||
Height = 30,
|
||||
Padding = new Thickness(10, 0),
|
||||
CornerRadius = new CornerRadius(9),
|
||||
Background = Palette("Panel"),
|
||||
BorderBrush = Palette("BorderMid"),
|
||||
BorderThickness = new Thickness(1),
|
||||
Foreground = Palette("TextDim"),
|
||||
HorizontalContentAlignment = HorizontalAlignment.Center,
|
||||
|
||||
// ◆ NOT FOCUSABLE, AND THAT IS THE WHOLE CONTROL RATHER THAN A DETAIL.
|
||||
//
|
||||
// These keys are an extension of the keyboard, not a place the keyboard should go. As
|
||||
// ordinary buttons they took Avalonia's focus on tap, which takes it off the NativeWebView
|
||||
// — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing
|
||||
// Tab or an arrow handed the terminal one byte and took the keyboard away from it: the next
|
||||
// thing typed on a hardware keyboard went nowhere, and the row went on working because its
|
||||
// buttons are pressed rather than typed into, which is what makes it look like the terminal
|
||||
// had died instead.
|
||||
//
|
||||
// Focusable=false is what a toolbar button is, and it means the focused element never
|
||||
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
||||
//
|
||||
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
|
||||
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
|
||||
// decides about its element — taking the keyboard's input connection off the terminal and
|
||||
// swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
|
||||
// that half back; see TerminalFocus for the whole story, including why the handing back has
|
||||
// to be posted rather than done inline.
|
||||
Focusable = false,
|
||||
};
|
||||
|
||||
private void ToggleControl()
|
||||
{
|
||||
controlLatched = !controlLatched;
|
||||
|
||||
@@ -336,9 +336,11 @@
|
||||
the thing the whole screen is about — a member can only be selected under one, so choosing who is the
|
||||
only half left to make.
|
||||
-->
|
||||
<!-- DeepChrome rather than Chrome, since v5 — the same raised-bar decision SnippetsScreen and
|
||||
FilesScreen make for their own selection bars; see the remark on PhoneShell's vault header. -->
|
||||
<Border Grid.Row="4"
|
||||
IsVisible="{Binding SelectedMember, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Padding="14,12">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@
|
||||
},
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||
"requested": "[10.0.11, )",
|
||||
"resolved": "10.0.11",
|
||||
"contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
|
||||
+486
-130
@@ -153,6 +153,7 @@
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
@@ -268,7 +269,26 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!-- Every button in this window is small, mono and tracked out; only the colours differ. -->
|
||||
<!--
|
||||
Every button in this window is small, mono and tracked out; only the colours differ.
|
||||
|
||||
◆ VerticalContentAlignment IS NOT VerticalAlignment, and this style used to set only the second. The
|
||||
first places the caption inside the button; the second places the button inside its parent. Avalonia's
|
||||
default for content alignment is Stretch, so on any of these given a fixed Height — the hosts toolbar's
|
||||
three at 40, and every dialog's row of them — the ContentPresenter stretched the caption's TextBlock to
|
||||
the full content box and a TextBlock draws its line at the TOP of its bounds. Measured on the hosts
|
||||
toolbar: a 40-pixel button with 9 pixels above the ink and 20 below it. Nothing was the wrong height,
|
||||
which is why this read as one — the box was right and the label sat in the top third of it.
|
||||
|
||||
Center rather than a hand-tuned Padding, because the gap is the difference between the line box and the
|
||||
content box and so moves with the font size: these carry 11.5 by default and the primary action
|
||||
overrides it to 13.5. Every other button class here already sets it — navseg, sesstab, headerghost,
|
||||
sidebarrow, fieldrow, paneicon — so this is the three shapes that were missed rather than a new idiom.
|
||||
|
||||
HorizontalContentAlignment is deliberately left alone. It is Stretch too, and it is invisible on a
|
||||
button sized to its own caption; the ones that are stretched wide state their own (the flyouts' rows
|
||||
ask for Left), and centring those from here would move text nobody complained about.
|
||||
-->
|
||||
<Style Selector="Button.ghost, Button.accent, Button.danger">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
@@ -277,6 +297,7 @@
|
||||
<Setter Property="Padding" Value="10,5" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
@@ -305,6 +326,22 @@
|
||||
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The titlebar's search pill. A plain <c>Border</c> rather than a styled <c>Button</c> template, because
|
||||
the pill sits inside a <c>Button.flat</c> that already owns the click — see <c>TitleBar.axaml</c> — and
|
||||
what changes under the pointer is the pill's own border, not a fill behind it. Declared on the parent
|
||||
button's <c>:pointerover</c> rather than the border's own, because a <c>Border</c> has no pointer state
|
||||
of its own to key a selector on.
|
||||
-->
|
||||
<Style Selector="Border.searchpill">
|
||||
<Setter Property="Background" Value="{StaticResource SearchPill}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Button.search:pointerover Border.searchpill">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Button.grouphead was here: the padding on the fold-away group heading that used to sit between the host
|
||||
cards. The headings went when the grid became cards and the group cards above it became the thing that
|
||||
@@ -331,178 +368,359 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<!--
|
||||
The nav rail. The active destination is marked with an accent bar down its left edge and a wash
|
||||
behind it, which is the design's whole idiom for "you are here" — the same two marks a selected list
|
||||
row carries, so the window has one vocabulary for selection rather than one per control.
|
||||
-->
|
||||
<!--
|
||||
A destination in the sidebar. v2 turns these from 54-pixel stacked labels into 32-pixel rows with a
|
||||
glyph, a word and a count, and the active one becomes a filled rounded row rather than a label with an
|
||||
accent bar down its left edge. The bar is gone because a filled row at this width already reads as
|
||||
chosen, and the bar was carrying that on its own when there was no room for a fill.
|
||||
The nav rail. v5b redraws it against TitleBar.dc.html's sibling NavRail.dc.html rather than against
|
||||
the v2 mock this replaces — see the comment block at the top of NavRail.axaml for what moved and why.
|
||||
The active destination is now a filled accent row rather than a wash with a bar down its edge; the
|
||||
idiom is otherwise the same one the switcher below and a selected list row already use.
|
||||
|
||||
TextGhost rather than TextFaint for the resting label: the mock computes rgb(124,127,152) for an
|
||||
inactive row, which is TextGhost's own #7C7F98 exactly, not TextFaint's paler #9C9EB4.
|
||||
-->
|
||||
<Style Selector="Button.nav">
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="Padding" Value="10,0" />
|
||||
<Setter Property="Height" Value="35" />
|
||||
<Setter Property="Padding" Value="11,8" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="LetterSpacing" Value="0.1" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav.active">
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Button.nav.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The three parts of a sidebar row. Separate classes rather than inline setters because nine rows draw
|
||||
them and the day one of the three moves is the day eight of them would not have.
|
||||
|
||||
The glyph and the label inherit the row's own foreground, so they light with it; the count does not,
|
||||
because a number that lit with its row would compete with the word beside it for the same emphasis.
|
||||
The two parts of a sidebar row: a 19-pixel Material glyph and its word, ten pixels apart. v2's third
|
||||
column — a count read off the vault — left with the row it decorated; see the remark on
|
||||
<c>Button.nav</c> above for where that number went and why it is not replaced here.
|
||||
-->
|
||||
<Style Selector="TextBlock.navicon">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="Width" Value="20" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||
<Setter Property="FontSize" Value="19" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.navlabel">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.navcount">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A terminal tab, and since v2 a pill rather than a filing-cabinet tab: 30 tall inside a 42 strip, its
|
||||
own rounded outline, and the active one filled instead of marked along an edge. The edge mark is gone
|
||||
because a pill has no edge to share with its neighbour — the gap between two of them is the divider
|
||||
the old top-and-right border was standing in for.
|
||||
The segmented SSH / SFTP / S3 switcher at the rail's head. A track the width of the rail's own content
|
||||
column, 2 pixels of padding holding three equal segments — so the three are Buttons with
|
||||
HorizontalAlignment="Stretch" inside a Grid of three equal columns, the same "buttons carry no state"
|
||||
reasoning <c>NavRail.axaml</c>'s own remarks give for every row beneath them.
|
||||
-->
|
||||
<Style Selector="Button.tab">
|
||||
<!-- Less on the right than the left: the close box lives inside the tab and brings its own margin. -->
|
||||
<Setter Property="Padding" Value="12,0,8,0" />
|
||||
<Setter Property="Height" Value="30" />
|
||||
<Setter Property="Margin" Value="0,0,6,0" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Style Selector="Border.navtrack">
|
||||
<Setter Property="Height" Value="31" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
<Setter Property="Padding" Value="2" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
◆ ORDER IS THE BEHAVIOUR HERE. The base pill comes first, then its hover, then the exceptions — and
|
||||
that ordering is the fix for a bug this arrangement had when v2 first drew it.
|
||||
|
||||
A pill paints its own Background, which the flat tab it replaced did not. That one detail moves where
|
||||
the hover has to live: `Button.flat:pointerover` is declared far above and used to supply it, and the
|
||||
moment `.tab`'s template rule set a Background of its own, the later declaration won and every tab in
|
||||
the strip lost its pointer feedback silently. The `+` lost more than that — it sits below as an
|
||||
exception, so a `.tab` rule declared after it was overriding the very thing that made it an exception,
|
||||
and it drew as a filled outlined pill contradicting the comment above it.
|
||||
|
||||
Avalonia has no specificity; the later declaration wins. The same trap is recorded further down this
|
||||
file for Border.rowmark. Anything added below must be an exception to what is above it, never a
|
||||
restatement of the base.
|
||||
-->
|
||||
<Style Selector="Button.tab /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="Background" Value="{StaticResource Panel}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Style Selector="Button.navseg">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Height" Value="27" />
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="LetterSpacing" Value="0.1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
<Style Selector="Button.navseg /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
</Style>
|
||||
<Style Selector="Button.navseg.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
One of the strip's three fixed tabs — Vaults, SFTP, S3. A pill in every respect except that it has no
|
||||
close box, so it takes its padding back on the right: the base rule is short there to leave room for
|
||||
the cross a terminal tab carries inside itself, and a fixed tab with the same asymmetry sits visibly
|
||||
off-centre beside one that has a reason for it.
|
||||
|
||||
Nothing else differs, deliberately. These are tabs and have to read as tabs — the whole point of the
|
||||
strip is that "where the window is" is one row of one kind of control.
|
||||
The rail's foot: the user chip that opens the popover. Flat until the pointer arrives, the same idiom
|
||||
every other row in this rail follows, at its own height and radius because it is a chip rather than a
|
||||
destination — see <c>NavRail.axaml</c> for what it opens and why a <c>Flyout</c> is safe here.
|
||||
-->
|
||||
<Style Selector="Button.tab.fixed">
|
||||
<Setter Property="Padding" Value="12,0" />
|
||||
<Style Selector="Button.navuser">
|
||||
<Setter Property="Height" Value="34" />
|
||||
<Setter Property="Padding" Value="13,7" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="Button.navuser:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The button that opens a connection. A tab in every respect but the marks a tab carries: no active
|
||||
state, because it is never the thing showing, and no outline, because it is not one of the things
|
||||
being chosen between. Both of those are cleared rather than merely omitted — the base rule above sets
|
||||
a fill and a border, so an exception has to say so.
|
||||
The user popover itself: the panel the rail's chip opens, in this window's own idiom rather than the
|
||||
theme's. The shared MenuFlyoutPresenter rule further down this file gives every popup Chrome and a
|
||||
4-pixel radius, which is right for a context menu and wrong for this one — the design draws the account
|
||||
menu as a rounded card, the same radius-12 shape as every other floating surface here. Reached with
|
||||
FlyoutPresenterClasses from NavRail.axaml rather than by widening that rule, so a right-click menu two
|
||||
screens away does not quietly become a card as well.
|
||||
-->
|
||||
<Style Selector="Button.tab.plus">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Width" Value="28" />
|
||||
<Setter Property="Height" Value="28" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Style Selector="FlyoutPresenter.poppanel">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.plus /template/ ContentPresenter#PART_ContentPresenter">
|
||||
|
||||
<!--
|
||||
A row inside the user popover: a vault switch, "New vault", Settings, Preferences, Vaults, Logout. All
|
||||
six share one shape — flat, a track fill under the pointer, 8 pixels of rounding — because the popover
|
||||
draws them as one list and a row that looked different from its neighbours would read as a separator
|
||||
that is not one.
|
||||
|
||||
◆ FLAT MEANS SAYING SO, which this rule did not. It set a corner radius and a padding and left the
|
||||
Background alone, so every row wore the Fluent theme's own button fill and its border: six raised pills
|
||||
stacked in a menu, where the design draws six lines of text that light up under the pointer. The hover
|
||||
rule below was already right and was simply invisible against a fill that was there all along. Set on
|
||||
the ContentPresenter as well as on the Button, the same as Button.flat does and for the same reason —
|
||||
the theme's template binds its own brush there, and a Background set only on the control loses to it.
|
||||
-->
|
||||
<Style Selector="Button.poprow">
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Padding" Value="11,4" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="MinHeight" Value="20" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="Background" Value="{StaticResource Hover}" />
|
||||
<Style Selector="Button.poprow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.tab.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Style Selector="Button.poprow:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The two halves of the Vaults tab: the tab itself, and the caret that opens its menu. Two buttons
|
||||
because they do two things, drawn as one pill because they are one destination — so the pair meets in
|
||||
the middle with no gap, no doubled border down the join, and the outer corners rounded as any tab's
|
||||
are.
|
||||
|
||||
Both the Button and its ContentPresenter carry a CornerRadius above, so both have to be squared here:
|
||||
setting only one leaves a rounded outline inside a square hit area, which shows as a hairline of the
|
||||
strip's background cutting through the join.
|
||||
|
||||
After Button.tab.fixed rather than beside it, because .caret takes that rule's padding back to zero
|
||||
and Avalonia has no specificity — the later declaration is the one that wins. See the ordering note
|
||||
above.
|
||||
The check square beside a shown vault in the popover — magenta rather than the accent, because the
|
||||
accent already means "press this" everywhere else in the window and a vault switch is a fact, not an
|
||||
action waiting to be taken. See Palette.axaml's remark on Magenta.
|
||||
-->
|
||||
<Style Selector="Button.tab.split">
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="8,0,0,8" />
|
||||
<Style Selector="Border.vaultcheck">
|
||||
<Setter Property="Width" Value="12" />
|
||||
<Setter Property="Height" Value="12" />
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
<Setter Property="Background" Value="{StaticResource Magenta}" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.split /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="8,0,0,8" />
|
||||
<Setter Property="BorderThickness" Value="1,1,0,1" />
|
||||
|
||||
<!--
|
||||
── v5b: SESSION TAB ROW ─────────────────────────────────────────────────────────────────────────────
|
||||
The tab strip's replacement, per <c>Terminal.dc.html</c>/<c>SFTP.dc.html</c>: rounded only at the top,
|
||||
fused visually to the bordered container it sits above rather than a free-floating pill — see
|
||||
<c>SessionTabRow.axaml</c>. It went from a 42-pixel strip spanning the window to a 38-pixel row inside
|
||||
each of the two screens that carry one, which is why the layout budget in <c>LayoutHarness</c> no
|
||||
longer subtracts a tab strip's height from every screen: only these two now pay it, out of their own
|
||||
26-pixel padded column rather than out of the window's own chrome.
|
||||
|
||||
◆ ORDER IS THE BEHAVIOUR HERE, as it was for the pill this replaces. The base tab comes first, then its
|
||||
hover, then <c>.active</c> — each later rule is an exception to what came before it, because Avalonia
|
||||
has no specificity and settles two rules matching one element by declaration order alone.
|
||||
-->
|
||||
<Style Selector="Button.sesstab">
|
||||
<Setter Property="Padding" Value="16,10,12,10" />
|
||||
<Setter Property="Height" Value="38" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="VerticalAlignment" Value="Bottom" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="9,9,0,0" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="13.5" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.caret">
|
||||
<Style Selector="Button.sesstab /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="9,9,0,0" />
|
||||
</Style>
|
||||
<Style Selector="Button.sesstab:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The tab whose pane the container below is showing: DeepChrome body — the same value the container's
|
||||
own host header and status bar are painted in, which is what fuses the two into one shape rather than
|
||||
a tab floating above a separate box — and a 2px top border in one of two colours. Which colour is set
|
||||
by the row's own <c>Classes="sftp"</c>, from the usage site in <c>MainWindow.axaml</c>: unmarked is the
|
||||
terminal row's <c>TerminalTabAccent</c>, and <c>.sftp</c> is the SFTP row's <c>Magenta</c> — see the
|
||||
remark on both keys in <c>Palette.axaml</c>. Marked on <c>TerminalTabViewModel.IsSelected</c> rather
|
||||
than <c>IsShowing</c>, unlike the strip this replaces: the SFTP row's own active tab is not "the pane
|
||||
the terminal surface is showing" at all, so the narrower flag would leave it permanently dark. See
|
||||
<c>MainWindowViewModel.SelectFilesHostCommand</c>.
|
||||
-->
|
||||
<Style Selector="Button.sesstab.active">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
</Style>
|
||||
<Style Selector="Button.sesstab.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource DeepChrome}" />
|
||||
<Setter Property="BorderThickness" Value="0,2,0,0" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
|
||||
</Style>
|
||||
<Style Selector=".sftp Button.sesstab.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Magenta}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The button that opens a connection, reusing the strip's own shape rather than a new one: no active
|
||||
state, because it is never the thing showing, and no outline, because it is not one of the things
|
||||
being chosen between.
|
||||
-->
|
||||
<Style Selector="Button.sesstab.plus">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0,8,8,0" />
|
||||
<Setter Property="Width" Value="28" />
|
||||
<Setter Property="Height" Value="28" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.caret /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="0,8,8,0" />
|
||||
<Style Selector="Button.sesstab.plus /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.sesstab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5b: THE "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ─────────────────────────────────────────────
|
||||
A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c>
|
||||
rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it
|
||||
no filled hover, only the border turning to the accent.
|
||||
|
||||
Named for the host header it was drawn for, which v5c-4 retired; the button itself moved intact to the
|
||||
head of the session sidebar and is stretched across that column there. See <c>SessionSidebar.axaml</c>.
|
||||
-->
|
||||
<Style Selector="Button.headerghost">
|
||||
<Setter Property="Height" Value="32" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="16,0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="Button.headerghost /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderHover}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
</Style>
|
||||
<Style Selector="Button.headerghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5b: THE SESSION SIDEBAR'S ROWS ──────────────────────────────────────────────────────────────────
|
||||
QUICK ACCESS's pins and, on the terminal surface, SNIPS — both h33, radius 9, a glyph and a mono
|
||||
label, flat until the pointer finds them. See <c>SessionSidebar.axaml</c>.
|
||||
-->
|
||||
<Style Selector="Button.sidebarrow">
|
||||
<Setter Property="Height" Value="33" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="12,8" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
</Style>
|
||||
<Style Selector="Button.sidebarrow /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
</Style>
|
||||
<Style Selector="Button.sidebarrow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5c-4: THE SIDEBAR'S OWN CLOSE AND REOPEN ────────────────────────────────────────────────────────
|
||||
One class for both, because they are one control in two states — a 26-pixel square carrying a chevron,
|
||||
at the head of the column when it is open and at the head of the rail when it is not. Square rather
|
||||
than the 33-tall rows below it: it is chrome belonging to the panel, not an entry in the list the panel
|
||||
is holding, and matching the rows' shape would have offered it as one.
|
||||
-->
|
||||
<Style Selector="Button.sidebargrip">
|
||||
<Setter Property="Width" Value="26" />
|
||||
<Setter Property="Height" Value="26" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.sidebargrip /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.sidebargrip:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The "+ Pin folder" / "+ Add Snip" row at the foot of each section: 30 tall rather than 33, and its own
|
||||
quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections.
|
||||
-->
|
||||
<Style Selector="Button.sidebaradd">
|
||||
<Setter Property="Height" Value="30" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Padding" Value="12,8" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
<Setter Property="FontSize" Value="11.5" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.sidebaradd /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="9" />
|
||||
</Style>
|
||||
<Style Selector="Button.sidebaradd:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
@@ -512,6 +730,7 @@
|
||||
-->
|
||||
<Style Selector="Button.choice">
|
||||
<Setter Property="Padding" Value="10,5" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="LetterSpacing" Value="0.5" />
|
||||
@@ -661,23 +880,63 @@
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5b: THE SFTP PANES' OWN FILE ROWS ───────────────────────────────────────────────────────────────
|
||||
A third list shape, on the same reasoning A LIST OF TILES gives above it: the design draws a file row
|
||||
with 7-pixel rounded corners and a single fill — Track, not Hover or AccentWash — for both hover and
|
||||
selected, and neither the accent wash nor a square-cornered highlight peeking out from behind a rounded
|
||||
row is the mock's own choice. See TransfersScreen.axaml's local and remote listings.
|
||||
|
||||
No selected+unhovered accent strip either, unlike Border.rowmark's own rows: the design has nothing to
|
||||
distinguish "selected" from "hovered" beyond which one is true at the moment, and :selected alone
|
||||
already answers that once the pointer has moved on — a strip drawn on top would be a second, redundant
|
||||
mark for the one state this list bothers to keep after the pointer leaves.
|
||||
|
||||
Declared after ListBox's own base rules, for the reason recorded three times over already in this file:
|
||||
Avalonia has no specificity, and an exception declared above the rule it excepts does nothing at all.
|
||||
-->
|
||||
<Style Selector="ListBox.filerows > ListBoxItem /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.filerows > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.filerows > ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.filerows > ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The card itself. A fixed width and a free height, which is the pair that makes a WrapPanel of these
|
||||
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
|
||||
rather than narrower.
|
||||
|
||||
◆ 224 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
||||
column at the window's minimum is 1016 less the rail's 190 and the drawer's 320, which is 506. The
|
||||
scrolling stack inside it takes 16 of margin on each side, and the vertical scrollbar takes its own —
|
||||
call the usable width 474. A WrapPanel fits floor(474 / (Width + 10)) per row, so two columns needs
|
||||
Width no more than 227.
|
||||
◆ 214 IS DERIVED, and the arithmetic is written out because getting it wrong is invisible. The grid's
|
||||
column at the window's minimum is 1081 less the rail's 255 and the drawer's 320, which is 506. The
|
||||
scrolling stack inside it takes 26 of margin on each side, and the vertical scrollbar takes its own —
|
||||
call the usable width 454. A WrapPanel fits floor(454 / (Width + 10)) per row, so two columns needs
|
||||
Width no more than 217, and 214 is that with the same few pixels of slack the previous number kept.
|
||||
|
||||
The first number here was 248, from the same reasoning with the two margins left out. It laid out
|
||||
cleanly and the layout harness passed it, because the harness asks whether a control is inside the
|
||||
window and not how many of them fit on a line — so the grid quietly became one column wide at exactly
|
||||
the size this application guarantees, which is the shape the cards exist to avoid. The second was 232,
|
||||
derived the same way against the drawer's own 304; v5 widened the drawer to 320 for the ADDRESS field's
|
||||
breathing room, which narrowed the budget this number is drawn from and had to move it down in step.
|
||||
Every number here has moved at least once, and always because something beside the cards did:
|
||||
|
||||
· 248, from this reasoning with the two margins left out. It laid out cleanly and the layout harness
|
||||
passed it, because the harness asks whether a control is inside the window and not how many of them
|
||||
fit on a line — so the grid quietly became one column wide at exactly the size this application
|
||||
guarantees, which is the shape the cards exist to avoid.
|
||||
· 232, derived against the drawer's own 304, which v5 widened to 320 for the ADDRESS field's breathing
|
||||
room — narrowing the budget this number is drawn from and moving it down in step.
|
||||
· 224, which is what that gave. The stated arithmetic still said 1016 and 190 by then: v5b's rail took
|
||||
190 to 255 and the window's minimum 1016 to 1081 in the same pass, so the two changes cancelled and
|
||||
the answer stayed right while the working went stale.
|
||||
· 214, now that HostsScreen's board is inset 26 a side rather than 16 — see that file's own remark on
|
||||
why every screen frames its content the same way. Twenty pixels of board is twenty pixels the cards
|
||||
no longer have, and this is where they come from.
|
||||
|
||||
◆ THE TEST THAT CATCHES THIS IS NOT THE HARNESS. See
|
||||
ScreenLayoutTests.TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen, which counts columns
|
||||
because that is the thing this number exists to buy and the thing no fit assertion can see.
|
||||
-->
|
||||
<Style Selector="Border.tile">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
@@ -685,7 +944,7 @@
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="12,10" />
|
||||
<Setter Property="Width" Value="224" />
|
||||
<Setter Property="Width" Value="214" />
|
||||
<Setter Property="Margin" Value="0,0,10,10" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover Border.tile">
|
||||
@@ -914,6 +1173,42 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5b: THE SFTP PANE HEADERS' QUIET GLYPHS ─────────────────────────────────────────────────────────
|
||||
Two variants of the icon-only button above, for the two places wave C's SFTP restyle draws one that is
|
||||
not the drawer's own chrome: a pane's UP/REFRESH/DELETE affordances — see TransfersScreen.axaml, whose
|
||||
remote pane keeps DELETE's own destructive colour rather than borrowing the plain one — and a pane's
|
||||
drive picker, which is a chip rather than a square icon and so borrows Border.chip's own geometry
|
||||
instead, for the reason Button.chiptoggle borrows it too.
|
||||
-->
|
||||
<Style Selector="Button.paneicon.danger">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
<Style Selector="Button.paneicon.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource DangerWash}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.panechip">
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.panechip /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
</Style>
|
||||
<Style Selector="Button.panechip:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The status dot, in one place rather than as a converter in code.
|
||||
|
||||
@@ -942,6 +1237,16 @@
|
||||
<Setter Property="Fill" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Amber, and it does not contradict the rule above it. Green is what is true and this is not yet true;
|
||||
purple is what you can press and a dot is not pressable. What is left is the caveat colour, which is
|
||||
exactly what a connection still being made is. The same amber the connecting card's track uses, from
|
||||
the same brush, so that the tab and the card the tab opens agree.
|
||||
-->
|
||||
<Style Selector="Ellipse.dot.connecting">
|
||||
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The accent strip a selected row carries, drawn by the row template rather than by the item, because the
|
||||
item's presenter is the thing the theme keeps repainting.
|
||||
@@ -1059,6 +1364,57 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5c ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Settings mode's own furniture: a page title, a tracked section label, a card group and the rows inside
|
||||
it. Named apart from Border.section and TextBlock.sectionlabel above rather than reusing them — those
|
||||
are the hosts drawer's 320-pixel column, at that column's own padding and heading size, and the
|
||||
settings pages are a 1100-pixel one at the numbers Settings-General.dc.html and its three siblings all
|
||||
state identically: 12-radius cards on Raised with a 1px Border inset, 20/24 row padding, a 1px
|
||||
BorderSubtle divider between rows and none under the last one.
|
||||
-->
|
||||
<Style Selector="TextBlock.settingstitle">
|
||||
<Setter Property="FontSize" Value="33" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="LetterSpacing" Value="-0.5" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.settingssection">
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LetterSpacing" Value="1.2" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
<Setter Property="Margin" Value="0,32,0,12" />
|
||||
</Style>
|
||||
<Style Selector="Border.settingscard">
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="Border.settingsrow">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Padding" Value="24,20" />
|
||||
</Style>
|
||||
<Style Selector="Border.settingsrow.last">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.settingsrowtitle">
|
||||
<Setter Property="FontSize" Value="14.5" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="LetterSpacing" Value="-0.2" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.settingsrowcaption">
|
||||
<Setter Property="FontSize" Value="12.5" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="LineHeight" Value="19.4" />
|
||||
<Setter Property="MaxWidth" Value="640" />
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
</Style>
|
||||
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
# Regenerates dodossh.ico from the same geometry the Android launcher icon draws.
|
||||
# Regenerates dodossh.ico and dodossh.icns from the same geometry the Android launcher icon draws.
|
||||
#
|
||||
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
|
||||
# reason it is a vector is that there is then one geometry to change and no set of PNG densities
|
||||
# to forget one of. Windows will not take a vector: <ApplicationIcon> wants an .ico and nothing
|
||||
# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays
|
||||
# honest: the numbers below are the ones in that XML, and regenerating is the whole edit.
|
||||
# to forget one of. Neither desktop platform will take a vector: <ApplicationIcon> wants an .ico
|
||||
# and nothing else, Window.Icon wants a bitmap, and vpk wants an .icns for the macOS bundle. So
|
||||
# the rasters exist, and this script is how they stay honest: the numbers below are the ones in
|
||||
# that XML, and regenerating is the whole edit.
|
||||
#
|
||||
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
|
||||
#
|
||||
# Both outputs are written every run, deliberately. Two scripts, or one script with a switch,
|
||||
# is how the two files come to be drawn from different geometry — which nobody would notice,
|
||||
# because no one person looks at a Windows taskbar and a macOS Dock on the same afternoon.
|
||||
#
|
||||
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
|
||||
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
|
||||
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
|
||||
@@ -22,37 +27,57 @@ Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
|
||||
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5B8CFF') # dodo_accent / AccentColor
|
||||
$ink = [System.Drawing.ColorTranslator]::FromHtml('#0E1220') # AccentInk
|
||||
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5D42DE') # dodo_accent / AccentColor
|
||||
$ink = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') # AccentInk
|
||||
|
||||
# Every size Windows asks for: 16 in a titlebar and a tree, 32 on the desktop, 48 in a large-icon
|
||||
# view, 256 for the preview pane. Shipping fewer means Windows downsamples one of these to fill
|
||||
# the gap, and its downsampler is not kind to a hairline.
|
||||
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||
|
||||
function New-MarkPng([int]$size)
|
||||
# $tileFraction is how much of the canvas the accent tile fills, and it is the one number that
|
||||
# differs between the two platforms.
|
||||
#
|
||||
# Windows passes 1.0: the tile bleeds to the edge, because Windows draws application icons at
|
||||
# whatever size they come in and every other icon on the taskbar does the same.
|
||||
#
|
||||
# macOS passes 0.8047, and that is not taste. Apple's icon grid puts a rounded-rect app icon in
|
||||
# an 824-pixel square inside a 1024-pixel canvas — 824/1024 — with the remaining hundred pixels a
|
||||
# side left as air for the Dock's shadow and its magnification. An icon that ignores the grid and
|
||||
# bleeds to the edge does not read as bold; it reads as the one icon in the Dock that is too big,
|
||||
# because it sits beside Finder and Safari which do not.
|
||||
function New-MarkPng([int]$size, [double]$tileFraction = 1.0)
|
||||
{
|
||||
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||
$g = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||
|
||||
# The tile, and the inset that centres it when it does not fill the canvas.
|
||||
$tile = [double]$size * $tileFraction
|
||||
$inset = ([double]$size - $tile) / 2.0
|
||||
|
||||
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
|
||||
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
|
||||
$radius = [double]$size * 0.22
|
||||
#
|
||||
# 0.22 of the tile rather than of the canvas, so the corner keeps its proportion to the shape
|
||||
# it is rounding instead of growing as the air around it does. It is also within a whisker of
|
||||
# the 185/824 Apple's own grid specifies, which is why one radius serves both files.
|
||||
$radius = $tile * 0.22
|
||||
$d = $radius * 2.0
|
||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||
$path.AddArc(0.0, 0.0, $d, $d, 180, 90)
|
||||
$path.AddArc($size - $d, 0.0, $d, $d, 270, 90)
|
||||
$path.AddArc($size - $d, $size - $d, $d, $d, 0, 90)
|
||||
$path.AddArc(0.0, $size - $d, $d, $d, 90, 90)
|
||||
$path.AddArc($inset, $inset, $d, $d, 180, 90)
|
||||
$path.AddArc($inset + $tile - $d, $inset, $d, $d, 270, 90)
|
||||
$path.AddArc($inset + $tile - $d, $inset + $tile - $d, $d, $d, 0, 90)
|
||||
$path.AddArc($inset, $inset + $tile - $d, $d, $d, 90, 90)
|
||||
$path.CloseFigure()
|
||||
$brush = New-Object System.Drawing.SolidBrush($accent)
|
||||
$g.FillPath($brush, $path)
|
||||
|
||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge.
|
||||
$scale = [double]$size / 72.0
|
||||
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) }
|
||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge. Scaled to the tile and
|
||||
# offset by the inset, so the glyph keeps its place within the tile at either fraction.
|
||||
$scale = $tile / 72.0
|
||||
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale + $inset), (($y - 18.0) * $scale + $inset)) }
|
||||
|
||||
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
|
||||
# difference between a mark and a smudge. The phone's file already bumps this width for the
|
||||
@@ -117,3 +142,86 @@ $target = Join-Path $PSScriptRoot 'dodossh.ico'
|
||||
$w.Dispose(); $out.Dispose()
|
||||
|
||||
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
|
||||
|
||||
# ---- dodossh.icns, for the macOS bundle ----------------------------------------------------------
|
||||
#
|
||||
# Written here rather than by `iconutil` on a Mac, and that is the point of doing it the long way.
|
||||
# iconutil is the documented tool and it exists only on macOS, so an icon that needed it could not
|
||||
# be regenerated on the machine this project is developed on — the geometry above would change and
|
||||
# the .icns would quietly keep the old mark until somebody next opened a Mac. The container format
|
||||
# is a magic word, a length and a run of typed PNG chunks, which is little enough to own.
|
||||
#
|
||||
# ◆ EVERY LENGTH IN THIS FILE IS BIG-ENDIAN, AND BinaryWriter IS NOT.
|
||||
#
|
||||
# The one thing that will catch anybody editing this. A .icns written little-endian is not rejected
|
||||
# with an error — Finder and vpk both just show the placeholder icon, because the first chunk claims
|
||||
# a length of about two billion and the parser walks off the end and gives up. Hence Write-BE32.
|
||||
#
|
||||
# Type codes are Apple's, and the pairs are not redundant. ic08 and ic13 are both 256 pixels because
|
||||
# one is "256 at 1x" and the other is "128 at 2x", and a Retina display asked for the second will not
|
||||
# accept the first. Same for ic09/ic14 at 512. iconutil emits both from an .iconset for this reason,
|
||||
# so this does too.
|
||||
$icnsTypes = @(
|
||||
@{ Type = 'ic11'; Size = 32 } # 16@2x
|
||||
@{ Type = 'ic12'; Size = 64 } # 32@2x
|
||||
@{ Type = 'ic07'; Size = 128 } # 128@1x
|
||||
@{ Type = 'ic13'; Size = 256 } # 128@2x
|
||||
@{ Type = 'ic08'; Size = 256 } # 256@1x
|
||||
@{ Type = 'ic14'; Size = 512 } # 256@2x
|
||||
@{ Type = 'ic09'; Size = 512 } # 512@1x
|
||||
@{ Type = 'ic10'; Size = 1024 } # 512@2x
|
||||
)
|
||||
|
||||
# Apple's icon grid: an 824-pixel shape centred in a 1024-pixel canvas. See New-MarkPng.
|
||||
$macTileFraction = 824.0 / 1024.0
|
||||
|
||||
# Rendered once per distinct pixel size rather than once per type code, so the two 256s and the two
|
||||
# 512s are byte-identical and the file does not carry the same image twice over at different
|
||||
# compression. It also halves the drawing, which at 1024 is not nothing.
|
||||
$rendered = @{}
|
||||
foreach ($size in ($icnsTypes.Size | Sort-Object -Unique))
|
||||
{
|
||||
[byte[]]$png = New-MarkPng $size $macTileFraction
|
||||
$rendered[$size] = $png
|
||||
}
|
||||
|
||||
$icns = New-Object System.IO.MemoryStream
|
||||
|
||||
function Write-BE32([System.IO.Stream]$stream, [uint32]$value)
|
||||
{
|
||||
$bytes = [System.BitConverter]::GetBytes($value)
|
||||
if ([System.BitConverter]::IsLittleEndian) { [array]::Reverse($bytes) }
|
||||
$stream.Write($bytes, 0, 4)
|
||||
}
|
||||
|
||||
function Write-Ascii([System.IO.Stream]$stream, [string]$text)
|
||||
{
|
||||
$bytes = [System.Text.Encoding]::ASCII.GetBytes($text)
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
}
|
||||
|
||||
# The header's length field covers the whole file including the header, so it is written last —
|
||||
# eight bytes of nothing now, seeked back to and filled in once the total is known.
|
||||
Write-Ascii $icns 'icns'
|
||||
Write-BE32 $icns 0
|
||||
|
||||
foreach ($entry in $icnsTypes)
|
||||
{
|
||||
$payload = $rendered[$entry.Size]
|
||||
Write-Ascii $icns $entry.Type
|
||||
|
||||
# Length includes this chunk's own eight-byte header, which is the off-by-eight everybody
|
||||
# writes once.
|
||||
Write-BE32 $icns ([uint32]($payload.Length + 8))
|
||||
$icns.Write($payload, 0, $payload.Length)
|
||||
}
|
||||
|
||||
$total = [uint32]$icns.Length
|
||||
$icns.Position = 4
|
||||
Write-BE32 $icns $total
|
||||
|
||||
$icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
|
||||
[System.IO.File]::WriteAllBytes($icnsTarget, $icns.ToArray())
|
||||
$icns.Dispose()
|
||||
|
||||
Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.6 KiB |
@@ -66,6 +66,21 @@
|
||||
-->
|
||||
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
|
||||
|
||||
<!--
|
||||
For the macOS keychain interop in Platform/, and for nothing else.
|
||||
|
||||
Set on this project rather than in Directory.Build.props deliberately. The frameworks that hold a
|
||||
Secure Enclave key take CFDictionaries of raw pointers, so building one means pinning arrays and
|
||||
taking their addresses — see MacDeviceKeyStore. Every other project here is managed code with no
|
||||
business doing that, and a solution-wide flag would quietly permit it everywhere, including in the
|
||||
crypto project where a stray pointer is the last thing anybody wants to have been allowed.
|
||||
|
||||
The alternative — GCHandle.Alloc with GCHandleType.Pinned — needs no flag and was considered. It
|
||||
would replace each `fixed` with an allocate/free pair that has to be balanced by hand across the
|
||||
early returns those methods are full of, which trades a compiler-checked scope for a manual one.
|
||||
-->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
|
||||
<!--
|
||||
False here, unlike every server project. The root Directory.Build.props sets it true because
|
||||
the API is container-hosted, UTC-only and has no business formatting anything for a human.
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Session;
|
||||
using static DodoSSH.Client.App.Platform.MacSecurity;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the device key encrypted to a Secure Enclave key whose use requires the user's presence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The macOS counterpart of <see cref="WindowsDeviceKeyStore"/>, and the same argument holds it up:
|
||||
/// <b>the consent is enforced by the platform, not by this class</b>. The unwrapping key is generated
|
||||
/// inside the Secure Enclave and never leaves it — there is no code path, privileged or otherwise, that
|
||||
/// turns it into bytes — and it is created under an access control requiring
|
||||
/// <see cref="AccessControlFlags.UserPresence"/>, so Touch ID or the login password is a condition of
|
||||
/// <em>using</em> it. Malware running as the user can ask for a decryption; it cannot answer the prompt,
|
||||
/// and the attempt is visible.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A store that showed its own prompt and then read a protected file would be trivially bypassed, which
|
||||
/// is the mistake ADR 0007 originally described and the Windows store's comment corrects. The correction
|
||||
/// applies here unchanged.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>P-256 and ECIES, where Windows uses RSA-OAEP, and the difference is not a preference.</b> The
|
||||
/// Secure Enclave holds exactly one kind of key: a 256-bit key on the NIST P-256 curve. It will not hold
|
||||
/// an RSA key at any size. So the wrap is <c>eciesEncryptionCofactorX963SHA256AESGCM</c> — an ephemeral
|
||||
/// agreement against the enclave's public half, X9.63-KDF to an AES-GCM key, and the ephemeral public
|
||||
/// key carried in the output. The framework does all of that; what matters here is that the input is 32
|
||||
/// bytes and there is no size limit worth worrying about.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Sealing is silent and unsealing prompts, which is better than the Windows shape rather than merely
|
||||
/// different.</b> On Windows, <c>CngKey.Create</c> with <c>ProtectKey</c> raises a dialog at creation as
|
||||
/// well, because the policy means "protect this key with a PIN" and Windows sets that up there and then.
|
||||
/// Here <see cref="SecKeyCopyPublicKey"/> works on an enclave key without any prompt, so registering a
|
||||
/// device shows nothing and only unlock asks. <see cref="SaveAsync"/> is therefore not user-facing on
|
||||
/// this platform — but it is still called from where the Windows one has to be, and relying on that
|
||||
/// difference would make the shared caller platform-specific for no gain.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What this cannot be tested against, and what follows from that.</b> Every method except
|
||||
/// <see cref="IsAvailableAsync"/> and the empty case of <see cref="TryLoadAsync"/> needs an interactive
|
||||
/// login session and real enclave hardware, so none can be exercised by an automated test — the same
|
||||
/// line the Windows store draws. It also means <see cref="IsSupported"/> must probe rather than infer:
|
||||
/// see its remarks for the three ordinary machines that have no usable enclave and must degrade to the
|
||||
/// passphrase rather than fail at unlock.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("macos")]
|
||||
public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||
{
|
||||
/// <summary>
|
||||
/// The keychain tag this application's enclave key is filed under.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Versioned for the reason the Windows key name is: a future change of curve or wrap algorithm can
|
||||
/// create a new key beside the old one rather than failing to open blobs written by a previous
|
||||
/// build. A device that cannot be opened falls back to the passphrase, which is survivable — but
|
||||
/// silently, and a user would only notice their fingerprint had stopped working.
|
||||
///
|
||||
/// Prefixed with the bundle identifier because the keychain is shared across every application the
|
||||
/// user runs, unlike a CNG key name, which is scoped to the user's key store already.
|
||||
/// </remarks>
|
||||
private const string KeyTag = "dev.dodotech.dodossh.devicekey.v1";
|
||||
|
||||
/// <summary>
|
||||
/// Shown in the Touch ID prompt, so it has to read as a sentence to a person.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// macOS composes it into "DodoSSH is trying to ...", so this is a verb phrase and not a sentence of
|
||||
/// its own. The same words the Windows consent dialog uses.
|
||||
/// </remarks>
|
||||
private const string ConsentPrompt = "unlock your DodoSSH vault";
|
||||
|
||||
private readonly ClientPaths paths;
|
||||
|
||||
/// <summary>Creates the store.</summary>
|
||||
public MacDeviceKeyStore(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
this.paths = paths;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this Mac has a Secure Enclave that will hold a key for this build.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Probed by creating a throwaway key and deleting it, rather than by asking whether the hardware
|
||||
/// exists. Three ordinary situations answer "no" here and would otherwise only be discovered at the
|
||||
/// moment somebody tried to unlock:
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An Intel Mac with no T2.</b> Apple Silicon and T2 machines have an enclave; earlier Intel
|
||||
/// models do not, and there is no single attribute that says so.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A build that is not code signed.</b> Enclave key creation requires a signing identity, so
|
||||
/// every <c>dotnet run</c> and every build from an IDE fails here with a missing-entitlement error.
|
||||
/// That is the correct answer rather than a nuisance: a development build should keep asking for the
|
||||
/// passphrase, and this is what makes it do so without a platform check somewhere else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A machine with no login password set.</b> <see cref="AccessControlFlags.UserPresence"/> has
|
||||
/// nothing to demand, and the framework refuses the access control object rather than silently
|
||||
/// creating a key anybody could use.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The probe uses its own tag and no UI policy, so nothing prompts and nothing collides with the
|
||||
/// real key. It is deleted immediately; a probe key left behind would accumulate one per launch.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static bool IsSupported()
|
||||
{
|
||||
try
|
||||
{
|
||||
var probe = $"{KeyTag}.probe.{Guid.CreateVersion7():N}";
|
||||
|
||||
using var scope = new CoreFoundationScope();
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
if (!symbols.Complete)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var key = CreateEnclaveKey(scope, symbols, probe);
|
||||
|
||||
if (key == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Discarded deliberately. The question this method answers is whether the enclave will make a
|
||||
// key, and it demonstrably just did; a failure to clean the probe up afterwards leaves one
|
||||
// stray keychain item and does not make the answer no.
|
||||
_ = DeleteKey(symbols, probe);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is DllNotFoundException
|
||||
or EntryPointNotFoundException
|
||||
or BadImageFormatException)
|
||||
{
|
||||
// A macOS without these frameworks is not a thing that exists, so this is really the guard
|
||||
// for the case that does: a future release renaming or removing one of them. The answer is
|
||||
// the same as for hardware that is absent — no device key, ask for the passphrase.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||
ValueTask.FromResult(IsSupported());
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SaveAsync(
|
||||
ReadOnlyMemory<byte> devicePrivateKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sealedKey = Seal(devicePrivateKey.Span)
|
||||
?? throw new InvalidOperationException(
|
||||
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
|
||||
|
||||
paths.EnsureCreated();
|
||||
|
||||
await File.WriteAllBytesAsync(paths.DeviceKeyFile, sealedKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(paths.DeviceKeyFile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Unseal(sealedKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (File.Exists(paths.DeviceKeyFile))
|
||||
{
|
||||
File.Delete(paths.DeviceKeyFile);
|
||||
}
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
if (symbols.Complete)
|
||||
{
|
||||
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
|
||||
// and the file deleted above is the half that decides whether unlock will try at all. A key
|
||||
// left in the enclave with no ciphertext to open is inert.
|
||||
_ = DeleteKey(symbols, KeyTag);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
|
||||
/// them is the same — do not offer a device unlock.
|
||||
/// </remarks>
|
||||
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = new CoreFoundationScope();
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
if (!symbols.Complete)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Created on first use rather than at registration, so that a device key re-registered after
|
||||
// a ForgetAsync gets a key again without anything having to notice that it had gone.
|
||||
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
{
|
||||
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
|
||||
}
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var publicKey = scope.Keep(SecKeyCopyPublicKey(privateKey));
|
||||
|
||||
if (publicKey == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var plaintext = Data(scope, devicePrivateKey);
|
||||
|
||||
if (plaintext == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ciphertext = scope.Keep(
|
||||
SecKeyCreateEncryptedData(publicKey, symbols.EciesAlgorithm, plaintext, out var error));
|
||||
|
||||
scope.Keep(error);
|
||||
|
||||
return ciphertext == IntPtr.Zero ? null : ToArray(ciphertext);
|
||||
}
|
||||
catch (Exception exception) when (exception is DllNotFoundException
|
||||
or EntryPointNotFoundException
|
||||
or BadImageFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the call that prompts. Every failure becomes null, and the set is wider than it looks:
|
||||
/// the key may be gone, the user may have cancelled or let the prompt time out, the enclave may have
|
||||
/// invalidated it after the login password was reset, or the blob may predate a key that has since
|
||||
/// been replaced. None of them are distinguishable to a user and all have the same remedy, so none
|
||||
/// are worth telling apart here — see <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Blocking, and it blocks on a person. The prompt is modal to the application, so this must not run
|
||||
/// on a thread that is also expected to draw the window behind it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static byte[]? Unseal(byte[] sealedKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = new CoreFoundationScope();
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
if (!symbols.Complete)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
|
||||
|
||||
if (privateKey == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ciphertext = Data(scope, sealedKey);
|
||||
|
||||
if (ciphertext == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var plaintext = scope.Keep(
|
||||
SecKeyCreateDecryptedData(privateKey, symbols.EciesAlgorithm, ciphertext, out var error));
|
||||
|
||||
scope.Keep(error);
|
||||
|
||||
return plaintext == IntPtr.Zero ? null : ToArray(plaintext);
|
||||
}
|
||||
catch (Exception exception) when (exception is DllNotFoundException
|
||||
or EntryPointNotFoundException
|
||||
or BadImageFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a key inside the Secure Enclave, filed under <paramref name="tag"/>. Owned by the scope.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The attribute dictionary is the whole security decision, so it is worth reading rather than
|
||||
/// pattern-matching. <c>TokenID = SecureEnclave</c> is what puts the private half in hardware;
|
||||
/// without it this silently generates an ordinary software key that behaves identically in every
|
||||
/// visible way and protects nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>AccessibleWhenUnlockedThisDeviceOnly</c> rather than any of the migratable classes, because a
|
||||
/// device key that could be restored onto another machine from a backup would no longer mean "this
|
||||
/// machine". The enclave already makes that impossible; saying it as well means the intent survives
|
||||
/// a future change of storage.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>UseDataProtectionKeychain</c> is the macOS-specific one and the easiest to omit. Without it,
|
||||
/// macOS routes this to the older file-based keychain, which does not understand access control
|
||||
/// objects or the enclave, and the call fails with a parameter error that says nothing about the
|
||||
/// missing key.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static IntPtr CreateEnclaveKey(CoreFoundationScope scope, MacSymbols symbols, string tag)
|
||||
{
|
||||
var access = scope.Keep(SecAccessControlCreateWithFlags(
|
||||
IntPtr.Zero,
|
||||
symbols.AccessibleWhenUnlockedThisDeviceOnly,
|
||||
AccessControlFlags.PrivateKeyUsage | AccessControlFlags.UserPresence,
|
||||
out var accessError));
|
||||
|
||||
scope.Keep(accessError);
|
||||
|
||||
if (access == IntPtr.Zero)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var privateAttrs = Dictionary(
|
||||
scope,
|
||||
[symbols.AttrIsPermanent, symbols.AttrApplicationTag, symbols.AttrAccessControl],
|
||||
[symbols.True, TagData(scope, tag), access]);
|
||||
|
||||
if (privateAttrs == IntPtr.Zero)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var keySize = Number(scope, 256);
|
||||
|
||||
var parameters = Dictionary(
|
||||
scope,
|
||||
[
|
||||
symbols.AttrKeyType,
|
||||
symbols.AttrKeySizeInBits,
|
||||
symbols.AttrTokenId,
|
||||
symbols.UseDataProtectionKeychain,
|
||||
symbols.PrivateKeyAttrs,
|
||||
],
|
||||
[
|
||||
symbols.KeyTypeEcSecPrimeRandom,
|
||||
keySize,
|
||||
symbols.TokenIdSecureEnclave,
|
||||
symbols.True,
|
||||
privateAttrs,
|
||||
]);
|
||||
|
||||
if (parameters == IntPtr.Zero)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var key = scope.Keep(SecKeyCreateRandomKey(parameters, out var error));
|
||||
|
||||
scope.Keep(error);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks the enclave key up by tag. Owned by the scope; zero when there is none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="prompt"/> is attached here and consumed later: the lookup itself does not raise
|
||||
/// anything, because a handle to an enclave key is not a use of it. The words reach the user at the
|
||||
/// decrypt, which is the operation the access control actually guards.
|
||||
///
|
||||
/// <c>UseOperationPrompt</c> is deprecated in favour of an <c>LAContext</c>, and is used anyway. An
|
||||
/// LAContext would mean binding LocalAuthentication as well for one string, and the deprecated key
|
||||
/// still works; the day it stops, this call fails and the store degrades to the passphrase, which is
|
||||
/// the failure this whole class is built to degrade into.
|
||||
/// </remarks>
|
||||
private static IntPtr FindKey(CoreFoundationScope scope, MacSymbols symbols, string tag, string? prompt)
|
||||
{
|
||||
List<IntPtr> keys =
|
||||
[
|
||||
symbols.Class,
|
||||
symbols.AttrApplicationTag,
|
||||
symbols.AttrKeyType,
|
||||
symbols.UseDataProtectionKeychain,
|
||||
symbols.ReturnRef,
|
||||
];
|
||||
|
||||
List<IntPtr> values =
|
||||
[
|
||||
symbols.ClassKey,
|
||||
TagData(scope, tag),
|
||||
symbols.KeyTypeEcSecPrimeRandom,
|
||||
symbols.True,
|
||||
symbols.True,
|
||||
];
|
||||
|
||||
if (prompt is not null)
|
||||
{
|
||||
keys.Add(symbols.UseOperationPrompt);
|
||||
values.Add(scope.Keep(CFString(prompt)));
|
||||
}
|
||||
|
||||
var query = Dictionary(scope, [.. keys], [.. values]);
|
||||
|
||||
if (query == IntPtr.Zero)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var status = SecItemCopyMatching(query, out var result);
|
||||
|
||||
// errSecItemNotFound is the ordinary answer on a machine that has never registered a device, and
|
||||
// it is not distinguished from any other failure for the reason the class remarks give.
|
||||
return status == Success ? scope.Keep(result) : IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>Removes the key with this tag from the keychain.</summary>
|
||||
/// <returns>Whether the keychain now has no key under this tag.</returns>
|
||||
/// <remarks>
|
||||
/// <c>ItemNotFound</c> counts as success, and that is the common case rather than an edge: it is
|
||||
/// what a machine that never registered a device answers, and what the second of two
|
||||
/// <see cref="ForgetAsync"/> calls answers. Treating it as a failure would make forgetting a device
|
||||
/// twice report a problem that does not exist.
|
||||
/// </remarks>
|
||||
private static bool DeleteKey(MacSymbols symbols, string tag)
|
||||
{
|
||||
using var scope = new CoreFoundationScope();
|
||||
|
||||
var query = Dictionary(
|
||||
scope,
|
||||
[symbols.Class, symbols.AttrApplicationTag, symbols.UseDataProtectionKeychain],
|
||||
[symbols.ClassKey, TagData(scope, tag), symbols.True]);
|
||||
|
||||
if (query == IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var status = SecItemDelete(query);
|
||||
|
||||
return status is Success or ItemNotFound;
|
||||
}
|
||||
|
||||
// ---- Small CoreFoundation conveniences ---------------------------------------------------------
|
||||
|
||||
/// <remarks>
|
||||
/// The arrays are pinned for the duration of the call and not beyond it, which is correct because
|
||||
/// <c>CFDictionaryCreate</c> copies them: the dictionary retains each key and value, and never reads
|
||||
/// the arrays again.
|
||||
/// </remarks>
|
||||
private static IntPtr Dictionary(CoreFoundationScope scope, IntPtr[] keys, IntPtr[] values)
|
||||
{
|
||||
// A zero anywhere means one of the constants did not resolve or an earlier allocation failed.
|
||||
// Passing it on produces a dictionary with a null key, which CFDictionaryCreate does not reject
|
||||
// — it crashes inside the callback table instead.
|
||||
if (Array.IndexOf(keys, IntPtr.Zero) >= 0 || Array.IndexOf(values, IntPtr.Zero) >= 0)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var symbols = MacSymbols.Resolve();
|
||||
|
||||
unsafe
|
||||
{
|
||||
fixed (IntPtr* keyPtr = keys)
|
||||
fixed (IntPtr* valuePtr = values)
|
||||
{
|
||||
return scope.Keep(CFDictionaryCreate(
|
||||
IntPtr.Zero,
|
||||
(IntPtr)keyPtr,
|
||||
(IntPtr)valuePtr,
|
||||
keys.Length,
|
||||
symbols.TypeDictionaryKeyCallBacks,
|
||||
symbols.TypeDictionaryValueCallBacks));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Copies bytes into a CFData. Owned by the scope.</summary>
|
||||
/// <remarks>
|
||||
/// The pin lasts only as long as the call, which is correct: <c>CFDataCreate</c> copies, so the
|
||||
/// CFData does not reference this memory afterwards. <c>CFDataCreateWithBytesNoCopy</c> would not,
|
||||
/// and is not used for exactly that reason — it would hand the framework a pointer into the managed
|
||||
/// heap and rely on the object staying where the collector first put it.
|
||||
/// </remarks>
|
||||
private static IntPtr Data(CoreFoundationScope scope, ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* pointer = bytes)
|
||||
{
|
||||
return scope.Keep(CFDataCreate(IntPtr.Zero, (IntPtr)pointer, bytes.Length));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// UTF-8 rather than any other encoding, and it only has to be consistent with itself: the tag is an
|
||||
/// opaque blob the keychain matches byte for byte, so what matters is that a lookup encodes it the
|
||||
/// same way the creation did. It is written once, here, for exactly that reason.
|
||||
/// </remarks>
|
||||
private static IntPtr TagData(CoreFoundationScope scope, string tag) =>
|
||||
Data(scope, Encoding.UTF8.GetBytes(tag));
|
||||
|
||||
private static IntPtr Number(CoreFoundationScope scope, int value)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
return scope.Keep(CFNumberCreate(IntPtr.Zero, (nint)CFNumberIntType, (IntPtr)(&value)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds a CFString from a managed string. Owned, so the caller tracks it.</summary>
|
||||
/// <remarks>
|
||||
/// Built explicitly rather than left to the marshaller, because these calls take a
|
||||
/// <c>CFStringRef</c> and not a C string — the runtime's default marshalling would hand over a
|
||||
/// <c>char*</c>, which CoreFoundation reads as an object pointer and follows into nothing.
|
||||
/// </remarks>
|
||||
private static IntPtr CFString(string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* pointer = bytes)
|
||||
{
|
||||
// kCFStringEncodingUTF8 is 0x08000100, spelled out rather than named because it is the
|
||||
// only encoding constant this file uses.
|
||||
return CFStringCreateWithBytes(IntPtr.Zero, (IntPtr)pointer, bytes.Length, 0x08000100, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[LibraryImport(CoreFoundation)]
|
||||
private static partial IntPtr CFStringCreateWithBytes(
|
||||
IntPtr allocator,
|
||||
IntPtr bytes,
|
||||
nint numBytes,
|
||||
uint encoding,
|
||||
[MarshalAs(UnmanagedType.U1)] bool isExternalRepresentation);
|
||||
|
||||
private static byte[] ToArray(IntPtr data)
|
||||
{
|
||||
var length = (int)CFDataGetLength(data);
|
||||
var pointer = CFDataGetBytePtr(data);
|
||||
|
||||
if (length <= 0 || pointer == IntPtr.Zero)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new byte[length];
|
||||
Marshal.Copy(pointer, result, 0, length);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// The pieces of CoreFoundation and Security.framework <see cref="MacDeviceKeyStore"/> needs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Separated from the store itself because it is a different kind of code with a different kind of
|
||||
/// review: nothing here makes a decision, and everything here is a translation of a C declaration that
|
||||
/// is either right or wrong. Mixing the two would mean the security argument in
|
||||
/// <see cref="MacDeviceKeyStore"/> had to be read past two hundred lines of marshalling to find.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every Create or Copy returns an object this process owns.</b> That is CoreFoundation's Create
|
||||
/// Rule, and it is the thing here that goes wrong silently: the enclave key handle is small, so a leak
|
||||
/// shows up as nothing at all until a long-running process has done a few thousand unlocks.
|
||||
/// <see cref="CoreFoundationScope"/> exists so ownership is tracked by construction rather than by
|
||||
/// remembering, and every function below that returns a handle says whether it is owned.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The integer widths are the part worth checking against the headers rather than skimming.</b>
|
||||
/// <c>CFIndex</c>, <c>CFOptionFlags</c> and <c>CFNumberType</c> are all pointer-width on a 64-bit Mac,
|
||||
/// not 32-bit, and getting one wrong does not fail cleanly — it shifts every argument after it, so the
|
||||
/// call receives plausible rubbish and returns a parameter error that names nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("macos")]
|
||||
internal static partial class MacSecurity
|
||||
{
|
||||
internal const string SecurityFramework =
|
||||
"/System/Library/Frameworks/Security.framework/Security";
|
||||
|
||||
internal const string CoreFoundation =
|
||||
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
|
||||
|
||||
/// <summary>
|
||||
/// The access control flags <c>SecAccessControlCreateWithFlags</c> takes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>ulong</c> because the parameter is a <c>CFOptionFlags</c>, which is an <c>unsigned long</c>.
|
||||
/// Only the two flags that are used are listed; the full set is large, and copying it in would
|
||||
/// invite somebody to reach for one without reading what it does to the prompt — <c>Biometry</c>
|
||||
/// alone, for instance, leaves a Mac with no Touch ID unable to unlock at all rather than falling
|
||||
/// back to the login password.
|
||||
/// </remarks>
|
||||
[Flags]
|
||||
internal enum AccessControlFlags : ulong
|
||||
{
|
||||
/// <summary>
|
||||
/// Touch ID if the machine has it, the login password if not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The forgiving one, deliberately. <c>BiometryCurrentSet</c> would additionally invalidate the
|
||||
/// key whenever a fingerprint is added or removed, which sounds stricter and here buys nothing:
|
||||
/// this key wraps a device key whose loss already means "ask for the passphrase", so the only
|
||||
/// effect would be users being sent back to their passphrase by an unrelated Settings change
|
||||
/// they would never connect to it.
|
||||
/// </remarks>
|
||||
UserPresence = 1ul << 0,
|
||||
|
||||
/// <summary>Required for any key that lives in the Secure Enclave.</summary>
|
||||
PrivateKeyUsage = 1ul << 30,
|
||||
}
|
||||
|
||||
/// <summary>The CFNumberType code for a 32-bit int, from CFNumber.h.</summary>
|
||||
internal const long CFNumberIntType = 9;
|
||||
|
||||
/// <summary>errSecSuccess.</summary>
|
||||
internal const int Success = 0;
|
||||
|
||||
/// <summary>errSecItemNotFound, which is an answer rather than a failure.</summary>
|
||||
internal const int ItemNotFound = -25300;
|
||||
|
||||
// ---- CoreFoundation ------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Releases an owned handle.</summary>
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial void CFRelease(IntPtr handle);
|
||||
|
||||
/// <summary>Copies bytes into a new CFData. Owned.</summary>
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial IntPtr CFDataCreate(IntPtr allocator, IntPtr bytes, nint length);
|
||||
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial IntPtr CFDataGetBytePtr(IntPtr data);
|
||||
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial nint CFDataGetLength(IntPtr data);
|
||||
|
||||
/// <summary>Boxes a value as a CFNumber. Owned.</summary>
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial IntPtr CFNumberCreate(IntPtr allocator, nint theType, IntPtr valuePtr);
|
||||
|
||||
/// <summary>Builds an immutable dictionary. Owned.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The key and value arrays are passed as raw pointers to memory the caller pins, rather than as
|
||||
/// managed arrays. Source-generated interop wants an explicit element count for a marshalled array,
|
||||
/// and supplying one here would mean stating the length twice — once for the marshaller and once as
|
||||
/// <paramref name="numValues"/> — which is exactly the pair that drifts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The two callback tables are what make the dictionary retain its keys and values, which is why
|
||||
/// they are passed rather than left null: with null callbacks the dictionary stores raw pointers and
|
||||
/// keeps nothing alive, and the resulting use-after-free is intermittent by nature.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[LibraryImport(CoreFoundation)]
|
||||
internal static partial IntPtr CFDictionaryCreate(
|
||||
IntPtr allocator,
|
||||
IntPtr keys,
|
||||
IntPtr values,
|
||||
nint numValues,
|
||||
IntPtr keyCallBacks,
|
||||
IntPtr valueCallBacks);
|
||||
|
||||
// ---- Security.framework --------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Builds the access policy a Secure Enclave key is created under. Owned.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial IntPtr SecAccessControlCreateWithFlags(
|
||||
IntPtr allocator,
|
||||
IntPtr protection,
|
||||
AccessControlFlags flags,
|
||||
out IntPtr error);
|
||||
|
||||
/// <summary>Creates a key pair from an attribute dictionary. Owned.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial IntPtr SecKeyCreateRandomKey(IntPtr parameters, out IntPtr error);
|
||||
|
||||
/// <summary>The public half of a key. Owned.</summary>
|
||||
/// <remarks>
|
||||
/// Available even for an enclave key, and that asymmetry is the whole reason this design works: the
|
||||
/// public half is an ordinary key this process can hold and use, while the private half is a handle
|
||||
/// to something inside the enclave that never becomes bytes. So sealing is silent and unsealing is
|
||||
/// the thing the user is asked about.
|
||||
/// </remarks>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial IntPtr SecKeyCopyPublicKey(IntPtr key);
|
||||
|
||||
/// <summary>Encrypts with a public key. Owned.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial IntPtr SecKeyCreateEncryptedData(
|
||||
IntPtr key,
|
||||
IntPtr algorithm,
|
||||
IntPtr plaintext,
|
||||
out IntPtr error);
|
||||
|
||||
/// <summary>Decrypts with a private key, prompting for whatever guards it. Owned.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial IntPtr SecKeyCreateDecryptedData(
|
||||
IntPtr key,
|
||||
IntPtr algorithm,
|
||||
IntPtr ciphertext,
|
||||
out IntPtr error);
|
||||
|
||||
/// <summary>Finds a keychain item. The out handle is owned when the result is <see cref="Success"/>.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial int SecItemCopyMatching(IntPtr query, out IntPtr result);
|
||||
|
||||
/// <summary>Deletes every keychain item matching the query.</summary>
|
||||
[LibraryImport(SecurityFramework)]
|
||||
internal static partial int SecItemDelete(IntPtr query);
|
||||
|
||||
// ---- The framework constants ---------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Reads one of a framework's global CFString constants, or zero if it is not exported.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The keys these dictionaries take are not strings this code may spell for itself. They are
|
||||
/// pointer-comparable constants exported by the framework, and a CFString built here with the same
|
||||
/// characters is a different object — the lookups would miss and the call would fail with a
|
||||
/// parameter error naming nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Dereferenced once, because the exported symbol is the variable rather than its value.</b>
|
||||
/// <c>TryGetExport</c> answers the address of the global; the CFStringRef is what that address
|
||||
/// holds. Missing the indirection produces a pointer that is stable, plausible and wrong, which is
|
||||
/// the worst of the three available outcomes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Zero on a missing symbol rather than an exception, because the caller's answer to every failure
|
||||
/// is the same one — report the store unavailable and let unlock ask for the passphrase — and a
|
||||
/// constant that has been renamed by a future macOS should reach that answer rather than a crash.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static IntPtr Constant(IntPtr library, string symbol) =>
|
||||
NativeLibrary.TryGetExport(library, symbol, out var address)
|
||||
? Marshal.ReadIntPtr(address)
|
||||
: IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases every CoreFoundation handle put into it, in reverse order, exactly once.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The alternative is a try/finally per handle, and the operations here need six or seven at a time — a
|
||||
/// dictionary holding a nested dictionary holding an access control object holding a CFData tag. Finallys
|
||||
/// nested that deep stop being read, and a handle released twice is a crash rather than a leak.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Keep"/> returns what it was given, so a handle can be tracked in the same expression that
|
||||
/// produces it and the call sites read as ordinary code.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("macos")]
|
||||
internal sealed class CoreFoundationScope : IDisposable
|
||||
{
|
||||
private readonly List<IntPtr> owned = [];
|
||||
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>Takes ownership of a handle and hands it straight back.</summary>
|
||||
/// <remarks>
|
||||
/// Zero is ignored rather than rejected. Every CoreFoundation call here answers zero on failure, so
|
||||
/// accepting it lets a caller track the result in the expression that produces it and check it on
|
||||
/// the next line, instead of writing the check twice.
|
||||
/// </remarks>
|
||||
internal IntPtr Keep(IntPtr handle)
|
||||
{
|
||||
if (handle != IntPtr.Zero)
|
||||
{
|
||||
owned.Add(handle);
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
// Reverse order, so a container is released before the things it retains. CoreFoundation does not
|
||||
// require it — retain counts make the order irrelevant — but it keeps the lifetimes readable in a
|
||||
// debugger, where a released container that still lists its contents is a confusing thing to meet.
|
||||
for (var i = owned.Count - 1; i >= 0; i--)
|
||||
{
|
||||
MacSecurity.CFRelease(owned[i]);
|
||||
}
|
||||
|
||||
owned.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// The framework constants <see cref="MacDeviceKeyStore"/> passes to CoreFoundation and Security.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every field here is a pointer read out of a loaded framework rather than a value this code could
|
||||
/// write down. The dictionaries these go into are matched by pointer identity, so a CFString built with
|
||||
/// the same characters is a different key and the lookup misses — see <see cref="MacSecurity.Constant"/>
|
||||
/// for the indirection that trips people.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Resolved once and cached, and the caching is what makes the failure survivable.</b> Two frameworks
|
||||
/// and nineteen symbols is a lot of things to be wrong about, and the useful property is that being
|
||||
/// wrong about any one of them shows up here — as <see cref="Complete"/> being false — rather than
|
||||
/// three calls later as a parameter error. A store that reports itself unavailable sends the user back
|
||||
/// to their passphrase; a store that half works corrupts the moment somebody registers a device.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Lazy{T}"/> rather than a static constructor, because a type initialiser that throws
|
||||
/// poisons the type for the life of the process and turns a missing symbol into a
|
||||
/// <c>TypeInitializationException</c> at every later call site. The load is done inside a try instead,
|
||||
/// and its failure is a value.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("macos")]
|
||||
internal sealed class MacSymbols
|
||||
{
|
||||
private static readonly Lazy<MacSymbols> Cached = new(Load, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
private MacSymbols()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Whether every symbol resolved.</summary>
|
||||
/// <remarks>
|
||||
/// Checked by every caller before any of the pointers are used. It is one check rather than
|
||||
/// nineteen, which is the only reason the call sites in <see cref="MacDeviceKeyStore"/> are
|
||||
/// readable.
|
||||
///
|
||||
/// Computed rather than stored, so that the instance returned when a framework will not load at all
|
||||
/// — every field left at zero — answers false without that having to be set anywhere. One rule,
|
||||
/// applied to the only state there is.
|
||||
/// </remarks>
|
||||
internal bool Complete => AllResolved();
|
||||
|
||||
// CoreFoundation.
|
||||
internal IntPtr True { get; private init; }
|
||||
|
||||
internal IntPtr TypeDictionaryKeyCallBacks { get; private init; }
|
||||
|
||||
internal IntPtr TypeDictionaryValueCallBacks { get; private init; }
|
||||
|
||||
// Security: item classes and query keys.
|
||||
internal IntPtr Class { get; private init; }
|
||||
|
||||
internal IntPtr ClassKey { get; private init; }
|
||||
|
||||
internal IntPtr ReturnRef { get; private init; }
|
||||
|
||||
internal IntPtr UseDataProtectionKeychain { get; private init; }
|
||||
|
||||
internal IntPtr UseOperationPrompt { get; private init; }
|
||||
|
||||
// Security: key attributes.
|
||||
internal IntPtr AttrKeyType { get; private init; }
|
||||
|
||||
internal IntPtr AttrKeySizeInBits { get; private init; }
|
||||
|
||||
internal IntPtr AttrTokenId { get; private init; }
|
||||
|
||||
internal IntPtr AttrIsPermanent { get; private init; }
|
||||
|
||||
internal IntPtr AttrApplicationTag { get; private init; }
|
||||
|
||||
internal IntPtr AttrAccessControl { get; private init; }
|
||||
|
||||
internal IntPtr PrivateKeyAttrs { get; private init; }
|
||||
|
||||
// Security: attribute values.
|
||||
internal IntPtr KeyTypeEcSecPrimeRandom { get; private init; }
|
||||
|
||||
internal IntPtr TokenIdSecureEnclave { get; private init; }
|
||||
|
||||
internal IntPtr AccessibleWhenUnlockedThisDeviceOnly { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The one algorithm the Secure Enclave's P-256 keys support for encryption, and the reason this
|
||||
/// store wraps rather than signs. The long name spells out the whole construction: an ephemeral
|
||||
/// key agreed against the enclave's public half with cofactor ECDH, run through the X9.63 KDF with
|
||||
/// SHA-256, used as an AES-GCM key. The ephemeral public key travels in the output, which is why the
|
||||
/// ciphertext is larger than the 32 bytes going in and why nothing else has to be stored beside it.
|
||||
/// </remarks>
|
||||
internal IntPtr EciesAlgorithm { get; private init; }
|
||||
|
||||
/// <summary>The resolved symbols, loaded once.</summary>
|
||||
internal static MacSymbols Resolve() => Cached.Value;
|
||||
|
||||
private static MacSymbols Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!NativeLibrary.TryLoad(MacSecurity.CoreFoundation, out var cf)
|
||||
|| !NativeLibrary.TryLoad(MacSecurity.SecurityFramework, out var sec))
|
||||
{
|
||||
// Every pointer left at zero, which AllResolved reads as incomplete.
|
||||
return new MacSymbols();
|
||||
}
|
||||
|
||||
// The two callback tables are structs rather than object pointers, so what is wanted is the
|
||||
// address of the export itself and not what it holds. Every other symbol here is a CFTypeRef
|
||||
// global and needs the dereference; these two do not, and mixing them up produces a
|
||||
// dictionary that does not retain its contents.
|
||||
var keyCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryKeyCallBacks", out var k)
|
||||
? k
|
||||
: IntPtr.Zero;
|
||||
|
||||
var valueCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryValueCallBacks", out var v)
|
||||
? v
|
||||
: IntPtr.Zero;
|
||||
|
||||
return new MacSymbols
|
||||
{
|
||||
True = MacSecurity.Constant(cf, "kCFBooleanTrue"),
|
||||
TypeDictionaryKeyCallBacks = keyCallBacks,
|
||||
TypeDictionaryValueCallBacks = valueCallBacks,
|
||||
|
||||
Class = MacSecurity.Constant(sec, "kSecClass"),
|
||||
ClassKey = MacSecurity.Constant(sec, "kSecClassKey"),
|
||||
ReturnRef = MacSecurity.Constant(sec, "kSecReturnRef"),
|
||||
UseDataProtectionKeychain = MacSecurity.Constant(sec, "kSecUseDataProtectionKeychain"),
|
||||
UseOperationPrompt = MacSecurity.Constant(sec, "kSecUseOperationPrompt"),
|
||||
|
||||
AttrKeyType = MacSecurity.Constant(sec, "kSecAttrKeyType"),
|
||||
AttrKeySizeInBits = MacSecurity.Constant(sec, "kSecAttrKeySizeInBits"),
|
||||
AttrTokenId = MacSecurity.Constant(sec, "kSecAttrTokenID"),
|
||||
AttrIsPermanent = MacSecurity.Constant(sec, "kSecAttrIsPermanent"),
|
||||
AttrApplicationTag = MacSecurity.Constant(sec, "kSecAttrApplicationTag"),
|
||||
AttrAccessControl = MacSecurity.Constant(sec, "kSecAttrAccessControl"),
|
||||
PrivateKeyAttrs = MacSecurity.Constant(sec, "kSecPrivateKeyAttrs"),
|
||||
|
||||
KeyTypeEcSecPrimeRandom = MacSecurity.Constant(sec, "kSecAttrKeyTypeECSECPrimeRandom"),
|
||||
TokenIdSecureEnclave = MacSecurity.Constant(sec, "kSecAttrTokenIDSecureEnclave"),
|
||||
AccessibleWhenUnlockedThisDeviceOnly =
|
||||
MacSecurity.Constant(sec, "kSecAttrAccessibleWhenUnlockedThisDeviceOnly"),
|
||||
|
||||
EciesAlgorithm = MacSecurity.Constant(
|
||||
sec,
|
||||
"kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM"),
|
||||
};
|
||||
}
|
||||
catch (Exception exception) when (exception is DllNotFoundException or BadImageFormatException)
|
||||
{
|
||||
return new MacSymbols();
|
||||
}
|
||||
}
|
||||
|
||||
private bool AllResolved() =>
|
||||
True != IntPtr.Zero
|
||||
&& TypeDictionaryKeyCallBacks != IntPtr.Zero
|
||||
&& TypeDictionaryValueCallBacks != IntPtr.Zero
|
||||
&& Class != IntPtr.Zero
|
||||
&& ClassKey != IntPtr.Zero
|
||||
&& ReturnRef != IntPtr.Zero
|
||||
&& UseDataProtectionKeychain != IntPtr.Zero
|
||||
&& UseOperationPrompt != IntPtr.Zero
|
||||
&& AttrKeyType != IntPtr.Zero
|
||||
&& AttrKeySizeInBits != IntPtr.Zero
|
||||
&& AttrTokenId != IntPtr.Zero
|
||||
&& AttrIsPermanent != IntPtr.Zero
|
||||
&& AttrApplicationTag != IntPtr.Zero
|
||||
&& AttrAccessControl != IntPtr.Zero
|
||||
&& PrivateKeyAttrs != IntPtr.Zero
|
||||
&& KeyTypeEcSecPrimeRandom != IntPtr.Zero
|
||||
&& TokenIdSecureEnclave != IntPtr.Zero
|
||||
&& AccessibleWhenUnlockedThisDeviceOnly != IntPtr.Zero
|
||||
&& EciesAlgorithm != IntPtr.Zero;
|
||||
}
|
||||
@@ -31,7 +31,12 @@ internal static class UpdateChannels
|
||||
/// </remarks>
|
||||
internal static IUpdateChannel ForThisMachine()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
// Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches
|
||||
// this too. Velopack has a Linux path — AppImage — but this repository does not build one, so a
|
||||
// Linux build is a checkout somebody ran, and handing it an UpdateManager would have it poll a
|
||||
// feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future
|
||||
// AppImage an addition here rather than a thing that silently already half-happened.
|
||||
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
|
||||
{
|
||||
return new UnavailableUpdateChannel();
|
||||
}
|
||||
@@ -55,14 +60,21 @@ internal static class UpdateChannels
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Windows update channel, backed by Velopack against the project's own forge.
|
||||
/// The desktop update channel, backed by Velopack against the project's own forge.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one file in the repository that names Velopack. It lives beside <c>WindowsDeviceKeyStore</c>
|
||||
/// rather than in a project of its own because it is the same kind of thing — a Windows-only
|
||||
/// implementation of an interface declared in <c>DodoSSH.Client.Session</c> — and because
|
||||
/// <c>DodoSSH.Client.Shell</c> is shared with the Android head, which must never acquire an updater.
|
||||
/// The one file in the repository that names Velopack. It lives beside the platform key stores rather
|
||||
/// than in a project of its own because it is the same kind of thing — a desktop-only implementation of
|
||||
/// an interface declared in <c>DodoSSH.Client.Session</c> — and because <c>DodoSSH.Client.Shell</c> is
|
||||
/// shared with the Android head, which must never acquire an updater.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One class for both desktop platforms, where the key stores are one class each.</b> The difference
|
||||
/// is where the platform knowledge sits. A key store is platform knowledge from top to bottom: different
|
||||
/// hardware, different API, different failure modes. Velopack's <c>UpdateManager</c> has already absorbed
|
||||
/// all of that, and what is left over — check, download, apply, restart — is identical on the two. The
|
||||
/// only thing that differs is which string names the feed, and that is <see cref="ChannelFor"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
|
||||
@@ -103,7 +115,7 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// but unsaid on one side and stated on the other is how a feed goes quiet with no error anywhere:
|
||||
/// the check succeeds, finds nothing, and reports that the client is up to date forever.
|
||||
/// </remarks>
|
||||
private const string ReleaseChannel = "win";
|
||||
private const string WindowsReleaseChannel = "win";
|
||||
|
||||
/// <summary>
|
||||
/// The nightly channel, which is a different name rather than the same one on a different tag.
|
||||
@@ -122,7 +134,26 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// a download somebody watched. A channel each means neither ever sees the other's releases at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private const string NightlyChannel = "win-nightly";
|
||||
private const string WindowsNightlyChannel = "win-nightly";
|
||||
|
||||
/// <summary>The macOS release channel, and Velopack's own default there.</summary>
|
||||
/// <remarks>
|
||||
/// A contract with <c>scripts/release-macos.sh</c>, exactly as the Windows pair is one with the
|
||||
/// PowerShell script. Stated for the same reason, which applies with more force here: the four
|
||||
/// channels all publish to one repository, so the only thing keeping a Mac from being offered a
|
||||
/// <c>win</c> package is that it never reads that index.
|
||||
/// </remarks>
|
||||
private const string MacReleaseChannel = "osx";
|
||||
|
||||
/// <summary>The macOS nightly channel.</summary>
|
||||
/// <remarks>
|
||||
/// Named here and not yet published by anything. The CI job for the macOS head builds and bundles
|
||||
/// and deliberately uploads nothing — see the packaging step in <c>ci.yml</c> — so a nightly macOS
|
||||
/// build checking this feed finds an empty channel and reports itself up to date, which is the
|
||||
/// correct behaviour for a channel with no publisher. The name exists so that turning the publisher
|
||||
/// on later is one job rather than a job plus a rename that has to reach every installed client.
|
||||
/// </remarks>
|
||||
private const string MacNightlyChannel = "osx-nightly";
|
||||
|
||||
private readonly UpdateManager manager;
|
||||
|
||||
@@ -141,10 +172,13 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// <inheritdoc />
|
||||
public bool IsSupported => true;
|
||||
|
||||
/// <summary>Always, on this head.</summary>
|
||||
/// <summary>Always, on this head, on either platform.</summary>
|
||||
/// <remarks>
|
||||
/// Velopack's apply runs <c>Update.exe</c> over this installation and restarts it, so the process is
|
||||
/// gone by the time anything could have asked a question. The phone's is the other answer; see
|
||||
/// Velopack's apply hands off to a separate updater process — <c>Update.exe</c> on Windows, the
|
||||
/// <c>UpdateMac</c> helper inside the bundle on macOS — which replaces this installation and
|
||||
/// relaunches it, so the process is gone by the time anything could have asked a question. The
|
||||
/// mechanism differs and the answer does not, which is why this is a constant rather than another
|
||||
/// thing <see cref="ChannelFor"/> would have to decide. The phone's is the other answer; see
|
||||
/// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
|
||||
/// </remarks>
|
||||
public bool ApplyingEndsTheProcess => true;
|
||||
@@ -183,7 +217,36 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
|
||||
return new UpdateManager(
|
||||
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
|
||||
new UpdateOptions { ExplicitChannel = nightly ? NightlyChannel : ReleaseChannel });
|
||||
new UpdateOptions { ExplicitChannel = ChannelFor(nightly) });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The one of the four channel names this build belongs to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two independent axes — which platform, and which of that platform's two channels — and they are
|
||||
/// resolved in one place so that neither can be answered differently somewhere else. The platform
|
||||
/// half is the running OS rather than anything recorded in the build, because a package can only
|
||||
/// ever be applied on the platform it was built for; the channel half comes from assembly metadata,
|
||||
/// because a release build and a nightly are the same bytes on the same OS and only the metadata
|
||||
/// tells them apart.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Windows is the fallback rather than a third branch. Only Windows and macOS reach here at all —
|
||||
/// <see cref="UpdateChannels.ForThisMachine"/> is the gate — so the alternative would be an
|
||||
/// unreachable throw, and an unreachable throw in the middle of the updater is a thing somebody
|
||||
/// later has to reason about to discover it cannot happen.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static string ChannelFor(bool nightly)
|
||||
{
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return nightly ? MacNightlyChannel : MacReleaseChannel;
|
||||
}
|
||||
|
||||
return nightly ? WindowsNightlyChannel : WindowsReleaseChannel;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -9,9 +9,17 @@ namespace DodoSSH.Client.App.Platform;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
|
||||
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
|
||||
/// passphrase — which is the honest answer rather than a degraded one.
|
||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware,
|
||||
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
|
||||
/// keeps asking for the passphrase — which is the honest answer rather than a degraded one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Both real stores are asked whether they work rather than told that they do.</b> Each
|
||||
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and deleting it — because on
|
||||
/// both platforms the provider is present and reports itself present on machines where creating a key
|
||||
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, and on macOS also every
|
||||
/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would
|
||||
/// mean each of those discovering the truth at the moment somebody tried to unlock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
||||
@@ -29,9 +37,17 @@ public static class DesktopDeviceKeyStores
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()
|
||||
? new WindowsDeviceKeyStore(paths)
|
||||
: new UnavailableDeviceKeyStore();
|
||||
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new WindowsDeviceKeyStore(paths);
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS() && MacDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new MacDeviceKeyStore(paths);
|
||||
}
|
||||
|
||||
return new UnavailableDeviceKeyStore();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,28 @@
|
||||
Showing the last terminal's pane would be a lie, and showing nothing reads as the application having
|
||||
broken, so this says which machine, as whom, and how far along it is.
|
||||
|
||||
── The step list, and why it is amber ───────────────────────────────────────────────────────────────
|
||||
"How far along it is" used to be one line of prose that never changed after the tab was created, which
|
||||
made every slow connection look exactly like every hung one. It is now the five steps of actually
|
||||
getting there, each lit at the moment the handshake reports it — see SshConnectionPhase, which names
|
||||
only the boundaries a client can genuinely observe. A connection that stops therefore stops on a named
|
||||
row, and "the host key is being checked" stops being the same screen as "the host is not answering".
|
||||
|
||||
Amber for the step in flight, and that is the palette's rule rather than an exception to it. Green is
|
||||
what is true and purple is what you can press; a step still happening is neither, and it is precisely
|
||||
the caveat-worth-reading amber exists for — see the remark above Warn in Palette.axaml. Steps behind it
|
||||
go green as they become true, and the one a refusal landed on goes red. Nothing on the list is drawn in
|
||||
the accent, because there is nothing on it to press.
|
||||
|
||||
Nothing here animates, which is the argument the transfer strip makes for its own track in
|
||||
TransfersScreen.axaml, arriving at a screen with more reason to want a spinner. A spinner is furniture
|
||||
invented to fill a state nobody measured; these steps are measured, so the track fills to what has
|
||||
actually finished and then waits there. Waiting is what waiting looks like.
|
||||
|
||||
The list is drawn for both states rather than once per state. A refused connection has the same five
|
||||
rows and the same track — the difference is only that one row is red and the track stops — and drawing
|
||||
it twice would be two templates to keep identical for the sake of a colour.
|
||||
|
||||
It obeys the occlusion rule the whole window obeys: this is Avalonia-drawn content in the WebView's own
|
||||
rectangle, so the shell collapses the terminal while it is up. IsTerminalShowing and IsConnectingShowing
|
||||
are exclusive by construction — a selected tab either has a session or it does not — which is what makes
|
||||
@@ -24,8 +46,69 @@
|
||||
can be laid out by a test: WebView2's adapter refuses the headless session's thread.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
|
||||
<!--
|
||||
A rule per lit state over one quiet default, so that the pending weight is stated once and each state
|
||||
that differs from it is the one line that says how.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepcaption">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepcaption.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The marker beside each caption. Fixed width and centred, because four different characters on a
|
||||
ragged left edge is a list that looks broken; see ConnectionStepViewModel.Mark for which they are.
|
||||
-->
|
||||
<Style Selector="TextBlock.stepmark">
|
||||
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Width" Value="14" />
|
||||
<Setter Property="TextAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.stepmark.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The track over the list. Amber while the attempt is alive and red once it is not, so that the bar says
|
||||
the same thing as the row it stopped on rather than staying the colour of something still being waited
|
||||
for. Chip underneath, matching the transfer strip's track.
|
||||
-->
|
||||
<Style Selector="ProgressBar.steptrack">
|
||||
<Setter Property="Height" Value="5" />
|
||||
<Setter Property="MinHeight" Value="5" />
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
<Style Selector="ProgressBar.steptrack.stopped">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
</UserControl.Styles>
|
||||
|
||||
<Panel>
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14" MaxWidth="460"
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
||||
Margin="24">
|
||||
|
||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||
@@ -38,15 +121,43 @@
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Two states, deliberately different. Waiting is an accent line under the host's name; a refusal is
|
||||
the reason, in the palette's red, because it is the only place the reason will be after the user
|
||||
navigates away from the screen that started the connection.
|
||||
Bound to StepsDone against StepCount rather than to a percentage: five steps and a maximum of five
|
||||
means the bar is a count of things that really finished, and the arithmetic that would turn it into
|
||||
a percentage is exactly the arithmetic that would start inventing one.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="12"
|
||||
Foreground="{StaticResource Accent}" HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" TextAlignment="Center"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
||||
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
||||
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}" HorizontalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="7" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="stepmark"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Mark}" />
|
||||
<TextBlock Classes="stepcaption mono"
|
||||
Classes.done="{Binding IsDone}"
|
||||
Classes.running="{Binding IsRunning}"
|
||||
Classes.stopped="{Binding IsStopped}"
|
||||
Text="{Binding Caption}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
A refusal is the reason, in the palette's red, because it is the only place the reason will be after
|
||||
the user navigates away from the screen that started the connection. It sits under the list rather
|
||||
than replacing it: which row it stopped on is half the answer and the sentence is the other half.
|
||||
-->
|
||||
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
|
||||
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
|
||||
TextWrapping="Wrap" TextAlignment="Center"
|
||||
@@ -62,14 +173,23 @@
|
||||
handshake that finishes afterwards is adopted rather than dropped — see
|
||||
MainWindowViewModel.CloseTabAsync. Two buttons rather than one with a converted label, because the
|
||||
two are different decisions and only one of them abandons something still running.
|
||||
-->
|
||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="GIVE UP"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
|
||||
<Button Classes="ghost" HorizontalAlignment="Center" Content="CLOSE TAB"
|
||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
Beside each, the logs. The step list is this attempt and the log is every other one, which is the
|
||||
question both a connection taking too long and a connection just refused actually raise — has this
|
||||
machine ever worked. It is the ordinary rail destination reached the ordinary way rather than a
|
||||
second log grown inside this card, and leaving by it does not abandon the handshake: the tab stays
|
||||
in the strip and the card is still here on the way back.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
||||
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
|
||||
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
|
||||
CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||
<Button Classes="ghost" Content="CLOSE TAB" Command="{Binding CloseTabCommand}"
|
||||
CommandParameter="{Binding SelectedTab}"
|
||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
@@ -584,6 +584,56 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Making a credential without leaving the host. The moment one is wanted is this one: somebody
|
||||
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
||||
sending them to the other screen to add it would lose the half-typed host they are standing
|
||||
in. Same argument as the new-tag box further down, same immediate write, same honest
|
||||
consequence — the credential stays if this editor is cancelled, because a host can only name
|
||||
an id that exists.
|
||||
|
||||
A button beside the picker rather than an entry inside it. Every row of that list is a
|
||||
binding the host can have; "make a new one" is an action, and as an entry it would sit in the
|
||||
box afterwards describing a state no host can be in.
|
||||
-->
|
||||
<Button Classes="ghost" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
|
||||
FontSize="10.5" Height="28" Padding="10,0"
|
||||
IsVisible="{Binding !IsAddingEditorCredential}"
|
||||
Command="{Binding BeginEditorCredentialCommand}"
|
||||
ToolTip.Tip="Adds a credential to the keychain and binds this host to it" />
|
||||
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
|
||||
IsVisible="{Binding IsAddingEditorCredential}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="label" Text="NEW CREDENTIAL" FontSize="10" />
|
||||
<TextBox Text="{Binding EditorNewCredentialLabel}" PlaceholderText="name" Height="36" />
|
||||
<!--
|
||||
Optional, and what makes a credential worth being its own item: one account on twenty
|
||||
machines is rotated in one place. Left blank, this host's own username is used.
|
||||
-->
|
||||
<TextBox Text="{Binding EditorNewCredentialUsername}" Height="36"
|
||||
PlaceholderText="username (blank: use this host's own)" />
|
||||
<!-- Masked, on the reasoning the keychain's own password box carries. -->
|
||||
<TextBox Text="{Binding EditorNewCredentialPassword}" PlaceholderText="password"
|
||||
PasswordChar="•" Height="36">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorCredentialCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<TextBox Text="{Binding EditorNewCredentialNotes}" PlaceholderText="notes"
|
||||
AcceptsReturn="True" Height="44" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Added to the keychain as soon as you press ADD, so it stays even if you cancel this host. Renaming and deleting are on the keychain screen." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="ADD"
|
||||
Command="{Binding AddEditorCredentialCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding CancelEditorCredentialCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
|
||||
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
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="#CC0E1220">
|
||||
<Border Background="#CC05050A">
|
||||
<Panel>
|
||||
|
||||
<!-- ============ UNKNOWN HOST KEY ============ -->
|
||||
|
||||
@@ -98,6 +98,18 @@
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<!--
|
||||
── 26 DOWN EACH SIDE, the same inset Keychain, Snips, Logs and Pins all take. ──────────────────────
|
||||
Those four say it once, as Margin="26" on their own root; this screen repeats it on each of the four
|
||||
rows below, and it has to. The board's ScrollViewer is the last row and is deliberately full-bleed, so
|
||||
that its scrollbar rides the pane's own edge rather than floating 26 pixels inside it — a root margin
|
||||
would inset the bar with everything else. It would also inset the drawer in the second column, which
|
||||
draws its own edge and wants none.
|
||||
|
||||
It was 16 and 20 until this pass, which put the Hosts header a visible step left of and above every
|
||||
other screen's. Four numbers rather than one is the cost of the two exceptions above; changing one of
|
||||
them means changing all four.
|
||||
-->
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<!--
|
||||
@@ -107,7 +119,7 @@
|
||||
buttons over a board of forty is a pair whose subject the user has to work out. The group's own
|
||||
Edit/Move/Delete sit on its own heading's menu for the same reason.
|
||||
-->
|
||||
<Grid Grid.Row="0" Margin="16,20,16,16" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
|
||||
<Grid Grid.Row="0" Margin="26,26,26,16" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto">
|
||||
|
||||
<TextBlock Grid.Column="0" Text="Hosts" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
@@ -219,7 +231,7 @@
|
||||
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
|
||||
who has found this box should know about the one that also connects on Enter.
|
||||
-->
|
||||
<Border Grid.Row="1" Margin="16,0,16,16">
|
||||
<Border Grid.Row="1" Margin="26,0,26,16">
|
||||
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
|
||||
@@ -232,7 +244,7 @@
|
||||
one of them sits here, above the board, rather than laid over it: a card over the cards would hide
|
||||
the very ticks or the very group it is asking about.
|
||||
-->
|
||||
<StackPanel Grid.Row="2" Margin="16,0,16,12" Spacing="10">
|
||||
<StackPanel Grid.Row="2" Margin="26,0,26,12" Spacing="10">
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
|
||||
@@ -434,7 +446,7 @@
|
||||
HostsScreen.axaml.cs.
|
||||
-->
|
||||
<ScrollViewer Grid.Row="3" x:Name="Scroll" HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel Margin="16,0,16,20" Spacing="16">
|
||||
<StackPanel Margin="26,0,26,26" Spacing="16">
|
||||
|
||||
<!--
|
||||
Named because it is where keyboard focus lands when the terminal gives it back, and because
|
||||
@@ -690,7 +702,7 @@
|
||||
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
|
||||
IsVisible="{Binding HasPins}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="3">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
||||
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
||||
<TextBlock Classes="mono" Text="{Binding PinCount}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
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.
|
||||
|
||||
Reachable from the preferences screen and not from the nav rail. It is a task rather than a
|
||||
destination — done once, or once a year — and a seventh rail entry would cost every screen a slot for
|
||||
something almost nobody is looking at.
|
||||
v5c-3: restyled into Import.dc.html's own table over SettingsView's content column — SettingsNav stays
|
||||
lit on Preferences while this is up, and the titlebar says "Back to preferences"; see
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
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,
|
||||
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.
|
||||
|
||||
── ◆ 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"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="IMPORT SSH CONFIG" FontSize="12"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ConfigPath}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="2" Classes="ghost" Content="SCAN" Command="{Binding ScanCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Reads the file and shows what it found. Nothing is stored." />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch" RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="14,12,14,0"
|
||||
<!-- ============ HEADER ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="10">
|
||||
<TextBlock Classes="settingstitle" Text="Import SSH config" />
|
||||
<TextBlock Classes="mono" Text="{Binding HeaderStatus}" FontSize="12"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Height="40" VerticalAlignment="Top" Content="SCAN AGAIN"
|
||||
Command="{Binding ScanCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Reads the file again and shows what it found. Nothing is stored." />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="0,10,0,0"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<!--
|
||||
What could not be honoured, above the list rather than beside it. Every one of these is a way the
|
||||
import is quieter than the file — an ignored Match block, a dropped ProxyCommand — and a person
|
||||
comparing the two needs to be told before they conclude the parser lost something.
|
||||
What could not be honoured, at document level. Every one of these is a way the import is quieter than
|
||||
the file — an ignored Match block, a dropped ProxyCommand — and a person comparing the two needs to be
|
||||
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}"
|
||||
BorderThickness="1" IsVisible="{Binding HasWarnings}">
|
||||
<ItemsControl ItemsSource="{Binding Warnings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<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" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</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">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
|
||||
<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" />
|
||||
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="24,1*,1.4*,0.7*,0.5*,1.3*">
|
||||
<!--
|
||||
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>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
||||
<Border Classes="importrow" CornerRadius="8">
|
||||
<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" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="12.5"
|
||||
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" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding User}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Port}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="5" Classes="meaningchip" CornerRadius="5" Padding="8,3"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
Classes.new="{Binding IsMeaningNew}" Classes.exists="{Binding IsMeaningExisting}"
|
||||
Classes.warn="{Binding IsMeaningWarned}" ToolTip.Tip="{Binding Meaning}">
|
||||
<TextBlock Text="{Binding Meaning}" FontSize="10" FontWeight="SemiBold"
|
||||
MaxWidth="230" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ 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
|
||||
between a bookmark that connects and one that asks for a password, and somebody who is not told
|
||||
will conclude the import was broken.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap" 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." />
|
||||
|
||||
<!--
|
||||
◆ THE OPT-IN CARD. Hidden entirely where the scan found no IdentityFile anywhere — an offer to read
|
||||
~/.ssh on a screen where it would read nothing is a control that teaches people to ignore it.
|
||||
-->
|
||||
<Border CornerRadius="12" Background="{StaticResource Raised}" BorderBrush="{StaticResource WarnSoft}"
|
||||
BorderThickness="1" Padding="18,16" IsVisible="{Binding HasKeyFiles}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||
<CheckBox VerticalAlignment="Top" Margin="0,3,0,0" IsChecked="{Binding ImportsKeys}" />
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Also store the private keys these entries point at" FontSize="13.5"
|
||||
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." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ What became of each key file, after the fact. Capped with a scroll viewer because a config with
|
||||
thirty keyed hosts would otherwise push IMPORT off the window — the one control this screen must
|
||||
never lose.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasKeyReport}" Padding="12,10" CornerRadius="10"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}" BorderThickness="1">
|
||||
<ScrollViewer MaxHeight="120">
|
||||
<ItemsControl ItemsSource="{Binding KeyReport}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Text="{Binding}" Classes="hint" FontSize="10.5" TextWrapping="Wrap" Margin="0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</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>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
||||
<StackPanel Margin="14,0">
|
||||
<Grid ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="0,7">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="12"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Authentication}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="4" Classes="chip" HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
||||
</Border>
|
||||
</Grid>
|
||||
<TextBlock Classes="hint" Text="{Binding Warnings}" FontSize="10.5" Margin="34,0,0,8"
|
||||
TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
IsVisible="{Binding HasWarnings}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="4" Padding="14,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding HasRows}">
|
||||
<StackPanel Spacing="8">
|
||||
<!--
|
||||
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
|
||||
will conclude the import was broken.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
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." />
|
||||
|
||||
<!--
|
||||
◆ THE TICK. Hidden entirely where the scan found no IdentityFile anywhere — an offer to read ~/.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}">
|
||||
<CheckBox IsChecked="{Binding ImportsKeys}">
|
||||
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="Also import the private keys these hosts point at" />
|
||||
</CheckBox>
|
||||
<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." />
|
||||
</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>
|
||||
|
||||
<!--
|
||||
◆ What became of each key file, after the fact. Below the button because it is the answer rather
|
||||
than the offer, and capped with a scroll viewer because a config with thirty keyed hosts would
|
||||
otherwise push IMPORT off the window — the one control this screen must never lose.
|
||||
-->
|
||||
<Border IsVisible="{Binding HasKeyReport}" Padding="10,8" CornerRadius="4"
|
||||
Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1">
|
||||
<ScrollViewer MaxHeight="120">
|
||||
<ItemsControl ItemsSource="{Binding KeyReport}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Text="{Binding}" Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Margin="0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
@@ -10,7 +9,11 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// Importing hosts from <c>~/.ssh/config</c>.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
internal sealed partial class ImportScreen : UserControl
|
||||
{
|
||||
@@ -24,9 +27,6 @@ internal sealed partial class ImportScreen : UserControl
|
||||
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)
|
||||
{
|
||||
if (DataContext is ImportViewModel import)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,128 +15,210 @@
|
||||
|
||||
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.
|
||||
|
||||
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"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="HOST KEYS" FontSize="12"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Summary}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
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
|
||||
by name would answer a different question.
|
||||
-->
|
||||
<TextBox Grid.Column="2" x:Name="PinFilter" Text="{Binding Filter}" Width="240"
|
||||
PlaceholderText="filter by host or fingerprint" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<!-- ============ HEADER ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,Auto,*,Auto">
|
||||
<Button Grid.Column="0" Classes="ghost" Width="40" Height="40" Padding="0"
|
||||
Command="{Binding BackCommand}" ToolTip.Tip="Back to Keychain">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="17"
|
||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1" Text="Host keys" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
|
||||
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>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,6,14,6"
|
||||
IsVisible="{Binding HasVisiblePins}">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="9.5" LetterSpacing="1"
|
||||
Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="ALGORITHM" 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" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="2" x:Name="PinList" Focusable="True"
|
||||
ItemsSource="{Binding VisiblePins}"
|
||||
SelectedItem="{Binding Selected}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
|
||||
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,7,14,7">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="12"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="10"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<!--
|
||||
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
|
||||
published; an ellipsis in the middle turns that into a glance, which is the habit the whole
|
||||
mechanism exists to replace.
|
||||
-->
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="340"
|
||||
IsVisible="{Binding !HasVisiblePins}" />
|
||||
|
||||
<!--
|
||||
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
|
||||
by name would answer a different question.
|
||||
-->
|
||||
<TextBox Grid.Column="4" x:Name="PinFilter" Text="{Binding Filter}" Width="320" Height="40"
|
||||
PlaceholderText="filter by host or fingerprint" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="14,16" Spacing="6">
|
||||
<TextBlock Grid.Row="1" Classes="hint" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
|
||||
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." />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="12"
|
||||
Text="Choose a pinned key to see it in full, and to withdraw it."
|
||||
IsVisible="{Binding !HasSelection}" />
|
||||
<!-- ============ THE TABLE ============ -->
|
||||
<Border Grid.Row="2" Margin="0,18,0,0" CornerRadius="12" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" ClipToBounds="True">
|
||||
<Grid ColumnDefinitions="*,320">
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
<Grid Grid.Column="0" Background="{StaticResource Pane}" RowDefinitions="Auto,*">
|
||||
|
||||
<Border Classes="chip warn" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding !Selected.IsDialledByAHost}">
|
||||
<TextBlock Text="no host uses this" />
|
||||
</Border>
|
||||
<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"
|
||||
Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="ALGORITHM" 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" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="label" Text="FINGERPRINT" Margin="0,12,0,4" />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
|
||||
FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<ListBox Grid.Row="1" x:Name="PinList" Classes="pinrows" Focusable="True" Margin="12,8"
|
||||
ItemsSource="{Binding VisiblePins}" SelectedItem="{Binding Selected}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
|
||||
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="12">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="12.5"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="11"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<!--
|
||||
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 published; an ellipsis in the middle turns that into a glance, which is the habit
|
||||
the whole mechanism exists to replace.
|
||||
-->
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="label" Text="APPROVED" Margin="0,12,0,4" />
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="11"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
<!--
|
||||
Said rather than implied. No vault item carries a timestamp, so this date is read back out of
|
||||
the item's own version 7 id — which records when the pin was created and knows nothing about
|
||||
it being re-approved since. Presenting that as "last used" would be inventing a fact.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11" 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." />
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="340" IsVisible="{Binding !HasVisiblePins}" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,12,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." />
|
||||
</Grid>
|
||||
|
||||
<Button Classes="danger" Content="FORGET THIS HOST KEY" Margin="0,12,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
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." />
|
||||
</StackPanel>
|
||||
<!-- ============ DETAIL SIDEBAR ============ -->
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="20,22" Spacing="14">
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<TextBlock Classes="hint" FontSize="12"
|
||||
Text="Choose a pinned key to see it in full, and to withdraw it."
|
||||
IsVisible="{Binding !HasSelection}" />
|
||||
|
||||
<StackPanel Spacing="14" IsVisible="{Binding HasSelection}">
|
||||
|
||||
<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}">
|
||||
<TextBlock Text="no host uses this" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="label" Text="FINGERPRINT" />
|
||||
<Border Background="{StaticResource Pane}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="10" Padding="12,10">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
|
||||
FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" LineHeight="16" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="label" Text="APPROVED" />
|
||||
<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 the item's own version 7 id — which records when the pin was created and knows nothing
|
||||
about it being re-approved since. Presenting that as "last used" would be inventing a fact.
|
||||
-->
|
||||
<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." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
◆ 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." />
|
||||
|
||||
<StackPanel Spacing="8" Margin="0,10,0,0">
|
||||
<Button Classes="ghost" Height="38" HorizontalAlignment="Stretch"
|
||||
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." />
|
||||
<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>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -18,136 +18,237 @@
|
||||
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
|
||||
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"
|
||||
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" />
|
||||
<Grid RowDefinitions="Auto,*" Margin="26">
|
||||
|
||||
<Button Grid.Column="1" Classes="flat cat" Content="CONNECTIONS"
|
||||
Classes.active="{Binding ShowsConnections}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:LogSection.Connections}" />
|
||||
<Button Grid.Column="2" Classes="flat cat" Content="KEYCHAIN"
|
||||
Classes.active="{Binding ShowsActivity}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:LogSection.Activity}" />
|
||||
<!-- ============ THE HEADER ============ -->
|
||||
<Grid Grid.Row="0" Margin="0,0,0,20" ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="14,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<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}" />
|
||||
<Button Grid.Column="1" Classes="navseg" Classes.active="{Binding ShowsActivity}"
|
||||
Content="KEYCHAIN" Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:LogSection.Activity}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding HeaderStatusLine}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,14,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<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}">
|
||||
<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="2" Classes="label" Text="LASTED" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="KIND" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="STARTED" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="9.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="ConnectionList" Classes="logrows" Focusable="True"
|
||||
ItemsSource="{Binding Connections}" Margin="12,0,12,10">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Height="40" Margin="8,0,10,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" />
|
||||
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="12.5" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="11"
|
||||
Foreground="{StaticResource TextGhost}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Duration}" FontSize="11"
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<Border Classes="chip warn" Padding="7,0" Height="18" IsVisible="{Binding HasOutcome}">
|
||||
<TextBlock Text="{Binding Outcome}" FontSize="9.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="420"
|
||||
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>
|
||||
|
||||
<Button Grid.Column="4" Classes="ghost" Content="REFRESH" Command="{Binding RefreshCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ Connections ============ -->
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsConnections}">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="14,6,14,6"
|
||||
IsVisible="{Binding HasConnections}">
|
||||
<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="2" Classes="label" Text="LASTED" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="KIND" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="STARTED" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="9.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="ConnectionList" Focusable="True"
|
||||
ItemsSource="{Binding Connections}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="0,6,14,6">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="14,0,8,0">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding Duration}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding HasOutcome}">
|
||||
<TextBlock Text="{Binding Outcome}" FontSize="9.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="420"
|
||||
IsVisible="{Binding !HasConnections}" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ 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.4*,0.8*,0.8*,1.6*,0.8*" Margin="20,14,20,10"
|
||||
IsVisible="{Binding HasActivity}">
|
||||
<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="2" Classes="label" Text="WHAT" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="FIELDS" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="9.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="ActivityList" Classes="logrows" Focusable="True"
|
||||
ItemsSource="{Binding Activity}" Margin="12,0,12,10">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
|
||||
<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.5"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="4,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="2" Classes="opchip" Classes.created="{Binding IsCreated}"
|
||||
Classes.deleted="{Binding IsDeleted}" Height="19" CornerRadius="5" Padding="8,0"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Operation}" FontSize="10" FontWeight="SemiBold" />
|
||||
</Border>
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasChangedFields}" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="420"
|
||||
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.Row="0" ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6"
|
||||
IsVisible="{Binding HasActivity}">
|
||||
<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="2" Classes="label" Text="WHAT" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="FIELDS" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="9.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="ActivityList" Focusable="True" ItemsSource="{Binding Activity}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
|
||||
<Grid ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="12"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Operation}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<!--
|
||||
The names of the fields that changed, and never what they changed to. A log that recorded
|
||||
an old password would be a plaintext credential store with a vault drawn around it.
|
||||
-->
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasChangedFields}" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="420"
|
||||
IsVisible="{Binding !HasActivity}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -9,15 +9,14 @@
|
||||
Icon="/Assets/dodossh.ico"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="1016"
|
||||
MinHeight="574"
|
||||
MinWidth="1081"
|
||||
MinHeight="583"
|
||||
Background="{StaticResource Canvas}"
|
||||
SystemDecorations="BorderOnly"
|
||||
Focusable="True">
|
||||
|
||||
<!--
|
||||
The shell window: a titlebar it draws itself, a sidebar, a tab strip, one surface at a time, and a
|
||||
status bar.
|
||||
The shell window: a titlebar it draws itself, a nav rail, one surface at a time, and a status bar.
|
||||
|
||||
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 —
|
||||
@@ -26,6 +25,17 @@
|
||||
fitting at 690 wide. Widening the sidebar without widening the window would have quietly broken them
|
||||
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
|
||||
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.
|
||||
@@ -37,12 +47,19 @@
|
||||
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
||||
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
|
||||
visible and reachable from every other one. What that costs is that the terminal and the pages now share
|
||||
the area beneath the strip, and exactly one of them may occupy it. 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.
|
||||
v5b retires the tab strip this file used to draw above the whole window — Vaults, SFTP and S3 left it
|
||||
for the rail's own switcher in an earlier pass, and this one moves the remaining pills, one per open
|
||||
terminal, off the window's own chrome entirely. Each of the two screens that carries a tab row —
|
||||
<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.
|
||||
|
||||
@@ -67,221 +84,261 @@
|
||||
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
|
||||
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">
|
||||
|
||||
<views:TitleBar Grid.Row="0" />
|
||||
<views:TitleBar Grid.Row="0" IsVisible="{Binding !IsSettingsMode}" />
|
||||
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<!-- 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.
|
||||
|
||||
It was the other way round for a reason that stopped being true: while every tab was a terminal,
|
||||
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.
|
||||
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
|
||||
to give up its own first row to. See the remark below on where a session's tabs live now.
|
||||
-->
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
<views:NavRail Grid.Column="0" />
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||
<Panel Grid.Column="1">
|
||||
|
||||
<!--
|
||||
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}" />
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*">
|
||||
<!-- ============ THE PAGES ============ -->
|
||||
<Panel IsVisible="{Binding IsShowingPages}">
|
||||
|
||||
<!--
|
||||
◆ THE PIN STRIP, ABOVE THE TERMINAL AND NOTHING ELSE.
|
||||
|
||||
A Grid row rather than a sibling in the Panel below it — the terminal, the pages and the
|
||||
connecting card are all layered on top of one another there, which is right for three things
|
||||
that occupy the same rectangle and wrong for a row that is supposed to sit above it. Auto height
|
||||
and IsVisible="False" collapse to nothing when there is nothing to show, so a page screen or an
|
||||
empty terminal loses no height to a row it never draws — see ShowsPinStrip, which is false on
|
||||
every page and every tab whose host has no pins.
|
||||
|
||||
ActiveTabPinnedPaths is MainWindowViewModel's, not the vault's: which tab is selected and
|
||||
whether its session is live are the shell's business, and RefreshConnectedHosts is where the
|
||||
two lists — Tabs and Vault.Hosts — are already walked together to paint the status dots. This
|
||||
rides along on the same walk rather than opening a subscription of its own.
|
||||
Wrapped, like every other screen here. It used to be bound directly — its data context was
|
||||
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
|
||||
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
|
||||
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
|
||||
its visibility goes on the wrapper.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="14,8" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding ShowsPinStrip, FallbackValue=False}">
|
||||
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<Panel IsVisible="{Binding IsHostsScreen}">
|
||||
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ SFTP ============ -->
|
||||
<!--
|
||||
◆ v5b's session shell, wrapping this screen's existing content rather than replacing it — the
|
||||
two-pane grid and the queue inside TransfersScreen.axaml are untouched; wave C restyles their
|
||||
internals. What is new here is everything design-notes/v5b-fidelity-notes.md calls the session
|
||||
shell: a 26px padded column, an in-screen tab row, a bordered rounded-bottom container holding
|
||||
a host header and a status bar around the screen's own content, and a 300px sidebar.
|
||||
|
||||
Gated on IsTransfersScreen exactly as before — IsShowingPages means "the Avalonia page area,
|
||||
not the WebView", which is the occlusion question, and which mode the switcher is on is this
|
||||
wrapper's own separate question.
|
||||
|
||||
The tab row's own click does not select a terminal tab — there is no per-tab SFTP session in
|
||||
this application, and building one is out of this wave's scope; see the notes' own open
|
||||
question and MainWindowViewModel.SelectFilesHostCommand for how this resolves it: a click
|
||||
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}">
|
||||
<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">
|
||||
<!--
|
||||
A single level of $parent[ItemsControl] is enough to reach back to the shell: this
|
||||
ItemsControl's own DataContext is MainWindowViewModel, the window's root, so one hop up
|
||||
from the chip's string DataContext lands on it directly — no #Board-style named-element
|
||||
indirection needed, because there is no second level of template nesting here.
|
||||
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.
|
||||
-->
|
||||
<Button Classes="ghost" Padding="8,4" ToolTip.Tip="{Binding}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="12" />
|
||||
<TextBlock FontFamily="{StaticResource MonoFont}" Text="{Binding}" FontSize="11.5"
|
||||
MaxWidth="220" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
<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 Grid.Row="1">
|
||||
<!-- ============ S3 ============ -->
|
||||
<!--
|
||||
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.
|
||||
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 rail's own switcher segment that
|
||||
calls it now; see NavRail.axaml.
|
||||
|
||||
<!-- ============ THE PAGES ============ -->
|
||||
<Panel IsVisible="{Binding IsShowingPages}">
|
||||
|
||||
<!--
|
||||
Wrapped, like every other screen here. It used to be bound directly — its data context was
|
||||
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
|
||||
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
|
||||
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
|
||||
its visibility goes on the wrapper.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsHostsScreen}">
|
||||
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ SFTP ============ -->
|
||||
<!--
|
||||
Inside this Panel although it is a tab rather than a rail screen, and that is not an
|
||||
oversight. IsShowingPages means "the Avalonia page area, not the WebView", which is the
|
||||
occlusion question and is true of all three fixed tabs; which of them is showing is the
|
||||
separate question each child below answers. Keeping the two apart is what lets the terminal
|
||||
stay collapsed under one rule rather than under four.
|
||||
|
||||
What differs from a rail screen is only the rail: NavRail collapses on IsVaultsTab above, so
|
||||
this screen is laid out at the full window width.
|
||||
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and
|
||||
putting both on one element resolves IsVisible against that view model, where
|
||||
IsTransfersScreen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
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}">
|
||||
<Border Margin="26" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||
ClipToBounds="True">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ S3 ============ -->
|
||||
<!--
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsBucketsScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
<!-- ============ KEYCHAIN ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the reason the vault column always was: this
|
||||
element's visibility is the shell's business and its data context is the vault, and put both
|
||||
on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
|
||||
not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsKeychainScreen}">
|
||||
<views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ KEYCHAIN ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the reason the vault column always was: this
|
||||
element's visibility is the shell's business and its data context is the vault, and put both
|
||||
on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
|
||||
not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsKeychainScreen}">
|
||||
<views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
<!-- ============ HOST KEYS ============ -->
|
||||
<!--
|
||||
Wrapped, like the two above and for the same reason: its data context is the screen's own
|
||||
view model, where IsKnownHostsScreen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsKnownHostsScreen}">
|
||||
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ HOST KEYS ============ -->
|
||||
<!--
|
||||
Wrapped, like the two above and for the same reason: its data context is the screen's own
|
||||
view model, where IsKnownHostsScreen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsKnownHostsScreen}">
|
||||
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ SNIPPETS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsSnippetsScreen}">
|
||||
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ LOGS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsLogsScreen}">
|
||||
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ VAULTS ============ -->
|
||||
<!--
|
||||
Wrapped, for the reason the keychain and transfers screens are: the visibility is the shell's
|
||||
business and the data context is the vaults view model, and both on one element would resolve
|
||||
IsVaultsScreen against a type that does not have it.
|
||||
|
||||
Bound to Vaults, which is the vaults themselves and the people in them — not to Vault, which
|
||||
is one vault's contents and is what the keychain and hosts screens above draw.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsVaultsScreen}">
|
||||
<views:VaultsScreen DataContext="{Binding Vaults}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ PREFERENCES ============ -->
|
||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||
|
||||
<!-- ============ IMPORT ============ -->
|
||||
<!--
|
||||
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
|
||||
the others whose data context is their own view model.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsImportScreen}">
|
||||
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
|
||||
</Panel>
|
||||
<!-- ============ SNIPPETS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsSnippetsScreen}">
|
||||
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ LOGS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsLogsScreen}">
|
||||
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
The other thing that can be in the terminal's rectangle: a tab whose session does not exist
|
||||
yet, or never will. Exclusive with the WebView below by construction — a selected tab either
|
||||
has a session or it does not — which is what makes drawing it here safe under the occlusion
|
||||
rule, the same way the page area is. See ConnectingCard.axaml.
|
||||
v5c-2: the VAULTS panel that used to live here is gone. ShellScreen.Vaults now only ever shows
|
||||
through settings mode's own SettingsVaultsPage — see MainWindowViewModel.ShowScreen, which
|
||||
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.
|
||||
|
||||
v5c-3: IMPORT left this Panel the same way, and for the same reason. Import.dc.html draws the
|
||||
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.
|
||||
-->
|
||||
<views:ConnectingCard x:Name="ConnectingPane"
|
||||
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
||||
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate 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
|
||||
the tab rework: the terminal belongs to the window now, not to the hosts screen.
|
||||
|
||||
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, 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 — but it is what the previewer
|
||||
does.
|
||||
-->
|
||||
<NativeWebView x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
</Panel>
|
||||
|
||||
<!-- ============ TERMINAL ============ -->
|
||||
<!--
|
||||
◆ v5b's session shell for the terminal surface, built the same way SFTP's own wrapper above is —
|
||||
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.
|
||||
-->
|
||||
<Grid RowDefinitions="Auto,*" Margin="26" IsVisible="{Binding IsTerminalSurface}">
|
||||
<views:SessionTabRow Grid.Row="0" TabCommand="{Binding SelectTabCommand}" />
|
||||
<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 Grid.Row="0" Background="{StaticResource Pane}">
|
||||
|
||||
<!--
|
||||
The other thing that can be in the terminal's rectangle: a tab whose session does not
|
||||
exist yet, or never will. Exclusive with the WebView below by construction — a
|
||||
selected tab either has a session or it does not — which is what makes drawing it here
|
||||
safe under the occlusion rule, the same way the page area is. See ConnectingCard.axaml.
|
||||
-->
|
||||
<views:ConnectingCard x:Name="ConnectingPane"
|
||||
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
||||
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
|
||||
browser process tree, so twenty tabs would cost twenty of them.
|
||||
|
||||
FallbackValue, because a compiled binding with no DataContext yields UnsetValue,
|
||||
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
|
||||
— but it is what the previewer does.
|
||||
-->
|
||||
<NativeWebView x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
</Panel>
|
||||
|
||||
<views:SessionStatusBar Grid.Row="1" ShowsEncoding="True" />
|
||||
</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>
|
||||
</Border>
|
||||
</Grid>
|
||||
</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 —
|
||||
which covers Avalonia-drawn content and nothing else. The terminal is collapsed rather than covered;
|
||||
@@ -439,11 +496,18 @@
|
||||
no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner
|
||||
announcing an update that does not exist.
|
||||
-->
|
||||
<views:UpdateBanner Grid.Row="2"
|
||||
DataContext="{Binding Updates}"
|
||||
IsVisible="{Binding 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>
|
||||
|
||||
|
||||
@@ -54,6 +54,21 @@ internal sealed partial class MainWindow : Window
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Colours the system-drawn frame the moment there is a handle to colour it on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>OnOpened</c> and not the constructor: the window has no platform handle until it is shown, and
|
||||
/// <see cref="NativeWindowFrame"/> does nothing without one. See that class for what the frame is and
|
||||
/// why <c>BorderOnly</c> still has one.
|
||||
/// </remarks>
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
|
||||
NativeWindowFrame.MatchTo(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the Linux backend for the one mode it can actually draw inside this window.
|
||||
/// </summary>
|
||||
@@ -111,11 +126,13 @@ internal sealed partial class MainWindow : Window
|
||||
/// </remarks>
|
||||
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,
|
||||
{ Screen: ShellScreen.Keychain } => VaultPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
|
||||
_ => this,
|
||||
@@ -213,6 +230,25 @@ internal sealed partial class MainWindow : Window
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -247,6 +283,7 @@ internal sealed partial class MainWindow : Window
|
||||
if (shell is { } previous)
|
||||
{
|
||||
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
|
||||
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||
}
|
||||
|
||||
@@ -265,6 +302,7 @@ internal sealed partial class MainWindow : Window
|
||||
wasUnlocked = viewModel.IsUnlocked;
|
||||
|
||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||
viewModel.PropertyChanged += OnShellPropertyChanged;
|
||||
}
|
||||
|
||||
@@ -277,6 +315,15 @@ internal sealed partial class MainWindow : Window
|
||||
/// </remarks>
|
||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||
|
||||
/// <remarks>
|
||||
/// The same call for a session that was already open and has just been typed into from the sidebar —
|
||||
/// see <see cref="MainWindowViewModel.TerminalFocusRequested"/>. Posted like every other path here,
|
||||
/// although nothing was revealed this turn: the post also re-checks that a terminal is still showing,
|
||||
/// which is what keeps this from stealing the keyboard if the insert landed the user on the snippets
|
||||
/// screen instead.
|
||||
/// </remarks>
|
||||
private void OnTerminalFocusRequested(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||
|
||||
/// <remarks>
|
||||
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
|
||||
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Paints the frame Windows still draws around a <c>BorderOnly</c> window in the application's own
|
||||
/// colour, so the top edge stops reading as a leftover system titlebar.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The symptom this exists for:</b> a pale strip across the very top of the window, a few pixels
|
||||
/// tall and plainly not part of the application — most obvious on a machine with "show accent colour
|
||||
/// on title bars and window borders" turned on, where it comes out blue against a near-black shell.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is not <c>TitleBar.axaml</c> leaking and it is not a margin. It is DWM, and the reason it is
|
||||
/// there is visible in Avalonia's own Win32 backend: <c>WindowImpl.UpdateWindowProperties</c> gives a
|
||||
/// <see cref="WindowDecorations.BorderOnly"/> window <c>WS_BORDER | WS_THICKFRAME</c> and then calls
|
||||
/// <c>DwmExtendFrameIntoClientArea</c> with one-pixel margins on all four sides. So the compositor
|
||||
/// owns a hairline of every edge of this window, and it fills that hairline with the system's caption
|
||||
/// and border colours — which are chosen by the user's personalisation settings and have no reason to
|
||||
/// resemble <c>CanvasColor</c>. The window is the wrong place to look for the pixels; they were never
|
||||
/// painted by anything in this tree.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The fix is to tell DWM what colour to use rather than to try to cover it. <c>DWMWA_BORDER_COLOR</c>
|
||||
/// and <c>DWMWA_CAPTION_COLOR</c> arrived in Windows 11 21H2 and are exactly that; both are set to the
|
||||
/// window's own background, so the hairline still exists — the resize grip is on it, and the drop
|
||||
/// shadow hangs off it — and simply cannot be seen. Deliberately <em>not</em> <c>DWMWA_COLOR_NONE</c>,
|
||||
/// which removes the border outright: on a dark desktop that leaves a near-black window with no edge
|
||||
/// at all, which trades one visual defect for another.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Windows 10 gets the dark-mode attribute and nothing else, and that is the whole of what is
|
||||
/// available there: the two colour attributes are unsupported, <c>DwmSetWindowAttribute</c> answers
|
||||
/// <c>E_INVALIDARG</c>, and the calls do nothing. Hence the ignored return values — every attribute
|
||||
/// here is an improvement where it lands and a no-op where it does not, so there is nothing for a
|
||||
/// caller to handle and nothing worth logging on a path that runs once at startup.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class NativeWindowFrame
|
||||
{
|
||||
/// <summary>Windows 11 21H2 and later: the colour of the frame border.</summary>
|
||||
private const int BorderColorAttribute = 34;
|
||||
|
||||
/// <summary>Windows 11 21H2 and later: the colour of the caption, including the extended frame.</summary>
|
||||
private const int CaptionColorAttribute = 35;
|
||||
|
||||
/// <summary>
|
||||
/// Windows 10 1903 and later: draw the frame in the dark palette.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Redundant on Windows 11, where the two colour attributes above name the colours outright, and it
|
||||
/// is set anyway because it is the only one of the three that Windows 10 honours. The build before
|
||||
/// 1903 used attribute 19 for this; that is not chased here, because a border on an OS release that
|
||||
/// left support in 2020 is not worth a second interop call.
|
||||
/// </remarks>
|
||||
private const int DarkModeAttribute = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Matches <paramref name="window"/>'s system-drawn frame to the colour it paints itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Call once the window has a handle — <c>OnOpened</c> is the first such moment. Calling earlier
|
||||
/// finds no platform handle and silently does nothing, which is the defect this replaced: the strip
|
||||
/// is only visible once the window is on screen, so a call that ran too early looks like a fix that
|
||||
/// does not work rather than a fix that never ran.
|
||||
/// </remarks>
|
||||
internal static void MatchTo(Window window)
|
||||
{
|
||||
// Every attribute below is a DWM one, and DWM is Windows. Elsewhere the frame is drawn by the
|
||||
// platform's own compositor and there is nothing here to say to it.
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.TryGetPlatformHandle()?.Handle is not { } handle || handle == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Set(handle, DarkModeAttribute, 1);
|
||||
|
||||
// The window's own Background rather than a named resource, so the frame cannot drift from the
|
||||
// canvas when the palette moves. A brush that is not solid — a gradient, or nothing set at all —
|
||||
// has no single colour to match, and leaving the system's own is better than inventing one.
|
||||
if (window.Background is not ISolidColorBrush { Color: var canvas })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var reference = ColorRef(canvas);
|
||||
Set(handle, BorderColorAttribute, reference);
|
||||
Set(handle, CaptionColorAttribute, reference);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets one integer-valued DWM attribute, and discards the answer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The discard is the point of this method existing rather than being three call sites. Every
|
||||
/// attribute here is unsupported on some Windows this application runs on, and unsupported means
|
||||
/// <c>E_INVALIDARG</c> and no change — which is the intended outcome on that OS, not a failure, so
|
||||
/// there is nothing for the caller to do with the <c>HRESULT</c> and nothing worth logging once at
|
||||
/// startup. Written once, with the reasoning, rather than left implicit at each call.
|
||||
/// </remarks>
|
||||
private static void Set(IntPtr window, int attribute, int value) =>
|
||||
_ = DwmSetWindowAttribute(window, attribute, ref value, sizeof(int));
|
||||
|
||||
/// <summary>
|
||||
/// Packs <paramref name="color"/> into a Win32 <c>COLORREF</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>0x00BBGGRR</c> — blue in the high byte, not red, and the alpha byte must be zero. Getting the
|
||||
/// order wrong produces a plausible-looking wrong colour rather than an error, which is the kind of
|
||||
/// bug that survives a glance at the window.
|
||||
/// </remarks>
|
||||
private static int ColorRef(Color color) => color.R | (color.G << 8) | (color.B << 16);
|
||||
|
||||
/// <remarks>
|
||||
/// <c>DllImport</c> rather than <c>LibraryImport</c>, for the reason
|
||||
/// <see cref="NativeKeyboardFocus"/> gives at its own P/Invoke: the generated form needs
|
||||
/// <c>AllowUnsafeBlocks</c> across a project that handles key material, and this signature is
|
||||
/// blittable, so there is no marshalling for it to improve.
|
||||
/// </remarks>
|
||||
#pragma warning disable SYSLIB1054
|
||||
[DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(IntPtr window, int attribute, ref int value, int size);
|
||||
#pragma warning restore SYSLIB1054
|
||||
}
|
||||
@@ -7,159 +7,308 @@
|
||||
<!--
|
||||
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
|
||||
named in MainWindow and in the layout suite, and "the list of places this window goes" is what it was
|
||||
called when it was 54 pixels and is what it still is. The width is the thing that changed, not the job.
|
||||
── v5b ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Redrawn against NavRail.dc.html, which changes more here than a fresh coat of paint. 190 pixels became
|
||||
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 ────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Three things the extra 136 pixels buy, and they are why the design widened it rather than a matter of
|
||||
taste. The five-character abbreviations are gone — PINS and SNIPS were a width constraint and are now
|
||||
Pins and Snippets. Each entry carries a glyph, so the list can be scanned by shape as well as read. And
|
||||
each carries a count, which is the one genuinely new fact: how many hosts, how many keys, how many pins
|
||||
is a question you would otherwise have to open the screen to answer.
|
||||
── THE SWITCHER, AT THE RAIL'S OWN HEAD. ─────────────────────────────────────────────────────────────
|
||||
SSH, SFTP and S3 were the strip's three fixed tabs — Vaults, SFTP, S3 — until this pass moved the choice
|
||||
here as a segmented control, which is where the mock always drew it. Vaults did not come with it: the
|
||||
rail's own item list below is what that tab used to gate, so a fourth segment naming it would have been
|
||||
a second way to reach exactly what six rows underneath already reach. SSH, SFTP and S3 are a true
|
||||
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
|
||||
read, and preferences are not counted — so those two show nothing rather than a zero. The design draws a
|
||||
number on every row; a zero beside Logs would be a fact this application never computed.
|
||||
── THE RAIL IS NO LONGER DRAWN ONLY UNDER ONE TAB. ───────────────────────────────────────────────────
|
||||
It used to collapse whenever the strip was on SFTP, S3 or a terminal — see the version of this remark
|
||||
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. ──────────────────────────────────────
|
||||
Both were rail entries until the strip grew fixed tabs for them. They are the two destinations that are
|
||||
not about the keychain — they are a place you leave the keychain to work in, and you stay there while a
|
||||
transfer runs — which is exactly what a tab is for and what a rail entry is not. The rail is drawn only
|
||||
under the Vaults tab now, so an entry here for either of them would be a route out of the tab it lives
|
||||
in. See MainWindowViewModel.IsVaultsTab.
|
||||
── THE FIRST ROW IS MODE-DEPENDENT, exactly as the mock's own <c>mode</c> prop is. ───────────────────
|
||||
One row rather than three shown and hidden by turn: its icon, its label and what it runs all come from
|
||||
<c>MainWindowViewModel.FirstRailItemIcon</c>/<c>FirstRailItemLabel</c>/<c>ShowFirstRailItemCommand</c>,
|
||||
which read the same three flags the switcher above lights — so the row and the segment can never name
|
||||
two different modes between them.
|
||||
|
||||
What this costs is that the S3 count has nowhere to go: the strip's tabs are one word each, and the rail
|
||||
was where "how many buckets" was printed. It is on the S3 screen itself, which is where somebody
|
||||
counting buckets is going anyway.
|
||||
── Vaults and Preferences left the rail's own list for the user chip's popover, at the foot. ─────────
|
||||
Both are still one click away; see the popover below the user chip. The chip itself carries the signed-
|
||||
in identity this application actually has — a display name and, where the server sent one, an email —
|
||||
which is also new: the titlebar drew an account name and a vault chip before this pass and does not any
|
||||
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the 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
|
||||
screen behind it lists vaults and the people in each, where it used to list teams that owned vaults. See
|
||||
VaultsViewModel. It shares its word with the tab strip's first tab; the two are different levels of the
|
||||
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.
|
||||
Buttons rather than a TabStrip or a ListBox, still, for the reason the v3 remark gave: all three hold
|
||||
the selection themselves, so a click would move the highlight before the shell decided anything, and a
|
||||
Button carries no state to disagree with the screen that is actually showing.
|
||||
-->
|
||||
|
||||
<Border Width="190" Background="{StaticResource Sidebar}"
|
||||
<Border Width="255" Background="{StaticResource DeepChrome}"
|
||||
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">
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
|
||||
ToolTip.Tip="Your hosts, and what is known about the one you have selected">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="navicon" Text="▤" />
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Hosts" />
|
||||
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.Hosts.Count}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<StackPanel DockPanel.Dock="Top" Spacing="18">
|
||||
|
||||
<!--
|
||||
TotalItemCount is keys plus passwords and deliberately excludes buckets, which used to disagree
|
||||
with the list under it. It no longer does: buckets have their own entry above, so this number and
|
||||
this screen now count the same things.
|
||||
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.
|
||||
-->
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsKeychainShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Keychain}"
|
||||
ToolTip.Tip="Your keychain: SSH keys and stored passwords">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="navicon" Text="⚿" />
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Keychain" />
|
||||
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.TotalItemCount}" />
|
||||
<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>
|
||||
</Button>
|
||||
</Border>
|
||||
|
||||
<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
|
||||
bound to it would count what the Pins screen's own filter box happens to match and would
|
||||
change as somebody typed in it. Every other count here is a total; this one has to be too.
|
||||
-->
|
||||
<TextBlock Grid.Column="2" Classes="navcount"
|
||||
Text="{Binding Vault.KnownHostPins.Count}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<StackPanel Spacing="8">
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
|
||||
ToolTip.Tip="Commands you have saved, and how to put one into a terminal">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="navicon" Text="❯" />
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Snippets" />
|
||||
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.Snippets.Count}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<!--
|
||||
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 IsLogsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}"
|
||||
ToolTip.Tip="What has been connected to, and what has been changed in this keychain">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="navicon" Text="≡" />
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Logs" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
|
||||
ToolTip.Tip="Your hosts, and what is known about the one you have selected">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Hosts" />
|
||||
</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.
|
||||
<!--
|
||||
Keys, not Keychain — the mock's own word for this screen, which still holds SSH keys and stored
|
||||
passwords; see ShellScreen.Keychain for the name that did not move with the label.
|
||||
-->
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsKeychainShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Keychain}"
|
||||
ToolTip.Tip="Your keychain: SSH keys and stored passwords">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Keys" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
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>
|
||||
<!--
|
||||
◆ NO Pins ROW. The pins screen is still here and still reached in one click — from "Host keys"
|
||||
on the Keys screen's own header, which is where a list of approved host keys belongs: they are
|
||||
keychain material, and that button was already the second way to reach them. Two rail rows away
|
||||
from each other, both landing on the same screen, is a rail that has to be read twice.
|
||||
|
||||
It is also the last of the rail's own deviations from the mock to go. The row was kept in v5b on
|
||||
the grounds that the design has no screen for approved host keys at all — see the file-level
|
||||
remark — which is true of the design and was never a reason for a rail entry once the keychain
|
||||
had a door to the same place.
|
||||
-->
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
|
||||
ToolTip.Tip="Commands you have saved, and how to put one into a terminal">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Snips" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="flat nav" Classes.active="{Binding IsLogsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}"
|
||||
ToolTip.Tip="What has been connected to, and what has been changed in this keychain">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Classes="navicon" Text="" />
|
||||
<TextBlock Classes="navlabel" Text="Logs" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<Button DockPanel.Dock="Bottom" Classes="flat nav" Margin="8,0,8,10"
|
||||
Classes.active="{Binding IsPreferencesShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}"
|
||||
ToolTip.Tip="Preferences, and this machine's device key">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="navicon" Text="⚙" />
|
||||
<TextBlock Grid.Column="1" Classes="navlabel" Text="Preferences" />
|
||||
<!--
|
||||
The rail's foot: the signed-in identity, and everything the strip's old Vaults tab used to gate
|
||||
behind a caret. A Flyout is safe here without any ordering games: it opens inside the rail's own
|
||||
255-pixel column, which the terminal's native child window never occupies — there is no rectangle
|
||||
here a popup could be composited underneath, unlike the window-wide tab strip this rail's own
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
</DockPanel>
|
||||
|
||||
@@ -1,9 +1,97 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
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
|
||||
{
|
||||
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, cursor and scrollback — the renderer hard-codes those three. The text size above is the one that is not, and it is the path the others would follow if they were worth a row here." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Changing channel from in here — 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="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,9 +27,9 @@
|
||||
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.
|
||||
-->
|
||||
<!-- 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. -->
|
||||
<Border x:Name="Backdrop" Background="#CC0E1220" PointerPressed="OnBackdropPressed">
|
||||
<Border x:Name="Backdrop" Background="#9905050A" PointerPressed="OnBackdropPressed">
|
||||
<Border Width="640" VerticalAlignment="Top" Margin="0,160,0,0"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="14">
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:contracts="using:DodoSSH.Contracts"
|
||||
x:Class="DodoSSH.Client.App.Views.SettingsVaultsPage"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
x:Name="Root">
|
||||
|
||||
<!--
|
||||
v5c-2: Vaults, against Settings-Vaults.dc.html. Replaces the old full-bleed VaultsScreen, which this
|
||||
settings page now covers completely — every command it had is reachable here exactly once, and
|
||||
VaultsScreen.axaml is gone. Its data is DataContext.Vaults (VaultsViewModel), reached as {Binding Vaults.*}
|
||||
throughout because this page's own DataContext is the shell, the same choice every other settings page
|
||||
makes.
|
||||
|
||||
── WHAT THE DESIGN DREW AND THIS PAGE DOES NOT ─────────────────────────────────────────────────────────
|
||||
"Manage devices" and "3 devices" in the sync line — no list-devices endpoint exists. The whole VAULT
|
||||
DEFAULTS card (auto-lock, require-password-on-unlock, relay toggle) and the whole RECOVERY card (kit,
|
||||
export) — none of the three exists; sync is always on. The magenta "Default" badge — the app does track a
|
||||
"new items go to" vault (VaultViewModel.TargetVaultId), 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; dropped rather than faked. Per-vault UNLOCKED/LOCKED chips — this app locks the keychain as a
|
||||
whole, not one vault at a time, so there is no per-vault state for a chip to draw; the keychain-level fact
|
||||
lives in the sync card's dot instead. The "Unlock Rocateq" modal — same reason, there is nothing per-vault
|
||||
to unlock. Member avatar stacks on the card list — VaultRowViewModel carries a member *count*, not the
|
||||
members themselves; only the selected vault's Members collection is actually loaded, which is why avatars
|
||||
are drawn in the members panel below and nowhere else. The "Sorted by name ▾" control — decorative in the
|
||||
mock; Vaults.Vaults is already ordered personal-first-then-name and there is no second order to switch to.
|
||||
|
||||
── THE MEMBERS PANEL ────────────────────────────────────────────────────────────────────────────────────
|
||||
The design draws an 860px modal. This app's only overlay idiom below a whole-window mode is the scrim a
|
||||
Border with a translucent Background and a PointerPressed handler draws — see QuickConnect.axaml, the
|
||||
other place a click-away-to-close panel exists — so that is what this is, capped at MaxWidth 860 rather
|
||||
than fixed to it: the settings content column is narrower than 860 at the window's minimum, and a modal
|
||||
that insisted on the full width would arrange its own rows past the edge of what this page is given.
|
||||
|
||||
Inside it is the old screen's own right-hand column, restyled rather than reinvented: a selectable list of
|
||||
members, the SET ROLE buttons, the ADD row, REMOVE, HAND OVER, SHARE KEY/WITHDRAW KEY and the KEY HOLDERS
|
||||
list, on the same VaultsViewModel.SelectedMember selection the old screen used. Deleting a vault stays a
|
||||
card-level action (the danger icon below), not a members-panel one, so PendingAction is drawn in only one
|
||||
of the two places at a time — see the two IsVisible guards below, which key off IsMembersPanelOpen because
|
||||
HAND OVER can only ever be armed with the panel open (it needs a selected member) and DELETE only with it
|
||||
closed (the card's own icon is what arms it).
|
||||
|
||||
The sentence that must survive from the old screen: a vault cannot be deleted through the membership list
|
||||
behind it — there is no such call anywhere in the server, and archiving a team while its vault still
|
||||
exists is refused. That is not the same fact as "a vault cannot be deleted" — the DELETE icon below does
|
||||
delete the vault itself — and both sentences are kept, each where it is true.
|
||||
-->
|
||||
|
||||
<Panel>
|
||||
|
||||
<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="Vaults" />
|
||||
<Border Background="{StaticResource Chip}" CornerRadius="9" MinWidth="34" Height="30">
|
||||
<TextBlock Text="{Binding Vaults.Vaults.Count}" FontSize="13.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextFaint}" Margin="8,0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
<Button Classes="ghost" Height="40" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
|
||||
<Button Classes="accent" Height="40" Content="+ NEW VAULT"
|
||||
Command="{Binding Vaults.NewVaultCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- The name-a-vault and rename-a-vault forms, in place — this window has no modal idiom for a bare
|
||||
text field, and the members panel below is a different kind of overlay: over one vault's people,
|
||||
not over a form. -->
|
||||
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
|
||||
IsVisible="{Binding Vaults.IsCreatingVault}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="settingsrowtitle" Text="New vault" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding Vaults.NewVaultName}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Its key is made on this machine and nobody else has it. Add people to it once it exists, then press SHARE KEY." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Height="36" Content="CREATE"
|
||||
Command="{Binding Vaults.CreateVaultCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="ghost" Height="36" Content="CANCEL"
|
||||
Command="{Binding Vaults.CancelNewVaultCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
|
||||
IsVisible="{Binding Vaults.IsRenamingVault}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Rename vault" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding Vaults.EditVaultName}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Everybody who shares this vault sees the new name. Nothing is re-encrypted and no key changes — the name has always been stored in plain text, because a person has to be able to pick a vault before anything is decrypted." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Height="36" Content="SAVE"
|
||||
Command="{Binding Vaults.SaveVaultNameCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="ghost" Height="36" Content="CANCEL"
|
||||
Command="{Binding Vaults.CancelRenameVaultCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- A vault's own destructive confirmation. Only while the members panel is shut — see the remark
|
||||
above for why the two never compete for this one PendingAction. Both conditions on the same
|
||||
Border, not one nested inside the other: a Border visible with its content hidden would still
|
||||
draw an empty danger-tinted card while the members panel's own copy of this confirmation is
|
||||
showing. -->
|
||||
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}">
|
||||
<Border.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<Binding Path="Vaults.IsConfirming" />
|
||||
<Binding Path="!Vaults.IsMembersPanelOpen" />
|
||||
</MultiBinding>
|
||||
</Border.IsVisible>
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="{Binding Vaults.PendingAction.Question}" FontSize="14.5" FontWeight="Bold"
|
||||
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Vaults.PendingAction.Consequence}" FontSize="12.5"
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="danger" Height="36" Content="CONFIRM"
|
||||
Command="{Binding Vaults.ConfirmActionCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="ghost" Height="36" Content="CANCEL"
|
||||
Command="{Binding Vaults.CancelActionCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Sync status: only what is real — no device count, no "ran N minutes ago" (this session tracks no
|
||||
such timestamp). The dot and the word are MainWindowViewModel.IsFullySynced/SyncLabel, the exact
|
||||
fact the titlebar's own dot already tells the truth with. -->
|
||||
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20,18">
|
||||
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="9" Height="9"
|
||||
Margin="0,7,0,0" VerticalAlignment="Top" />
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="mono" Text="{Binding SyncLabel}" FontSize="13" FontWeight="Bold"
|
||||
LetterSpacing="0.5" Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="settingsrowcaption" Text="End-to-end encrypted." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="YOUR VAULTS" Margin="0,32,0,12" />
|
||||
|
||||
<StackPanel Spacing="10" Margin="0,0,0,40" IsVisible="{Binding Vaults.HasVaults}">
|
||||
<ItemsControl ItemsSource="{Binding Vaults.Vaults}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Spacing="10" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultRowViewModel">
|
||||
<Border Classes="settingscard" Padding="22,18">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
<Border Grid.Column="0" Width="52" Height="52" CornerRadius="12"
|
||||
Background="{StaticResource AvatarGradient}">
|
||||
<TextBlock Text="{Binding Initial}" FontWeight="Bold" FontSize="20"
|
||||
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="7" Margin="16,0" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Name}" FontSize="17" FontWeight="Bold"
|
||||
LetterSpacing="-0.25" Foreground="{StaticResource Text}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Classes="chip">
|
||||
<TextBlock Text="{Binding RoleLabel}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding Detail}" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="mono" FontSize="10.5" Foreground="{StaticResource WarnText}"
|
||||
Text="{Binding State}" IsVisible="{Binding HasState}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button Classes="paneicon" Width="34" Height="34" FontSize="15"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.OpenMembersPanelCommand}"
|
||||
CommandParameter="{Binding}" IsVisible="{Binding IsShared}"
|
||||
ToolTip.Tip="See and manage who is in this vault.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Width="34" Height="34" FontSize="15"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.RenameVaultRowCommand}"
|
||||
CommandParameter="{Binding}" IsVisible="{Binding CanAdminister}"
|
||||
ToolTip.Tip="Changes what this vault is called. The name is plaintext on the server, as it always was; nothing inside is re-encrypted.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon danger" Width="34" Height="34" FontSize="15"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.DeleteVaultRowCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
IsVisible="{Binding !IsPersonal}"
|
||||
ToolTip.Tip="Deletes this vault and withdraws everybody's key to it. It cannot reach a machine that has already synced it.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="settingsrowcaption" Margin="0,20,0,40" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !Vaults.HasVaults}"
|
||||
Text="No vaults yet. Unlock your keychain to see the personal one, or make a vault to share hosts and credentials with colleagues." />
|
||||
|
||||
<TextBlock Margin="0,0,0,40" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding Vaults.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Text="{Binding Vaults.Status}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ THE MEMBERS PANEL ============ -->
|
||||
<Border x:Name="MembersBackdrop" Background="#9905050A"
|
||||
IsVisible="{Binding Vaults.IsMembersPanelOpen}" PointerPressed="OnBackdropPressed">
|
||||
<Border MaxWidth="860" MaxHeight="620" Margin="30"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="14">
|
||||
<Grid RowDefinitions="Auto,*" Margin="26,22">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,14">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Vaults.SelectedVault.Name, StringFormat='{}{0} · Members'}"
|
||||
FontSize="18" FontWeight="Bold" LetterSpacing="-0.25"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="1" Classes="paneicon" Width="26" Height="26"
|
||||
Command="{Binding Vaults.CloseMembersPanelCommand}" ToolTip.Tip="Close">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="15" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel Spacing="16">
|
||||
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Vaults.HasSharedMembershipWarning}"
|
||||
Text="{Binding Vaults.SharedMembershipWarning}" />
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding !Vaults.SelectedIsPersonal}">
|
||||
|
||||
<TextBlock Classes="settingssection" Text="MEMBERS" Margin="0" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Vaults.Members}" SelectedItem="{Binding Vaults.SelectedMember}"
|
||||
Background="Transparent" BorderThickness="0" MaxHeight="220">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultMemberRowViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,150,Auto" Margin="0,4">
|
||||
<Border Grid.Column="0" Width="30" Height="30" CornerRadius="15"
|
||||
Background="{StaticResource AvatarGradient}">
|
||||
<TextBlock Text="{Binding Initials}" FontWeight="Bold" FontSize="10"
|
||||
Foreground="White" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1" Spacing="2" Margin="10,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="settingsrowcaption" FontSize="11" Text="{Binding Email}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="settingsrowcaption" FontSize="10.5" Text="{Binding KeyState}"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Classes="settingsrowcaption" FontSize="10" Text="{Binding LastActive}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Role}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Margin="10,0,0,0" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- The hand-over confirmation, only while this panel is the one that armed it. -->
|
||||
<Border Padding="14" CornerRadius="10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1"
|
||||
IsVisible="{Binding Vaults.IsConfirming}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="{Binding Vaults.PendingAction.Question}" FontSize="13.5" FontWeight="Bold"
|
||||
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding Vaults.PendingAction.Consequence}" FontSize="11.5"
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="danger" Content="CONFIRM" Command="{Binding Vaults.ConfirmActionCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding Vaults.CancelActionCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Vaults.CanAdministerSelected}">
|
||||
<TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="flat choice" Content="VIEWER" Command="{Binding Vaults.ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="flat choice" Content="MEMBER" Command="{Binding Vaults.ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Member}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
<Button Classes="flat choice" Content="ADMIN" Command="{Binding Vaults.ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="ghost" Content="HAND OVER" Command="{Binding Vaults.HandOverCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}" IsVisible="{Binding Vaults.OwnsSelected}"
|
||||
ToolTip.Tip="Hands this vault to the selected member. They become its owner and you become an admin; only the new owner can hand it on again." />
|
||||
<Button Classes="danger" Content="REMOVE" Command="{Binding Vaults.RemoveMemberCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}"
|
||||
ToolTip.Tip="Removes the selected member and withdraws every key they hold to this vault. It blocks future reads only." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Vaults.CanAdministerSelected}">
|
||||
<TextBlock Classes="settingssection" Text="INVITE" Margin="0" />
|
||||
<Grid ColumnDefinitions="*,140,44">
|
||||
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com"
|
||||
Text="{Binding Vaults.NewMemberEmail}" Margin="0,0,8,0" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
|
||||
<Button Classes="flat choice" Content="VIEWER"
|
||||
Classes.active="{Binding Vaults.AddsAsViewer}"
|
||||
Command="{Binding Vaults.ChooseNewMemberRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}" />
|
||||
<Button Classes="flat choice" Content="MEMBER"
|
||||
Classes.active="{Binding Vaults.AddsAsMember}"
|
||||
Command="{Binding Vaults.ChooseNewMemberRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Member}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2" Classes="accent" Width="44" Content="+"
|
||||
Command="{Binding Vaults.AddMemberCommand}" IsEnabled="{Binding !Vaults.IsBusy}"
|
||||
Margin="8,0,0,0"
|
||||
ToolTip.Tip="Adds the account that signs in with this address. An address with no account here is refused and says so." />
|
||||
</Grid>
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
Text="Adding somebody lets the server serve them this vault. It does not let them read it — that needs a vault key, which SHARE KEY below wraps for them." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding Vaults.SelectedIsPersonal}">
|
||||
<TextBlock Classes="settingssection" Text="YOURS ALONE" Margin="0" />
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
Text="Nobody can be added to your personal vault, and the server refuses a key grant on one outright. Make a vault for the things you want to share, and put them in it." />
|
||||
</StackPanel>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" />
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding Vaults.SelectedIsShared}">
|
||||
<Button Classes="accent" Content="SHARE KEY" Command="{Binding Vaults.ShareVaultCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}"
|
||||
ToolTip.Tip="Wraps this vault's key to the selected member. Their published key is checked against the server's append-only key log first." />
|
||||
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding Vaults.RevokeVaultCommand}"
|
||||
IsEnabled="{Binding !Vaults.IsBusy}"
|
||||
ToolTip.Tip="Withdraws the selected member's key to this vault. Blocks future reads only." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="settingssection" Text="KEY HOLDERS" Margin="0" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Vaults.Grants}" Background="Transparent" BorderThickness="0"
|
||||
MaxHeight="120">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultGrantRowViewModel">
|
||||
<Grid ColumnDefinitions="10,*" Margin="0,3">
|
||||
<Ellipse Grid.Column="0" Width="6" Height="6" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsLive}" Fill="{StaticResource Live}" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" Margin="6,0,0,0">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="settingsrowcaption" FontSize="11" Text="{Binding State}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!--
|
||||
Load-bearing, from the old screen: there is no DELETE here for a reason, and the reason is
|
||||
not "not built yet". The membership list behind a shared vault cannot be archived while the
|
||||
vault exists — that is a different fact from the vault itself, which the card's own DELETE
|
||||
icon does remove.
|
||||
-->
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
Text="A vault's membership list cannot be deleted on its own — the server refuses to archive it while the vault it carries still exists. Deleting the vault itself is the card's own icon, outside this panel." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,29 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Vaults, restyled into the settings page idiom — see the remark at the top of the markup.</summary>
|
||||
internal sealed partial class SettingsVaultsPage : UserControl
|
||||
{
|
||||
public SettingsVaultsPage() => InitializeComponent();
|
||||
|
||||
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
||||
|
||||
/// <remarks>
|
||||
/// Only a press on the wash itself, the same test <c>QuickConnect.OnBackdropPressed</c> makes: a press
|
||||
/// inside the card bubbles through here too, with its source the control that was actually hit rather
|
||||
/// than the backdrop, so closing on those would make the panel impossible to click into.
|
||||
/// </remarks>
|
||||
private void OnBackdropPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (!ReferenceEquals(e.Source, MembersBackdrop) || Shell is not { } shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
shell.Vaults.CloseMembersPanelCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<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.SettingsView"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
v5c: the settings MODE — the full-window chrome swap the design calls Settings, built as one control so
|
||||
MainWindow.axaml can show or hide the whole thing behind MainWindowViewModel.IsSettingsMode with a single
|
||||
IsVisible, the same way it already does for the unlocked application and the setup half of the window.
|
||||
|
||||
Titlebar, then a row of the 340px SettingsNav beside whichever page ActiveSettingsPage names. Seven pages
|
||||
now, all of the design's — v5c-2 added Groups and Tags and retired the old VaultsScreen; see
|
||||
SettingsNav.axaml and design-notes/v5c-fidelity-notes.md.
|
||||
|
||||
Every page including Vaults is wrapped in the 1100px centred column now. SettingsVaultsPage replaces the
|
||||
old VaultsScreen's own 268px-list-beside-a-full-bleed-pane layout with the design's card idiom — see that
|
||||
page's own remark for what changed and why.
|
||||
|
||||
v5c-3: ImportScreen joined the Panel, drawn over SettingsPreferencesPage rather than beside it — see
|
||||
MainWindowViewModel.IsImportOpen. SettingsNav stays lit on Preferences the whole time, per Import.dc.html,
|
||||
because ActiveSettingsPage never actually leaves SettingsPage.Preferences; only IsImportOpen and the pair
|
||||
of IsVisible bindings below move. Its data context is ImportScreen rather than this control's own, the
|
||||
same split MainWindow.axaml drew before the importer moved in here.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,*" Background="{StaticResource Canvas}">
|
||||
|
||||
<views:SettingsTitleBar Grid.Row="0" />
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||
|
||||
<views:SettingsNav Grid.Column="0" />
|
||||
|
||||
<Panel Grid.Column="1">
|
||||
|
||||
<views:SettingsGeneralPage IsVisible="{Binding IsSettingsGeneralPage}" />
|
||||
|
||||
<views:SettingsVaultsPage IsVisible="{Binding IsSettingsVaultsPage}" />
|
||||
|
||||
<views:SettingsAccountPage IsVisible="{Binding IsSettingsAccountPage}" />
|
||||
|
||||
<views:SettingsSecurityPage IsVisible="{Binding IsSettingsSecurityPage}" />
|
||||
|
||||
<views:SettingsPreferencesPage IsVisible="{Binding IsSettingsPreferencesContentShowing}" />
|
||||
|
||||
<views:SettingsGroupsPage IsVisible="{Binding IsSettingsGroupsPage}" />
|
||||
|
||||
<views:SettingsTagsPage IsVisible="{Binding IsSettingsTagsPage}" />
|
||||
|
||||
<!--
|
||||
Wrapped, like MainWindow.axaml wraps every screen whose data context is its own rather than this
|
||||
control's: IsVisible has to resolve against the ambient MainWindowViewModel, and putting it on the
|
||||
same element as the DataContext override below would have it resolve against ImportViewModel
|
||||
instead, where IsImportOpen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsImportOpen}">
|
||||
<views:ImportScreen DataContext="{Binding ImportScreen}" />
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The settings mode's whole chrome: its own titlebar, its own 340px rail, and whichever of its five pages
|
||||
/// <see cref="DodoSSH.Client.Shell.ViewModels.MainWindowViewModel.ActiveSettingsPage"/> names.
|
||||
/// </summary>
|
||||
internal sealed partial class SettingsView : UserControl
|
||||
{
|
||||
public SettingsView() => InitializeComponent();
|
||||
}
|
||||
@@ -10,245 +10,357 @@
|
||||
The list and the writing belong to the vault, as every other item kind's do; this screen is the filter,
|
||||
the editor and the insert over the top. See SnippetsViewModel.
|
||||
|
||||
The two buttons at the bottom right are the whole safety design, and their wording is load-bearing.
|
||||
A terminal is one input stream with no notion of being at a prompt — the remote may be inside vi, or at
|
||||
a sudo password prompt with echo off — so this application cannot say "run this command", only "type
|
||||
this into whatever is there". RUN appears solely for a snippet whose own flag says it runs, which makes
|
||||
that a decision taken once while writing it rather than a button beside every one of them.
|
||||
The two buttons at the bottom right of the detail pane are the whole safety design, and their wording is
|
||||
load-bearing. A terminal is one input stream with no notion of being at a prompt — the remote may be
|
||||
inside vi, or at a sudo password prompt with echo off — so this application cannot say "run this
|
||||
command", only "type this into whatever is there". RUN appears solely for a snippet whose own flag says
|
||||
it runs, which makes that a decision taken once while writing it rather than a button beside every one
|
||||
of them.
|
||||
|
||||
── v5b — Snippets.dc.html ──────────────────────────────────────────────────────────────────────────────
|
||||
A fidelity pass, not a new shape. What moved:
|
||||
|
||||
◆ THE SCREEN'S OWN TITLE. The design renames this screen "Snips" — adopted here on screen, matching the
|
||||
nav rail, which already says Snips (see NavRail.axaml's own remark on the mock's wording for this
|
||||
destination).
|
||||
|
||||
◆ THE HEADER. "Snips" 33 bold, a count chip, the existing filter box restyled to 280px, and a
|
||||
"+ New snip" accent button in place of the old ghost "+ NEW SNIPPET" — same NewCommand.
|
||||
|
||||
◆ THE LIST. Card rows rather than a flat list — radius 10, Track fill and an accent ring on the selected
|
||||
one, Track on hover — with a { } glyph, the mono name, the "runs immediately" chip (real: it is
|
||||
RunsOnInsert, already tracked per snippet), the vault chip, and the mono command preview beneath. No
|
||||
modified date: SnippetSecret carries none, and neither does any other item kind — VaultItem is (id,
|
||||
secret, version, three sync flags) and nothing else; see docs/design-import-gaps.md. The bottom action
|
||||
row keeps EDIT / MOVE TO VAULT… / DELETE, restyled and DELETE now danger-coloured to match.
|
||||
|
||||
◆ THE DETAIL/EDITOR SIDEBAR, widened to 340px per the design. Two modes exist as they always have — the
|
||||
editor while IsEditing, the selected snippet's own facts and its insert controls otherwise — because the
|
||||
two are genuinely different forms doing different things, not a cosmetic split; merging them would let
|
||||
somebody fixing a typo re-file a snippet by leaving a vault picker where they found it. VAULT draws a
|
||||
lock glyph beside the vault name, per the design. "Runs on insert"/"runs immediately" keeps this screen's
|
||||
own longer caption rather than the design's shorter one — it states the operational consequence
|
||||
("typed at the prompt and waits for you") where the design's states only the rationale for having the
|
||||
setting once; both are true, the existing one is more actionable. The insert button keeps its own dynamic
|
||||
label — "TYPE INTO {tab}" or "NO TERMINAL OPEN" — over the design's static "Type into terminal", because
|
||||
naming the destination tab is a fact the design's caption does not carry and this screen already had.
|
||||
Its own caption similarly stays: it names vi and a sudo prompt with echo off as the two ways "at a
|
||||
prompt" can be wrong, which is more than the design's own sentence says.
|
||||
|
||||
◆ THE DELETE CONFIRMATION. Snippets.dc.html's own modal — vault-wide reach, a tombstone, no undo — is
|
||||
exactly what VaultViewModel.HowFarADeletionGoes already says for every other kind of item, so
|
||||
SnippetsViewModel.RequestDelete/ConfirmDelete/CancelDelete say it in the same words. Additive next to the
|
||||
existing uncounted DeleteCommand rather than a change to it — that command is what the phone's own DELETE
|
||||
row still calls, with no confirmation card on that screen to answer one; see SnippetsViewModel's own
|
||||
remarks on both commands.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="*,300">
|
||||
<UserControl.Styles>
|
||||
<!--
|
||||
A card, not a full-bleed row: 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" — distinct from
|
||||
ListBox.filerows, which draws no ring, because a snippet card is chosen for one editor at a time rather
|
||||
than opened directly.
|
||||
-->
|
||||
<Style Selector="ListBox.snipcards > ListBoxItem /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.snipcards > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.snipcards > ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.snipcards > 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,*" Margin="26">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="SNIPPETS" FontSize="12"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Status}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<!-- ============ THE HEADER ============ -->
|
||||
<Grid Grid.Row="0" Margin="0,0,0,20" ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||||
|
||||
<!--
|
||||
The command is searched as well as the name: half of what anybody remembers about a saved
|
||||
command is a word that was inside it.
|
||||
-->
|
||||
<TextBox Grid.Column="2" x:Name="SnippetFilter" Text="{Binding Filter}" Width="240"
|
||||
PlaceholderText="filter by name or command" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<TextBlock Grid.Column="0" Text="Snips" 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 Visible.Count}" FontSize="12.5"
|
||||
Foreground="{StaticResource TextDim}" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="SnippetList" Focusable="True"
|
||||
ItemsSource="{Binding Visible}"
|
||||
SelectedItem="{Binding Selected}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*" Margin="0,7,14,7">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
The flag, where the decision is made. A snippet that presses Enter for you is not the
|
||||
same kind of thing as one that does not, and the list is where somebody chooses between
|
||||
them.
|
||||
-->
|
||||
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding RunsOnInsert}">
|
||||
<TextBlock Text="runs immediately" FontSize="9.5" />
|
||||
</Border>
|
||||
<Border Classes="chip warn" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
||||
</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; the row is where somebody
|
||||
decides whether the thing they are about to edit is theirs alone.
|
||||
-->
|
||||
<Border Classes="chip" Padding="4,0" IsVisible="{Binding HasVaultBadge}">
|
||||
<TextBlock Text="{Binding VaultBadge}" FontSize="9.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
|
||||
text reads as a single command, which is the thing being decided about on this row.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding Preview}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
<!--
|
||||
The command is searched as well as the name: half of what anybody remembers about a saved command is
|
||||
a word that was inside it.
|
||||
-->
|
||||
<TextBox Grid.Column="3" x:Name="SnippetFilter" Text="{Binding Filter}" Width="280" Height="40"
|
||||
CornerRadius="10" Margin="0,0,10,0"
|
||||
PlaceholderText="filter by name or command" VerticalAlignment="Center" />
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="360"
|
||||
IsVisible="{Binding !HasVisible}" />
|
||||
|
||||
<Border Grid.Row="2" Padding="14,8" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,1,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="+ NEW SNIPPET" Command="{Binding NewCommand}" />
|
||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
<!--
|
||||
Sharing a snippet, which is what moving one into a team's vault is. Beside EDIT rather than
|
||||
inside it: 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 — and a picker inside the form would let
|
||||
somebody correcting a typo hand a command to a team by leaving it where they found it. The
|
||||
picker itself opens beside the snippet, in the pane on the right.
|
||||
-->
|
||||
<Button Classes="ghost" Content="MOVE TO VAULT…" Command="{Binding MoveCommand}"
|
||||
IsEnabled="{Binding CanMove}"
|
||||
ToolTip.Tip="Re-encrypts this snippet with another vault's key. Everybody who holds that key can then read and insert it." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Button Grid.Column="4" Classes="accent" Height="40" FontSize="13.5" Content="+ New snip"
|
||||
Command="{Binding NewCommand}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="14,16" Spacing="8">
|
||||
<!-- ============ THE BORDERED BODY ============ -->
|
||||
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
|
||||
ClipToBounds="True">
|
||||
<Grid ColumnDefinitions="*,340">
|
||||
|
||||
<!-- ============ The editor ============ -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||
<Grid Grid.Column="0" RowDefinitions="*,Auto" Background="{StaticResource Pane}">
|
||||
|
||||
<!--
|
||||
Which vault a *new* snippet is filed into, asked on the form it is being typed into rather
|
||||
than through a standing preference elsewhere. Not drawn for an existing snippet — its vault is
|
||||
not a field of this form, and changing it is MOVE — and not drawn at all where there is only
|
||||
one vault to choose between, because a control offering one option is a question nobody asked.
|
||||
-->
|
||||
<StackPanel Spacing="4" IsVisible="{Binding ShowsEditorVaultChoice}">
|
||||
<TextBlock Classes="label" Text="VAULT" />
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding EditorVaultChoices}"
|
||||
SelectedItem="{Binding EditorSelectedVault}">
|
||||
<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="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
|
||||
<ListBox Grid.Row="0" x:Name="SnippetList" Classes="snipcards" Focusable="True"
|
||||
ItemsSource="{Binding Visible}"
|
||||
SelectedItem="{Binding Selected}"
|
||||
Margin="10,10,10,4">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<StackPanel Margin="6,7" Spacing="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="mono" Text="{}{ }" FontSize="11" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
The flag, where the decision is made. A snippet that presses Enter for you is not the
|
||||
same kind of thing as one that does not, and the list is where somebody chooses between
|
||||
them.
|
||||
-->
|
||||
<Border Classes="chip warn" Padding="7,0" Height="18" VerticalAlignment="Center"
|
||||
IsVisible="{Binding RunsOnInsert}">
|
||||
<TextBlock Text="runs immediately" FontSize="9.5" />
|
||||
</Border>
|
||||
<Border Classes="chip warn" Padding="7,0" Height="18" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
||||
</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; the row is where somebody
|
||||
decides whether the thing they are about to edit is theirs alone.
|
||||
-->
|
||||
<Border Classes="chip" Padding="7,0" Height="18" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasVaultBadge}">
|
||||
<TextBlock Text="{Binding VaultBadge}" FontSize="9.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
|
||||
text reads as a single command, which is the thing being decided about on this row.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding Preview}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="0" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="360"
|
||||
IsVisible="{Binding !HasVisible}" />
|
||||
|
||||
<Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,1,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
<!--
|
||||
Sharing a snippet, which is what moving one into a team's vault is. Beside EDIT rather than
|
||||
inside it: 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 — and a picker inside the form would
|
||||
let somebody correcting a typo hand a command to a team by leaving it where they found it.
|
||||
The picker itself opens beside the snippet, in the pane on the right.
|
||||
-->
|
||||
<Button Classes="ghost" Content="Move to vault…" Command="{Binding MoveCommand}"
|
||||
IsEnabled="{Binding CanMove}"
|
||||
ToolTip.Tip="Re-encrypts this snippet with another vault's key. Everybody who holds that key can then read and insert it." />
|
||||
<Button Classes="danger" Content="Delete" Command="{Binding RequestDeleteCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator
|
||||
has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell
|
||||
waiting for one that never comes.
|
||||
-->
|
||||
<TextBox Text="{Binding EditorCommand}" PlaceholderText="the command" AcceptsReturn="True"
|
||||
Height="140" TextWrapping="NoWrap" FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="12" />
|
||||
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="48" TextWrapping="Wrap" />
|
||||
<CheckBox IsChecked="{Binding EditorRunsOnInsert}"
|
||||
Content="Press Enter after inserting this" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Off means the command is typed at the prompt and waits for you. That single Enter is the only thing standing between a saved command and a running one, so leave it off unless you meant it." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- ============ The selected snippet ============ -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding !IsEditing}">
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="18,20" Spacing="10">
|
||||
|
||||
<TextBlock Classes="hint" FontSize="12"
|
||||
Text="Choose a snippet to see it in full and put it into a terminal."
|
||||
IsVisible="{Binding !HasSelection}" />
|
||||
<!-- ============ The editor ============ -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsEditing}">
|
||||
<TextBlock Classes="label" Text="NAME" FontSize="10" />
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" FontFamily="{StaticResource MonoFont}" />
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
<!--
|
||||
Which vault a *new* snippet is filed into, asked on the form it is being typed into rather
|
||||
than through a standing preference elsewhere. Not drawn for an existing snippet — its vault
|
||||
is not a field of this form, and changing it is MOVE — and not drawn at all where there is
|
||||
only one vault to choose between, because a control offering one option is a question
|
||||
nobody asked.
|
||||
-->
|
||||
<StackPanel Spacing="4" IsVisible="{Binding ShowsEditorVaultChoice}">
|
||||
<TextBlock Classes="label" Text="VAULT" FontSize="10" />
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding EditorVaultChoices}"
|
||||
SelectedItem="{Binding EditorSelectedVault}">
|
||||
<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="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Named here as well as on the row, because this pane is where somebody decides to put a
|
||||
command into a production terminal, and who else holds a key to it is part of that decision.
|
||||
The row's badge is off the screen by the time this is being read.
|
||||
-->
|
||||
<Border Classes="chip" Padding="4,0" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding HasSelectionVaultBadge}">
|
||||
<TextBlock Text="{Binding SelectionVaultBadge}" FontSize="9.5" />
|
||||
</Border>
|
||||
<TextBlock Classes="label" Text="COMMAND" FontSize="10" />
|
||||
<!--
|
||||
Stored exactly as typed — no trimming, no newline normalisation. A here-document's
|
||||
terminator has to arrive on a line of its own, and tidying the trailing newline off it
|
||||
leaves the shell waiting for one that never comes.
|
||||
-->
|
||||
<TextBox Text="{Binding EditorCommand}" PlaceholderText="the command" AcceptsReturn="True"
|
||||
Height="140" TextWrapping="NoWrap" FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="12" />
|
||||
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="48" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Snippet.Command}"
|
||||
FontSize="10.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="3">
|
||||
<TextBlock Text="Runs on insert" FontSize="12.5" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Off means the command is typed at the prompt and waits for you. That single Enter is the only thing standing between a saved command and a running one, so leave it off unless you meant it." />
|
||||
</StackPanel>
|
||||
<CheckBox Grid.Column="1" IsChecked="{Binding EditorRunsOnInsert}"
|
||||
VerticalAlignment="Top" Margin="10,0,0,0" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Snippet.Notes}" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap" Margin="0,6,0,0"
|
||||
IsVisible="{Binding Selected.Snippet.Notes, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!--
|
||||
The button names the tab it will type into. This screen is not the terminal — the strip
|
||||
above it is — so "INSERT" alone would leave somebody working out which of six open tabs is
|
||||
about to receive a command, at the moment that is worst to be wrong about.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding ShowsSelectionActions}">
|
||||
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
|
||||
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
|
||||
|
||||
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Left"
|
||||
Command="{Binding RunCommand}"
|
||||
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
|
||||
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
|
||||
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
◆ SHARING THE SNIPPET, which is what moving it into a team's vault amounts to. A picker and
|
||||
two buttons, not a question with a yes: what is being asked is which vault, and a move is
|
||||
undone by moving it back rather than by being careful — so this is not drawn in the danger
|
||||
colours the deletion question uses.
|
||||
|
||||
It takes the insert controls' place while it is up, for the reason the host pane hides
|
||||
CONNECT: the button that opened this is still on screen otherwise, offering to open it again.
|
||||
|
||||
The sentence is the part worth keeping. Unlike a host, a snippet crosses whole — it has no
|
||||
group and no tags to leave behind — so what there is to say is who can read it afterwards,
|
||||
and for a command that may carry a hostname or a path that is the whole of the decision.
|
||||
-->
|
||||
<StackPanel Spacing="8" Margin="0,14,0,0" IsVisible="{Binding IsMoving}">
|
||||
<TextBlock Classes="label" Text="MOVE TO VAULT" />
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveVaultChoices}"
|
||||
SelectedItem="{Binding SelectedMoveVault}">
|
||||
<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="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." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveCommand}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,6,0,0">
|
||||
<Button Classes="accent" Content="Save" Command="{Binding SaveCommand}" />
|
||||
<Button Classes="ghost" Content="Cancel" Command="{Binding CancelCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<!-- ============ The selected snippet ============ -->
|
||||
<StackPanel Spacing="10" IsVisible="{Binding !IsEditing}">
|
||||
|
||||
<TextBlock Classes="hint" FontSize="12"
|
||||
Text="Choose a snippet to see it in full and put it into a terminal."
|
||||
IsVisible="{Binding !HasSelection}" />
|
||||
|
||||
<StackPanel Spacing="10" IsVisible="{Binding HasSelection}">
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="15"
|
||||
FontWeight="Bold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Classes="label" Text="COMMAND" FontSize="10" />
|
||||
<Border CornerRadius="10" Background="{StaticResource Pane}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" Padding="12">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Snippet.Command}"
|
||||
FontSize="11.5" LineHeight="19"
|
||||
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Snippet.Notes}" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Selected.Snippet.Notes, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!--
|
||||
VAULT, with the design's own lock glyph. Named here as well as on the row, because this
|
||||
pane is where somebody decides to put a command into a production terminal, and who else
|
||||
holds a key to it is part of that decision.
|
||||
-->
|
||||
<StackPanel Spacing="4" IsVisible="{Binding HasSelectionVaultBadge}">
|
||||
<TextBlock Classes="label" Text="VAULT" FontSize="10" />
|
||||
<Border Height="38" CornerRadius="10" Background="{StaticResource Pane}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" Padding="12,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SelectionVaultBadge}"
|
||||
FontSize="11.5" Foreground="{StaticResource TextDim}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" FontFamily="{StaticResource IconFont}" Text=""
|
||||
FontSize="14" Foreground="{StaticResource TextFaint}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The button names the tab it will type into, over the design's static "Type into
|
||||
terminal" — see the file-level remark above for why the dynamic label is the truer one.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding ShowsSelectionActions}">
|
||||
<Button Classes="accent" Content="{Binding InsertLabel}" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
|
||||
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
|
||||
|
||||
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding RunCommand}"
|
||||
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
|
||||
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" TextAlignment="Center"
|
||||
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
◆ SHARING THE SNIPPET, which is what moving it into a team's vault amounts to. A picker
|
||||
and two buttons, not a question with a yes.
|
||||
-->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsMoving}">
|
||||
<TextBlock Classes="label" Text="MOVE TO VAULT" FontSize="10" />
|
||||
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveVaultChoices}"
|
||||
SelectedItem="{Binding SelectedMoveVault}">
|
||||
<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="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." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="Move" Command="{Binding ConfirmMoveCommand}" />
|
||||
<Button Classes="ghost" Content="Cancel" Command="{Binding CancelMoveCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
◆ THE DELETE CONFIRMATION. See RequestDelete's own remarks for why this is additive
|
||||
beside DeleteCommand rather than a change to it.
|
||||
-->
|
||||
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="1" CornerRadius="10" Padding="12"
|
||||
IsVisible="{Binding IsConfirmingDelete}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
|
||||
Text="{Binding DeleteQuestion}" />
|
||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding DeleteConsequence}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="danger" Content="Delete snippet"
|
||||
Command="{Binding ConfirmDeleteCommand}" />
|
||||
<Button Classes="ghost" Content="Cancel" Command="{Binding CancelDeleteCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.TerminalTabs"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The window's tab strip: three fixed tabs, then one per open terminal.
|
||||
|
||||
── IT IS NOT ONLY TERMINALS ANY MORE, and the type is still called TerminalTabs. ─────────────────────
|
||||
Vaults, SFTP and S3 sit at the head of the strip and are always there. The name stays because the strip
|
||||
is named in MainWindow, in the layout harness's height budget and in its own suite, and renaming a type
|
||||
to track what it grew into is a rename across four files that leaves the product identical. What the
|
||||
name now under-describes is written here instead.
|
||||
|
||||
── THE THREE FIXED TABS ─────────────────────────────────────────────────────────────────────────────
|
||||
None of the three can be closed, and that is the difference between them and a terminal rather than a
|
||||
styling choice. A terminal tab owns a shell and closing it ends that shell; these three own nothing —
|
||||
they are three places this window goes, and a close box on one would be asking whether to destroy a
|
||||
destination.
|
||||
|
||||
Vaults is first and is the only one with anything under it: the nav rail, and whichever of its screens
|
||||
the rail points at. SFTP and S3 were rail entries until this strip existed, and they moved because they
|
||||
are the two destinations you *stay in* while something runs. The rail is drawn only under Vaults; see
|
||||
MainWindowViewModel.IsVaultsTab for why that is expressed as a page test rather than as a surface.
|
||||
|
||||
S3 carries no count although the rail entry it replaces did. There is room for one, and a number on two
|
||||
of five tabs reads as a fact about those two rather than as the tab's own state — a terminal tab has
|
||||
nothing to count, and the eye reads the strip left to right expecting the same shape.
|
||||
|
||||
── THE TERMINAL TABS ────────────────────────────────────────────────────────────────────────────────
|
||||
Every one is one pane in the one WebView, so switching is a single frame telling the page which pane to
|
||||
show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and
|
||||
goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing
|
||||
one is the only thing in this application that deliberately ends a session.
|
||||
|
||||
The strip spans the whole window rather than one screen, which is what it is for: a connection you
|
||||
opened stays visible and one click away while you are looking at a transfer, a key, or preferences.
|
||||
Clicking a tab switches the window's surface to that terminal — see MainWindowViewModel.ShellSurface.
|
||||
|
||||
Two of the design's header controls are still absent: SPLIT and FORWARDS. Splits would need a second
|
||||
pane geometry the renderer does not have, and port forwarding does not exist in the SSH layer. Two
|
||||
disabled buttons would teach nobody anything; see docs/design-import-gaps.md.
|
||||
|
||||
An ItemsControl of buttons rather than a TabStrip, because the selection lives on the shell — a tab
|
||||
outlives the vault that opened it — and a strip that owned its own selection would be a second copy of
|
||||
that state.
|
||||
|
||||
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Tabs became pills: taller, rounded, each with its own outline, on the sidebar's surface rather than the
|
||||
chrome's. The design puts a "Hosts" pill at the head of this strip and hides the sidebar while a session
|
||||
is showing, so that pill is the only way back. The three fixed tabs are that idea taken at its word and
|
||||
one step further: the rail is not hidden, but it belongs to the Vaults tab, and the head of the strip is
|
||||
where you go to get back to it.
|
||||
-->
|
||||
|
||||
<Border Height="42" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
|
||||
<!--
|
||||
Everything in one scrolling row: the three fixed tabs, a rule, the terminals, the button that opens
|
||||
another, then the sentence for when there are none. The strip stays rather than collapsing — a row of
|
||||
chrome that appears and disappears would move every screen up and down by 42 pixels each time the last
|
||||
tab closed.
|
||||
-->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel Orientation="Horizontal" Margin="8,0,0,0">
|
||||
|
||||
<!--
|
||||
The three that are always here. Buttons with no close box, marked active from the shell's own
|
||||
state rather than holding a selection of their own — the same reason the terminal tabs below are
|
||||
buttons and not a TabStrip.
|
||||
|
||||
Each is lit by a different property and the three are exclusive by construction: IsVaultsTab is
|
||||
"a page, and not one of these two", and the other two are the existing IsTransfersShowing and
|
||||
IsBucketsShowing that both heads already use. Nothing here can light two at once.
|
||||
-->
|
||||
<!--
|
||||
Two buttons drawn as one pill: the tab, and a caret that opens the vault menu. Split rather than
|
||||
one button with a menu, because the tab's job is to go somewhere and that must stay a single
|
||||
click — a tab you cannot press without being asked a question is not a tab.
|
||||
|
||||
── WHY A FLYOUT IS SAFE HERE, WHEN THE + BUTTON BELOW STILL REFUSES ONE ────────────────────────
|
||||
That refusal stands and its reasoning is unchanged: this strip sits directly above the WebView's
|
||||
rectangle, and whether a popup dropping into it composites above a native child window is not
|
||||
something this project treats as settled without a screenshot.
|
||||
|
||||
What makes the question not arise here is the order in the handler. OnVaultMenuPressed selects
|
||||
the Vaults tab *first*, which sets the shell's surface to a page and collapses the renderer — so
|
||||
by the time the flyout opens there is no native child window under it. Exactly the move
|
||||
QuickConnect already makes. It is also the behaviour a user expects: the caret belongs to the
|
||||
Vaults tab, so pressing it going to Vaults is not a surprise.
|
||||
|
||||
The handler is explicit rather than Button.Flyout's own open, so that ordering is a thing the
|
||||
code states and the headless suite can assert, rather than a thing the framework happens to do.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="0">
|
||||
|
||||
<Button Classes="flat tab fixed split" Classes.active="{Binding IsVaultsTab}"
|
||||
Command="{Binding ShowVaultsCommand}"
|
||||
ToolTip.Tip="Your keychain: hosts, keys, pins, snippets and logs">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<TextBlock Text="▦" FontSize="13" VerticalAlignment="Center" />
|
||||
<TextBlock Text="Vaults" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="VaultMenu" Classes="flat tab fixed caret" Width="22"
|
||||
Classes.active="{Binding IsVaultsTab}"
|
||||
Click="OnVaultMenuPressed"
|
||||
ToolTip.Tip="Choose which vaults this window shows, or make a new one">
|
||||
<TextBlock Text="⌄" FontSize="11" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
|
||||
<FlyoutBase.AttachedFlyout>
|
||||
<Flyout Placement="BottomEdgeAlignedLeft">
|
||||
<StackPanel Width="230" Spacing="8">
|
||||
|
||||
<!--
|
||||
Chips rather than checkable menu items. Nothing in this application uses a checkable
|
||||
MenuItem, and binding one needs an ItemContainerTheme to reach ToggleType and IsChecked
|
||||
plus a composed collection to put a fixed entry after a bound one — where the chip
|
||||
toggle beside every host's tags already says on-and-off in this window's own language.
|
||||
-->
|
||||
<TextBlock Classes="label" Text="SHOW ITEMS FROM"
|
||||
IsVisible="{Binding HasVaultSwitches}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding VaultToggles}"
|
||||
IsVisible="{Binding HasVaultSwitches}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultToggleViewModel">
|
||||
<Button Classes="chiptoggle" Classes.worn="{Binding IsShown}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
|
||||
Margin="0,0,0,4"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ToggleVaultCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock Text="{Binding Display}" FontSize="11"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
A hint rather than a disabled switch, because the personal vault's chip is drawn lit and
|
||||
pressing it says the same thing in the status bar. One sentence under the list is where
|
||||
somebody looks when a chip does not move.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasVaultSwitches}"
|
||||
Text="Switching a vault off only stops it being listed here. It still syncs, and hosts that authenticate with its keys still connect." />
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}"
|
||||
IsVisible="{Binding HasVaultSwitches}" />
|
||||
|
||||
<!--
|
||||
A handler rather than a Command binding, because this one navigates and the menu has to
|
||||
shut on the way. A Flyout stays open when something inside it is pressed — which is
|
||||
right for the chips above, where switching two vaults off is one visit — and wrong for
|
||||
the one entry that leaves.
|
||||
-->
|
||||
<Button Classes="ghost" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Left"
|
||||
Content="New vault…"
|
||||
Click="OnNewVaultPressed"
|
||||
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" />
|
||||
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</FlyoutBase.AttachedFlyout>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<Button Classes="flat tab fixed" Classes.active="{Binding IsTransfersShowing}"
|
||||
Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}"
|
||||
ToolTip.Tip="Move files to and from a host over SFTP">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<TextBlock Text="⇅" FontSize="13" VerticalAlignment="Center" />
|
||||
<TextBlock Text="SFTP" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
The same screen as SFTP over the same view model — an object store and an SFTP host are both an
|
||||
IRemoteFileStore — and a separate tab anyway, because which picker is offered is decided by the
|
||||
destination rather than by a toggle inside the screen. See ShowFiles, which also explains why
|
||||
pressing this while an SFTP session is open refuses instead of arriving.
|
||||
-->
|
||||
<Button Classes="flat tab fixed" Classes.active="{Binding IsBucketsShowing}"
|
||||
Command="{Binding ShowFilesCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}"
|
||||
ToolTip.Tip="Objects in an S3-compatible bucket from your keychain">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<TextBlock Text="◳" FontSize="13" VerticalAlignment="Center" />
|
||||
<TextBlock Text="S3" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
What separates the fixed tabs from the terminals. Without it the strip is five pills of the same
|
||||
shape and the user has to read all five to learn that three of them are places and two are
|
||||
machines. It is a rule rather than a gap because a gap at this width reads as the strip having
|
||||
been laid out carelessly.
|
||||
-->
|
||||
<Border Width="1" Height="18" Margin="6,0,10,0" VerticalAlignment="Center"
|
||||
Background="{StaticResource Border}" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Tabs}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
|
||||
<!--
|
||||
The close box is inside the tab, not beside it. Beside it, the two were siblings in a grid:
|
||||
the cross was as tall as the strip and sat outside the tab's own background, so it read as a
|
||||
divider between tabs rather than as part of one, and the tab it belonged to was ambiguous
|
||||
for the tab to its right.
|
||||
|
||||
Nested buttons work, and it is worth knowing why rather than assuming. Avalonia's
|
||||
Button.OnPointerPressed checks IsLeftButtonPressed, takes the pointer capture and marks the
|
||||
event handled — so a left press on the cross does not also select the tab. It deliberately
|
||||
does not handle any other button, which is exactly what lets a middle press bubble out of
|
||||
the cross and reach the handler below.
|
||||
|
||||
Marked active on IsShowing rather than on IsSelected, which are not the same question. The
|
||||
selection survives navigating away — that is what makes the strip a way back to a terminal —
|
||||
so a tab lit while preferences filled the window would be a second "you are here" mark
|
||||
pointing at something nobody can see. See TerminalTabViewModel.IsShowing.
|
||||
-->
|
||||
<Button Classes="flat tab"
|
||||
Classes.active="{Binding IsShowing}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
PointerPressed="OnTabPointerPressed"
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!--
|
||||
Green while the shell behind this tab is running, grey while it is connecting and once
|
||||
it has ended. The pane keeps its scrollback either way, which is usually why somebody is
|
||||
still looking at a tab whose dot has gone out.
|
||||
-->
|
||||
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
What a tab with no pane has to say for itself: "connecting…" while the handshake runs,
|
||||
and the refusal once one has failed. It is here rather than only on the card because the
|
||||
whole point of not blocking the window is that the user is somewhere else — the strip is
|
||||
the one piece of chrome that is on screen wherever that is.
|
||||
-->
|
||||
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" FontSize="10.5"
|
||||
MaxWidth="180" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding !HasSession}" />
|
||||
|
||||
<!--
|
||||
Always drawn, never on hover only. The strip has no other close affordance, and one
|
||||
that appears when the pointer is already over the tab cannot be found by somebody
|
||||
looking for it.
|
||||
-->
|
||||
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((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, which is also what Ctrl+K does — so the tooltip can say that
|
||||
honestly, and there is one way to start a connection rather than two that have to agree.
|
||||
|
||||
Not a MenuFlyout offering "SSH" and "local shell", which is the nicer-looking answer and is not
|
||||
verifiably safe here: this strip sits directly above the WebView's rectangle, and whether a popup
|
||||
dropping into it composites above a native child window depends on whether Avalonia gives it its
|
||||
own platform window. docs/platform-flags.md records what this project already paid for treating a
|
||||
rendering claim as settled without a screenshot. The palette has no such question — opening it
|
||||
collapses the terminal outright.
|
||||
-->
|
||||
<Button Classes="flat tab plus" Width="30"
|
||||
Command="{Binding ToggleSearchCommand}"
|
||||
ToolTip.Tip="Open a connection · Ctrl+K">
|
||||
<TextBlock Text="+" FontSize="15" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Nothing open, and this is where that is said. It is also the only place near the terminal that can
|
||||
carry a sentence at all: the rectangle below is a native child window, and anything Avalonia draws
|
||||
in it is drawn underneath.
|
||||
-->
|
||||
<TextBlock Classes="mono" FontSize="10.5"
|
||||
Text="no terminals open · press + or Ctrl+K, or choose a host and press Connect"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding !HasTabs}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -1,108 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The tab strip, above every screen.</summary>
|
||||
internal sealed partial class TerminalTabs : UserControl
|
||||
{
|
||||
public TerminalTabs() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Closes a tab on a middle click.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Wired on the tab's own template root, which is the whole answer to "and not on the strip itself".
|
||||
/// A middle press on the background, on the sentence, or on the button that opens a connection reaches
|
||||
/// no handler at all, because there is none there to reach. Nothing has to test what was clicked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><c>PointerUpdateKind</c>, not <c>IsMiddleButtonPressed</c>.</b> The latter reports button
|
||||
/// <em>state</em>: it is equally true for a left press made while the middle button happens to be held,
|
||||
/// and for every press during a middle drag. The question here is which button caused this press, and
|
||||
/// that is the one thing only <c>PointerUpdateKind</c> answers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On press rather than on release, which is what every browser and every terminal does. Matching a
|
||||
/// release to its press would need capture tracking, to buy the ability to change your mind about a
|
||||
/// middle click — a gesture nobody makes by accident and nobody aborts.
|
||||
/// </para>
|
||||
/// </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;
|
||||
}
|
||||
|
||||
// Handled, so the strip's ScrollViewer does not also take this as the start of a pan.
|
||||
e.Handled = true;
|
||||
|
||||
// Fire-and-forget, as the host sidebar's double-tap connect is: CloseTabCommand is asynchronous —
|
||||
// it waits for the workspace to tear the session down — and an event handler has nowhere to await
|
||||
// it. Its failures are the workspace's to report, not this strip's.
|
||||
shell.CloseTabCommand.Execute(tab);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the vault menu, on the Vaults tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The tab is selected before the menu opens, and that order is the whole reason this is a handler
|
||||
/// rather than <c>Button.Flyout</c>.</b> Selecting it puts the shell on a page, which collapses the
|
||||
/// renderer — so the popup never has to drop over the WebView's native child window, and the question
|
||||
/// this strip's comment refuses to answer without a screenshot does not come up. See the comment on the
|
||||
/// caret in the markup, and <c>docs/platform-flags.md</c> for what treating such a question as settled
|
||||
/// has already cost this project.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is also what a user expects. The caret belongs to the Vaults tab, so pressing it arriving at
|
||||
/// Vaults is the same gesture as pressing the tab, with a menu on the end.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnVaultMenuPressed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel shell || sender is not Control caret)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
shell.ShowVaultsCommand.Execute(null);
|
||||
|
||||
FlyoutBase.ShowAttachedFlyout(caret);
|
||||
}
|
||||
|
||||
/// <summary>Leaves for the teams screen with the new-vault form open, shutting the menu behind it.</summary>
|
||||
/// <remarks>
|
||||
/// The menu is closed first, because the command navigates and a flyout left open would be hanging over
|
||||
/// a screen it has nothing to do with. A <c>Flyout</c> does not close when something inside it is
|
||||
/// pressed — which is what the switches above it want, and not what this wants.
|
||||
/// </remarks>
|
||||
private void OnNewVaultPressed(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MainWindowViewModel shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.FindControl<Button>("VaultMenu") is { } caret)
|
||||
{
|
||||
FlyoutBase.GetAttachedFlyout(caret)?.Hide();
|
||||
}
|
||||
|
||||
shell.ShowNewVaultCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
@@ -9,86 +9,91 @@
|
||||
|
||||
The window asks Windows for no chrome at all, so everything a titlebar does has to be here: dragging,
|
||||
the double-click to maximise, and three buttons. That is a real cost, and the reason it is worth paying
|
||||
is that a 38-pixel grey system bar above a near-black application is the one part of the window that
|
||||
is that a 53-pixel grey system bar above a near-black application is the one part of the window that
|
||||
would look borrowed.
|
||||
|
||||
It sits above the terminal rather than over it, which matters more than it looks: the terminal is a
|
||||
native child window that composites above anything Avalonia draws in the same rectangle, so a titlebar
|
||||
overlapping it would be painted underneath and its close button would not be clickable.
|
||||
|
||||
── v5b ──────────────────────────────────────────────────────────────────────────────────────────────
|
||||
Redrawn against TitleBar.dc.html rather than the v3 mock this replaced, and three things left with the
|
||||
redraw. The vault chip and the account name are gone from here — both now live on the rail's user chip,
|
||||
which is where the design's own "who is signed in" lives too, one destination lower than the window's
|
||||
own name. 44 pixels became 53, which is the design's own height and not a number this file chose; see
|
||||
<c>LayoutHarness.TitleBarHeight</c> and the matching 9-pixel rise in <c>MainWindow.axaml</c>'s own
|
||||
<c>MinHeight</c>, which is what keeps every screen the exact height it was designed against despite the
|
||||
bar above it growing.
|
||||
|
||||
The design's kbd chip reads ⌘K; this one reads CTRL K, because a Windows build is not where the ⌘ key
|
||||
lives — the same substitution the search box's own tooltip already made before this pass touched it.
|
||||
SYNCED stays, on the right, past the window buttons' own left edge: a documented deviation from a design
|
||||
whose titlebar has no home for it at all — see design-notes/v5b-fidelity-notes.md.
|
||||
-->
|
||||
|
||||
<Border Height="44" Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="14,0,10,0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<!--
|
||||
Filled rather than outlined since v2, and rounded. The same mark the phone's header draws and the
|
||||
same one the launcher icon carries, so the three cannot drift.
|
||||
24 pixels and radius 7, the design's own tile — up from v3's 20/6, which was this bar's own
|
||||
approximation before there was a mock to measure against. Filled rather than outlined, and the
|
||||
same mark the phone's header draws and the launcher icon carries, so the three cannot drift.
|
||||
-->
|
||||
<Border Width="20" Height="20" CornerRadius="6" Background="{StaticResource Accent}">
|
||||
<Border Width="24" Height="24" CornerRadius="7" Background="{StaticResource Accent}">
|
||||
<TextBlock Classes="mono" Text=">_" FontSize="10" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentInk}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<!--
|
||||
The product's name is the one string in this bar that is not machine-shaped, so v2 sets it in the
|
||||
sans face while the address, the account and the fingerprint beside it stay monospaced.
|
||||
19 bold at -0.2 tracking, the design's own numbers. Named, because a nightly says so here — see
|
||||
the code-behind, where the release build is exactly what this markup says and nothing changes
|
||||
for it.
|
||||
-->
|
||||
<!--
|
||||
Named, because a nightly says so here. See the code-behind: the release build is what this
|
||||
markup says and nothing changes for it.
|
||||
-->
|
||||
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
<!--
|
||||
The design puts an organisation here — "dodotech / platform". There are no organisations: the
|
||||
server has team tables and no endpoint that reads them, so the only name this application can
|
||||
truthfully print is the one on the vault it has open. The account is beside it because a machine
|
||||
can be enrolled to one account at a time and knowing which is the point of the chip.
|
||||
-->
|
||||
<Border Classes="chip" IsVisible="{Binding IsUnlocked}">
|
||||
<TextBlock Text="{Binding Vault.VaultName}" />
|
||||
</Border>
|
||||
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="11"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" MaxWidth="220" />
|
||||
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="19" FontWeight="Bold"
|
||||
LetterSpacing="-0.2" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The quick-connect box. It searches hosts and nothing else — the design's box also promises "run
|
||||
command", and there is no snippet or saved-command item type for it to run. Clicking it is the same
|
||||
as Ctrl+K, which is what the window binds; the design says ⌘K, and this is a Windows build.
|
||||
|
||||
514 wide and centred, which is what the design states for this box specifically — MaxWidth rather
|
||||
than Width, so the button is free to shrink at the window's minimum instead of arranging outside its
|
||||
own parent when there is no room for all 514 of it.
|
||||
-->
|
||||
<!--
|
||||
Stretch-to-a-maximum, not a fixed width. The design draws this box at exactly 380 and centred, and
|
||||
stating that as a Width on the Border is what makes it wrong: the Button around it is free to
|
||||
shrink when the account name or the vault chip beside it is long, and a Border that will not shrink
|
||||
with it arranges outside its own parent — over the name on one side and over the window buttons on
|
||||
the other. MaxWidth on the stretching button gives the same 380 whenever there is room and gives
|
||||
way when there is not.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="flat" MaxWidth="380" Height="28" Margin="16,0"
|
||||
<Button Grid.Column="1" Classes="flat search" MaxWidth="514" Height="35"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding ToggleSearchCommand}" IsEnabled="{Binding IsUnlocked}">
|
||||
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10,0">
|
||||
<Border Classes="searchpill" CornerRadius="10" Padding="16,0,8,0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="⌕" FontSize="13"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" Text=""
|
||||
FontSize="15" Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||
<!--
|
||||
"Search or connect…", which is what it does: the palette connects on Enter. Not the design's
|
||||
wider promise of running a command — a snippet is inserted from its own screen, and a box that
|
||||
offered to run one would be offering something this palette does not do.
|
||||
-->
|
||||
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13" Margin="8,0"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="2" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
|
||||
CornerRadius="4" Padding="5,1" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="CTRL K" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||
<!--
|
||||
CTRL K, not the design's ⌘K — see the remark at the top of this file.
|
||||
|
||||
◆ PADDED RATHER THAN 34 WIDE, which is the design's own width for a chip reading ⌘K: two
|
||||
glyphs, where the substitution this bar makes is six characters and a space. At 10.5 mono
|
||||
that run is wider than 34, so the chip clipped it — "CTRL" with the K cut in half. MinWidth
|
||||
keeps the design's footprint for the day this face has a ⌘ to draw, and the padding is what
|
||||
the longer label actually needs.
|
||||
-->
|
||||
<Border Grid.Column="2" MinWidth="34" Height="18" CornerRadius="5" Padding="7,0"
|
||||
Background="{StaticResource KbdChip}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -112,20 +117,28 @@
|
||||
<Border Width="1" Height="16" Background="{StaticResource Border}"
|
||||
IsVisible="{Binding IsUnlocked}" />
|
||||
|
||||
<!--
|
||||
The window controls, restyled to the design's own quiet Material glyphs: remove, crop_square,
|
||||
close, rather than the dashes and box this bar drew before there was a mock to measure them
|
||||
against. What each does is unchanged — see the code-behind.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Spacing="2">
|
||||
<Button Classes="flat" Width="26" Height="24" Click="OnMinimise"
|
||||
ToolTip.Tip="Minimise">
|
||||
<TextBlock Text="–" FontSize="13" Foreground="{StaticResource TextDim}"
|
||||
<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 Text="▢" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||
<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 Text="✕" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="14"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -35,40 +35,71 @@
|
||||
|
||||
<UserControl.Styles>
|
||||
<!--
|
||||
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
|
||||
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
|
||||
described as deliberately rare. This is the one place it is spent.
|
||||
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
A directory used to be marked by colour alone — this application shipped no icon set when that rule
|
||||
was written. It ships one now: v5b embeds Material Icons as IconFont, already spent on the nav rail,
|
||||
the drawer and the session shell's own sidebar, and per SFTP.dc.html a file row draws a 14px glyph of
|
||||
its own — folder for a directory, and the closest classic Material Icons has to the design's own
|
||||
Material-Symbols-only "draft": insert_drive_file, a plain document rather than a page with a folded
|
||||
corner. Recorded as a deviation rather than silently swapped, because "draft" is simply not a glyph
|
||||
this font contains.
|
||||
|
||||
Two further colours come from the mode, and they are split across the two columns on purpose: NAME says
|
||||
what a row is, PERMS says what is notable about how it is set. So an executable is green in NAME —
|
||||
"live, yours, something that runs" — while a file anyone may write to is amber in PERMS, over the
|
||||
characters that actually say so. The two never compete for one TextBlock, which is what lets a
|
||||
world-writable executable show both facts instead of one winning an argument.
|
||||
So NAME's colour stopped being the directory's own mark — the glyph is now — and become what the
|
||||
design says instead: a folder is white and medium weight, a file is TextDim and regular. Blue is not
|
||||
spent here any more; Info keeps meaning "a distinct scope" everywhere else it already did, and this
|
||||
screen no longer borrows it.
|
||||
|
||||
Both are files only; see SftpEntry, which will not read a mode off a symbolic link or a directory.
|
||||
Rendering `-rwxrwxrwx` in two colours at once is not something this list can do, so amber over the whole
|
||||
string is the compromise: the eye lands on the column, and the string itself is the detail.
|
||||
A second colour still comes from the mode, and it still overrides the first rather than competing with
|
||||
it: an executable is Live green in NAME — "live, yours, something that runs" — regardless of whether
|
||||
the row beside it is asking for the folder colours or the file ones. PERMS keeps its own, separate
|
||||
amber for a mode anyone may write to, on the same reasoning as before: NAME says what a row is, PERMS
|
||||
says what is notable about how it is set, and the two never compete for one TextBlock.
|
||||
-->
|
||||
<Style Selector="TextBlock.entry">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.entry.dir">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
<!--
|
||||
Two different hues, and after v2 that takes saying. These marks encode two independent facts in one
|
||||
column, so they have to be told apart at a glance — and they used to be, for free, because the accent
|
||||
was green and Info was blue. v2 made the accent blue too, which put #5B8CFF beside #7FB0FF: the same
|
||||
hue two steps apart, which is a shade rather than a distinction.
|
||||
|
||||
So an executable is Live green now. It is the one use of that colour that is not about a session, and
|
||||
it earns it on the same grounds — it is a fact about the file rather than something to press, and it
|
||||
is the colour this marker already was before the accent moved out from under it.
|
||||
Declared after .dir on purpose — Avalonia has no specificity, and an executable that is also somehow
|
||||
the target row would need this rule to win. It never is in practice; see SftpEntry, which reads the
|
||||
execute bit off a file's own mode and nothing else's.
|
||||
-->
|
||||
<Style Selector="TextBlock.entry.dir">
|
||||
<Setter Property="Foreground" Value="{StaticResource Info}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.entry.exec">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The glyph beside NAME. One quiet colour regardless of folder or file — the design's own renderVals
|
||||
gives both the same default iconFg — because the glyph itself already says which one a row is; the
|
||||
colour underneath it is furniture, on the same TextFaint-family step TextBlock.fieldglyph already uses
|
||||
for a mark meant to be skipped rather than read.
|
||||
-->
|
||||
<Style Selector="TextBlock.entryicon">
|
||||
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
SIZE and MODIFIED, both quieter than the design's own literal values ask for — rgb(93,95,116) and
|
||||
rgb(110,112,137) sit a step below every text colour this palette already names. Per
|
||||
design-notes/v5b-fidelity-notes.md's own instruction for values this close, the nearest existing steps
|
||||
are reused rather than two new keys added for a difference nobody would see: TextGhost for SIZE, which
|
||||
the design draws a shade darker than MODIFIED, and TextFaint — one step lighter — for MODIFIED, which
|
||||
keeps the two in the same order the design puts them in even though neither is the design's exact hex.
|
||||
-->
|
||||
<Style Selector="TextBlock.entrysize">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.entrydate">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Faint by default, as this column has always been: a mode is there so its absence would be noticed. It
|
||||
steps up to amber only when it has something to say, which is the whole reason the default is quiet.
|
||||
@@ -84,6 +115,40 @@
|
||||
<Style Selector="TextBlock.perms.loose">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
── v5b: the TRANSFERS strip's own progress track ────────────────────────────────────────────────────
|
||||
5px, 3px radius, Chip's own fill under an AccentGradient indicator — the design's own slim bar. A real
|
||||
0% is what a queued transfer draws with this: the indicator's width is nothing, which reads exactly as
|
||||
the design's own queued sample row does (a bar the same colour as its own track). No indeterminate
|
||||
animation is drawn for it — a queued transfer really is at zero, so zero is the honest thing to show,
|
||||
not a state this screen has to invent furniture for.
|
||||
-->
|
||||
<Style Selector="ProgressBar.transferbar">
|
||||
<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 AccentGradient}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The strip's own right-aligned status word. Quiet by default — a stopped transfer's "stopped" gets no
|
||||
rule of its own and falls through to this — and three states get their own colour: in motion, kept, and
|
||||
refused. See TransferRowViewModel.StatusWord for what word each state actually prints.
|
||||
-->
|
||||
<Style Selector="TextBlock.transferstatus">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.transferstatus.running">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.transferstatus.done">
|
||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.transferstatus.failed">
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
@@ -99,14 +164,45 @@
|
||||
-->
|
||||
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<!--
|
||||
── v5b ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
Per SFTP.dc.html: a 10px tracked-out label beside the current path in mono 13.5, and the pane's
|
||||
affordances restyled to quiet glyphs rather than dropped. "THIS MACHINE" becomes the design's own
|
||||
"LOCAL" — nothing in this file's own comments ever argued for that particular wording over the
|
||||
design's, unlike HOST/BUCKET across the arrow column, which keeps its own words for a reason
|
||||
recorded where it is drawn.
|
||||
|
||||
UP is arrow_upward here and arrow_downward on the host/bucket pane below, matching the design's own
|
||||
two glyphs exactly rather than picking one "up a directory" icon for both — both buttons do the same
|
||||
thing (go to the parent directory), and the pair reading differently is the design's own choice
|
||||
faithfully carried over rather than a functional difference invented to justify it.
|
||||
-->
|
||||
<!--
|
||||
◆ THE PATH SITS IN THE STAR COLUMN, ALONE, AND THAT PLACEMENT IS LOAD-BEARING.
|
||||
|
||||
TextTrimming only acts when measure hands the block a finite width, and a horizontal StackPanel
|
||||
never does — it measures every child at infinity, takes the full answer, and an Auto grid column
|
||||
passes that on. With the path in a StackPanel beside the label, a directory deep enough — the
|
||||
remote pane meets one on any real host, this pane on any machine whose profile path is long —
|
||||
made the header wider than the pane and pushed the icon buttons past the window's own edge. The
|
||||
layout suite caught it at the 472-pixel session budget, on the one machine whose home directory
|
||||
was long enough to arm it. Star column: bounded width, working ellipsis, buttons that stay.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="THIS MACHINE" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Grid.Column="0" Classes="label" FontSize="10" Text="LOCAL"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left"
|
||||
Text="{Binding LocalPath}" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding LocalPath}" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
|
||||
<!--
|
||||
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
|
||||
directory. Without this the pane is stuck on whichever drive the user profile is on.
|
||||
directory. Without this the pane is stuck on whichever drive the user profile is on. Chips
|
||||
rather than the pane's own quiet icon buttons, because a drive is named rather than a single
|
||||
glyph — "C:" has no honest Material Icons equivalent.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding LocalRoots}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
@@ -116,14 +212,20 @@
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<Button Classes="ghost" Content="{Binding Name}"
|
||||
<Button Classes="panechip" Content="{Binding Name}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
|
||||
CommandParameter="{Binding Path}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Classes="ghost" Content="UP" Command="{Binding LocalUpCommand}" />
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshLocalCommand}" />
|
||||
<Button Classes="paneicon" Command="{Binding LocalUpCommand}"
|
||||
ToolTip.Tip="Up one directory">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RefreshLocalCommand}"
|
||||
ToolTip.Tip="Refresh">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -150,25 +252,37 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110" Margin="0,2,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
|
||||
<!--
|
||||
v5b: 20 for the glyph column rather than the design's bare icon with no gutter, because a Material
|
||||
glyph centred with no reserved width drifts as the font's own advance width varies between folder
|
||||
and insert_drive_file. 60/56 for SIZE/MODIFIED are narrower than the design's literal 80/78 — see
|
||||
the row template below for why the whole column set was chosen against 204 pixels rather than the
|
||||
design's own 1920-pixel canvas.
|
||||
-->
|
||||
<Grid Grid.Row="2" ColumnDefinitions="20,*,60,56" Margin="0,2,10,4">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="3" x:Name="LocalList" ItemsSource="{Binding LocalEntries}"
|
||||
<!--
|
||||
h38, radius 7, Track for hover and selected — see ListBox.filerows in App.axaml — and no rowmark:
|
||||
the design has nothing to distinguish "selected" from "hovered" beyond which one is currently true,
|
||||
and :selected alone already carries that once the pointer moves on.
|
||||
-->
|
||||
<ListBox Grid.Row="3" x:Name="LocalList" Classes="filerows" ItemsSource="{Binding LocalEntries}"
|
||||
SelectedItem="{Binding SelectedLocalEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*,84,110" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<Grid ColumnDefinitions="20,*,60,56" Height="38" Margin="14,0,10,0">
|
||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Text="{Binding Name}" FontSize="12"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
Text="{Binding Name}" FontSize="13.5"
|
||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
@@ -219,26 +333,48 @@
|
||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
|
||||
DragDrop.AllowDrop="True">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<!--
|
||||
── v5b ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
HOST/BUCKET keep their own words rather than adopting the design's generic "REMOTE": the comment
|
||||
this replaced already carried a documented honesty reason — the pair is the difference between a
|
||||
directory tree and a flat namespace with inferred folders — and that reason still holds, so only
|
||||
the style moves to the design's 10px tracked label, not the wording.
|
||||
|
||||
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
|
||||
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
||||
-->
|
||||
<!--
|
||||
The path is alone in the star column for the reason the local pane's header remark spells out:
|
||||
TextTrimming needs the finite width only a star column gives it, and this is the pane where the
|
||||
long path is not even unusual — it is any host with a deep directory tree.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<!--
|
||||
Which kind of remote this pane is for, in the place the local pane names itself. It is the
|
||||
only thing left saying so on the screen itself — the bar that used to print SFTP or S3 across
|
||||
the top is gone — and the pair is worth keeping apart, because HOST and BUCKET is the
|
||||
difference between a directory tree and a flat namespace with inferred folders in it.
|
||||
-->
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsHostPicker}" />
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="BUCKET" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsBucketPicker}" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="UP" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
||||
IsEnabled="{Binding CanDeleteRemote}" />
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"
|
||||
Margin="0,0,12,0">
|
||||
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsHostPicker}" />
|
||||
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowsBucketPicker}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
HorizontalAlignment="Left"
|
||||
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding RemotePath}" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
|
||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
|
||||
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -246,27 +382,23 @@
|
||||
<!--
|
||||
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
|
||||
|
||||
A row of its own rather than three more cells in the header above, and the reason is arithmetic
|
||||
rather than taste: this pane is 381 pixels wide at the window's minimum, UP, REFRESH and DELETE
|
||||
take most of that, and an account-at-host chip beside a DISCONNECT would have pushed one of them
|
||||
off the edge. The layout suite would have caught it — which is the point of stating the number
|
||||
here, so the next thing added to either row is measured against it rather than tried.
|
||||
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: the session shell
|
||||
prints the very same address beside this screen — in the sidebar's own session block since v5c-4
|
||||
retired the header row that printed it above — see MainWindowViewModel.SessionAddress,
|
||||
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
|
||||
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
|
||||
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
|
||||
170-pixel chip this row used to carry would have taken most of that on its own.
|
||||
|
||||
Two things and a gap, and the gap is the point: the status line was tried here and does not fit.
|
||||
What is left after a 170-pixel address and a DISCONNECT is about eighty pixels, which turns every
|
||||
sentence into its first word and an ellipsis. It is at the foot of the screen instead — see the
|
||||
queue's own strip, which has the width for one.
|
||||
The status line still is not here, for the reason the comment this replaced gave: there is no room
|
||||
for a sentence beside anything else this row holds. It is at the foot of the screen instead — see
|
||||
the queue's own strip.
|
||||
-->
|
||||
<Border Grid.Row="1" Padding="12,6" Background="{StaticResource Raised}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0" Classes="chip accent" MaxWidth="170">
|
||||
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
<Button Grid.Column="2" Classes="ghost" Content="DISCONNECT"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
</Grid>
|
||||
<Button Classes="headerghost" HorizontalAlignment="Right" Content="DISCONNECT"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
@@ -277,6 +409,11 @@
|
||||
This is the strongest warning on any of these screens, and deliberately: everything else this
|
||||
application deletes is a tombstone against a copy the server still has, and a file on somebody's
|
||||
host is bytes with nothing behind them.
|
||||
|
||||
A WrapPanel for the two buttons rather than a horizontal StackPanel — v5b's own change, made once
|
||||
the session shell's sidebar could take this pane down to 204 pixels: "DELETE ON THE HOST" and
|
||||
CANCEL side by side want closer to 220, so at the narrow width they wrap onto their own line
|
||||
instead of one of them going off the edge, which the WrapPanel achieves for free.
|
||||
-->
|
||||
<Border Grid.Row="2" Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
@@ -289,11 +426,12 @@
|
||||
Text="{Binding PendingRemoteDeletion.FullPath}" />
|
||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="danger" Content="DELETE ON THE HOST"
|
||||
<WrapPanel>
|
||||
<Button Classes="danger" Content="DELETE ON THE HOST" Margin="0,0,8,4"
|
||||
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteRemoteCommand}" />
|
||||
</StackPanel>
|
||||
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
|
||||
Command="{Binding CancelDeleteRemoteCommand}" />
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -324,37 +462,48 @@
|
||||
Making a directory sits here, beside the path it would be made in, rather than with the queue's
|
||||
controls. It exists because the queue refuses to overwrite: without somewhere else to put a file,
|
||||
"that name is already taken" is a dead end.
|
||||
|
||||
v5b narrows the box from 140 to 90: at the session shell's own 204-pixel width once QUICK ACCESS
|
||||
is showing, 140 plus MKDIR plus the spacing between them ran past the pane's own edge. Still wide
|
||||
enough for a real directory name to be typed and read back, which is what this box is for; a
|
||||
name that outgrows it scrolls inside the box the way every TextBox already does.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBox Width="140" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
||||
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
||||
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="4" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" />
|
||||
<!--
|
||||
v5b: PERMS keeps its own column — a real fact about a file on the host, dropped nowhere — but at
|
||||
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
|
||||
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
|
||||
-->
|
||||
<Grid Grid.Row="4" ColumnDefinitions="20,*,56,52,48" Margin="0,2,10,4">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" HorizontalAlignment="Right" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="5" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
||||
<ListBox Grid.Row="5" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
|
||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<Grid ColumnDefinitions="20,*,56,52,48" Height="38" Margin="14,0,10,0">
|
||||
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Classes.exec="{Binding IsExecutable}"
|
||||
Text="{Binding Name}" FontSize="12"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
Text="{Binding Name}" FontSize="13.5"
|
||||
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
|
||||
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||||
Text="{Binding Permissions}" FontSize="10.5" VerticalAlignment="Center" />
|
||||
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
@@ -392,10 +541,11 @@
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
|
||||
<!--
|
||||
The mark the hosts screen puts on a group, at the size an empty state can carry one. This
|
||||
application ships no icon set — see the note on colour at the top of this file — so a glyph in
|
||||
a rounded square is what an icon is here, and ▤ is already the one that means "a place things
|
||||
are kept".
|
||||
The mark the hosts screen puts on a group, at the size an empty state can carry one. Still a
|
||||
hand-picked glyph rather than one of the Material Icons codepoints the file rows now use — this
|
||||
invitation predates the app's own IconFont and is outside wave C's restyle, which touched the
|
||||
pane headers, the rows and the TRANSFERS strip and not this card — and ▤ already means "a place
|
||||
things are kept" without borrowing from a font built for a different visual language.
|
||||
-->
|
||||
<Border Width="44" Height="44" CornerRadius="12" HorizontalAlignment="Center"
|
||||
Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
@@ -545,94 +695,126 @@
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ The queue ============ -->
|
||||
<Border Grid.Row="1" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" MaxHeight="196">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<!--
|
||||
============ The TRANSFERS strip ============
|
||||
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
DeepChrome and a top border, per SFTP.dc.html, in place of the plain Sidebar fill this used to carry —
|
||||
the same surface the session shell's own header and status bar are painted in, which is what fuses the
|
||||
strip into that frame rather than leaving it read as another pane. Two Borders share this row and
|
||||
exactly one is ever visible: the design's own header — TRANSFERS plus a count chip — with the queue
|
||||
underneath while there is one, and a single quiet line otherwise. That is the "collapse" wave C's own
|
||||
brief asks for: not zero height, because the honest sentence that used to live inside the full strip
|
||||
still has somewhere to be read, but far short of the full header-plus-rows shape.
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="TRANSFERS" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="10.5" Margin="10,0,0,0"
|
||||
The design's own aggregate throughput readout — "8.4 MB/s" beside an upward arrow — is not drawn.
|
||||
TransferRowViewModel.Progress computes a rate per transfer, off TransferSnapshot.BytesPerSecond, and
|
||||
nothing anywhere in this queue sums those into one number for the whole strip; inventing one here would
|
||||
be exactly the fabricated fact this project's honesty rule forbids. Recorded as a deviation rather than
|
||||
silently dropped.
|
||||
-->
|
||||
<Border Grid.Row="1" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" MaxHeight="196" IsVisible="{Binding HasTransfers}">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16,12">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="label" FontSize="10" Text="TRANSFERS" VerticalAlignment="Center" />
|
||||
<!--
|
||||
h18, radius 5, Chip fill with no border — the design's own count chip, distinct from Border.chip's
|
||||
usual bordered-and-unfilled shape elsewhere in this application. ActiveTransfersLabel is
|
||||
"N active", off TransfersViewModel.ActiveTransfers: queued counts as active there for the reason
|
||||
its own remark gives, which matches what this chip is naming.
|
||||
-->
|
||||
<Border Grid.Column="1" Height="18" CornerRadius="5" Background="{StaticResource Chip}"
|
||||
Padding="8,0" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="10.5" FontWeight="Medium"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="one at a time · nothing lands at its final name until it is complete" />
|
||||
Text="{Binding ActiveTransfersLabel}" />
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
◆ THE STATUS LINE, in the state where the remote pane has no room for one.
|
||||
<!--
|
||||
◆ THE STATUS LINE, in the state where the remote pane has no room for one — unchanged in meaning
|
||||
from the strip this replaced, only in paint. Right-aligned beside the queue's own "one at a time"
|
||||
policy sentence, which stays for the reason it always did: the queue refuses to overwrite, and
|
||||
nothing else on this screen says so.
|
||||
-->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="0,8,0,10">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="10.5" Foreground="{StaticResource TextGhost}"
|
||||
Text="one at a time · nothing lands at its final name until it is complete" />
|
||||
<TextBlock Grid.Column="1" Classes="hint" FontSize="11" Margin="16,0,0,0"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"
|
||||
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
|
||||
</Grid>
|
||||
|
||||
It is here rather than beside DISCONNECT because this row spans the window and that one spans
|
||||
half of it: what the screen has to say about a session is a sentence, and a sentence needs the
|
||||
width. Only while something is open — the other half of the time it is inside the invitation
|
||||
in the remote pane, next to the button that provoked it, which is where a refusal has to be.
|
||||
<ScrollViewer Grid.Row="2">
|
||||
<ItemsControl ItemsSource="{Binding Transfers}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||
<!--
|
||||
Per SFTP.dc.html: a 320-pixel ellipsized "source → destination" label, a slim progress track
|
||||
and a right-aligned status word — see TransferRowViewModel.Label/StatusWord. STOP, RETRY/
|
||||
RESUME and DISCARD are the app's own, kept per wave C's brief rather than dropped for having
|
||||
no equivalent in the design's static mock: icon-only now, restyled to the pane headers' own
|
||||
quiet-glyph idiom, with a tooltip carrying the word the button used to print.
|
||||
-->
|
||||
<Grid ColumnDefinitions="320,*,64,Auto" Margin="0,5" ColumnSpacing="14">
|
||||
<TextBlock Grid.Column="0" Classes="mono" FontSize="12"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
Text="{Binding Label}" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
Right-aligned in a free column, so it reads as this row's other end rather than as a third
|
||||
clause of the sentence to its left.
|
||||
-->
|
||||
<TextBlock Grid.Column="2" Classes="hint" FontSize="11.5" Margin="16,0,0,0"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"
|
||||
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<ProgressBar Grid.Column="1" Classes="transferbar" Minimum="0" Maximum="100"
|
||||
Value="{Binding Percent}" VerticalAlignment="Center"
|
||||
ToolTip.Tip="{Binding Progress}" />
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="hint" FontSize="11.5" Margin="12,4,12,14"
|
||||
IsVisible="{Binding !HasTransfers}"
|
||||
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
|
||||
<TextBlock Grid.Column="2" Classes="mono transferstatus"
|
||||
Classes.running="{Binding IsInProgress}" Classes.done="{Binding IsDone}"
|
||||
Classes.failed="{Binding HasFailed}" FontSize="12" FontWeight="Medium"
|
||||
Text="{Binding StatusWord}" TextAlignment="Right" VerticalAlignment="Center" />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Transfers}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||
<Grid ColumnDefinitions="16,150,*,190,Auto" Margin="12,4">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="12"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Margin="0,0,8,0">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11.5"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<ProgressBar Grid.Column="2" Height="4" Minimum="0" Maximum="100"
|
||||
Value="{Binding Percent}" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Accent}"
|
||||
Background="{StaticResource Raised}" />
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="10.5"
|
||||
Margin="10,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
|
||||
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="6">
|
||||
<Border Classes="chip">
|
||||
<TextBlock Text="{Binding StateLabel}" />
|
||||
</Border>
|
||||
<Button Classes="ghost" Content="STOP" IsVisible="{Binding IsRunning}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Classes="ghost" Content="{Binding RetryLabel}" IsVisible="{Binding CanRetry}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Classes="danger" Content="DISCARD" IsVisible="{Binding IsFinished}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Classes="ghost" Content="CLEAR FINISHED" Margin="12,6,12,12"
|
||||
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}"
|
||||
IsVisible="{Binding HasTransfers}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="4">
|
||||
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding IsRunning}"
|
||||
ToolTip.Tip="Stop this transfer"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding CanRetry}"
|
||||
ToolTip.Tip="{Binding RetryLabel}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="" />
|
||||
</Button>
|
||||
<Button Classes="paneicon danger" Width="22" Height="22" IsVisible="{Binding IsFinished}"
|
||||
ToolTip.Tip="Discard this row"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
|
||||
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The collapsed shape: no header, no count chip, no rows — just the honest sentence the full strip
|
||||
carried before anything was ever queued, kept per wave C's own brief rather than dropped along with the
|
||||
rest of the strip's furniture.
|
||||
-->
|
||||
<Border Grid.Row="1" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Padding="16,12" IsVisible="{Binding !HasTransfers}">
|
||||
<TextBlock Classes="hint" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
First contact and a changed key, over the whole screen. The same two refusals a terminal makes, and
|
||||
they arrive here on their own because this is a separate connection — a host trusted for a shell is
|
||||
|
||||
@@ -1,362 +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:contracts="using:DodoSSH.Contracts"
|
||||
x:Class="DodoSSH.Client.App.Views.VaultsScreen"
|
||||
x:DataType="vm:VaultsViewModel">
|
||||
|
||||
<!--
|
||||
Vaults, and the people in each of them.
|
||||
|
||||
── THIS WAS THE TEAMS SCREEN, AND THE TEAM IS NOW BEHIND THE VAULT. ─────────────────────────────────
|
||||
The left column used to list teams; a team owned vaults, and sharing meant creating a team, then a
|
||||
vault in it, then wrapping a key. Two of those three steps were about a concept nobody came here for.
|
||||
So the rows are vaults now: naming one makes the membership list that carries it, and everything on
|
||||
the right — members, key holders — is that vault's. The server still authorises against
|
||||
a team, because that is what VaultAccessService resolves; what went is the requirement that a person
|
||||
know it exists. The one case where it is still visible is a membership list carrying several vaults,
|
||||
which this screen cannot make and will not hide: see SharedMembershipWarning.
|
||||
|
||||
── THE ONE FACT THE WHOLE SCREEN IS BUILT AROUND ────────────────────────────────────────────────────
|
||||
Adding somebody to a vault and giving them its key are two different acts, and only the first is
|
||||
something a server can do. The second needs a machine that holds the key, because this server never
|
||||
does. So the members list and the key-holders list are both here and are not the same list, an
|
||||
addition says out loud that it granted nothing readable yet, and SHARE KEY is its own button rather
|
||||
than a checkbox on the member row.
|
||||
|
||||
What the design asked for and is still not here: two-factor state (no such concept exists anywhere in
|
||||
this product) and avatars (no picture is stored anywhere). There is no INVITED list either, and that
|
||||
one is a decision rather than a gap — an address is not a way into a vault, so only an account that
|
||||
already exists can be added and there is nothing pending to draw. See the ADD box below, which says
|
||||
what to do about somebody who has not signed in here yet. Last-active is recorded at most once per
|
||||
account per hour, so it is drawn coarsely. Nor is there a way to delete a vault: the server has no
|
||||
such call, and the screen says so rather than offering a button that refuses.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="268,*">
|
||||
|
||||
<!-- ============ The vault list ============ -->
|
||||
<Border Grid.Column="0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||
<Grid RowDefinitions="44,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="VAULTS" FontSize="12" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Classes="ghost" Content="NEW"
|
||||
Command="{Binding NewVaultCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Makes a vault you can share. Its key is generated on this machine, and nobody else has it until you hand it out." />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<!--
|
||||
Read from this machine's own vault list rather than from the server, so the column is right
|
||||
with no connection. What is missing offline is who is in each one, which is why a row can
|
||||
say its membership is unknown rather than saying nothing at all.
|
||||
-->
|
||||
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
|
||||
Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="0,3">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding RoleLabel}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Margin="8,0,0,0" />
|
||||
</Grid>
|
||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding Detail}" />
|
||||
<!--
|
||||
Only when there is something to say. A vault waiting for a key and one owing a rekey
|
||||
are both temporary and both need somebody to act; a permanent "fine" beside them
|
||||
would teach people to stop reading the line.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding State}"
|
||||
TextWrapping="Wrap" IsVisible="{Binding HasState}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" Margin="14,12" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasVaults}"
|
||||
Text="No vaults yet. Unlock your keychain to see the personal one, or make a vault to share hosts and credentials with colleagues." />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
The name-a-vault form, in place rather than in a modal: this window has no idiom for one. It is
|
||||
in this column rather than beside the pane on the right for one reason — that pane is bound to
|
||||
HasSelection, so with no vaults at all it is not on screen, and "no vaults at all" is exactly
|
||||
the state somebody arrives in from the tab strip's New vault entry.
|
||||
|
||||
One field. The membership list behind it is made with it and named after it, and its slug is
|
||||
derived — see VaultsViewModel.CreateVaultAsync. Asking for a URL handle would be asking for one
|
||||
from somebody who has not been told they are making anything but a vault.
|
||||
-->
|
||||
<Border Grid.Row="2" Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsCreatingVault}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="NEW VAULT" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding NewVaultName}" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Its key is made on this machine and nobody else has it. Add people to it once it exists, then press SHARE KEY." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="CREATE" Command="{Binding CreateVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewVaultCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ The selected vault: who is in it, and who can open it ============ -->
|
||||
<Grid Grid.Column="1" RowDefinitions="44,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SelectedVault.Name}" FontSize="12"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
The vault's own operations. RENAME is an admin's; handing it on is the owner's alone, and
|
||||
that is the line the server draws as well — an admin the owner promoted must not be able to
|
||||
take the vault from them.
|
||||
|
||||
DELETE is an admin's too, and it is last and red because it is the only one of the three that
|
||||
cannot be undone. It is absent on the personal vault rather than disabled: there is no version
|
||||
of this window in which that vault can go, so a greyed button would be an offer that never
|
||||
becomes real. Its question is the card below, as every destructive answer on this screen is.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding ShowsVaultActions}">
|
||||
<Button Classes="ghost" Content="RENAME" Command="{Binding RenameVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}"
|
||||
ToolTip.Tip="Changes what this vault is called. The name is plaintext on the server, as it always was; nothing inside is re-encrypted." />
|
||||
<Button Classes="ghost" Content="HAND OVER" Command="{Binding HandOverCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding OwnsSelected}"
|
||||
ToolTip.Tip="Hands this vault to the selected member. They become its owner and you become an admin; only the new owner can hand it on again." />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanDeleteSelected}"
|
||||
ToolTip.Tip="Deletes this vault and withdraws everybody's key to it. It cannot reach a machine that has already synced it." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasSelection}">
|
||||
<StackPanel Margin="14,14" Spacing="18">
|
||||
|
||||
<!-- The rename form, in place, exactly as the create form on the left is. -->
|
||||
<Border Padding="12" CornerRadius="4" BorderThickness="1"
|
||||
BorderBrush="{StaticResource Border}" IsVisible="{Binding IsRenamingVault}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="RENAME VAULT" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding EditVaultName}" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="Everybody who shares this vault sees the new name. Nothing is re-encrypted and no key changes; the name has always been stored in plain text, because a person has to be able to pick a vault before anything is decrypted." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveVaultNameCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelRenameVaultCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The armed confirmation, drawn where the buttons that armed it were. The keychain screen's
|
||||
idiom, and for the same reason: there is no modal anywhere in this window.
|
||||
-->
|
||||
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="12"
|
||||
IsVisible="{Binding IsConfirming}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="{Binding PendingAction.Question}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="{Binding PendingAction.Consequence}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="danger" Content="CONFIRM" Command="{Binding ConfirmActionCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelActionCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The personal vault, which is the one vault sharing cannot reach. Said here rather than by
|
||||
drawing the members section empty: an empty MEMBERS heading over a vault that can never have
|
||||
any reads as a feature that has not loaded.
|
||||
-->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding SelectedIsPersonal}">
|
||||
<TextBlock Classes="label" Text="YOURS ALONE" />
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="Nobody can be added to your personal vault, and the server refuses a key grant on one outright — a key wrapped to somebody it will go on refusing to serve would look like sharing and would not be. Make a vault above for the things you want to share, and put them in it." />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Members -->
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding SelectedIsShared}">
|
||||
<TextBlock Classes="label" Text="MEMBERS" />
|
||||
|
||||
<!--
|
||||
Only ever non-empty for a membership list this screen did not make. Adding somebody to one
|
||||
vault and silently adding them to three others is precisely the fact a vault-shaped screen
|
||||
is in a position to hide, so it says it instead.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasSharedMembershipWarning}"
|
||||
Text="{Binding SharedMembershipWarning}" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
|
||||
Background="Transparent" BorderThickness="0" MaxHeight="240">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultMemberRowViewModel">
|
||||
<Grid ColumnDefinitions="*,168,Auto" Margin="0,3">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding Email}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding KeyState}"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding LastActive}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Margin="10,0,0,0" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!--
|
||||
Buttons and a command rather than a selector bound to the role, which is the choice the
|
||||
key editor and the category rail already make and for the reason they record: a selector
|
||||
moves its own highlight before anything can refuse, so it can end up showing a role
|
||||
nobody was given. OWNER is absent because it is not a role that can be assigned —
|
||||
handing the vault over is its own act, with its own confirmation.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding CanAdministerSelected}">
|
||||
<TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="flat choice" Content="VIEWER" Command="{Binding ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="May pull this vault and may not push. It does not withdraw a key they already hold." />
|
||||
<Button Classes="flat choice" Content="MEMBER" Command="{Binding ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Member}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="May read and change what is in this vault." />
|
||||
<Button Classes="flat choice" Content="ADMIN" Command="{Binding ChangeRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="May also add and remove people, rename the vault, and share its key." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
|
||||
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com"
|
||||
Text="{Binding NewMemberEmail}" Margin="0,0,6,0" />
|
||||
<Button Grid.Column="1" Classes="accent" Content="ADD"
|
||||
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Adds the account that signs in with this address. An address with no account here is refused and says so — ask them to sign in to this server once, which is what creates the account, and then add them." />
|
||||
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
|
||||
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Removes the selected member and withdraws every key they hold to this vault. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
|
||||
</Grid>
|
||||
|
||||
<StackPanel Spacing="4" IsVisible="{Binding CanAdministerSelected}">
|
||||
<TextBlock Classes="label" Text="THEY ARRIVE AS" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="flat choice" Content="VIEWER" Classes.active="{Binding AddsAsViewer}"
|
||||
Command="{Binding ChooseNewMemberRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}" />
|
||||
<Button Classes="flat choice" Content="MEMBER" Classes.active="{Binding AddsAsMember}"
|
||||
Command="{Binding ChooseNewMemberRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Member}" />
|
||||
<Button Classes="flat choice" Content="ADMIN" Classes.active="{Binding AddsAsAdmin}"
|
||||
Command="{Binding ChooseNewMemberRoleCommand}"
|
||||
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding CanAdministerSelected}"
|
||||
Text="Adding somebody lets the server serve them this vault. It does not let them read it: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
|
||||
</StackPanel>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" />
|
||||
|
||||
<!-- Who holds the key -->
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding SelectedIsShared}">
|
||||
<Button Classes="accent" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Wraps this vault's key to the selected member. Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged." />
|
||||
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding RevokeVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Withdraws the selected member's key to this vault. Blocks future reads only." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Who can open this vault — the design's "shared with" avatars, as names and a state.
|
||||
Withdrawn and stale grants stay listed and say which they are, because a list that quietly
|
||||
dropped them would show a departed colleague as merely absent rather than as somebody whose
|
||||
key was taken away. The dot is Live and means exactly what it says: this person can open
|
||||
this vault right now.
|
||||
-->
|
||||
<TextBlock Classes="label" Text="KEY HOLDERS" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Grants}" Background="Transparent" BorderThickness="0"
|
||||
MaxHeight="150">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultGrantRowViewModel">
|
||||
<Grid ColumnDefinitions="10,*" Margin="0,3">
|
||||
<Ellipse Grid.Column="0" Width="6" Height="6" VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsLive}" Fill="{StaticResource Live}" />
|
||||
<StackPanel Grid.Column="1" Spacing="2" Margin="6,0,0,0">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding State}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding SelectedIsShared}"
|
||||
Text="Sharing verifies the recipient's key against the key log, which proves this server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
|
||||
|
||||
<!--
|
||||
Said once, where somebody would otherwise go looking for a DELETE button. There is no call
|
||||
for it anywhere in the server, and archiving the membership list behind a vault is refused
|
||||
while the vault exists — so a button here would be one that always refuses.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
||||
Text="A vault cannot be deleted. Nothing in this product removes one, and the server refuses to archive the membership list behind it while it still exists." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" FontSize="12" Margin="20" TextWrapping="Wrap"
|
||||
VerticalAlignment="Top" IsVisible="{Binding !HasSelection}"
|
||||
Text="Make a vault on the left, or wait for somebody to add you to one. A vault holds hosts and credentials that a group of people share; its key is what makes those readable, and that key is handed out by people rather than by the server." />
|
||||
|
||||
<Border Grid.Row="2" Padding="14,10" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Classes="hint" FontSize="11.5" Text="{Binding Status}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -1,9 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Vaults: which there are, who is in each, and who holds a key to it.</summary>
|
||||
internal sealed partial class VaultsScreen : UserControl
|
||||
{
|
||||
public VaultsScreen() => InitializeComponent();
|
||||
}
|
||||
@@ -23,9 +23,13 @@ namespace DodoSSH.Client.Session;
|
||||
/// <param name="AutomaticUpdateChecks">
|
||||
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
|
||||
/// </param>
|
||||
/// <param name="SessionSidebarOpen">
|
||||
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
|
||||
/// </param>
|
||||
public sealed record ClientSettings(
|
||||
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
|
||||
bool AutomaticUpdateChecks = true)
|
||||
bool AutomaticUpdateChecks = true,
|
||||
bool SessionSidebarOpen = true)
|
||||
{
|
||||
/*
|
||||
A positional record, and the defaults live on the parameters rather than on property initializers.
|
||||
@@ -102,6 +106,24 @@ public sealed record ClientSettings(
|
||||
warns against.
|
||||
*/
|
||||
|
||||
/*
|
||||
SessionSidebarOpen: why closing the sidebar is remembered, and why it is remembered here.
|
||||
|
||||
On by default, because the sidebar is where a session's pins, its snips and the way across to the
|
||||
other surface live — a first launch that hid all three would be hiding the feature rather than
|
||||
offering to.
|
||||
|
||||
Remembered at all because closing it is a choice about how much of a 1180-pixel window a terminal
|
||||
gets, and a choice that has to be made again on every launch is one the application is not really
|
||||
offering. It belongs in this file rather than in the vault for the same reason the font size does:
|
||||
it is a fact about this screen, not about this keychain, and following somebody from a 27-inch
|
||||
monitor onto a laptop would be a preference nobody asked for.
|
||||
|
||||
Not per-surface and not per-tab. The sidebar is one control drawn on two screens — see
|
||||
SessionSidebar.axaml — and a window where it is open on SFTP and closed on the terminal is a
|
||||
window that appears to lose it at random.
|
||||
*/
|
||||
|
||||
/// <summary>Brings a value inside the range this type will store.</summary>
|
||||
public static int ClampTerminalFontSize(int pixels) =>
|
||||
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
|
||||
|
||||
@@ -232,4 +232,69 @@
|
||||
<FontFamily x:Key="MonoFont">avares://DodoSSH.Client.Shell/Assets/Fonts#JetBrains Mono,ui-monospace,Cascadia Mono,Consolas,monospace</FontFamily>
|
||||
<FontFamily x:Key="IconFont">avares://DodoSSH.Client.Shell/Assets/Fonts#Material Icons</FontFamily>
|
||||
|
||||
<!--
|
||||
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
A fidelity pass rather than a new design: the titlebar and the nav rail are redrawn closer to what
|
||||
v3's own mock actually specifies, and the eight values below are what that redraw needed and did not
|
||||
already have a name for. They arrived together because the source notes' table did, not because they
|
||||
are one idea — read each on its own.
|
||||
|
||||
DeepChrome is one step darker than Chrome itself, #0B0B14 against #10111E, and it is what the titlebar
|
||||
and the nav rail are painted in now instead of Chrome. The two used to share Chrome's value; the mock
|
||||
draws the window's own furniture — the bar at the top, the rail down the side, the strip a session
|
||||
sits in, a host's header row, the status bar at the foot — one shade below every screen it frames, so
|
||||
the frame reads as the thing holding the glass rather than as another pane of it. Only the titlebar and
|
||||
the rail are repainted in this pass; the session shell's header row and the status bar are wave B's.
|
||||
|
||||
Track is rgb(20,21,36), and it is not Hover: the two are close enough to be mistaken for a rounding
|
||||
error and are not one. Hover, #14151E, is what a row goes to under the pointer everywhere this palette
|
||||
already reached. Track is a half-step warmer and is what three specific things ask for instead — the
|
||||
segmented switcher's own resting track, the rail row a pointer sits over, and (in wave B) a selected
|
||||
file row — because the mock draws those three at a value Hover does not quite match, and "close enough"
|
||||
is how a screenshot next to the mock finds the seam this file exists to prevent.
|
||||
|
||||
Pane is rgb(10,10,18), the file and terminal surface's replacement for TerminalSurface in this design:
|
||||
darker and cooler rather than warmer, which is the same swap the surfaces above went through when v3
|
||||
turned the whole palette near-black. Not renaming TerminalSurface itself, because nothing has repainted
|
||||
a terminal pane yet — this key exists so wave B's SFTP panes have a name to reach for when they do.
|
||||
|
||||
SearchPill is the titlebar's own field, rgb(20,20,31) — close to Field's #101019 and a distinct value
|
||||
the mock states for this one control, with BorderMid as its resting border and Accent replacing it
|
||||
under the pointer. Kept separate from Field for the reason Raised and Chrome stay separate from each
|
||||
other: the day the search pill needs to move again, a shared key would have to be pulled apart first.
|
||||
|
||||
KbdChip, rgb(35,35,58), is the fill behind the keyboard-shortcut hint inside that pill — CTRL K, not
|
||||
the mock's ⌘K; see TitleBar.axaml. Distinct from Chip's #1A1A28 because the two are asked to sit near
|
||||
each other in the one place both appear and a shared value would read as one shape rather than two.
|
||||
|
||||
Magenta, rgb(221,18,150), is SFTP's own colour: the top border of an active SFTP tab in wave B's session
|
||||
shell, and the check square beside a shown vault in the rail's popover below. Named for what it looks
|
||||
like rather than for a job, because it is about to do two unrelated ones and a name tied to either would
|
||||
be wrong for the other — the same reasoning Chip's own name follows.
|
||||
|
||||
TerminalTabAccent, rgb(124,92,255), is a second read of the window's own Accent — close to #5D42DE and
|
||||
not equal to it — reserved for exactly one thing: the top border of an active terminal tab in wave B's
|
||||
session shell, so SSH's own tab reads as a different colour from SFTP's magenta one at a glance. Added
|
||||
now, alongside the rest of this table, rather than when wave B first draws a tab; it is unused until
|
||||
then, which is stated here so the next reader does not go looking for what has not been wired up yet.
|
||||
|
||||
AvatarGradient is the rail's own user chip: a 134.668-degree sweep through three violets, #4A37A9 into
|
||||
#8D51E7 into #AB49EC, behind the two-letter initials the chip draws from whichever name is actually
|
||||
signed in. A LinearGradientBrush rather than three stops guessed at a round angle, because Avalonia's
|
||||
gradient axis is start-point-to-end-point rather than degrees, and the coordinates below are what the
|
||||
mock's own angle resolves to.
|
||||
-->
|
||||
<SolidColorBrush x:Key="DeepChrome" Color="#0B0B14" />
|
||||
<SolidColorBrush x:Key="Track" Color="#141524" />
|
||||
<SolidColorBrush x:Key="Pane" Color="#0A0A12" />
|
||||
<SolidColorBrush x:Key="SearchPill" Color="#14141F" />
|
||||
<SolidColorBrush x:Key="KbdChip" Color="#23233A" />
|
||||
<SolidColorBrush x:Key="Magenta" Color="#DD1296" />
|
||||
<SolidColorBrush x:Key="TerminalTabAccent" Color="#7C5CFF" />
|
||||
<LinearGradientBrush x:Key="AvatarGradient" StartPoint="14%,0%" EndPoint="86%,100%">
|
||||
<GradientStop Color="#4A37A9" Offset="0" />
|
||||
<GradientStop Color="#8D51E7" Offset="0.46" />
|
||||
<GradientStop Color="#AB49EC" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
@@ -54,6 +55,16 @@ internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
|
||||
internal string Address => host.Address;
|
||||
|
||||
/// <summary>What <c>HostName</c> said, on its own — the v5c table's own column, beside <see cref="User"/>
|
||||
/// and <see cref="Port"/> rather than folded into <see cref="Address"/>.</summary>
|
||||
internal string Hostname => host.Hostname;
|
||||
|
||||
/// <summary>What <c>User</c> said, or an em dash where the entry named none.</summary>
|
||||
internal string User => host.Username is { Length: > 0 } user ? user : "—";
|
||||
|
||||
/// <summary>What <c>Port</c> said, defaulting to 22 the same way <see cref="ImportedHost"/> does.</summary>
|
||||
internal string Port => host.Port.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>Whether a host with this address is already in the keychain.</summary>
|
||||
internal bool AlreadyPresent { get; }
|
||||
|
||||
@@ -61,6 +72,23 @@ internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
|
||||
internal bool HasBadge => AlreadyPresent;
|
||||
|
||||
/// <summary>
|
||||
/// What the v5c table's WHAT THIS MEANS chip says, mapped honestly off the two facts this row actually
|
||||
/// carries — nothing this screen cannot back up. Skipped patterns (a wildcard <c>Host</c> block) never
|
||||
/// become a row at all, so there is no third, "skipped" state to draw here; a row's own per-host
|
||||
/// warnings, from <see cref="HasWarnings"/>, are the amber case instead — a flattened <c>ProxyJump</c> or
|
||||
/// a dropped directive is exactly the kind of thing "quieter than the file" that <c>ImportScreen.axaml</c>'s
|
||||
/// own remark says has to be told before it looks like data loss.
|
||||
/// </summary>
|
||||
internal string Meaning => HasWarnings ? Warnings : AlreadyPresent ? "already here" : "new host";
|
||||
|
||||
/// <summary>The warned case wins over "already here" — a warning is the more actionable of the two facts.</summary>
|
||||
internal bool IsMeaningWarned => HasWarnings;
|
||||
|
||||
internal bool IsMeaningExisting => !HasWarnings && AlreadyPresent;
|
||||
|
||||
internal bool IsMeaningNew => !HasWarnings && !AlreadyPresent;
|
||||
|
||||
/// <summary>Whether this row's <c>ssh_config</c> entry named a key at all.</summary>
|
||||
/// <remarks>
|
||||
/// Most do not, and the tick above the list is about the ones that do. Kept as a property rather than
|
||||
@@ -138,7 +166,10 @@ internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
/// <see cref="KeyReport"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
|
||||
internal sealed partial class ImportViewModel(
|
||||
VaultViewModel vault,
|
||||
SshConfigLocator locator,
|
||||
Action? onCancel = null) : ObservableObject
|
||||
{
|
||||
internal ObservableCollection<ImportRowViewModel> Rows { get; } = [];
|
||||
|
||||
@@ -202,6 +233,66 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
|
||||
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
|
||||
|
||||
/// <summary>Whether every row is ticked — what the v5c table's header tick-all box shows.</summary>
|
||||
internal bool AllTicked => Rows.Count > 0 && SelectedCount == Rows.Count;
|
||||
|
||||
/// <summary>
|
||||
/// The v5c header's own mono status line: the file this reads, and whether it has been read yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two real facts and nothing invented — <see cref="ConfigPath"/> and <see cref="HasScanned"/>. The
|
||||
/// fuller narrative belongs to <see cref="Status"/>, which this does not replace: what happened on a scan
|
||||
/// or an import is a sentence, not a fact this header line has room to state honestly in a handful of
|
||||
/// words.
|
||||
/// </remarks>
|
||||
internal string HeaderStatus => HasScanned ? $"{ConfigPath} · scanned" : $"{ConfigPath} · not scanned yet";
|
||||
|
||||
/// <summary>
|
||||
/// The key-material card's own always-visible sentence, ahead of the tick.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The count is hosts naming a key file, not raw <c>IdentityFile</c> lines — a fact <see cref="Rows"/>
|
||||
/// actually carries, where a literal line count would not survive a host that names more than one and is
|
||||
/// only ever bound to the first. The rest of the sentence is <c>ImportViewModel</c>'s own long-standing
|
||||
/// claim, restated in the design's words after checking it against <c>SshConfigLocator</c>: this type is
|
||||
/// the only place in the application that reads a private key out of a directory nobody pointed at file
|
||||
/// by file, and <see cref="ImportAsync"/> is the only place that ever calls
|
||||
/// <see cref="SshConfigLocator.ReadIdentity"/> — never <see cref="ScanAsync"/> — so nothing is read until
|
||||
/// IMPORT is pressed.
|
||||
/// </remarks>
|
||||
internal string KeyMaterialIntro
|
||||
{
|
||||
get
|
||||
{
|
||||
var count = Rows.Count(row => row.HasKeyFile);
|
||||
var directory = Path.GetDirectoryName(ConfigPath) ?? ConfigPath;
|
||||
var noun = count == 1 ? "host names" : "hosts name";
|
||||
|
||||
return $"The scan found {count} {noun} a key file in {directory}. 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.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The vault every import lands in — see <see cref="VaultViewModel.ImportHostsAsync"/>.</summary>
|
||||
/// <remarks>
|
||||
/// Fixed rather than offered as a picker: the import goes through the same
|
||||
/// <c>session.ActiveVaultId</c> every other bulk write does, and there is no per-import target choice to
|
||||
/// bind — see design-notes/v5c-fidelity-notes.md. Printed as a fact instead of drawn as a dropdown.
|
||||
/// </remarks>
|
||||
internal string VaultName => vault.VaultName;
|
||||
|
||||
/// <summary>The v5c footer's own sentence: how many are ticked, out of how many, and where they land.</summary>
|
||||
internal string SelectionSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
var noun = Rows.Count == 1 ? "entry" : "entries";
|
||||
|
||||
return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the file and shows what it found. Writes nothing.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
@@ -389,6 +480,16 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
|
||||
internal void NoteSelectionChanged() => RaiseListState();
|
||||
|
||||
/// <summary>The footer's own Cancel button: back to the Preferences page, nothing stored.</summary>
|
||||
/// <remarks>
|
||||
/// A delegate rather than a reference up to <c>MainWindowViewModel</c>, on the same reasoning
|
||||
/// <c>VaultViewModel</c>'s own <c>copyToClipboard</c> is one: this type has no business knowing settings
|
||||
/// mode exists, and a null delegate — nothing wired, as in a layout test that builds this directly — makes
|
||||
/// the button a no-op rather than a crash.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void Cancel() => onCancel?.Invoke();
|
||||
|
||||
/// <remarks>
|
||||
/// The rows carry the answer as well as the view model, because each one says what it will authenticate
|
||||
/// with and that sentence changes with the tick. Pushed rather than bound per row: a row cannot see a
|
||||
@@ -441,5 +542,11 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
OnPropertyChanged(nameof(HasKeyReport));
|
||||
OnPropertyChanged(nameof(SelectedCount));
|
||||
OnPropertyChanged(nameof(ImportLabel));
|
||||
OnPropertyChanged(nameof(AllTicked));
|
||||
OnPropertyChanged(nameof(KeyMaterialIntro));
|
||||
OnPropertyChanged(nameof(SelectionSummary));
|
||||
}
|
||||
|
||||
/// <remarks>The header's own status line is a function of <see cref="HasScanned"/> alone.</remarks>
|
||||
partial void OnHasScannedChanged(bool value) => OnPropertyChanged(nameof(HeaderStatus));
|
||||
}
|
||||
|
||||
@@ -106,10 +106,19 @@ internal sealed class KnownHostRowViewModel(
|
||||
internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultViewModel vault;
|
||||
private readonly Action? onBack;
|
||||
|
||||
internal KnownHostsViewModel(VaultViewModel vault)
|
||||
/// <param name="vault">Where the pins, the reload and the withdrawal all actually live.</param>
|
||||
/// <param name="onBack">
|
||||
/// What the v5c header's own back arrow does — a delegate rather than a reference up to
|
||||
/// <c>MainWindowViewModel</c>, on the same reasoning <c>ImportViewModel</c>'s own <c>onCancel</c> is one:
|
||||
/// this type has no business knowing <c>ShellScreen</c> exists. Null in a layout test that builds this
|
||||
/// directly makes the button a no-op rather than a crash.
|
||||
/// </param>
|
||||
internal KnownHostsViewModel(VaultViewModel vault, Action? onBack = null)
|
||||
{
|
||||
this.vault = vault;
|
||||
this.onBack = onBack;
|
||||
|
||||
// The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
|
||||
// copy of a trust decision is the one kind of staleness that matters here.
|
||||
@@ -155,6 +164,14 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
|
||||
internal bool HasPins => Shown.Any();
|
||||
|
||||
/// <summary>
|
||||
/// How many approved host keys this machine can see, before the filter box narrows the table — the v5c
|
||||
/// header's own count chip. Unfiltered, on the same reasoning <see cref="Summary"/> reads off
|
||||
/// <see cref="Shown"/> rather than <see cref="VisiblePins"/>: it is a fact about the list, not about
|
||||
/// whatever somebody last typed into the filter.
|
||||
/// </summary>
|
||||
internal int Count => Shown.Count();
|
||||
|
||||
internal bool HasVisiblePins => VisiblePins.Count > 0;
|
||||
|
||||
internal bool HasSelection => Selected is not null;
|
||||
@@ -218,6 +235,22 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Puts the selected pin's fingerprint on the clipboard. Forwarded, like <see cref="ForgetSelectedAsync"/>.</summary>
|
||||
[RelayCommand]
|
||||
private async Task CopyFingerprintAsync()
|
||||
{
|
||||
if (Selected is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await vault.CopyPinFingerprintCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>The header's own back arrow: to the Keychain screen this list was pulled out of.</summary>
|
||||
[RelayCommand]
|
||||
private void Back() => onBack?.Invoke();
|
||||
|
||||
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
|
||||
|
||||
partial void OnFilterChanged(string value) => Rebuild();
|
||||
@@ -247,6 +280,7 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(HasPins));
|
||||
OnPropertyChanged(nameof(Count));
|
||||
OnPropertyChanged(nameof(HasVisiblePins));
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
|
||||
@@ -114,6 +114,12 @@ internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry
|
||||
_ => "changed",
|
||||
};
|
||||
|
||||
/// <summary>── v5b ── Whether the WHAT chip should draw in its "created" colour.</summary>
|
||||
internal bool IsCreated => entry.Secret.Operation is ActivityOperation.Created;
|
||||
|
||||
/// <summary>── v5b ── Whether the WHAT chip should draw in its "deleted" colour.</summary>
|
||||
internal bool IsDeleted => entry.Secret.Operation is ActivityOperation.Deleted;
|
||||
|
||||
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
|
||||
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
@@ -192,6 +198,24 @@ internal sealed partial class LogsViewModel : ObservableObject
|
||||
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
|
||||
+ "names of the fields that changed, never their contents.";
|
||||
|
||||
/// <summary>
|
||||
/// ── v5b ── The mono sentence Logs.dc.html prints beside REFRESH: what one row of the showing log
|
||||
/// actually records.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per section rather than one line for the screen, and drawn from the same fact this type's own header
|
||||
/// remarks already state: an entry is written once, at close, for a connection; and once per write, per
|
||||
/// device, for an activity row. Not <see cref="Status"/> itself — that is a refresh outcome, cleared to
|
||||
/// empty on success — so <see cref="HeaderStatusLine"/> is the one the header actually binds, showing an
|
||||
/// error over this fact when there is one to show.
|
||||
/// </remarks>
|
||||
internal string SectionFact => Section is LogSection.Connections
|
||||
? "an entry is written once, when a connection closes"
|
||||
: "one row per write, per device";
|
||||
|
||||
/// <summary>What the header's own status line shows: an error, if refreshing just produced one, else the fact.</summary>
|
||||
internal string HeaderStatusLine => Status.Length > 0 ? Status : SectionFact;
|
||||
|
||||
/// <summary>Shows one of the two logs.</summary>
|
||||
[RelayCommand]
|
||||
private void ShowSection(LogSection section) => Section = section;
|
||||
@@ -296,7 +320,11 @@ internal sealed partial class LogsViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(ShowsConnections));
|
||||
OnPropertyChanged(nameof(ShowsActivity));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
OnPropertyChanged(nameof(SectionFact));
|
||||
OnPropertyChanged(nameof(HeaderStatusLine));
|
||||
}
|
||||
|
||||
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(HeaderStatusLine));
|
||||
}
|
||||
|
||||
/// <summary>A connection that is open right now.</summary>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user