Commit Graph
7 Commits
Author SHA1 Message Date
jaap-jan 43c939b697 Give the window its v5b chrome and each session surface its own shell 2026-08-08 00:49:59 +02:00
jaap-jan af0e29a98b Give the desktop a nightly channel, the way the phone has one
ADR 0014 gave the phone a nightly and ADR 0013 rule 3 gave the desktop none, so
the two heads had different answers to the same question — how does somebody try
what is on main? — for no reason except the order the work happened in. This is
the desktop's answer: CI publishes a build from main on every push, and it
installs beside the release one rather than over it.

The phone gets its separation from the platform. Android refuses an update signed
by a different key, so its two channels cannot replace one another whatever
anybody does. Nothing refuses anything here: Velopack applies what its feed serves
and verifies no signature. So all of it is construction, and there are four
separations because each closes a different door.

A pack id each, so the two install in different directories and neither feed's
package can be applied to the other's install. A Velopack channel each —
win and win-nightly — so neither build ever reads the other's release index; the
name reaches the wire as releases.win-nightly.json, which is why the constant in
VelopackUpdateChannel and the argument in ci.yml have to agree or the channel
answers nothing forever with no error. A prerelease flag, so the release channel
cannot see the nightly even by accident. And a profile directory each, which is
the one that is easy to skip and would hurt most: the cache schema is migrated on
every launch, before unlock, so a shared profile means a nightly quietly upgrading
a database the release build then opens. Both are installed at once by design, so
that is an ordinary Tuesday rather than a corner case.

The prerelease flag turns out to be load-bearing across heads as well. The phone's
release channel reads releases/latest, which skips prereleases — so a desktop
nightly published as a stable release would become the newest release in this
repository and every phone on the release channel would start failing its check
against a release carrying no Android manifest.

Which build this is arrives as assembly metadata, the same mechanism and the same
reasoning as the Android head: the updater needs the string rather than a branch,
and a value baked into the assembly is one a crash report can be asked for. Three
things read it — the feed, the prerelease flag, and the profile — and one more
shows it: the titlebar says DodoSSH Nightly. Everything else that distinguishes
the two is somewhere nobody is looking while typing a passphrase into one of them.

The version needed a floor and it is applied to the whole build rather than to the
packaging. MinVer answers 0.0.0-alpha.0.N until the first v* tag and vpk refuses
anything below 0.0.1, so the job lifts the patch digit and keeps the height —
through MinVerVersionOverride, so the assemblies carry the same number the
installer does. Packing a version the assembly disagreed with would put one string
on the preferences screen and another in the feed, which is the screen somebody
reads when asked which nightly they are on.

Two things found by running it rather than reading it. -t:MinVer needs a restore
first, because the target arrives with the package and MSB4057 on a clean checkout
reads like a typo in the workflow rather than a missing restore; the release
script had the same gap and now restores before it reads. And vpk rejects an empty
--packVersion loudly, which is how a broken version handoff announces itself
rather than shipping a package called 1.0.0.

Rule 3 is untouched. The release channel still has no job, no token and no runner,
and the two channels cannot see each other. What a nightly costs is written where
somebody reads it before installing one: whoever can write a release here can put
a build on every nightly machine, which is fine for a build being tried and is not
fine for a build holding somebody's infrastructure credentials.

Verified by running the job's own steps against a clone in a Linux container:
DodoSSH.Desktop.Nightly-win-nightly-Setup.exe, and an index naming pack id
DodoSSH.Desktop.Nightly at 0.0.1-alpha.0.144. The upload itself is the one step
not exercised — it needs a real forge and a write token, and check 16.10 is what
walks the half no runner can.
2026-08-05 22:35:51 +02:00
jaap-jan 208443b932 Move SFTP and S3 into the tab strip, and the host list into a card grid
Four asks in one pass over the desktop head, and two of them are furniture
moving rather than anything new.

THE STRIP IS THE WINDOW'S NOW, not the terminal's. Vaults, SFTP and S3 sit at
its head and cannot be closed, and open terminals follow them. SFTP and S3 left
the nav rail to get there, which is the one semantic change: they are the two
destinations you stay in while something runs, and a rail entry is for
somewhere you go and come back from. So the rail belongs to the Vaults tab and
collapses with it, which also means SFTP, S3 and a terminal each get the full
1016 rather than the 826 a page gets.

