Public Access
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c56f20a4 | ||
|
|
7f77539ba6 | ||
|
|
ca081af209 | ||
|
|
890a5f2246 | ||
|
|
8c67fce32c | ||
|
|
0ffd259ccd | ||
|
|
9bc9069425 | ||
|
|
e936ab4646 | ||
|
|
506d2803a2 |
@@ -291,6 +291,7 @@ jobs:
|
|||||||
# so it is not done either. What reaches users is built, installed and walked through Phase 16
|
# so it is not done either. What reaches users is built, installed and walked through Phase 16
|
||||||
# of docs/manual-checks.md by a person first.
|
# of docs/manual-checks.md by a person first.
|
||||||
- name: package the windows desktop client
|
- name: package the windows desktop client
|
||||||
|
id: winpack
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -374,6 +375,93 @@ jobs:
|
|||||||
ls -la "$releases"
|
ls -la "$releases"
|
||||||
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
|
echo "Packaged DodoSSH $packVersion for win-x64, from a build MinVer calls $version."
|
||||||
|
|
||||||
|
# Handed to the macOS step below rather than worked out again there. The floor logic above
|
||||||
|
# is thirty lines of reasoning about MinVer's pre-first-tag answer, and a second copy of it
|
||||||
|
# is a second thing to keep in step — while two desktop packages built from one commit
|
||||||
|
# carrying different version numbers is precisely the confusion this file spends that
|
||||||
|
# reasoning to avoid.
|
||||||
|
echo "packVersion=$packVersion" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# ◆ AND THE macOS BUNDLE IS BUILT HERE, ON LINUX, AND IS ALSO THROWN AWAY.
|
||||||
|
#
|
||||||
|
# Same argument as the Windows step above, one platform along: the failures a release is most
|
||||||
|
# exposed to are the ones only the packager finds, and the person who would otherwise find them
|
||||||
|
# is the one midway through a release on the one Mac that can cut one.
|
||||||
|
#
|
||||||
|
# What this catches that the Windows step cannot: the osx-arm64 restore graph. A native package
|
||||||
|
# that resolves for win-x64 and has no osx-arm64 asset — libsodium and SkiaSharp both ship per
|
||||||
|
# RID — fails here, on every main build, rather than at the first `dotnet publish` of a release
|
||||||
|
# nobody can retry without a Mac.
|
||||||
|
#
|
||||||
|
# ◆ bundle, NOT pack, AND THE DIFFERENCE IS NOT A CHOICE.
|
||||||
|
#
|
||||||
|
# `vpk [osx]` cross-compiling from a non-Mac offers exactly one packaging verb: bundle, which
|
||||||
|
# builds the .app. There is no `[osx] pack` off a Mac, and that is correct rather than a gap —
|
||||||
|
# pack signs with codesign, submits to Apple with notarytool and staples the ticket, all of
|
||||||
|
# which is Apple tooling that exists on no other platform. So this proves the bundle and stops
|
||||||
|
# where the platform does.
|
||||||
|
#
|
||||||
|
# No --plist and no --icon either, deliberately. Both are proved by scripts/release-macos.sh on
|
||||||
|
# the machine that can also check the result; passing a rendered plist here would mean copying
|
||||||
|
# the substitution out of that script to no end, since nothing looks at what this produces.
|
||||||
|
#
|
||||||
|
# ◆ NOTHING IS UPLOADED, FOR THE REASON THE WINDOWS STEP GIVES.
|
||||||
|
#
|
||||||
|
# RUNNER_TEMP, dying with the job. ADR 0013 rule 3 puts the capability to ship somebody a build
|
||||||
|
# on a machine which is not a runner, and an unsigned .app is additionally something no Mac
|
||||||
|
# would open — so publishing it would be handing out a file whose only possible use is confusion.
|
||||||
|
- name: publish and bundle the macos desktop client
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# RestoreLockedMode=false for the RID, exactly as the win-x64 publish above does — see the
|
||||||
|
# long note there for why the committed lock files are deliberately RID-free. This runner's
|
||||||
|
# checkout is thrown away, so the lock files it rewrites go nowhere.
|
||||||
|
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||||
|
--configuration Release --runtime osx-arm64 --self-contained true \
|
||||||
|
-p:RestoreLockedMode=false \
|
||||||
|
--output "$RUNNER_TEMP/osx-arm64"
|
||||||
|
|
||||||
|
# The apphost has no extension on macOS, so this is `DodoSSH` and not `DodoSSH.exe`. Named
|
||||||
|
# rather than globbed, because a publish that produced no apphost at all would otherwise
|
||||||
|
# bundle happily and produce an .app that launches nothing.
|
||||||
|
if [ ! -s "$RUNNER_TEMP/osx-arm64/DodoSSH" ]; then
|
||||||
|
echo "The osx-arm64 publish produced no apphost." >&2
|
||||||
|
ls -la "$RUNNER_TEMP/osx-arm64" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bundles="$RUNNER_TEMP/osx-bundle"
|
||||||
|
|
||||||
|
# The quotes around [osx] are load-bearing, exactly as they are on '[win]' above: unquoted,
|
||||||
|
# the shell reads it as a glob matching any one of o, s and x.
|
||||||
|
dotnet vpk '[osx]' bundle \
|
||||||
|
--skip-updates \
|
||||||
|
--packId DodoSSH.Desktop \
|
||||||
|
--packVersion '${{ steps.winpack.outputs.packVersion }}' \
|
||||||
|
--packDir "$RUNNER_TEMP/osx-arm64" \
|
||||||
|
--packTitle DodoSSH \
|
||||||
|
--packAuthors DodoTech \
|
||||||
|
--mainExe DodoSSH \
|
||||||
|
--bundleId dev.dodotech.dodossh \
|
||||||
|
--runtime osx-arm64 \
|
||||||
|
--channel osx \
|
||||||
|
--outputDir "$bundles"
|
||||||
|
|
||||||
|
# Asked for rather than inferred from an exit code, for the reason the Windows step gives.
|
||||||
|
# The Info.plist is the specific thing worth naming: a bundle missing it is a directory
|
||||||
|
# macOS will not treat as an application at all, and it is the one part of the .app that
|
||||||
|
# vpk composes rather than copies.
|
||||||
|
app="$bundles/DodoSSH.Desktop.app"
|
||||||
|
if [ ! -s "$app/Contents/Info.plist" ]; then
|
||||||
|
echo "vpk reported success and there is no Info.plist at $app/Contents/Info.plist." >&2
|
||||||
|
find "$bundles" -maxdepth 3 >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Bundled DodoSSH ${{ steps.winpack.outputs.packVersion }} for osx-arm64."
|
||||||
|
|
||||||
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
||||||
# server through Testcontainers and runs the API as a child process — so it needs a
|
# server through Testcontainers and runs the API as a child process — so it needs a
|
||||||
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ docs/platform-flags.md what differs off Windows, and the gotchas that have c
|
|||||||
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
||||||
docs/android-port.md the Android head: what was decided, what is built, what is left
|
docs/android-port.md the Android head: what was decided, what is built, what is left
|
||||||
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
|
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
|
||||||
|
release-macos.sh — the same, signed and notarized, on a Mac
|
||||||
|
build/macos/ the entitlements and Info.plist template the macOS bundle is built from
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
||||||
@@ -152,8 +154,32 @@ reinstalling asks for your passphrase rather than starting over. Use **Sign out*
|
|||||||
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
|
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
|
||||||
this machine's device key from your account.
|
this machine's device key from your account.
|
||||||
|
|
||||||
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
|
## Installing on macOS
|
||||||
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
|
|
||||||
|
A `.pkg` on the same release page, for Apple Silicon. Everything above about where a client may come from,
|
||||||
|
about the update check and about uninstalling applies unchanged; what differs is worth three short
|
||||||
|
paragraphs.
|
||||||
|
|
||||||
|
**It is signed and notarized, so there is no warning to click past.** That is not generosity — macOS refuses
|
||||||
|
to open an un-notarized download outright rather than warning about it, so unlike the Windows build there
|
||||||
|
was never an unsigned option. If you *do* see "cannot be opened because Apple cannot check it for malicious
|
||||||
|
software", the file did not come from the project's release page, and that is worth taking literally.
|
||||||
|
|
||||||
|
**Apple Silicon only for now.** An Intel package is a small amount of work and no one here has an Intel Mac
|
||||||
|
to check it on, and this project does not ship desktop builds nobody has run — see
|
||||||
|
[docs/manual-checks.md](docs/manual-checks.md). Under Rosetta the arm64 build will not run; there is no
|
||||||
|
graceful version of that, and the honest answer is that the platform is not covered yet.
|
||||||
|
|
||||||
|
**Touch ID can stand in for your passphrase**, on a Mac with a Secure Enclave. The key that unwraps your
|
||||||
|
device key is generated inside the enclave and never leaves it, and the enclave — not DodoSSH — is what
|
||||||
|
requires your fingerprint or login password before it will use it. Cancel the prompt and you get the
|
||||||
|
passphrase screen, always. The application lives at `/Applications/DodoSSH.Desktop.app` and your vault cache
|
||||||
|
at `~/Library/Application Support/DodoSSH`, which are deliberately two different places so that removing the
|
||||||
|
first never touches the second.
|
||||||
|
|
||||||
|
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine, and
|
||||||
|
`scripts/release-macos.sh` on a Mac. Deliberately not a CI job; ADR 0013 decision 3 explains why, and it is
|
||||||
|
not only that the runners are Linux.
|
||||||
|
|
||||||
### The nightly desktop build
|
### The nightly desktop build
|
||||||
|
|
||||||
@@ -886,8 +912,18 @@ keychain plus a terminal — and the spike that gates all of it.
|
|||||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
||||||
[Installing on Windows](#installing-on-windows) for what a user sees.
|
[Installing on Windows](#installing-on-windows) for what a user sees.
|
||||||
|
|
||||||
Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the
|
**The macOS half is built on the same machinery**, and signed from the start because Gatekeeper leaves no
|
||||||
first release aimed at strangers), and macOS and Linux packaging.
|
choice: `scripts/release-macos.sh` publishes, signs every native library, notarizes with Apple and staples
|
||||||
|
the ticket before it will hand anything over, and refuses to upload until a person has installed it. The
|
||||||
|
device key is held in the Secure Enclave behind Touch ID. CI publishes `osx-arm64` and builds the `.app`
|
||||||
|
on every main build to prove it still packages, and uploads nothing. See
|
||||||
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) decision 10 and
|
||||||
|
[Installing on macOS](#installing-on-macos).
|
||||||
|
|
||||||
|
Still to do here: Windows signing (the first Windows release is unsigned, and the trigger for buying a
|
||||||
|
certificate is the first release aimed at strangers), macOS on Intel, Linux packaging, and Phase 18 of the
|
||||||
|
manual checks — the macOS build has never actually run, because there is no macOS runner in CI and
|
||||||
|
everything above is verified only as far as the bundle.
|
||||||
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
What the hardened runtime has to be asked to relax before a .NET application will run under it.
|
||||||
|
|
||||||
|
The hardened runtime is not optional: notarization refuses a Developer ID submission without it,
|
||||||
|
and Gatekeeper refuses an un-notarized download. So every entitlement below is the price of being
|
||||||
|
distributable at all, and each one is a hole in a wall that is otherwise worth having. They are
|
||||||
|
listed one at a time, with what breaks without each, because the temptation when notarization
|
||||||
|
fails at eleven at night is to paste in a longer list from somewhere and stop thinking.
|
||||||
|
|
||||||
|
◆ WHAT IS DELIBERATELY NOT HERE.
|
||||||
|
|
||||||
|
com.apple.security.app-sandbox. Developer ID distribution outside the App Store does not require
|
||||||
|
the sandbox, and turning it on would break the product outright: the terminal's data plane is a
|
||||||
|
loopback WebSocket (see DodoSSH.Client.Terminal/TerminalDataPlane.cs), and a sandboxed process
|
||||||
|
needs com.apple.security.network.server to listen at all, plus network.client to reach any host
|
||||||
|
the user asks for. This is the same shape of decision as ruling out MSIX on Windows, which was
|
||||||
|
ruled out for the same loopback reason — docs/platform-flags.md.
|
||||||
|
|
||||||
|
com.apple.security.cs.debugger. Would let this process attach to others. Nothing here debugs
|
||||||
|
anything, and it is the entitlement most worth not having.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!--
|
||||||
|
CoreCLR compiles IL to machine code at runtime and then executes the pages it just wrote. The
|
||||||
|
hardened runtime's default is that no page is both writable and executable, so without this the
|
||||||
|
process does not start — it dies during runtime initialisation, before any of this application's
|
||||||
|
code runs, which means before anything exists that could report it.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The broader form of the same permission, and it is needed as well as allow-jit rather than
|
||||||
|
instead of it. allow-jit covers pages mapped through the MAP_JIT convention; CoreCLR also
|
||||||
|
allocates executable memory outside that path — stubs, precode, and the write-xor-execute
|
||||||
|
fallback it uses when MAP_JIT is unavailable. With only the first, startup gets further and
|
||||||
|
still fails.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Library validation requires every loaded dylib to be signed by the same team as the main
|
||||||
|
binary. This bundle carries native libraries built by other people — libsodium, libSkiaSharp,
|
||||||
|
libHarfBuzzSharp, libe_sqlite3, libAvaloniaNative — and the release script signs each of them
|
||||||
|
with this Developer ID, which would in principle satisfy validation.
|
||||||
|
|
||||||
|
It is disabled anyway, and the reason is the updater. Velopack replaces the bundle in place and
|
||||||
|
relaunches it, and the process doing the replacing is not always signed by the same team as the
|
||||||
|
process being replaced during the changeover. Leaving validation on makes the failure mode of a
|
||||||
|
bad update "the application will not start", with no way to recover except a reinstall the user
|
||||||
|
would have to be told about through some other channel.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The runtime reads DYLD_ variables while resolving its own native dependencies, and Velopack's
|
||||||
|
update path sets them. Without this the hardened runtime strips them silently and the failure
|
||||||
|
surfaces later as a library that cannot be found, naming a file that is plainly present.
|
||||||
|
-->
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
The Info.plist for the macOS bundle, with the version left as a placeholder.
|
||||||
|
|
||||||
|
◆ A TEMPLATE RATHER THAN A FILE, BECAUSE vpk COPIES A CUSTOM PLIST VERBATIM.
|
||||||
|
|
||||||
|
Measured, not assumed: `vpk [osx] bundle --plist` performs no substitution of any kind. It logs
|
||||||
|
"Bundle using provided Info.plist" and copies the bytes. That is also why it refuses --plist and
|
||||||
|
--bundleId together — with a plist supplied, every key is the caller's problem.
|
||||||
|
|
||||||
|
So a committed Info.plist would carry whatever version it was written with into every release
|
||||||
|
afterwards, and the failure is quiet in the worst way: Velopack's own release index would carry the
|
||||||
|
right version, the updater would compare correctly and update correctly, and only the About window,
|
||||||
|
Finder's Get Info panel and any crash report would claim the build was something else. Nobody
|
||||||
|
reads those on the day of a release. scripts/release-macos.sh substitutes @VERSION@ into a copy
|
||||||
|
and passes that.
|
||||||
|
|
||||||
|
◆ WHY A CUSTOM PLIST AT ALL, WHEN vpk WRITES A PERFECTLY GOOD ONE.
|
||||||
|
|
||||||
|
Three keys it does not write, each of which is a real defect without it:
|
||||||
|
|
||||||
|
CFBundleDisplayName The bundle on disk is DodoSSH.Desktop.app, because the pack id must not
|
||||||
|
be DodoSSH — see scripts/release-macos.sh for the directory collision
|
||||||
|
that rule prevents. On Windows the pack id is invisible; on macOS it
|
||||||
|
names the thing in /Applications and in the Dock. This key is what puts
|
||||||
|
"DodoSSH" back in front of a person while the bundle keeps the id.
|
||||||
|
|
||||||
|
LSMinimumSystemVersion Without it macOS will happily launch this on a release the runtime was
|
||||||
|
never built for, and the user gets a dyld crash rather than a sentence.
|
||||||
|
|
||||||
|
NSHumanReadableCopyright Shown in the About panel. Absent, the panel shows a blank line.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!--
|
||||||
|
CFBundleName is what the menu bar shows and is capped at 15 characters by convention;
|
||||||
|
CFBundleDisplayName is what Finder and the Dock show. Both say DodoSSH, and the bundle
|
||||||
|
directory does not. See the note above.
|
||||||
|
-->
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>DodoSSH</string>
|
||||||
|
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>DodoSSH</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Reverse-DNS under the domain this project actually controls. It is the identity Gatekeeper,
|
||||||
|
the notary service and the keychain all key off, so it is as irreversible as the Windows pack
|
||||||
|
id: changing it makes an update a different application, and it orphans anything the previous
|
||||||
|
identifier stored — including the Secure Enclave key MacDeviceKeyStore holds, which is scoped
|
||||||
|
to this identifier and cannot be migrated because its whole point is that it never leaves the
|
||||||
|
enclave.
|
||||||
|
-->
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>dev.dodotech.dodossh</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The apphost the publish produced, named for the product by <AssemblyName> in the csproj rather
|
||||||
|
than for the project. Must match --mainExe or the bundle launches nothing.
|
||||||
|
-->
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>DodoSSH</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Both version keys take the numeric core only — 1.2.3 and never 1.2.3-rc.1 — because Apple
|
||||||
|
defines them as one to three dot-separated integers and notarization rejects what it cannot
|
||||||
|
parse. The full version, prerelease suffix and all, is in Velopack's release index, and that
|
||||||
|
is the one the updater compares. These two are for Finder and for Gatekeeper.
|
||||||
|
|
||||||
|
They are the same value rather than the usual marketing/build split, because there is no build
|
||||||
|
counter here that a release does not already bump.
|
||||||
|
-->
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>@VERSION@</string>
|
||||||
|
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>@VERSION@</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The file name inside Contents/Resources, which is where --icon puts it. With a custom plist
|
||||||
|
nothing rewrites this key, so a rename of the asset that forgets this line produces a bundle
|
||||||
|
showing the generic application icon and no error anywhere.
|
||||||
|
-->
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>dodossh.icns</string>
|
||||||
|
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
12.0, and it is read off the binaries rather than off a support matrix. The apphost and
|
||||||
|
libcoreclr.dylib in a net10.0 osx-arm64 publish both carry LC_BUILD_VERSION with minos 12.0.0,
|
||||||
|
so 12.0 is the oldest release these bytes are built to load on.
|
||||||
|
|
||||||
|
Microsoft's *support* statement for .NET 10 is higher than this, and that difference is
|
||||||
|
deliberate rather than overlooked: this key decides whether macOS refuses to launch the app at
|
||||||
|
all, and refusing on a release where it would in fact have run is the worse of the two errors.
|
||||||
|
A user on an unsupported-but-working macOS gets the application; the support matrix governs
|
||||||
|
what gets fixed if it misbehaves there, which is a different question.
|
||||||
|
-->
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>12.0</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Without this the window is drawn at 1x and scaled up, which on a Retina display turns the
|
||||||
|
terminal — the one surface in this application that is nothing but small text — into a blur.
|
||||||
|
-->
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<key>NSPrincipalClass</key>
|
||||||
|
<string>NSApplication</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
False, and stated rather than left out. An agent application has no Dock icon and no menu bar;
|
||||||
|
this one is an ordinary windowed application and the default is already false, but the key
|
||||||
|
being absent is indistinguishable from somebody having removed it.
|
||||||
|
-->
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<false/>
|
||||||
|
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© DodoTech. MIT licensed.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# ADR 0007 — What protects the device key on Windows
|
# ADR 0007 — What protects the device key on the desktop
|
||||||
|
|
||||||
**Status:** accepted, 2026-07-30
|
**Status:** accepted, 2026-07-30
|
||||||
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
|
**Supersedes nothing. Constrains** the device-unlock work described in the client roadmap.
|
||||||
@@ -141,6 +141,14 @@ would have become false under DPAPI alone. A gesture is still something the atta
|
|||||||
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
- **A TPM is not always there.** A machine without one gets a store that reports itself unavailable, so
|
||||||
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
unlock keeps asking for the passphrase and neither affordance appears in the interface. The passphrase path
|
||||||
is therefore required, not a nicety.
|
is therefore required, not a nicety.
|
||||||
|
- **macOS reaches the same decision through different hardware, and the argument transfers intact.**
|
||||||
|
`MacDeviceKeyStore` puts the wrapping key in the Secure Enclave under an access control requiring user
|
||||||
|
presence, so Touch ID or the login password is a condition of *using* it and the enforcement is the
|
||||||
|
platform's rather than the process's — which is the entire point of the 2026-07-30 amendment above, and
|
||||||
|
the thing a self-drawn prompt over a protected file would fail to be. The mechanical differences are
|
||||||
|
incidental: P-256 with ECIES because the enclave holds no other kind of key, and no prompt when sealing
|
||||||
|
because the public half needs no consent. See docs/platform-flags.md for the three ordinary Macs where the
|
||||||
|
probe answers no, one of which is every unsigned development build.
|
||||||
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
|
- **The stored key must be treated as losable at any time** — a reset PIN, a cleared TPM, a replaced key.
|
||||||
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
|
Every loss degrades to a passphrase prompt and never to a locked-out vault, which is why every failure in
|
||||||
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
|
the store returns null rather than throwing and why the three unlock statuses all end in the same advice.
|
||||||
|
|||||||
@@ -228,6 +228,44 @@ changes.
|
|||||||
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
||||||
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
||||||
|
|
||||||
|
**This rule is Windows-only, and macOS gets the opposite one.** See decision 10: there is no "unsigned for
|
||||||
|
now" available on that platform at any price, because Gatekeeper refuses rather than warns.
|
||||||
|
|
||||||
|
### 10. macOS is a second desktop platform on the same machinery, signed from the start
|
||||||
|
|
||||||
|
The macOS head is the same application, the same Velopack, and the same two-phase person-run release. Four
|
||||||
|
things differ, and each is forced rather than chosen.
|
||||||
|
|
||||||
|
**Signing is a precondition, not an improvement.** Decision 8's whole argument — one dialog per user per
|
||||||
|
lifetime, buy a certificate when a stranger is invited to install — has no macOS equivalent. An
|
||||||
|
un-notarized download is refused outright, so the Developer ID certificate and the notarization round trip
|
||||||
|
are the price of the package existing. `scripts/release-macos.sh` therefore refuses to run without the
|
||||||
|
signing identities, where the Windows script refuses nothing.
|
||||||
|
|
||||||
|
**The channels are `osx` and `osx-nightly`, and they are separate for decision 9's reason.** Four channels
|
||||||
|
now publish to one repository, and the only thing keeping a Mac from being offered a Windows package is
|
||||||
|
that it never reads that index. The macOS nightly channel is named and has no publisher: CI builds and
|
||||||
|
bundles the macOS head to prove it still builds, and uploads nothing, exactly as it does for the Windows
|
||||||
|
release channel.
|
||||||
|
|
||||||
|
**The pack id is shared with Windows, and on macOS it is visible.** vpk names the bundle after the pack id,
|
||||||
|
so `/Applications` holds `DodoSSH.Desktop.app`. Decision 2's reasoning applies with more force here rather
|
||||||
|
than less: a pack id of `DodoSSH` would put Velopack's install root on `~/Library/Application
|
||||||
|
Support/DodoSSH`, which is `ClientPaths.DataDirectory`, and an uninstall would take the user's un-synced
|
||||||
|
outbox with it. `CFBundleDisplayName` puts the product name back in front of a person; the directory keeps
|
||||||
|
the id.
|
||||||
|
|
||||||
|
**arm64 only, because the check is the scarce thing.** Velopack keys a channel to one architecture, and an
|
||||||
|
Intel package would be the only artefact in this repository reaching users without somebody having walked
|
||||||
|
Phase 18 against it. The engineering for a second channel is small and is described in the release script;
|
||||||
|
what is missing is an Intel Mac to verify on, and shipping blind is the thing this project's manual-check
|
||||||
|
discipline exists to refuse.
|
||||||
|
|
||||||
|
**And one thing that does not differ, which is worth saying because it is the expensive half.** The
|
||||||
|
capability to publish still lives on a person's machine and never in CI. Notarization does not change that:
|
||||||
|
Apple's ticket says this build came from this developer account, and says nothing about whether the build
|
||||||
|
should have been made. Velopack clients still apply what their feed serves. Rule 3 is untouched.
|
||||||
|
|
||||||
### 9. There is a second desktop channel, published by CI, and it is a second application
|
### 9. There is a second desktop channel, published by CI, and it is a second application
|
||||||
|
|
||||||
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
|
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
|
||||||
|
|||||||
@@ -348,6 +348,16 @@ runtime or the receipt is silently invisible — the service still runs, but not
|
|||||||
to show, at most once, with no result read back: a refusal costs the notification and nothing else, which is
|
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.
|
what the manifest's own comment on the permission says.
|
||||||
|
|
||||||
|
**And a fourth correction, found by the notification refusing to come down.** "1 shell connected" outlived
|
||||||
|
the shell, both ways a shell can close. A shell exiting on its own announced `SessionEnded` from inside its
|
||||||
|
run's own finally block — where the run task is by definition not yet complete, so the
|
||||||
|
`LiveSessionCount` the keep-alive reads from that event still counted the session that had just ended, and
|
||||||
|
nothing fired afterwards to correct it. A tab closed by hand announced nothing at all, by a recorded
|
||||||
|
decision that assumed every subscriber was the closer. Both reversed in `TerminalWorkspace`: the end is now
|
||||||
|
announced from a continuation after the run has actually completed, and `CloseSessionAsync` announces too,
|
||||||
|
after its own drain — the event's remark carries the reversal, and `SessionEnded`'s subscribers were all
|
||||||
|
already "reconcile to reality" handlers for which a second announcement is harmless.
|
||||||
|
|
||||||
### Phone first
|
### Phone first
|
||||||
|
|
||||||
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
|
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
|
||||||
|
|||||||
+143
-2
@@ -1745,6 +1745,24 @@ is a terminal that answers the buttons and ignores the keyboard: it reads as the
|
|||||||
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
|
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
|
||||||
tap of an arrow key.
|
tap of an arrow key.
|
||||||
|
|
||||||
|
### 11.10a The accessory keys do not cost the terminal its *software* keyboard either
|
||||||
|
|
||||||
|
With a shell open and the software keyboard up, tap **esc**, **tab** or an arrow on the accessory row, then
|
||||||
|
keep typing on the software keyboard.
|
||||||
|
|
||||||
|
**Pass:** the keyboard settles back unchanged — same layout, same suggestion strip, same height — and
|
||||||
|
everything typed after the tap still reaches the terminal. The accessory row stays visible above the
|
||||||
|
keyboard throughout. A blink during the press itself is tolerable: the platform takes the focus on both
|
||||||
|
halves of every touch and the return is posted right behind each theft, so the connection can visibly flap
|
||||||
|
for the press's own duration — what it must never do is *stay* swapped after the finger lifts.
|
||||||
|
|
||||||
|
**Failure means:** Android's own view focus stayed on Avalonia's input view after the tap instead of being
|
||||||
|
handed back. This is the half `Focusable = false` cannot reach — the platform requests focus for its own
|
||||||
|
view after dispatching every handled touch — and the symptom chain is the keyboard swapping to its no-input
|
||||||
|
layout and the inset churn parking it over the very row that was tapped. The first fix for this failed by
|
||||||
|
timing alone: it handed focus back from inside the very dispatch the platform re-steals it after. See
|
||||||
|
`TerminalFocus` in the Android head's Platform folder for both the mechanism and the fix's shape.
|
||||||
|
|
||||||
### 11.11 Closing a connection and opening a new one both take you somewhere real
|
### 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.
|
Open a shell, close its tab, then open a different one from HOSTS.
|
||||||
@@ -2115,13 +2133,19 @@ foreground service is protecting), wait thirty seconds with the shell doing noth
|
|||||||
**Pass:** the notification stayed up the whole time, and the shell is exactly where it was — same scrollback,
|
**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.
|
same prompt — with typing reaching the host immediately. Exit the shell.
|
||||||
|
|
||||||
**Pass:** the notification goes with it, once nothing else is open.
|
**Pass:** the notification goes with it, once nothing else is open. Open another shell and close it from the
|
||||||
|
shells strip's ✕ instead of exiting — the notification comes down for that route too, which is the route
|
||||||
|
that used to leave it up: a deliberate close announced nothing to the keep-alive at all, and a shell exiting
|
||||||
|
on its own was announced while the count still included it.
|
||||||
|
|
||||||
**Failure means:** an upload that stalls with the screen off is the count not reaching
|
**Failure means:** an upload that stalls with the screen off is the count not reaching
|
||||||
`SessionForegroundService`, and Android has stopped the process mid-transfer. A notification left up
|
`SessionForegroundService`, and Android has stopped the process mid-transfer. A notification left up
|
||||||
afterwards is `ActivityChanged` not being subscribed — the other end of the same wire. A shell that has
|
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
|
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.
|
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
|
### 14.6a A Files connection with nothing moving still survives backgrounding
|
||||||
|
|
||||||
@@ -2572,3 +2596,120 @@ package manager will not offer to.
|
|||||||
|
|
||||||
**Failure means:** the channels are not separate, and a public key is signing the application people keep
|
**Failure means:** the channels are not separate, and a public key is signing the application people keep
|
||||||
their credentials in.
|
their credentials in.
|
||||||
|
|
||||||
|
## Phase 18 — Installing the macOS client, and being updated by it
|
||||||
|
|
||||||
|
The macOS counterpart of phase 16, and it needs a Mac with a Secure Enclave — an Apple Silicon machine or
|
||||||
|
an Intel one with a T2. Every check here is structurally unreachable by a test for the reasons phase 16
|
||||||
|
gives, plus one this platform adds: **CI has no macOS runner at all**, so this phase is the only place the
|
||||||
|
suite and the application ever run on macOS. Anything `docs/platform-flags.md` marks as unverified on macOS
|
||||||
|
is verified here or nowhere.
|
||||||
|
|
||||||
|
Run `bash scripts/release-macos.sh` first. It stops after packing and notarizing, on purpose, so that
|
||||||
|
everything below happens before anything reaches a user. Phase 16.0 — the feed being readable without
|
||||||
|
credentials — applies unchanged and is not repeated.
|
||||||
|
|
||||||
|
### 18.1 Gatekeeper accepts it on a machine that did not build it · **do this one first**
|
||||||
|
|
||||||
|
The Mac that signed a package trusts it locally whatever happened, so the build machine cannot answer this
|
||||||
|
question about itself. Copy the `.pkg` to a second Mac — or at minimum download it through a browser, which
|
||||||
|
is what applies the quarantine attribute — and open it.
|
||||||
|
|
||||||
|
**Pass:** it installs with no warning beyond the ordinary installer prompts.
|
||||||
|
|
||||||
|
**Failure means:** "cannot be opened because Apple cannot check it for malicious software" is notarization
|
||||||
|
that did not happen or a ticket that did not staple. The script's `spctl --assess` and `xcrun stapler
|
||||||
|
validate` should have caught it before this point, so reaching here means one of those two checks was
|
||||||
|
removed or skipped. Do not distribute the package.
|
||||||
|
|
||||||
|
### 18.2 The Dock shows the product and not the pack id
|
||||||
|
|
||||||
|
Look at the installed application in `/Applications`, in the Dock, and in the menu bar while it runs.
|
||||||
|
|
||||||
|
**Pass:** the menu bar says **DodoSSH**. Finder shows **DodoSSH**. The bundle on disk is
|
||||||
|
`DodoSSH.Desktop.app` and that is expected — see the pack id note in `scripts/release-macos.sh`.
|
||||||
|
|
||||||
|
**Failure means:** "DodoSSH.Desktop" in the menu bar is `CFBundleName` not reaching the bundle, which means
|
||||||
|
the rendered `Info.plist` did not get used. Since vpk copies a custom plist verbatim and substitutes
|
||||||
|
nothing, check the same bundle's `CFBundleShortVersionString` — if it reads `@VERSION@`, the template was
|
||||||
|
passed through unrendered.
|
||||||
|
|
||||||
|
### 18.3 The icon is the mark, at every size
|
||||||
|
|
||||||
|
Look at it in the Dock, in Finder's icon view at a large size, and in `⌘I` Get Info.
|
||||||
|
|
||||||
|
**Pass:** the accent tile and the `>_` mark, crisp at 1024, with the same air around it that Finder and
|
||||||
|
Safari have.
|
||||||
|
|
||||||
|
**Failure means:** a generic application icon is `CFBundleIconFile` naming a file that is not in
|
||||||
|
`Contents/Resources`. An icon that fills its square edge to edge, larger than its neighbours, is
|
||||||
|
`New-MarkPng` having been called with the Windows tile fraction — see `dodossh-icon.ps1`.
|
||||||
|
|
||||||
|
### 18.4 Touch ID guards the device key, and the enclave enforces it
|
||||||
|
|
||||||
|
Register a device key from the security settings page, then lock the vault and unlock it again.
|
||||||
|
|
||||||
|
**Pass:** registering shows **no** prompt at all — sealing uses only the public half — and unlocking raises
|
||||||
|
the system Touch ID sheet saying DodoSSH is trying to *unlock your DodoSSH vault*. The vault opens on a
|
||||||
|
successful touch.
|
||||||
|
|
||||||
|
**Failure means:** a prompt at registration is not a failure of correctness but says the key was not created
|
||||||
|
in the enclave; check that `kSecAttrTokenID` reached the attributes. **No prompt at unlock, with the vault
|
||||||
|
opening anyway, is the serious one** — it means the key is a software key and the access control did nothing,
|
||||||
|
which is precisely the "a gate inside the process is not a gate" mistake `WindowsDeviceKeyStore` documents.
|
||||||
|
|
||||||
|
### 18.5 Declining the fingerprint falls back to the passphrase
|
||||||
|
|
||||||
|
Repeat 18.4 and cancel the Touch ID sheet.
|
||||||
|
|
||||||
|
**Pass:** the unlock screen asks for the passphrase, and it works.
|
||||||
|
|
||||||
|
**Failure means:** an error dialog, or a stuck screen, is `TryLoadAsync` throwing rather than answering
|
||||||
|
null. Every failure it can meet — cancelled, timed out, key invalidated by a password reset — is meant to
|
||||||
|
be indistinguishable and to land on the passphrase.
|
||||||
|
|
||||||
|
### 18.6 A development build offers no device key at all
|
||||||
|
|
||||||
|
Run the application with `dotnet run` rather than from the installed bundle, and open the security settings
|
||||||
|
page.
|
||||||
|
|
||||||
|
**Pass:** registering a device key is not offered.
|
||||||
|
|
||||||
|
**Failure means:** being offered it is `IsSupported` having inferred availability from the OS rather than
|
||||||
|
probing. An unsigned build cannot create an enclave key, so accepting the offer would put a wrap on the
|
||||||
|
server that nothing can ever open and list a capability this machine does not have.
|
||||||
|
|
||||||
|
### 18.7 The terminal works, which is the WKWebView question
|
||||||
|
|
||||||
|
Connect to a host and use the shell: type, run something that scrolls, resize the window.
|
||||||
|
|
||||||
|
**Pass:** the terminal attaches within a second or two and behaves as it does on Windows.
|
||||||
|
|
||||||
|
**Failure means:** a blank pane that reports a renderer timeout after fifteen seconds is the loopback
|
||||||
|
WebSocket not reaching WKWebView. This is the check that most needs walking, because the data plane has
|
||||||
|
never run against this backend — see `TerminalDataPlane`. If it fails, the App Sandbox is the first thing to
|
||||||
|
rule out: the entitlements deliberately do not enable it, and a sandboxed process cannot listen on loopback
|
||||||
|
without `com.apple.security.network.server`.
|
||||||
|
|
||||||
|
### 18.8 An update is offered, downloaded and applied
|
||||||
|
|
||||||
|
With the release installed, cut a second release with a higher version and publish it, then leave the first
|
||||||
|
running.
|
||||||
|
|
||||||
|
**Pass:** the banner appears, downloads, and on applying the application closes and reopens on the new
|
||||||
|
version. The vault's contents and the known hosts survive.
|
||||||
|
|
||||||
|
**Failure means:** an update that never arrives is usually the channel — `osx` here and `osx` in
|
||||||
|
`VelopackUpdateChannel.MacReleaseChannel`, with no error anywhere when they disagree. An update that
|
||||||
|
downloads and fails to apply, leaving the application unable to restart, is library validation: check that
|
||||||
|
`com.apple.security.cs.disable-library-validation` survived into the entitlements.
|
||||||
|
|
||||||
|
### 18.9 Uninstalling does not take the vault with it
|
||||||
|
|
||||||
|
Register a device, sync something, then remove the application.
|
||||||
|
|
||||||
|
**Pass:** `~/Library/Application Support/DodoSSH` still holds the cache and the outbox afterwards.
|
||||||
|
|
||||||
|
**Failure means:** an empty directory is the pack id having been changed to `DodoSSH`, which puts Velopack's
|
||||||
|
install root on top of `ClientPaths.DataDirectory` and makes an uninstall delete a user's un-synced work.
|
||||||
|
This is the single reason the bundle is named `DodoSSH.Desktop.app`.
|
||||||
|
|||||||
+61
-3
@@ -3,8 +3,18 @@
|
|||||||
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
|
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
|
||||||
have already cost time once. Development is Windows-first, but **the full test suite now runs on
|
have already cost time once. Development is Windows-first, but **the full test suite now runs on
|
||||||
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
|
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
|
||||||
suspicion. **macOS is still untested**, and anything marked *unverified* has not run on the platform
|
suspicion. Anything marked *unverified* has not run on the platform in question and must not be
|
||||||
in question and must not be assumed to work.
|
assumed to work.
|
||||||
|
|
||||||
|
**macOS now builds and packages, and has still never run.** The distinction matters more here than
|
||||||
|
anywhere else on this page, because the two halves are verified in completely different places. The
|
||||||
|
build is measured on every main and tag build: CI publishes `osx-arm64` and runs `vpk [osx] bundle`
|
||||||
|
on a Linux runner, which is enough to catch a restore graph with no macOS native asset and an `.app`
|
||||||
|
that will not compose. Everything past that — whether the window draws, whether the terminal's
|
||||||
|
loopback WebSocket reaches WKWebView, whether the Secure Enclave holds a device key — is verified
|
||||||
|
only by a person walking Phase 18 of [manual-checks.md](manual-checks.md) on a Mac, because **there
|
||||||
|
is no macOS runner in CI**. Treat every macOS runtime claim below as unverified unless it says
|
||||||
|
otherwise.
|
||||||
|
|
||||||
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
|
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
|
||||||
has been verified or made moot — not when it merely stops being convenient.
|
has been verified or made moot — not when it merely stops being convenient.
|
||||||
@@ -17,6 +27,19 @@ docs/crypto.md §1. *Already mitigated* — but if a BCL AEAD path is ever added
|
|||||||
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
|
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
|
||||||
any vault on macOS.
|
any vault on macOS.
|
||||||
|
|
||||||
|
**The Secure Enclave holds P-256 keys and nothing else**, which is why `MacDeviceKeyStore` wraps the
|
||||||
|
device key with ECIES rather than with the RSA-OAEP the Windows store uses. It will not hold an RSA
|
||||||
|
key at any size, so this is not a preference. The useful consequence is that the macOS shape is
|
||||||
|
*better* than the Windows one: `SecKeyCopyPublicKey` works on an enclave key without prompting, so
|
||||||
|
registering a device is silent and only unlock asks — where Windows raises a dialog at key creation
|
||||||
|
too. *Unverified:* no enclave call in this repository has ever run.
|
||||||
|
|
||||||
|
**Three ordinary Macs have no usable enclave**, and `IsSupported` probes rather than infers for that
|
||||||
|
reason: an Intel machine without a T2, a machine with no login password set, and — the one that
|
||||||
|
surprises people — **any build that is not code signed**, because enclave key creation needs a
|
||||||
|
signing identity. So `dotnet run` correctly offers no device key at all. Do not "fix" this by
|
||||||
|
checking the OS instead; the offer would then put a wrap on the server that nothing can ever open.
|
||||||
|
|
||||||
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
|
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
|
||||||
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
|
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
|
||||||
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
|
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
|
||||||
@@ -26,7 +49,11 @@ and the parameters are stored per user at enrollment, so a bad default is a per-
|
|||||||
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
|
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
|
||||||
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
||||||
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
|
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
|
||||||
or notarization fails with an error that does not name the offending file.
|
or notarization fails with an error that does not name the offending file. *Mitigated* in
|
||||||
|
`scripts/release-macos.sh`, which signs every `.dylib` and `createdump` in a loop before vpk touches
|
||||||
|
anything — vpk's own pass uses `codesign --deep`, which is the shape Apple documents as wrong for
|
||||||
|
nested code and is the likeliest source of that unnamed rejection. The loop looks redundant next to
|
||||||
|
`--deep` and is not; do not delete it because a release once succeeded without it.
|
||||||
|
|
||||||
## Desktop client
|
## Desktop client
|
||||||
|
|
||||||
@@ -366,6 +393,37 @@ AppContainer where loopback connections are blocked without a `CheckNetIsolation
|
|||||||
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
||||||
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
||||||
|
|
||||||
|
**The App Sandbox is ruled out on macOS for the same reason, and the entitlements say so.** A
|
||||||
|
sandboxed process cannot listen on loopback without `com.apple.security.network.server`, and the
|
||||||
|
terminal is that listener. Developer ID distribution outside the App Store does not require the
|
||||||
|
sandbox, so this costs nothing today — but it does mean the Mac App Store is closed to this
|
||||||
|
application without solving the data plane differently first. See
|
||||||
|
`build/macos/DodoSSH.entitlements`.
|
||||||
|
|
||||||
|
**The hardened runtime is not optional and .NET needs four holes punched in it.** Notarization
|
||||||
|
refuses a Developer ID submission without it, and CoreCLR will not start under it without
|
||||||
|
`allow-jit` and `allow-unsigned-executable-memory` — both, not either, because the runtime allocates
|
||||||
|
executable memory outside the `MAP_JIT` path as well. `disable-library-validation` and
|
||||||
|
`allow-dyld-environment-variables` are needed for Velopack's updater rather than for the runtime.
|
||||||
|
Each is argued individually in the entitlements file; the failure mode for a missing one is a
|
||||||
|
process that dies during runtime initialisation, before anything exists that could report it.
|
||||||
|
|
||||||
|
**`vpk` cross-compiles to macOS only as far as the bundle.** `vpk [osx] bundle` runs anywhere and
|
||||||
|
produces a real `.app`; there is no `[osx] pack` off a Mac, because pack drives `codesign`,
|
||||||
|
`notarytool` and `stapler`. So CI can prove the bundle builds and only a Mac can produce something
|
||||||
|
installable. Note this is the *opposite* of the Windows story, where `vpk [win] pack` builds the
|
||||||
|
whole installer on Linux — the asymmetry is Apple tooling, not a Velopack limitation.
|
||||||
|
|
||||||
|
**A custom `Info.plist` is copied verbatim by vpk, with no substitution whatsoever.** That is why
|
||||||
|
`--plist` and `--bundleId` are mutually exclusive, and why `build/macos/Info.plist.template` is a
|
||||||
|
template the release script renders rather than a committed file. A committed plist would carry one
|
||||||
|
version into every release afterwards, and the symptom is silent: Velopack's index would still be
|
||||||
|
right, the updater would still work, and only Get Info and any crash report would disagree.
|
||||||
|
|
||||||
|
**macOS app icons live on an 824-in-1024 grid.** An icon that bleeds to the edge of its canvas is
|
||||||
|
not bolder, it is the one icon in the Dock that is too big. `dodossh-icon.ps1` draws the `.icns` at
|
||||||
|
that fraction and the `.ico` at full bleed, from one geometry.
|
||||||
|
|
||||||
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
|
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
|
||||||
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
||||||
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -46,8 +46,11 @@ internal sealed class SessionKeepAlive : IDisposable
|
|||||||
this.activeTransfers = activeTransfers;
|
this.activeTransfers = activeTransfers;
|
||||||
this.holdsFileSession = holdsFileSession;
|
this.holdsFileSession = holdsFileSession;
|
||||||
|
|
||||||
// Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is
|
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
|
||||||
// a binder call and needs no particular thread. Nothing here touches the interface.
|
// closer's own — which is fine: starting and stopping a service is a binder call and needs no
|
||||||
|
// particular thread. Nothing here touches the interface. That the announcement waits for the run to
|
||||||
|
// actually complete, and comes for deliberate closes too, is what makes reading LiveSessionCount
|
||||||
|
// from it honest — the event's own remark carries the stuck notification that taught us both.
|
||||||
workspace.SessionEnded += OnSessionEnded;
|
workspace.SessionEnded += OnSessionEnded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,11 +116,27 @@
|
|||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!-- A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse. -->
|
<!--
|
||||||
|
A row in a list: the whole row is the target, and it is 54 tall because a thumb is not a mouse.
|
||||||
|
|
||||||
|
◆ VerticalContentAlignment, because that height is the whole point of this class and Avalonia's default
|
||||||
|
for content alignment is Stretch — so the content presenter stretched the caption to the full row and a
|
||||||
|
TextBlock draws its line at the TOP of what it is given. Most rows here never showed it, having a
|
||||||
|
StackPanel or a Grid of already-centred children in them, which is what made the four that did look
|
||||||
|
like four unrelated mistakes: the breadcrumb chips and the up-one-directory button on FilesScreen, and
|
||||||
|
TerminalScreen's CLOSE THIS TAB, each a bare TextBlock in a row 36 or 44 tall with no vertical padding.
|
||||||
|
Measured at those numbers, the caption sat flush against the top edge with 21 to 33 pixels below it.
|
||||||
|
|
||||||
|
The desktop head's App.axaml carries the same setter on its own shapes for the same reason, and excludes
|
||||||
|
two of them — see the remark on Button.ghost there. Nothing is excluded here: no row's content depends
|
||||||
|
on being stretched, there being no full-height strip inside any of the thirty-three, and the Grids that
|
||||||
|
stop filling hold only children that already centre themselves, so they land where they always did.
|
||||||
|
-->
|
||||||
<Style Selector="Button.row">
|
<Style Selector="Button.row">
|
||||||
<Setter Property="MinHeight" Value="54" />
|
<Setter Property="MinHeight" Value="54" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
|||||||
@@ -139,9 +139,37 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
{
|
{
|
||||||
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
var row = this.FindControl<StackPanel>("AccessoryKeys")!;
|
||||||
|
|
||||||
|
// Both halves of a press steal Android's own focus — the platform requests it for Avalonia's view
|
||||||
|
// after dispatching every handled touch, DOWN and UP alike; TerminalFocus carries the decompiled
|
||||||
|
// citation. Countered at the row rather than inside each key's Click, and for two reasons: Click
|
||||||
|
// only exists for the UP half, so a keyboard detached at DOWN would stay detached for the whole
|
||||||
|
// length of the press; and the Return is posted past the current dispatch, so its ordering against
|
||||||
|
// the key's own handler does not matter — which is what lets one pair of handlers cover ten keys.
|
||||||
|
row.AddHandler(PointerPressedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||||
|
row.AddHandler(PointerReleasedEvent, (_, _) => TerminalFocus.Return(), RoutingStrategies.Tunnel);
|
||||||
|
|
||||||
foreach (var (label, bytes, latches) in Keys)
|
foreach (var (label, bytes, latches) in Keys)
|
||||||
{
|
{
|
||||||
var key = new Button
|
var key = CreateKey(label);
|
||||||
|
|
||||||
|
if (latches)
|
||||||
|
{
|
||||||
|
controlKey = key;
|
||||||
|
key.Click += (_, _) => ToggleControl();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
key.Click += (_, _) => SendAsync(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.Children.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One key of the accessory row, before its click is wired.</summary>
|
||||||
|
/// <remarks>Split from <see cref="BuildAccessoryRow"/> for length rather than for reuse.</remarks>
|
||||||
|
private static Button CreateKey(string label) =>
|
||||||
|
new()
|
||||||
{
|
{
|
||||||
Content = new TextBlock
|
Content = new TextBlock
|
||||||
{
|
{
|
||||||
@@ -179,23 +207,16 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
//
|
//
|
||||||
// Focusable=false is what a toolbar button is, and it means the focused element never
|
// Focusable=false is what a toolbar button is, and it means the focused element never
|
||||||
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
// changes: the WebView is still it, so nothing resigns and nothing has to be handed back.
|
||||||
|
//
|
||||||
|
// At the Avalonia layer, that is. Android keeps a focus of its own, and the touch that
|
||||||
|
// presses one of these keys hands it to Avalonia's input view regardless of what Avalonia
|
||||||
|
// decides about its element — taking the keyboard's input connection off the terminal and
|
||||||
|
// swapping its layout mid-typing. The row's own pointer handlers in BuildAccessoryRow hand
|
||||||
|
// that half back; see TerminalFocus for the whole story, including why the handing back has
|
||||||
|
// to be posted rather than done inline.
|
||||||
Focusable = false,
|
Focusable = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (latches)
|
|
||||||
{
|
|
||||||
controlKey = key;
|
|
||||||
key.Click += (_, _) => ToggleControl();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
key.Click += (_, _) => SendAsync(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.Children.Add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleControl()
|
private void ToggleControl()
|
||||||
{
|
{
|
||||||
controlLatched = !controlLatched;
|
controlLatched = !controlLatched;
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
<Setter Property="CornerRadius" Value="6" />
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
<Setter Property="Padding" Value="6,2" />
|
<Setter Property="Padding" Value="6,2" />
|
||||||
<Setter Property="MinHeight" Value="0" />
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontWeight" Value="Medium" />
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
@@ -463,6 +464,7 @@
|
|||||||
<Setter Property="Padding" Value="13,7" />
|
<Setter Property="Padding" Value="13,7" />
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="CornerRadius" Value="12" />
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
@@ -482,6 +484,7 @@
|
|||||||
<Style Selector="Button.poprow">
|
<Style Selector="Button.poprow">
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="Padding" Value="11,4" />
|
<Setter Property="Padding" Value="11,4" />
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
<Setter Property="MinHeight" Value="20" />
|
<Setter Property="MinHeight" Value="20" />
|
||||||
@@ -671,6 +674,7 @@
|
|||||||
-->
|
-->
|
||||||
<Style Selector="Button.choice">
|
<Style Selector="Button.choice">
|
||||||
<Setter Property="Padding" Value="10,5" />
|
<Setter Property="Padding" Value="10,5" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="10.5" />
|
<Setter Property="FontSize" Value="10.5" />
|
||||||
<Setter Property="LetterSpacing" Value="0.5" />
|
<Setter Property="LetterSpacing" Value="0.5" />
|
||||||
@@ -1120,6 +1124,7 @@
|
|||||||
<Style Selector="Button.panechip">
|
<Style Selector="Button.panechip">
|
||||||
<Setter Property="Padding" Value="8,4" />
|
<Setter Property="Padding" Value="8,4" />
|
||||||
<Setter Property="MinHeight" Value="0" />
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
<Setter Property="FontSize" Value="11" />
|
<Setter Property="FontSize" Value="11" />
|
||||||
<Setter Property="FontWeight" Value="Medium" />
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
# Regenerates dodossh.ico from the same geometry the Android launcher icon draws.
|
# Regenerates dodossh.ico and dodossh.icns from the same geometry the Android launcher icon draws.
|
||||||
#
|
#
|
||||||
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
|
# The phone's mark is a vector — Resources/drawable/ic_launcher_foreground.xml — and the whole
|
||||||
# reason it is a vector is that there is then one geometry to change and no set of PNG densities
|
# reason it is a vector is that there is then one geometry to change and no set of PNG densities
|
||||||
# to forget one of. Windows will not take a vector: <ApplicationIcon> wants an .ico and nothing
|
# to forget one of. Neither desktop platform will take a vector: <ApplicationIcon> wants an .ico
|
||||||
# else, and Window.Icon wants a bitmap. So the raster exists, and this script is how it stays
|
# and nothing else, Window.Icon wants a bitmap, and vpk wants an .icns for the macOS bundle. So
|
||||||
# honest: the numbers below are the ones in that XML, and regenerating is the whole edit.
|
# the rasters exist, and this script is how they stay honest: the numbers below are the ones in
|
||||||
|
# that XML, and regenerating is the whole edit.
|
||||||
#
|
#
|
||||||
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
|
# pwsh -File src/DodoSSH.Client.App/Assets/dodossh-icon.ps1
|
||||||
#
|
#
|
||||||
|
# Both outputs are written every run, deliberately. Two scripts, or one script with a switch,
|
||||||
|
# is how the two files come to be drawn from different geometry — which nobody would notice,
|
||||||
|
# because no one person looks at a Windows taskbar and a macOS Dock on the same afternoon.
|
||||||
|
#
|
||||||
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
|
# Coordinates are the launcher's 108-unit viewport, mapped so the middle 72 fills the canvas.
|
||||||
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
|
# That 72 is not an arbitrary crop: it is the part of an adaptive icon a launcher actually shows,
|
||||||
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
|
# the outer 18 on each edge being what it eats for masking and parallax. Rendering the whole 108
|
||||||
@@ -30,29 +35,49 @@ $ink = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') # AccentInk
|
|||||||
# the gap, and its downsampler is not kind to a hairline.
|
# the gap, and its downsampler is not kind to a hairline.
|
||||||
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
|
$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||||
|
|
||||||
function New-MarkPng([int]$size)
|
# $tileFraction is how much of the canvas the accent tile fills, and it is the one number that
|
||||||
|
# differs between the two platforms.
|
||||||
|
#
|
||||||
|
# Windows passes 1.0: the tile bleeds to the edge, because Windows draws application icons at
|
||||||
|
# whatever size they come in and every other icon on the taskbar does the same.
|
||||||
|
#
|
||||||
|
# macOS passes 0.8047, and that is not taste. Apple's icon grid puts a rounded-rect app icon in
|
||||||
|
# an 824-pixel square inside a 1024-pixel canvas — 824/1024 — with the remaining hundred pixels a
|
||||||
|
# side left as air for the Dock's shadow and its magnification. An icon that ignores the grid and
|
||||||
|
# bleeds to the edge does not read as bold; it reads as the one icon in the Dock that is too big,
|
||||||
|
# because it sits beside Finder and Safari which do not.
|
||||||
|
function New-MarkPng([int]$size, [double]$tileFraction = 1.0)
|
||||||
{
|
{
|
||||||
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
$bitmap = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
|
||||||
$g = [System.Drawing.Graphics]::FromImage($bitmap)
|
$g = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||||
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
|
||||||
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
$g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||||
|
|
||||||
|
# The tile, and the inset that centres it when it does not fill the canvas.
|
||||||
|
$tile = [double]$size * $tileFraction
|
||||||
|
$inset = ([double]$size - $tile) / 2.0
|
||||||
|
|
||||||
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
|
# The accent tile, rounded as a launcher mask rounds it. A square-cornered tile would be the
|
||||||
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
|
# one icon on the taskbar with corners, which reads as unfinished rather than as deliberate.
|
||||||
$radius = [double]$size * 0.22
|
#
|
||||||
|
# 0.22 of the tile rather than of the canvas, so the corner keeps its proportion to the shape
|
||||||
|
# it is rounding instead of growing as the air around it does. It is also within a whisker of
|
||||||
|
# the 185/824 Apple's own grid specifies, which is why one radius serves both files.
|
||||||
|
$radius = $tile * 0.22
|
||||||
$d = $radius * 2.0
|
$d = $radius * 2.0
|
||||||
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
|
||||||
$path.AddArc(0.0, 0.0, $d, $d, 180, 90)
|
$path.AddArc($inset, $inset, $d, $d, 180, 90)
|
||||||
$path.AddArc($size - $d, 0.0, $d, $d, 270, 90)
|
$path.AddArc($inset + $tile - $d, $inset, $d, $d, 270, 90)
|
||||||
$path.AddArc($size - $d, $size - $d, $d, $d, 0, 90)
|
$path.AddArc($inset + $tile - $d, $inset + $tile - $d, $d, $d, 0, 90)
|
||||||
$path.AddArc(0.0, $size - $d, $d, $d, 90, 90)
|
$path.AddArc($inset, $inset + $tile - $d, $d, $d, 90, 90)
|
||||||
$path.CloseFigure()
|
$path.CloseFigure()
|
||||||
$brush = New-Object System.Drawing.SolidBrush($accent)
|
$brush = New-Object System.Drawing.SolidBrush($accent)
|
||||||
$g.FillPath($brush, $path)
|
$g.FillPath($brush, $path)
|
||||||
|
|
||||||
# 108-viewport units to pixels, with the outer 18 dropped on each edge.
|
# 108-viewport units to pixels, with the outer 18 dropped on each edge. Scaled to the tile and
|
||||||
$scale = [double]$size / 72.0
|
# offset by the inset, so the glyph keeps its place within the tile at either fraction.
|
||||||
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale), (($y - 18.0) * $scale)) }
|
$scale = $tile / 72.0
|
||||||
|
function P([double]$x, [double]$y) { New-Object System.Drawing.PointF((($x - 18.0) * $scale + $inset), (($y - 18.0) * $scale + $inset)) }
|
||||||
|
|
||||||
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
|
# A stroke thinner than a pixel renders as a grey suggestion of itself, which at 16px is the
|
||||||
# difference between a mark and a smudge. The phone's file already bumps this width for the
|
# difference between a mark and a smudge. The phone's file already bumps this width for the
|
||||||
@@ -117,3 +142,86 @@ $target = Join-Path $PSScriptRoot 'dodossh.ico'
|
|||||||
$w.Dispose(); $out.Dispose()
|
$w.Dispose(); $out.Dispose()
|
||||||
|
|
||||||
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
|
Write-Output "Wrote $target ($($sizes.Count) sizes, $((Get-Item $target).Length) bytes)"
|
||||||
|
|
||||||
|
# ---- dodossh.icns, for the macOS bundle ----------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Written here rather than by `iconutil` on a Mac, and that is the point of doing it the long way.
|
||||||
|
# iconutil is the documented tool and it exists only on macOS, so an icon that needed it could not
|
||||||
|
# be regenerated on the machine this project is developed on — the geometry above would change and
|
||||||
|
# the .icns would quietly keep the old mark until somebody next opened a Mac. The container format
|
||||||
|
# is a magic word, a length and a run of typed PNG chunks, which is little enough to own.
|
||||||
|
#
|
||||||
|
# ◆ EVERY LENGTH IN THIS FILE IS BIG-ENDIAN, AND BinaryWriter IS NOT.
|
||||||
|
#
|
||||||
|
# The one thing that will catch anybody editing this. A .icns written little-endian is not rejected
|
||||||
|
# with an error — Finder and vpk both just show the placeholder icon, because the first chunk claims
|
||||||
|
# a length of about two billion and the parser walks off the end and gives up. Hence Write-BE32.
|
||||||
|
#
|
||||||
|
# Type codes are Apple's, and the pairs are not redundant. ic08 and ic13 are both 256 pixels because
|
||||||
|
# one is "256 at 1x" and the other is "128 at 2x", and a Retina display asked for the second will not
|
||||||
|
# accept the first. Same for ic09/ic14 at 512. iconutil emits both from an .iconset for this reason,
|
||||||
|
# so this does too.
|
||||||
|
$icnsTypes = @(
|
||||||
|
@{ Type = 'ic11'; Size = 32 } # 16@2x
|
||||||
|
@{ Type = 'ic12'; Size = 64 } # 32@2x
|
||||||
|
@{ Type = 'ic07'; Size = 128 } # 128@1x
|
||||||
|
@{ Type = 'ic13'; Size = 256 } # 128@2x
|
||||||
|
@{ Type = 'ic08'; Size = 256 } # 256@1x
|
||||||
|
@{ Type = 'ic14'; Size = 512 } # 256@2x
|
||||||
|
@{ Type = 'ic09'; Size = 512 } # 512@1x
|
||||||
|
@{ Type = 'ic10'; Size = 1024 } # 512@2x
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apple's icon grid: an 824-pixel shape centred in a 1024-pixel canvas. See New-MarkPng.
|
||||||
|
$macTileFraction = 824.0 / 1024.0
|
||||||
|
|
||||||
|
# Rendered once per distinct pixel size rather than once per type code, so the two 256s and the two
|
||||||
|
# 512s are byte-identical and the file does not carry the same image twice over at different
|
||||||
|
# compression. It also halves the drawing, which at 1024 is not nothing.
|
||||||
|
$rendered = @{}
|
||||||
|
foreach ($size in ($icnsTypes.Size | Sort-Object -Unique))
|
||||||
|
{
|
||||||
|
[byte[]]$png = New-MarkPng $size $macTileFraction
|
||||||
|
$rendered[$size] = $png
|
||||||
|
}
|
||||||
|
|
||||||
|
$icns = New-Object System.IO.MemoryStream
|
||||||
|
|
||||||
|
function Write-BE32([System.IO.Stream]$stream, [uint32]$value)
|
||||||
|
{
|
||||||
|
$bytes = [System.BitConverter]::GetBytes($value)
|
||||||
|
if ([System.BitConverter]::IsLittleEndian) { [array]::Reverse($bytes) }
|
||||||
|
$stream.Write($bytes, 0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Ascii([System.IO.Stream]$stream, [string]$text)
|
||||||
|
{
|
||||||
|
$bytes = [System.Text.Encoding]::ASCII.GetBytes($text)
|
||||||
|
$stream.Write($bytes, 0, $bytes.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
# The header's length field covers the whole file including the header, so it is written last —
|
||||||
|
# eight bytes of nothing now, seeked back to and filled in once the total is known.
|
||||||
|
Write-Ascii $icns 'icns'
|
||||||
|
Write-BE32 $icns 0
|
||||||
|
|
||||||
|
foreach ($entry in $icnsTypes)
|
||||||
|
{
|
||||||
|
$payload = $rendered[$entry.Size]
|
||||||
|
Write-Ascii $icns $entry.Type
|
||||||
|
|
||||||
|
# Length includes this chunk's own eight-byte header, which is the off-by-eight everybody
|
||||||
|
# writes once.
|
||||||
|
Write-BE32 $icns ([uint32]($payload.Length + 8))
|
||||||
|
$icns.Write($payload, 0, $payload.Length)
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = [uint32]$icns.Length
|
||||||
|
$icns.Position = 4
|
||||||
|
Write-BE32 $icns $total
|
||||||
|
|
||||||
|
$icnsTarget = Join-Path $PSScriptRoot 'dodossh.icns'
|
||||||
|
[System.IO.File]::WriteAllBytes($icnsTarget, $icns.ToArray())
|
||||||
|
$icns.Dispose()
|
||||||
|
|
||||||
|
Write-Output "Wrote $icnsTarget ($($icnsTypes.Count) entries, $((Get-Item $icnsTarget).Length) bytes)"
|
||||||
|
|||||||
Binary file not shown.
@@ -66,6 +66,21 @@
|
|||||||
-->
|
-->
|
||||||
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
|
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
For the macOS keychain interop in Platform/, and for nothing else.
|
||||||
|
|
||||||
|
Set on this project rather than in Directory.Build.props deliberately. The frameworks that hold a
|
||||||
|
Secure Enclave key take CFDictionaries of raw pointers, so building one means pinning arrays and
|
||||||
|
taking their addresses — see MacDeviceKeyStore. Every other project here is managed code with no
|
||||||
|
business doing that, and a solution-wide flag would quietly permit it everywhere, including in the
|
||||||
|
crypto project where a stray pointer is the last thing anybody wants to have been allowed.
|
||||||
|
|
||||||
|
The alternative — GCHandle.Alloc with GCHandleType.Pinned — needs no flag and was considered. It
|
||||||
|
would replace each `fixed` with an allocate/free pair that has to be balanced by hand across the
|
||||||
|
early returns those methods are full of, which trades a compiler-checked scope for a manual one.
|
||||||
|
-->
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
False here, unlike every server project. The root Directory.Build.props sets it true because
|
False here, unlike every server project. The root Directory.Build.props sets it true because
|
||||||
the API is container-hosted, UTC-only and has no business formatting anything for a human.
|
the API is container-hosted, UTC-only and has no business formatting anything for a human.
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using static DodoSSH.Client.App.Platform.MacSecurity;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps the device key encrypted to a Secure Enclave key whose use requires the user's presence.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The macOS counterpart of <see cref="WindowsDeviceKeyStore"/>, and the same argument holds it up:
|
||||||
|
/// <b>the consent is enforced by the platform, not by this class</b>. The unwrapping key is generated
|
||||||
|
/// inside the Secure Enclave and never leaves it — there is no code path, privileged or otherwise, that
|
||||||
|
/// turns it into bytes — and it is created under an access control requiring
|
||||||
|
/// <see cref="AccessControlFlags.UserPresence"/>, so Touch ID or the login password is a condition of
|
||||||
|
/// <em>using</em> it. Malware running as the user can ask for a decryption; it cannot answer the prompt,
|
||||||
|
/// and the attempt is visible.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A store that showed its own prompt and then read a protected file would be trivially bypassed, which
|
||||||
|
/// is the mistake ADR 0007 originally described and the Windows store's comment corrects. The correction
|
||||||
|
/// applies here unchanged.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>P-256 and ECIES, where Windows uses RSA-OAEP, and the difference is not a preference.</b> The
|
||||||
|
/// Secure Enclave holds exactly one kind of key: a 256-bit key on the NIST P-256 curve. It will not hold
|
||||||
|
/// an RSA key at any size. So the wrap is <c>eciesEncryptionCofactorX963SHA256AESGCM</c> — an ephemeral
|
||||||
|
/// agreement against the enclave's public half, X9.63-KDF to an AES-GCM key, and the ephemeral public
|
||||||
|
/// key carried in the output. The framework does all of that; what matters here is that the input is 32
|
||||||
|
/// bytes and there is no size limit worth worrying about.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Sealing is silent and unsealing prompts, which is better than the Windows shape rather than merely
|
||||||
|
/// different.</b> On Windows, <c>CngKey.Create</c> with <c>ProtectKey</c> raises a dialog at creation as
|
||||||
|
/// well, because the policy means "protect this key with a PIN" and Windows sets that up there and then.
|
||||||
|
/// Here <see cref="SecKeyCopyPublicKey"/> works on an enclave key without any prompt, so registering a
|
||||||
|
/// device shows nothing and only unlock asks. <see cref="SaveAsync"/> is therefore not user-facing on
|
||||||
|
/// this platform — but it is still called from where the Windows one has to be, and relying on that
|
||||||
|
/// difference would make the shared caller platform-specific for no gain.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What this cannot be tested against, and what follows from that.</b> Every method except
|
||||||
|
/// <see cref="IsAvailableAsync"/> and the empty case of <see cref="TryLoadAsync"/> needs an interactive
|
||||||
|
/// login session and real enclave hardware, so none can be exercised by an automated test — the same
|
||||||
|
/// line the Windows store draws. It also means <see cref="IsSupported"/> must probe rather than infer:
|
||||||
|
/// see its remarks for the three ordinary machines that have no usable enclave and must degrade to the
|
||||||
|
/// passphrase rather than fail at unlock.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
|
public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The keychain tag this application's enclave key is filed under.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Versioned for the reason the Windows key name is: a future change of curve or wrap algorithm can
|
||||||
|
/// create a new key beside the old one rather than failing to open blobs written by a previous
|
||||||
|
/// build. A device that cannot be opened falls back to the passphrase, which is survivable — but
|
||||||
|
/// silently, and a user would only notice their fingerprint had stopped working.
|
||||||
|
///
|
||||||
|
/// Prefixed with the bundle identifier because the keychain is shared across every application the
|
||||||
|
/// user runs, unlike a CNG key name, which is scoped to the user's key store already.
|
||||||
|
/// </remarks>
|
||||||
|
private const string KeyTag = "dev.dodotech.dodossh.devicekey.v1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shown in the Touch ID prompt, so it has to read as a sentence to a person.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// macOS composes it into "DodoSSH is trying to ...", so this is a verb phrase and not a sentence of
|
||||||
|
/// its own. The same words the Windows consent dialog uses.
|
||||||
|
/// </remarks>
|
||||||
|
private const string ConsentPrompt = "unlock your DodoSSH vault";
|
||||||
|
|
||||||
|
private readonly ClientPaths paths;
|
||||||
|
|
||||||
|
/// <summary>Creates the store.</summary>
|
||||||
|
public MacDeviceKeyStore(ClientPaths paths)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
this.paths = paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this Mac has a Secure Enclave that will hold a key for this build.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Probed by creating a throwaway key and deleting it, rather than by asking whether the hardware
|
||||||
|
/// exists. Three ordinary situations answer "no" here and would otherwise only be discovered at the
|
||||||
|
/// moment somebody tried to unlock:
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>An Intel Mac with no T2.</b> Apple Silicon and T2 machines have an enclave; earlier Intel
|
||||||
|
/// models do not, and there is no single attribute that says so.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A build that is not code signed.</b> Enclave key creation requires a signing identity, so
|
||||||
|
/// every <c>dotnet run</c> and every build from an IDE fails here with a missing-entitlement error.
|
||||||
|
/// That is the correct answer rather than a nuisance: a development build should keep asking for the
|
||||||
|
/// passphrase, and this is what makes it do so without a platform check somewhere else.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A machine with no login password set.</b> <see cref="AccessControlFlags.UserPresence"/> has
|
||||||
|
/// nothing to demand, and the framework refuses the access control object rather than silently
|
||||||
|
/// creating a key anybody could use.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The probe uses its own tag and no UI policy, so nothing prompts and nothing collides with the
|
||||||
|
/// real key. It is deleted immediately; a probe key left behind would accumulate one per launch.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static bool IsSupported()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var probe = $"{KeyTag}.probe.{Guid.CreateVersion7():N}";
|
||||||
|
|
||||||
|
using var scope = new CoreFoundationScope();
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
if (!symbols.Complete)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = CreateEnclaveKey(scope, symbols, probe);
|
||||||
|
|
||||||
|
if (key == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discarded deliberately. The question this method answers is whether the enclave will make a
|
||||||
|
// key, and it demonstrably just did; a failure to clean the probe up afterwards leaves one
|
||||||
|
// stray keychain item and does not make the answer no.
|
||||||
|
_ = DeleteKey(symbols, probe);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is DllNotFoundException
|
||||||
|
or EntryPointNotFoundException
|
||||||
|
or BadImageFormatException)
|
||||||
|
{
|
||||||
|
// A macOS without these frameworks is not a thing that exists, so this is really the guard
|
||||||
|
// for the case that does: a future release renaming or removing one of them. The answer is
|
||||||
|
// the same as for hardware that is absent — no device key, ask for the passphrase.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||||
|
ValueTask.FromResult(IsSupported());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask SaveAsync(
|
||||||
|
ReadOnlyMemory<byte> devicePrivateKey,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sealedKey = Seal(devicePrivateKey.Span)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
|
||||||
|
|
||||||
|
paths.EnsureCreated();
|
||||||
|
|
||||||
|
await File.WriteAllBytesAsync(paths.DeviceKeyFile, sealedKey, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!File.Exists(paths.DeviceKeyFile))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Unseal(sealedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (File.Exists(paths.DeviceKeyFile))
|
||||||
|
{
|
||||||
|
File.Delete(paths.DeviceKeyFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
if (symbols.Complete)
|
||||||
|
{
|
||||||
|
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
|
||||||
|
// and the file deleted above is the half that decides whether unlock will try at all. A key
|
||||||
|
// left in the enclave with no ciphertext to open is inert.
|
||||||
|
_ = DeleteKey(symbols, KeyTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
|
||||||
|
/// them is the same — do not offer a device unlock.
|
||||||
|
/// </remarks>
|
||||||
|
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = new CoreFoundationScope();
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
if (!symbols.Complete)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created on first use rather than at registration, so that a device key re-registered after
|
||||||
|
// a ForgetAsync gets a key again without anything having to notice that it had gone.
|
||||||
|
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);
|
||||||
|
|
||||||
|
if (privateKey == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (privateKey == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var publicKey = scope.Keep(SecKeyCopyPublicKey(privateKey));
|
||||||
|
|
||||||
|
if (publicKey == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var plaintext = Data(scope, devicePrivateKey);
|
||||||
|
|
||||||
|
if (plaintext == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ciphertext = scope.Keep(
|
||||||
|
SecKeyCreateEncryptedData(publicKey, symbols.EciesAlgorithm, plaintext, out var error));
|
||||||
|
|
||||||
|
scope.Keep(error);
|
||||||
|
|
||||||
|
return ciphertext == IntPtr.Zero ? null : ToArray(ciphertext);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is DllNotFoundException
|
||||||
|
or EntryPointNotFoundException
|
||||||
|
or BadImageFormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the call that prompts. Every failure becomes null, and the set is wider than it looks:
|
||||||
|
/// the key may be gone, the user may have cancelled or let the prompt time out, the enclave may have
|
||||||
|
/// invalidated it after the login password was reset, or the blob may predate a key that has since
|
||||||
|
/// been replaced. None of them are distinguishable to a user and all have the same remedy, so none
|
||||||
|
/// are worth telling apart here — see <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Blocking, and it blocks on a person. The prompt is modal to the application, so this must not run
|
||||||
|
/// on a thread that is also expected to draw the window behind it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static byte[]? Unseal(byte[] sealedKey)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = new CoreFoundationScope();
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
if (!symbols.Complete)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
|
||||||
|
|
||||||
|
if (privateKey == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ciphertext = Data(scope, sealedKey);
|
||||||
|
|
||||||
|
if (ciphertext == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var plaintext = scope.Keep(
|
||||||
|
SecKeyCreateDecryptedData(privateKey, symbols.EciesAlgorithm, ciphertext, out var error));
|
||||||
|
|
||||||
|
scope.Keep(error);
|
||||||
|
|
||||||
|
return plaintext == IntPtr.Zero ? null : ToArray(plaintext);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is DllNotFoundException
|
||||||
|
or EntryPointNotFoundException
|
||||||
|
or BadImageFormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a key inside the Secure Enclave, filed under <paramref name="tag"/>. Owned by the scope.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The attribute dictionary is the whole security decision, so it is worth reading rather than
|
||||||
|
/// pattern-matching. <c>TokenID = SecureEnclave</c> is what puts the private half in hardware;
|
||||||
|
/// without it this silently generates an ordinary software key that behaves identically in every
|
||||||
|
/// visible way and protects nothing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>AccessibleWhenUnlockedThisDeviceOnly</c> rather than any of the migratable classes, because a
|
||||||
|
/// device key that could be restored onto another machine from a backup would no longer mean "this
|
||||||
|
/// machine". The enclave already makes that impossible; saying it as well means the intent survives
|
||||||
|
/// a future change of storage.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>UseDataProtectionKeychain</c> is the macOS-specific one and the easiest to omit. Without it,
|
||||||
|
/// macOS routes this to the older file-based keychain, which does not understand access control
|
||||||
|
/// objects or the enclave, and the call fails with a parameter error that says nothing about the
|
||||||
|
/// missing key.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr CreateEnclaveKey(CoreFoundationScope scope, MacSymbols symbols, string tag)
|
||||||
|
{
|
||||||
|
var access = scope.Keep(SecAccessControlCreateWithFlags(
|
||||||
|
IntPtr.Zero,
|
||||||
|
symbols.AccessibleWhenUnlockedThisDeviceOnly,
|
||||||
|
AccessControlFlags.PrivateKeyUsage | AccessControlFlags.UserPresence,
|
||||||
|
out var accessError));
|
||||||
|
|
||||||
|
scope.Keep(accessError);
|
||||||
|
|
||||||
|
if (access == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var privateAttrs = Dictionary(
|
||||||
|
scope,
|
||||||
|
[symbols.AttrIsPermanent, symbols.AttrApplicationTag, symbols.AttrAccessControl],
|
||||||
|
[symbols.True, TagData(scope, tag), access]);
|
||||||
|
|
||||||
|
if (privateAttrs == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keySize = Number(scope, 256);
|
||||||
|
|
||||||
|
var parameters = Dictionary(
|
||||||
|
scope,
|
||||||
|
[
|
||||||
|
symbols.AttrKeyType,
|
||||||
|
symbols.AttrKeySizeInBits,
|
||||||
|
symbols.AttrTokenId,
|
||||||
|
symbols.UseDataProtectionKeychain,
|
||||||
|
symbols.PrivateKeyAttrs,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
symbols.KeyTypeEcSecPrimeRandom,
|
||||||
|
keySize,
|
||||||
|
symbols.TokenIdSecureEnclave,
|
||||||
|
symbols.True,
|
||||||
|
privateAttrs,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (parameters == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = scope.Keep(SecKeyCreateRandomKey(parameters, out var error));
|
||||||
|
|
||||||
|
scope.Keep(error);
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks the enclave key up by tag. Owned by the scope; zero when there is none.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <paramref name="prompt"/> is attached here and consumed later: the lookup itself does not raise
|
||||||
|
/// anything, because a handle to an enclave key is not a use of it. The words reach the user at the
|
||||||
|
/// decrypt, which is the operation the access control actually guards.
|
||||||
|
///
|
||||||
|
/// <c>UseOperationPrompt</c> is deprecated in favour of an <c>LAContext</c>, and is used anyway. An
|
||||||
|
/// LAContext would mean binding LocalAuthentication as well for one string, and the deprecated key
|
||||||
|
/// still works; the day it stops, this call fails and the store degrades to the passphrase, which is
|
||||||
|
/// the failure this whole class is built to degrade into.
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr FindKey(CoreFoundationScope scope, MacSymbols symbols, string tag, string? prompt)
|
||||||
|
{
|
||||||
|
List<IntPtr> keys =
|
||||||
|
[
|
||||||
|
symbols.Class,
|
||||||
|
symbols.AttrApplicationTag,
|
||||||
|
symbols.AttrKeyType,
|
||||||
|
symbols.UseDataProtectionKeychain,
|
||||||
|
symbols.ReturnRef,
|
||||||
|
];
|
||||||
|
|
||||||
|
List<IntPtr> values =
|
||||||
|
[
|
||||||
|
symbols.ClassKey,
|
||||||
|
TagData(scope, tag),
|
||||||
|
symbols.KeyTypeEcSecPrimeRandom,
|
||||||
|
symbols.True,
|
||||||
|
symbols.True,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (prompt is not null)
|
||||||
|
{
|
||||||
|
keys.Add(symbols.UseOperationPrompt);
|
||||||
|
values.Add(scope.Keep(CFString(prompt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = Dictionary(scope, [.. keys], [.. values]);
|
||||||
|
|
||||||
|
if (query == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = SecItemCopyMatching(query, out var result);
|
||||||
|
|
||||||
|
// errSecItemNotFound is the ordinary answer on a machine that has never registered a device, and
|
||||||
|
// it is not distinguished from any other failure for the reason the class remarks give.
|
||||||
|
return status == Success ? scope.Keep(result) : IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes the key with this tag from the keychain.</summary>
|
||||||
|
/// <returns>Whether the keychain now has no key under this tag.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>ItemNotFound</c> counts as success, and that is the common case rather than an edge: it is
|
||||||
|
/// what a machine that never registered a device answers, and what the second of two
|
||||||
|
/// <see cref="ForgetAsync"/> calls answers. Treating it as a failure would make forgetting a device
|
||||||
|
/// twice report a problem that does not exist.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool DeleteKey(MacSymbols symbols, string tag)
|
||||||
|
{
|
||||||
|
using var scope = new CoreFoundationScope();
|
||||||
|
|
||||||
|
var query = Dictionary(
|
||||||
|
scope,
|
||||||
|
[symbols.Class, symbols.AttrApplicationTag, symbols.UseDataProtectionKeychain],
|
||||||
|
[symbols.ClassKey, TagData(scope, tag), symbols.True]);
|
||||||
|
|
||||||
|
if (query == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = SecItemDelete(query);
|
||||||
|
|
||||||
|
return status is Success or ItemNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Small CoreFoundation conveniences ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The arrays are pinned for the duration of the call and not beyond it, which is correct because
|
||||||
|
/// <c>CFDictionaryCreate</c> copies them: the dictionary retains each key and value, and never reads
|
||||||
|
/// the arrays again.
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr Dictionary(CoreFoundationScope scope, IntPtr[] keys, IntPtr[] values)
|
||||||
|
{
|
||||||
|
// A zero anywhere means one of the constants did not resolve or an earlier allocation failed.
|
||||||
|
// Passing it on produces a dictionary with a null key, which CFDictionaryCreate does not reject
|
||||||
|
// — it crashes inside the callback table instead.
|
||||||
|
if (Array.IndexOf(keys, IntPtr.Zero) >= 0 || Array.IndexOf(values, IntPtr.Zero) >= 0)
|
||||||
|
{
|
||||||
|
return IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (IntPtr* keyPtr = keys)
|
||||||
|
fixed (IntPtr* valuePtr = values)
|
||||||
|
{
|
||||||
|
return scope.Keep(CFDictionaryCreate(
|
||||||
|
IntPtr.Zero,
|
||||||
|
(IntPtr)keyPtr,
|
||||||
|
(IntPtr)valuePtr,
|
||||||
|
keys.Length,
|
||||||
|
symbols.TypeDictionaryKeyCallBacks,
|
||||||
|
symbols.TypeDictionaryValueCallBacks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Copies bytes into a CFData. Owned by the scope.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The pin lasts only as long as the call, which is correct: <c>CFDataCreate</c> copies, so the
|
||||||
|
/// CFData does not reference this memory afterwards. <c>CFDataCreateWithBytesNoCopy</c> would not,
|
||||||
|
/// and is not used for exactly that reason — it would hand the framework a pointer into the managed
|
||||||
|
/// heap and rely on the object staying where the collector first put it.
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr Data(CoreFoundationScope scope, ReadOnlySpan<byte> bytes)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* pointer = bytes)
|
||||||
|
{
|
||||||
|
return scope.Keep(CFDataCreate(IntPtr.Zero, (IntPtr)pointer, bytes.Length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// UTF-8 rather than any other encoding, and it only has to be consistent with itself: the tag is an
|
||||||
|
/// opaque blob the keychain matches byte for byte, so what matters is that a lookup encodes it the
|
||||||
|
/// same way the creation did. It is written once, here, for exactly that reason.
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr TagData(CoreFoundationScope scope, string tag) =>
|
||||||
|
Data(scope, Encoding.UTF8.GetBytes(tag));
|
||||||
|
|
||||||
|
private static IntPtr Number(CoreFoundationScope scope, int value)
|
||||||
|
{
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
return scope.Keep(CFNumberCreate(IntPtr.Zero, (nint)CFNumberIntType, (IntPtr)(&value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a CFString from a managed string. Owned, so the caller tracks it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Built explicitly rather than left to the marshaller, because these calls take a
|
||||||
|
/// <c>CFStringRef</c> and not a C string — the runtime's default marshalling would hand over a
|
||||||
|
/// <c>char*</c>, which CoreFoundation reads as an object pointer and follows into nothing.
|
||||||
|
/// </remarks>
|
||||||
|
private static IntPtr CFString(string value)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(value);
|
||||||
|
|
||||||
|
unsafe
|
||||||
|
{
|
||||||
|
fixed (byte* pointer = bytes)
|
||||||
|
{
|
||||||
|
// kCFStringEncodingUTF8 is 0x08000100, spelled out rather than named because it is the
|
||||||
|
// only encoding constant this file uses.
|
||||||
|
return CFStringCreateWithBytes(IntPtr.Zero, (IntPtr)pointer, bytes.Length, 0x08000100, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
private static partial IntPtr CFStringCreateWithBytes(
|
||||||
|
IntPtr allocator,
|
||||||
|
IntPtr bytes,
|
||||||
|
nint numBytes,
|
||||||
|
uint encoding,
|
||||||
|
[MarshalAs(UnmanagedType.U1)] bool isExternalRepresentation);
|
||||||
|
|
||||||
|
private static byte[] ToArray(IntPtr data)
|
||||||
|
{
|
||||||
|
var length = (int)CFDataGetLength(data);
|
||||||
|
var pointer = CFDataGetBytePtr(data);
|
||||||
|
|
||||||
|
if (length <= 0 || pointer == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new byte[length];
|
||||||
|
Marshal.Copy(pointer, result, 0, length);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The pieces of CoreFoundation and Security.framework <see cref="MacDeviceKeyStore"/> needs.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Separated from the store itself because it is a different kind of code with a different kind of
|
||||||
|
/// review: nothing here makes a decision, and everything here is a translation of a C declaration that
|
||||||
|
/// is either right or wrong. Mixing the two would mean the security argument in
|
||||||
|
/// <see cref="MacDeviceKeyStore"/> had to be read past two hundred lines of marshalling to find.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Every Create or Copy returns an object this process owns.</b> That is CoreFoundation's Create
|
||||||
|
/// Rule, and it is the thing here that goes wrong silently: the enclave key handle is small, so a leak
|
||||||
|
/// shows up as nothing at all until a long-running process has done a few thousand unlocks.
|
||||||
|
/// <see cref="CoreFoundationScope"/> exists so ownership is tracked by construction rather than by
|
||||||
|
/// remembering, and every function below that returns a handle says whether it is owned.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The integer widths are the part worth checking against the headers rather than skimming.</b>
|
||||||
|
/// <c>CFIndex</c>, <c>CFOptionFlags</c> and <c>CFNumberType</c> are all pointer-width on a 64-bit Mac,
|
||||||
|
/// not 32-bit, and getting one wrong does not fail cleanly — it shifts every argument after it, so the
|
||||||
|
/// call receives plausible rubbish and returns a parameter error that names nothing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
|
internal static partial class MacSecurity
|
||||||
|
{
|
||||||
|
internal const string SecurityFramework =
|
||||||
|
"/System/Library/Frameworks/Security.framework/Security";
|
||||||
|
|
||||||
|
internal const string CoreFoundation =
|
||||||
|
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The access control flags <c>SecAccessControlCreateWithFlags</c> takes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>ulong</c> because the parameter is a <c>CFOptionFlags</c>, which is an <c>unsigned long</c>.
|
||||||
|
/// Only the two flags that are used are listed; the full set is large, and copying it in would
|
||||||
|
/// invite somebody to reach for one without reading what it does to the prompt — <c>Biometry</c>
|
||||||
|
/// alone, for instance, leaves a Mac with no Touch ID unable to unlock at all rather than falling
|
||||||
|
/// back to the login password.
|
||||||
|
/// </remarks>
|
||||||
|
[Flags]
|
||||||
|
internal enum AccessControlFlags : ulong
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Touch ID if the machine has it, the login password if not.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The forgiving one, deliberately. <c>BiometryCurrentSet</c> would additionally invalidate the
|
||||||
|
/// key whenever a fingerprint is added or removed, which sounds stricter and here buys nothing:
|
||||||
|
/// this key wraps a device key whose loss already means "ask for the passphrase", so the only
|
||||||
|
/// effect would be users being sent back to their passphrase by an unrelated Settings change
|
||||||
|
/// they would never connect to it.
|
||||||
|
/// </remarks>
|
||||||
|
UserPresence = 1ul << 0,
|
||||||
|
|
||||||
|
/// <summary>Required for any key that lives in the Secure Enclave.</summary>
|
||||||
|
PrivateKeyUsage = 1ul << 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The CFNumberType code for a 32-bit int, from CFNumber.h.</summary>
|
||||||
|
internal const long CFNumberIntType = 9;
|
||||||
|
|
||||||
|
/// <summary>errSecSuccess.</summary>
|
||||||
|
internal const int Success = 0;
|
||||||
|
|
||||||
|
/// <summary>errSecItemNotFound, which is an answer rather than a failure.</summary>
|
||||||
|
internal const int ItemNotFound = -25300;
|
||||||
|
|
||||||
|
// ---- CoreFoundation ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Releases an owned handle.</summary>
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial void CFRelease(IntPtr handle);
|
||||||
|
|
||||||
|
/// <summary>Copies bytes into a new CFData. Owned.</summary>
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial IntPtr CFDataCreate(IntPtr allocator, IntPtr bytes, nint length);
|
||||||
|
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial IntPtr CFDataGetBytePtr(IntPtr data);
|
||||||
|
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial nint CFDataGetLength(IntPtr data);
|
||||||
|
|
||||||
|
/// <summary>Boxes a value as a CFNumber. Owned.</summary>
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial IntPtr CFNumberCreate(IntPtr allocator, nint theType, IntPtr valuePtr);
|
||||||
|
|
||||||
|
/// <summary>Builds an immutable dictionary. Owned.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The key and value arrays are passed as raw pointers to memory the caller pins, rather than as
|
||||||
|
/// managed arrays. Source-generated interop wants an explicit element count for a marshalled array,
|
||||||
|
/// and supplying one here would mean stating the length twice — once for the marshaller and once as
|
||||||
|
/// <paramref name="numValues"/> — which is exactly the pair that drifts.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The two callback tables are what make the dictionary retain its keys and values, which is why
|
||||||
|
/// they are passed rather than left null: with null callbacks the dictionary stores raw pointers and
|
||||||
|
/// keeps nothing alive, and the resulting use-after-free is intermittent by nature.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[LibraryImport(CoreFoundation)]
|
||||||
|
internal static partial IntPtr CFDictionaryCreate(
|
||||||
|
IntPtr allocator,
|
||||||
|
IntPtr keys,
|
||||||
|
IntPtr values,
|
||||||
|
nint numValues,
|
||||||
|
IntPtr keyCallBacks,
|
||||||
|
IntPtr valueCallBacks);
|
||||||
|
|
||||||
|
// ---- Security.framework --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Builds the access policy a Secure Enclave key is created under. Owned.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial IntPtr SecAccessControlCreateWithFlags(
|
||||||
|
IntPtr allocator,
|
||||||
|
IntPtr protection,
|
||||||
|
AccessControlFlags flags,
|
||||||
|
out IntPtr error);
|
||||||
|
|
||||||
|
/// <summary>Creates a key pair from an attribute dictionary. Owned.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial IntPtr SecKeyCreateRandomKey(IntPtr parameters, out IntPtr error);
|
||||||
|
|
||||||
|
/// <summary>The public half of a key. Owned.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Available even for an enclave key, and that asymmetry is the whole reason this design works: the
|
||||||
|
/// public half is an ordinary key this process can hold and use, while the private half is a handle
|
||||||
|
/// to something inside the enclave that never becomes bytes. So sealing is silent and unsealing is
|
||||||
|
/// the thing the user is asked about.
|
||||||
|
/// </remarks>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial IntPtr SecKeyCopyPublicKey(IntPtr key);
|
||||||
|
|
||||||
|
/// <summary>Encrypts with a public key. Owned.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial IntPtr SecKeyCreateEncryptedData(
|
||||||
|
IntPtr key,
|
||||||
|
IntPtr algorithm,
|
||||||
|
IntPtr plaintext,
|
||||||
|
out IntPtr error);
|
||||||
|
|
||||||
|
/// <summary>Decrypts with a private key, prompting for whatever guards it. Owned.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial IntPtr SecKeyCreateDecryptedData(
|
||||||
|
IntPtr key,
|
||||||
|
IntPtr algorithm,
|
||||||
|
IntPtr ciphertext,
|
||||||
|
out IntPtr error);
|
||||||
|
|
||||||
|
/// <summary>Finds a keychain item. The out handle is owned when the result is <see cref="Success"/>.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial int SecItemCopyMatching(IntPtr query, out IntPtr result);
|
||||||
|
|
||||||
|
/// <summary>Deletes every keychain item matching the query.</summary>
|
||||||
|
[LibraryImport(SecurityFramework)]
|
||||||
|
internal static partial int SecItemDelete(IntPtr query);
|
||||||
|
|
||||||
|
// ---- The framework constants ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads one of a framework's global CFString constants, or zero if it is not exported.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The keys these dictionaries take are not strings this code may spell for itself. They are
|
||||||
|
/// pointer-comparable constants exported by the framework, and a CFString built here with the same
|
||||||
|
/// characters is a different object — the lookups would miss and the call would fail with a
|
||||||
|
/// parameter error naming nothing.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Dereferenced once, because the exported symbol is the variable rather than its value.</b>
|
||||||
|
/// <c>TryGetExport</c> answers the address of the global; the CFStringRef is what that address
|
||||||
|
/// holds. Missing the indirection produces a pointer that is stable, plausible and wrong, which is
|
||||||
|
/// the worst of the three available outcomes.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Zero on a missing symbol rather than an exception, because the caller's answer to every failure
|
||||||
|
/// is the same one — report the store unavailable and let unlock ask for the passphrase — and a
|
||||||
|
/// constant that has been renamed by a future macOS should reach that answer rather than a crash.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static IntPtr Constant(IntPtr library, string symbol) =>
|
||||||
|
NativeLibrary.TryGetExport(library, symbol, out var address)
|
||||||
|
? Marshal.ReadIntPtr(address)
|
||||||
|
: IntPtr.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Releases every CoreFoundation handle put into it, in reverse order, exactly once.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The alternative is a try/finally per handle, and the operations here need six or seven at a time — a
|
||||||
|
/// dictionary holding a nested dictionary holding an access control object holding a CFData tag. Finallys
|
||||||
|
/// nested that deep stop being read, and a handle released twice is a crash rather than a leak.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Keep"/> returns what it was given, so a handle can be tracked in the same expression that
|
||||||
|
/// produces it and the call sites read as ordinary code.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
|
internal sealed class CoreFoundationScope : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<IntPtr> owned = [];
|
||||||
|
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
/// <summary>Takes ownership of a handle and hands it straight back.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Zero is ignored rather than rejected. Every CoreFoundation call here answers zero on failure, so
|
||||||
|
/// accepting it lets a caller track the result in the expression that produces it and check it on
|
||||||
|
/// the next line, instead of writing the check twice.
|
||||||
|
/// </remarks>
|
||||||
|
internal IntPtr Keep(IntPtr handle)
|
||||||
|
{
|
||||||
|
if (handle != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
owned.Add(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disposed = true;
|
||||||
|
|
||||||
|
// Reverse order, so a container is released before the things it retains. CoreFoundation does not
|
||||||
|
// require it — retain counts make the order irrelevant — but it keeps the lifetimes readable in a
|
||||||
|
// debugger, where a released container that still lists its contents is a confusing thing to meet.
|
||||||
|
for (var i = owned.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
MacSecurity.CFRelease(owned[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
owned.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The framework constants <see cref="MacDeviceKeyStore"/> passes to CoreFoundation and Security.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every field here is a pointer read out of a loaded framework rather than a value this code could
|
||||||
|
/// write down. The dictionaries these go into are matched by pointer identity, so a CFString built with
|
||||||
|
/// the same characters is a different key and the lookup misses — see <see cref="MacSecurity.Constant"/>
|
||||||
|
/// for the indirection that trips people.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Resolved once and cached, and the caching is what makes the failure survivable.</b> Two frameworks
|
||||||
|
/// and nineteen symbols is a lot of things to be wrong about, and the useful property is that being
|
||||||
|
/// wrong about any one of them shows up here — as <see cref="Complete"/> being false — rather than
|
||||||
|
/// three calls later as a parameter error. A store that reports itself unavailable sends the user back
|
||||||
|
/// to their passphrase; a store that half works corrupts the moment somebody registers a device.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="Lazy{T}"/> rather than a static constructor, because a type initialiser that throws
|
||||||
|
/// poisons the type for the life of the process and turns a missing symbol into a
|
||||||
|
/// <c>TypeInitializationException</c> at every later call site. The load is done inside a try instead,
|
||||||
|
/// and its failure is a value.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("macos")]
|
||||||
|
internal sealed class MacSymbols
|
||||||
|
{
|
||||||
|
private static readonly Lazy<MacSymbols> Cached = new(Load, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||||
|
|
||||||
|
private MacSymbols()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether every symbol resolved.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Checked by every caller before any of the pointers are used. It is one check rather than
|
||||||
|
/// nineteen, which is the only reason the call sites in <see cref="MacDeviceKeyStore"/> are
|
||||||
|
/// readable.
|
||||||
|
///
|
||||||
|
/// Computed rather than stored, so that the instance returned when a framework will not load at all
|
||||||
|
/// — every field left at zero — answers false without that having to be set anywhere. One rule,
|
||||||
|
/// applied to the only state there is.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool Complete => AllResolved();
|
||||||
|
|
||||||
|
// CoreFoundation.
|
||||||
|
internal IntPtr True { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr TypeDictionaryKeyCallBacks { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr TypeDictionaryValueCallBacks { get; private init; }
|
||||||
|
|
||||||
|
// Security: item classes and query keys.
|
||||||
|
internal IntPtr Class { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr ClassKey { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr ReturnRef { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr UseDataProtectionKeychain { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr UseOperationPrompt { get; private init; }
|
||||||
|
|
||||||
|
// Security: key attributes.
|
||||||
|
internal IntPtr AttrKeyType { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AttrKeySizeInBits { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AttrTokenId { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AttrIsPermanent { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AttrApplicationTag { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AttrAccessControl { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr PrivateKeyAttrs { get; private init; }
|
||||||
|
|
||||||
|
// Security: attribute values.
|
||||||
|
internal IntPtr KeyTypeEcSecPrimeRandom { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr TokenIdSecureEnclave { get; private init; }
|
||||||
|
|
||||||
|
internal IntPtr AccessibleWhenUnlockedThisDeviceOnly { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The one algorithm the Secure Enclave's P-256 keys support for encryption, and the reason this
|
||||||
|
/// store wraps rather than signs. The long name spells out the whole construction: an ephemeral
|
||||||
|
/// key agreed against the enclave's public half with cofactor ECDH, run through the X9.63 KDF with
|
||||||
|
/// SHA-256, used as an AES-GCM key. The ephemeral public key travels in the output, which is why the
|
||||||
|
/// ciphertext is larger than the 32 bytes going in and why nothing else has to be stored beside it.
|
||||||
|
/// </remarks>
|
||||||
|
internal IntPtr EciesAlgorithm { get; private init; }
|
||||||
|
|
||||||
|
/// <summary>The resolved symbols, loaded once.</summary>
|
||||||
|
internal static MacSymbols Resolve() => Cached.Value;
|
||||||
|
|
||||||
|
private static MacSymbols Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!NativeLibrary.TryLoad(MacSecurity.CoreFoundation, out var cf)
|
||||||
|
|| !NativeLibrary.TryLoad(MacSecurity.SecurityFramework, out var sec))
|
||||||
|
{
|
||||||
|
// Every pointer left at zero, which AllResolved reads as incomplete.
|
||||||
|
return new MacSymbols();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two callback tables are structs rather than object pointers, so what is wanted is the
|
||||||
|
// address of the export itself and not what it holds. Every other symbol here is a CFTypeRef
|
||||||
|
// global and needs the dereference; these two do not, and mixing them up produces a
|
||||||
|
// dictionary that does not retain its contents.
|
||||||
|
var keyCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryKeyCallBacks", out var k)
|
||||||
|
? k
|
||||||
|
: IntPtr.Zero;
|
||||||
|
|
||||||
|
var valueCallBacks = NativeLibrary.TryGetExport(cf, "kCFTypeDictionaryValueCallBacks", out var v)
|
||||||
|
? v
|
||||||
|
: IntPtr.Zero;
|
||||||
|
|
||||||
|
return new MacSymbols
|
||||||
|
{
|
||||||
|
True = MacSecurity.Constant(cf, "kCFBooleanTrue"),
|
||||||
|
TypeDictionaryKeyCallBacks = keyCallBacks,
|
||||||
|
TypeDictionaryValueCallBacks = valueCallBacks,
|
||||||
|
|
||||||
|
Class = MacSecurity.Constant(sec, "kSecClass"),
|
||||||
|
ClassKey = MacSecurity.Constant(sec, "kSecClassKey"),
|
||||||
|
ReturnRef = MacSecurity.Constant(sec, "kSecReturnRef"),
|
||||||
|
UseDataProtectionKeychain = MacSecurity.Constant(sec, "kSecUseDataProtectionKeychain"),
|
||||||
|
UseOperationPrompt = MacSecurity.Constant(sec, "kSecUseOperationPrompt"),
|
||||||
|
|
||||||
|
AttrKeyType = MacSecurity.Constant(sec, "kSecAttrKeyType"),
|
||||||
|
AttrKeySizeInBits = MacSecurity.Constant(sec, "kSecAttrKeySizeInBits"),
|
||||||
|
AttrTokenId = MacSecurity.Constant(sec, "kSecAttrTokenID"),
|
||||||
|
AttrIsPermanent = MacSecurity.Constant(sec, "kSecAttrIsPermanent"),
|
||||||
|
AttrApplicationTag = MacSecurity.Constant(sec, "kSecAttrApplicationTag"),
|
||||||
|
AttrAccessControl = MacSecurity.Constant(sec, "kSecAttrAccessControl"),
|
||||||
|
PrivateKeyAttrs = MacSecurity.Constant(sec, "kSecPrivateKeyAttrs"),
|
||||||
|
|
||||||
|
KeyTypeEcSecPrimeRandom = MacSecurity.Constant(sec, "kSecAttrKeyTypeECSECPrimeRandom"),
|
||||||
|
TokenIdSecureEnclave = MacSecurity.Constant(sec, "kSecAttrTokenIDSecureEnclave"),
|
||||||
|
AccessibleWhenUnlockedThisDeviceOnly =
|
||||||
|
MacSecurity.Constant(sec, "kSecAttrAccessibleWhenUnlockedThisDeviceOnly"),
|
||||||
|
|
||||||
|
EciesAlgorithm = MacSecurity.Constant(
|
||||||
|
sec,
|
||||||
|
"kSecKeyAlgorithmECIESEncryptionCofactorX963SHA256AESGCM"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is DllNotFoundException or BadImageFormatException)
|
||||||
|
{
|
||||||
|
return new MacSymbols();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool AllResolved() =>
|
||||||
|
True != IntPtr.Zero
|
||||||
|
&& TypeDictionaryKeyCallBacks != IntPtr.Zero
|
||||||
|
&& TypeDictionaryValueCallBacks != IntPtr.Zero
|
||||||
|
&& Class != IntPtr.Zero
|
||||||
|
&& ClassKey != IntPtr.Zero
|
||||||
|
&& ReturnRef != IntPtr.Zero
|
||||||
|
&& UseDataProtectionKeychain != IntPtr.Zero
|
||||||
|
&& UseOperationPrompt != IntPtr.Zero
|
||||||
|
&& AttrKeyType != IntPtr.Zero
|
||||||
|
&& AttrKeySizeInBits != IntPtr.Zero
|
||||||
|
&& AttrTokenId != IntPtr.Zero
|
||||||
|
&& AttrIsPermanent != IntPtr.Zero
|
||||||
|
&& AttrApplicationTag != IntPtr.Zero
|
||||||
|
&& AttrAccessControl != IntPtr.Zero
|
||||||
|
&& PrivateKeyAttrs != IntPtr.Zero
|
||||||
|
&& KeyTypeEcSecPrimeRandom != IntPtr.Zero
|
||||||
|
&& TokenIdSecureEnclave != IntPtr.Zero
|
||||||
|
&& AccessibleWhenUnlockedThisDeviceOnly != IntPtr.Zero
|
||||||
|
&& EciesAlgorithm != IntPtr.Zero;
|
||||||
|
}
|
||||||
@@ -31,7 +31,12 @@ internal static class UpdateChannels
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static IUpdateChannel ForThisMachine()
|
internal static IUpdateChannel ForThisMachine()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
// Two platforms now, and the check is a list rather than a negation for a reason: Linux reaches
|
||||||
|
// this too. Velopack has a Linux path — AppImage — but this repository does not build one, so a
|
||||||
|
// Linux build is a checkout somebody ran, and handing it an UpdateManager would have it poll a
|
||||||
|
// feed carrying nothing it could apply. Naming the platforms that are packaged keeps a future
|
||||||
|
// AppImage an addition here rather than a thing that silently already half-happened.
|
||||||
|
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
|
||||||
{
|
{
|
||||||
return new UnavailableUpdateChannel();
|
return new UnavailableUpdateChannel();
|
||||||
}
|
}
|
||||||
@@ -55,14 +60,21 @@ internal static class UpdateChannels
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The Windows update channel, backed by Velopack against the project's own forge.
|
/// The desktop update channel, backed by Velopack against the project's own forge.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The one file in the repository that names Velopack. It lives beside <c>WindowsDeviceKeyStore</c>
|
/// The one file in the repository that names Velopack. It lives beside the platform key stores rather
|
||||||
/// rather than in a project of its own because it is the same kind of thing — a Windows-only
|
/// than in a project of its own because it is the same kind of thing — a desktop-only implementation of
|
||||||
/// implementation of an interface declared in <c>DodoSSH.Client.Session</c> — and because
|
/// an interface declared in <c>DodoSSH.Client.Session</c> — and because <c>DodoSSH.Client.Shell</c> is
|
||||||
/// <c>DodoSSH.Client.Shell</c> is shared with the Android head, which must never acquire an updater.
|
/// shared with the Android head, which must never acquire an updater.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One class for both desktop platforms, where the key stores are one class each.</b> The difference
|
||||||
|
/// is where the platform knowledge sits. A key store is platform knowledge from top to bottom: different
|
||||||
|
/// hardware, different API, different failure modes. Velopack's <c>UpdateManager</c> has already absorbed
|
||||||
|
/// all of that, and what is left over — check, download, apply, restart — is identical on the two. The
|
||||||
|
/// only thing that differs is which string names the feed, and that is <see cref="ChannelFor"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
|
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
|
||||||
@@ -103,7 +115,7 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// but unsaid on one side and stated on the other is how a feed goes quiet with no error anywhere:
|
/// but unsaid on one side and stated on the other is how a feed goes quiet with no error anywhere:
|
||||||
/// the check succeeds, finds nothing, and reports that the client is up to date forever.
|
/// the check succeeds, finds nothing, and reports that the client is up to date forever.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const string ReleaseChannel = "win";
|
private const string WindowsReleaseChannel = "win";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The nightly channel, which is a different name rather than the same one on a different tag.
|
/// The nightly channel, which is a different name rather than the same one on a different tag.
|
||||||
@@ -122,7 +134,26 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// a download somebody watched. A channel each means neither ever sees the other's releases at all.
|
/// a download somebody watched. A channel each means neither ever sees the other's releases at all.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const string NightlyChannel = "win-nightly";
|
private const string WindowsNightlyChannel = "win-nightly";
|
||||||
|
|
||||||
|
/// <summary>The macOS release channel, and Velopack's own default there.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A contract with <c>scripts/release-macos.sh</c>, exactly as the Windows pair is one with the
|
||||||
|
/// PowerShell script. Stated for the same reason, which applies with more force here: the four
|
||||||
|
/// channels all publish to one repository, so the only thing keeping a Mac from being offered a
|
||||||
|
/// <c>win</c> package is that it never reads that index.
|
||||||
|
/// </remarks>
|
||||||
|
private const string MacReleaseChannel = "osx";
|
||||||
|
|
||||||
|
/// <summary>The macOS nightly channel.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Named here and not yet published by anything. The CI job for the macOS head builds and bundles
|
||||||
|
/// and deliberately uploads nothing — see the packaging step in <c>ci.yml</c> — so a nightly macOS
|
||||||
|
/// build checking this feed finds an empty channel and reports itself up to date, which is the
|
||||||
|
/// correct behaviour for a channel with no publisher. The name exists so that turning the publisher
|
||||||
|
/// on later is one job rather than a job plus a rename that has to reach every installed client.
|
||||||
|
/// </remarks>
|
||||||
|
private const string MacNightlyChannel = "osx-nightly";
|
||||||
|
|
||||||
private readonly UpdateManager manager;
|
private readonly UpdateManager manager;
|
||||||
|
|
||||||
@@ -141,10 +172,13 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsSupported => true;
|
public bool IsSupported => true;
|
||||||
|
|
||||||
/// <summary>Always, on this head.</summary>
|
/// <summary>Always, on this head, on either platform.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Velopack's apply runs <c>Update.exe</c> over this installation and restarts it, so the process is
|
/// Velopack's apply hands off to a separate updater process — <c>Update.exe</c> on Windows, the
|
||||||
/// gone by the time anything could have asked a question. The phone's is the other answer; see
|
/// <c>UpdateMac</c> helper inside the bundle on macOS — which replaces this installation and
|
||||||
|
/// relaunches it, so the process is gone by the time anything could have asked a question. The
|
||||||
|
/// mechanism differs and the answer does not, which is why this is a constant rather than another
|
||||||
|
/// thing <see cref="ChannelFor"/> would have to decide. The phone's is the other answer; see
|
||||||
/// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
|
/// <see cref="IUpdateChannel.ApplyingEndsTheProcess"/> for what the caller does differently.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool ApplyingEndsTheProcess => true;
|
public bool ApplyingEndsTheProcess => true;
|
||||||
@@ -183,7 +217,36 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
|
|
||||||
return new UpdateManager(
|
return new UpdateManager(
|
||||||
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
|
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
|
||||||
new UpdateOptions { ExplicitChannel = nightly ? NightlyChannel : ReleaseChannel });
|
new UpdateOptions { ExplicitChannel = ChannelFor(nightly) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one of the four channel names this build belongs to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Two independent axes — which platform, and which of that platform's two channels — and they are
|
||||||
|
/// resolved in one place so that neither can be answered differently somewhere else. The platform
|
||||||
|
/// half is the running OS rather than anything recorded in the build, because a package can only
|
||||||
|
/// ever be applied on the platform it was built for; the channel half comes from assembly metadata,
|
||||||
|
/// because a release build and a nightly are the same bytes on the same OS and only the metadata
|
||||||
|
/// tells them apart.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Windows is the fallback rather than a third branch. Only Windows and macOS reach here at all —
|
||||||
|
/// <see cref="UpdateChannels.ForThisMachine"/> is the gate — so the alternative would be an
|
||||||
|
/// unreachable throw, and an unreachable throw in the middle of the updater is a thing somebody
|
||||||
|
/// later has to reason about to discover it cannot happen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static string ChannelFor(bool nightly)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsMacOS())
|
||||||
|
{
|
||||||
|
return nightly ? MacNightlyChannel : MacReleaseChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nightly ? WindowsNightlyChannel : WindowsReleaseChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -9,9 +9,17 @@ namespace DodoSSH.Client.App.Platform;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
|
/// One place decides, so nothing above has to carry a platform guard. A machine with no secure hardware,
|
||||||
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
|
/// or one that is neither Windows nor macOS, gets <see cref="UnavailableDeviceKeyStore"/> and therefore
|
||||||
/// passphrase — which is the honest answer rather than a degraded one.
|
/// keeps asking for the passphrase — which is the honest answer rather than a degraded one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Both real stores are asked whether they work rather than told that they do.</b> Each
|
||||||
|
/// <c>IsSupported</c> probes by doing the thing — creating a throwaway key and deleting it — because on
|
||||||
|
/// both platforms the provider is present and reports itself present on machines where creating a key
|
||||||
|
/// fails: a Windows box with no usable TPM, a Mac with no Secure Enclave, and on macOS also every
|
||||||
|
/// unsigned development build, since enclave keys need a signing identity. Inferring from the OS would
|
||||||
|
/// mean each of those discovering the truth at the moment somebody tried to unlock.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
||||||
@@ -29,9 +37,17 @@ public static class DesktopDeviceKeyStores
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()
|
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||||
? new WindowsDeviceKeyStore(paths)
|
{
|
||||||
: new UnavailableDeviceKeyStore();
|
return new WindowsDeviceKeyStore(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsMacOS() && MacDeviceKeyStore.IsSupported())
|
||||||
|
{
|
||||||
|
return new MacDeviceKeyStore(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UnavailableDeviceKeyStore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,14 @@
|
|||||||
channel this application has for saying that a save failed, that a sync was refused, that a merge picked
|
channel this application has for saying that a save failed, that a sync was refused, that a merge picked
|
||||||
a winner. The design is a mock-up of a working afternoon and has nowhere for a sentence like that to go;
|
a winner. The design is a mock-up of a working afternoon and has nowhere for a sentence like that to go;
|
||||||
dropping the bar would have meant dropping the sentence, or repeating it on six screens.
|
dropping the bar would have meant dropping the sentence, or repeating it on six screens.
|
||||||
|
|
||||||
|
── One line, several terminals ────────────────────────────────────────────────────────────────────────
|
||||||
|
One bar for a window that now holds any number of tabs, and connecting writes to it — so "Connected to
|
||||||
|
prod-db." outlived the tab it was about, and closing prod-db left that sentence sitting under whichever
|
||||||
|
terminal the user looked at next. A connection's own lines are now owned by the attempt that wrote them
|
||||||
|
and are cleared when its tab is closed; everything else the vault says stays, because closing a terminal
|
||||||
|
is not an answer to a failed save. See VaultViewModel.ForgetConnectionStatus and
|
||||||
|
MainWindowViewModel.CloseTabAsync.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Height="24" Background="{StaticResource Chrome}"
|
<Border Height="24" Background="{StaticResource Chrome}"
|
||||||
|
|||||||
@@ -1924,9 +1924,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// Closes one terminal, ending its shell.
|
/// Closes one terminal, ending its shell.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
|
/// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
|
||||||
/// close button and not a menu item: closing the window somebody's job is running in should take exactly
|
/// close button and not a menu item: closing the window somebody's job is running in should take exactly
|
||||||
/// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
|
/// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It takes the status line with it, when that line is this tab's.</b> The strip along the bottom is
|
||||||
|
/// one line for the whole window — see <c>StatusBar.axaml</c> — and connecting writes to it, so closing
|
||||||
|
/// prod-db used to leave "Connected to prod-db." reporting on a session that is gone, over whichever
|
||||||
|
/// terminal the user looked at next. Only the sentence this attempt wrote goes; anything the vault has
|
||||||
|
/// said since stays, because a failed save is not something closing a terminal answers.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task CloseTabAsync(TerminalTabViewModel tab)
|
private async Task CloseTabAsync(TerminalTabViewModel tab)
|
||||||
@@ -1936,9 +1945,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed first, so the workspace's SessionEnded — which fires as the pump unwinds — finds no tab to
|
// Removed first, so the workspace's SessionEnded — announced once the close below has fully drained
|
||||||
// mark dead and does nothing. The alternative ordering leaves a window in which a tab that is on its
|
// — finds no tab to mark dead and does nothing here. The alternative ordering leaves a window in
|
||||||
// way out is repainted as disconnected.
|
// which a tab that is on its way out is repainted as disconnected.
|
||||||
var index = Tabs.IndexOf(tab);
|
var index = Tabs.IndexOf(tab);
|
||||||
Tabs.Remove(tab);
|
Tabs.Remove(tab);
|
||||||
|
|
||||||
@@ -1960,6 +1969,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
RaiseTabState();
|
RaiseTabState();
|
||||||
|
|
||||||
|
// Only this tab's own sentence, and only if that is what the bar currently holds — see the remark.
|
||||||
|
Vault?.ForgetConnectionStatus(tab.AttemptId);
|
||||||
|
|
||||||
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
|
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
|
||||||
// changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
|
// changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
|
||||||
// terminal just went would keep a lit dot until something else happened to move the selection.
|
// terminal just went would keep a lit dot until something else happened to move the selection.
|
||||||
@@ -3400,7 +3412,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
|
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
|
||||||
{
|
{
|
||||||
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
var tab = new TerminalTabViewModel(e.Label, e.Address) { AttemptId = e.AttemptId };
|
||||||
attempts[e.AttemptId] = tab;
|
attempts[e.AttemptId] = tab;
|
||||||
|
|
||||||
AdoptTab(tab);
|
AdoptTab(tab);
|
||||||
@@ -3429,6 +3441,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// dropping it would leave a shell running with nothing in the window naming it.
|
// dropping it would leave a shell running with nothing in the window naming it.
|
||||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||||
{
|
{
|
||||||
|
AttemptId = e.AttemptId,
|
||||||
StartedAt = clock.GetUtcNow(),
|
StartedAt = clock.GetUtcNow(),
|
||||||
Cipher = NullIfEmpty(e.Cipher),
|
Cipher = NullIfEmpty(e.Cipher),
|
||||||
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||||
|
|||||||
@@ -84,6 +84,18 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal uint SessionId { get; private set; }
|
internal uint SessionId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The connection attempt this tab was opened for, or <see cref="Guid.Empty"/> for a tab nothing dialled.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Carried only so that closing this tab can take the vault's status line with it when the line is about
|
||||||
|
/// this connection — see <c>VaultViewModel.ForgetConnectionStatus</c>. It is the attempt id and not the
|
||||||
|
/// session id because the message can be written before a session exists ("Connecting to prod-db…") and
|
||||||
|
/// after one has stopped existing, and because the two failure sentences a tab can end on never have a
|
||||||
|
/// session id at all.
|
||||||
|
/// </remarks>
|
||||||
|
internal Guid AttemptId { get; init; }
|
||||||
|
|
||||||
internal string Label { get; }
|
internal string Label { get; }
|
||||||
|
|
||||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||||
|
|||||||
@@ -2130,6 +2130,60 @@ internal sealed partial class VaultViewModel(
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string status = string.Empty;
|
private string status = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The connection <see cref="Status"/> is currently about, or <see cref="Guid.Empty"/> when it is about
|
||||||
|
/// something else — a save, a sync, a refusal that has nothing to do with a terminal.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The status line is one line for the whole window, and most of what goes through it is about the vault,
|
||||||
|
/// which is one thing however many terminals are open. A connection is not: "Connected to prod-db." is
|
||||||
|
/// about one tab, and it used to outlive that tab — close prod-db, look at the terminal beside it, and the
|
||||||
|
/// strip along the bottom was still reporting on a session that is no longer there.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Ownership rather than a blanket clear on every close, because the alternative loses the sentences this
|
||||||
|
/// bar exists for: a failed save or a refused sync is what the design deleted the bar's other four fields
|
||||||
|
/// to make room for, and closing a terminal is no reason to take one away.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Reset by <see cref="OnStatusChanged"/> on every write, so the only messages that carry an owner are the
|
||||||
|
/// ones <see cref="SayAbout"/> writes. Anything assigning <see cref="Status"/> in the ordinary way says,
|
||||||
|
/// by doing so, that its message belongs to no tab.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private Guid statusAttemptId;
|
||||||
|
|
||||||
|
partial void OnStatusChanged(string value) => statusAttemptId = Guid.Empty;
|
||||||
|
|
||||||
|
/// <summary>Writes a status line that belongs to one connection attempt.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// After the assignment, not before: <see cref="OnStatusChanged"/> clears the owner on every write, which
|
||||||
|
/// is what makes "belongs to nobody" the default rather than something each of the other status writes
|
||||||
|
/// would have to remember to say.
|
||||||
|
/// </remarks>
|
||||||
|
private void SayAbout(ConnectionAttemptEventArgs attempt, string message)
|
||||||
|
{
|
||||||
|
Status = message;
|
||||||
|
statusAttemptId = attempt.AttemptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the status line if what it is saying is about this connection, and leaves it alone otherwise.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Called by the shell when a tab is closed — see <c>MainWindowViewModel.CloseTabAsync</c>. Cleared to
|
||||||
|
/// empty rather than replaced with "Closed prod-db.": the tab going is the report, it is the report the
|
||||||
|
/// user just made happen, and a bar that answers every close with a sentence is a bar people stop reading.
|
||||||
|
/// </remarks>
|
||||||
|
internal void ForgetConnectionStatus(Guid attemptId)
|
||||||
|
{
|
||||||
|
if (attemptId != Guid.Empty && statusAttemptId == attemptId)
|
||||||
|
{
|
||||||
|
Status = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int pendingChanges;
|
private int pendingChanges;
|
||||||
|
|
||||||
@@ -10767,7 +10821,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
Dialled(target, authentication));
|
Dialled(target, authentication));
|
||||||
|
|
||||||
ConnectionStarting?.Invoke(this, attempt);
|
ConnectionStarting?.Invoke(this, attempt);
|
||||||
Status = $"Connecting to {target.Label}…";
|
SayAbout(attempt, $"Connecting to {target.Label}…");
|
||||||
|
|
||||||
await OpenSessionAsync(attempt, target, authentication, cancellationToken).ConfigureAwait(true);
|
await OpenSessionAsync(attempt, target, authentication, cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
@@ -11042,7 +11096,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
|
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
|
||||||
{
|
{
|
||||||
Status = reason;
|
// Owned by the attempt, unlike Answer's own line below: this is the case where the tab stays, carrying
|
||||||
|
// the same reason, so closing that tab is exactly the moment the line stops being about anything.
|
||||||
|
SayAbout(attempt, reason);
|
||||||
|
|
||||||
ConnectionFailed?.Invoke(
|
ConnectionFailed?.Invoke(
|
||||||
this,
|
this,
|
||||||
@@ -11097,7 +11153,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// only record that machine was reached at all.
|
// only record that machine was reached at all.
|
||||||
connectionLog?.Identify(sessionId, target.Label, target.HostId);
|
connectionLog?.Identify(sessionId, target.Label, target.HostId);
|
||||||
|
|
||||||
Status = $"Connected to {target.Label}.";
|
SayAbout(attempt, $"Connected to {target.Label}.");
|
||||||
|
|
||||||
// The session the workspace just opened is the only place the negotiated cipher and host-key
|
// The session the workspace just opened is the only place the negotiated cipher and host-key
|
||||||
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
|
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
|
||||||
@@ -11128,7 +11184,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
// screen it is typed on says so.
|
// screen it is typed on says so.
|
||||||
if (target.Row is { } row)
|
if (target.Row is { } row)
|
||||||
{
|
{
|
||||||
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
await RememberTypedPasswordAsync(attempt, row, authentication, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11160,6 +11217,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task RememberTypedPasswordAsync(
|
private async Task RememberTypedPasswordAsync(
|
||||||
|
ConnectionAttemptEventArgs attempt,
|
||||||
HostRowViewModel row,
|
HostRowViewModel row,
|
||||||
HostAuthentication authentication,
|
HostAuthentication authentication,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -11175,8 +11233,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
if (row.IsReadOnly)
|
if (row.IsReadOnly)
|
||||||
{
|
{
|
||||||
Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
|
SayAbout(
|
||||||
+ "newer version of DodoSSH, and binding a credential would re-encode it.";
|
attempt,
|
||||||
|
$"Connected to {row.Label}. Its password was not saved: this host was written by a newer "
|
||||||
|
+ "version of DodoSSH, and binding a credential would re-encode it.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11200,7 +11260,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
|
SayAbout(
|
||||||
|
attempt,
|
||||||
|
$"Connected to {row.Label}, but its password could not be saved: {exception.Message}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11211,8 +11273,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
|
SayAbout(
|
||||||
+ "will not be asked for again.";
|
attempt,
|
||||||
|
$"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it will "
|
||||||
|
+ "not be asked for again.");
|
||||||
|
|
||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,8 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised with the session id when a shell ends on its own.
|
/// Raised with the session id once a session is over — its shell having ended on its own, or a
|
||||||
|
/// deliberate close having fully drained.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -268,13 +269,23 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
/// the half of the interface Avalonia draws.
|
/// the half of the interface Avalonia draws.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Raised on whatever thread the pump finished on</b>, which is a thread-pool thread. A handler that
|
/// <b>Raised only after the session's run task has completed, and that ordering is load-bearing.</b>
|
||||||
/// touches an observable collection has to marshal; this type has no toolkit to do it with, which is
|
/// It used to fire from inside the run's own finally block, where the task is by definition not yet
|
||||||
/// exactly why it does not try.
|
/// complete — so a handler reading <see cref="LiveSessionCount"/> still counted the session that had
|
||||||
|
/// just ended, which is how the phone's foreground notification went on saying "1 shell connected"
|
||||||
|
/// over nothing. See <see cref="AnnounceEndedAsync"/>. Raised on a thread-pool continuation, or on the
|
||||||
|
/// closer's own thread; a handler that touches an observable collection has to marshal either way.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Not raised by <see cref="CloseSessionAsync"/>. That path already has a caller who knows the session is
|
/// <b>Raised by <see cref="CloseSessionAsync"/> too, which reverses a recorded decision.</b> The old
|
||||||
/// going, and telling it what it just asked for is how a tab close turns into a second tab close.
|
/// reasoning — the caller asked, so telling it is an echo — assumed every subscriber was the caller.
|
||||||
|
/// The phone's keep-alive is not: it hears this event to reconcile a notification with reality, and a
|
||||||
|
/// close that announced nothing left that notification claiming a shell that was gone. Every subscriber
|
||||||
|
/// treats the event as "reconcile" rather than "act" — a tab is marked dead if it is still there and
|
||||||
|
/// skipped if it is not — so a second announcement for a session that already announced its own end
|
||||||
|
/// (closing the tab of a shell that exited earlier) is deliberate and harmless. Shutdown is the one
|
||||||
|
/// close that stays silent: <see cref="DisposeAsync"/> is tearing the subscribers down with the
|
||||||
|
/// sessions, and news nobody is left to hear is not news.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;
|
public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;
|
||||||
@@ -403,6 +414,10 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
sessions[sessionId] = new LiveSession(connection, pump, run);
|
sessions[sessionId] = new LiveSession(connection, pump, run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The announcement's own continuation — see AnnounceEndedAsync. Started after the entry is stored,
|
||||||
|
// so the containment check inside it can never run against a dictionary the session had not reached.
|
||||||
|
_ = AnnounceEndedAsync(sessionId, run);
|
||||||
|
|
||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,6 +589,14 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
// Expected on the ordinary path: disposing the pump cancels its run.
|
// Expected on the ordinary path: disposing the pump cancels its run.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After the drain, so a handler reading LiveSessionCount sees this session already gone — the
|
||||||
|
// event's own remark carries why a deliberate close is announced at all, and why shutdown is not:
|
||||||
|
// DisposeAsync sets the flag before its closing loop, and is dismantling every subscriber anyway.
|
||||||
|
if (Volatile.Read(ref disposed) == 0)
|
||||||
|
{
|
||||||
|
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -694,10 +717,41 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
|
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
|
||||||
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
|
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
|
||||||
// the application is closing.
|
// the application is closing.
|
||||||
|
//
|
||||||
|
// SessionEnded is deliberately NOT raised from here, and it used to be — see
|
||||||
|
// AnnounceEndedAsync for what was wrong with that.
|
||||||
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A continuation rather than a line in <see cref="RunSessionAsync"/>'s finally, and the difference is
|
||||||
|
/// what a handler sees. Inside that finally the run task is not yet complete — a finally is part of the
|
||||||
|
/// task — so <see cref="LiveSessionCount"/>, which counts incomplete runs, still included the session
|
||||||
|
/// that had just ended. The phone's keep-alive answers this event by reading exactly that count, and
|
||||||
|
/// reconciled its foreground notification to "1 shell connected" over a shell that was gone, with
|
||||||
|
/// nothing left to fire afterwards and correct it. By the time an await on the run resumes, the task is
|
||||||
|
/// complete and the count is honest.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The containment check keeps the deliberate paths out of this route: <see cref="CloseSessionAsync"/>
|
||||||
|
/// removes the entry before it disposes the pump, and makes its own announcement after its own drain.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task AnnounceEndedAsync(uint sessionId, Task run)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await run.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// The run's faults belong to whoever drains it — CloseSessionAsync, on the deliberate path.
|
||||||
|
// This continuation cares only that the run is over, however it got there.
|
||||||
|
}
|
||||||
|
|
||||||
// Only when the session is still one this workspace knows about. CloseSessionAsync removes the
|
|
||||||
// entry before it disposes the pump, so a tab the user closed does not come back as news.
|
|
||||||
bool announce;
|
bool announce;
|
||||||
|
|
||||||
lock (sessionGate)
|
lock (sessionGate)
|
||||||
@@ -710,7 +764,6 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The address as dialled, for the log.</summary>
|
/// <summary>The address as dialled, for the log.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Presenters;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Layout.Tests;
|
namespace DodoSSH.Client.App.Layout.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The three button shapes centre their caption inside a button taller than the caption.
|
/// The button shapes that centre their caption do, and the two that deliberately do not still fill.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -41,6 +42,15 @@ namespace DodoSSH.Client.App.Layout.Tests;
|
|||||||
/// action — so an absolute expectation would be a font metric written down in a test file, and it would move
|
/// action — so an absolute expectation would be a font metric written down in a test file, and it would move
|
||||||
/// the day the face does. "Centred" survives both.
|
/// the day the face does. "Centred" survives both.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The five shapes beyond the original three were swept in afterwards, and none of them was misbehaving
|
||||||
|
/// when it was: every one is content-sized everywhere it is used today, so <c>Stretch</c> and <c>Center</c>
|
||||||
|
/// agreed and the change moved nothing — 113 buttons across 29 screens measured byte-identical before and
|
||||||
|
/// after. What the sweep buys is that the day any of them is given a height, it is already right. That is
|
||||||
|
/// also why <see cref="AStretchingShapeStillFillsItsButton"/> matters more than it looks: the same
|
||||||
|
/// reasoning applied to <c>flat</c> or <c>cat</c> would break a pill and a strip that are currently
|
||||||
|
/// correct.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class ButtonCaptionTests
|
public sealed class ButtonCaptionTests
|
||||||
{
|
{
|
||||||
@@ -61,6 +71,11 @@ public sealed class ButtonCaptionTests
|
|||||||
[InlineData("ghost")]
|
[InlineData("ghost")]
|
||||||
[InlineData("accent")]
|
[InlineData("accent")]
|
||||||
[InlineData("danger")]
|
[InlineData("danger")]
|
||||||
|
[InlineData("navuser")]
|
||||||
|
[InlineData("poprow")]
|
||||||
|
[InlineData("panechip")]
|
||||||
|
[InlineData("chiptoggle")]
|
||||||
|
[InlineData("choice")]
|
||||||
public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape)
|
public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape)
|
||||||
{
|
{
|
||||||
await MeasureAsync(
|
await MeasureAsync(
|
||||||
@@ -96,6 +111,72 @@ public sealed class ButtonCaptionTests
|
|||||||
"the caption should sit high, which is the defect this suite was written for"));
|
"the caption should sit high, which is the defect this suite was written for"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>flat</c> and <c>cat</c> are excluded from the rule above, and must stay excluded.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Both stretch their content on purpose, and both would be silently broken by a later pass that
|
||||||
|
/// "finished" the sweep the rest of these classes belong to — which is exactly why this is a test and
|
||||||
|
/// not a comment.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>flat</c> carries the titlebar's search pill, a <c>Border.searchpill</c> with no height of its own
|
||||||
|
/// that is meant to fill all 35 pixels of the button; the usage states
|
||||||
|
/// <c>HorizontalContentAlignment="Stretch"</c> and relies on the vertical default matching it. Centring
|
||||||
|
/// from the style would shrink that pill to its caption's line box inside a button twice as tall.
|
||||||
|
/// <c>cat</c> carries the keychain rail's accent strip, a <c>Border.rowmark</c> whose style sets
|
||||||
|
/// <c>Width="2"</c> and no height at all — "at full row height", says the rule's own remark — so its
|
||||||
|
/// height is the stretch and nothing else.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asserted as "the content fills the button", not as "the caption is off-centre": what these two need
|
||||||
|
/// is the fill, and a test phrased the other way would still pass if the fill broke in some new way.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("flat")]
|
||||||
|
[InlineData("cat")]
|
||||||
|
public async Task AStretchingShapeStillFillsItsButton(string shape)
|
||||||
|
{
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
// A bare Border is what both of them actually hold: no height, sized only by its parent.
|
||||||
|
var fill = new Border();
|
||||||
|
var button = new Button { Content = fill, Height = FixedHeight };
|
||||||
|
button.Classes.Add(shape);
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// The slot read off the presenter rather than recomputed from the button's Padding:
|
||||||
|
// these shapes differ in whether their presenter also draws a border, and a hand-rolled
|
||||||
|
// sum was two pixels out on Button.cat for exactly that reason.
|
||||||
|
var presenter = button.GetVisualDescendants()
|
||||||
|
.OfType<ContentPresenter>()
|
||||||
|
.Single(p => string.Equals(p.Name, "PART_ContentPresenter", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
var slot = presenter.Bounds.Height
|
||||||
|
- presenter.Padding.Top - presenter.Padding.Bottom
|
||||||
|
- presenter.BorderThickness.Top - presenter.BorderThickness.Bottom;
|
||||||
|
|
||||||
|
fill.Bounds.Height.ShouldBe(
|
||||||
|
slot,
|
||||||
|
Tolerance,
|
||||||
|
$"Button.{shape} must stretch its content — the search pill and the rail's accent "
|
||||||
|
+ "strip have no height of their own");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
private static Task MeasureAsync(
|
private static Task MeasureAsync(
|
||||||
string shape, VerticalAlignment? alignment, Action<double, double> assert) =>
|
string shape, VerticalAlignment? alignment, Action<double, double> assert) =>
|
||||||
LayoutHarness.OnTheUiThreadAsync(
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
|||||||
@@ -1254,6 +1254,68 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.IsTerminalShowing.ShouldBeTrue();
|
shell.IsTerminalShowing.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The status line is one line for the whole window, so a sentence about a session that has been closed is
|
||||||
|
/// a sentence the user reads over some other terminal.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClosingATab_TakesItsOwnStatusLineWithIt()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
||||||
|
|
||||||
|
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
||||||
|
|
||||||
|
vault.Status.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other half, and the reason closing does not simply blank the bar: everything this application has
|
||||||
|
/// to say about a save, a sync or a refusal goes through the same line — see <c>StatusBar.axaml</c> — and
|
||||||
|
/// closing a terminal answers none of it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClosingATab_LeavesAStatusLineThatIsAboutSomethingElse()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
vault.Status = "The keychain could not be saved.";
|
||||||
|
|
||||||
|
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
||||||
|
|
||||||
|
vault.Status.ShouldBe("The keychain could not be saved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Two sessions to the same host, which is the case a label could not tell apart: the line belongs to the
|
||||||
|
/// attempt that wrote it, so closing the other one leaves it alone.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClosingATab_LeavesTheLineAnotherTabWrote()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
var first = shell.Tabs[0];
|
||||||
|
|
||||||
|
// The second connection's own line is what the bar holds now.
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
var reported = vault.Status;
|
||||||
|
|
||||||
|
await shell.CloseTabCommand.ExecuteAsync(first);
|
||||||
|
|
||||||
|
vault.Status.ShouldBe(reported);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Connecting, while it is still happening ----
|
// ---- Connecting, while it is still happening ----
|
||||||
//
|
//
|
||||||
// A handshake is a network round trip and no longer holds the vault while it runs, so there is a stretch
|
// A handshake is a network round trip and no longer holds the vault while it runs, so there is a stretch
|
||||||
|
|||||||
@@ -217,12 +217,15 @@ public sealed class TerminalWorkspaceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The event the tab strip listens to, so a dot can go out the moment a shell exits rather than at the
|
/// The event the tab strip and the phone's keep-alive listen to, so a dot can go out — and a foreground
|
||||||
/// next thing that happens to repaint. Raised only when the session ended on its own: a tab the user
|
/// notification can come down — the moment a shell exits rather than at the next thing that happens to
|
||||||
/// closed has a caller who already knows, and telling it would turn one close into two.
|
/// repaint. The count captured inside the handler is the sharper half of this test: the announcement
|
||||||
|
/// used to fire from inside the run's own finally block, where the run task is not yet complete, so
|
||||||
|
/// <c>LiveSessionCount</c> read from the handler still said 1 — and the phone's notification went on
|
||||||
|
/// claiming a shell that was gone, with nothing left to fire and correct it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ASessionEndingOnItsOwnIsAnnounced()
|
public async Task ASessionEndingOnItsOwnIsAnnounced_AfterTheCountStoppedIncludingIt()
|
||||||
{
|
{
|
||||||
// A shell with no output to give: its first read returns 0, which is a remote closing the channel,
|
// A shell with no output to give: its first read returns 0, which is a remote closing the channel,
|
||||||
// so the pump finishes with nobody asking it to.
|
// so the pump finishes with nobody asking it to.
|
||||||
@@ -231,10 +234,12 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var ended = new List<uint>();
|
var ended = new List<uint>();
|
||||||
|
var liveAtAnnouncement = -1;
|
||||||
workspace.SessionEnded += (_, e) =>
|
workspace.SessionEnded += (_, e) =>
|
||||||
{
|
{
|
||||||
lock (ended)
|
lock (ended)
|
||||||
{
|
{
|
||||||
|
liveAtAnnouncement = workspace.LiveSessionCount;
|
||||||
ended.Add(e.SessionId);
|
ended.Add(e.SessionId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -249,18 +254,33 @@ public sealed class TerminalWorkspaceTests
|
|||||||
return ended.Contains(sessionId);
|
return ended.Contains(sessionId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
lock (ended)
|
||||||
|
{
|
||||||
|
liveAtAnnouncement.ShouldBe(0, "the announcement must wait for the run to actually complete");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc cref="ASessionEndingOnItsOwnIsAnnounced" />
|
/// <remarks>
|
||||||
|
/// The reversal of a recorded decision, and the event's own remark carries why: a close used to be
|
||||||
|
/// announced to nobody, on the theory that the caller already knew — but the phone's keep-alive is not
|
||||||
|
/// the caller, and a close it never heard about left the foreground notification claiming a shell that
|
||||||
|
/// was gone. Announced once, after the drain, so the count a handler reads is already honest.
|
||||||
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClosingASessionIsNotAnnouncedBack()
|
public async Task ClosingASessionIsAnnounced_OnceItHasDrained()
|
||||||
{
|
{
|
||||||
var connections = new FakeConnectionFactory();
|
var connections = new FakeConnectionFactory();
|
||||||
|
|
||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var announcements = 0;
|
var announcements = 0;
|
||||||
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
|
var liveAtAnnouncement = -1;
|
||||||
|
workspace.SessionEnded += (_, _) =>
|
||||||
|
{
|
||||||
|
liveAtAnnouncement = workspace.LiveSessionCount;
|
||||||
|
Interlocked.Increment(ref announcements);
|
||||||
|
};
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
@@ -268,12 +288,15 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await workspace.CloseSessionAsync(sessionId);
|
await workspace.CloseSessionAsync(sessionId);
|
||||||
|
|
||||||
Volatile.Read(ref announcements)
|
Volatile.Read(ref announcements)
|
||||||
.ShouldBe(0, "a close the caller asked for is not news to report back to it");
|
.ShouldBe(1, "a close is news to the keep-alive even though it is an echo to the closer");
|
||||||
|
liveAtAnnouncement.ShouldBe(0, "announced after the drain, so the count already excludes it");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
|
/// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
|
||||||
/// that the SSH connections are known to be released rather than assumed to be.
|
/// that the SSH connections are known to be released rather than assumed to be — and that these closes,
|
||||||
|
/// unlike a deliberate one, are announced to nobody: shutdown is dismantling every subscriber along
|
||||||
|
/// with the sessions, and news nobody is left to hear is not news.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DisposingTheWorkspaceClosesEverySession()
|
public async Task DisposingTheWorkspaceClosesEverySession()
|
||||||
@@ -282,6 +305,9 @@ public sealed class TerminalWorkspaceTests
|
|||||||
|
|
||||||
var workspace = CreateWorkspace(connections);
|
var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
|
var announcements = 0;
|
||||||
|
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
|
||||||
|
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
@@ -292,6 +318,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
workspace.LiveSessionCount.ShouldBe(0);
|
workspace.LiveSessionCount.ShouldBe(0);
|
||||||
connections.Connections.Count.ShouldBe(2);
|
connections.Connections.Count.ShouldBe(2);
|
||||||
connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
|
connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
|
||||||
|
Volatile.Read(ref announcements).ShouldBe(0, "shutdown closes are not announced");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Reattach ----
|
// ---- Reattach ----
|
||||||
|
|||||||
Reference in New Issue
Block a user