Public Access
56c19b006dca05e1442e1b7ea746eb05a222261e
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2810f7c12b |
Stop the terminal's Enter key dismissing the keyboard it was typed on
The first device run of ADR 0015 found the premise held and the field misconfigured
in a second way: the suggestion strip, autocorrect and predictive text were all
gone, and every Enter closed the keyboard. On a terminal that is every command, so
the phone asked for a tap between each one.
TerminalInputSurface had been left with Avalonia's default ReturnKeyType, and the
same OnCreateInputConnection that carries the inputType fix maps that default, on a
field that is not Multiline, to IME_ACTION_DONE:
_ => options.Multiline ? 1073741824 : 6, // 6 is IME_ACTION_DONE
Done means done. The keyboard performs the action and dismisses itself, which is
right for the text box the default was written for and wrong for a surface where
Enter is punctuation rather than submission.
TextInputReturnKeyType.Return is the member that maps to IME_ACTION_NONE — value 1,
so `returnKeyType - 1 => 0 => 1073741824` — leaving Enter a plain newline with no
action attached and no reason for the IME to hide.
◆ THE TRAP, WHICH IS THE ONE 11.10b ALREADY WARNED ABOUT FROM THE OTHER SIDE.
Multiline=true also reaches IME_ACTION_NONE, through that same default arm, and is
the obvious fix and the wrong one. It ORs TYPE_TEXT_FLAG_MULTI_LINE into InputType,
which moves it off 1 and silently fails the ShowSuggestions guard the whole ADR
turns on. Taking the obvious route to a working Enter would have handed the
suggestion strip back and looked like the premise had failed rather than like the
fix had. ReturnKeyType touches imeOptions only, which is why it is what is set.
Enter's byte was never the problem and is not what changed: IME_ACTION_DONE was
already synthesising KEYCODE_ENTER through AvaloniaInputConnection.PerformEditorAction,
so commands did run — the keyboard just left afterwards.
OnTextInput now also normalises line endings to CR. Belt and braces for the case
this change makes newly possible: a keyboard with no editor action attached may
commit "\n" as ordinary text rather than sending KEYCODE_ENTER, and a shell handed
LF for Enter looks like a shell that ignored the key. Normalising rather than
special-casing also stops a pasted "\r\n" running its line twice.
Check 11.10e is the regression test, and says in as many words not to fix a
recurrence with Multiline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c0dd5a147e |
Give the phone's terminal its own keyboard, and stop refereeing a focus fight
Typing into a shell on the phone has been the worst thing about this head, and
the reason is not the renderer. It is that the keyboard over the terminal was
configured by Chromium rather than by us: a prose keyboard, with a suggestion
strip, autocorrect, autocapitalisation and a predictive layer that inserts text
ahead of the cursor — over a surface where every character is a command.
The obvious fix does not exist. xterm.js already sets every hint the web
platform has on its helper textarea — autocorrect, autocapitalize and
spellcheck are all off in the vendored bundle — and Android ignores all three,
because they are advisory. What actually silences a keyboard on this platform
is an inputType carrying TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, which is what
Termux's own TerminalView sets and the reason typing in Termux feels like a
terminal. A page inside a WebView cannot reach that field: Chromium derives the
whole EditorInfo from the focused DOM node, and no attribute produces the
visible-password variation.
Avalonia can reach it, on the version already pinned here. Decompiled from
Avalonia.Android 12.1.1, AndroidInputMethod.OnCreateInputConnection:
if ((int)outAttrs.InputType == 1 && options.ShowSuggestions == false)
outAttrs.InputType = (InputTypes)(outAttrs.InputType | 0x80080);
0x80080 is VISIBLE_PASSWORD | NO_SUGGESTIONS — Termux's pair exactly. So the
keyboard behaviour wanted is one attached property away from any Avalonia
control, and permanently out of reach of the WebView. That asymmetry is the
whole decision; ADR 0015 records it, along with the two stages deliberately not
taken and what would falsify the premise.
◆ THE INPUT PATH LEAVES THE WEBVIEW. THE RENDERER DOES NOT MOVE.
TerminalInputSurface is a 1x1 focusable control that draws nothing and is where
every character typed into a shell now arrives. It is not stretched over the
renderer, which would be the obvious shape and is wrong three times over: the
WebView composites above everything Avalonia paints so it would be invisible,
it would never see the touches the WebView consumes first, and it would sit
over the connect page sharing that Panel whenever no session is open. Focus
does not need area.
Its IME client retains nothing and claims nothing — no surrounding text, no
preedit — because a terminal has no document for an input method to edit. The
line being edited is on the far side of an SSH channel and this client has
never been told what is on it.
◆ THE FOCUS FIGHT ENDS BY REMOVING A CONTESTANT.
TerminalFocus is deleted rather than amended. It existed to hand Android's own
view focus back to the WebView after AvaloniaView.DispatchTouchEvent took it at
both ends of every touch, and it was correct for as long as the input
connection belonged to the page. It no longer does, so the focus the platform
hands to Avalonia's view is now the focus the keyboard wants. The accessory
row's two posted counter-moves go with it; the keys stay Focusable=false, which
is now what keeps Avalonia's focus on the input surface.
RendererFocus replaces it with the opposite move: the WebView is made
non-focusable, so it cannot take an input connection and Chromium's own
showSoftInput cannot succeed from it. Non-focusable is not non-interactive —
focusability governs keyboard input, not touch delivery — so the page keeps its
pan-y scrollback untouched. What it can no longer do is raise a keyboard, so
taps on it are observed with Handled=false throughout and reported, filtered by
the platform's own ScaledTouchSlop. Without that filter, dragging the
scrollback would raise the keyboard over the output being scrolled to.
◆ TWO THINGS FOUND ON THE WAY, ONE OF THEM OLD.
The Ctrl latch never worked with the software keyboard. Not a regression and
not new — everything typed went straight into the page and never passed
SendAsync, which is where the latch lives, so `ctrl` then `c` was inert unless
the second key also came from the accessory row. Routing input through Avalonia
fixes it as a side effect, and check 11.10d exists to keep it fixed.
Avalonia raises a text-input event for any key whose UnicodeChar is 32 or
above, whether or not the key event was handled — it reads the handled flag
only to fold into its own return value. Ctrl-C on a hardware keyboard therefore
produced 0x03 and then a literal c. controlByteAlreadySent swallows the second.
The keys in TryTranslate need no such guard, their UnicodeChars being below 32.
One deliberate loss, in the open: Delete sends backspace. Avalonia turns an
IME's deleteSurroundingText into a dispatched KEYCODE_FORWARD_DEL, so a soft
keyboard taking that route is indistinguishable here from a real Delete key —
and a backspace that deletes forwards on every phone is worse than a missing
forward-delete on the few devices that have one.
Nothing here is verifiable without a device, so the checks are written rather
than the tests: 11.10b is the falsification test and should be run first,
because everything staged on this stands or falls on whether one attached
property actually silences a real keyboard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
cfc2e3ae0c |
Notice when a file session stops, and give the file browser the four things it was short of
The commit before this one taught a terminal to notice its machine going away.
The file browser had the same defect and three of the same symptoms, and it is
also the screen people leave soonest — so the disconnect work and four things it
was missing arrive together, because they are the same complaint: this screen
knows less about the host than the host does.
◆ THE DISCONNECT HALF, WHICH IS THE OTHER SURFACE OF THE LAST COMMIT.
IsConnected was set at connect and cleared only by the DISCONNECT button. A host
that went away therefore left this screen saying CONNECTED over a session that
was gone, and every action after it failing with the same server message and
nothing saying why they all did.
IRemoteFileStore now carries a latched TransportFailure and a ConnectionLost
event. The latch is the shell's own — read ISshShellSession.TransportFailure for
the argument, which is unchanged. The event is not, and it is the part this
surface needs: a file browser is idle between operations, so without something
raised at the moment the transport fails, nobody finds out until they click. A
bucket implements both and raises neither, which is honest rather than a gap:
S3 is request-per-operation with no connection to lose.
- THE LISTING STAYS ON SCREEN, and that is the half that makes the rest worth
having. SftpEntry is a record of what the server said rather than a handle,
so the pane can go on showing where the user was — which is what RECONNECT
is offering to put them back into. That needed a new ShowsRemoteContents on
both heads: every "is there anything here" binding read IsConnected, which
was right while the only way to stop being connected was to press a button
that empties the pane, and wrong the moment a session could be lost.
- RECONNECT re-dials through the ordinary connect path — same host key gate,
same refusals, same log entry — and then walks back to the directory the
pane was showing. The path is read off RemotePath rather than captured,
because the pane already keeps it up to date and a copy is a second thing to
get wrong. A host wanting a typed password is asked for one rather than
dialled with an empty box.
- SessionEndedBar is now one control on both surfaces, with the shell
resolving which it is drawn over: a terminal has two endings to tell apart
and a keychain entry to check, a file session has one ending and already
captured what it would dial. The phone's Files screen has its own copy,
bound to TransfersViewModel directly, because its DataContext is that rather
than the shell.
◆ PERMISSIONS: THE PERMS COLUMN STOPS BEING READ-ONLY.
It has been a true fact about a file with nothing to be done about it since it
arrived, and the commonest next act after using this screen was opening a
terminal — a whole second authenticated connection — to chmod what had just been
uploaded.
Nine checkboxes rather than a box to type 755 into, because the mode is already
drawn as -rwxr-xr-x in the column the form was opened from: a who-can-do-what
grid is the same representation the reader is holding, where an octal field asks
them to translate something they can see into something they cannot check. The
octal is printed underneath as it changes, so the two readings check each other.
The EXEC column is headed "enter" over a directory, which is what that bit means
there and the commonest misreading of a POSIX mode. The listing is re-read after
APPLY, because a umask, an ACL or a filesystem mounted without permissions can
all land somewhere other than what was asked for.
Beside it, mode and mtime carried across a transfer — which is what makes an
uploaded deploy.sh arrive executable instead of with the server's umask on it.
THE MODE IS ONLY SENT FROM A PLATFORM THAT HAS ONE. A file on Windows has no
POSIX mode; this screen already says so by leaving the local pane's PERMS column
empty rather than filling it with a plausible -rw-r--r--, and inventing 0644, or
0755 for anything called .sh, would be this client deciding a fact about
somebody's file that nothing on the machine it came from ever said. The
timestamp goes either way, because every platform has one. A store with neither
refuses in its own words and is never asked.
◆ MULTI-SELECT: A TICK IS NOT THE LIST'S SELECTION.
The two answer different questions and the split is the whole design. The
selection is "the row I am looking at", which rename, move and permissions act
on — each takes a form naming one path and is one-at-a-time by construction. The
tick is "the rows I have gathered up", which download, upload and delete take,
and it survives the highlight moving.
With nothing ticked the batch falls back to the selection, which is what keeps
the commonest use of this screen from growing a step — the usual way a
multi-select makes a tool worse. Ticks are released once a set is queued, or a
second press sends everything again.
Deleting several names every path rather than a count, because agreeing to "3
items" is agreeing to a number, and this is the one act on this screen that
nothing walks back. Each goes on its own and a refusal does not stop the rest:
the host turns down a directory that still has something in it, and in a set of
five that must not silently cancel the four it would have removed — nor hide
which ones it did not.
A drawn checkbox rather than ctrl-click, and not for taste: the phone shares
this view model and has neither ctrl nor shift, so a multi-selection has to be a
tap target there whatever the desktop does. One shape on both heads, and a state
somebody can see.
◆ RECURSIVE FOLDERS: THE TREE BECOMES FILES BEFORE THE QUEUE SEES IT.
A directory used to be counted and skipped. It is now walked, and the queue
still knows nothing about trees — which is what keeps every property it has:
each file resumable on its own, each written to a part file and renamed into
place, none able to overwrite anything already there.
The plan is collected whole and then queued, because directories have to exist
before the files that go in them and shortest-first is the only order that
guarantees a parent before its child. It also means a walk refused halfway has
queued nothing yet, so the sentence it produces describes one outcome rather
than an outcome and a queue that half happened.
Links are counted, not followed, in both directions. One pointing at an ancestor
is a walk with no end, and on the remote side there is a second reason: an lstat
listing says a name is a link and not what it leads to, so descending would be a
round trip per entry to find out. Locally that is AttributesToSkip on the one
enumeration that decides what to descend into. A 5000-file ceiling is a backstop
for the accidental drag of a home directory, and it says so rather than
truncating quietly.
The status line grew four more ways to be partly what was asked for, and states
each: gone, refused, left alone, not a file.
◆ EDITING IN PLACE: FETCH, OPEN, WATCH, PUSH BACK.
The round trip this replaces is download, find it, open it, edit, come back,
delete the old one because nothing here overwrites, upload, check the mode
survived.
DELIBERATELY NOT THROUGH THE TRANSFER QUEUE, in either direction. The queue
refuses to overwrite, which is the right rule for a file somebody dragged and
exactly the wrong one for a file they are editing, where replacing the original
is the entire intent. The write-back deletes first: OpenWriteAsync at offset
zero writes over the beginning and leaves any tail that is longer — its own
contract says so — which for a config that got shorter means a file ending in
the back half of its previous version. Deleting is the only truncation this
interface offers, and the mode goes back on afterwards because deleting took it.
A stat before every save, compared against what the server said when the copy
was taken, catches somebody else writing while the file is open — which is how a
config gets silently reverted. There is no merge and no button pretending
otherwise; the three answers offered are the three that exist. Length and
timestamp rather than a digest, because a digest is a second full read of a file
there is otherwise no reason to fetch.
Both platform halves are injected: what opens a .conf is a registry
association, a desktop entry or a Launch Services binding depending on the
desktop, and watching a file is a platform object with its own thread. The
watcher watches the directory and filters the name, because a great many editors
write a temporary file and rename it over the top — which to a watcher pointed
at an inode is the file disappearing. The phone gets neither delegate, so the
button is not live there rather than there and disappointing.
◆ TWO THINGS FOUND ON THE WAY, BOTH WORTH STATING.
The shell suite's inline `post` was not serialising the way a dispatcher does.
Routing TransfersViewModel through it exposed a check-then-add in
OnTransferChanged that two threads turn into two rows for one transfer. Harmless
in production, where the UI thread serialises — the fixture was under-specifying
what it stood in for, and now posts under a lock.
And the edit session replaced its own record with `with` on each save, which
left the watcher holding an instance that was no longer the current one: every
save after the first quietly did nothing. The stamp moves in place now, and the
test saves twice.
◆ WHAT IS VERIFIED.
901 across the five suites this touches, 19 of them new. The layout suite earned
its keep three times over: the batch deletion card's path list overflowed the
204-pixel pane at 120, at 72 and at 56 before 32 held, and the edit strip
overflowed by 131 until the whole strip — rather than the log inside it — was
bounded. Each number in those files is measured and says which failure it came
from, because the next person to add a line to either card will move them again.
What no suite covers is the platform halves of editing: Process.Start with
UseShellExecute and a live FileSystemWatcher are the operating system, and a test
waiting on one would be measuring it. The shell's own half — when a save is
written back, when it is refused, what stopping releases — is covered against a
watcher that fires on demand.
|
||
|
|
e2733e5cbc |
Notice when a shell ends, say which way it ended, and offer the way back
A terminal whose machine rebooted stayed green for ever. Not "went stale and
recovered late" — for ever: the tab claimed CONNECTED, the elapsed timer went
on counting, and the only thing that ever corrected it was closing the
application. That is the defect this fixes, and it turned out to be two
separate failures wearing one symptom.
◆ THE HANG IS IN SSH.NET, AND KEEP-ALIVE ALONE WOULD NOT HAVE FIXED IT.
ShellStream ends a blocked read from exactly two places: its channel being
closed, and the session raising Disconnected. A shell that exits gives the
first. But Session raises Disconnected from one method only — OnDisconnectReceived,
for a server that politely says goodbye — and a socket that simply fails goes
down a different path entirely: RaiseError fires the error event, sets the
disconnecting flag and disposes the socket, without ever raising Disconnected.
So the stream was never disposed, its Monitor.Wait was never pulsed, and the
read blocked until the process ended. The transport was gone, the client knew
the transport was gone, and nothing woke the one thread that was waiting.
That is why the first attempt at a test for this sat at thirty seconds and
failed. Both halves are needed and neither is sufficient:
- Keep-alive makes the failure HAPPEN. An idle SSH session is a socket
nobody writes to, and TCP has no way to discover that the far end has gone
without writing to it — nothing was asked, so nothing could fail. Thirty
seconds, matching OpenSSH's own ServerAliveInterval examples. It is the
write that matters and not a reply: SSH_MSG_IGNORE need not be answered,
so this is traffic rather than a heartbeat with a deadline, and there is
no timeout invented here to be wrong about a slow link.
- Disposing the stream from the error handler makes it ARRIVE. Queued to the
thread pool and not done inline, which is not tidiness: this runs on
SSH.NET's message-listener thread, and closing a channel waits for the
server's close — processed by that same thread — so an inline dispose is
the listener thread waiting on itself.
SFTP gets the keep-alive too. Its operations are request/response and bounded
by ConnectionInfo.Timeout already, so it needs no equivalent wake-up.
◆ exit AND reboot ARE TOLD APART BY A LATCH, BECAUSE THE OBVIOUS WAY IS A RACE.
Wait for the read to end and then ask whether the transport is still up, and
against a real server the answer is wrong about half the time: this was the
only channel on the connection, so sshd hangs up a millisecond after closing
it. By the time anything could ask, the transport is gone either way — and a
poll would report every ordinary `exit` as a dropped connection.
So ISshShellSession.TransportFailure is set at the moment SSH.NET raises the
failure, which its own ordering puts strictly before the disconnect that wakes
the read. A read that has just returned zero consults a settled value: set if
the transport failed, still null if the remote merely closed the channel. The
pump turns that into a TerminalSessionOutcome — RemoteClosed, ConnectionLost,
ClosedLocally — carried on SessionEnded, which used to hand over a session id
and nothing else. A subscriber cannot work this out afterwards; by the time it
hears, there is no transport left to interrogate.
Deliberately no fourth value for "the remote rebooted". Nothing on the wire
distinguishes a reboot from a cable being pulled, and a name this client
cannot check would be a guess dressed as a fact.
◆ WHAT IS DRAWN, AND WHY IT IS NOT DRAWN OVER THE PANE.
The status bar says DISCONNECTED or SHELL EXITED where it used to say NOT
CONNECTED for both — a word that is honest about a tab still dialling and
useless about one whose shell has stopped. Red for the first and grey for the
second: one is a machine that stopped answering and the other is work
finished, and crying wolf on the ordinary end of every session is its own bug.
The tab strip's dot gains the same fourth state, on both heads.
Under the terminal, a strip carrying the reason and two buttons. It takes its
own Auto grid row rather than overlaying the renderer, and that is forced
rather than chosen: the WebView is a native child view composited above
everything Avalonia draws, so the alternative is collapsing the terminal — and
the scrollback is precisely what the user wants to read, because the last
thing the remote said is usually why it went. terminal.js already writes the
same sentence into the pane; this is the copy that does not scroll away.
RECONNECT reuses the tab in place, which is what the word means to somebody
who pressed it: the terminal they were working in comes back, where it was.
It dials through the vault's ordinary connect path, so every refusal — a key
that has left the keychain, an unknown host key, a group default that has
changed since — is decided in the one place that already decides them; what
this adds is which tab the attempt belongs to. The old session is closed as
part of it, because a dead shell still holds an authenticated connection this
process has stopped reading, and its pane still holds an xterm, a scrollback
and a WebGL context, of which a browser hands out about sixteen.
Two refusals it makes rather than pretending: a host reached by typing an
address has no keychain entry to redial, and one that wants a password typed
goes to the keychain with the sheet raised rather than being dialled with an
empty box. Both say so on the strip where the button was pressed, not only in
the vault's status on a screen that surface is not.
TRY AGAIN appears on a refused connection for the same reason, and it is worth
as much: the commonest refusal is a host that was not up yet.
◆ WHAT IS ACTUALLY VERIFIED.
Two tests against a real sshd in a container. `exit` is reported as the remote
closing the session — the one a polling implementation fails, and a fake shell
cannot show it because a fake has no server behind it deciding to hang up. And
a live shell losing its transport is reported as a lost connection, produced
by dialling through a plain TCP relay the test owns and then cutting it; the
container is shared by the collection, so killing sshd was never an option.
The first draft of that second test used LoopbackProxyTests' SOCKS forward and
timed out: disconnecting the carrier of a ForwardedPortDynamic does not close
the loopback socket it accepted. Recorded in the test, because it is a
property of SSH.NET's forwarder rather than of anything here, and it is not
what a dying machine looks like.
What no test reproduces is the waiting. A relay that closes sends a FIN, so
the socket reports itself at once; a machine switched off sends nothing, and
finding that out is the keep-alive's business and the OS's retransmit budget's
— neither of which a test can hurry along without a clock this code does not
own. A reboot is prompt in practice because the machine comes back and RSTs.
Beside those: three pump outcomes, three workspace announcements, and seven
shell-flow tests covering the drop, the clean exit, reuse of the tab, retry
after a refusal, and both ways a reconnect can honestly come to nothing.
Also fixed in passing: SshNetShellSession.IsOpen read ShellStream.CanRead,
which is documented as always true — the stream stays readable after disposal
so buffered output can be drained — so it had answered "yes" for every shell
that ever existed. It reads CanWrite now. And MainWindowViewModel's session-
ended handler moved from Dispatcher.UIThread.Post to the injected post
delegate, which is what makes any of the shell half testable at all; the
remark listing the three unreachable posts is one shorter.
|
||
|
|
c2c860b4f2 |
Give the seated user the TPM, so nobody has to join the tss group
ADR 0007 decision 4 refused this a commit ago, on the grounds that widening
access to the machine's TPM is a system-wide decision an application package
should not make for an administrator. That refusal is overturned here, and the
reasoning it is replaced with is narrower rather than louder — because the
refusal had treated "widen access" as one thing when it is two.
Adding a person to `tss` is what the refusal was really about, and it stays the
wrong answer: permanent, effective over SSH, applying whether or not they are at
the keyboard, and needing an administrator and a fresh login before the feature
appears at all.
Tagging /dev/tpmrm0 with `uaccess` is a different thing. It is systemd's own
mechanism for this exact question — the one /dev/dri, webcams, scanners and FIDO
keys already use — and logind grants the ACL to the user of the *active local
session*, then takes it back when that session ends. Not over SSH, not on an
inactive VT, not to daemons. The rule changes neither the mode nor the group, so
everything that worked before still works and the ACL is layered on top.
tpmrm and never tpm, which is what makes it defensible at all: /dev/tpm0 is the
raw device and is exclusive, where /dev/tpmrm0 is the kernel's resource manager,
which virtualises handles and isolates callers precisely so that more than one
unprivileged program can use the chip.
◆ THE COST IS A DENIAL OF SERVICE AND NOT A DISCLOSURE, AND IT IS WRITTEN DOWN.
TPM objects are protected by their own auth values, so this lets nobody read
anybody else's sealed data. What it allows is *failed* attempts — and a TPM
answers enough of those by locking out everyone, which is the same anti-hammering
this project relies on to make a four-digit PIN safe. On a machine that unlocks
its disk with the TPM at boot, somebody sitting at it can trip that and stop the
next boot from unlocking until it recovers.
An administrator who does not accept that masks the file:
sudo ln -s /dev/null /etc/udev/rules.d/71-dodossh-tpm.rules
which is the standard override, survives upgrades, and puts DodoSSH back to
behaving as though the machine had no TPM. That is in the rule's own comments,
because the person who needs it is reading the file rather than the ADR.
Numbered 71 because 60-tpm-udev.rules is what creates the node's ownership and
73-seat-late.rules is what turns the tag into an ACL; verified in that order on
Fedora 44, along with `udevadm verify` accepting the file and the mask being
reported as "masked by previous entry". %post reloads and replays it against the
tpmrm subsystem, because a rule that waits for a reboot is a feature that does
not work on the day it is installed.
Both channels install the same path deliberately: this is about the machine's
TPM rather than about either build, the file is byte-identical from both, and
giving each its own copy would mean removing one package silently revoked the
other's access.
Phase 19 grew a check that the ACL exists for the local session and does *not*
exist for an SSH one, and another that masking the rule really does put the
machine back — then tells you to undo it before the checks that follow.
Also recorded, after it cost five confusing failures: swtpm serves exactly one
client, so a shell readiness probe sitting on the port makes the whole device-key
suite fail with "The TPM closed the connection" and nothing pointing at why.
|
||
|
|
4552538533 |
Let the Linux head check for updates, and seal its device key in the TPM
Two things the RPM shipped without, and the reason they arrive together is
that both are the same question — what may this platform ask of the machine,
and what must it not ask of the user.
◆ UPDATES: THIS CLIENT CHECKS AND FETCHES, AND PACKAGEKIT INSTALLS.
ADR 0013 rule 11 said there was no updater at all, and that went too far. It
conflated noticing that a newer build exists with installing it: the second is
what the trust argument is about, and the first is not — a client that checks
a fixed address and tells you what it found has taken no capability from
anybody. Written as a flat refusal, the rule bought its safety by paying a cost
that was avoidable, and the ADR now says so in its own words.
So LinuxUpdateChannel reads the rolling nightly release, compares versions the
way rpm compares them, downloads the package, verifies its digest and hands the
file to PackageKit over gdbus. Polkit asks; the user may say no; nothing is
torn down before the answer, because ApplyingEndsTheProcess is false and the
shell already knows what that means — it is the phone's shape, and declining an
update must not cost somebody their open shells.
Three details that are load-bearing rather than incidental:
- The comparison is rpmvercmp and not SemVer, because rpm is what will accept
or refuse the install. Same argument ADR 0014 makes for versionCode. The
installed side is read out of the rpm database rather than derived from the
assembly, so the SemVer-to-rpm translation exists in exactly one place. All
28 expected values in RpmVersionTests came out of rpm itself rather than out
of a reading of the algorithm.
- The digest is checked and is not a signature. Velopack verifies its own
downloads and Android checks a signature; an RPM over HTTPS has neither. The
manifest names a SHA-256 and a mismatch deletes the file rather than leaving
something installable-by-hand in the profile directory.
- ONLY_TRUSTED is cleared, which is what routes the request to polkit's
package-install-untrusted action. The package is unsigned, so requiring
trust would fail every update at the last step.
◆ THE DEVICE KEY: SEALED IN THE TPM, BEHIND A PIN THE TPM CHECKS.
ADR 0007's amendment is the bar: a gate inside the process is not a gate. The
key is sealed into a TPM 2.0 keyedhash object whose userAuth comes from a PIN,
so unsealing is a command the chip refuses; the blob is encrypted to the storage
hierarchy and is useless on any other machine; nothing here ever compares a PIN.
The sealed object deliberately carries no NoDA attribute, and that one omission
is the whole reason four digits is enough: every wrong answer advances the TPM's
dictionary-attack counter and the chip stops answering entirely once it trips.
Measured at four wrong tries against a software TPM, after which the correct PIN
gets nothing either. It is asserted by a test rather than left to a reading of
the template, because removing the attribute leaves every other assertion
passing.
This is weaker than Hello and Touch ID and the difference is invisible, so it is
written down in three places rather than smoothed over: those raise their prompt
from inside the platform and this one is a window we draw. The prompt is
deliberately plain for that reason — teaching people that a convincing DodoSSH
PIN box is a system dialog is the one thing this design must not do.
Rejected, each for failing the amendment: the Secret Service API, which has no
per-use consent and is DPAPI's shape exactly; fprintd, which answers a question
and holds nothing, so skipping the call skips the gate; and a polkit-gated root
helper, which would work and puts a privileged component in a package that
installs nothing setuid.
Availability is narrower than "has a TPM": on Fedora /dev/tpmrm0 is root:tss and
desktop users are not in tss, so the ordinary case reports unavailable and keeps
asking for the passphrase. Shipping a udev rule to widen that is refused — it is
the administrator's decision, not an application package's.
◆ WHAT IS ACTUALLY VERIFIED, SINCE THAT IS THE PART THAT USUALLY ISN'T.
This is the only device key store in the repository with real coverage, and it
is only possible because the PIN is a delegate and the TPM is a device: seal,
unseal, wrong PIN, lockout, decline and a foreign blob all run unattended
against a software TPM. Windows and macOS cannot be tested at all — their
prompts block a suite forever, which WindowsDeviceKeyStoreTests records.
TSS.Net's own TcpTpmDevice cannot reach swtpm: it opens the platform socket too
and exchanges a handshake only the Microsoft reference simulator answers, so
Connect() blocks with no error and no timeout. SoftwareTpm speaks the data
channel and nothing else.
The suite also lied once and was fixed: the lockout test trips shared state, and
without a DictionaryAttackLockReset in Dispose every later test got null from a
correct PIN — six of them assert null and passed for entirely the wrong reason.
End to end on a real Fedora 44: a genuine linux-x64 publish packages, installs,
and every DT_NEEDED in the installed tree resolves except liblttng-ust, which is
the one this spec deliberately excludes. What no runner covers is /dev/tpmrm0,
PackageKit and polkit, and Phase 19 grew five checks for exactly those.
Also fixed: the magic-bytes literal picked up raw control characters, which made
LinuxDeviceKeyStore.cs binary to git — no diffs and no line-ending normalisation.
|
||
|
|
1bf79c7c26 |
Package the Linux desktop head as an RPM, and publish a nightly one
The third desktop platform shipped as nothing: a checkout somebody built.
VelopackUpdateChannel already named the two platforms it serves rather than
negating the one it does not, so that a Linux package would be an addition
there rather than something that had silently half-happened. This is it, and
it is deliberately not a Velopack channel.
There is no updater in the package at all. On this platform dnf is already an
updater the user trusts, audits and controls, and a second one answering to
this project instead is the capability ADR 0013 rule 4 spends a page keeping
unsettable. The cost is real and is written down rather than glossed: nothing
tells a Linux user that a newer build exists, because there is no repository
to tell them.
Rule 3 is untouched. The build job packages the release-shaped RPM on every
main build and every tag and uploads nothing, exactly as it does for Windows
and macOS; the desktop job packages the nightly-shaped one and puts it on the
rolling nightly-desktop release beside the Windows feed. Two RPMs with
different names owning different paths cannot replace one another, so rule 9's
separations come almost free here — the profile split still belongs to the
build, through DodoChannel.
vpk verifies that what it packs actually calls VelopackApp.Build().Run(), and
nothing here does that for us, so scripts/ci-linux-rpm.sh makes its own
assertions: it asks the finished package what it requires, what it provides,
what it holds and what modes those carry. Two of those checks exist because
the first realistic payload tripped them.
- .NET's libmscordbi.so links libmscordaccore.so. With bundled provides
excluded — which they must be, or this package would advertise libcoreclr
to the whole system — that produced a dependency no host on earth can
satisfy. The requires exclusion is computed from the payload rather than
listed, because a list of Microsoft's internal file names goes stale in
the direction that yields an uninstallable package.
- cp -a inherits the publish directory's modes, and off the wrong filesystem
that meant a world-writable /opt/DodoSSH: any local account able to
replace the SSH client every other account runs. For an application
holding a team's credentials in plaintext that is the trust model walked
around rather than attacked. Every mode is now set absolutely.
Verified as far as a runner can: both packages install side by side on Fedora
44, resolve webkit2gtk4.1 2.52.5 — the backend MainWindow records as measured
— keep separate launchers, icons and profiles, and dnf remove takes one tree
and leaves the other. 0.0.1~alpha.0.143 sorts below both 0.0.1 and the next
height. What no runner can answer is Phase 19, which is new and unwalked.
RHEL 9 is deliberately not claimed. It ships WebKitGTK's 4.0 line under
libwebkit2gtk-4.0.so.37, and an or-branch for it was written and then removed:
nobody has run the head there, and dnf refusing with "nothing provides
libwebkit2gtk-4.1.so.0" is a better answer than an install that draws a blank
terminal. Same sentence, and the same reason, as the missing Intel Mac.
x86_64 only, for that reason and not a different one.
|
||
|
|
1970e20e56 |
Merge pull request 'Type a path on the SFTP pane, and put a file right without deleting it' (#15) from claude/dodossh-sftp-improvements-3b6305 into main
Reviewed-on: #15 |
||
|
|
b2f93a172b |
Give the file listings' columns their content back
The columns in both panes were fixed pixel widths, and measuring them against what they actually draw found them wrong at both ends of the range this screen gets. ◆ MODIFIED WAS TRUNCATED AT EVERY WIDTH THE APPLICATION HAS EVER BEEN DRAWN AT. It prints a sixteen-character timestamp, which needs 116 pixels, and it had 52. On a maximised window that read "2026-08" beside a NAME column with four hundred empty pixels in it. PERMS needs 60 for a ten-character mode and had 48; SIZE needs 58 for "999.9 GB" and had 56. ◆ AND AT THE SESSION SHELL'S OWN NARROWEST, NAME MEASURED ZERO PIXELS. Twenty for the glyph plus 56+52+48 for the numbers is 176 of the 180 a row has, so the remote pane drew sizes, timestamps and modes for rows whose names were not on screen at all. That is the same fault as the one above rather than a second one: the numbers were picked for the narrow budget, could not have fitted their own content there, and never grew anywhere else. Star columns with a MaxWidth answer both, because the cap is what makes "share the width" mean the right thing at each end. Wide, the three numeric columns stop growing at what their text needs and NAME keeps every remaining pixel. Narrow, nothing carries a MinWidth to overflow with, so all four give ground together and NAME — weighted heaviest — is the last to run out. MODIFIED takes the extra weight so it reaches its cap first, being the widest content and the column most often actually read; the local pane's NAME carries the remote's PERMS weight folded into it, which is what makes SIZE and MODIFIED come out the same width on both sides of the arrow column. Timestamps.Format already argued that both panes write a time the same way so the two can be read against each other, and two different widths would have made that argument for nothing. ◆ THE HEADINGS RAN TOGETHER, and that was visible as one word: SIZE ended on the pixel MODIFIED began at, so the row read "SIZEMODIFIED". They are right-aligned in their own columns, so the gap has to come from a left margin on each — which does not move the label and does stop the column before it reaching this one. The three now carry one, and ellipsize rather than clip where even the label has no room. ◆ TWO PLACEHOLDERS WERE CUT OFF MID-WORD, which reads as a rendering fault rather than as a hint. MKDIR's box drew "new directo" — 101 pixels of text in 74 pixels of room — and says "directory" now, since the button beside it carries the "new" half already. The pane header's path box drew a path ending mid-segment at the narrow budget and says "path": the only time that placeholder shows is on a pane with nothing connected, where the box is disabled and the invitation below is doing the explaining, and what the box is for is in its tooltip where there is room for it. FOUR COPIES OF THE COLUMN DEFINITIONS, one heading row and one row template per pane. Avalonia cannot share a ColumnDefinitions instance between two Grids — each mutates its own computed sizes — and SharedSizeGroup only applies to Auto columns, so what catches a copy that drifts is measurement rather than discipline: every cell is held against the width its own text needs when unconstrained, the headings against each other, and NAME against zero at the narrowest budget. Nothing asserts a number this file also wrote down, which is the failure mode a test of a layout most easily has. The roomy end is measured at the width a maximised 1843-pixel window leaves, which is where this was reported from; LayoutHarness.SessionScreenWidth cannot express it because that constant is pinned to the window's minimum, so the arithmetic is spelled out beside the test that uses it. |
||
|
|
882f01c515 |
Type a path on the SFTP pane, and put a file right without deleting it
ci / api image (pull_request) Successful in 23s
ci / android head (push) Successful in 3m27s
ci / desktop nightly (push) Successful in 53s
ci / build and test (pull_request) Successful in 2m30s
ci / android head (pull_request) Successful in 7m21s
ci / build and test (push) Successful in 2m28s
ci / api image (push) Successful in 36s
ci / desktop nightly (pull_request) Skipped
Three things the file browser could not do. Somewhere to go had to be walked to, a file that landed under the wrong name could only be removed and sent again, and the listing went on showing the directory as it was before this application put something in it. ◆ THE PATH IS A BOX NOW, not a TextBlock printing where the pane was. A breadcrumb only reaches what is under the directory already listed, so crossing a host's filesystem cost a click and a round trip a level, and a path somebody already knew could not be entered at all. Enter goes; Escape puts back what the pane is really showing. What is typed may be absolute, relative to the directory showing, or start at ~ — the four shapes a shell takes, because somebody typing into a box is typing what they would type into a shell. SftpPath.Resolve turns all of them into one absolute path, and it resolves them here rather than handing a relative path to the server: the server would resolve it against the session's own working directory, which is not necessarily the directory the breadcrumb above the box is showing. `..` is collapsed textually, which differs from cd through a symbolic link; the alternative is a realpath round trip before anything lists. The box holds its own value rather than RemotePath itself. Binding it straight to the path would navigate on every keystroke — `/`, then `/v`, then `/va` — a round trip and a failed listing per character. Every other way of moving writes the real path back into it; a refused listing deliberately does not, because the path that did not resolve is the one worth having in front of you. ◆ RENAME AND MOVE, and DELETE moves to join them. It had been sitting in the pane header beside UP and REFRESH — two things that move the pane and one that destroys a file, in one row — and the three that act on the selected row are now together in a row of their own, under the name of the row they are about. One SFTP rename underneath both new operations and two separate questions on the screen, because the answer is a different kind of thing each time: a rename takes a name and must not take a path, a move takes a directory and must not take a name. One box asking for a destination path would be fewer controls and a worse question — the two mistakes it invites are exactly the ones the split makes impossible. Each form opens on the answer that changes nothing, so what gets edited is the half somebody came to change. Three refusals are made here, and they are the ones a server's message would not explain: a separator in a name (no such file, about a directory nobody meant to name), a destination equal to the source (success, and a file that did not move), and a directory into itself (EINVAL, true and useless). Everything else is the server's — a name already taken, a path that is not there, a mount that cannot be crossed — and arrives in the status line in its own words. Neither is painted in the deletion card's red: a rename is undone by renaming back, and spending that red twice makes it stop meaning anything. ◆ THE LISTING REFRESHES ITSELF when a transfer lands in it. Both panes, since an upload lands on the host and a download lands here and either leaves the pane that is showing that directory a step behind. Only the directory actually on screen: a queue moving a hundred files elsewhere must not be a round trip per file for a listing nobody is looking at. It waits rather than interrupting. Another operation running would have two listings racing for one collection, and re-listing under an open question is how the row somebody answered about stops being the row that gets acted on — so the staleness is remembered and settled at the end of the operation or when the question is dismissed. It says nothing either way: nobody asked for it, and the status line at that moment is carrying the news about the transfer. The selection survives, re-found by path, which is what makes an unasked-for refresh safe to run under somebody's pointer at all. ◆ THE LAYOUT SUITE FOUND A REAL FAULT and the fix is where the height goes. The rename form did not fit the 204 pixels the session shell's own sidebar leaves this pane — MKDIR and the listing were pushed off the bottom. The form is shorter now (the placeholder says what the box wants, so there is no field label above it), and the item row and the breadcrumb step aside while any of the three questions is up, which is what the phone head already did with its own buttons. Measuring also settled something the markup had been claiming and is not true: at that width the listing collapses to nothing under any of the three cards, the deletion card included, and has done since the sidebar arrived. So "the row it is about is still on screen" is retired from the deletion card's remark, and the full path each card carries is what identifies the row instead. Recorded where it is drawn rather than left to be rediscovered. TESTS. SftpPath.Resolve gets the four shapes, ~ with and without a home to expand it to, a blank box, and climbing past the root. ShellFlowTests gets nine against a session that records what it was asked to do — which path was renamed, which directories were listed — rather than against the sentence this code writes about itself: the path box absolute and relative, rename in place, the separator refusal, a move by relative path, the no-op refusal, the three questions displacing each other, and the three rules the automatic refresh follows. The local half is in TransferQueueingTests, where a real folder answers. Two new shapes are measured, and the deletion card's own measurement moves to the connected state — the only state it can be asked in, and the narrow one. What the tests cannot reach is in docs/manual-checks.md as phase 2b: a real server's cross-device and name-taken refusals, and a soft keyboard, which is why the phone's path box has a GO button rather than trusting whatever the IME decided to draw on its return key. |
||
|
|
2f8daacb60 |
Merge pull request 'Reconnect the terminal view when somebody comes back to it' (#14) from claude/terminal-reconnection-stuck-4e57ff into main
Reviewed-on: #14 |
||
|
|
ef12e8cc99 |
Stop the data plane's tests racing the socket they just connected
CI went red on the run that added the renderer tests, and not on anything they assert: TerminalDataPlaneTests.Output_ReachesTheRenderer read the output frame where it expected the session's opening one, having lost a race that has been in the helper since it was written. Connected and attached are two different moments. ClientWebSocket.ConnectAsync completes on the 101, which UpgradeAsync writes before it has a WebSocket to attach — it builds one from the stream and swaps it in a few instructions later, on the accept thread. SendAsync drops anything sent in between, which is the transport's documented contract rather than a bug: there is nowhere to put a frame for a renderer that is not there, and queueing it is the unbounded growth the credit window exists to prevent. So a helper that returned on the handshake and let its caller send immediately was betting on thread scheduling, every run, on every test in the file. It only started losing now because that assembly grew nine tests and a JavaScript engine to run them in, which is more work in parallel with a window measured in instructions. The race is older than the branch that exposed it. ConnectAsync now waits for the plane's own SocketAttached, subscribed before the connection because the event can be over before ConnectAsync returns, and bounded so a socket that never attaches fails the helper instead of hanging the suite in a later receive. TerminalWorkspaceTests.ConnectRendererAsync has the same exposure through OpenSessionAsync's opening frame and now waits on WaitForRendererAsync, with a note on why that is enough for the reattach tests and what would stop being enough. ◆ NOTHING IN src CHANGED, AND THAT IS THE CONCLUSION RATHER THAN THE SHORTCUT. Production waits for exactly this moment already — every path that opens a session goes through TerminalWorkspace.WaitForRendererAsync, which resolves from the same few lines that raise the event this helper now waits on. Only the tests skipped the gate the application does not. Verified by widening the window rather than by hunting the flake: a 100ms delay inserted between the 101 and the attach, in a throwaway tree, hangs the old helper outright — both frames dropped, the test blocked in receive — and passes 89/89 with this one. Green three times over on the CI platform besides (Alpine, musl, Release, dotnet/sdk:10.0-alpine). |
||
|
|
963cb7f670 |
Reconnect the terminal view when somebody comes back to it
Going back to a terminal left alone for a while found it stuck on "Reconnecting the terminal view…", and stuck is the right word: the banner stayed and nothing behind it was reconnecting. The page's whole recovery story was a setTimeout chain, and a chain is exactly what a WebView is entitled to stop running. Chromium throttles timers in a page nobody is looking at — down to once a minute once it has been hidden five minutes — and a renderer that is frozen, or reclaimed and not yet reloaded, runs none of them. So the socket drops while nobody is watching, the banner goes up, the retry is scheduled, and the retry is then the one thing not running. Three defects, each of which leaves that banner up for the rest of the page's life. ◆ NOTHING LISTENED FOR THE PAGE COMING BACK. The only thing that could clear the banner was a timer that may never fire. terminal.js now reconnects on visibilitychange, focus and online — the events that mean somebody is looking again, and the ones that cannot be throttled — cancelling the pending timer and resetting the backoff. Over a healthy socket all three do nothing, which is what makes them safe to fire as often as clicking a window does. ◆ A HANDSHAKE THAT NEVER FINISHED WAS INVISIBLE. Every retry was scheduled by a close or an error, so an attempt parked in CONNECTING — which is what a suspended renderer leaves behind — scheduled nothing at all, ever. There is now a five-second watchdog on the handshake. ◆ STALE SOCKETS SCHEDULED RETRIES, AND THAT ONE IS A LOOP RATHER THAN A STALL. connect() never detached the old socket's handlers, and the host aborts the displaced socket on takeover — TerminalDataPlane.UpgradeAsync, doing exactly what it should. That close read as a fresh failure and scheduled a retry against the socket that had just succeeded, whose own close scheduled the next: no fixed point, reconnecting every second forever with the banner up for most of it. Every handler now asks whether it is still the page's own attempt, and connect() closes what it abandons. ◆ WHICH OF THE PLATFORM BEHAVIOURS ACTUALLY BIT IS NOT ESTABLISHED, and the fix does not depend on knowing. Throttled timers, a frozen renderer and a reclaimed one all end at the same dead timer; guessing between them would have produced a narrower fix for one of the three. THE TEST RUNS terminal.js ITSELF, in a fake browser, inside dotnet test. RendererPage loads the file the shell project ships — not a transcription of its logic into C#, which would be a copy that stays correct while the page rots — into a Jint engine, one per test, over a harness that fakes a WebSocket and a clock and nothing else. Jint rather than a node script because CI would run the node one and nobody's inner loop would; the cost is that Jint is not Chromium, so this proves the page's logic and nothing about how a WebView behaves. That line is drawn in RendererPage's remark and picked up by two new manual checks, 1.10 for the desktop and 11.12a for the phone, which own the platform half. Four of the nine tests fail against the page as it stood — the stale close, the parked handshake, and the two wake-ups. Two more assert that a wake-up over a healthy socket does nothing, and pass against either version on purpose: they are what stops the cure being worse. Left alone deliberately: a socket that is open and dead shows no banner at all, because readyState still reads OPEN. That looks like a terminal that swallows what is typed, needs a liveness probe rather than a faster retry, and is written down at the end of 11.12a rather than quietly bundled in here. |
||
|
|
68964d8a34 |
Merge pull request 'Name a quick-connect result's vault, and search on it' (#13) from claude/search-items-vault-display-865eb0 into main
Reviewed-on: #13 |
||
|
|
6934efb1e6 |
Merge pull request 'Take the SSH.NET release that fixes the SCP path traversal' (#12) from claude/sharp-fermat-9b8dc5 into main
Reviewed-on: #12 |
||
|
|
e252d337d1 |
Take the SSH.NET release that fixes the SCP path traversal
Every project in the repository stopped building, on a warning about a package
none of them had changed:
error NU1903: Warning As Error: Package 'SSH.NET' 2025.1.0 has a known high
severity vulnerability
GHSA-q939-rpr3-3284 was published after the pin was written. ScpClient.Download
in recursive mode takes the filenames the server sends and joins them to the
local destination without validating them, so a malicious or compromised SCP
server can name "../" its way out of the download directory and write anywhere
the client process can — CWE-22, 7.1, and the same shape as OpenSSH's
CVE-2019-6111 with the traversal left in. Everything up to and including
2025.1.0 is affected; 2026.0.0 is the fix.
◆ THIS APPLICATION WAS NEVER EXPOSED, AND THE UPGRADE IS STILL THE RIGHT ANSWER.
Nothing here constructs a ScpClient. File transfer is SftpClient throughout —
SshNetSftpSession is the only path to a remote file — so the vulnerable method
has no caller to reach it from. That makes this a hygiene bump rather than an
incident, and it is worth saying plainly because the alternative on offer was a
scoped NuGetAuditSuppress with that reasoning written beside it. A suppression is
what you reach for when there is nothing to upgrade TO. There is: the maintainers
shipped the fix, taking it costs two nullable annotations, and a suppression
would have left this repository carrying a known-vulnerable library and a comment
explaining why that is fine — which stays true only until somebody adds the first
ScpClient call and has no reason to look here.
BouncyCastle.Cryptography moves 2.6.2 -> 2.7.0 as a consequence, not a
preference. CentralPackageTransitivePinningEnabled makes the PackageVersion here
the resolved version for a transitive dependency, and SSH.NET 2026.0.0 asks for
2.7.0, so a pin left at 2.6.2 is a downgrade error rather than a version this
repository gets to choose. Its comment records the constraint so the next person
does not try to walk it back.
◆ THE RELEASE NOTES SAY NO BREAKING CHANGES. THE NULLABLE ANNOTATIONS DISAGREE.
ConnectionInfo.CurrentServerEncryption is now string? — correct of them, since it
really is null until the key exchange completes — and that is CS8601 at both
places this codebase reads a cipher off a live connection. Neither can observe
the null: SshNetConnection and SshNetSftpSession are only ever constructed after
ConnectAsync has been awaited, which is what their existing remarks already say
and why the reads are at construction rather than lazy.
Coalesced to string.Empty rather than silenced with !, because empty is already
this codebase's word for a cipher that could not be read. MainWindowViewModel's
NullIfEmpty exists for exactly that, TerminalWorkspace.GetSessionFacts already
hands over string.Empty on the same grounds, and the status bar collapses on it.
So the boundary keeps the interface's non-nullable string, no consumer changes,
and an unreachable null would degrade the way a real one already does.
SshNetConnection's remark asserted that SSH.NET types the property non-nullable
and that reading straight through was therefore honest rather than lazy. That
sentence is now false, and a comment contradicting the line beneath it is worse
than no comment, so it says what is actually true: the coalesce is there for the
annotation and the pre-key-exchange window this call site cannot be in.
The Android head needed its own restore. It is outside DodoSSH.slnx, so the
solution-wide --force-evaluate does not reach it, and left alone it would have
kept the vulnerable pin and gone stale under locked mode — the hazard
platform-flags.md records, arriving on schedule.
Heeding the warning left by the ILLink commit: --force-evaluate on Windows
rewrote all 39 lock files with CRLF, and 21 of them had no content change. Only
the 18 that really moved are here.
Verified with `dotnet build` at the root, no audit-disabling flags: 0 errors,
NU1903 gone, and the 21 remaining warnings are the pre-existing Meziantou/CA and
Avalonia ones that were present in the failing build too.
A library upgrade is not a version string, so the suites that exercise it were
run against live containers rather than trusted: 87 Ssh, 80 Terminal, 27
ObjectStore, 10 Transfer, none failing.
|
||
|
|
fdea3911c1 |
Name a quick-connect result's vault, and search on it
Ctrl+K reaches every vault the session holds a key for — that is deliberate, and it is what makes the palette worth opening from anywhere. What it did not do was say where a result came from. A team and a person who both call a machine prod-db got two identical rows, and Enter took whichever the ranking happened to put first: the same three characters, two different machines, depending on nothing anybody could see. Nothing new is computed for this. HostRowViewModel.VaultBadge has been filled in since the host list learned to span vaults, and it is already drawn on the hosts board, the keychain, the snippets list and the known-hosts pane. The palette was the one list reaching across every vault that did not print it. ◆ THE VAULT IS THE LAST OF THE FOUR RANKS, AND THAT IS THE POINT. A vault name is the widest reading of the three the palette had: one word matches every host in that vault at once, where a name or an address matches one machine. So it sits behind name-starts-with, name-contains and address — otherwise typing a machine's name would bury it under everybody else's. It matches on HasVaultBadge rather than on VaultName, so the search only ever matches what the row actually shows. A session holding one vault prints no vault on any row, and matching it there would answer "personal" with the entire keychain, ranked behind nothing and explained by nothing on screen. In the row, the right-hand column becomes two lines against the two on the left, in the same order: what this is above, how it is reached below. With one vault the badge is empty, the line collapses, and the kind word stays centred exactly where it was. The name is capped and ellipsised because that column is Auto-sized — a long vault name would otherwise take its width out of the host name beside it. Three tests, one per claim. VaultSharingTests puts platform-gateway in the personal vault and prod-db in "Platform secrets" and types "platform": both come back, the gateway first. ShellFlowTests types the personal vault's own name into a one-vault session and gets nothing. QuickConnectTests is the markup's half — the shared vault's row draws PLATFORM SECRETS, and a one-vault row draws no vault at all — with the shared vault put into the session through the layout suite's own StubTeamServer, as the settings pages' suite does it. 449 App tests and 155 layout tests pass. Both suites were run with -p:NuGetAudit=false: SSH.NET 2025.1.0 has picked up GHSA-q939-rpr3-3284 and NU1903 fails restore repo-wide, which predates this branch and is nothing to do with it. No package or lock file is touched here. |
||
|
|
10f80bded1 |
Merge pull request 'Colour the window's frame, inset Hosts like its neighbours, drop Pins' (#9) from claude/title-bar-color-windows-6d386c into main
Reviewed-on: #9 |
||
|
|
281f849086 |
Merge pull request 'Look for a newer build the moment the application starts' (#8) from claude/version-check-startup-e06e9a into main
Reviewed-on: #8 |
||
|
|
25407756c3 |
Look for a newer build the moment the application starts
The first pass of the update loop waited two minutes. Every pass after it came six hours apart, which is the right interval for a product that ships rarely — but the delay in front of the first one quietly excluded a whole way of using this application. A client opened to reach one host and closed again is over before the two minutes are. Used that way, it never checks at all: not once, not slowly, never. That is precisely the machine ADR 0011 names as the real cost of distributing outside a store — quietly a year behind — and the galling part is that the mechanism to fix it was switched on the whole time and simply never reached. The delay's own argument is recorded in the diff it is being removed from, and it was not a bad one: nothing anybody does in their first two minutes depends on an update, and launch is already contending for the network with a schema migration, a resumed sign-in and a first sync, at the one moment somebody is watching the window. What it weighed was the cost of checking early against the benefit of checking early. It never weighed the cost of not checking at all. ◆ THE YIELD IS WHAT KEEPS THIS OFF THE LAUNCH PATH, AND IT IS NOT DECORATION. Start() is called from MainWindowViewModel.StartAsync ahead of the migration, so an inline first pass would run whatever the channel does before its own first await — Velopack reads the install layout from disk — between the user and their window. Yielding hands the rest of launch back and puts the check in a later turn, which is the same moment in every sense anybody can perceive and none of the cost. So the answer to the delay's argument is not that it was wrong; it is that a yield buys most of what two minutes bought. Task.Yield takes no token where Task.Delay did, so the loop body now observes cancellation at its head. Without that, an application closed during launch spends its last moment asking a release channel about a build it will not run. Two things deliberately not changed. The AUTOMATIC UPDATE CHECKS preference still gates the pass — "on start" means every start, not regardless of what the user asked for, and that setting is already on by default. And the data cost is unchanged rather than merely acceptable: a check is a few hundred bytes and the download only follows if something newer exists, so this moves the same traffic earlier without adding any. That matters most on the phone, where the same loop runs against AndroidUpdateChannel. TheFirstPassRunsAtStart_RatherThanOnADelay drives the real loop rather than CheckOnceAsync, which is the one thing that file otherwise avoids — and here it is the point, because the claim is about when the pass happens rather than what it does. It waits on the pass and not on a clock, so there is nothing to be flaky about: a regression that puts a delay back does not fail on a margin, it spins until the suite's own cancellation ends it. DisposingStopsTheLoop keeps its assertion and gains a note that it is now a race rather than a formality. §16.7 of the manual checks gains the sentence that reopening the application does what CHECK NOW does. It is the step somebody following that section would otherwise discover by accident. 447 App tests and 153 layout tests pass. |
||
|
|
a763f4b113 |
Merge pull request 'Stop the SDK's own trimmer version deciding whether CI can restore' (#11) from claude/illink-lock-drift into main
Reviewed-on: #11 |
||
|
|
881562e81b |
Follow the SDK's ILLink version into the three lock files that pin it
CI's restore stopped on a commit that changed no dependency and touched none of
these files:
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has
changed from [10.0.10, ) to [10.0.11, ).
◆ NOTHING HERE MOVED. THE RUNNER'S SDK DID. Microsoft.NET.ILLink.Tasks is
referenced implicitly by the SDK — no .csproj asks for it — and its version
tracks the runtime patch band. global.json pins 10.0.100 with
rollForward: latestMinor, so setup-dotnet installs whatever the newest 10.x SDK
is on the day. When that band moved to 10.0.11, every lock file carrying the
entry went stale at once, and RestoreLockedMode is what turns that into a stop
rather than a silent upgrade. Three files carry it: the Android head, Contracts
and Crypto.
Contracts and Crypto are regenerated by --force-evaluate under SDK 10.0.303,
which bundles the same 10.0.11 the runner has; the contentHash is NuGet's, from
that restore. dotnet restore DodoSSH.slnx --locked-mode is clean under it.
◆ THE ANDROID LOCK FILE IS HAND-EDITED AND WAS NOT VERIFIED BY A RESTORE. Same
three lines, same version, same hash the real restore produced — but generated
by an editor, not by NuGet. Installing 10.0.303 rewrote the machine-wide
workload manifests for the 10.0.300 band, after which that project fails
NETSDK1147 asking for wasm-tools (which nothing here targets) under 10.0.302 as
well as 10.0.303, so there was no working local restore left to produce it
honestly. The android workload on that machine is Visual Studio-managed and
repairing it is not a thing to do in passing. This is the hazard the docs
already record — the Android head is outside DodoSSH.slnx, so the locked-mode
gate that keeps the other fourteen lock files honest has never seen it — landing
again, from the other direction. If the android job still fails on NU1004, this
line is the first thing to doubt.
platform-flags.md gains the failure beside the existing lock-file hazards,
including the trap that cost the most time here: --force-evaluate from an SDK
older than the runner's rewrites the lock at the OLD version, changes nothing,
and looks like it worked. Check dotnet --version against the version in the
error before believing a regeneration.
This recurs on every SDK patch that moves the band. That is the accepted cost of
letting the SDK float; pinning an exact SDK trades a recurring lock-file bump
for a recurring toolchain bump and one mandated SDK per contributor.
|
||
|
|
93e35a0095 |
Stop the SDK's own trimmer version deciding whether CI can restore
CI went red across the whole repository — main's run 125 and every open pull
request at once — on a restore that never reached a compiler:
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has
changed from [10.0.10, ) to [10.0.11, ). The packages lock file is
inconsistent with the project dependencies so restore can't be run in
locked mode.
Nothing in any of those commits touched a package. .NET had shipped SDK 10.0.400.
◆ THE VERSION IN THE LOCK FILES WAS NEVER THIS REPOSITORY'S TO DECIDE.
Microsoft.NET.ILLink.Tasks is referenced by nothing here. The SDK adds it to any
project setting IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
DodoSSH.Crypto do, and the Android head gets it from trimming being on by
default — and it supplies the version itself, from the KnownILLinkPack item in
its own Microsoft.NETCoreSdk.BundledVersions.props. 10.0.302 says 10.0.10;
10.0.400 says 10.0.11.
packages.lock.json records that as a Direct reference with a requested range, so
what the committed file actually means is "whichever SDK last ran a restore".
global.json says rollForward: latestMinor, so setup-dotnet installs the newest
10.x SDK that exists on the morning it runs. The gate did its job — an unreviewed
dependency change is exactly what it is there to stop — but the change it caught
was not one anybody could have reviewed, and it will recur on every servicing
release.
Regenerating the lock files alone would have been the worse repair, and not only
because it holds until the next release. It cannot be done from this machine at
all: every SDK installed here tops out at 10.0.302, which writes 10.0.10 straight
back and re-breaks CI. The recorded version would flip according to who restored
last — the precise state locking exists to prevent.
So the version is pinned in Directory.Build.targets and the three lock files are
regenerated against the pin. It is an Update on the SDK's item rather than a
PackageVersion in Directory.Packages.props because the reference is implicit:
the SDK supplies a version, so central package management is never consulted. It
sits in a target because the conditioning is on %(TargetFramework) — all the
KnownILLinkPack items share one identity and only that metadata separates
net10.0's from net8.0's — and item batching in a condition is legal inside a
target and MSB4191 during evaluation.
Pinned forward to 10.0.11 rather than back to 10.0.10, which would have been a
one-line change with no lock file churn. Holding the trimmer a release behind the
framework it analyses to dodge an error is how a missed trim warning happens, and
taking the newer one makes the bump a reviewed diff, which is what the gate was
asking for.
Verified against the SDK that broke it rather than only the one here:
- sdk:10.0-alpine, 10.0.400, `dotnet restore DodoSSH.slnx --locked-mode` —
exit 0. That is ci.yml's line, on CI's SDK.
- the android workload on sdk:10.0-noble, 10.0.400, locked-mode restore of
DodoSSH.Client.Android — exit 0. That is scripts/ci-android.sh's line.
- locally on 10.0.302, the same locked-mode restore of the solution — exit 0.
One set of lock files satisfying both SDKs is the whole point of the pin, and the
third check is the one that demonstrates it.
Release build clean: 0 errors, and 0 IL-prefixed diagnostics from the newer
analyser on the two trimmable projects. 1,869 tests over 19 suites, none failing.
A caution for the next person, learned the hard way here: `--force-evaluate` on
Windows rewrites every lock file it touches with CRLF, and 23 of the 26 had no
content change at all. Only the three that really moved are in this commit.
|
||
|
|
b80bf23341 |
Merge pull request 'Stop one tab's status banner from speaking for all the others' (#10) from claude/status-bar-tab-isolation-caa52b into main
Reviewed-on: #10 |
||
|
|
8c58e5a558 |
Stop one tab's status banner from speaking for all the others
A shell that ended printed "The remote closed the session." into the status banner at the foot of the terminal. Switch to a tab whose shell was still very much alive and the sentence was still there, sitting under a live prompt and describing a terminal that was no longer on screen. There is one #status element for the whole page, because there is one page for every terminal — the panes are stacked in the same box and all but the active one are hidden — and SESSION_CLOSED wrote its reason straight into it. The other half of the same mistake ran the other way: SESSION_OPENED and SESSION_REMOVED both cleared the element outright, so opening or closing any tab wiped a message that belonged to a different one. Whichever tab spoke last owned the banner. The fix is to separate the two things that were being put in one place by who they are actually true of. A session's last words are a fact about one terminal and are now held on the session record, drawn only while that session's pane is the one showing; activate() re-renders, so the banner follows the tab and a dead tab still says what became of it when you come back to it. The socket's own state — "Connecting…", "Reconnecting the terminal view…" — stays page-wide, because there is a single socket behind every pane, and it wins when both have something to say: a page whose socket is down is not showing live output on any pane. A SESSION_CLOSED for a session this page has no pane for is now dropped rather than printed. There is nothing to attach it to, and putting it in the banner anyway is precisely the bug in miniature. Verified by driving the real handleFrame through a stub DOM under node, which is as close as this repo gets — there is no JS test harness and CI runs dotnet only, so nothing here is a standing test. Twelve checks over open, close, switch, reopen, remove and a socket drop pass against this file; the same script run against the previous one reproduces the report exactly, epitaph under a live tab included. Not seen in a running app: no C# changed, and the page is unreachable without one. |
||
|
|
cec73010d3 |
Colour the window's frame, inset Hosts like its neighbours, drop Pins
Three things one pass over the shell's chrome turned up, none of them related to the others beyond having been looked at together. ◆ A PALE STRIP ACROSS THE TOP OF THE WINDOW ON WINDOWS, and it is not this application's titlebar. Avalonia's Win32 backend gives a BorderOnly window WS_BORDER | WS_THICKFRAME and then calls DwmExtendFrameIntoClientArea with one-pixel margins on all four sides — read out of WindowImpl.UpdateWindowProperties in 12.1.1 rather than guessed at. So DWM owns a hairline of every edge and fills it with the system's caption and border colours, which follow the user's personalisation settings: with "show accent colour on title bars and window borders" on, that is blue against a near-black shell. Nothing in the visual tree painted those pixels, which is why nothing in the visual tree could cover them. NativeWindowFrame sets DWMWA_BORDER_COLOR and DWMWA_CAPTION_COLOR to the window's own Background, so the hairline still exists — the resize grip is on it, the drop shadow hangs off it — and cannot be seen. Deliberately not DWMWA_COLOR_NONE, which removes the border outright and leaves a near-black window with no edge at all on a dark desktop. Windows 10 gets the dark-mode attribute and nothing else, because the two colour attributes are Windows 11 and DwmSetWindowAttribute simply answers E_INVALIDARG there. Called from OnOpened, not the constructor: there is no platform handle until the window is shown, and calling early is a silent no-op — which looks exactly like a fix that does not work. Verified on screen on Windows 11. ◆ THE HOSTS HEADER SAT A STEP LEFT OF AND ABOVE EVERY OTHER SCREEN'S. Keychain, Snips, Logs and Pins all frame their content with Margin="26"; Hosts was on 16 a side and 20 on top. It is 26 all round now, stated per row rather than once on the root, because the board's ScrollViewer is deliberately full-bleed so that its scrollbar rides the pane's edge, and because a root margin would also inset the drawer, which draws its own. That cost the cards ten pixels, and the layout suite is what said so: TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen failed, because Border.tile's 224 was derived from the board's old 16-pixel margins and the grid quietly collapses to one column at exactly the size this application guarantees. 224 becomes 214, with the arithmetic in App.axaml rewritten — it had also gone stale in a way that hid itself, still citing the 1016 minimum and 190 rail from before v5b, whose two changes happened to cancel. ◆ PINS LEAVES THE RAIL, and only the rail. KnownHostsScreen is still built and still one click away, from "Host keys" on the Keys screen's own header, which was always the second way in. The row was kept through v5b on the grounds that the mock has no screen for approved host keys — a reason for the screen to exist, and never a reason for a rail entry once the keychain had a door to the same place. Two rows landing on one screen is a rail that has to be read twice. MainWindowViewModel.IsKnownHostsShowing stays: it names a real shell state and ShellFlowTests still asserts on it. design-import-gaps.md recorded that row as a deliberate deviation and manual-checks.md Phase 1.1 walked the rail entry by entry; both are corrected, and the manual check now reaches the screen the way a user would. The layout suite's rail row count moves from six to five with it. 153 layout tests and 446 shell tests pass. The frame is confirmed by eye; the Hosts inset and the rail are covered by the layout suite but were not seen running, because the instance launched to check them came up locked. |
||
|
|
53ff15ba86 |
Keep drawing the terminal after Android takes the GPU context away
The phone's terminal was blank whenever it was connected. Not slow, not mis-sized, not disconnected: a live session accepting keystrokes, acknowledging output and drawing nothing at all. ◆ THE WEBGL ADDON DOES NOT RECOVER FROM A LOST CONTEXT AND DOES NOT FAIL LOUDLY. It stays loaded over a dead context and renders an empty rectangle, which is xterm's documented behaviour and the reason its guidance is to subscribe to onContextLoss and dispose. This page never did, and until there was a phone there was no reason to notice. Losing the context is ordinary on Android and nearly unheard of on Windows, which is what made this a one-head bug in shared code. Collapsing the renderer sets the native view to GONE — Avalonia's AndroidNativeControlHostImpl.HideWithSize, read out of the assembly rather than guessed at — and a WebView with no surface has no GL context. The shell collapses it every time a tab starts connecting, every time the connect sheet opens and every time the app is backgrounded. Worse, the ordering guarantees it for the first session on every launch: OpenSessionAsync sends SESSION_OPENED before the tab reports a session, so IsTerminalShowing is still false and the pane, the terminal and its GL context are all built inside a collapsed WebView. WebView2 hides a child HWND and keeps rendering throughout, which docs/platform-flags.md measured at length. The addon is not reloaded after a loss. A pane that lost the context once is on a surface that will do it again, and thrashing between renderers is worse than being slow — the DOM renderer is what the existing fallback comment already argues for, because a blank pane is not usable and a slow one is. The comment above MINIMUM_FITTABLE_PIXELS was wrong for this head and is corrected with it. It asserted that collapsing the WebView leaves this page's viewport alone, so no observer fires and the guard protects nothing — true of a hidden child HWND, false of a GONE view, which its parent's layout skips outright. On the phone the guard is the only thing standing between a lock, a connect sheet or a trip to the background and a remote pty reflowed to 2x1. Also on the way past: the renderer-timeout message told phone users to install the Microsoft Edge WebView2 runtime. That is the other blank-terminal failure mode's message, and naming a runtime that cannot exist on the device is worse than saying nothing at the one moment somebody is trying to work out what went wrong. It now names Android's own WebView on that head, as a runtime check for the reason MainWindowViewModel.GestureWait records beside its own. Not verified on a device — there is no handset or emulator here, and no test covers this page. The diagnosis is the decompiled hide path plus xterm's own requirement, not an observation. 523 tests over the shell and the terminal pass, and both heads build. |
||
|
|
766fe6aebe |
Stop the test sshd penalising the suite for its own host-key refusals
The SSH suite has failed intermittently for months with SshConnectionException "The connection was closed by the remote host", within milliseconds, on whichever class happened to be running. Two previous attempts guessed at the cause and said so honestly; this one has a mechanism and a before/after. ◆ THE CAUSE IS PerSourcePenalties, WHICH THIS SUITE PROVOKES BY DESIGN. OpenSSH 9.8 added per-source penalties and 10.x enables them by default; the image runs 10.3 and its config never mentions the keyword, so the compiled-in default was what ran. A source address that repeatedly disconnects without attempting authentication gets penalised, and while the penalty holds every connection from it is answered with the clear-text line "Not allowed at this time" and then closed. That is exactly the traffic this suite generates. This client's first contact with an unknown host is a connection deliberately refused at the host key — a disconnect with no authentication attempt — so every helper that learns a host key by being turned away first, plus RefusingTheHostKey_AbortsTheConnection and AnUntrustedHost_IsRefusedExactlyAsAShellWouldBe, feeds the penalty counter. Enough of them close together and sshd stops talking to the test host for a while, then starts again. Measured on a fresh container, probing 200 times with connections of that shape: with the image default, the first refusal came back at probe 18 and 183 of the 200 were refused. With PerSourcePenalties no, none of 200 were. That is the before/after the earlier attempts could not produce. It also explains the shape of the failure, which never fitted a throttle. The class that failed lost EVERY connection it made rather than a random few — including the one test that expects a refusal, which passed throughout for the wrong reason — while the classes around it were untouched. That is a window in which the server refuses one source, not a probabilistic drop. Both earlier diagnoses are recorded in the fixture so they are not tried again. MaxStartups was blamed on the reasoning that xUnit runs test classes in parallel, so ten unauthenticated connections would be in flight at once; but every class touching this server shares one collection and xUnit parallelises collections, not classes, so they run one after another and never have more than a connection or two open. The reload window was blamed next, and a wait for the banner was written and removed as unproven — it was unproven because the banner answers perfectly right up until the penalty lands, so a check that stopped at the first "SSH-" ran entirely inside the good part. MaxStartups is kept, on the narrower argument that it is right regardless: a connection throttle is hardening a test server has no business reproducing. Removing it would be a second change riding along with this one. The readiness gate that replaces the reconfigure's silence is a guard rather than a wait. It requires 25 connections answered back to back, which is the specific provocation rather than a soak test: 25 is above the measured threshold of 18 on purpose, and it costs under a second when the setting is off. Ten was tried first and was worse than useless — it sits below the threshold, so it passed against a server that was still penalising. With the fix removed the gate now fails in a minute naming PerSourcePenalties and quoting the server's own "Not allowed at this time", instead of the suite failing later somewhere unrelated. The gate also closes a hole the container's own readiness cannot: a log line and netstat showing :2222 both pass on a container whose sshd has gone, because Docker publishes the port with a host-side proxy that accepts before it has anything to forward to. It is probed from the host rather than with docker exec for the same reason it matters — that is the path the tests take, and penalties are counted per source address. Rejected: patching sshd_config from /custom-cont-init.d to avoid the reload entirely. It looks like the right hook and is not — the container's log puts "sshd is listening on port 2222" before "[custom-init] Files found, executing", so a script there edits a file the running server has already read. It leaves a config that greps correctly and a server behaving as though it were never touched, which is the same trap as patching the wrong one of the image's two config files. Twenty-eight tests failed before that was noticed; the finding is in the fixture. Four consecutive full-solution runs clean, and the SSH suite green on every run since. 1,861 tests, none failing. |
||
|
|
9f73893e14 |
Merge pull request 'Give the terminal back the width and the keyboard the session shell took' (#7) from claude/quick-access-terminal-fixes-565d4a into main
Reviewed-on: #7 |
||
|
|
ccaf7a8e72 |
Give the terminal back the width and the keyboard the session shell took
Seven things reported from a day's use of v5b's session shell, and they are one commit because five of them are the same complaint from different angles: the window spends too much of itself on chrome describing the session, and the parts that are not chrome do not behave. ◆ THE HOST HEADER IS GONE, and that is a deliberate departure from the design. Terminal.dc.html and SFTP.dc.html both draw a 60-pixel row above the pane: the address on the left, a cross-surface button on the right. Both facts are worth having and the strip they sat in was not — the tab already names the host, and a full-width bar repeating it was the cheapest 60 pixels in the layout to give back. The address is now the first line of the sidebar and the button is stretched across the column under it, so nothing is lost and the pane is taller. Which word that button carries and which command it runs used to be handed in from the two usage sites in MainWindow.axaml, because the control was drawn twice. One sidebar cannot do that, so SessionCrossSurfaceLabel and OpenOtherSurfaceCommand resolve it in the shell — the same place SessionAddress already decides which surface's fact to read. The two directions underneath are untouched: SelectFilesHostAsync for a tab's host, OpenTerminalForFilesHostAsync for a fresh terminal at whatever SFTP has open. SessionHeader.axaml is deleted rather than left unused, and LayoutHarness stops subtracting its 60 pixels from every session screen's budget — the same treatment the retired window-wide tab strip got, and for the same reason: a constant for chrome nobody draws is a suite quietly measuring the wrong rectangle. ◆ AND THE SIDEBAR CLOSES, which the design has no state for at all. 300 pixels of an 1081-pixel minimum is a great deal to spend on a list that is often two rows long. The column now folds to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel that vanishes leaving no trace is one people report as lost rather than as closed. Both states live in the one control and swap on IsSessionSidebarOpen, so MainWindow's own "Auto" column takes whichever width is showing without knowing the state exists. Written through to ClientSettings.SessionSidebarOpen rather than held for the session. It is a decision about how much of the window a terminal gets, and one that had to be made again on every launch would not really be on offer. ---- THE FOUR SMALLER ONES ---- A SNIP LANDED IN A TERMINAL NOBODY COULD TYPE AT, and looked selected when it got there. Two causes with nothing in common. The click moved Win32 focus onto the sidebar row, and term.focus() in the page cannot take it back — only the host can, so the shell raises TerminalFocusRequested and the window answers with the same posted focus every other path here uses. The highlight was bash: xterm wraps a paste in bracketed-paste markers, readline marks what arrives inside them as an active region, and it stays in reverse video until the next keystroke. Right for a clipboard paste, wrong for a snippet picked off a sidebar. Single-line snips are typed rather than pasted now, which needs no markers; multi-line still pastes, because "runs three commands unasked" is the worse of the two failures and the markers are the whole of what prevents it. A BLACK BAR UNDER THE TERMINAL, on Windows. xterm.css paints its scrolling viewport #000 — its own comment explains why, and it is a macOS scrollbar concern. Everywhere else that black is covered by the rows, except along the bottom: the fit addon floors the row count, so the remainder below the last whole row is bare viewport, up to a line tall, against this page's #171a26. The light square at its right-hand end is where WebView2's classic scrollbar corner lands. The viewport is repainted in the page's own background, and the scrollbar with it — thin and in these colours rather than a grey Windows channel down the side of a near-black terminal, and kept rather than hidden, because a surface that scrolls with no sign that it does is worse than a quiet bar. THE PINS ROW DREW A TOFU BOX. U+E946 is not in the embedded Material Icons face at all — that file is the 2019 build and its cmap skips E944 and E946 — so the rail's Pins row and the hosts screen's own pin badge have both been drawing a missing-glyph rectangle since v5b picked the codepoint. push_pin in that vintage is U+F10D, verified against the file rather than against a codepoints table for a later release of the font. Every other icon codepoint in the repository was audited the same way; this was the only miss. THE KBD CHIP CUT THE CHORD IN HALF. 34 pixels is the design's width for a chip reading ⌘K, and this build substitutes CTRL K — six characters and a space, wider than 34 at 10.5 mono. MinWidth and padding instead, so the design's footprint survives for the day this face has a ⌘ to draw. ---- AND THE POPOVER UNDER THE USER CHIP ---- Reported as not matching the design, and it was not: Button.poprow set a corner radius and a padding and never touched the Background, so every row wore the Fluent theme's own #33FFFFFF button fill. Six raised pills stacked in a menu the design draws as six lines of text — and the hover rule underneath was already correct and simply invisible against a fill that never went away. Set on the ContentPresenter as well as on the Button, the same as Button.flat, because the theme binds its brush there and a Background set only on the control loses to it. The panel itself gets this window's own radius-12 card treatment through a FlyoutPresenter class rather than by widening the shared context-menu rule, and Vaults and Preferences stop being drawn one step dimmer than Settings and Logout, which read as two disabled entries in a menu of five live ones. ---- WHAT PROVES IT ---- Three tests in the layout suite, two of them checked against the defect they describe: the popover row's resting fill (fails with #33ffffff without the style), and the kbd chip against the natural width of its own text, measured on a detached copy because a TextBlock's DesiredSize is already clipped to what it was given and reports 34 inside a 34-pixel chip either way. SessionSidebarTests is new — the sidebar has never been laid out by a test, and it now holds a string of unbounded length beside a button that has to stay clickable. In the shell suite: the cross-surface row in both directions, the closed state surviving to disk, and the focus request being made when a snip lands and not made when it does not. |
||
|
|
6fc1e3a7c5 |
Merge pull request 'Say how far a connection has got while it is still being made' (#6) from claude/connection-status-indicator-e52da7 into main
Reviewed-on: #6 |
||
|
|
8a77b7ca68 |
Say how far a connection has got while it is still being made
The connecting card set its status string once, when the tab was created, and never touched it again. Every connection therefore looked identical from the outside: one three seconds into a key exchange, one waiting out a fifteen-second timeout against a machine that is asleep, and one that had hung all drew the same "connecting…". The card now draws the five steps of getting there, each lit at the moment the handshake reports reaching it, over an amber track that fills as they finish. ◆ NOTHING ON THE LIST IS INVENTED. Every row changes state because a layer below it said so, at the instant the thing it names actually began. That is the whole reason it is worth showing, and it is why most of this commit is plumbing rather than XAML: there was no progress reporting anywhere in the stack to hook a step list onto, and a card animating plausible progress would have been indistinguishable from one that had stopped receiving any. SshConnectionPhase names four phases and deliberately not more. SSH.NET runs the entire handshake inside one ConnectAsync and raises exactly one event from the middle of it — HostKeyReceived, once the key exchange has produced a key to show — so that event is the only interior moment there is to report. Everything before it is Reaching and everything after it is Authenticating. A fifth phase in that assembly would have to be a timer, so there is not one. OpeningShell is reported by TerminalWorkspace instead, because that is where it happens: the factory's work ends with an authenticated connection, and asking for a pseudo-terminal on one is a separate round trip. The SFTP path passes null — a second connection opened behind an already-open shell has nobody watching a step list for it. The card's fifth step, "Starting the terminal", is the renderer wait and lives in the shell rather than in the SSH assembly, which has never heard of a renderer. On the first connection after a cold start it is a real wait with a real failure mode of its own — a missing WebView2 runtime — so a list that began at "reaching the host" would leave the one wait most likely to hang unnamed. Amber for the step in flight, and that follows the palette's rule rather than bending it. Green is what is true and purple is what you can press; a step still happening is neither, and it is exactly the caveat-worth-reading that amber exists for. Steps behind it go green as they become true. Nothing animates, which is the argument TransfersScreen.axaml already makes for its own track, reaching a screen with far more reason to want a spinner: a spinner is furniture invented to fill a state nobody measured, and these states are measured, so the track fills to what has finished and then waits there. A refusal keeps the step it stopped on, in red, with the ones behind it still green. That is the half a progress bar could not do, and it is the difference between "that host is not there" and "that host is there and would not have me" — a question the reason sentence alone frequently does not settle. The strip's dot goes amber while a tab is connecting, on both heads. It was grey, and so is a tab whose shell has exited: the two states in that strip with the least in common, one worth waiting for and one over. PhoneShell's own comment already recorded half of this — the dot stopped being green before anything had answered — and this is the other half. Progress is raised inline rather than through System.Progress<T>, which captures whatever synchronisation context it was constructed on and posts to it. That reads like a convenience and is really a second place the marshalling decision gets made: silently, differently under a test with no context, and out of order with respect to the failure that follows a phase. The shell marshals once, in one handler, through a new optional post parameter on MainWindowViewModel — the same seam TransfersViewModel already uses, and for the reason its own remark gives. The three Dispatcher.UIThread.Post calls that predate it are the ones this suite's comments record as out of reach; they are left alone rather than swept in here. Both heads draw the list. They differ in one place: Phone.axaml's mono class sets a colour and a size along with the family, so the caption rule names its own family instead of composing the two and asking two rules for one Foreground. The desktop's mono sets the family alone, which is why ConnectingCard does compose them. Each head also gains SHOW LOGS beside the button that gives up — the step list is this attempt and the log is every other one, which is what a connection taking too long actually raises. Seven tests, and the two that matter most run against the container rather than a fake: a real handshake reports its phases in order, and a host-key refusal never claims to have authenticated. A fake asserting what it was written to assert would have established nothing about either. The rest cover the tab advancing while the connection is gated, the step a refusal stops on, and a phase reported after the user has given up on the tab. 1,861 tests, none failing. The Android head's layout is not verified by anything. It compiles, and compiled bindings mean every new binding path resolves, but that project is not in DodoSSH.slnx, there is no test project for it and no device here — so unlike the desktop card, whose shapes the layout harness measures, these rows have not been drawn. Vertical fit is reasoned, not observed. |
||
|
|
9755b5f6ae |
Merge pull request 'Stop the SSH suite's server refusing connections at random' (#4) from claude/ssh-fixture-hup-race into main
Reviewed-on: #4 |
||
|
|
afc6a042f1 |
Merge pull request 'Let the host editor make the credential it is about to bind' (#5) from claude/host-credentials-saved-sets-1786d4 into main
Reviewed-on: #5 |
||
|
|
e41eca01a8 |
Stop the SSH suite's server refusing connections at random
The suite fails intermittently with SshConnectionException "The connection was closed by the remote host", within tens of milliseconds, on whichever test happens to connect first. It has been seen in CI and reproduces locally. This raises sshd's MaxStartups in the fixture, which is the most likely cause and is worth doing regardless. sshd's compiled-in default is 10:30:100: past ten unauthenticated connections in flight it refuses new ones at random, thirty percent of the time, rising to always at a hundred. The image ships the line commented out, so that default was what ran. xUnit runs test classes in parallel and most of the classes here open a connection, so ten in flight is reachable during the opening seconds — and a refusal presents to the client exactly as observed, because a dropped connection and a server that never answered are indistinguishable from that end. ◆ IT IS A MITIGATION AND NOT A DEMONSTRATED CURE, AND THE COMMENT SAYS SO. The flake rate could not be measured. On the Windows development machine the identical unmodified suite ran 85/85 clean and, an hour later, failed 13 runs out of 15; a Linux container gave 30/30 clean and then failed on the first run of the next batch. Docker throughput on that host swings far enough to swamp the effect, so every before/after comparison taken there was noise — including two that were briefly believed. It is committed on the narrower argument that it is right either way. A connection throttle is hardening this suite has no interest in reproducing: it exists to test an SSH client, not to survive a rate limit, and a test server that drops connections at random is a bad test server whether or not it is the cause of this particular flake. The other candidate was the reload window — pkill returns when SIGHUP is delivered, not when sshd has finished closing its listeners and re-execing, so a connection immediately afterwards can be refused the same way. A wait that required three consecutive banner reads before returning was written and then removed: it could not be shown to change anything either, and a fixture carrying two unproven fixes for one symptom is worse than one, because the next person has to disprove both. Both candidates, and how to tell them apart with sshd's own log, are recorded in the fixture and in docs/platform-flags.md. |
||
|
|
e96d01aab9 |
Let the host editor make the credential it is about to bind
The authentication picker has listed saved credentials since they existed, but making one meant leaving a half-typed host for the keychain screen and coming back to find it gone. On the phone it was worse than a detour: that head has no credential editor at all, so it could bind a host to a credential and never produce one. + NEW CREDENTIAL opens a card under the picker — name, optional username, password, notes — and ADD writes it and binds the host in one step. A button beside the picker rather than an entry inside it. Every row of that list is a binding a host can have, and "make a new one" is an action: as an entry it would sit in the box afterwards describing a state no host can be in, and cancelling the form would leave the picker showing it. It carries its own five fields rather than reusing the keychain editor's, and that is the load-bearing part. IsEditingCredential is what AVaultEditorIsInTheWay asks about, so sharing it would have made the whole Vault screen refuse to open an editor while this card sat open on the Hosts screen, with a status line naming a form the user cannot see on a screen they are not looking at — the exact failure that guard was split in two to end. A test pins it. It writes to the keychain immediately, unlike every other field in this editor, because a credential is a shared item with an id and a host can only name an id that exists. The consequence is honest rather than hidden and the hint says so: a credential added this way outlives a cancelled host edit. What was still being typed does not — every path that closes the host editor clears the form, and one of those fields is a password. The binding is written before the reload rather than after it. RefreshOpenEditors rebuilds this picker and then restores it from the editor's own selection, so setting it first is what survives the pass, and by the time it is read ReloadCredentialsAsync has put the matching entry in the list to land on. A name already taken is duplicated, not reused, and that is a deliberate parting from the new-tag box six lines further down which offers the existing tag instead. Two tags called "staging" are one intention spelled twice; two credentials called "root" are two different passwords, and quietly binding the host to whichever was there already would authenticate it as an account nobody chose. A duplicate label in the picker is the smaller problem. Into editingHostVaultId, so the credential lands wherever the host is being sealed and everybody who can read the host can read what it authenticates with. Stricter than the tag path — which files into the active vault and is recorded as a gap in docs/design-import-gaps.md — and it can be, because this picker lists credentials from every readable vault rather than one. Five flow tests cover the bind-through-reload path, the cancel semantics on both the saved credential and the abandoned one, the cross-screen guard, the duplicate name and the empty-password refusal. The layout test is separate and necessary: the card is collapsed until somebody presses the button, so a harness driven by the default state draws none of it, and TheHostDrawerFitsWithTheHostEditorOpen would have gone on passing over a card that blew the column. 1,854 tests, none failing. |
||
|
|
7f77539ba6 |
Merge pull request 'Give the desktop a macOS head, signed from the first release' (#3) from claude/macos-build-release-2a8a0d into main
Reviewed-on: #3 |
||
|
|
ca081af209 | Merge branch 'main' into claude/macos-build-release-2a8a0d | ||
|
|
890a5f2246 |
Give the desktop a macOS head, signed from the first release
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. |
||
|
|
8c67fce32c |
Centre a phone row's caption in the row it is given
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. |
||
|
|
0ffd259ccd |
Give the rest of the button shapes their content alignment too
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. |
||
|
|
9bc9069425 |
Post the terminal's focus return past the dispatch that steals it
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. |
||
|
|
e936ab4646 |
Announce a session's end when it is actually over, and for closes too
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. |
||
|
|
506d2803a2 |
Hand Android's own focus back to the terminal after an accessory key
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. |
||
|
|
cc8bf37321 |
Merge branch 'claude/terminal-reattach'
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
671611a9a0 | Merge branch 'claude/phone-pins' | ||
|
|
21cf77f64a |
Centre a button caption in the button, not just the button in its parent
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. |
||
|
|
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. |
||
|
|
242280ce6b |
Name a root chip after the root, not with its whole path
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. |
||
|
|
de0b5f12ae |
Let the pane headers' paths actually trim
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. |
||
|
|
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. |
||
|
|
d32f5609e3 |
Rewrite checks 7.6/7.6a for the picker that replaced the drag
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. |
||
|
|
9d5ff9f23a |
Draw what authenticated, and over what, on the session status bar
The v5b design's own row: the negotiated cipher, then the host key's algorithm and the name of the key or credential that authenticated, as one mono run beside CONNECTED — on both surfaces, off MainWindowViewModel's surface-aware SessionCipher and SessionIdentityText, the same shape SessionAddress set. The identity's name comes out of TryBuildAuthentication, the one resolution point that always had it in scope and always threw it away; it rides HostAuthentication to the tab and to the SFTP connect alike. Three deviations, recorded in the gaps doc: the algorithm prints as negotiated rather than shortened, the run is plain text because no pin-details modal exists for an open session, and a typed password shows the algorithm alone — there is no item behind the dot. A dead terminal tab keeps its facts for the scrollback still on screen; an SFTP disconnect, with no scrollback, clears them. |
||
|
|
8209f15741 |
Let a session's transport say what it negotiated
ISshConnection and ISftpSession both carry Cipher now — the server-to-client algorithm off SSH.NET's own ConnectionInfo, captured once because a rekey is not an event that library raises — and TerminalWorkspace.GetSessionFacts hands that plus the host key's algorithm back per session, without ever handing over the connection itself. Nothing reads either yet; the status bar that will is the next commit. |
||
|
|
8915650a0d | Record the phone catching up in the design-import log | ||
|
|
b931a06998 | Repaint the phone's chrome, radii and accent to the v5 vocabulary | ||
|
|
ca48e18b57 | Give the phone the desktop's face: Montserrat by default | ||
|
|
c59b517fdf | Record v5c in the design-import log, and true up the manual checks | ||
|
|
bb2f973687 | Redraw the host keys screen with its pins' own facts beside it | ||
|
|
c8507b44fe | Give the application a settings area built from what really exists | ||
|
|
422d5ca10e | Record v5b in the design-import log, and true up the manual checks | ||
|
|
d91729c0b8 | Restyle the keychain, the snips and the logs to their v5b shapes | ||
|
|
43c939b697 | Give the window its v5b chrome and each session surface its own shell | ||
|
|
1b76c51fbb | Name v5b's deep chrome, its track and its magenta in the palette | ||
|
|
f3c0b9ca1b | Merge branch 'claude/friendly-elgamal-e085e5' into claude/v5-design-fidelity | ||
|
|
7ca74a1e35 |
Retint the scrims to the v5 canvas, quick connect at the spec's 60%
Five scrim hardcodes still dimmed through the old canvas #0E1220; they now sit on #05050A. Each keeps its alpha except QuickConnect's backdrop, which the v5 spec pins at rgba(5,5,10,0.6) — its essay drops from 80% to 60% of Canvas to match. |
||
|
|
d7f0bea258 | Repaint the launcher mark and the window icon in v5's own ink | ||
|
|
7d64027972 | Record v5 in the design-import log, and let the working notes go | ||
|
|
281e828e25 | Sweep out the group-card navigation nothing reaches any more | ||
|
|
dca2e888d6 | Redraw quick connect in v5's shape, auth word and all | ||
|
|
2ba7c14e35 | Restyle the drawer, pin folders on a host, and say when it was last connected | ||
|
|
c3ef4bd8b4 | Flatten the hosts screen into one board of sections | ||
|
|
369109dd4e | Write the disabled accent glow as 'none', which this Avalonia can parse | ||
|
|
06f9dcfc27 | Snapshot the v5 hosts screen mid-restructure, with handoff notes to resume it | ||
|
|
bea0279937 | Repaint both heads in the v5 palette and embed its three fonts | ||
|
|
49645db680 | Let a host carry pinned folders, merged path by path | ||
|
|
82966af37b |
Let a connection be reached through a proxy on this machine's loopback
Step 1 of docs/reaching-a-host-you-cannot-dial.md, and it is not the step that document said it was. SshConnectionRequest carries an optional SshLoopbackProxy and BuildConnectionInfo hands SSH.NET its proxy ConnectionInfo when there is one. Nothing passes one yet: the callers are jump hosts and the relay, which are steps 2 and 3. ◆ THE BRIDGE WAS THE WRONG FIRST STEP, AND BUILDING IT WOULD HAVE BEEN THE MISTAKE THIS DOCUMENT IS ABOUT. ADR 0004 says the relay's loopback bridge "also provides ProxyJump via a SOCKS5 dynamic forward — one mechanism, two features", and the plan took that to mean the bridge was the shared foundation. It is not: ForwardedPortDynamic *is* the listener for a jump host — SSH.NET accepts on it, speaks SOCKS5 on it and tunnels through the bastion — so nothing is left for a bridge of ours to do on that path. The relay is the case with no SshClient to hang a forward off, so it is the bridge's only consumer, and the bridge belongs in the commit that uses it. What the two actually share is one level down and a tenth of the size: being told to reach a target through a loopback proxy while staying about the target. That is what this is. Three properties, one test each. A port and nothing else, so a proxy anywhere but loopback cannot be expressed. The failure that shape rules out is an open SOCKS proxy on the user's network for the life of a shell, which nothing would report — so it is made unrepresentable rather than validated, on the same grounds AuthenticationChoice carries a kind. SOCKS5 rather than a dumb pipe, which is what keeps host key pinning honest. The target's own name and port stay in the request, travel to the proxy in the CONNECT, and are what the gate pins — so a machine reached through a bastion is pinned under its own name instead of under 127.0.0.1 on whatever ephemeral port that day's forward got, which is not an identity at all. A pipe would have meant handing SSH.NET a stand-in and remembering everywhere else that it was one. And a proxy that is not listening fails as a connection error rather than as an unknown host key. The gate turns "no host key seen" into a fingerprint prompt, and a connection that never reached a server has seen none either; the prompt would offer to fix the wrong thing, with no fingerprint to show. TWO THINGS THE TESTS MEASURED RATHER THAN ASSUMED, both found by the first run failing. The target is resolved at the *bastion*, not here — a SOCKS CONNECT names it and the far end looks it up. So the test asks for localhost:2222, the address inside the container, and the published port this host would use means nothing there. That is not a quirk of the fixture; it is what ProxyJump means, and it is why an ssh_config writes the target's internal address beside its jump host. Getting it wrong is a SOCKS "general failure" that names neither end. And the test server refuses forwarding. linuxserver/openssh-server ships AllowTcpForwarding no, which a dynamic forward does not notice — opening one asks the server nothing — so every connection through it is refused at channel-open and reported as the same general failure. The fixture patches it and HUPs sshd. There are two sshd_config files in that image and the running server uses /config/sshd/sshd_config; the first attempt patched /etc/ssh/sshd_config, which is the one a search finds first, changed the text and nothing else, and left the failure exactly where it was. VERIFIED. Build clean with no new warnings, 85 tests in Client.Ssh.Tests against the real sshd, and the solution builds. The proxy test was seen to fail — proxy.Port + 1 in BuildConnectionInfo — and seen green again. An earlier mutation attempt did not compile, and the log said 85 passing because the run never started and the previous log was still on disk; the second attempt deletes the log first, which is worth copying whenever a mutation "passes". dotnet format reports one pre-existing IDE1006 in DodoSSH.Api/Features/Events/EventsEndpoint.cs, in a project nothing here touches. Left alone. |
||
|
|
575a9a9f5e |
Stop the relay checkbox promising a connection this client cannot make
Ticking "Connect through the server relay" moved the host's address and port out of the encrypted payload into plaintext columns on the server — the single deliberate privacy concession in the design, per ADR 0004 — and then the client dialled the address directly, exactly as it does with the box clear. VaultViewModel builds SshConnectionRequest(hostname, port, username, credential) and nothing on this side reads RelayEnabled at all. The connection failed the way it always had, for a machine the laptop could not reach, with nothing saying the box had done nothing. The server half is built and shipped: tickets, the WebSocket, the deny list, the CHECK constraint that enforces a non-null address for a relay-enabled host. What does not exist is the client's path to it, so this is an unfinished feature rather than a broken one — but the control in front of it was collecting the cost of the finished version. Both heads now say so, in the label and in the first sentence of the paragraph under it. Not disabled, and that is the one decision here worth stating: a host somebody has already ticked has to be able to lose the flag, and a control greyed out with the concession switched on would trap it there. Tickable and honest beats untickable and stuck. This is step 0 of docs/reaching-a-host-you-cannot-dial.md, and the only step of it that should ship alone — the sentence is written to be deleted when the bridge lands. VERIFIED. Build clean, 112 layout tests. The drawer's paragraph is longer than it was and the host editor is measured with the drawer open at the window's minimum, so the wrap is held inside the column rather than assumed to fit. |
||
|
|
6185d74800 |
Write down the two things a user is promised and does not get
Two plans, both for the same class of defect: a control or a code that a user is told to rely on, backed by storage and by nothing else. Neither is started; what follows is the reasoning, so that starting is not where it gets thought about. ── UNLOCKING WITHOUT THE PASSPHRASE ───────────────────────────────────────────────────────────────────── Every account is issued a recovery code at enrollment. The client generates it, wraps the identity bundle under KEK_rc, the server stores that wrap as UserKeyWrapKind.Recovery, and both heads work to make sure the user writes it down — the phone raises FLAG_SECURE for that screen alone and will not let anybody past it. Nothing can use it. SessionOpener has UnlockAsync and UnlockWithDeviceAsync, and there is no third. Walk the failure through: forget the passphrase, and the bundle cannot be unwrapped, so no vault key opens and every item is unreadable. Signing out and back in returns the same passphrase wrap. The device key would be the other door, and sign-out withdraws it — which is the advice the unlock screen gives for exactly this situation. The loss is total and permanent, and the thing built to prevent it is inert. The docs already disagree with each other about this, which is how it surfaced. manual-checks §10.2 calls the code "the only thing standing between a forgotten passphrase and an unrecoverable vault"; android-port says losing it *along with* the passphrase is what makes a vault unrecoverable; README says signing out is the only answer and nothing can recover one. The third is the true one today. More than half the work is already done and one piece of it was done on purpose: LocalCacheKey derives from the identity bundle rather than from MK — crypto.md §3.2, changed 2026-07-30 — specifically so an unlock that never computes MK can still read the cache it wrote. What is missing is an endpoint to serve the wrap, an unlock path, and a way to set a new passphrase afterwards, without which the account unlocks with a one-time code forever. That last step is the same re-wrap a change-passphrase feature needs, so it delivers both. Two traps are recorded because both would produce a code that verifies nowhere. The derivation uses the displayed string *including its dashes*, so the unlock must canonicalise to the printed form rather than strip it; and the recovery wrap uses a different Argon2 profile to the passphrase one (64 MiB against 256), so it must derive from the parameters served with the wrap rather than from a profile constant. ── REACHING A HOST YOU CANNOT DIAL ────────────────────────────────────────────────────────────────────── This started as "delete the dead jump-host field" and inverted twice. HostSecret.JumpHostIds is stored, validated, encoded and three-way merged, and nothing reads it or writes it — the ssh_config importer looks like the writer and is not; it records ProxyJump as an option and a note saying DodoSSH cannot honour it. The first draft recommended deleting it. That was wrong twice over. ADR 0004's last consequence had already designed the implementation — a loopback TCP bridge for the relay, and "the same bridge provides ProxyJump via a SOCKS5 dynamic forward", one mechanism and two features — which the pinned SSH.NET 2025.1.0 supports through ForwardedPortDynamic and ProxyTypes.Socks5, checked in Renci.SshNet.xml rather than remembered. And the stored shape is right: an ordered list of host ids is what a chain is, the merge arm is correct, and the missing schema version is a line to add. ◆ Looking properly found the same shape one field over, where it costs something. RelayEnabled is also stored, merged and never read by the connect path — but it is user-settable, and both heads draw a checkbox promising it. Ticking it moves the host's address and port out of the encrypted payload into plaintext columns, which ADR 0004 calls the single deliberate concession in the design, and then the client dials directly anyway. The privacy is spent and the feature is not delivered. That is a defect rather than a gap, and it is step 0. The comparison the plan turns on: the relay reaches what the *deployment* can reach and the jump host reaches what a *machine in the keychain* can reach, so they are not substitutes. On a self-hosted box outside the target's network the relay reaches nothing the laptop could not. And the privacy ordering is the opposite way round from the ADR's framing — the relay costs a plaintext address, the jump host costs nothing, because the operator is not in it. Both documents carry a section on what their own earlier reasoning got wrong, which for the second one is the load-bearing part: "nothing reads this field" was read as evidence of a mistake when it was evidence of an unfinished feature — and the same sentence one field over would have found the checkbox that is lying. |
||
|
|
88809f0d66 |
Stop the docs claiming absences that have since been built
An audit of README.md, the seven docs and the fourteen ADRs against the code, looking for what is described as absent or planned. Most of it held. What did not is here, and it clusters: every stale claim but one is downstream of the settings file arriving without this document noticing. design-import-gaps said the client has no preferences store and writes exactly two files. It writes three — ClientSettings is in settings.json beside the cache — and two preferences are saved through it. From that one error followed four more: the terminal font size row said "fixed at the renderer's 13px" when it has been 8 to 32 from a screen and three chords for some time; the transfer-resume row and the per-host last directory row both blamed a store that now exists, when what they actually want is a table and a scalar file is the wrong shape for one; and the Preferences table asserted no preference could be saved at all. It also said TerminalServerOpcode has four values and none carries an option. It has eight, and one of them is FontSize — which is the interesting part rather than a counting error, because that opcode is the proof that the frame these rows say is missing can be built. The rows now say what each one would actually take, which for three of the four is a setting, an opcode and a control, and for the Backspace row is a reason: which byte backspace sends is a fact about the remote's stty, so a client-side switch fixes a mismatch by hiding it. THE SAME TWO ERRORS WERE SHIPPED IN THE INTERFACE. The preferences screen carries a NOT BUILT YET list, so that what the screen does not do is as legible as what it does — and it said terminal size was hard-coded a hundred lines below a working size control, and said there is one release channel a month after the nightly shipped. A list of absences is only worth having if it is true, and a screen contradicting itself in the same scroll is worse than no list. Both lines are corrected rather than removed: the first now says which three of the four are genuinely hard-coded, and the second says what is actually missing, which is a way to change channel from inside the application rather than by installing the other build. docs/adding-hosts-on-the-phone.md is deleted. It was a work plan whose own header says "Status: built. All six steps." — nothing links to it, and the decisions it records are in the code it produced, including the one it is proudest of: HostSecret.AsksForPassword carries its own remark on why naming neither binding had to stop meaning "ask me". What was left was step ordering and per-test instructions for work that shipped. Git keeps it. crypto.md is deliberately untouched. It is normative and frozen, and its claims are about the DSH1 format rather than about this build — including the one that reads oddly next to the code, that a passphrase is one of four ways to open a vault. Under the spec it is. What is missing is a statement about what this build can open, and that belongs beside the spec rather than inside it; see docs/unlocking-without-the-passphrase.md. VERIFIED. Build clean, 112 layout tests, 354 app tests. The preferences screen is measured by the layout suite, so the longer copy is held inside the window at the minimum size rather than assumed to fit. |
||
|
|
d8cf16fb46 | Merge branch 'claude/windows-multiselect-support-37541c' | ||
|
|
507cd9ff88 |
Choose more than one host card on the desktop, the way the phone already can
The chosen-hosts set has been in VaultViewModel since the phone's connect card became a contextual action bar: a set of entity ids, a tick on the row, and seven things that can be done to it. Only one head could fill it. The desktop's grid answered a press with one selection — the card the drawer, CONNECT and the context menu are about — so filing eleven imported machines under a heading was eleven drags, and clearing out a vault was eleven rounds of the deletion question. So the pointer gets three ways into the same set. Ctrl-clicks a card to tick it, Shift-clicks to tick the run between the anchor and the card, and drags a band out over the space between and below the cards to tick everything it touches. Esc, CLEAR, a plain click on a card and a click on the empty space each drop it, and Ctrl+A takes every card being drawn — VisibleHosts, so with something in the find box that is the ones on screen and not the ones it is hiding, which is the version of that shortcut whose result can be checked before Delete is pressed. TWO SELECTIONS ON ONE SCREEN, AND KEEPING THEM FROM DISAGREEING IS MOST OF THE CHANGE. Ctrl and Shift are answered on the tunnel and marked handled, so the ListBox never moves its own mark onto the card: a Ctrl-click that also selected would light the card it had just unticked and open the drawer on a machine somebody is removing from a set. A plain press drops the set unless it lands on a ticked card, and that case is deferred to the release, because the press may be the start of a drag of all of it. After any ordinary click exactly one card is in play, which is what makes every command on the screen unambiguous again. The context menu is where the seven live, and it is one markup with two halves gated on IsChoosingHosts. Connect, Browse files and Edit… are drawn only for a single ticked host, as the phone's sheet collapses them and for the same reason; the other four read better for a count. A right click on a card outside the set drops the set first, so a Delete… about the card under the pointer can never be offered while six sit ticked behind the menu — the same rule OnContextRequested has always enforced for the selection, reached from the other direction. No bar of buttons: the phone raises one because it has no other way to hold seven entries, and a strip repeating a menu that already exists would be a second home for the wording that matters most. What the desktop gains instead is a count beside the HOSTS heading, CLEAR, and a sentence saying where the actions are. A drag that starts on a ticked card carries every ticked card. The payload is a list rather than a row now, and a drop of more than one goes through FileChosenHostsUnder, which makes the refusals once — an open editor, and a group belonging to one keychain — and reports a count instead of forty status lines. Moving whichever card the pointer happened to be holding and leaving the other five where they are is a gesture that quietly does a fraction of what it looks like it does, and the five left behind look filed. The three panels the set's actions raise had never been drawn in a window: the vault picker with its key question, the group picker, and the deletion question. All three sit above the grid rather than over it, which is the arrangement the GROUPS section and the phone's list already use and for the reason written there — the ticked cards are the information the question exists to give, so the grid shortens instead. A DEFECT FOUND BEHIND IT, AND IT WAS ALREADY LIVE ON THE PHONE. The deletion question names a count and the run that answers it reads the set again, and nothing kept the two the same set: the panel is deliberately above a live list, so one more tick between "Delete these 6 hosts?" and pressing DELETE deleted seven, with the seventh named in nothing the user had read. It needed a deliberate act on a phone and a second's work with a band, which is what turned it up. VaultViewModel now remembers which hosts the question was asked about and drops the question when the set stops being them — the question rather than the set, because what somebody has just chosen is what they meant. It also covers the case nobody performs: a colleague's deletion arriving mid-question and shrinking the set under it. VERIFIED. 354 tests in App.Tests and 111 in App.Layout.Tests, build clean, no new warnings. Six gesture tests drive real pointer and key input through the headless window — the modifier click and what it must not do to the selection, the run and its re-measurement from the anchor, the band and the click that drops the set, Ctrl+A under a filter, and the menu's two halves — plus a DragOver carrying two hosts. Four layout tests measure the strip and the three panels at the window's minimum; the vault panel binds a key to its host first, or it would measure the short shape and certify the tall one. Two flow tests cover the multi-drop's write and its refusal, and the deletion question dropping itself. manual-checks gains 7.6a for dragging a set, which no test can see for the reason 7.6 gives, and 7.7a for the gestures — the rectangle actually being painted and the tick and the fill being legible together are the two things the harness cannot look at. |
||
|
|
7b616e0bb0 | Merge branch 'claude/windows-update-bar-buttons-b19b2d' | ||
|
|
36b8a23020 |
Give the update banner the view model it is typed to
The banner has never worked. It went into MainWindow's fourth row with no data context of its own, so it inherited the shell's — and it is the one control in that file typed to a screen's view model rather than to MainWindowViewModel, because it is the only one with a layout suite that hosts it over UpdateViewModel alone. Compiled bindings type-check against x:DataType at runtime, so every binding inside it resolved against the wrong object and failed the way a compiled binding does: quietly. No headline, and DismissBannerCommand and RestartNowCommand both null. A button with a null command is enabled, hovers, depresses and does nothing, which is why this looked like a hit-testing problem and why the WebView was the first suspect. It is not one. The strip is a sibling row for the reason the occlusion rule gives and that arrangement is correct — the terminal's rectangle is never covered, only shortened. What was actually on offer was an announcement that an update had been downloaded, with two buttons that refused to install it and no way to make it go away either. The preferences screen's RESTART NOW worked throughout, because it binds Updates.RestartNowCommand from the shell's own context, which is the contrast that pins the cause. The context is set on the banner itself and IsVisible loses its Updates. prefix with it, because a data context on an element resolves that element's other bindings too — the rule the page area's wrappers upstairs exist to work around. Those wrappers are needed because IsHostsScreen and its siblings belong to the shell; IsBannerShowing belongs to the banner's own view model, so there is nothing to wrap here. Neither existing suite could have caught it. A layout test supplies the data context it is measuring, which is exactly the assumption that was wrong, and the shell suite has no visual tree — its project file already says it does not cover whether the XAML binds to the right names. So the new test asserts the wiring rather than the layout: a real shell over the ready-update fake, MainWindow constructed and never shown, and the banner asked what context it got, whether it is visible and whether RESTART NOW carries a command. Checked failing with the one attribute removed. Constructing the window is safe where showing it is not, and nothing here needs it shown: a data context propagates when it is set, not when the tree is measured. |
||
|
|
1e8a1f2e83 | Merge branch 'claude/trust-connect-popup-56abec' | ||
|
|
4f9faa2fe3 |
Ask about a host key where the connection was made, not on the host list
The trust prompt was two banners at the top of the desktop's hosts screen, so the shell navigated there before letting a handshake raise one: Screen = Hosts, Surface = Page, in OnVaultConnectionFailed and again in the palette's own connect. The reason was sound — a connection can be started from Ctrl+K on any screen, and a question behind whatever somebody is looking at is a question nobody can answer — and it was answered the wrong way round. Rather than making the decision reachable from where the user is, it moved the user to where the decision was, and charged every screen for it. It is worst for the one connection that has no host at all. A machine typed into the phone's connect box is deliberately in no keychain, so a first contact from there judged it on a list it does not appear on, after taking the box that dialled it away. So both heads now draw the decision over the surface. HostKeyCard is the desktop's, and is the counterpart of the phone's HostKeySheet: a scrim with no press handler, because a question with two named answers must not be answerable by missing; the unknown key offering TRUST AND CONNECT, because judging a fingerprint against what an operator published is a decision a person is entitled to make and the only moment they can make it; and the changed key offering no way forward at all, because a button beside that warning is "continue anyway" with two clicks instead of one. The phone needed no new markup — its sheet was already a shell-level overlay, so deleting the navigation is what puts it over the Connections screen. IsHostKeyDecisionShowing is on the shell rather than on a screen because the answer decides an occlusion. A second connection can be refused while a first one is open, so this card is routinely raised over a live terminal, and that rectangle is a native child window: layered over it the card would be sliced at its left edge with TRUST AND CONNECT taking no clicks, which for the most safety-critical question in the product is the worst place for that class of bug to land. IsTerminalShowing gives the rectangle up instead. The banners are gone rather than copied. One prompt in two markups is two copies of the most safety-critical wording here, and the second is the one that goes stale. TWO DEFECTS FOUND BEHIND IT. VaultViewModel.RejectHostKey cleared only the pending key and never the mismatch, so the changed-key refusal had no working exit. That was invisible for as long as it was a banner nothing was drawn over — nothing was trapped, and the next attempt cleared it — and it was already live on the phone, where that refusal is an opaque full-screen panel whose one button runs this command: pressing it left the panel up over every screen the user went to next, including the host editor the panel tells them to open. TransfersViewModel.RejectHostKey has always cleared both; the vault's was the outlier. Its button said BACK TO HOSTS, which was wrong twice over, and now says BACK. And an assertion written for this change could not fail: the palette test asserted the renderer was collapsed in a scenario whose only tab had just been removed, so it was collapsed for want of a session whatever the occlusion rule said. It is gone, with a note pointing at the test that can fail on it. VERIFIED. 1580 tests, build clean, no new warnings, format clean. Three mutations each seen to fail and then seen green again: dropping !IsHostKeyDecisionShowing from IsTerminalShowing, caught by AChangedHostKey_CollapsesTheTerminalItIsRefusedOver; reverting RejectHostKey to clear one flag, caught by RefusingAHostKeyDecision_TakesItOffTheScreen(false) and by that same test; and dropping the two host-key arms from OnVaultPropertyChanged, caught by TheHostKeyDecision_IsAnnouncedToTheWindowWhenItArrivesAndWhenItGoes. That last one is the first test in this repository to watch PropertyChanged, and it is worth being the first: every other assertion about the flag reads it directly, and a direct read passes with the subscription deleted — while the card would never go away. The two layout tests moved with the prompts, from the hosts screen to the card. manual-checks gains 7.4a for the occlusion, 7.4b for getting out of a refusal and 11.7a for the hand-typed case, none of which a test can see; 1.5 and 7.4 are corrected rather than left describing a window that no longer moves. ONE ROUGH EDGE, DELIBERATELY LEFT. On the desktop, refusing a first contact whose tab was the only one leaves the terminal surface with no tabs — a blank rectangle under the strip's "no terminals open · press + or Ctrl+K", which is the one sentence near that rectangle Avalonia can draw. The alternative was falling back to the page, and on the phone that means the host list, which is the bug this commit is about. A desktop connect page would close it properly. |
||
|
|
e750ba05e3 | Merge branch 'claude/edit-screen-refresh-items-63a808' | ||
|
|
6d6edb02c1 |
Keep an open editor's pickers in step with the vault
The host editor's four pickers were snapshots taken when it opened, and the comment on EditorAuthenticationChoices said why: a picker whose contents move under somebody halfway through a form is worse than a list a minute stale, and only one editor could be open at a time anyway, so the only way to add a key was to close this one. The second half of that stopped being true when AHostEditorIsInTheWay was split from AVaultEditorIsInTheWay. The host editor is the Hosts screen's business and the keychain's editors are the Vault screen's; neither refuses the other now, which was the right split — it stopped three quarters of a screen going inert over an editor the user was not looking at — but it left the assumption those snapshots rested on false and nothing to notice. So the ordinary way of using the feature was the broken one. Somebody starts editing a host, finds there is no key to bind it to, goes to KEYS, makes one, and comes back to a picker that does not have it — with the fix being to throw the form away and start again. The same for a password, a tag, a group, and for a whole vault made on the Teams screen because the host being typed belongs to the team rather than to the person typing it: the vault they had just made for it was the one place they could not file it. RefreshOpenEditors refills whichever editor is open, and it hangs off ReloadAsync rather than off the twenty-odd commands that write to the vault. That is the choice worth stating, because it is what makes a sync count as well as a save: a key pulled from another machine reaches the open editor by the same path a key typed here does, and a place that wrote to the vault without refreshing the editor would be a bug nobody would find for months. What the old comment was protecting against is real, so every picker is put back onto what it was already showing, by id, and not one typed field is touched. An editor that reset its own bindings because a background sync landed would be a worse bug than the stale list this fixes — it would rebind a host as a side effect of somebody else's work. The placeholder entries go back too, which is the case 3.4 measures: a group deleted on another machine mid-edit still cannot unfile the host when the form is saved. The group editor gets the same treatment for the same reasons; it shares the drawer, and its default binding is lent to every host under it. The snippet editor's vault picker was the same copy of the same list and went stale the same way. It watches TargetVaults rather than the reload, because that screen has always been a wrapper over the vault's collections and has no reload of its own to hang off — which is how it already follows Snippets. The move panels are deliberately left alone. A vault arriving from a sync while one is open still will not appear in it, but a move panel is opened by the act that fills it and its picker resets its selection to the first entry on every rebuild, so refreshing it would move a destination somebody had chosen. Same class of bug, different answer, and not this change. Five tests, and four of them were checked failing with the RefreshOpenEditors call commented out: a key reaching the open host editor and binding when chosen, an item arriving without moving a selection that was already made, a tag arriving as an unworn chip, a key reaching the group editor, and a vault reaching the host and snippet editors without moving either. Manual check 7.12 sits beside 7.11, which is this same bug on the files screen's picker, and says what the worse failure would look like: a picker that moves rather than one that does not notice. |
||
|
|
808a9a7fc1 |
Open a new host in the vault of the group it is being made in
+ NEW HOST decided two defaults separately and let them contradict each other. The group came from the screen — the selected card, or failing that the group whose contents are showing — and the vault came from the keychain screen's standing "new items go to" preference. Inside a group belonging to any other vault the two disagreed, and the group is what lost: GroupInEditingVault drops a group the editor's vault has not got, on the sound reasoning that a host filed under an id its readers cannot resolve looks unfiled to everybody but the person who wrote it. So pressing the button while standing inside a team's PLATFORM opened a form filed under nothing, bound for the personal vault, with no sentence anywhere saying either thing had happened. The vault now follows the group. A group lives in exactly one vault, so a host that is to land in that group has to be sealed in that vault too — which is the rule + NEW GROUP has followed for a parent since the cards became a tree, and the comment there claiming this as a deliberate difference from the host's editor is the one the code has now caught up with. The filter stays, because there is one case left for it: the group's vault may be one this session can read and not write, a team vault this account is a viewer of. TargetVaults is the readable-and-writable set and is what decides here, so a viewer keeps the standing preference and loses the group with it, rather than opening an editor aimed at a save that cannot happen. Both directions are tested, since one alone would not say which default wins: standing in a shared vault's group, the editor opens on that vault with the group selected and the host saves there; and with the preference pointed at the shared vault while a personal-vault group is open, the group beats the picker somebody set once. |
||
|
|
f1d6499bb5 |
Merge branch 'main'
Two of main's changes land in files this branch rewrote, and both needed carrying across by hand rather than by the merge. The phone's nav staying up on Connections with nothing running is a fourth input to RefreshChrome, which this branch had already given two more — whether hosts are ticked and whether the host editor is filling the screen. They compose: the rail and the bottom bar now ask (pages || connectPage) && !editing, so a page-shaped terminal surface keeps its way off the screen and the editor still takes the whole display. The key question under the host's move panel is the harder one, because this branch deleted the panel it was added to. The connect card is gone and the phone's only route to a move is the action bar, so leaving the merge to take this side would have removed a capability main had just shipped — silently, since nothing would fail to build. It is asked in the action bar's own picker instead, in two shapes fewer than the desktop's: one host, because which key to carry is a fact about one machine and a selection of six has six answers, and a move rather than a copy, because taking the key out from under an original that is staying put would leave that original unable to connect. BindingOfTheMovingHost splits into MovableBindingOf so both heads answer it the same way from different panels. Main also fixed a real trap in the same commit — a host that only inherited its key from its group arrived in the destination naming nothing at all, because the group stays behind — and the batch move had the same bug for the same reason. It goes through Detached now, which is where that fix lives. The carried host is written as the carry left it rather than being detached again, which is the one thing worth measuring: the key takes a new id over there, so a run that rebuilt the payload from the row would send the machine across naming a tombstone. Both directions are pinned, along with the rule about which shapes the question is asked in at all. |
||
|
|
c882fa0cd3 |
Give the phone a selection instead of a card under the list
A long press on a host raised a connect card over the bottom of the list: a password box, CONNECT, EDIT, MOVE and DELETE. It was the right idea in the wrong place. It covered rows, it had room for five things and never a sixth, and every one of them was about exactly one machine — so filing eleven imported hosts under a group was eleven trips through a form, and there was nowhere to put a sixth action if anybody wanted one. A long press now chooses the host it landed on, and the actions move into a bar across the top of the screen, in the vault header's place rather than beside it. That is where Android has put them since contextual action bars existed, and it is the one strip a list can never grow into — but the real reason for it is that while it is up the screen is unambiguously about the ticked hosts and nothing else, which is what lets the count in the middle of it mean something. Left to right: the cross that leaves the mode, the count, the pencil, and a ⋯ holding Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate and Remove. A tap still connects and still raises nothing. Once anything is ticked it ticks and unticks instead, which is what every Android list does and is not merely a convention worth following: a tap that connected while five machines sat ticked would open a terminal on top of a selection somebody was halfway through building. Unticking the last host leaves the mode, so there are two ways out of it and the cross is only one of them. Both gestures now read the row from the element under the finger rather than from the list's selection, and that is a correctness change rather than tidying. A tap on a group heading moves the selection and the view model bounces it straight back to whichever host was chosen before — which answered "a host, or nothing" for free while a tap only ever connected. It stops answering it the moment a tap can tick one: the heading would tick a machine the user was not pointing at, into a set they are about to delete. Three of the seven entries are about one machine and are drawn only for one. A terminal, a file-transfer session and a form each have no reading over six, so they are collapsed rather than refused. The other four read better for a count than without one — it is the reason the set exists — and each of them says afterwards how many hosts it wrote and how many it left alone. Skipping beats refusing the whole run: a selection of eleven with one read-only row would otherwise do nothing at all and then report about the wrong ten. Copy to vault and Duplicate are new, and the difference between them is what each can safely carry. A copy crosses a key boundary, so it drops the group and the tags exactly as a move does — both are items of the vault being left, and a host arriving with either would point at something the destination does not contain, resolvable on the machine that sent it and dangling for everybody else. A duplicate stays in the same keychain, so everything it points at is still there and it keeps both. Change group is the write dragging a card onto a group already makes on the desktop, run over a selection; it refuses one spanning two keychains rather than half-filing it, which is the refusal a drop across that boundary already makes one host at a time. Connect via SFTP is the one action that leaves the vault. Which machine is a decrypted item and so is this object's business; the screen it leads to and the transfers view model behind it are the shell's — so it is an event, on the same division SessionOpened already draws for a shell. The host is re-found in that screen's own copy of the list, because the picker binds to rows in that copy and handing it the vault's object would select nothing. What is left of the card is the password box, and only because it had nowhere else to go: a host that authenticates with a typed password cannot be reached by a tap alone. That tap now raises a sheet rather than the bar, and the difference is that a sheet is up only while a question is on screen — the bar was raised by a long press and stayed, so it was a password box sitting over the list whether or not anything was being asked. Dismissing it empties the box, which is not tidiness either: a secret left behind would satisfy the emptiness check that decides whether to raise the sheet at all, so the next tap would dial with somebody else's password. The pencil moving into that bar takes the host editor with it. It was a card in the list's own row, under the search box and the sync line — twenty controls sharing a screen with two rows of chrome about the list it had replaced. It is a page now, and PhoneShell stands all four of its rows down for it, which is what "opens with all the options" means at 360dp. That needed a second subscription in that control: two of its flags are questions about the vault rather than about the shell, and the shell does not forward the vault's notifications. The ticks are held as entity ids rather than as rows, and written back onto the rows after every reload. Every row object in the list is replaced on every filter keystroke and every synchronisation pass, so a set of rows would empty itself once a minute under somebody choosing what to do with eleven machines. Ids that no longer resolve are dropped, so a colleague's deletion arriving mid-selection leaves a count that matches what is on screen. One caller had to change with it. ConnectToRecent opened the pane about a host, which was the desktop's drawer and the phone's card; the phone's answer is now a tick, and nothing on that list means "selected" any more — so arriving with the host merely selected would be arriving at a screen with nothing to press. Both are raised together, and the one the head in front of the user does not draw is inert. |
||
|
|
69858f82d1 | Merge branch 'claude/vault-key-sync-sharing-d098aa' | ||
|
|
509a7c34f5 | Merge branch 'claude/snippets-vault-sharing-470476' |