The tab is expressed as "a page, and not one of the two the strip took" rather
than as a fourth ShellSurface. Both are still ShellScreen members and have to
be — that is what they are on the phone, where they are two rows in a hub — so
a surface each would have been a second way to say a thing Screen already says.
IsTransfersShowing and IsBucketsShowing light the other two tabs unchanged.
What is new is one field: the page Vaults comes back to, because it is the one
tab with sub-navigation and therefore the one that can return to the wrong
place. That is not the hidden field ShellSurface argues against — that one
would be a second copy of "which page"; this is a tab remembering its own.

THE HOSTS SCREEN IS A GRID, and the 268-pixel sidebar is gone. That column was
choosing among forty machines and editing one of them at two-thirds width, and
it was narrow so the editor beneath it could be a column at all. Cards took the
first job at full width; a 304-pixel drawer took the second and collapses when
nothing is selected. Pressing a group card narrows the grid; SHOW ALL is the
way back. The group editor moved into the drawer as well, which finally makes
IsEditingGroup mean the same thing on both heads — it was the phone's alone,
because the desktop's editor was a bar that was always on screen.

AreHostsExpanded and ToggleHosts went with the control that used them. They
folded the whole list away under one heading, an affordance that existed
because the column was narrow. Folding a single group is a different thing and
is still here.

THE TYPE SCALE IS A POINT LARGER and the text ramp is white. The base size was
never stated anywhere — a bare TextBlock took TextElement's default of 12 — so
raising the scale meant naming it, on Window and on UserControl. The second
selector is not redundancy: the layout harness hosts a UserControl in a window
it builds itself, and without it the suite would measure every screen a point
smaller than it ships, silently. A selector on TextBlock would have been the
obvious way and is wrong, because a style setter beats an inherited value and
would collapse every deliberate step back to one number.

#E3E7F4 is a blue-tinted white on blue-black surfaces, which costs contrast
twice — once for being darker than white and once for sharing a hue with what
it is drawn on. Pure white is 18.3:1 against the canvas where that was 15.5:1.
Every step below moved with the top, so the intervals the design chose are
kept and TextDim clears 9:1 against 6.4:1. The palette is shared, so the phone
has both changes too.

TWO DEFECTS THE HARNESS STRUCTURALLY CANNOT SEE, found by rendering the screen
rather than by measuring it, and both now covered.

The tile was 232 and was first written as 248, from arithmetic that left out
the scrolling stack's own margins. Every layout test passed — the harness asks
whether a control is inside the window, never how many fit on a line — so the
grid quietly became one column wide at exactly the minimum this application
guarantees, which is the shape cards exist to avoid.
TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen counts columns
instead, and fails at 248.

And a card's text ran past its own border, because a horizontal StackPanel
measures children with infinite width: a TextBlock inside one never learns it
is short of room, so TextTrimming never fires. Both card rows are grids with a
star column that gives way and an Auto column that does not — a hostname with
its tail cut is still the machine you were looking for, where a badge or the
word naming an auth method is not.

The keychain header changed shape for the same class of reason. It was
Auto,Auto,*,Auto with the buttons last, so the slack column was the only thing
absorbing a change of width and five buttons fell off the right edge the moment
the type grew. That is how GENERATE lost the word KEY once already. The summary
sits in the star column and trims now, so the buttons always get their width.

HostSidebarTests became HostGridTests and moved to the grid with the gestures
it drives. docs/design-import-gaps.md gains a v3 section naming the five
toolbar controls in the design with nothing behind them — a view-mode switch, a
tag filter, a calendar, a share control and Serial — and manual-checks.md and
the README follow the controls that moved.
2026-08-03 15:08:48 +02:00
jaap-janandClaude Opus 5 359087f1ce Give the tabs their hover back, and the plus the shape its comment claims
ci / build and test (push) Successful in 1m12s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 27s
Two things v2 broke in the last commit, both found by reading resolved brushes rather than markup, and both
the same mistake: Avalonia has no specificity, so the later declaration wins, and a rule that restated the
base instead of excepting from it went in below the rules it was supposed to be underneath.

