Public Access
main
425
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ed936380b3 |
Give the user menu one door into settings instead of three
Settings, Vaults and Preferences all opened the same full-window settings mode and differed only in which of its seven pages they landed on. SettingsNav, which lists every one of those pages including Vaults and Preferences, is on screen the whole time you are in there — so two of the three rows were deep links into a place the third one opens. They saved one click and cost a menu that read as though it offered three destinations. It was three rows for a reason that had stopped applying. Until v5c, "Settings" and "Preferences" were two doors onto the same bare Preferences screen, and splitting them was the fix for something real; Vaults arrived beside them when VaultsScreen became a settings page too. Folding those two back in is not a return to that state. They are pages now, with a rail of their own, and what a menu at the foot of the nav rail is for is getting into settings at all. Nothing became unreachable — both are one click deeper, from the rail that was always going to be the way to them. ◆ WHAT STAYS, AND WHY IT IS NOT THE SAME ARGUMENT. Logout stays and still lands on the Account page rather than on General, because its confirmation card is drawn inline on that one page while the vault is unlocked; arming it from anywhere else would raise a card nobody could see. The vault switches and New vault above the divider stay too — neither is a page of the settings mode, and the switches have no other home. ◆ THE ROWS ARE REMOVED FROM THE MARKUP, NOT UNWIRED. NavRailTests asserts their absence rather than only asserting that Settings still works, because the failure that guards against is silent: a row left in the markup with its handler deleted is a menu entry that does nothing when pressed, which reads as a broken application rather than a tidied menu. The helper it asserts through excludes the vault switches by their data context and not by their label — a vault is free to be called "Vaults", and a test that a menu entry is absent must not be defeatable by what somebody named a vault. Two facts go, one comes back plus the absence check; the layout suite is 166. Manual check 1.8 walked a reader through all three rows in turn and now walks them through the one, then through SettingsNav's seven — which is where the two went. design-import-gaps.md gains a v5c-5 section rather than an edit to the v5c table: what that table records is what shipped in that pass, and it did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c270f3fe39 |
Import a Hetzner Cloud project's servers, and forget the token that read it
An estate on Hetzner is already described somewhere: the project lists every server, its addresses, its plan and where it runs. Getting that into a keychain meant copying forty addresses out of a browser by hand, which is the same work the ssh_config importer exists to remove for the machines described in a file. ◆ A WIZARD RATHER THAN A SCREEN, AND THE DIFFERENCE IS WHAT EACH STEP COSTS. ImportScreen reads a file that is already on this machine, so it scans on open and shows a list. This cannot: it needs a credential that has to be made in another company's console first, and then it makes a request over the internet. Those are two moments rather than one, and a screen that mixed the token box in with the list would ask for the credential again every time somebody came back to look at the list. So three steps — token, choose, done — and the first two write nothing. Step two carries one address choice, one login user and one port for the whole import, because an estate is reached the same way throughout and a per-server dropdown would be forty answers to one question. ◆ THE TOKEN IS USED ONCE AND CLEARED, INCLUDING WHEN IT WORKED. The half worth stating is the success path. Clearing only on failure would leave a credential capable of listing somebody's whole estate sitting in a text box for as long as they spend reading forty rows, which is the longest anybody stands on this screen. Nothing writes it to the vault, to settings or to a log, and reopening the wizard restarts at step one rather than resuming — a resumed step two would be a list nothing on screen has the means to refresh, going on looking current for as long as the application stays open. Read is the only permission it needs and the note above the box says so: a Read & Write token would work identically here and would be a credential capable of deleting the estate, pasted in for no gain. The note also says a token belongs to one project — there is no API call that will say which, and none that lists the others — so an account with four projects runs this four times. Without that sentence the wizard looks like it lost most of the estate. ◆ THE ADDRESS IS A CHOICE, AND TWO OF THE THREE FALL BACK WHERE THE THIRD MUST NOT. Public IPv4 and public IPv6 fall back to each other: the same machine over the same internet, so the worst case is a route nobody picked, and the row says which. Private does not fall back at all. A server on no private network is simply not importable under that choice — its tickbox is disabled, and neither direction of the header's tick-all can reach it — because falling back would put an address on the open internet into a bookmark that was asked to stay off it. IPv6 needs saying twice over. Hetzner routes a whole /64 and the API reports the network, which nothing can dial; the address a stock image answers on is the first one in it. That is a convention of Hetzner's own images rather than a fact the API stated, so every use of it carries a warning on the row and in the stored note, and it is computed by setting the low byte rather than by string surgery on the prefix. It is not an edge case either — IPv4 is billed separately now and a project built since then can be entirely IPv6-only. ◆ NOTHING IMPORTED HERE AUTHENTICATES, AND THAT IS SAID BEFORE THE BUTTON. Hetzner has never held the private half of an SSH key. The API will name a project's keys and hand over their public halves; there is no private half to bring. So every host arrives bound to nothing and asks for a password until somebody points it at a key already in the keychain — the same shape an ssh_config import has with its key tick off, and stated above the button on the same reasoning: somebody who is not told will conclude the import was broken. What the note keeps is what does not go stale — the id, the plan, the location, the image, the labels. The power state is a column and never a note: "running" about a machine somebody switched off last March is worse than no note. ◆ WHERE IT LIVES: TWO FLAGS OVER ONE CONTENT COLUMN. Drawn over the Preferences page inside settings mode exactly as ImportScreen is, with SettingsNav unmoved and the titlebar offering "Back to preferences". IsHetznerImportOpen is a second flag rather than a "which importer" enum, because they are two independent facts about the content column and a single value would need a "neither" member IsImportOpen already expresses; what keeps them from both being true is that every path that opens one closes the other. The titlebar and the Preferences page ask IsImporterOpen, which is written against both. Preferences grows a second importer row beside the first rather than one row that asks "from where?", which would put a question in front of the far more common answer. ◆ WHAT IS NOT HERE. Hetzner's Robot — dedicated servers and Storage Boxes — is a different API behind a different credential, and nothing in this touches it. Said on the Preferences row and on the token step rather than left to be discovered by an account that has both. And there is no phone layout. This one would work on Android, unlike the ssh_config importer beside it: it is a token and a network request, the view model is shared already, and scoped storage has nothing to do with it. What is missing is a screen — the desktop draws a five-column table, and the phone version of that is a different layout rather than the same one narrower. Recorded in android-port.md and on the phone's own Preferences screen, because the note there previously read as though no import of any kind could work. ◆ TESTS, AND WHAT IS LEFT FOR A PERSON. HetznerCloudClientTests is 16 cases over the shapes the API actually returns, against a stubbed HttpMessageHandler: paging followed, the token in a header and never a query string, a refused token distinguished from a fault at Hetzner's end, a token with a line break refused before anything is sent, the /64, the private-network asymmetry, and a server the API described without a name. ShellFlowTests.Hetzner.cs is 11 more against this fixture's real vault, real cache and real server — split into a file of its own, as FakeVaultServer keeps its team endpoints. What they establish beyond the reader's own suite is the half it cannot reach: that reading stores nothing, that importing goes through the ordinary create-and-push path and lands on the server with nothing pressing Sync, that a server the keychain already has arrives unticked, and that the token does not outlive the request. Two layout cases measure both steps against the same content budget every other settings page gets, which is also what proves the markup binds to names that exist. Manual checks 2.11a–2.11d are what no test in this repository can do, because there is no account, no token and no network in the ordinary suite: a real project listed against the real console, a wrong token, the private choice, and what an imported host does when you connect to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
56c19b006d |
Stop the updater offering the build it saw first, whatever has been published since
A client two versions behind was offered the older of the two, installed it,
restarted, and was told at once that another update was waiting. Two downloads,
two installs and two restarts to move one step.
The feed was not the problem and could not have been. The nightly release is
deleted and remade on every push to main, so it holds exactly one build: a client
can only ever be offered the newest thing there is. The wrong version came from
this application's own state.
◆ THE PASS STOPPED ASKING, WHICH IS NOT WHAT IT WAS MEANT TO STOP DOING.
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
Ready means something is downloaded and waiting. The intent behind excluding it is
right — there is no point fetching what has already been fetched — and what it
actually did was skip the check. So from the first thing any process downloaded,
its banner was pinned to that build for the rest of its life, however many newer
ones were published afterwards. On a rolling channel that is the normal case
rather than a corner.
Not re-fetching is still the rule and now lives in FetchAsync, which compares
versions rather than refusing to look: the same build is left alone, a newer one
replaces what was pending. Which is also why FetchAsync answers with what is
*pending* rather than with what the check returned — a client on 1.0 holding 1.1
is told 1.1 by every later check, because 1.1 is still the newest thing on the
feed, so "the check found something" and "there is something new" are different
questions and only the second one is worth a download.
A build already fetched is never discarded, including when a check answers null.
Null means the feed's newest is no newer than the build that is *running*, which
says nothing about the one on disk — that one is still newer, and withdrawing an
offer somebody has already been shown would be the worse answer.
◆ AND A LAST LOOK BEFORE THE PROCESS IS REPLACED, WHICH IS THE HALF THE TIMER
CANNOT REACH.
The loop asks every six hours. The build that caused this was published minutes
before RESTART NOW was pressed, so no interval anybody would accept would have
caught it. The press now asks once more.
It costs one small request in the ordinary case, because the feed answers with the
build already on disk and nothing is fetched. It costs a download only when there
genuinely is something newer, which is the case where the alternative was two
downloads anyway.
Bounded at ten seconds, and the wait rather than the request: VelopackUpdateChannel
records that Velopack's check takes no token, so cancelling would be observed only
after the call had already returned. Ten seconds bounds a small JSON document
against a network that drops packets rather than refusing them; a refused
connection fails in milliseconds and never reaches it.
Nothing here refuses a restart. An unreachable forge, a look that ran out of time,
a fetch of the newer build that failed halfway — all leave the build on disk
untouched and still newer than the one running, so the restart goes ahead with it.
Hence swallowed rather than reported: Failed is for something a person is waiting
on an answer to, and this person is waiting on a restart they are about to get.
IsRestarting keeps the banner up through it. Everything the strip draws keys off
IsReady and the look moves the state off that, so without it pressing RESTART NOW
made the banner vanish with nothing on screen saying why, for as long as a 120 MB
package takes. It says what it is doing instead, percentage and all.
◆ THE CHANNEL HAD TO LEARN WHICH BUILD IT FETCHED, AS AGAINST WHICH IT LAST FOUND.
Required by both halves above rather than tidied along with them. Matched()
resolved an AvailableUpdate only against `found`, which a check overwrites — and
both changes make this application check while a build is already downloaded. A
check that lands a newer answer and then fails to fetch it, or is given up on
halfway, leaves the shell restarting with the build it already has and Matched()
throwing at it: the offer real, the package on disk real, and the only thing wrong
being that the channel had forgotten which one it was. A slow forge became a
refused restart.
So `fetched` is recorded after a download returns, tried before `found`, and
survives every later check.
Settings → General claimed checks start "a couple of minutes after launch", which
has been untrue since the first pass moved to launch. It now also says the loop
keeps asking while an update is waiting, because that is the behaviour somebody
reading that row would want to know about.
UpdateFlowTests goes from 21 to 28: the pass asks again and fetches once, the
pending build is replaced when the feed moves on, a superseded offer reappears
through a dismissal, the press installs what the last look finds, and the three
ways the last look can fail all still install what was already on disk. Check
16.7a is the manual half, needing two real releases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1908189100 |
Make the terminal's right button copy and paste, and never open a menu
Selecting output and right-clicking it opened the WebView's own context menu with Copy on it, which is correct behaviour for a web page and wrong for a terminal. What a terminal's right button has meant since the Windows console got QuickEdit, and since PuTTY shipped, is this: with something selected it copies and drops the selection, with nothing selected it pastes. No menu, no second click. The gesture can only live in the page. The selection is drawn into a canvas the host never sees, and once a terminal has focus the host's window receives no input events at all — the same reason the Ctrl+plus chords are handled there. ◆ THE CLIPBOARD IS THE HOST'S IN BOTH DIRECTIONS, AND THE READING IS WHY. navigator.clipboard.readText() is gated behind the clipboard-read permission in every Chromium, and an embedded WebView with no PermissionRequested handler answers a permission request by putting a prompt on the screen. Trading a context menu for a permission dialog is not this change. So the page asks and the host answers, exactly as requestFontSizeStep does. Writing does not go the other way either, although navigator.clipboard.writeText would have worked from the page on Windows: a clipboard that is the host's in one direction and the page's in the other is two mechanisms to keep working across WebView2, WebKitGTK and Android's WebView. The host has owned a clipboard delegate since the keychain grew its COPY buttons, so each App.axaml.cs grows a ClipboardReader beside its ClipboardWriter and MainWindowViewModel takes it. Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard. ◆ Clipboard IS NOT Paste, AND THE DIFFERENCE IS THE BRACKETING. Paste carries a snippet off the sidebar, and the page deliberately *types* a single-line one rather than bracketing it, because bash marks a bracketed paste as an active region and a snippet should read as though it had been typed. A clipboard paste is the case those markers are for — readline uses them to decide it has been pasted into — so Clipboard is always bracketed and never followed by an Enter. One opcode for both would have needed a flag meaning "which of the two kinds of paste is this", on a payload whose existing flag byte means something else. Both client frames are dispatched before the session lookup, which is what keeps the gesture working in a pane whose shell has just ended: squinting at the message a remote left behind is exactly when somebody copies the line. Dispatch was past MA0051's length once it grew a second such branch, so the frames no pump needs to see moved into Announce() and the font-size chord went with them. ◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED. preventDefault alone suppresses the menu and still lets xterm's own contextmenu handler run, which moves its helper textarea under the pointer at z-index 1000 and leaves the selection sitting in it, selected. Both of those exist to serve the native menu. Taking the event in the capture phase on the pane and stopping it there is what keeps the gesture wholly the page's. rightClickSelectsWord is now false, which on macOS is a change of default rather than a restatement of it: xterm sets it true there alone, so a right-click would have selected the word under the pointer and copied that instead of pasting — wrong on one platform only, which is the worst way for it to be wrong. The renderer harness grows the pane it has been inviting since RendererReconnectionTests: a fake Terminal, the two addons, and a real UTF-8 codec, because both directions run through the page's own TextEncoder and TextDecoder and a Latin-1 stub would pass a test with an em dash in the selection. Its warning about declaring names terminal.js declares now covers the half that does not SyntaxError — a harness frame() was silently replaced by the page's frame() and failed six tests on a type error a long way from the cause. Manual checks 1.11 and 11.13 are what is left for a person: that a real contextmenu event reaches a capture listener, that no native menu is drawn once it is prevented, and that xterm's real paste() brackets. 11.13 also records what is unverified — whether Android's WebView delivers a pointer's secondary click as a contextmenu event at all. ClipboardReader is wired on that head regardless, because the alternative is a head where the paste half silently does nothing and nobody finds out; that head is not compiled here either, this machine having no android workload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
c0396b4f4d |
Merge pull request 'Let the session address be selected, and copied whole' (#16) from claude/terminal-ip-selection-4db6cd into main
Reviewed-on: #16 |
||
|
|
06369274dc |
Let the session address be selected, and copied whole
The address at the head of the session column is the one place the desktop head prints the endpoint it actually dialled, and it was a TextBlock — which in Avalonia is a drawing of a string rather than a string. The next thing people do with an endpoint is paste it somewhere: into a second client, into a ticket, into the ssh command being written in the window beside this one. Retyping is how a digit goes missing from an address that is mostly digits. SelectableTextBlock, which is what the rest of this application already reaches for wherever a string is on screen to be taken away — a fingerprint, a public key, a recovery code, a snippet's command. Nothing else about the row moves: same font, weight, colour and trimming, same tooltip, same close button beside it. The sidebar is one control drawn on both surfaces, so SFTP gets it for the same reason it gets everything else in that block. The trimming and the selection do disagree at the long end, and the fix for that is not to drop the trimming: the ellipsis is there because a long account@host:port would otherwise push the close button off the edge of a panel whose whole point is that it can be got rid of. A drag reaches only the glyphs that were drawn, so a trimmed address has to be taken with select-all — and select-all takes the whole thing, because the ellipsis is a drawing and not a shorter value. That is the half a test can quietly lose, so it is the half the new test asserts, on the longest address the suite has rather than on one that fits and would pass either way. 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 / desktop nightly (pull_request) Skipped
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
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. |