Author SHA1 Message Date
jaap-jan 05c56f20a4 Let a closed terminal take its own status line with it
The strip along the bottom is one line for the whole window, and connecting
writes to it, so "Connected to prod-db." outlived the tab it was about: close
prod-db, look at the terminal beside it, and the bar was still reporting on a
session that is no longer there.

A connection's own lines are now owned by the attempt that wrote them, and
closing a tab clears the line only when that is what the bar is holding.
Ownership rather than a blanket clear on 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 answer to one.
2026-08-10 11:41:55 +02:00
jaap-jan 7f77539ba6 Merge pull request 'Give the desktop a macOS head, signed from the first release' (#3) from claude/macos-build-release-2a8a0d into main
ci / build and test (push) Failing after 2m4s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m34s
Reviewed-on: #3
2026-08-10 08:58:59 +00:00
jaap-jan ca081af209 Merge branch 'main' into claude/macos-build-release-2a8a0d
ci / build and test (pull_request) Failing after 2m10s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m12s
2026-08-10 08:58:50 +00:00
jaap-jan 890a5f2246 Give the desktop a macOS head, signed from the first release
ci / android head (pull_request) Canceled after 0s
ci / desktop nightly (pull_request) Canceled after 0s
ci / api image (pull_request) Canceled after 0s
ci / build and test (pull_request) Canceled after 1m21s
The same application, the same Velopack and the same two-phase person-run
release as Windows, with four things forced to differ. Signing is a
precondition rather than an improvement: Gatekeeper refuses an
un-notarized download outright instead of warning about it, so there was
never the "unsigned for now" that ADR 0013 decision 8 argues for on
Windows, and release-macos.sh refuses to start without the identities.

The packaging split is narrower than it first looked, and the old claim
at the foot of ci.yml is why it was worth checking rather than assuming.
vpk cross-compiles when told to: 'vpk [osx] bundle' builds a real .app on
any platform, and CI now publishes osx-arm64 and bundles it on every main
and tag build, which is what catches a restore graph with no macOS native
asset. There is no '[osx] pack' off a Mac, and that part is correct — pack
drives codesign, notarytool and stapler, which exist nowhere else.

The dylib signing loop in the script looks redundant beside vpk's own
pass and is not. vpk signs with 'codesign --deep', which is the shape
Apple documents as wrong for nested code, and platform-flags has recorded
a notarization rejection that names no file since before any of this
existed. Signing each native binary inside-out first leaves that pass
nothing to get wrong.

MacDeviceKeyStore reaches ADR 0007's conclusion through different
hardware: a P-256 key in the Secure Enclave under an access control
requiring user presence, so the platform enforces the gate rather than
this process — which is the whole point of that ADR's amendment. The
enclave holds no other kind of key, hence ECIES where Windows uses
RSA-OAEP, and the shape that falls out is better than the Windows one:
sealing needs only the public half and is silent, so only unlock prompts.
IsSupported probes rather than infers, because three ordinary Macs answer
no — an Intel machine without a T2, one with no login password, and every
unsigned development build, since enclave keys need a signing identity.

Two decisions worth stating because they are reversible. arm64 only: a
second channel is small work and nobody here has an Intel Mac to walk
Phase 18 on, and an x64 package would be the only artefact in this
repository reaching users unverified. And the pack id stays
DodoSSH.Desktop even though vpk names the bundle after it, so
/Applications holds DodoSSH.Desktop.app: decision 2's reasoning binds
harder here, because a pack id of DodoSSH would put Velopack's install
root on top of ClientPaths.DataDirectory and let an uninstall take the
user's un-synced outbox with it. CFBundleDisplayName puts the product
name back in front of a person.

Measured rather than assumed, since none of it is obvious: the publish
and the bundle were both run, LSMinimumSystemVersion is 12.0 because that
is the minos in the apphost's own LC_BUILD_VERSION, and vpk copies a
custom Info.plist verbatim with no substitution at all — which is why the
plist is a template the script renders and not a committed file.

What is not done is the half that needs the hardware. There is no macOS
runner, so nothing past "it bundles" has ever run. Phase 18 is the whole
of the verification, and the two checks most likely to fail are the
terminal against WKWebView and the enclave interop, neither of which has
executed once.
2026-08-10 10:43:28 +02:00
jaap-jan 8c67fce32c Centre a phone row's caption in the row it is given
ci / build and test (push) Successful in 2m18s
ci / android head (push) Successful in 3m27s
ci / desktop nightly (push) Failing after 1m45s
ci / api image (push) Successful in 39s
Button.row sets the height a thumb needs and left the caption's placement to
Avalonia's Stretch default, so the content presenter stretched the caption to
the whole row and a TextBlock draws its line at the top of what it is given —
the same omission the desktop head's ghost/accent/danger rule had.

Most of the thirty-three rows never showed it, which is what made the four that
did look like four unrelated mistakes rather than one rule: a row whose content
is a StackPanel or a Grid of already-centred children is centred whatever this
property says. The four that are a bare TextBlock are FilesScreen's breadcrumb
crumb, its up-one-directory chip and its pinned-path chip, and TerminalScreen's
CLOSE THIS TAB — 36 or 44 tall with no vertical padding, so measured at those
numbers the caption sat flush against the top edge with 21 to 33 pixels of
nothing under it, eleven to seventeen pixels off centre in a control barely
twice that tall.

Nothing is excluded here, unlike the desktop's own sweep: no row's content
depends on being stretched — there is no full-height strip inside any of the
thirty-three, the thing that keeps flat and cat out of the equivalent rule over
there — and the Grids that stop filling hold only children that already centre
themselves, so they land where they always did.

The other two phone classes that do not set it are both fine and neither should
get it. RadioButton.chip declares its own ControlTemplate whose presenter reads
VerticalAlignment="Center" outright, so it centres regardless and the property
would not be read; Button.scrim is the full-screen dimmer behind a sheet and
has no caption at all.

Not covered by a test, and it cannot be from here: there is no Android layout
suite, the desktop harness cannot instantiate net10.0-android views, and
AvaloniaRuntimeXamlLoader — which would let it load Phone.axaml on its own —
lives in a package this repo does not reference. What is verified is that the
head builds, so the Avalonia XAML compiler has accepted the setter, and that
the desktop's own 147 layout tests are unmoved.
2026-08-10 10:35:38 +02:00
jaap-jan 0ffd259ccd Give the rest of the button shapes their content alignment too
ci / build and test (push) Successful in 2m28s
ci / desktop nightly (push) Successful in 50s
ci / api image (push) Successful in 24s
ci / android head (push) Successful in 3m15s
The sweep the ghost/accent/danger fix implied: navuser, poprow, panechip,
chiptoggle and choice each set VerticalContentAlignment now, because each set
everything else about how its content sits and left that one to Avalonia's
Stretch default.

None of them was misbehaving. Every one is content-sized everywhere it is used
today, so Stretch and Center agreed and this moves nothing — 113 buttons across
29 screens and cards measured byte-identical before and after, the strips that
have no height of their own included. What it buys is that the day one of them
is given a height, it is already right rather than quietly drawing its label in
the top third.

flat and cat are deliberately NOT swept in, and the reasoning that would sweep
them is exactly the trap. flat carries the titlebar's search pill, a
Border.searchpill with no height of its own that is meant to fill all 35 pixels
of its button — the usage states HorizontalContentAlignment="Stretch" and takes
the vertical default to match. cat carries the keychain rail's accent strip, a
Border.rowmark whose style sets Width="2" and no height at all, "at full row
height" by its own remark. Centring either from the style shrinks a pill and a
strip that are correct today. AStretchingShapeStillFillsItsButton pins both, and
fails when flat is centred.

ButtonCaptionTests covers the five new shapes on the existing rule. Its
stretch-fill assertion reads the content slot off the presenter rather than
recomputing it from the button's Padding: the shapes differ in whether their
presenter also draws a border, and a hand-rolled sum was two pixels out on
Button.cat for that reason.
2026-08-10 10:20:26 +02:00
jaap-jan 9bc9069425 Post the terminal's focus return past the dispatch that steals it
ci / build and test (push) Successful in 2m29s
ci / android head (push) Successful in 3m23s
ci / desktop nightly (push) Successful in 54s
ci / api image (push) Successful in 25s
The first fix handed Android's focus back from inside the keys' Click
handlers — which fire inside the UP event's dispatch, and Avalonia's
own view requests focus for itself after every handled touch dispatch
returns (AvaloniaView.DispatchTouchEvent, decompiled from 12.1.1). So
the platform's request ran after ours and undid it microseconds later,
which is exactly what the phone showed: the terminal still lost focus.

The return is now posted onto the main looper, landing one message
after the dispatch that stole, and it is wired at the row for both
halves of a press — DOWN steals too, and Click only exists for UP, so
a keyboard detached at DOWN would otherwise stay detached for the whole
length of the press. Check 11.10a now also says what a tolerable blink
looks like against a failure that stays.
2026-08-09 21:35:08 +02:00
jaap-jan e936ab4646 Announce a session's end when it is actually over, and for closes too
ci / android head (push) Successful in 3m17s
ci / desktop nightly (push) Successful in 41s
ci / build and test (push) Successful in 2m27s
ci / api image (push) Successful in 28s
The phone's notification kept saying '1 shell connected' after the
shell was gone, and both close routes were at fault. A shell exiting on
its own raised SessionEnded from inside its run's finally block — where
the run task is by definition not yet complete, so the LiveSessionCount
the keep-alive reads still counted the dead shell, and nothing fired
later to correct it. A tab closed by hand announced nothing at all, by
a recorded decision that assumed every subscriber was the closer; the
keep-alive is not, and a close it never heard about left the
notification claiming a shell over nothing.

The end is now announced from a continuation after the run completes,
and CloseSessionAsync announces after its own drain — every subscriber
was already a reconcile-to-reality handler, so the echo the old remark
feared costs nothing. Shutdown stays silent: it is dismantling the
subscribers along with the sessions.
2026-08-09 13:01:14 +02:00
jaap-jan 506d2803a2 Hand Android's own focus back to the terminal after an accessory key
ci / android head (push) Successful in 3m22s
ci / desktop nightly (push) Successful in 41s
ci / api image (push) Successful in 26s
ci / build and test (push) Successful in 2m27s
Focusable=false was only ever half the fix, and its remark now says so:
Avalonia's focus stays on the NativeWebView, but the touch that presses
a key still hands Android's native focus to Avalonia's input view — the
platform moves it before Avalonia decides anything. The WebView's input
connection dies with it, the keyboard swaps to its no-input layout, and
the inset churn parks it over the very row that was tapped.

Each key now returns that focus once its byte is on the wire, through a
sibling of SoftKeyboard that walks the decor view to the one WebView
this application has. Free when nothing moved. Check 11.10a is the
phone-in-hand proof.
2026-08-09 11:35:15 +02:00
jaap-jan cc8bf37321 Merge branch 'claude/terminal-reattach'
ci / build and test (push) Successful in 2m30s
ci / android head (push) Successful in 3m25s
ci / desktop nightly (push) Successful in 37s
ci / api image (push) Successful in 37s
Brings the Android keep-alive corrections and the terminal renderer
reattach: the foreground service now actually comes up for shells and
an idle Files session, survives refreshes from the background, and the
terminal's data plane lets a reloaded WebView page take its socket back
over instead of freezing every session behind a dead one.
2026-08-09 10:59:01 +02:00
jaap-jan 3f5979d639 Record the renderer-reattach correction and its phone checks
The port notes carry the third correction of this round: the data
plane assumed a renderer that attaches once and lives forever, which no
foreground service can make true of Android's separate WebView renderer
process. Phase 11 gains the two checks a phone can run — close and
reopen a connection, and a backgrounded shell surviving its renderer
being killed, banner and all.
2026-08-09 10:54:45 +02:00
jaap-jan aaff81272a Teach the page and the shell to put a reattached view back together
The page's socket now retries itself forever with backoff — a dropped
socket is an ordinary event on a phone, not the end of the terminal's
life — and createSession is idempotent, so a replay landing on a pane
that survived changes nothing. A replay creating a pane that did not
survive writes one dim line saying the earlier output stayed on the
host, because that is the truth about a reloaded page's scrollback.

The shell answers RendererReattached with the two things only it owns:
the font size, and which tab is active.
2026-08-09 10:54:38 +02:00
jaap-jan 4d1f07f253 Replay the live sessions to a renderer that just attached
Each live session gets its credit window reset — the unacknowledged
bytes died with the old page, and their acknowledgement is never
coming — and its SessionOpened frame again, flagged as a replay so the
page can tell a reattach from a genuinely new session. A session whose
shell already ended gets nothing: its scrollback lived only in the page
that is gone, and a frame implying otherwise would lie.

RendererReattached is the seam for what the workspace has no business
owning: the font size and the selected tab live in the shell, which
re-pushes them from its own subscription.
2026-08-09 10:54:29 +02:00
jaap-jan 095774c498 Take a returning renderer's socket over instead of refusing it
One attach per process was WebView2's truth, not Android's: the phone
kills the WebView's renderer independently of the app process, the page
reloads, and its fresh socket was answered 409 by a guard that never
reset — with no way back short of restarting the app. Only our own page
knows the token, so a second valid upgrade is that page returning; it
now displaces the old socket, which may never notice it is dead on its
own, since a killed renderer sends no FIN.

A send into the dead socket also no longer escapes as a fault. It used
to unwind the pump's flush loop, after which nothing drained the credit
window and the still-live shell froze behind it for good — including
the BCL quirk where such a send surfaces as an OperationCanceledException
nobody's token asked for.
2026-08-09 10:54:20 +02:00
jaap-jan 3977f68870 Record the keep-alive corrections in the port notes and the manual checks
The port doc's backgrounding decision now carries the four corrections
rather than describing a wiring that was not true, and Phase 14 gains
the checks a phone can actually run: a backgrounded shell surviving, an
idle Files connection surviving, the permission ask arriving at the
first thing worth showing, and a refusal costing the notification and
nothing else.
2026-08-09 10:14:24 +02:00
jaap-jan 48ea5e22d5 Actually keep the phone's sessions alive when the app is backgrounded
The foreground service existed, and four defects in its wiring meant it
mostly did not run. A shell opening was never announced to it — only the
ending was — so the service never came up for a shell at all. An idle
connected Files session counted as nothing. Every refresh restarted the
service, which Android 12+ answers with a crash the moment the app is
backgrounded — a transfer finishing in the pocket took the remaining
connections with it. And POST_NOTIFICATIONS was declared but never
requested, so on Android 13+ the receipt was silently invisible.

Updates while backgrounded now go through the notification manager; a
foregrounded refresh still prefers a real start, so a stop still in
flight cannot leave an orphan receipt over an unprotected process.
2026-08-09 10:14:17 +02:00
jaap-jan 810bc48d3f Tell the keep-alive wire when the Files session opens and closes
HasLiveFileSession answers the phone's foreground-service question — is
there a connection here that dying with the process would sever — and a
bucket answers no, because HTTP holds nothing open. ActivityChanged now
also fires at the end of MarkHostConnected and CloseSessionAsync, where
both facts it reads are finally true together.

Also makes the bucket pins test actually open a bucket: it never set
Remote, so CONNECT dialled the auto-selected host, and its assertions
passed only because that host had no pins either.
2026-08-09 10:14:10 +02:00
jaap-jan 671611a9a0 Merge branch 'claude/phone-pins' 2026-08-09 09:46:50 +02:00
jaap-jan 21cf77f64a Centre a button caption in the button, not just the button in its parent
ci / build and test (push) Successful in 2m28s
ci / android head (push) Successful in 3m18s
ci / desktop nightly (push) Successful in 47s
ci / api image (push) Successful in 27s
App.axaml's Button.ghost, Button.accent, Button.danger rule set
VerticalAlignment and never VerticalContentAlignment. The first places the
button in its parent; the second places the caption in the button, and its
default is Stretch — so on any of these given a fixed Height the content
presenter stretched the caption TextBlock to the whole content box, and a
TextBlock draws its line at the top of whatever it is given. Measured on the
hosts toolbar, whose three buttons are 40 pixels: nine above the ink and
twenty below it. Every box was the height it declared, which is why this read
as one of them being the wrong height — nothing was mis-sized, the labels sat
in the top third.

Center rather than a hand-tuned Padding, because the gap is the difference
between the line box and the content box and moves with the font size: these
carry 11.5 by default and the primary action overrides it to 13.5. It is the
three shapes that were missed rather than a new idiom — navseg, sesstab,
headerghost, sidebarrow, fieldrow and paneicon all state it already, as does
every one of the phone head's own button classes. 134 buttons carry these
three classes; the ones that show it are those with an explicit Height, which
is both toolbars, the drawer's Save/Cancel pair, and the import screen. A
button sized to its own caption was already right and is untouched.
HorizontalContentAlignment is deliberately left alone: it is Stretch too and
invisible on a self-sized button, and the flyout rows that are stretched wide
ask for Left themselves.

ButtonCaptionTests measures a bare Button, since Application.Styles is global
and a screen-level test would pin one toolbar and leave the rest to the same
defect. It measures the laid-out line rather than the TextBlock's arranged
bounds, and that distinction is the test: under Stretch those bounds fill the
content box and so are symmetrical whether or not the ink in them is. The
first draft asserted on them and passed against the defect; the calibration
test caught it, and against the old markup all three shapes now fail naming
their own gap.
2026-08-09 08:02:15 +02:00
jaap-jan dbf6ce1bcf Give the phone its pins: an editor section and chips on Files
The data was never the gap — HostSecret.PinnedPaths syncs and merges on both
heads, and the desktop's drawer has staged it since v5 — the phone just had
nowhere to add, remove or use a pin. Now it has both halves.

The host editor page gains a QUICK ACCESS section over the same shared
staging the drawer binds (EditorPinnedPaths, AddEditorPin, RemoveEditorPin),
with the remove target at this head's 44dp touch floor rather than the
desktop's 22-pixel close box, and no folder glyph because this head embeds no
icon font for one. The page also gains a Status line of its own: the add
command's five refusals speak through Status, and this page covers the screen
that normally draws it — a refusal nothing shows is no refusal at all.

The Files screen draws the connected host's pins as chips between the
breadcrumb and the listing, each running GoRemoteCommand exactly as a crumb
does. They are captured at connect, like ConnectedTo and the session facts
before them; a bucket gets none, having no HostSecret to pin anything on.
Covered headlessly in ShellFlowTests — connect populates, disconnect clears,
a bucket stays empty — and by manual checks 8.18 and 8.19, whose phase
preamble also stops claiming thirteen checks when it lists twenty-one.
2026-08-08 23:09:57 +02:00
jaap-jan 242280ce6b Name a root chip after the root, not with its whole path
ci / build and test (push) Successful in 2m15s
ci / android head (push) Successful in 3m24s
ci / desktop nightly (push) Successful in 43s
ci / api image (push) Successful in 33s
The chip derivation was TrimEnd(separator), which is a name only for the
Windows drives it was written against: on Unix it made the / chip an empty
pill and the home chip the entire home path, drawn at full width in a header
column nothing bounds. A home directory deep enough — CI's per-job HOME is
forty-six characters — had that one chip walk the header's own buttons out of
the window at the session shell's 472-pixel budget, which is the half of the
runner's red suite the star-column fix before this one did not reach.

A chip says C:, /, ~, or a mount's last segment now; the full path stays on
its command parameter, where length costs nothing. RootChipNameTests pins the
derivation with fixed strings, so it no longer takes a machine with a deep
profile path to ask the question.
2026-08-08 22:38:41 +02:00
jaap-jan de0b5f12ae Let the pane headers' paths actually trim
ci / build and test (push) Failing after 2m11s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m18s
TextTrimming only acts when measure hands the block a finite width, and a
horizontal StackPanel never does — it measures every child at infinity and an
Auto grid column passes the full answer on. So both SFTP pane headers grew
with their path, and a directory deep enough pushed the header's own icon
buttons past the window's edge at the session shell's 472-pixel budget.

The layout suite has said so on every CI run since v5b landed, and nowhere
else: the runner's per-job HOME is a 46-character path, which is what the
local pane opens on, and every developer machine's short profile path left
the same test green. The path sits alone in the star column now — bounded
width, working ellipsis — and the narrowest-budget test pins both panes to
sixty-character paths so the question is asked on every machine alike;
against the old markup that test fails on Windows too.
2026-08-08 22:28:26 +02:00
jaap-jan 009b35e069 Cover the mixed-keychain regroup refusal headlessly
The two-keychain branch of VaultViewModel.RegroupChosenHosts had no test:
7.6a's manual walk was the only thing asserting that a mixed set gets the
sentence instead of the picker. A ShellFlowTests case now ticks a host in
each of two vaults, reads the refusal off the status line, and shows the
same command opening the picker once the set is one keychain's again.
Check 7.6a cites the test and keeps only the popup wiring for the eye.
2026-08-08 22:19:46 +02:00
jaap-jan d32f5609e3 Rewrite checks 7.6/7.6a for the picker that replaced the drag
ci / build and test (push) Failing after 2m9s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m29s
The drag onto a group card went with v5's flat sections; filing a set
is the chosen-hosts menu's "Change group..." picker on both heads now.
The two checks walk that route instead and say honestly what
ShellFlowTests and ScreenLayoutTests already cover, what only a real
popup can show, and that nothing automated raises the mixed-keychain
refusal. The numbering preamble's example swaps to citations that
still exist.
2026-08-08 22:12:44 +02:00
45 changed files with 4598 additions and 270 deletions
+88
View File
@@ -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
+40 -4
View File
@@ -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
+67
View File
@@ -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>
+125
View File
@@ -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>
+9 -1
View File
@@ -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
+79 -3
View File
@@ -30,7 +30,10 @@ verified is that it compiles, links, packages, and carries the right natives.
transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it transfers protected by a **foreground service**. File transfer is not in the first scope; when it arrives it
is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived, is **one remote pane** with Android's document picker for moving files in and out. *It has since arrived,
both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out, both ways:* the pane, the queue, `ACTION_OPEN_DOCUMENT` going in and `ACTION_CREATE_DOCUMENT` coming out,
with the foreground service now counting transfers as well as shells. with the foreground service now counting transfers as well as shells — and, since, an idle-but-connected
Files session as well, which a transfer count alone was blind to. *Corrected the same round:* the service's
other half — a shell's own opening — had never been wired to anything at all, so a shell survived only for
as long as the app stayed foreground; see [Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service).
**What was actually checked**, so the rest can be read with the right amount of trust: **What was actually checked**, so the rest can be read with the right amount of trust:
@@ -235,6 +238,37 @@ The parts that are definitely different are the on-screen keyboard, and the fact
needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an needs Ctrl, Esc, Tab and arrows that the software keyboard does not offer — every Android SSH client ships an
accessory key row for this. That is UI work, not porting. accessory key row for this. That is UI work, not porting.
> **⚠️ Corrected by the build. The data plane assumed a renderer that attaches once and lives forever, and
> that assumption is WebView2's truth, not Android's.** Desktop's WebView2 process starts with the window and
> dies with it; `TerminalDataPlane` was written to that reality — one socket, attached once,
> `Interlocked.Exchange`-guarded against a second attach ever happening at all. On a phone the WebView's own
> renderer process is a separate thing from the app process the foreground service above is keeping alive,
> and Android kills *that* independently — under memory pressure, or simply for being backgrounded — with no
> foreground service able to save it. The page then reloads with a fresh socket, and three things broke on
> that reload before this was found: the second attach was refused outright (`409 Conflict`), because a
> second valid upgrade could only mean a bug or a hostile second process, never our own page coming back; a
> send into the dead first socket threw, and that exception unwound `TerminalSessionPump`'s flush loop,
> freezing the still-live shell behind it — `LiveSessionCount` kept counting a session nothing would ever
> drain again; and every byte sent while no page was attached had already spent flow-control credit that no
> acknowledgement could ever return, so a session outliving 256 KiB of output into a dead page stalled for
> good regardless of the other two. Waiting for the old socket to notice it was dead and close on its own
> was never going to be enough either — a killed renderer sends no TCP FIN, so the old receive loop could sit
> unaware for the whole 30-second keepalive.
>
> Fixed as a takeover rather than a guard: a second valid upgrade — origin, token and subprotocol all
> checked exactly as before — now displaces whatever socket was attached instead of being refused, since
> only this app's own page ever knows the token, so a second valid attach *is* that page, back again.
> `TerminalDataPlane.SendAsync` no longer lets a dead-socket send escape as a fault; it reads as "nobody
> listening," same as no socket being attached at all. `TerminalWorkspace` resets each live session's credit
> window on every attach and resends its `SessionOpened` frame, flagged as a replay, so the fresh page
> rebuilds the pane and the pump stops waiting on an acknowledgement that was never coming. And
> `terminal.js`'s socket now retries itself, forever, with backoff, instead of reporting the connection
> failed and stopping — the page dies with the app anyway, so there is no case where retrying is the wrong
> call. What is **not** recovered, and says so rather than pretending otherwise: scrollback across a page
> reload. It lived in the page's own DOM, and a reloaded page is a new DOM. The replay banner — *"the view
> reconnected; earlier output stayed on the host"* — is that honesty put where the person looking at the
> terminal will actually read it, not buried in a log.
--- ---
## Decisions taken ## Decisions taken
@@ -280,13 +314,50 @@ What is desktop-only is the *left* pane — `LocalDirectory`, the drive list, th
### Sessions survive backgrounding, via a foreground service ### Sessions survive backgrounding, via a foreground service
A persistent notification for as long as a shell or a transfer is live. A persistent notification for as long as a shell, a transfer, or an idle-but-connected Files session is
live.
It costs the user a notification and some battery. It buys the behaviour the desktop client already promises It costs the user a notification and some battery. It buys the behaviour the desktop client already promises
and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was and documents — that a shell outlives a vault lock, and that a transfer finishes — and the alternative was
to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a to make `TerminalWorkspace`'s guarantee desktop-only, which is a worse thing to have to write down than a
notification is to look at. notification is to look at.
**Three corrections found after the first cut shipped, all in the wiring rather than the design:**
- **A shell opening never started the service.** `SessionKeepAlive` heard `TerminalWorkspace.SessionEnded`
and refreshed on that, but nothing announced the opposite event — so a user who opened a shell and
backgrounded the app immediately had no foreground service at all, and Android was free to kill the
process holding it. `MainWindowViewModel.TerminalSessionOpened` is now wired the same way in
`App.axaml.cs`'s `ComposeKeepAlive`.
- **A connected-but-idle Files session counted as nothing.** A host open on the Files screen with no
transfer moving is a live SFTP connection a dying process would sever, and the old two-argument
`Reconcile(liveSessions, activeTransfers)` had no way to hear about it. `TransfersViewModel.HasLiveFileSession`
`IsConnected` with a real `ConnectedCipher`, which a bucket never has — is the third fact `Reconcile` now
takes.
- **Refreshing the notification restarted the service, which throws when backgrounded.** `Reconcile` called
`StartForegroundService` on every refresh, including the common case of a service that was already
running. On API 31+ that throws `ForegroundServiceStartNotAllowedException` the instant the app is
backgrounded — a transfer finishing in the pocket, one of two shells dying — which crashed the process and
took every session with it. `SessionForegroundService` now tracks whether it is already running and, when
it is, posts the updated notification through `NotificationManager.Notify` instead of asking Android to
start anything.
**The notification permission is requested, not just declared.** API 33+ requires `POST_NOTIFICATIONS` at
runtime or the receipt is silently invisible — the service still runs, but nothing on screen says so.
`SessionForegroundService.Reconcile` asks for it the first time in this process there is actually something
to show, at most once, with no result read back: a refusal costs the notification and nothing else, which is
what the manifest's own comment on the permission says.
**And a fourth correction, found by the notification refusing to come down.** "1 shell connected" outlived
the shell, both ways a shell can close. A shell exiting on its own announced `SessionEnded` from inside its
run's own finally block — where the run task is by definition not yet complete, so the
`LiveSessionCount` the keep-alive reads from that event still counted the session that had just ended, and
nothing fired afterwards to correct it. A tab closed by hand announced nothing at all, by a recorded
decision that assumed every subscriber was the closer. Both reversed in `TerminalWorkspace`: the end is now
announced from a continuation after the run has actually completed, and `CloseSessionAsync` announces too,
after its own drain — the event's remark carries the reversal, and `SessionEnded`'s subscribers were all
already "reconcile to reality" handlers for which a second announcement is harmless.
### Phone first ### Phone first
About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum About 360dp wide. The tablet route was cheaper — a landscape tablet is close to the existing 880×560 minimum
@@ -523,7 +594,12 @@ go at 360dp:
stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of stopping it from a count rather than a lifecycle. `TerminalWorkspace.LiveSessionCount` is the source of
truth deliberately: it already knows that a session whose shell exited is not live, which a counter truth deliberately: it already knows that a session whose shell exited is not live, which a counter
incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the incremented on open would not, and a phone showing "1 shell connected" over nothing would be exactly the
dishonesty the unlock screen's count exists to prevent. dishonesty the unlock screen's count exists to prevent. *Corrected since:* the opened half of a shell's
lifecycle was never wired in, so the service could never come up for a shell at all; an idle-but-connected
Files session now counts as a third live fact rather than nothing; a refresh while backgrounded updates
the notification in place instead of restarting the service, which the API throws on; and
`POST_NOTIFICATIONS` is now actually requested rather than merely declared. See
[Sessions survive backgrounding](#sessions-survive-backgrounding-via-a-foreground-service) for all four.
7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design, 7. ~~**The interface**, phone-first.~~ **Done for the decided scope** — all seven screens of the design,
plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and plus the two states the design does not draw because it starts at an enrolled phone (naming a server, and
choosing a passphrase). choosing a passphrase).
+1 -1
View File
@@ -258,7 +258,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". | > | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
> | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. | > | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. |
> | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. | > | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. |
> | QUICK ACCESS's editor, on the phone | **Deferred; the data is not.** `HostSecret.PinnedPaths` is shared, synced and merged on both heads, so a pin made on the desktop reaches the phone and back — Android just has nowhere yet to add or remove one itself. | > | QUICK ACCESS's editor, on the phone | **Shipped, over the same shared data the deferral above described.** The phone's host editor draws its own QUICK ACCESS section on the same staged `VaultViewModel.EditorPinnedPaths` the desktop's drawer binds — a row per pin, an add field and button, and a remove target sized to this head's 44dp touch floor rather than the desktop's 22-pixel close box. `AddEditorPinCommand`'s refusals surface through a `Status` line the editor page draws for itself, since that page covers the whole screen and the list behind it draws its own `Status` off-screen for as long as it is open. The pins themselves reach a second surface this head has that the desktop does not need: `TransfersViewModel.ConnectedPinnedPaths` carries them as chips on the Files screen while connected, and tapping one runs `GoRemoteCommand` — the same command the breadcrumb trail already used to navigate. Two deviations from the desktop, both named where they land: the chip row is a snapshot taken at connect rather than a live follow of the vault, so a pin edited mid-session shows up on the next connect rather than this one; and the editor's own hint sentence says the pins appear on the Files screen, not above a terminal — this head has no terminal strip for them to sit above, the same honesty the row below already states for the desktop's own hint. |
> | Collapse All beside every section's own collapse chevron | Bound on every heading's view model and shown on only the first — `SidebarGroupHeader.IsFirstBoardSection` is what a virtualised list of sections uses in place of a control of the board's own that would otherwise have to sit above all of them. | > | Collapse All beside every section's own collapse chevron | Bound on every heading's view model and shown on only the first — `SidebarGroupHeader.IsFirstBoardSection` is what a virtualised list of sections uses in place of a control of the board's own that would otherwise have to sit above all of them. |
> | The QUICK ACCESS hint's claim that pins live in a sidebar | "Pinned folders appear above the terminal for this host." — no sidebar exists on this screen for the sentence to point at, so the shipped hint says where they actually draw. | > | The QUICK ACCESS hint's claim that pins live in a sidebar | "Pinned folders appear above the terminal for this host." — no sidebar exists on this screen for the sentence to point at, so the shipped hint says where they actually draw. |
> | The mock's "Saving to **DodoTech ▾** vault" subtitle, with a picker's chevron inside a sentence | The pre-existing `DrawerSubtitle` wording — the vault's name alone, unchanged by this pass. A chevron inside running text implies the text itself is the control, which it is not: the vault picker is its own element, shown only while creating and only above one writable vault, as it always has been. | > | The mock's "Saving to **DodoTech ▾** vault" subtitle, with a picker's chevron inside a sentence | The pre-existing `DrawerSubtitle` wording — the vault's name alone, unchanged by this pass. A chevron inside running text implies the text itself is the control, which it is not: the vault picker is its own element, shown only while creating and only above one writable vault, as it always has been. |
+327 -37
View File
@@ -17,7 +17,7 @@ Three constraints put things on this list, and they are worth knowing before add
Each item says what to do, what a pass looks like, and what a failure would mean. Each item says what to do, what a pass looks like, and what a failure would mean.
**On the numbering.** A check keeps its number for life, because code comments and other documents cite them **On the numbering.** A check keeps its number for life, because code comments and other documents cite them
`HostGridTests` sends a reader to 7.6, `platform-flags.md` to 3.63.8. A check inserted later therefore `platform-flags.md` sends a reader to 3.63.8, ADR 0013 to 16.4. A check inserted later therefore
takes a letter rather than pushing its neighbours along: 3.2a and 3.2b sit between 3.2 and 3.3 and always takes a letter rather than pushing its neighbours along: 3.2a and 3.2b sit between 3.2 and 3.3 and always
will. Add in the same way, and keep each one next to the check it belongs beside; a gap in the numbers means will. Add in the same way, and keep each one next to the check it belongs beside; a gap in the numbers means
a phase had nothing left for a person to do, which is the good outcome rather than an omission. a phase had nothing left for a person to do, which is the good outcome rather than an omission.
@@ -930,52 +930,66 @@ Start 7.1's slow connection and press GIVE UP (or the tab's cross) while it is s
opened — it is a real shell, and one running with nothing naming it would be worse than one that comes opened — it is a real shell, and one running with nothing naming it would be worse than one that comes
back. back.
### 7.6 Dragging a host onto a group card · **least covered, like all drag and drop** ### 7.6 Filing a ticked host through "Change group…" · **the drag's replacement**
Make two groups and file a host into one. Drag a host card up onto the other group's card. The gesture this number used to describe — a host card dragged onto a group card — went with the group cards
themselves in v5's flat-sections rework: every group is a heading now, there is nothing on the board to drop
onto, and no gesture replaced the drag. Filing from the board is the chosen-hosts menu's **Change group…**,
the phone's route become both heads' — see `VaultViewModel.ConfirmRegroupChosenHostsAsync`'s own remarks.
(A single host can also still be filed through its own editor's GROUP picker; 3.2 walks that route.)
**Pass:** the group card under the pointer takes a two-pixel accent border while the pointer is over it, the Make two groups and file a host into one. Ctrl-click that host so it takes its ✓, right-click it, and choose
cursor shows a move rather than a refusal, and the drop files the host — **the card leaves the grid**, going **Change group…**.
inside the group it was dropped on, the host counts under both group cards change, and the status line says
where it went. That sentence is the only thing left saying so, which is why it is worth reading: the card
itself is on the level below now, and nothing is selected once it has gone.
**Also check three refusals**, each of which must show the "no" cursor and mark nothing: over the card of the **Pass:** the entry is on the menu only while something is ticked — right-click an unticked card first and
group the host is *already* in — type its name into the find box first, which is what brings a filed card the menu is the ordinary four (Connect, Details…, Edit…, Delete…) with no filing entry among them. Choosing
back to this level; over another **host** card, which is deliberately not a target now that there are no it opens the CHANGE GROUP panel **above the board, not over it** — the ticked card stays in view, "1 chosen"
headings to say which group it would mean; and over the empty space around the cards. sits beside the panel's heading — and the ComboBox reads **"No group"**, not the group the host is already
in; drop it down and every group in this keychain is listed after it.
**The targets are the cards on screen, which are one level** — see 3.2a. Filing into a group nested under **Then pick the other group and press FILE.** **Pass:** the panel folds, the card is drawn under the other
another means opening the outer one first, exactly as moving a file into a subfolder does. heading and the headings' counts follow, the tick is off, and the status line says where it went — `Filed 1
host(s) under …`.
**And getting a host back out** is the host's own editor — pick "No group" in its picker. There is no **Then take it back out:** tick it again, open **Change group…**, and press FILE with the ComboBox untouched.
UNGROUPED target on the desktop any more, because there is no UNGROUPED heading for it to be. **Pass:** the host is unfiled, back under the "No group" heading. The picker opening on "No group" is a
decision rather than an oversight — unfiling a run of machines is exactly as common as filing them, and the
box says what FILE will do before it is pressed — but it is also why the box has to be *read*: FILE never
means "keep things as they are". CANCEL, tried once, folds the panel and keeps the ticks.
**And a drag held near the top or bottom edge of the grid scrolls it**, which is what makes this usable at **Failure means:** the write and its guards are all covered headlessly — the filing by
all with forty machines: the group cards are the first thing in the scroller, and a drag cannot use the `ShellFlowTests.ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce`, the refusal under an open editor by
wheel. The pointer has to keep moving inside the band — a stationary pointer gets no drag events. `RegroupingTheChosenHosts_IsRefusedWhileTheEditorIsOpen`, the panels' one-at-a-time rule by
`TheActionBarsPanels_TakeEachOthersPlaceRatherThanStacking`, and the panel's fit by
`ScreenLayoutTests.TheHostsScreenFitsWithTheChosenHostsGroupPanelOpen`. What none of them can see is a real
popup: the menu entry and the ComboBox's dropdown both live in popups, reaching the vault through the same
`#Board` indirection 7.9 explains. A menu missing the entry, or a FILE that files nothing, is that wiring —
7.9's class of failure, on the one entry that opens a panel rather than acting at once.
**Failure means:** headless Avalonia cannot synthesise a platform drag, so the picking up, the cursor and ### 7.6a Filing a whole set at once, and the one refusal · **the refusal needs a second vault**
the drop are covered by nothing. What *is* automated is the decision each drag event takes —
`HostGridTests.TheGroupCardsAreWhatAcceptsADroppedHost` raises a real `DragOver` over both kinds of card —
and the write at the end, `ShellFlowTests.MovingAHostToAGroup_FilesItAndTakesItOffTheLevelItCameFrom`.
### 7.6a Dragging a whole set onto a group card · **also uncovered, and the same reason** Tick three hosts (7.7a is how), right-click one of the three, choose **Change group…**, pick a group, and
press FILE.
Tick three hosts (7.7a is how), then pick one of the three up and drag it onto a group card. **Pass:** the same panel, its heading now saying "3 chosen", and the write moves **all three** — every card
under the picked heading, the status line counting them, and the ticks gone once it is done. A host that was
never ticked stays exactly where it was.
**Pass:** the card marks itself exactly as it does for one host, and the drop files **all three** — the **Then, with a second vault:** tick one host from each keychain and choose **Change group…** again.
status line says how many, and all three leave the level. Ticking nothing and dragging a single card still
files that one card, which is what this gesture has always done.
**And a card that is not in the set** drags alone: press one of the unticked cards and the ticks come off **Pass:** no panel opens, and the status line says the hosts are in more than one keychain and a group
before the drag starts, so what lands is the one machine that was under the pointer. belongs to one. The refusal is whole and it is early — raised when the picker is asked for, over the whole
set, rather than after a group was picked from a list that could only ever have been one keychain's. Filing
across that line would leave everyone else in the shared vault seeing a machine filed under nothing.
**Failure means:** a drag that filed one of three is the payload having been built from the card rather than **Failure means:** the set's write is `ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce` again — one
from the set — the thing this gesture must never do quietly, since the other two stay behind looking filed. command reads the whole set, so "filed one of three" has no half-gesture to hide in the way the old drag's
The write is `ShellFlowTests.DroppingTheChosenHostsOnAGroupCard_FilesEveryOneOfThem` and the drag event's payload did. The refusal is covered headlessly too:
answer is `HostGridTests.AGroupCardTakesAWholeSetOfDraggedHosts`; the platform's half of it is covered by `RegroupingHostsChosenAcrossTwoKeychains_IsRefusedBeforeThePickerOpens` ticks a host in each of two
nothing, as 7.6 explains. keychains and asserts no picker opens and the sentence is on the status line. What is left for the eye is
7.9's wiring, as in 7.6 — and a panel that does open over a mixed set is the worse half: it would offer one
keychain's groups for another keychain's machines, which is the half-filed set the refusal exists to
prevent.
### 7.7 A click still selects, and a double click still connects ### 7.7 A click still selects, and a double click still connects
@@ -1087,7 +1101,7 @@ time)" — is the worse failure of the two: it rebinds a host as a side effect o
## Phase 8 — Adding and removing on the phone's host list ## Phase 8 — Adding and removing on the phone's host list
Thirteen checks, and the reason there are thirteen rather than none is worth stating: **the layout suite Twenty-one checks, and the reason there are twenty-one rather than none is worth stating: **the layout suite
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's `DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
application, dispatcher and platform are one-shot process globals, so a second head cannot share the application, dispatcher and platform are one-shot process globals, so a second head cannot share the
@@ -1404,6 +1418,60 @@ destinations, so switching under a live one would show a screen titled S3 listin
the row — that screen keeps its own copy of the host list, so it has to be re-found there by entity id the row — that screen keeps its own copy of the host list, so it has to be re-found there by entity id
rather than handed the vault's object. rather than handed the vault's object.
### 8.18 QUICK ACCESS in the phone's host editor
Open a host's editor and scroll to QUICK ACCESS.
**Pass:** an empty list, an add field and an ADD button. Type `/var/www/app` and press ADD.
**Pass:** a row appears carrying that path and a ✕ at least 44dp on a side. Type the same path again and
press ADD.
**Pass:** nothing is added, and a sentence appears on the page saying the path is already pinned — this page
covers the whole screen while it is open, so that sentence is this page's own `Status` line rather than the
one the host list draws above HOSTS, which is off-screen right now. Clear the box and press ADD with nothing
typed.
**Pass:** a sentence saying a pinned path cannot be blank, in the same place.
Press the ✕ on the pinned row, then SAVE.
**Pass:** back on HOSTS with the pin gone. Open the editor on that host again.
**Pass:** QUICK ACCESS is empty — the removal was saved, not merely staged. Re-pin `/var/www/app`, SAVE, and
check the same host on the desktop.
**Pass:** the pin is there. `HostSecret.PinnedPaths` is shared and merged like every other field on a host,
so nothing about this page keeps its own copy.
**Failure means:** a refusal that changes nothing on screen is `AddEditorPinCommand` writing to `Status`
with nothing on this page bound to it — the honesty rule broken silently, since the command still behaves
correctly and only the telling of it is missing. A pin gone after SAVE-then-reopen but present on the
desktop is `BuildHost` not reading `EditorPinnedPaths`, or `EditSelectedHost` not loading it back in.
### 8.19 Pin chips on the Files screen
Pin a folder on a host, then connect to it on the files screen (SFTP), either directly or via **Connect via
SFTP**.
**Pass:** once connected, a row of chips appears between the breadcrumb and the listing, one per pin, each
at least 44dp tall. Tap one.
**Pass:** the listing navigates straight to that directory, the same as tapping a breadcrumb crumb does.
Disconnect, then connect to a host with nothing pinned.
**Pass:** no chip row at all — not an empty one. Connect to a bucket instead.
**Pass:** still no chip row, on any bucket. A bucket has no `HostSecret` underneath it and so nothing to
pin.
**Failure means:** chips that do not move the listing are the row's `GoRemoteCommand` binding pointed at the
wrong `DataContext` — see the `$parent[views:FilesScreen]` escape every other command in this file uses. A
chip row surviving a disconnect, or appearing under a bucket, is `TransfersViewModel.ConnectedPinnedPaths`
not being cleared in `CloseSessionAsync` or `OpenBucketAsync`. A chip row missing a pin added *after* this
connect is not a bug — see `ConnectedPinnedPaths`'s own remark on why this is a snapshot rather than a live
follow, and try disconnecting and reconnecting instead.
--- ---
## Phase 9 — Tag chips and the picker ## Phase 9 — Tag chips and the picker
@@ -1677,6 +1745,57 @@ is a terminal that answers the buttons and ignores the keyboard: it reads as the
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first 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
Open a shell, close its tab, then open a different one from HOSTS.
**Pass:** the new terminal renders and takes input straight away — no stuck "Connecting…" status, no blank
pane that never receives the prompt.
**Failure means:** `TerminalDataPlane` refused the page's reattach. The renderer's `WebSocket` does not
survive a tab going from one to zero and back to one on every device, and a host that answers a second valid
upgrade with `409 Conflict` instead of taking the socket over leaves every terminal after the first
permanently unreachable — see the correction in `docs/android-port.md`'s terminal section.
### 11.12 A backgrounded shell survives its renderer being killed · **needs several minutes, or developer tooling**
With a shell open and something worth reading in its scrollback, background the app (home button, not back)
for several minutes — long enough for Android to consider reclaiming it — then return. If the device exposes
it, forcing a stop of the WebView renderer process from Developer Options while backgrounded is the more
reliable way to trigger the same thing on demand rather than waiting on the OS's own judgement. Either way,
type something once you are back.
**Pass:** one of two honest outcomes, both good. Either the pane is exactly as it was — the renderer process
survived, so nothing needed to happen — or the pane is empty but for a dim line reading `── the view
reconnected; earlier output stayed on the host ──`, meaning the page reloaded and reattached. In both cases
what is typed now reaches the shell, and the shell is still the same one — not a new tab, not a reconnect
sheet, no "Connecting…" status stuck on screen.
**Failure means:** if the status stays stuck or nothing typed arrives, the renderer's socket did not retry
itself — see `terminal.js`'s `connect()` and its backoff. If the pane came back empty with **no** banner, a
session that survived a reload is being shown as though its scrollback had too, which is not true and is
worse than saying nothing: the banner exists so this is never silently wrong. If typing does nothing but the
banner is there, the session's credit window was not reset on reattach and the shell is frozen behind it —
see `TerminalWorkspace.ReplayAfterAttachAsync`.
--- ---
## Phase 12 — Shared vaults: the operations that span two accounts ## Phase 12 — Shared vaults: the operations that span two accounts
@@ -2007,9 +2126,63 @@ Queue several files in each direction, put the phone to sleep with the screen of
notification goes away when the last one does — with no shell open. With a shell open it stays, because that notification goes away when the last one does — with no shell open. With a shell open it stays, because that
is what it was already for. is what it was already for.
Now, separately: open a shell to the host, press the home button (backgrounding rather than sleeping — the
distinction matters, because backgrounded is the state in which Android is free to kill a process no
foreground service is protecting), wait thirty seconds with the shell doing nothing, and return.
**Pass:** the notification stayed up the whole time, and the shell is exactly where it was — same scrollback,
same prompt — with typing reaching the host immediately. Exit the shell.
**Pass:** the notification goes with it, once nothing else is open. Open another shell and close it from the
shells strip's ✕ instead of exiting — the notification comes down for that route too, which is the route
that used to leave it up: a deliberate close announced nothing to the keep-alive at all, and a shell exiting
on its own was announced while the count still included it.
**Failure means:** an upload that stalls with the screen off is the count not reaching **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. afterwards is `ActivityChanged` not being subscribed — the other end of the same wire. A shell that has
disconnected on return is `MainWindowViewModel.TerminalSessionOpened` never reaching `SessionKeepAlive` — the
service only ever heard about a shell *ending*, so it never came up for one in the first place. A
notification still saying "1 shell connected" after the shell is gone — by either route — is
`TerminalWorkspace.SessionEnded` firing before the run completed, or a close not announcing; see
`AnnounceEndedAsync` and the event's own remark.
### 14.6a A Files connection with nothing moving still survives backgrounding
Connect to a host on the Files screen with no transfer queued — just browse to somewhere and stop. Note the
directory shown, then background the app, wait thirty seconds, and return.
**Pass:** the notification stayed up the whole time (check the shade if the return is too quick to see it
directly), and the pane is exactly where it was — the same listing, the same breadcrumb — with no reconnect
needed.
**Failure means:** `TransfersViewModel.HasLiveFileSession` not reaching `SessionKeepAlive`, so an idle but
still-open SFTP connection read as nothing running at all and the process was free to die under it.
### 14.6b The notification permission is asked for once, at the first thing worth showing · **needs Android 13+**
On a device running Android 13 or later, on a fresh install that has never connected to anything, open a
shell or the Files screen for the first time.
**Pass:** a system dialogue asking to allow notifications appears at that moment — not at launch, and not
before this first connect. Answer it either way; the connection completes regardless, and background the app
afterwards to confirm nothing else changed about it.
**Failure means:** the dialogue appearing at launch is asking before there is anything on screen to justify
it. Never appearing at all on API 33+ is the harder failure to notice, because nothing else surfaces it —
the service still starts and still holds the process open, only the receipt is invisible. See
`SessionForegroundService.RequestNotificationPermission`.
### 14.6c Refusing the permission costs the notification and nothing else
Continuing from 14.6b: choose **Don't allow** on the system dialogue. Queue a transfer, or open a shell, and
background the app.
**Pass:** no notification appears anywhere, but the transfer still finishes, or the shell is still there on
return, exactly as in 14.114.6a.
**Failure means:** anything disconnecting or failing here is the permission refusal being read as though it
had refused the service itself, rather than only the notification Android draws for it.
### 14.7 SAVE FILE writes where you pointed it, and the file opens ### 14.7 SAVE FILE writes where you pointed it, and the file opens
@@ -2423,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
View File
@@ -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,
+404
View File
@@ -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
+51 -22
View File
@@ -90,32 +90,61 @@ public sealed partial class DodoSshApp : Avalonia.Application
shell.DataContext = viewModel; shell.DataContext = viewModel;
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a // Difference 2, wired up in its own method purely for length — see ComposeKeepAlive for what it
// shell outlives a vault lock — true on a platform that stops backgrounded processes. // does and why.
// ComposeKeepAlive(workspace, viewModel);
// The transfer count is real now that the document picker gives this head a way to start one, and
// it is the half that matters most here: a shell survives backgrounding because somebody is looking
// at it, and an upload has to survive precisely when nobody is — the screen is off and the phone is
// in a pocket. Queued counts as active, so putting five files in the queue and locking the phone
// moves five files.
//
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
// because it is subscribed to the workspace, which lives as long as the process.
var keepAlive = new SessionKeepAlive(
workspace,
activeTransfers: () => viewModel.Transfers.ActiveTransfers);
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
// announces transfers appearing and finishing. Without this the notification would come up when an
// upload started and stay up after it finished, which is the failure this class exists to prevent.
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
keepAlive.Refresh();
return shell; return shell;
} }
/// <summary>
/// Wires up the foreground service that makes <c>TerminalWorkspace</c>'s promise — that a shell outlives
/// a vault lock — true on a platform that stops backgrounded processes.
/// </summary>
/// <remarks>
/// <para>
/// Split out of <see cref="Compose"/> for length rather than for reuse; there is exactly one caller.
/// </para>
/// <para>
/// The transfer count is real now that the document picker gives this head a way to start one, and it
/// matters exactly when nobody is looking: a shell survives backgrounding because somebody opened it,
/// and an upload has to survive precisely when nobody is — the screen is off and the phone is in a
/// pocket. Queued counts as active, so putting five files in the queue and locking the phone moves five
/// files. <c>holdsFileSession</c> covers the third case a count alone cannot: a host connected on the
/// Files screen with no transfer moving is still a live SFTP session that backgrounding would sever, and
/// <c>TransfersViewModel.HasLiveFileSession</c> is the existing fact — <c>IsConnected</c> with a real
/// cipher, which a bucket never has — that answers whether one is open.
/// </para>
/// <para>
/// <c>keepAlive</c> is a local rather than a field, matching the desktop head: an Avalonia Application
/// has no disposal hook, so a field holding a disposable would have nowhere honest to release it. It
/// stays alive because it is subscribed to the workspace, which lives as long as the process.
/// </para>
/// </remarks>
private static void ComposeKeepAlive(TerminalWorkspace workspace, MainWindowViewModel viewModel)
{
var keepAlive = new SessionKeepAlive(
workspace,
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession);
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
// Without this the notification would come up when an upload started and stay up after it
// finished, which is the failure this class exists to prevent.
viewModel.Transfers.ActivityChanged += (_, _) => keepAlive.Refresh();
// The half that was missing until now: a shell opening. SessionKeepAlive already heard the
// workspace announce a session ending, but nothing announced the opposite — a user who opened a
// shell and backgrounded the app had no foreground service at all, because the only wire in was the
// one for taking it down. TerminalSessionOpened is that other half, forwarded from
// VaultViewModel.SessionOpened, and without this line the service could never come up for a shell
// in the first place, which was precisely the promise this whole arrangement exists to keep.
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
keepAlive.Refresh();
}
/// <summary> /// <summary>
/// Writing to this phone's clipboard. /// Writing to this phone's clipboard.
/// </summary> /// </summary>
@@ -6,7 +6,7 @@ using global::Android.OS;
namespace DodoSSH.Client.Android.Platform; namespace DodoSSH.Client.Android.Platform;
/// <summary> /// <summary>
/// Keeps the process alive for as long as a shell or a transfer is live. /// Keeps the process alive for as long as a shell, a transfer, or a connected Files session is live.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -41,6 +41,26 @@ internal sealed class SessionForegroundService : Service
private const string ChannelId = "dodossh.sessions"; private const string ChannelId = "dodossh.sessions";
private const int NotificationId = 1; private const int NotificationId = 1;
// API 33+ requires the request to name a code the RequestPermissionsResult callback would be handed
// back — 1 is fine because this head never implements that callback at all, see
// RequestNotificationPermission's own remark for why a result is not worth listening for.
private const int NotificationPermissionRequestCode = 1;
/// <summary>
/// Whether <see cref="OnStartCommand"/> has run for this process without a matching
/// <see cref="OnDestroy"/> since — i.e. whether Android currently considers this service foregrounded.
/// </summary>
/// <remarks>
/// Volatile because <see cref="Reconcile"/> can run on whatever thread called
/// <see cref="SessionKeepAlive.Refresh"/>, while this is set from the binder thread Android delivers
/// service lifecycle callbacks on — two threads with no other synchronisation between them, and a stale
/// read here is the difference between updating a notification in place and calling
/// <c>StartForegroundService</c> on a service that is already running, which is what defect 3 was.
/// </remarks>
private static volatile bool running;
private static bool notificationPermissionRequested;
/// <remarks> /// <remarks>
/// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is /// A bound service would tie the sessions' lifetime to a binding, which is the opposite of what is
/// wanted here: the point is that they outlive whatever the user does with the interface. /// wanted here: the point is that they outlive whatever the user does with the interface.
@@ -49,7 +69,9 @@ internal sealed class SessionForegroundService : Service
public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId) public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
{ {
StartForeground(NotificationId, BuildNotification(intent?.GetStringExtra("summary") ?? "Working")); running = true;
StartForeground(NotificationId, BuildNotification(this, intent?.GetStringExtra("summary") ?? "Working"));
// NotSticky: if Android does kill this process, the SSH connections died with it and there is // NotSticky: if Android does kill this process, the SSH connections died with it and there is
// nothing to resume. Restarting the service would produce a notification claiming sessions that no // nothing to resume. Restarting the service would produce a notification claiming sessions that no
@@ -58,14 +80,36 @@ internal sealed class SessionForegroundService : Service
return StartCommandResult.NotSticky; return StartCommandResult.NotSticky;
} }
/// <inheritdoc />
/// <remarks> /// <remarks>
/// The other half of <see cref="running"/>. Android calls this whether the service stopped itself or
/// was stopped from outside — <see cref="Reconcile"/>'s down case calls <c>StopService</c> rather than
/// clearing the flag directly, so this override is the one place that actually knows the service has
/// gone, matching how <see cref="OnStartCommand"/> is the one place that knows it has come up.
/// </remarks>
public override void OnDestroy()
{
running = false;
base.OnDestroy();
}
/// <remarks>
/// <para>
/// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android /// Low importance on purpose. This notification is a receipt, not an alert — it exists because Android
/// requires one, and because the user is entitled to know the app is holding connections open. Making /// requires one, and because the user is entitled to know the app is holding connections open. Making
/// it buzz would be a notification about nothing having happened. /// it buzz would be a notification about nothing having happened.
/// </para>
/// <para>
/// Static, taking the <see cref="Context"/> it needs rather than reading <c>this</c>: the instance path
/// through <see cref="OnStartCommand"/> passes the service itself, and the in-place update path through
/// <see cref="Reconcile"/> has no service instance at all — only <see cref="PhoneEnvironment.Require"/>
/// — because posting to an already-running notification never touches the service's own lifecycle.
/// </para>
/// </remarks> /// </remarks>
private Notification BuildNotification(string summary) private static Notification BuildNotification(Context context, string summary)
{ {
var manager = (NotificationManager)GetSystemService(NotificationService)!; var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
if (OperatingSystem.IsAndroidVersionAtLeast(26)) if (OperatingSystem.IsAndroidVersionAtLeast(26))
{ {
@@ -79,12 +123,12 @@ internal sealed class SessionForegroundService : Service
} }
var reopen = PendingIntent.GetActivity( var reopen = PendingIntent.GetActivity(
this, context,
0, 0,
new Intent(this, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop), new Intent(context, typeof(MainActivity)).SetFlags(ActivityFlags.SingleTop),
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent); PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);
return new Notification.Builder(this, ChannelId) return new Notification.Builder(context, ChannelId)
.SetContentTitle("DodoSSH") .SetContentTitle("DodoSSH")
.SetContentText(summary) .SetContentText(summary)
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo) .SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
@@ -93,37 +137,138 @@ internal sealed class SessionForegroundService : Service
.Build(); .Build();
} }
/// <summary>Starts or stops the service to match what is actually running.</summary> /// <summary>Starts, stops, or refreshes the service's notification to match what is actually running.</summary>
/// <param name="liveSessions">Shells with a live channel behind them.</param> /// <param name="liveSessions">Shells with a live channel behind them.</param>
/// <param name="activeTransfers">Transfers still moving bytes.</param> /// <param name="activeTransfers">Transfers still moving bytes.</param>
public static void Reconcile(int liveSessions, int activeTransfers) /// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
/// <remarks>
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
/// always. Something live and the service not yet running starts it. Something live and the service
/// already running is the case that used to call <c>StartForegroundService</c> a second time, which on
/// API 31+ throws <c>ForegroundServiceStartNotAllowedException</c> the instant the app is backgrounded
/// — a transfer finishing in the pocket, one of two shells dying — crashing the process and taking the
/// remaining connections with it. That case — running, and the app backgrounded — now only posts a
/// fresh notification through the <see cref="NotificationManager"/> already holding the channel open,
/// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
/// start even over a service that looks like it is running; the inline remark below is why.
/// </remarks>
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession)
{ {
var context = PhoneEnvironment.Require(); var context = PhoneEnvironment.Require();
var intent = new Intent(context, typeof(SessionForegroundService));
if (liveSessions == 0 && activeTransfers == 0) if (liveSessions == 0 && activeTransfers == 0 && !holdsFileSession)
{ {
context.StopService(intent); context.StopService(new Intent(context, typeof(SessionForegroundService)));
return; return;
} }
// The summary says what is actually held, counted rather than generic — the same principle the // The summary says what is actually held, counted rather than generic — the same principle the
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on. // delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
intent.PutExtra("summary", Summarise(liveSessions, activeTransfers)); var summary = Summarise(liveSessions, activeTransfers, holdsFileSession);
context.StartForegroundService(intent); // In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
// always allowed and is preferred even when `running` says the service is up: a disconnect followed
// by a quick reconnect can land here while the StopService just issued is still in flight, and
// posting to that dying service's notification would leave an orphan receipt over an unprotected
// process — restarting instead makes the flag's small lag harmless. A start on a service that
// really is running only re-delivers OnStartCommand, whose StartForeground updates the same
// notification anyway. CurrentActivity is the foreground signal: set on resume, cleared on pause.
if (running && PhoneEnvironment.CurrentActivity is null)
{
var manager = (NotificationManager)context.GetSystemService(Context.NotificationService)!;
manager.Notify(NotificationId, BuildNotification(context, summary));
return;
} }
private static string Summarise(int liveSessions, int activeTransfers) RequestNotificationPermission(context);
Start(context, summary);
}
/// <remarks>
/// Split out of <see cref="Reconcile"/> for the try/catch alone, which needs its own remark and would
/// otherwise crowd the three-way branch above it.
/// </remarks>
private static void Start(Context context, string summary)
{ {
var parts = new List<string>(2); var intent = new Intent(context, typeof(SessionForegroundService));
intent.PutExtra("summary", summary);
try
{
context.StartForegroundService(intent);
}
catch (Java.Lang.IllegalStateException)
{
// ForegroundServiceStartNotAllowedException (API 31+) derives from this, and reaching it here
// means a start-worthy transition — a shell opening, a Files connection completing, the first
// transfer landing in an empty queue — happened while the app was backgrounded, which is
// precisely when Android refuses a new foreground start. There is no retry that helps: by the
// time this catch runs, the moment such a start would have been allowed has already passed.
// Swallowing it is the honest choice and not just the available one — letting the exception
// propagate would crash the process and drop the very shells and transfers this service exists
// to keep alive. A process that keeps running unprotected outlives one that does not run at all.
}
}
/// <summary>
/// Asks for the receipt notification's own permission, the first time this process actually has
/// something to show rather than at launch.
/// </summary>
/// <remarks>
/// At most once per process, via <see cref="notificationPermissionRequested"/> — not to work around a
/// platform limit, since Android already refuses to show the dialogue twice, but because a second call
/// to <c>RequestPermissions</c> after the first is still pending is its own kind of noise. No result is
/// read back: there is nothing this class would do differently for a grant versus a refusal, so a
/// callback would exist only to be empty. What refusal costs is stated rather than hidden — the
/// notification stays invisible — and what it does not cost is the point: the service still starts,
/// still holds the process in the foreground, and the shells and transfers it protects are exactly as
/// safe as if the user had said yes. See the manifest's own comment on this permission.
/// </remarks>
private static void RequestNotificationPermission(Context context)
{
// Isolated as its own guard clause rather than folded into the compound condition below: the
// platform-compatibility analyzer only recognises a version check as guarding what follows when it
// is the sole condition of its own early return, and PostNotifications is annotated API 33+.
if (!OperatingSystem.IsAndroidVersionAtLeast(33))
{
return;
}
// Read into a local rather than referenced again inside the lambda below: the guard clause above
// covers a direct call in this method's own body, but the platform-compatibility analyzer treats a
// lambda as reachable from anywhere and will not extend the guard across that boundary. Capturing
// the already-validated string sidesteps the false positive without weakening the actual check.
var postNotifications = global::Android.Manifest.Permission.PostNotifications;
if (notificationPermissionRequested
|| PhoneEnvironment.CurrentActivity is not { } activity
|| context.CheckSelfPermission(postNotifications) == Permission.Granted)
{
return;
}
notificationPermissionRequested = true;
activity.RunOnUiThread(() =>
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
}
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession)
{
var parts = new List<string>(3);
if (liveSessions > 0) if (liveSessions > 0)
{ {
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected"); parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
} }
if (holdsFileSession)
{
parts.Add("Files connected");
}
if (activeTransfers > 0) if (activeTransfers > 0)
{ {
parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running"); parts.Add(activeTransfers == 1 ? "1 transfer running" : $"{activeTransfers} transfers running");
@@ -17,37 +17,53 @@ namespace DodoSSH.Client.Android.Platform;
/// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which /// tally kept here. It already knows that a session whose shell exited half an hour ago is not live, which
/// a counter incremented on open and decremented on close would not. /// a counter incremented on open and decremented on close would not.
/// </para> /// </para>
/// <para>
/// Three facts feed <see cref="SessionForegroundService.Reconcile"/>, not one: live shells, moving
/// transfers, and an idle-but-connected Files session. The last of those used to be missing entirely —
/// a shell survives backgrounding because somebody opened it, but a Files connection with nothing moving
/// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that
/// gap closed, read the same way the other two facts are: asked, not cached.
/// </para>
/// </remarks> /// </remarks>
internal sealed class SessionKeepAlive : IDisposable internal sealed class SessionKeepAlive : IDisposable
{ {
private readonly TerminalWorkspace workspace; private readonly TerminalWorkspace workspace;
private readonly Func<int> activeTransfers; private readonly Func<int> activeTransfers;
private readonly Func<bool> holdsFileSession;
/// <param name="workspace">The live shells.</param> /// <param name="workspace">The live shells.</param>
/// <param name="activeTransfers"> /// <param name="activeTransfers">
/// How many transfers are moving bytes. A delegate rather than a queue, because file transfer is out /// How many transfers are moving bytes. A delegate rather than a queue, because ownership of the
/// of this head's first scope — see the decision in docs/android-port.md — and this is the seam it /// transfer queue stays with <c>TransfersViewModel</c> — this class only ever asks it a question.
/// will arrive through rather than a dependency taken before there is anything to depend on.
/// </param> /// </param>
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers) /// <param name="holdsFileSession">
/// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but-
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
/// </param>
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers, Func<bool> holdsFileSession)
{ {
this.workspace = workspace; this.workspace = workspace;
this.activeTransfers = activeTransfers; this.activeTransfers = activeTransfers;
this.holdsFileSession = holdsFileSession;
// Raised on whatever thread the pump unwound on, which is fine: starting and stopping a service is // Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
// a binder call and needs no particular thread. Nothing here touches the interface. // closer's own — which is fine: starting and stopping a service is a binder call and needs no
// particular thread. Nothing here touches the interface. That the announcement waits for the run to
// actually complete, and comes for deliberate closes too, is what makes reading LiveSessionCount
// from it honest — the event's own remark carries the stuck notification that taught us both.
workspace.SessionEnded += OnSessionEnded; workspace.SessionEnded += OnSessionEnded;
} }
/// <summary>Re-reads the counts and starts or stops the service to match.</summary> /// <summary>Re-reads the counts and starts or stops the service to match.</summary>
/// <remarks> /// <remarks>
/// Called after anything that could change either count — opening a shell, closing a tab, a transfer /// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
/// finishing. Calling it when nothing changed is free: reconciling to the state it is already in is /// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
/// either a redundant <c>startForegroundService</c> on a running service or a <c>stopService</c> on a /// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
/// stopped one, and Android treats both as no-ops. /// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one,
/// and Android treats all of those as no-ops.
/// </remarks> /// </remarks>
public void Refresh() => public void Refresh() =>
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers()); SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
/// <inheritdoc /> /// <inheritdoc />
public void Dispose() public void Dispose()
@@ -56,7 +72,7 @@ internal sealed class SessionKeepAlive : IDisposable
// The notification goes with the composition root. Leaving it up over a process that is shutting // The notification goes with the composition root. Leaving it up over a process that is shutting
// down is how an SSH client acquires a reputation for a notification you cannot get rid of. // down is how an SSH client acquires a reputation for a notification you cannot get rid of.
SessionForegroundService.Reconcile(0, 0); SessionForegroundService.Reconcile(0, 0, false);
} }
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh(); private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
@@ -0,0 +1,79 @@
using global::Android.Views;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Hands native focus back to the terminal's WebView after Avalonia chrome took it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The sibling of <see cref="SoftKeyboard"/>, and it exists for the same reason that one does:</b> the
/// keyboard over a terminal belongs to the WebView's own native view, which Avalonia's focus manager does
/// not own. The accessory row's keys are already <c>Focusable=false</c> — see TerminalScreen — so Avalonia's
/// idea of focus never leaves the terminal when one is tapped. What still moves is <em>Android's</em>:
/// <c>AvaloniaView.DispatchTouchEvent</c> (decompiled from Avalonia.Android 12.1.1) ends every handled
/// touch — DOWN and UP alike — with a <c>RequestFocus()</c> for Avalonia's own view. The WebView's input
/// connection dies with its focus, the keyboard swaps to the layout it shows an editor that takes no text,
/// and the inset churn that follows can leave it sitting on top of the very row that was tapped.
/// </para>
/// <para>
/// <b>Posted, not called — the posting is the fix's second attempt, and the first one's failure is why.</b>
/// The first version called <c>RequestFocus()</c> from the keys' own Click handlers, which fire
/// <em>inside</em> the UP event's dispatch — and the platform's own request runs <em>after</em> dispatch
/// returns, so it undid ours a few microseconds later and the terminal stayed unfocused. A posted runnable
/// runs on the next main-looper message, after the platform has taken its turn, so ours is the request that
/// sticks. The focus check lives inside the posted runnable for the same reason: the answer at call time is
/// about to be made stale by the very mechanism this exists to counter.
/// </para>
/// <para>
/// The page inside the WebView never noticed any of this — its own DOM focus never moved — so regaining
/// native focus re-establishes the same input connection and the keyboard settles back to what it was.
/// </para>
/// <para>
/// Found by walking the decor view rather than asked of the <c>NativeWebView</c> control, because the
/// control does not expose its platform child and this application only ever has the one WebView — the
/// walk's first match is necessarily the terminal. Every step is allowed to be absent, exactly as
/// <see cref="SoftKeyboard.Hide"/>'s are: no activity while backgrounded, no WebView while the terminal
/// surface has never been shown, and nothing to do in either case.
/// </para>
/// </remarks>
internal static class TerminalFocus
{
public static void Return()
{
if (PhoneEnvironment.CurrentActivity?.Window?.DecorView is not ViewGroup decor)
{
return;
}
if (FindWebView(decor) is not { } webView)
{
return;
}
webView.Post(() =>
{
if (!webView.IsFocused)
{
webView.RequestFocus();
}
});
}
private static View? FindWebView(ViewGroup parent)
{
for (var i = 0; i < parent.ChildCount; i++)
{
switch (parent.GetChildAt(i))
{
case global::Android.Webkit.WebView webView:
return webView;
case ViewGroup child when FindWebView(child) is { } found:
return found;
}
}
return null;
}
}
@@ -12,7 +12,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- The service's persistent notification. Runtime-requested on API 33+, and refusal is survivable. --> <!--
The service's persistent notification. Requested on API 33+ from SessionForegroundService.Reconcile,
the first time in this process there is actually something to show — not at launch, where the ask
would justify nothing on screen yet. Refusal is survivable: the service still starts and still holds
the process in the foreground either way, so a "no" costs the notification and nothing else.
-->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Releases the device key. See AndroidDeviceKeyStore. --> <!-- Releases the device key. See AndroidDeviceKeyStore. -->
+17 -1
View File
@@ -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" />
@@ -48,7 +48,7 @@
the refusal has no continue button here either. the refusal has no continue button here either.
--> -->
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto"> <Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
<!-- ============ header ============ --> <!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0"> <Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
@@ -199,8 +199,48 @@
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
<!-- ============ pins ============ -->
<!--
◆ The phone's answer to the desktop's QUICK ACCESS sidebar — the same PinnedPathList this host was
connected with, drawn where this head actually browses files rather than beside a terminal it has no
strip for. See TransfersViewModel.ConnectedPinnedPaths for why this is a snapshot taken at connect
rather than a live follow of the host row: a pin added or removed mid-session shows up here on the
next connect, not while this one is still open.
Its own row rather than folded into the breadcrumb above: the two scroll independently and mean
different things — the breadcrumb is where this pane is, the chips are places it can jump to — and a
shared row would make ↑ look like one more pin among several.
Gone rather than empty when nothing is pinned or nothing is connected, which HasConnectedPins already
answers: an empty scrolling strip under the breadcrumb would read as a loading row rather than as
"this host has nothing pinned".
-->
<ScrollViewer Grid.Row="4" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding HasConnectedPins}" Margin="0,0,0,4">
<ItemsControl ItemsSource="{Binding ConnectedPinnedPaths}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" Margin="14,0" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!--
Classes="row" at its default 44dp MinHeight rather than the breadcrumb's own 36 — these chips
are the destination, not the trail behind it, and a target worth a deliberate tap gets the
full touch floor this head holds everything else to. Not trimmed: a chip scrolls sideways with
the row rather than being squeezed to fit it, so the full path is always what a tap commits to.
-->
<Button Classes="row" MinHeight="44" Padding="11,0" CornerRadius="10"
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
CommandParameter="{Binding}">
<TextBlock Classes="mono" FontSize="12" Text="{Binding}" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- ============ the listing ============ --> <!-- ============ the listing ============ -->
<Panel Grid.Row="4"> <Panel Grid.Row="5">
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12" <TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
VerticalAlignment="Top" Text="{Binding Status}" /> VerticalAlignment="Top" Text="{Binding Status}" />
@@ -266,7 +306,7 @@
<!-- ============ what to do with the chosen entry ============ --> <!-- ============ what to do with the chosen entry ============ -->
<!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. --> <!-- DeepChrome rather than Chrome, since v5 — see the remark on PhoneShell's vault header. -->
<Border Grid.Row="5" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}" <Border Grid.Row="6" IsVisible="{Binding IsConnected}" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10"> BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
<StackPanel Spacing="9"> <StackPanel Spacing="9">
@@ -1098,6 +1098,60 @@
Command="{Binding AddEditorTagCommand}" /> Command="{Binding AddEditorTagCommand}" />
</Grid> </Grid>
<!--
◆ QUICK ACCESS. Staged on the shared VaultViewModel.EditorPinnedPaths the way EditorTagChoices
stages tags above — populated when the editor opens, read back by BuildHost on SAVE, left alone
by CANCEL. Nothing here is phone-only: a pin added on this page is the same PinnedPathList entry
the desktop's drawer stages under the same field, so it syncs to every other client the moment
this vault does, exactly as a tag would.
No folder glyph in the row, unlike the desktop's — this head carries MonoFont and a heading font
(see Theme/Phone.axaml) and no icon font, so the desktop's &#xE2C7; would draw as a tofu box
rather than a folder here. The path text carries the row on its own, trimmed in a Grid's star
column rather than a StackPanel's — a StackPanel measures its children at infinity, so
TextTrimming never actually engages inside one; this is the same fix TransfersScreen's pane
headers needed for the same reason.
The remove button is Classes="icon" at its default 44x44, not the desktop's 22x22 close box —
see this file's touch-floor rule at the top of the class: a target sized for a mouse pointer is
not one a thumb can reliably land on.
See surface B, the Files screen's pin chips, for where these pins actually get used on this
head — the hint sentence below names it rather than the desktop's terminal strip, which does
not exist here.
-->
<TextBlock Classes="label" Text="QUICK ACCESS" Margin="0,4,0,0" />
<ItemsControl ItemsSource="{Binding EditorPinnedPaths}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border MinHeight="44" CornerRadius="10" Background="{StaticResource Field}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" Padding="12,0">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5" Text="{Binding}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1" Classes="icon" Content="✕"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).RemoveEditorPinCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Unpins this path" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Classes="field" Text="{Binding EditorNewPin}"
PlaceholderText="/path/to/folder" />
<Button Grid.Column="2" Classes="secondary" Height="44" Width="72" Content="ADD"
Command="{Binding AddEditorPinCommand}" />
</Grid>
<TextBlock Classes="body"
Text="Pins show up as chips on the Files screen while you are connected to this host — tap one to jump straight there." />
<!-- <!--
◆ The one control here that publishes something. Turning it on copies this host's address and ◆ The one control here that publishes something. Turning it on copies this host's address and
port into plaintext columns the server can read, which is the single deliberate concession in port into plaintext columns the server can read, which is the single deliberate concession in
@@ -1117,6 +1171,18 @@
<TextBlock Classes="body" <TextBlock Classes="body"
Text="Not built yet: this app always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it works, the relay will dial on your behalf — which is why the address and port have to be stored in the clear. Everything else about the host stays encrypted either way, and a relayed host needs a port of its own rather than its group's." /> Text="Not built yet: this app always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it works, the relay will dial on your behalf — which is why the address and port have to be stored in the clear. Everything else about the host stays encrypted either way, and a relayed host needs a port of its own rather than its group's." />
<!--
This page covers the whole screen while it is open, which is why it needs a Status line of its
own rather than relying on the one the list behind it draws at the top of HOSTS (see this file's
other Classes="detail" Text="{Binding Status}") — that TextBlock is off-screen for as long as
IsEditing is true. AddEditorTagCommand and AddEditorPinCommand both refuse silently into Status
rather than throwing, so without a line to draw it here the honesty rule would be broken: a
refusal that writes a message nothing on screen shows is the same, to whoever pressed ADD, as no
refusal at all.
-->
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0"> <Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE" <Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
Command="{Binding SaveHostCommand}" /> Command="{Binding SaveHostCommand}" />
@@ -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;
+26 -1
View File
@@ -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" />
@@ -268,7 +269,26 @@
<Setter Property="Foreground" Value="{StaticResource TextFaint}" /> <Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style> </Style>
<!-- Every button in this window is small, mono and tracked out; only the colours differ. --> <!--
Every button in this window is small, mono and tracked out; only the colours differ.
◆ VerticalContentAlignment IS NOT VerticalAlignment, and this style used to set only the second. The
first places the caption inside the button; the second places the button inside its parent. Avalonia's
default for content alignment is Stretch, so on any of these given a fixed Height — the hosts toolbar's
three at 40, and every dialog's row of them — the ContentPresenter stretched the caption's TextBlock to
the full content box and a TextBlock draws its line at the TOP of its bounds. Measured on the hosts
toolbar: a 40-pixel button with 9 pixels above the ink and 20 below it. Nothing was the wrong height,
which is why this read as one — the box was right and the label sat in the top third of it.
Center rather than a hand-tuned Padding, because the gap is the difference between the line box and the
content box and so moves with the font size: these carry 11.5 by default and the primary action
overrides it to 13.5. Every other button class here already sets it — navseg, sesstab, headerghost,
sidebarrow, fieldrow, paneicon — so this is the three shapes that were missed rather than a new idiom.
HorizontalContentAlignment is deliberately left alone. It is Stretch too, and it is invisible on a
button sized to its own caption; the ones that are stretched wide state their own (the flyouts' rows
ask for Left), and centring those from here would move text nobody complained about.
-->
<Style Selector="Button.ghost, Button.accent, Button.danger"> <Style Selector="Button.ghost, Button.accent, Button.danger">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" /> <Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11.5" /> <Setter Property="FontSize" Value="11.5" />
@@ -277,6 +297,7 @@
<Setter Property="Padding" Value="10,5" /> <Setter Property="Padding" Value="10,5" />
<Setter Property="MinHeight" Value="0" /> <Setter Property="MinHeight" Value="0" />
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style> </Style>
<!-- <!--
@@ -443,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">
@@ -462,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" />
@@ -651,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" />
@@ -1100,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" />
+121 -13
View File
@@ -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}"
@@ -177,16 +177,27 @@
thing (go to the parent directory), and the pair reading differently is the design's own choice thing (go to the parent directory), and the pair reading differently is the design's own choice
faithfully carried over rather than a functional difference invented to justify it. faithfully carried over rather than a functional difference invented to justify it.
--> -->
<!--
◆ THE PATH SITS IN THE STAR COLUMN, ALONE, AND THAT PLACEMENT IS LOAD-BEARING.
TextTrimming only acts when measure hands the block a finite width, and a horizontal StackPanel
never does — it measures every child at infinity, takes the full answer, and an Auto grid column
passes that on. With the path in a StackPanel beside the label, a directory deep enough — the
remote pane meets one on any real host, this pane on any machine whose profile path is long —
made the header wider than the pane and pushed the icon buttons past the window's own edge. The
layout suite caught it at the 472-pixel session budget, on the one machine whose home directory
was long enough to arm it. Star column: bounded width, working ellipsis, buttons that stay.
-->
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0"> <Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"> <TextBlock Grid.Column="0" Classes="label" FontSize="10" Text="LOCAL"
<TextBlock Classes="label" FontSize="10" Text="LOCAL" VerticalAlignment="Center" /> VerticalAlignment="Center" Margin="0,0,12,0" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium" <TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
HorizontalAlignment="Left"
Text="{Binding LocalPath}" TextTrimming="CharacterEllipsis" Text="{Binding LocalPath}" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding LocalPath}" /> ToolTip.Tip="{Binding LocalPath}" />
</StackPanel> <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
<!-- <!--
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
directory. Without this the pane is stuck on whichever drive the user profile is on. Chips directory. Without this the pane is stuck on whichever drive the user profile is on. Chips
@@ -332,19 +343,26 @@
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing. distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
--> -->
<!--
The path is alone in the star column for the reason the local pane's header remark spells out:
TextTrimming needs the finite width only a star column gives it, and this is the pane where the
long path is not even unusual — it is any host with a deep directory tree.
-->
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0"> <Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"> <StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center"
Margin="0,0,12,0">
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center" <TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
IsVisible="{Binding ShowsHostPicker}" /> IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center" <TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
IsVisible="{Binding ShowsBucketPicker}" /> IsVisible="{Binding ShowsBucketPicker}" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium" </StackPanel>
<TextBlock Grid.Column="1" Classes="mono" FontSize="13.5" FontWeight="Medium"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
HorizontalAlignment="Left"
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis" Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding RemotePath}" /> ToolTip.Tip="{Binding RemotePath}" />
</StackPanel> <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="12,0,0,0">
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}" <Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory"> IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5DB;" /> <TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5DB;" />
@@ -525,6 +525,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// list. Detached in DisposeAsync, which is the only point either of them ends. // list. Detached in DisposeAsync, which is the only point either of them ends.
this.workspace.SessionEnded += OnWorkspaceSessionEnded; this.workspace.SessionEnded += OnWorkspaceSessionEnded;
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested; this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
this.workspace.RendererReattached += OnRendererReattached;
settings = new ClientSettingsStore(paths); settings = new ClientSettingsStore(paths);
@@ -635,6 +636,31 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) => private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step)); Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
/// <remarks>
/// <para>
/// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
/// plane's socket-accept thread, and both properties it reads here — <see cref="TerminalFontSize"/> and
/// <see cref="SelectedTab"/> — are bound to by the interface.
/// </para>
/// <para>
/// <see cref="TerminalWorkspace.RendererReattached"/> fires once the workspace has replayed what it
/// owns — the live sessions. Font size and the choice of active tab are not the workspace's to know;
/// they live here, so this is the other half of putting a reattached page back the way it was. The size
/// is sent exactly as <see cref="TellRendererTheFontSizeAsync"/> sends it at startup, because nothing
/// has changed — the page has merely forgotten, and this is only a reminder.
/// </para>
/// </remarks>
private void OnRendererReattached(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(() =>
{
_ = workspace.SetFontSizeAsync(TerminalFontSize, CancellationToken.None).AsTask();
if (SelectedTab is { } tab)
{
_ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
}
});
[ObservableProperty] [ObservableProperty]
private ShellState state = ShellState.Starting; private ShellState state = ShellState.Starting;
@@ -1898,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)
@@ -1910,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);
@@ -1934,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.
@@ -3051,6 +3089,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
workspace.SessionEnded -= OnWorkspaceSessionEnded; workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested; workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
workspace.RendererReattached -= OnRendererReattached;
transfers.PropertyChanged -= OnTransfersPropertyChanged; transfers.PropertyChanged -= OnTransfersPropertyChanged;
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and // Stopped here rather than left to the process exiting with it: the loop holds no vault key and
@@ -3373,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);
@@ -3402,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>
@@ -620,6 +620,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty] [ObservableProperty]
private string? connectedCipher; private string? connectedCipher;
/// <summary>
/// Whether there is a live SFTP connection this session would lose by dying — the phone's foreground-
/// service question, not the desktop's.
/// </summary>
/// <remarks>
/// <see cref="ConnectedCipher"/> is already the fact that tells a host apart from a bucket, because only
/// a host set it — a bucket is HTTP, per-request, and closes nothing a dying process would have kept
/// open, so it answers false here even while <see cref="IsConnected"/> is true. Android reads this to
/// decide whether an idle Files screen with no transfer moving still needs the process kept alive; the
/// desktop has no such question because nothing stops its process for having gone quiet.
/// </remarks>
internal bool HasLiveFileSession => IsConnected && ConnectedCipher is not null;
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary> /// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>. See <see cref="ConnectedCipher"/>.</summary>
[ObservableProperty] [ObservableProperty]
private string? connectedHostKeyAlgorithm; private string? connectedHostKeyAlgorithm;
@@ -637,6 +650,31 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty] [ObservableProperty]
private string? connectedIdentityLabel; private string? connectedIdentityLabel;
/// <summary>
/// The paths pinned on the connected host, for the phone's Files-screen chip row — the desktop draws
/// the same list in its QUICK ACCESS sidebar, over the terminal surface rather than this one. See
/// <see cref="VaultViewModel.EditorPinnedPaths"/> for where a pin is actually added or removed; this is
/// a read of what was already saved there.
/// </summary>
/// <remarks>
/// Captured at connect, the same moment <see cref="ConnectedTo"/> is, rather than followed live off the
/// host row's own <c>PinnedPaths</c>. A pin edited while this session stays open shows up on the next
/// connect rather than mid-session — the same lag <see cref="ConnectedTo"/> itself already carries for
/// a relabel — because this screen reads the vault once, at the moment it dials, rather than staying
/// wired to a collection it otherwise never has a reason to watch. A bucket has no pins at all:
/// <see cref="OpenBucketAsync"/> leaves this empty rather than reading as "not yet known", which is what
/// empty already means for a host that connected with none pinned.
/// </remarks>
internal ObservableCollection<string> ConnectedPinnedPaths { get; } = [];
/// <summary>Whether the connected host or bucket has any pins to draw as chips.</summary>
/// <remarks>
/// A read of <see cref="ConnectedPinnedPaths"/> rather than an <c>[ObservableProperty]</c> of its own,
/// so it is raised by hand at each of the three places that collection is repopulated or cleared —
/// <see cref="MarkHostConnected"/>, <see cref="OpenBucketAsync"/> and <see cref="CloseSessionAsync"/>.
/// </remarks>
internal bool HasConnectedPins => ConnectedPinnedPaths.Count > 0;
[ObservableProperty] [ObservableProperty]
private HostKeyPresentation? pendingHostKey; private HostKeyPresentation? pendingHostKey;
@@ -720,13 +758,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = []; internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
/// <summary>Raised on the UI thread whenever a transfer appears or changes state.</summary> /// <summary>
/// Raised on the UI thread whenever a transfer appears or changes state, or a host or bucket connects or
/// disconnects.
/// </summary>
/// <remarks> /// <remarks>
/// For a head that has to tell the operating system what this process is doing — Android's foreground /// For a head that has to tell the operating system what this process is doing — Android's foreground
/// service, which must be up for as long as bytes are moving and down afterwards. An event rather than /// service, which must be up for as long as bytes are moving, or a host session sits open, and down
/// letting that head watch <see cref="Transfers"/> itself: the collection announces rows arriving and /// afterwards. An event rather than letting that head watch <see cref="Transfers"/> itself: the
/// leaving, and the transition that matters most is neither of those but a row going from RUNNING to /// collection announces rows arriving and leaving, and the transition that matters most is neither of
/// DONE without moving. /// those but a row going from RUNNING to DONE without moving. Connecting and disconnecting are the other
/// two transitions the service cares about — see <see cref="HasLiveFileSession"/> — and neither touches
/// <see cref="Transfers"/> at all, so they need this same announcement made by hand.
/// </remarks> /// </remarks>
internal event EventHandler? ActivityChanged; internal event EventHandler? ActivityChanged;
@@ -794,12 +837,48 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
foreach (var root in LocalDirectory.Roots()) foreach (var root in LocalDirectory.Roots())
{ {
LocalRoots.Add(new CrumbViewModel(root.TrimEnd(Path.DirectorySeparatorChar), root)); LocalRoots.Add(new CrumbViewModel(RootChipName(root), root));
} }
RefreshLocalCommand.Execute(null); RefreshLocalCommand.Execute(null);
} }
/// <summary>What a root's chip in the pane header says: <c>C:</c>, <c>/</c>, <c>~</c>, or a mount's name.</summary>
/// <remarks>
/// <para>
/// A name, never a path — the full path is the chip's <see cref="CrumbViewModel.Path"/> and its command
/// parameter, and it stays there. This used to be <c>root.TrimEnd(separator)</c>, which is a name only
/// for a Windows drive: on Unix it made the <c>/</c> chip an empty pill and the home chip the entire
/// home path, drawn at full width in a header column nothing bounds. A machine whose home directory sat
/// deep enough — CI's per-job HOME is forty-six characters — had that one chip push the header's own
/// buttons past the window's edge at the session shell's 472-pixel budget.
/// </para>
/// <para>
/// <c>~</c> for home is the one substitution rather than a shortening: every shell a user of this
/// application has ever typed into already means "my home directory" by it, which is exactly what the
/// chip does when pressed.
/// </para>
/// </remarks>
internal static string RootChipName(string root)
{
if (string.Equals(root, LocalDirectory.Home, StringComparison.Ordinal))
{
return "~";
}
var trimmed = root.TrimEnd(Path.DirectorySeparatorChar);
if (trimmed.Length == 0)
{
// Unix's "/": trimming eats the whole string, and the root's name is the root itself.
return "/";
}
// A mount under /media or /run/media names itself by its last segment; a Windows drive ("C:") has
// no file-name segment at all, and the trimmed root is already the two-character name it always had.
var name = Path.GetFileName(trimmed);
return name.Length == 0 ? trimmed : name;
}
/// <summary> /// <summary>
/// Gives up the vault, keeping the connection and anything in flight. /// Gives up the vault, keeping the connection and anything in flight.
/// </summary> /// </summary>
@@ -911,6 +990,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
ConnectedHostKeyAlgorithm = null; ConnectedHostKeyAlgorithm = null;
ConnectedIdentityLabel = null; ConnectedIdentityLabel = null;
// And no pins either — see ConnectedPinnedPaths's own remark.
ConnectedPinnedPaths.Clear();
OnPropertyChanged(nameof(HasConnectedPins));
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow()); connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true); await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
@@ -997,10 +1080,26 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm; ConnectedHostKeyAlgorithm = opened.HostKey.Algorithm;
ConnectedIdentityLabel = identityLabel; ConnectedIdentityLabel = identityLabel;
// See ConnectedPinnedPaths's own remark for why this is a snapshot rather than a live follow.
ConnectedPinnedPaths.Clear();
foreach (var path in row.Host.PinnedPaths)
{
ConnectedPinnedPaths.Add(path);
}
OnPropertyChanged(nameof(HasConnectedPins));
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as // Recorded, and not hidden because it is "only" the file browser. Opening this is a second login as
// far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with // far as the remote's own auth.log is concerned, so a log of ours that omitted it would disagree with
// the host's — and anybody comparing the two would be right to believe the host. // the host's — and anybody comparing the two would be right to believe the host.
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow()); connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
// Raised here rather than from OnIsConnectedChanged, on purpose: IsConnected is set first, above,
// and ConnectedCipher second — a partial method firing off the first assignment would read
// HasLiveFileSession against a ConnectedCipher still holding whatever the previous session left
// there. Only at the end of this method are both facts actually true together.
OnPropertyChanged(nameof(HasLiveFileSession));
ActivityChanged?.Invoke(this, EventArgs.Empty);
} }
/// <summary> /// <summary>
@@ -1775,11 +1874,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
ConnectedCipher = null; ConnectedCipher = null;
ConnectedHostKeyAlgorithm = null; ConnectedHostKeyAlgorithm = null;
ConnectedIdentityLabel = null; ConnectedIdentityLabel = null;
ConnectedPinnedPaths.Clear();
OnPropertyChanged(nameof(HasConnectedPins));
RemotePath = string.Empty; RemotePath = string.Empty;
RemoteEntries.Clear(); RemoteEntries.Clear();
RemoteTrail.Clear(); RemoteTrail.Clear();
SelectedRemoteEntry = null; SelectedRemoteEntry = null;
// Same ordering reason as the raise at the end of MarkHostConnected: both properties this reads are
// already null above, so the raise belongs after them rather than in OnIsConnectedChanged. This also
// covers OpenBucketAsync, which calls this method first and never itself turns HasLiveFileSession on.
OnPropertyChanged(nameof(HasLiveFileSession));
ActivityChanged?.Invoke(this, EventArgs.Empty);
} }
/// <remarks> /// <remarks>
@@ -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);
} }
+94 -11
View File
@@ -3,7 +3,7 @@
/* /*
The renderer half of the terminal data plane. The renderer half of the terminal data plane.
Two things here are load-bearing and easy to get wrong: Three things here are load-bearing and easy to get wrong:
1. Output is acknowledged from term.write's completion callback, never on receipt. The 1. Output is acknowledged from term.write's completion callback, never on receipt. The
acknowledgement returns flow-control credit to the host, so acknowledging early would tell acknowledgement returns flow-control credit to the host, so acknowledging early would tell
@@ -15,6 +15,14 @@
partial sequences across writes. Decoding here would corrupt any multi-byte character that partial sequences across writes. Decoding here would corrupt any multi-byte character that
happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
conditions that are hardest to reproduce. conditions that are hardest to reproduce.
3. The socket reconnects itself, forever, with backoff. This page's WebView is routinely killed
and reloaded by Android under memory pressure or simply for being backgrounded, so "the
socket closed" is an ordinary event here, not the end of the terminal's life see connect().
A reloaded page starts with an empty session map, so createSession is idempotent (a session
that already has a pane is left alone) and a SESSION_OPENED frame carries a flag telling this
page whether it is a replay: nothing to do for a pane that is still here, and a short banner
for one that is not, because that pane's scrollback genuinely did not survive.
*/ */
const SERVER_OUTPUT = 1; const SERVER_OUTPUT = 1;
@@ -33,6 +41,25 @@ const CLIENT_FONT_SIZE_STEP = 4;
const HEADER_LENGTH = 5; const HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000; const SCROLLBACK_LINES = 5000;
/*
How long to wait before trying the socket again, and how that wait grows. Starting quick matters
because the ordinary case is a page that just finished loading after its WebView came back the
host's listener has been sitting there the whole time and capping it matters because there is no
point spacing attempts further apart than a person notices. Forever rather than giving up, because
giving up would need a way to try again and there is none better than the one already here: the page
dies with the app.
*/
const RECONNECT_INITIAL_DELAY_MS = 1000;
const RECONNECT_MAX_DELAY_MS = 5000;
/*
Styled like the SESSION_CLOSED banner (matching \x1b[38;5;244, the same dim grey), but written by
createSession's caller rather than by createSession itself: only a *replay* landing on a pane that
does not exist yet means the page reloaded and lost it, and createSession has no way to know which
of its callers that is.
*/
const REPLAY_BANNER = '\x1b[38;5;244m── the view reconnected; earlier output stayed on the host ──\x1b[0m\r\n';
/* /*
The size panes are created at, until the host says otherwise which it does as soon as it has read The size panes are created at, until the host says otherwise which it does as soon as it has read
the stored preference, usually before the first session exists. Kept here as well so a pane opened the stored preference, usually before the first session exists. Kept here as well so a pane opened
@@ -192,7 +219,19 @@ function handleKey(event) {
return true; return true;
} }
/**
* Builds a pane for a session, or returns the one already there.
*
* Idempotent because a replay can land on a page that never lost its pane the socket dropped and
* came back, but this page's own process survived and asking for a session that already has a pane
* must not build a second one on top of it, orphaning the first one's WebGL context and scrollback.
*/
function createSession(sessionId) { function createSession(sessionId) {
const existing = sessions.get(sessionId);
if (existing) {
return existing;
}
const pane = document.createElement('div'); const pane = document.createElement('div');
pane.className = 'pane'; pane.className = 'pane';
pane.dataset.sessionId = String(sessionId); pane.dataset.sessionId = String(sessionId);
@@ -290,10 +329,23 @@ function handleFrame(buffer) {
const payload = new Uint8Array(buffer, HEADER_LENGTH); const payload = new Uint8Array(buffer, HEADER_LENGTH);
switch (opcode) { switch (opcode) {
case SERVER_SESSION_OPENED: case SERVER_SESSION_OPENED: {
createSession(sessionId); // Checked before createSession, which would otherwise erase the answer by creating the pane
// this check is asking about.
const hadPaneAlready = sessions.has(sessionId);
const session = createSession(sessionId);
// Byte 1 means the host is replaying a session that existed before this socket attached — see
// TerminalWorkspace.ReplayAfterAttachAsync. A replay landing on a pane that is still here has
// nothing left to do beyond the idempotent create above; one landing on a pane that is not means
// this page reloaded and that pane's scrollback went with it, which is worth a line saying so.
if (payload.length > 0 && payload[0] === 1 && !hadPaneAlready) {
session.term.write(REPLAY_BANNER);
}
setStatus(''); setStatus('');
break; break;
}
case SERVER_OUTPUT: { case SERVER_OUTPUT: {
const session = sessions.get(sessionId) ?? createSession(sessionId); const session = sessions.get(sessionId) ?? createSession(sessionId);
@@ -414,6 +466,31 @@ function handleFrame(buffer) {
} }
} }
/** @type {number | null} */
let reconnectTimer = null;
let reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
/**
* Tries the socket again after a wait, unless a try is already pending.
*
* The guard is what keeps 'close' and 'error' from stacking two timers for one failure a socket
* that fails to open typically fires both, and each would otherwise schedule its own reconnect.
*/
function scheduleReconnect() {
if (reconnectTimer !== null) {
return;
}
setStatus('Reconnecting the terminal view…');
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
}
function connect() { function connect() {
const token = root.dataset.token; const token = root.dataset.token;
const url = root.dataset.socket; const url = root.dataset.socket;
@@ -423,16 +500,22 @@ function connect() {
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]); socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
socket.binaryType = 'arraybuffer'; socket.binaryType = 'arraybuffer';
socket.addEventListener('open', () => setStatus('')); socket.addEventListener('open', () => {
setStatus('');
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
// attempts within one outage, reset once the outage is actually over.
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
});
socket.addEventListener('message', (event) => handleFrame(event.data)); socket.addEventListener('message', (event) => handleFrame(event.data));
socket.addEventListener('close', () => { // Both close and error retry. They are not the same event on every failure — a socket that never
setStatus('Disconnected from DodoSSH.'); // opens can fire only 'error', one that opens and later drops fires only 'close' — and the host
}); // side of this same problem (TerminalDataPlane.UpgradeAsync's takeover) is exactly why retrying is
// safe: whichever attempt eventually reaches the host, a fresh valid upgrade always wins the socket.
socket.addEventListener('error', () => { socket.addEventListener('close', scheduleReconnect);
setStatus('The terminal connection failed.'); socket.addEventListener('error', scheduleReconnect);
});
} }
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a // One observer for the whole root rather than one per pane: resizes arrive in bursts while a
@@ -66,7 +66,6 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
new(TaskCreationOptions.RunContinuationsAsynchronously); new(TaskCreationOptions.RunContinuationsAsynchronously);
private WebSocket? socket; private WebSocket? socket;
private int accepted;
private int disposed; private int disposed;
/// <param name="assets">Where the renderer's files come from.</param> /// <param name="assets">Where the renderer's files come from.</param>
@@ -106,6 +105,26 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
/// </remarks> /// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested; public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>
/// Raised after a socket attaches — the first one, and every later takeover.
/// </summary>
/// <remarks>
/// <para>
/// Raised after <see cref="socket"/> has been swapped in but before <see cref="ReceiveLoopAsync"/> starts
/// consuming it, on the socket-accept thread — the same thread that is in the middle of
/// <see cref="UpgradeAsync"/> for this connection. <see cref="TerminalWorkspace"/> is this event's one
/// subscriber, and it uses the ordering to replay session state before anything the fresh page sends
/// (a resize, an early acknowledgement) can be dispatched; see its remark for why the two racing is
/// harmless regardless.
/// </para>
/// <para>
/// Unlike <see cref="RendererAttached"/>, which resolves once and answers "has a renderer ever attached"
/// for <see cref="TerminalWorkspace.WaitForRendererAsync"/>, this fires every time — because a takeover
/// is exactly the case <see cref="RendererAttached"/> was never meant to describe again.
/// </para>
/// </remarks>
public event EventHandler? SocketAttached;
/// <summary>Registers a session so inbound frames can be routed to it.</summary> /// <summary>Registers a session so inbound frames can be routed to it.</summary>
public void Register(uint sessionId, TerminalSessionPump pump) public void Register(uint sessionId, TerminalSessionPump pump)
{ {
@@ -148,8 +167,9 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
// Each connection on its own task, and deliberately not awaited. An upgraded WebSocket // Each connection on its own task, and deliberately not awaited. An upgraded WebSocket
// lives for the whole session, so handling connections in sequence would leave the accept // lives for the whole session, so handling connections in sequence would leave the accept
// loop parked inside the receive loop and every later request unanswered — the page's // loop parked inside the receive loop and every later request unanswered — the page's
// script and stylesheet among them. Concurrency needs no coordination here because the // script and stylesheet among them. Two upgrades racing each other need no coordination
// single-attach guard is an interlocked exchange. // here either, because the takeover in UpgradeAsync swaps the shared socket field with an
// interlocked exchange rather than assuming it is the only writer.
_ = HandleConnectionAsync(client, linked.Token); _ = HandleConnectionAsync(client, linked.Token);
} }
} }
@@ -192,6 +212,29 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken) .SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Not a cancellation despite the type: .NET's ManagedWebSocket wraps a send that fails because
// the underlying connection is already gone — which is exactly what a killed renderer's socket
// looks like — in an OperationCanceledException of its own manufacture, regardless of whether
// anyone actually cancelled anything. The filter is what tells the two apart: if the caller's
// own token were the cause, IsCancellationRequested would be true here and this catch does not
// apply, so a real cancellation still propagates. Everything below about why this must not
// fault the caller applies here exactly as it does to the exception types in the next catch.
}
catch (Exception exception)
when (exception is WebSocketException or ObjectDisposedException
or InvalidOperationException or IOException)
{
// The state check above is not atomic with the send, and a WebView renderer process killed by
// Android leaves its socket reporting Open long after nobody is reading from the other end. This
// has to read as "nobody listening" — the same as the no-socket case above — and never as a
// fault: SendAsync is called from TerminalSessionPump.SendOutputAsync inside the flush loop, and
// letting this exception escape would fault that loop. A faulted flush loop stops draining the
// credit window, the reader blocks once it fills, and the SSH session behind it freezes for good
// while LiveSessionCount still counts it as running. A dropped frame is recoverable — a frozen
// session is not.
}
finally finally
{ {
sendGate.Release(); sendGate.Release();
@@ -267,6 +310,25 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
.ConfigureAwait(false); .ConfigureAwait(false);
} }
/// <remarks>
/// <para>
/// <b>Takeover, not rejection.</b> A valid upgrade always wins the socket, even when one is already
/// attached — the old socket is aborted and the newcomer takes its place. Refusing a second attach used
/// to be the rule, on the theory that one renderer lives for the whole process. That is WebView2's
/// truth and not Android's: the platform kills the WebView's renderer process under memory pressure or
/// simply for being backgrounded, the page reloads, and the reload's socket is a second valid upgrade —
/// refusing it left the terminal permanently unreachable with no way back short of restarting the app.
/// </para>
/// <para>
/// Refusing protects nothing here anyway: only our own page knows the token (see the type-level remark
/// on what the token defends against), so a second valid upgrade <em>is</em> our page, reattaching.
/// Waiting for the old socket to notice it is dead and close on its own is not a safer alternative
/// either — a killed renderer process sends no TCP FIN, so the old receive loop can sit unaware for the
/// whole 30-second keepalive interval, and every reload landing in that window would still find the
/// door held shut by a socket nobody is on the other end of. Taking over immediately is what makes a
/// reload actually reattach.
/// </para>
/// </remarks>
private async Task UpgradeAsync( private async Task UpgradeAsync(
Stream stream, Stream stream,
HttpRequestLine request, HttpRequestLine request,
@@ -280,16 +342,6 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
return; return;
} }
if (Interlocked.Exchange(ref accepted, 1) == 1)
{
// One renderer, one socket. A second attach would be either a bug or something else on the
// machine having found the port.
await WriteResponseAsync(
stream, "409 Conflict", "text/plain", "Already attached"u8.ToArray(), cancellationToken)
.ConfigureAwait(false);
return;
}
var key = request.Headers.GetValueOrDefault("sec-websocket-key")!; var key = request.Headers.GetValueOrDefault("sec-websocket-key")!;
var accept = ComputeHandshakeAccept(key); var accept = ComputeHandshakeAccept(key);
@@ -314,11 +366,29 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
KeepAliveInterval = TimeSpan.FromSeconds(30), KeepAliveInterval = TimeSpan.FromSeconds(30),
}); });
socket = webSocket; // Whatever was attached before is displaced, not merely overwritten: Exchange hands back the old
rendererAttached.TrySetResult(); // reference so it can be aborted rather than left to linger as a socket nothing reads from again.
// Abort rather than a graceful close — a close frame would wait on a peer that, per the remark
// above, may never notice it should reply, and the newcomer already proved it is our page.
var previous = Interlocked.Exchange(ref socket, webSocket);
previous?.Abort();
rendererAttached.TrySetResult();
SocketAttached?.Invoke(this, EventArgs.Empty);
try
{
await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false); await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false);
} }
finally
{
// Cleared only if the field still holds this connection's own socket. A takeover has already
// swapped in a newer one by the time an aborted receive loop unwinds to here, and clearing the
// field regardless would race the newcomer: whichever of the two finished last would win, and
// it must always be the newcomer, never this one going away.
Interlocked.CompareExchange(ref socket, null, webSocket);
}
}
/// <remarks> /// <remarks>
/// The origin is checked because a page in the user's browser can attempt a WebSocket connection to /// The origin is checked because a page in the user's browser can attempt a WebSocket connection to
@@ -101,6 +101,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
private readonly Lock sessionGate = new(); private readonly Lock sessionGate = new();
private readonly CancellationTokenSource lifetime = new(); private readonly CancellationTokenSource lifetime = new();
/// <summary>
/// The payload that marks a <see cref="TerminalServerOpcode.SessionOpened"/> frame as a replay rather
/// than a fresh open. A one-byte non-empty payload, so terminal.js's existing length check (empty
/// payload for a real open) tells the two apart without a second opcode.
/// </summary>
private static readonly byte[] ReplayMarker = [1];
private uint nextSessionId = 1; private uint nextSessionId = 1;
private Task? server; private Task? server;
private int disposed; private int disposed;
@@ -125,6 +132,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
// came from. Nothing here decides anything about the size: the shell owns it, because the shell is // came from. Nothing here decides anything about the size: the shell owns it, because the shell is
// what remembers it between launches. // what remembers it between launches.
dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e); dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);
// Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
// handshake handling, and has no business making that wait on however long a replay takes. See
// ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
// is harmless.
dataPlane.SocketAttached += (_, _) => _ = ReplayAfterAttachAsync();
} }
/// <summary> /// <summary>
@@ -224,7 +237,28 @@ public sealed class TerminalWorkspace : IAsyncDisposable
} }
/// <summary> /// <summary>
/// Raised with the session id when a shell ends on its own. /// A live session's flow-control window, or null when the id names no session this workspace still has
/// open.
/// </summary>
/// <remarks>
/// A test seam rather than something the shell has ever needed: nothing outside this assembly has a
/// reason to see a pump's credit window rather than what the transport does with it, but
/// <see cref="ReplayAfterAttachAsync"/>'s reset of that window on reattach is exactly the kind of thing
/// that is easy to get backwards, and worth asserting directly rather than only through its side
/// effects. Internal rather than public, reachable from the test assembly through the
/// <c>InternalsVisibleTo</c> this project already declares for it.
/// </remarks>
internal CreditWindow? CreditsFor(uint sessionId)
{
lock (sessionGate)
{
return sessions.TryGetValue(sessionId, out var session) ? session.Pump.Credits : null;
}
}
/// <summary>
/// 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>
@@ -235,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;
@@ -254,6 +298,24 @@ public sealed class TerminalWorkspace : IAsyncDisposable
/// </remarks> /// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested; public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>
/// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
/// </summary>
/// <remarks>
/// <para>
/// The workspace's own share of "put the page back the way it was" is the sessions — each live one gets
/// its <c>SessionOpened</c> frame again, done by the time this fires. What is left is what the workspace
/// has no business owning: the font size and which tab is selected are both remembered by the shell, not
/// by a terminal, so this is the seam the shell uses to re-push them. See
/// <c>MainWindowViewModel</c>'s subscription for the other half.
/// </para>
/// <para>
/// Raised on the socket-accept thread, same as <see cref="TerminalDataPlane.SocketAttached"/> that
/// triggers it — a handler that touches a view model has to marshal.
/// </para>
/// </remarks>
public event EventHandler? RendererReattached;
/// <summary>Starts the loopback listener.</summary> /// <summary>Starts the loopback listener.</summary>
public void Start() => server = dataPlane.RunAsync(lifetime.Token); public void Start() => server = dataPlane.RunAsync(lifetime.Token);
@@ -352,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;
} }
@@ -523,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 />
@@ -563,6 +637,71 @@ public sealed class TerminalWorkspace : IAsyncDisposable
lifetime.Dispose(); lifetime.Dispose();
} }
/// <summary>
/// Rebuilds a freshly (re)attached page's idea of what is running, then tells the shell to rebuild its
/// own.
/// </summary>
/// <remarks>
/// <para>
/// Runs on the socket-accept thread that raised <see cref="TerminalDataPlane.SocketAttached"/> — the
/// constructor wires it up fire-and-forget for exactly that reason, so this method owns its own error
/// handling rather than leaving an unobserved exception for nobody to see.
/// </para>
/// <para>
/// Every live session — one whose <c>Run</c> has not completed — gets two things. Its credit window is
/// reset, because whatever was outstanding was reserved against bytes sent to a page that is now gone;
/// the acknowledgement that would return that credit died with it, and without this reset the session
/// would stall the moment 256 KiB of history had accumulated. And it gets its <c>SessionOpened</c> frame
/// again, marked with <see cref="ReplayMarker"/> so the page can tell a reattach from a session that is
/// genuinely new — the same frame a page that survived the socket drop already has a pane for, and one a
/// reloaded page does not.
/// </para>
/// <para>
/// A session whose shell has already ended gets nothing here. Its scrollback lived only in the page that
/// is gone, and sending a frame that implied otherwise would be exactly the kind of dishonesty this
/// fix is supposed to remove, not add. The tab strip still shows that session ended; nothing about this
/// method changes what <see cref="LiveSessionCount"/> or <see cref="IsSessionLive"/> report.
/// </para>
/// <para>
/// This can race the fresh page's own first frames — an early resize, an acknowledgement for output it
/// already had. That is harmless: every frame in both directions names its session, delivery order
/// within a session is preserved by both xterm and the socket, and a frame for a pane the page has not
/// created yet is simply dropped, the same as any frame for a session it does not know — see
/// <c>terminal.js</c>'s <c>handleFrame</c>.
/// </para>
/// </remarks>
private async Task ReplayAfterAttachAsync()
{
KeyValuePair<uint, LiveSession>[] live;
lock (sessionGate)
{
live = [.. sessions.Where(entry => !entry.Value.Run.IsCompleted)];
}
try
{
foreach (var (sessionId, session) in live)
{
session.Pump.Credits.Reset();
await dataPlane
.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.SessionOpened, sessionId, ReplayMarker),
CancellationToken.None)
.ConfigureAwait(false);
}
RendererReattached?.Invoke(this, EventArgs.Empty);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Best-effort, same as every other fire-and-forget path here: a page that dies again mid-replay
// leaves nothing worse than the problem this method exists to fix, and there is no caller on
// this thread left to hand a failure to.
}
}
private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump) private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump)
{ {
try try
@@ -578,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)
@@ -594,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>
@@ -0,0 +1,219 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Layout;
using Avalonia.VisualTree;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The button shapes that centre their caption do, and the two that deliberately do not still fill.
/// </summary>
/// <remarks>
/// <para>
/// ◆ THE DEFECT THIS EXISTS FOR was read as a height problem and was not one. <c>App.axaml</c>'s
/// <c>Button.ghost, Button.accent, Button.danger</c> rule set <c>VerticalAlignment</c> — where the button
/// sits in its parent — and never <c>VerticalContentAlignment</c>, where the caption sits in the button.
/// Avalonia's default for the second is <c>Stretch</c>, so on any of these given a fixed <c>Height</c> the
/// content presenter stretched the caption's <see cref="TextBlock"/> to the whole content box, and a
/// <see cref="TextBlock"/> draws its line at the TOP of its bounds. The hosts toolbar's three 40-pixel
/// buttons measured 9 pixels above the ink and 20 below it. Every box was the height it declared, which is
/// exactly why it read as one being wrong: nothing was mis-sized, the labels sat in the top third.
/// </para>
/// <para>
/// A bare <see cref="Button"/> rather than a screen, because <c>Application.Styles</c> is global — the same
/// property <see cref="LayoutHarnessTests"/> leans on — so this measures the style rule itself rather than
/// one of the hundred-odd places it lands. A screen-level test would pin one toolbar and leave the dialogs,
/// the drawer's Save/Cancel pair and the import screen's buttons uncovered by the thing that fixed them all.
/// </para>
/// <para>
/// ◆ THE LINE BOX IS MEASURED, NOT THE TextBlock's ARRANGED BOUNDS, and the difference is the whole test.
/// A stretched caption's <see cref="Visual.Bounds"/> fill the content box, so they are symmetrical about
/// the button's middle under the defect just as they are under the fix — the first draft of this suite
/// asserted on them, passed on both, and was caught only by
/// <see cref="AStretchedCaption_IsNotCentred_AndIsCaught"/>. What actually moves is where the line sits
/// INSIDE those bounds: <see cref="TextBlock"/> draws at the top of whatever it is given, so the ink is
/// centred only when the box it is drawn in is its own line box.
/// </para>
/// <para>
/// The gaps are compared to each other rather than to a number. What the caption's own line box measures is
/// a property of JetBrains Mono at whichever size the caller set — 11.5 by default and 13.5 on the primary
/// 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.
/// </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>
public sealed class ButtonCaptionTests
{
/// <summary>Taller than any caption these carry, which is the condition that exposes the defect.</summary>
/// <remarks>
/// The hosts and keychain toolbars' own number. A button left to size itself cannot show this at all:
/// its content box is its caption's line box exactly, so <c>Stretch</c> and <c>Center</c> agree and a
/// test written against one would pass under either.
/// </remarks>
private const double FixedHeight = 40;
/// <remarks>One pixel, for a content box whose odd leftover cannot be halved evenly.</remarks>
private const double Tolerance = 1;
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Theory]
[InlineData("ghost")]
[InlineData("accent")]
[InlineData("danger")]
[InlineData("navuser")]
[InlineData("poprow")]
[InlineData("panechip")]
[InlineData("chiptoggle")]
[InlineData("choice")]
public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape)
{
await MeasureAsync(
shape,
alignment: null,
(above, below) =>
Math.Abs(above - below).ShouldBeLessThanOrEqualTo(
Tolerance,
string.Create(
CultureInfo.InvariantCulture,
$"Button.{shape} left {above:0.#} above the caption and {below:0.#} below it.")));
}
/// <summary>
/// The instrument's own calibration: the check fails on the arrangement the fix replaced.
/// </summary>
/// <remarks>
/// The same practice <see cref="LayoutHarnessTests"/> states for the clipping harness, and it earns its
/// place here for the same reason. Everything above passes if <c>VerticalContentAlignment</c> is Center
/// AND it passes if the caption happens to fill its box, so without this a rule quietly reverted to
/// Stretch would have to be caught by eye again. Stretch is set inline here rather than by editing the
/// style sheet, because <c>Application.Styles</c> is global and a test that mutated it would be changing
/// every other test in the assembly out from under itself.
/// </remarks>
[Fact]
public async Task AStretchedCaption_IsNotCentred_AndIsCaught()
{
await MeasureAsync(
"ghost",
VerticalAlignment.Stretch,
(above, below) => (below - above).ShouldBeGreaterThan(
Tolerance,
"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(
string shape, VerticalAlignment? alignment, Action<double, double> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var button = new Button { Content = "Group ▾", Height = FixedHeight };
button.Classes.Add(shape);
if (alignment is { } forced)
{
button.VerticalContentAlignment = forced;
}
var window = LayoutHarness.HostAtMinimumSize(
button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
var caption = button.GetVisualDescendants().OfType<TextBlock>().Single();
var origin = caption.TranslatePoint(default, button);
origin.ShouldNotBeNull("the caption is not in the button's visual tree");
// The laid-out line rather than caption.Bounds.Height, which under Stretch is the
// content box and so is symmetrical whether or not the ink in it is — see the remark on
// the class.
var line = caption.TextLayout.Height;
var above = origin.Value.Y;
var below = button.Bounds.Height - above - line;
assert(above, below);
}
finally
{
window.Close();
}
},
Token);
}
@@ -1481,17 +1481,29 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
} }
/// <remarks> /// <remarks>
/// <para>
/// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own /// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own
/// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote /// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote
/// listing carrying every colour state a row can show (a directory, an executable, a world-writable /// listing carrying every colour state a row can show (a directory, an executable, a world-writable
/// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either /// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either
/// pane. /// pane.
/// </para>
/// <para>
/// Both pane headers carry a deep path on purpose, and the paths are set here rather than left to the
/// fixture's real filesystem. The header's ellipsis only works because the path sits alone in a star
/// column — see the pane header remark in <c>TransfersScreen.axaml</c> — and the regression it guards
/// against armed itself only on a machine whose home directory happened to be long: CI's per-job HOME
/// found it, every developer machine's short profile path missed it. A pinned sixty-character path asks
/// the question on every machine alike.
/// </para>
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget() public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget()
{ {
transfers.IsConnected = true; transfers.IsConnected = true;
transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222"; transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
transfers.LocalPath = "/home/deployment-service/.cache/build/workspaces/site/artefacts";
transfers.RemotePath = "/srv/releases/site/shared/uploads/production/2026/08/nightly";
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry( transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File, "docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
@@ -0,0 +1,47 @@
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Transfer;
namespace DodoSSH.Client.App.Tests;
/// <summary>What the local pane's root chips are called.</summary>
/// <remarks>
/// A chip carries a name and never a path — the path is the chip's command parameter, where its length costs
/// nothing. The derivation used to be <c>TrimEnd(separator)</c>, which is a name only for a Windows drive:
/// on Unix it made the <c>/</c> chip an empty pill and the home chip the whole home path, and a home
/// directory deep enough pushed the pane header's own buttons out of the window — CI's per-job HOME is what
/// finally said so. These pin the derivation with fixed strings, so the question no longer depends on how
/// long a path any particular machine keeps its profile under.
/// </remarks>
public sealed class RootChipNameTests
{
[Fact]
public void TheUnixRootIsASlash_NotAnEmptyPill() =>
TransfersViewModel.RootChipName("/").ShouldBe("/");
/// <remarks>
/// The one substitution rather than a shortening: every shell already means "my home directory" by
/// <c>~</c>, and the machine's real home path — however deep — stays on the chip's command alone.
/// </remarks>
[Fact]
public void TheHomeChipIsATilde_HoweverDeepHomeSits() =>
TransfersViewModel.RootChipName(LocalDirectory.Home).ShouldBe("~");
[Fact]
public void AMountNamesItselfByItsLastSegment() =>
TransfersViewModel.RootChipName("/media/usb0").ShouldBe("usb0");
/// <remarks>
/// Windows only, because the input only exists there — <c>LocalDirectory.Roots()</c> produces drive
/// roots on no other platform, and <c>Path.GetFileName</c> reads <c>C:\</c> differently on Unix.
/// </remarks>
[Fact]
public void AWindowsDriveKeepsItsTwoCharacterName()
{
if (!OperatingSystem.IsWindows())
{
return;
}
TransfersViewModel.RootChipName(@"C:\").ShouldBe("C:");
}
}
@@ -2,6 +2,7 @@ using System.Globalization;
using DodoSSH.Client.Auth; using DodoSSH.Client.Auth;
using DodoSSH.Client.Domain; using DodoSSH.Client.Domain;
using DodoSSH.Client.Import; using DodoSSH.Client.Import;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session; using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace; // FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
// see the csproj for why it is shared rather than reimplemented. // see the csproj for why it is shared rather than reimplemented.
@@ -1253,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
@@ -5845,6 +5908,60 @@ public sealed class ShellFlowTests : IAsyncLifetime
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked"); Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
} }
/// <remarks>
/// The other refusal at the same door, and the one that needs two keychains to raise: a group is an
/// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault
/// seeing a machine filed under nothing. Checked when the picker is asked for and over the whole set —
/// see <see cref="VaultViewModel.RegroupChosenHosts"/> — rather than once per host mid-write, which is
/// why no panel opens at all and the status line's sentence has to carry the whole explanation.
/// </remarks>
[Fact]
public async Task RegroupingHostsChosenAcrossTwoKeychains_IsRefusedBeforeThePickerOpens()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = "Platform secrets";
await vaults.CreateVaultCommand.ExecuteAsync(null);
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
await AddHostAsync(vault, "prod-db");
vault.NewHostCommand.Execute(null);
vault.EditorSelectedVault =
vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.EditorLabel = "prod-web";
vault.EditorHostname = "web.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
vault.RegroupChosenHostsCommand.Execute(null);
vault.IsRegroupingChosenHosts.ShouldBeFalse("a group belongs to one keychain");
vault.Status.ShouldStartWith("These hosts are in more than one keychain");
vault.IsChoosingHosts.ShouldBeTrue("the set was refused, not dissolved");
// Unticking the visitor is all it takes: the refusal is about the set, not a latch the screen has
// to be talked out of.
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
vault.RegroupChosenHostsCommand.Execute(null);
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
}
/// <remarks> /// <remarks>
/// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into /// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into
/// another vault: the copy stays in the same keychain, so everything it points at is still there. /// another vault: the copy stays in the same keychain, so everything it points at is still there.
@@ -8005,6 +8122,221 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]); shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]);
} }
/// <remarks>
/// The phone's Files-screen chip row, proven at the view model rather than through Avalonia: a pin
/// saved on the host before this screen ever connects to it is read straight off the row's own
/// <c>HostSecret.PinnedPaths</c> at the moment <c>MarkHostConnected</c> runs, which is what
/// <see cref="TransfersViewModel.ConnectedPinnedPaths"/>'s own remark promises rather than a live follow
/// of the vault.
/// </remarks>
[Fact]
public async Task ConnectingATransfersHostWithPins_PopulatesConnectedPinnedPaths()
{
var vault = await ReadyToConnectAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
shell.Transfers.Attach(vault, knownHosts);
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
shell.Transfers.ConnectedPinnedPaths.ShouldBe(["/var/www/app"]);
shell.Transfers.HasConnectedPins.ShouldBeTrue();
}
/// <remarks>
/// The other half of <see cref="ConnectingATransfersHostWithPins_PopulatesConnectedPinnedPaths"/>: the
/// chip row has to go with the connection it belongs to, or a later connect to a host with no pins would
/// show the previous host's.
/// </remarks>
[Fact]
public async Task DisconnectingTheTransfersScreen_ClearsConnectedPinnedPaths()
{
var vault = await ReadyToConnectAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
shell.Transfers.Attach(vault, knownHosts);
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.HasConnectedPins.ShouldBeTrue();
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty();
shell.Transfers.HasConnectedPins.ShouldBeFalse();
}
/// <remarks>
/// The phone's foreground-service question, proven at the view model rather than through Android: a
/// connect that opens an SFTP session is exactly the transition <c>SessionKeepAlive</c> needs to hear
/// about even when no transfer ever moves — see <see cref="TransfersViewModel.ActivityChanged"/>'s own
/// remark for why the queue's own raise, in <c>OnTransferChanged</c>, cannot cover a connect that never
/// touches <c>Transfers</c> at all.
/// </remarks>
[Fact]
public async Task ConnectingATransfersHost_RaisesActivityChangedAndTurnsOnHasLiveFileSession()
{
var vault = await ReadyToConnectAsync();
shell.Transfers.Attach(vault, knownHosts);
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
var raised = 0;
shell.Transfers.ActivityChanged += (_, _) => raised++;
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
shell.Transfers.HasLiveFileSession.ShouldBeTrue();
raised.ShouldBeGreaterThan(0);
}
/// <remarks>
/// The other half: a disconnect is as much a transition the service must hear about as a connect is,
/// because it is the moment the connection <see cref="TransfersViewModel.HasLiveFileSession"/> promised
/// was open stops being true — and the foreground service would otherwise keep the process alive over a
/// session that has already closed.
/// </remarks>
[Fact]
public async Task DisconnectingTheTransfersScreen_RaisesActivityChangedAndTurnsOffHasLiveFileSession()
{
var vault = await ReadyToConnectAsync();
shell.Transfers.Attach(vault, knownHosts);
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.HasLiveFileSession.ShouldBeTrue();
var raised = 0;
shell.Transfers.ActivityChanged += (_, _) => raised++;
await shell.Transfers.DisconnectCommand.ExecuteAsync(null);
shell.Transfers.HasLiveFileSession.ShouldBeFalse();
raised.ShouldBeGreaterThan(0);
}
/// <remarks>
/// A bucket is an <c>IRemoteFileStore</c> with no <c>HostSecret</c> underneath it, so there is no
/// <c>PinnedPaths</c> to read at all — see <see cref="TransfersViewModel.OpenBucketAsync"/>'s own remark.
/// The bucket here is created through the same keychain route
/// <see cref="TheS3ScreenWithNoBuckets_SaysWhereOneIsMadeAndGoesThere"/> exercises, and
/// <see cref="FakeObjectStoreFactory"/> stands in for the network the way <see cref="FakeSshConnectionFactory"/>
/// already does for SFTP.
/// </remarks>
[Fact]
public async Task ConnectingABucket_LeavesConnectedPinnedPathsEmpty()
{
var vault = await ReadyToConnectAsync();
shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());
vault.NewObjectStoreCommand.Execute(null);
vault.BucketEditorLabel = "Backups";
vault.BucketEditorBucket = "backups";
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
vault.BucketEditorRegion = "eu-west-1";
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
// Remote is what ConnectAsync branches on, and Attach's RefreshHosts has already auto-selected the
// host ReadyToConnectAsync left in the picker — without this line the command below dialled that
// host, and every assertion here passed only because that host happens to have no pins either. The
// ConnectedTo check is the proof the bucket path was actually taken.
shell.Transfers.Remote = RemoteKind.Bucket;
shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.ConnectedTo.ShouldBe("s3://backups");
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty();
shell.Transfers.HasConnectedPins.ShouldBeFalse();
}
/// <remarks>
/// A bucket is HTTP, per-request, with nothing open that a dying process would lose — see
/// <see cref="TransfersViewModel.HasLiveFileSession"/>'s own remark. <c>IsConnected</c> alone would have
/// answered this wrongly, which is exactly why the flag reads <c>ConnectedCipher</c> as well: nothing
/// underneath a bucket ever sets it.
/// </remarks>
[Fact]
public async Task ConnectingABucket_LeavesHasLiveFileSessionOff()
{
var vault = await ReadyToConnectAsync();
shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());
vault.NewObjectStoreCommand.Execute(null);
vault.BucketEditorLabel = "Backups";
vault.BucketEditorBucket = "backups";
vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
vault.BucketEditorSecretAccessKey = "a-secret-access-key";
vault.BucketEditorRegion = "eu-west-1";
await vault.SaveObjectStoreCommand.ExecuteAsync(null);
// ReadyToConnectAsync already left a host in the picker, and Attach's own RefreshHosts auto-selects
// it — so without this the CONNECT command below would dial that host rather than open the bucket,
// and a host with no pins would make ConnectedPinnedPathsEmpty-style assertions pass for the wrong
// reason. Remote is what ConnectAsync actually branches on.
shell.Transfers.Remote = RemoteKind.Bucket;
shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
shell.Transfers.ConnectedTo.ShouldBe("s3://backups", "proof this opened the bucket rather than the host");
shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
shell.Transfers.HasLiveFileSession.ShouldBeFalse();
}
/// <summary>A bucket that opens and lists as empty, so a bucket connect can be proven with no network.</summary>
private sealed class FakeObjectStoreFactory : IObjectStoreFactory
{
public IRemoteFileStore Open(ObjectStoreSecret store) => new FakeBucketStore();
}
/// <summary>The minimum <see cref="IRemoteFileStore"/> a bucket connect touches: home, then a listing.</summary>
private sealed class FakeBucketStore : IRemoteFileStore
{
public bool IsConnected => true;
public string HomeDirectory => "/";
public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<SftpEntry>>([]);
public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
Task.FromResult<SftpEntry?>(null);
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
public Task DeleteAsync(string path, CancellationToken cancellationToken) =>
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <remarks> /// <remarks>
/// <para> /// <para>
/// The picker that replaced the desktop's connect bar, and the four things that put it away again. It /// The picker that replaced the desktop's connect bar, and the four things that put it away again. It
@@ -8,6 +8,8 @@ internal sealed class FakeShellSession : ISshShellSession
private readonly List<byte> written = []; private readonly List<byte> written = [];
private readonly Lock gate = new(); private readonly Lock gate = new();
private readonly bool blockReads;
private long remaining; private long remaining;
private byte pattern; private byte pattern;
@@ -16,7 +18,19 @@ internal sealed class FakeShellSession : ISshShellSession
/// endless producer, which is what a runaway remote process looks like — those sessions are ended /// endless producer, which is what a runaway remote process looks like — those sessions are ended
/// by disposing the pump rather than by running out of data. /// by disposing the pump rather than by running out of data.
/// </param> /// </param>
internal FakeShellSession(long bytesToProduce = 0) => remaining = bytesToProduce; /// <param name="blockReads">
/// True for a shell that is open and live but has nothing to say — an idle prompt, rather than either
/// end of the "produces bytes" and "hit end of stream" spectrum <paramref name="bytesToProduce"/>
/// covers. <see cref="ReadAsync"/> then blocks until cancelled, which is what a real idle SSH channel's
/// read does. Exists for tests that need a session whose <c>Run</c> stays live without a background
/// read loop racing the test for control of the pump's credit window — see the reattach tests in
/// <c>TerminalWorkspaceTests</c>.
/// </param>
internal FakeShellSession(long bytesToProduce = 0, bool blockReads = false)
{
remaining = bytesToProduce;
this.blockReads = blockReads;
}
/// <inheritdoc /> /// <inheritdoc />
public bool IsOpen { get; private set; } = true; public bool IsOpen { get; private set; } = true;
@@ -52,6 +66,13 @@ internal sealed class FakeShellSession : ISshShellSession
{ {
ReadCount++; ReadCount++;
if (blockReads)
{
// Never completes on its own. The only way out is the same way a real blocked read ends: the
// token being cancelled, which is what disposing the pump does.
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
}
await Task.Yield(); await Task.Yield();
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@@ -109,7 +130,8 @@ internal sealed class FakeShellSession : ISshShellSession
/// workspace is the layer that decides when a session is over, and that decision is what needs a /// workspace is the layer that decides when a session is over, and that decision is what needs a
/// connection whose shell can be made to end on cue. /// connection whose shell can be made to end on cue.
/// </remarks> /// </remarks>
internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue) : ISshConnectionFactory internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue, bool blockShellReads = false)
: ISshConnectionFactory
{ {
/// <summary>Connections handed out, in order.</summary> /// <summary>Connections handed out, in order.</summary>
internal List<FakeConnection> Connections { get; } = []; internal List<FakeConnection> Connections { get; } = [];
@@ -119,7 +141,7 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue)
SshConnectionRequest request, SshConnectionRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var connection = new FakeConnection(request, bytesPerShell); var connection = new FakeConnection(request, bytesPerShell, blockShellReads);
Connections.Add(connection); Connections.Add(connection);
return Task.FromResult<ISshConnection>(connection); return Task.FromResult<ISshConnection>(connection);
@@ -127,7 +149,8 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue)
} }
/// <summary>A connection that opens fake shells and records its own disposal.</summary> /// <summary>A connection that opens fake shells and records its own disposal.</summary>
internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell) : ISshConnection internal sealed class FakeConnection(SshConnectionRequest request, long bytesPerShell, bool blockShellReads = false)
: ISshConnection
{ {
/// <inheritdoc /> /// <inheritdoc />
public bool IsConnected { get; private set; } = true; public bool IsConnected { get; private set; } = true;
@@ -148,7 +171,7 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
/// <inheritdoc /> /// <inheritdoc />
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken) public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
{ {
Shell = new FakeShellSession(bytesPerShell); Shell = new FakeShellSession(bytesPerShell, blockShellReads);
return Task.FromResult<ISshShellSession>(Shell); return Task.FromResult<ISshShellSession>(Shell);
} }
@@ -142,15 +142,96 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
await ConnectAsync(origin: "https://evil.example")); await ConnectAsync(origin: "https://evil.example"));
} }
/// <remarks>
/// The truth this replaced: a second valid attach used to be a 409, on the theory that one renderer
/// lives for the whole process. Android's WebView does not honour that theory — its renderer process is
/// routinely killed and the page reloads with a fresh socket — so a second valid attach is now a
/// takeover. This asserts both halves: the newcomer gets the connection, and the displaced socket
/// actually goes rather than lingering as a phantom nothing is reading from.
/// </remarks>
[Fact] [Fact]
public async Task ASecondRenderer_IsRejected() public async Task ASecondRenderer_TakesOver_AndTheFirstSocketIsDropped()
{ {
Start(); Start();
using var first = await ConnectAsync(); using var first = await ConnectAsync();
first.State.ShouldBe(WebSocketState.Open); first.State.ShouldBe(WebSocketState.Open);
await Should.ThrowAsync<WebSocketException>(async () => await ConnectAsync()); using var second = await ConnectAsync();
second.State.ShouldBe(WebSocketState.Open);
// The first socket was aborted rather than closed gracefully — Abort skips the close handshake
// entirely, so there is no Close frame for this side to see coming. What a receive on it sees
// instead is the connection simply gone, which the client surfaces as an exception rather than as
// a state that quietly flips on its own; nothing here reads from the socket otherwise, so the
// state alone would not move.
var firstBuffer = new byte[16];
await Should.ThrowAsync<Exception>(async () =>
await first.ReceiveAsync(firstBuffer.AsMemory(), TestContext.Current.CancellationToken));
await using var session = new FakeShellSession(bytesToProduce: 64);
await using var pump = CreatePump(session);
plane.Register(SessionId, pump);
var run = pump.RunAsync(TestContext.Current.CancellationToken);
var opened = await ReceiveAsync(second);
opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
var output = await ReceiveAsync(second);
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
output.Payload.Length.ShouldBe(64);
await SendAsync(
second,
(byte)TerminalClientOpcode.Acknowledge,
TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length));
await run;
}
/// <remarks>
/// <para>
/// The other half of the takeover: a renderer process that dies without a close handshake — which is
/// what a killed Android WebView actually does, no FIN, nothing — must not fault the send path. A
/// faulted send would propagate into <see cref="TerminalSessionPump"/>'s flush loop and freeze a live
/// session; see <see cref="TerminalDataPlane.SendAsync"/>'s remark for why. Disposing the client socket
/// abruptly, with no close handshake sent, is the closest this harness gets to that: the server-side
/// socket is left believing itself open until it actually tries to write to it.
/// </para>
/// <para>
/// Driven straight through <see cref="TerminalDataPlane.SendAsync"/> rather than through a pump, because
/// a pump adds nothing here — the point is entirely about the transport's own contract, and a session
/// layered on top would only leave it unclear whether a passing test proved the transport never threw or
/// merely that the frames never happened to need a live socket.
/// </para>
/// </remarks>
[Fact]
public async Task SendAsync_DoesNotThrow_WhenTheAttachedRendererDiedWithoutClosing_AndAFreshAttachStillReceives()
{
Start();
var first = await ConnectAsync();
first.State.ShouldBe(WebSocketState.Open);
first.Dispose();
// Whether this particular send lands on the OS's send buffer before the peer's absence is noticed,
// or fails immediately, is not the point — either way it must not throw.
await Should.NotThrowAsync(async () =>
await plane.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.Output, SessionId, "before"u8.ToArray()),
TestContext.Current.CancellationToken));
using var second = await ConnectAsync();
await Should.NotThrowAsync(async () =>
await plane.SendAsync(
TerminalFrame.Create((byte)TerminalServerOpcode.Output, SessionId, "after"u8.ToArray()),
TestContext.Current.CancellationToken));
var output = await ReceiveAsync(second);
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
Encoding.UTF8.GetString(output.Payload).ShouldBe("after");
} }
// ---- Frames ---- // ---- Frames ----
@@ -1,3 +1,6 @@
using System.Globalization;
using System.Net.WebSockets;
using System.Text;
using DodoSSH.Client.Ssh; using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Terminal.Tests; namespace DodoSSH.Client.Terminal.Tests;
@@ -214,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.
@@ -228,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);
} }
}; };
@@ -246,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);
@@ -265,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()
@@ -279,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(
@@ -289,6 +318,86 @@ 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 ----
/// <remarks>
/// <para>
/// The scenario the whole fix exists for: a page that lost its socket — killed WebView renderer, or
/// simply a reload — reattaches, and the session that was already running has to come back rather than
/// sit there forever with its output going nowhere.
/// </para>
/// <para>
/// The session's shell blocks on every read rather than producing output, which is what an idle prompt
/// looks like and — for this test — is what keeps its <c>Run</c> live without a background read loop
/// competing with this test over the credit window's exact value.
/// </para>
/// <para>
/// Neither assertion below polls, deliberately. The "before" one does not need to: reserving credit is
/// a synchronous call, so it is true the instant it returns. The "after" one does not need to either,
/// for a subtler reason — <see cref="TerminalWorkspace.ReplayAfterAttachAsync"/> calls
/// <c>Credits.Reset()</c> and only then awaits sending the replay frame for that same session, with no
/// suspension between the two, so by the time this test has received that frame the reset has
/// necessarily already happened. A poll here would only have hidden a real ordering bug behind a
/// generous timeout instead of catching it.
/// </para>
/// </remarks>
[Fact]
public async Task ANewRenderer_ReplaysTheLiveSessionAndResetsItsCredits()
{
var connections = new FakeConnectionFactory(blockShellReads: true);
await using var workspace = CreateWorkspace(connections);
workspace.Start();
using var first = await ConnectRendererAsync(workspace);
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
// The session's own opening frame, sent as soon as the pump starts running. Not a replay, and not
// what this test is about — read and discarded so it cannot be confused for one below.
await ReceiveFrameAsync(first);
var credits = workspace.CreditsFor(sessionId).ShouldNotBeNull();
credits.TryReserve(4096);
credits.Outstanding.ShouldBeGreaterThanOrEqualTo(
4096, "the pump's own read loop may have reserved a buffer's worth on top of this");
using var second = await ConnectRendererAsync(workspace);
var replay = await ReceiveFrameAsync(second);
replay.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
replay.SessionId.ShouldBe(sessionId);
replay.Payload.ShouldBe(new byte[] { 1 }, "a replay is flagged so the page can tell it apart from a fresh open");
credits.Outstanding.ShouldBe(0, "the replay frame above cannot have been sent before the reset that precedes it");
}
/// <remarks>
/// The other half of a reattach: the workspace has replayed what it owns, and this is the seam the
/// shell uses to replay what it owns instead — the font size and the selected tab, neither of which a
/// terminal session knows anything about. <c>MainWindowViewModel</c>'s subscription is what actually
/// does that; this only asserts that the workspace hands it the chance to.
/// </remarks>
[Fact]
public async Task ANewRenderer_RaisesRendererReattached()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
workspace.Start();
using var first = await ConnectRendererAsync(workspace);
var reattachedCount = 0;
workspace.RendererReattached += (_, _) => Interlocked.Increment(ref reattachedCount);
using var second = await ConnectRendererAsync(workspace);
await WaitUntilAsync(() => Volatile.Read(ref reattachedCount) > 0);
} }
// ---- Helpers ---- // ---- Helpers ----
@@ -362,12 +471,76 @@ public sealed class TerminalWorkspaceTests
private static InMemoryTerminalAssetProvider StubAssets() => private static InMemoryTerminalAssetProvider StubAssets() =>
new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal) new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
{ {
[TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()), // The placeholders, not a token and URL already filled in — the reattach tests below have to
// connect a real renderer, and doing that by reading them back out of the served page is what
// proves the workspace serves a page a real renderer could actually attach with, rather than
// one that merely looks servable.
[TerminalDataPlane.PagePath] = new(
"text/html; charset=utf-8",
Encoding.UTF8.GetBytes(
$"<html><body data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></body></html>")),
}); });
private static SshConnectionRequest Request() => private static SshConnectionRequest Request() =>
new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")); new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
/// <remarks>
/// Attaches the way the real page does: by fetching the served page, reading the token and socket URL
/// back out of it, and presenting them on the upgrade — rather than reaching into the workspace for a
/// token it does not expose. A shortcut here would prove only that a socket can be opened, not that the
/// workspace serves a page a renderer could actually attach with.
/// </remarks>
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
{
using var http = new HttpClient();
var page = await http.GetStringAsync(workspace.PageUrl, TestContext.Current.CancellationToken);
var token = ExtractAttribute(page, "data-token");
var socketUrl = ExtractAttribute(page, "data-socket");
var client = new ClientWebSocket();
client.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
client.Options.AddSubProtocol($"token.{token}");
client.Options.SetRequestHeader(
"Origin",
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{workspace.PageUrl.Port}"));
try
{
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
}
catch
{
client.Dispose();
throw;
}
return client;
}
private static string ExtractAttribute(string html, string name)
{
var marker = $"{name}=\"";
var start = html.IndexOf(marker, StringComparison.Ordinal) + marker.Length;
var end = html.IndexOf('"', start);
return html[start..end];
}
private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveFrameAsync(
ClientWebSocket socket)
{
var buffer = new byte[64 * 1024];
var result = await socket.ReceiveAsync(buffer.AsMemory(), TestContext.Current.CancellationToken);
TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload)
.ShouldBeTrue();
return (opcode, sessionId, payload.ToArray());
}
/// <remarks> /// <remarks>
/// Polled rather than awaited on a task, because the point is what an observer of the property /// Polled rather than awaited on a task, because the point is what an observer of the property
/// sees: the pump ends on a thread of its own, and the count has to catch up without anyone /// sees: the pump ends on a thread of its own, and the count has to catch up without anyone