Making a tab a pill gave it a Background of its own, which the flat tab it replaced never had. That one
detail moved where the hover has to live. `Button.flat:pointerover` is declared far above and had been
supplying it; the moment `Button.tab`'s template rule set a Background, it won, and every tab in the strip
stopped answering the pointer. Silently — a tab that no longer lights is not a crash and not a layout
change, and nothing in a suite that measures heights and reachability can see it.

The `+` lost more than that. It sits below as an exception — no outline, because it is not one of the
things being chosen between — and a `.tab` rule declared after it was overriding the exception itself. It
drew as a filled, outlined pill identical to a tab, contradicting the comment directly above it.

So the base pill and its hover come first now and the exceptions follow, which is the order the rest of
this file already uses and the order the Border.rowmark note further down was written about. The `+` clears
the fill as well as the border, because an exception to a rule that sets both has to say both.

The titlebar's search box had the same shape of error in geometry rather than colour. The design draws it
at exactly 380 and centred, and stating that as a Width on the inner Border is what made it wrong: the
button around it is free to shrink when the account name or the vault chip beside it is long, and a Border
that will not shrink with it arranges outside its own parent — over the name on one side and over the
window buttons on the other. MaxWidth on a stretching button gives the same 380 whenever there is room and
gives way when there is not.

The regression test is the point of this commit rather than an afterthought. It is the only test in that
suite that reads a brush, and the gap it fills is exactly the one these two went through: everything else
measures rectangles. It hovers a tab through the real input path and asserts the fill changes, then asserts
the `+` is neither filled like a tab nor outlined like one. Checked against the broken ordering before
being kept — it fails there and passes here, which is the only thing that makes a regression test worth
committing.

Verified by the whole suite: 1310 tests over nineteen projects, none failing, the layout suite now 70
cases. Both heads build. Still nothing seen on a display.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 19:54:37 +02:00
jaap-janandClaude Opus 5 3627021420 Give the desktop the second design too, and the window the size it now needs
The desktop v2 design is the other half of the one the phone took last commit, and this is its chrome: a
190-pixel labelled sidebar where the 54-pixel icon rail was, a titlebar with the search box centred in it,
and session tabs drawn as pills. The palette was already here — it is shared, and moved when the phone's
did — so what this changes is shape rather than colour.

**The window's minimum grew, and by exactly what v2 added.** The sidebar is 136 wider and the chrome 14
taller, so 880x560 became 1016x574. That is not a round number somebody liked: it leaves every screen the
same 826x464 it was designed against, which is the arithmetic the layout suite is built on. Four of the
tables stop fitting at 690 wide, so widening the sidebar and leaving the window alone would have broken
them somewhere no test was looking. LayoutHarness carries the new constants and the suite still passes at
the minimum, which is the whole reason it exists.

The rail's five-character abbreviations are gone with the width that caused them — PINS and SNIPS are Pins
and Snippets again — and each row gains a glyph and a count. A count is drawn only where one is real, so
SFTP, Logs and Preferences show nothing rather than a zero: a transfer queue's depth is not how many files
a screen holds, and a log has no total until it is read. The count beside Pins is the vault's own, not the
Pins screen's VisiblePins, which is the filtered list and would have made the sidebar count whatever
somebody had just typed into a filter box on another screen. Teams has no count for a related reason: they
are read from the server when that screen is opened rather than on unlock, so a number there would read 0
until somebody had already been to look.

One colour moved with it, finishing what the repalette started: the live-session summaries on the unlock and
sign-out cards were Info, so the two heads disagreed about a fact the phone paints green. They match again.

**Buckets became a destination rather than a mode**, which is what the design draws and what the phone
already does. The HOST / BUCKET pair inside the files screen is gone; ShellScreen.Buckets draws the same
TransfersScreen with the other picker, and the sidebar entry is what sets it. That also settles an old
disagreement rather than merely moving it: TotalItemCount is keys plus passwords and excludes buckets, so
the number beside the keychain used to disagree with the list under it, and now counts what that screen
shows.

There is one session behind both file destinations, so asking for the other kind while something is open is
refused rather than obeyed — and refusing means staying put. An earlier turn of this had it move anyway and
only decline to switch the picker, which put the S3 entry in the sidebar over a screen still listing an
SFTP host: two pieces of chrome disagreeing about where you are, which is worse than the navigation simply
not happening. The message that says so goes to Transfers.Status, which turned out to be drawn in the same
grid cell as the connected chip — survivable while it was mostly read before connecting, and not once a
refusal reports itself there. It has its own column now.

The design has nine entries' worth of screens and draws five. Pins, Teams, Import and Preferences are
built, working screens, so they keep their entries — the sidebar is labelled now and has the room, and
dropping an entry would have stranded a screen rather than simplified anything. The Team vault card the
design pins to the foot is not drawn: it is a second route to a screen already in the list, carrying a seat
count nothing here produces.

**The status bar survives the design that deletes it**, cut down to one thing. Two of the three facts it
carried moved into the titlebar with v2 — the sync word is beside its dot and the shortcut hint is inside
the box that uses it — so those are gone from it rather than printed twice. The third is Vault.Status, the
only channel this application has for saying a save failed or a merge picked a winner. The design is a
mock-up of an afternoon that goes well and has nowhere to put a sentence like that; dropping the bar would
have meant dropping the sentence or repeating it on nine screens.

What v2 draws and this does not is in docs/design-import-gaps.md, and it is the same list as the phone's
for the same reasons: the forwarding screen and both its chips, the host detail's fingerprint, tags and
last-session cards, the keychain's rotate button, the logs' FOLLOW pill and severity filters, and the
session footer's latency. The terminal is not inset behind a rounded frame either — it is a native child
window that composites above everything Avalonia paints, so the frame would clip nothing, which is the same
answer the phone gave.

**The light theme is not built.** Its accent is #6D5AE6, a different hue rather than a tint of the dark
one, so it needs every colour doubled, a variant to switch on, the renderer's own page switching with it,
and contrast checked twice. That is a piece of work rather than a setting, and it is separable from the
layout — which is why this commit is the layout.

The screens themselves are restyled through the shared vocabulary rather than rebuilt: corner radii,
chips, cards and the accent's ink, all in App.axaml, so every screen moves at once. Their layouts are left
alone deliberately. The design draws read-only detail panes and these screens carry the editors and forms
it has no equivalent of, so replacing a layout with the mock-up's would have lost the half that is
actually used.

Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the 68 layout cases
that stand up real Avalonia and measure every screen at the new minimum. Both heads build. Not run on a
machine with a display — see docs/manual-checks.md for what wants looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
2026-08-02 19:29:21 +02:00
jaap-jan fe9d7fc289 Give DodoSSH a phone, and a shared shell for both heads to drive
The Android head from docs/android-port.md, taken as far as its step 6.

Step 3, the spike, is answered and its throwaway screen is gone: libsodium.so and
libe_sqlite3.so are both in the arm64 APK, so NSec resolves its native half on Android
despite shipping no Android build, and the local cache opens. Two findings the audit
could not have had: Avalonia.Controls.WebView only ships net10.0-android36.0, which
settles the open "which Android versions" question at targetSdk 36; and Android has
blocked cleartext HTTP since API 28, so the terminal renderer needs a network security
config scoped to 127.0.0.1 or the WebView loads nothing.

DodoSSH.Client.Shell is new and is why the phone can exist: the view models, the terminal
renderer files and the palette moved there so both heads drive one state machine and draw
from one set of tokens. The desktop head is otherwise untouched and its 144 tests still
pass.

The platform pieces behind interfaces that already existed: the profile directory from
filesDir, a device key wrapped by a StrongBox-backed key that a fingerprint releases, and
a foreground service so a shell outliving a vault lock stays true on a platform that
stops backgrounded processes.

Sign-in is deliberately absent rather than approximated. It needs an app link, because
reusing the desktop loopback listener is the attack RFC 8252 section 8.3 names.
2026-07-31 20:58:48 +02:00
jaap-jan 9a76eced14 Give hosts and terminals their own screen, and the rest of the vault another
ci / build and test (ubuntu) (pull_request) Canceled after 0s
ci / build (windows) (pull_request) Canceled after 0s
Rebuilds the client's shell from an imported design: a titlebar and nav rail
it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host
search, and a vault screen that merges keys, passwords and pinned host keys
into one table. Hosts left the vault column for their own screen beside the
terminal, which is what the design asks for and turned out to be the better
split anyway.

Two screens the design shows have nothing behind them yet — file transfer
and teams — and say so plainly rather than rendering invented data; every
other gap between the design and this build is recorded in
docs/design-import-gaps.md.
2026-07-31 08:39:37 +02:00