009b35e06901a7218dac1ad3564c7a39eb2e2ca3
35
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
3d3d0bc95f |
Package the Windows client in CI, on the runner that could not
The build job published a win-x64 tree and stopped there, so the half of a release that fails in ways a compile cannot see was proved by nobody until a person was midway through cutting one. It now packs as well: vpk opens the published binaries and verifies the main executable really calls VelopackApp.Build().Run(), which is the check worth having — a refactor that drops that call compiles, tests green, and produces an application that silently never updates itself. The file said this was impossible on Linux, and also said it was fine, in comments forty lines apart. The claim that vpk needs Windows tooling to stamp the Setup.exe stub is the one that was wrong: vpk cross-compiles when told to, and the telling is a bracketed directive before the verb rather than a flag. Plain `vpk pack --runtime win-x64` on a Linux host refuses outright and says so in the message that names the fix. `[win]` must be quoted, or the shell reads it as a glob matching any one of w, i and n. Only signing needs Windows, and nothing here is signed yet. Fixing that does not move ADR 0013 rule 3 an inch, which is why the two reasons were recorded separately in the first place. What may not live on a runner is the token, not the build: Velopack clients apply what their feed serves without verifying a signature, so whoever can write a release can ship an update every install runs. The packages go to RUNNER_TEMP and die with the job. They are not offered as workflow artefacts either — an installer nobody has run should not sit somewhere that invites passing it on. Written out as shell rather than by calling scripts/release-windows.ps1. That script is a person's procedure and holds things a runner must not have and must not skip: it refuses a dirty tree, insists HEAD is tagged, downloads the previous release for deltas, and asks for the forge token. Calling it would mean either weakening it with CI switches or having CI satisfy conditions that only make sense at a desk. The constants the two now share — pack id, title, authors, channel, icon — are a contract with VelopackUpdateChannel and with every installed client, and both sides say so. Two things fell out of running the steps rather than reading them, and both were in code nothing had ever executed: dotnet msbuild -getProperty:Version answers 1.0.0. Without a target named it evaluates the project and runs nothing, and MinVer computes inside a target — so the read comes back as the SDK default on a full checkout with every tag present. That line is the tag check in this file, which is `if:` a tag ref, and there are no tags yet: the first release ever cut would have been refused by its own guard, which would then have blamed fetch-depth. release-windows.ps1 had the same line and would have demanded HEAD be tagged v1.0.0. Both now pass -t:MinVer. And MinVer answers 0.0.0-alpha.0.N until that first tag exists, which vpk rejects outright as below 0.0.1 — so packing the true version could not have worked on any build made today. The patch digit is lifted for the throwaway package only. The release script gets no such floor and must not: its version is the one users compare against, and there the refusal is the right outcome. Verified by extracting both steps from this file and running them against a real clone in a dotnet SDK container: Setup.exe, the portable zip, the .nupkg and releases.win.json, from a machine that is not Windows. |
||
|
|
23f1db9dc8 |
Stop the terminal's accessory keys taking the keyboard off it
Ctrl, Esc, Tab, the arrows and the two text-size keys were ordinary Avalonia buttons sitting over a NativeWebView. An ordinary button takes focus on tap, which takes it off the WebView — and the package's own OnLostFocus then calls the adapter's ResignFocus(). So pressing Tab handed the terminal one byte and took the keyboard away from it, and everything typed afterwards went nowhere. What makes it worth more than a one-line fix is the symptom. The row goes on working, because its keys are pressed rather than typed into, so what you see is a terminal that answers the buttons and ignores the keyboard — which reads as the session having died rather than as anything to do with focus. Focusable = false is what a toolbar button is: these keys are an extension of the keyboard, not a place it should go. The focused element then never changes, so nothing resigns and nothing has to be handed back — which matters, because the hand-back is the direction platform-flags already records as the hard one. The flags file gains the phone's half of that entry, and check 11.10 is the measurement: this needs a paired hardware keyboard and there is no test on this head that could stand in for one. |
||
|
|
e0655dbb31 |
Look the host list up by name, rather than off a field that is never assigned
Nightly 0.0.0-alpha.0.133 died before its first frame. The long press I added attached itself in HostsScreen's constructor through the field the Avalonia name generator declares for `x:Name` — and that field is assigned by the generated InitializeComponent, which no view in this repository calls. Every one of them loads its XAML directly. So the field compiles, resolves in the editor, and is null at run time; PhoneShell builds this control on the way up, so the NullReferenceException took the launch rather than the hosts screen. PhoneShell and TerminalScreen both use FindControl, and PhoneShell carries a <remarks> saying exactly this and naming exactly this consequence. I read neither and wrote the field. So the rule is in docs/platform-flags.md now as well. A comment on the control that already got it right is not where somebody writing a new one is looking, which is the whole of why two correct examples and one warning were not enough. And phase 8 opens with "it launches at all". Nothing on this head is covered by a test — no test project, no headless surface — so a view that throws while being built takes the launch with it and no gate anywhere says so. Thirty seconds, and it would have caught this one before it was published. |
||
|
|
a18ca56fde |
Copy the checkout into the container, because a socket is not a shared filesystem
The container started and could not see the repository: MSBUILD : error MSB1009: Project file does not exist. Switch: src/DodoSSH.Client.Android/DodoSSH.Client.Android.csproj `-v "$PWD:/build"` cannot work here. This runner is itself a container holding the host's Docker socket, so the workspace path it reports — /root/.cache/act/<hash>/… — exists in the runner and not on the daemon's host, which is where Docker resolves a bind source. It finds nothing, creates an empty directory, and mounts that. Nothing about the failure says so. Nothing earlier in this workflow would have caught it either, and that is the part worth keeping: the image job's `docker build` sends its context over the API and Testcontainers mounts nothing, so neither of the two places this repository already used Docker proves a bind mount would work. I read a working daemon as a shared filesystem, and they are not the same claim. `docker cp` goes over the same API and so does not care where the daemon lives. In with the whole checkout, .git included, since MinVer and the versionCode both read it — the repository is well under a megabyte packed. Out with the staged package. The NuGet cache becomes a named volume for the same reason: it lives on the daemon and needs no path either side has to agree on. The two container steps collapse into one, since with a copy in and a copy out there is nothing to be gained by paying for both twice, and scripts/ci-android.sh is now what runs inside — a file that can be read and executed on its own rather than a heredoc inside a workflow. Exercised locally against a real clone, every step as the job runs it: create, cp in, start --attach, cp out, parse the manifest on the outside. package: name='dev.dodotech.dodossh.nightly' versionCode='196' versionName='0.0.0-alpha.0.129' The second run took 2m25s against the first run's 8m, which is the named volume doing its job. |
||
|
|
30a3edb1d4 |
Build the phone in a container, because this runner cannot build it at all
The runner is Alpine, and .NET for Android does not work on musl. Not "needs setting up" — the SDK's own MSBuild tasks pull glibc shared objects out of the workload pack into the build process, and a musl-linked dotnet will not load one: error XARLP7000: Error relocating .../libZipSharpNative-3-3.so: __snprintf_chk: symbol not found That is a glibc fortify symbol musl does not implement, reached through a DllImport rather than an exec, so gcompat is no help: it gets a glibc *executable* started, which is a different problem. There is no musl variant of the pack. Everything this job did on the host to make Android work was therefore treatment of symptoms, mine included. The missing aapt2 was present. The "unsupported version" was of a binary that had never run. Both were this one sentence in a different accent, and the loader was the accent, not the sentence. So the toolchain moves into build/android-build.Dockerfile — Microsoft's own sdk:10.0-noble plus a JDK, the Android SDK and the workload — and the job keeps on the host only what the host is good at: checkout, git, publishing. The daemon needed no arranging, since the image job already builds with it and every Testcontainers suite reaches it over the socket. The image is tagged by the digest of the Dockerfile that made it, so on a persistent runner every run after the first is a cache hit, and a change to the toolchain is the only thing that buys a new one. Built rather than pulled: a community image with the Android SDK already in it would put a stranger in the path of a package this project signs and publishes. Eleven lines of apt and sdkmanager is the cheaper trade. Verified end to end in that image against a real clone rather than reasoned about, which after three rounds of reasoning seemed the least I could do. Restore under locked mode, Release build, then SignAndroidPackage: package: name='dev.dodotech.dodossh.nightly' versionCode='195' versionName='0.0.0-alpha.0.128' Signer #1 certificate SHA-256 digest: a9f067877724ddb0fdc04b637fbd5bfb97df753976616f100b48b522e132ba22 which is the keystore in build/. The versionName carries MinVer's height, so the csproj's target fires in the container too, and the manifest the feed publishes parses back on the host. Staging moves from RUNNER_TEMP to artifacts/, which is forced rather than preferred: the package is made inside a container and read outside one, so it has to land under the bind-mounted checkout. |
||
|
|
65256fa337 |
Take the phone's aapt2 from the SDK the job installs rather than the workload's
The android job got past restore and died in the .NET Android SDK's own tooling resolution:
warning : An error occurred trying to start process
'.../packs/Microsoft.Android.Sdk.Linux/36.1.69/tools/Linux/aapt2' … No such file or directory
error XA0111: Unsupported version of AAPT2 found at path '.../tools/Linux'
The error names the wrong problem. Nothing was found, so nothing had a version, and XA0111 points at
an Aapt2ToolPath in the project file that has never been set. The warning above it is the real message
and it is only a warning.
The pack was incomplete, and on this runner it would have stayed that way: act's host executor keeps
/usr/share/dotnet between runs, and `dotnet workload install` reads the installed-workload records and
does nothing when android is listed, whatever is on disk. So two changes, each of which stands alone.
The build and the packaging step are now given -p:Aapt2ToolPath pointing at build-tools, which this job
installs itself and can therefore vouch for. That is already the aapt2 the packaging step shells out to
for `dump badging`, so this makes one tool of what were two, and the manifest the feed publishes is now
read by the binary that wrote it. Checked rather than assumed: build-tools 36.0.0 answers aapt2 2.20 and
.NET for Android 36.1.43 builds and packages this head against it with no complaint. The version is
named once, in the step's env, because three things now depend on it agreeing with itself.
And the workload step probes for the pack file that went missing and repairs the workload when it is
absent. The probe is a witness rather than the point — the build no longer touches that binary — but a
5 MB file near the end of a 130 MB package is what a truncated extraction loses first, and r8.jar and
manifestmerger.jar are what it loses next.
|
||
|
|
7e0a1b2af8 |
Bring the phone's lock file back to the graph it actually restores
The android job's restore has never been reachable — the runner had no JDK and no SDK, so it failed before it got there — and DodoSSH.Client.Android is deliberately outside DodoSSH.slnx, so the solution restore that keeps the other fourteen lock files honest has never seen this one either. It went stale for a whole release and nothing could say so. Two things had drifted by the time the repaired job reached the step: MinVer, added to Directory.Build.props for the desktop updater, and the ABI set, which grew when the -r android-arm64 pin came off the packaging step so that the nightly is installable on more than an arm64 handset. --force-evaluate on this project alone, and the fourteen it references come back byte-identical: an android-* RID is not a graph any of them has a package for. Checked rather than assumed, because this is the same mechanism that once put win-x64 into the server's lock files and broke its image build. docs/platform-flags.md records the lasting half, which is not this fix: any change to a shared props file touches a lock file this repository cannot verify from a machine without the Android workload. |
||
|
|
b4a6c19ac1 |
Let the phone replace itself, and give CI a channel it may sign
The Android head had no updater and no release path, and the two are one problem: Android refuses an update signed by a different key, and CI generates a fresh debug key in every container. An APK released from a workflow could be installed once and never updated again — each new one an uninstall, which on this product means losing the cache, the outbox and the device key. So there are two channels, and they are two applications because the platform gives no third option. dev.dodotech.dodossh is cut from a v* tag by a person running scripts/release-android.ps1 with the key ADR 0011 rule 1 keeps off runners. dev.dodotech.dodossh.nightly is cut from main by CI and signed with a keystore committed here in the open — a key everybody has cannot be stolen and grants nothing by being held, which is why putting it in CI does not touch the rule. Neither can update the other, by construction. See ADR 0014. The android job assumed an image with a JDK and an Android SDK on it, which is what a GitHub runner is and what this project's is not. It now installs a JDK, fetches Google's command-line tools, accepts the licences and installs API 36 — each a no-op where it is already satisfied, and each cached by the persistent runner's own disk rather than by an action that would move a quarter of a gigabyte to rebuild a directory that never left. The client reads a small JSON manifest beside the APK, the counterpart of releases.win.json, and compares Android's versionCode rather than a version name: that integer is what the platform itself uses to accept or refuse an install, so comparing anything else would offer updates the phone then rejects. It fetches, and then asks Android to ask — the system draws its own confirmation, and from API 26 will not draw even that until unknown sources is on for this application. IUpdateChannel gained ApplyingEndsTheProcess. On Windows applying replaces the files and restarts, so the shell disposes the vault first and that is what zeroes the keys. On the phone the install is a request and the answer may be no, so disposing first would answer "not now" with a locked keychain and every shell closed — a punishment for declining an update. Two measured bugs found on the way, both older than this work and both invisible to a -getProperty check. ApplicationDisplayVersion is read by the Android targets in a top-level PropertyGroup, so the target setting it from MinVer ran after the only thing that reads it: every APK ever built here said versionName 1.0.0. And nothing found so far varies the launcher name per channel — four mechanisms tried, all of them recorded in platform-flags, none of them reaching the label the launcher shows. The two channels share an icon name for now and are told apart by package name, version, and what the preferences screen says. |
||
|
|
3ead865f01 |
Merge branch 'main' into the desktop updater, and give way on two numbers
Main landed a realtime push feature while this branch was building the updater, and the two collided in three places. Every one of them resolves the same way: main got there first, so this branch moves. **Two ADRs were both numbered 0012.** Main's is realtime push; this one is now [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call this a conflict — the filenames differ — so it would have merged quietly and left the directory with two 0012s and every cross-reference ambiguous. Renumbered here along with the nine places that point at it. **Two manual-check phases were both numbered 15**, and that one git did catch. Main's "Changes that arrive without a timer" keeps 15; installing and updating the desktop client becomes Phase 16, with its checks and every reference to them renumbered. The file's own rule is that a number is for life, which is exactly why the one that had not been pushed is the one that gives way. **The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and `EnforceCodeStyleInBuild` is on, so it failed the build on three lines of App.axaml.cs whose only change in this branch was an ADR number in a comment. Forty-six files normalised back to LF; the release script keeps CRLF, which is what `.gitattributes` and `.editorconfig` both already say for a PowerShell file. Nothing else conflicted. The updater does not touch the sync loop or the event stream, and the one file both sides edited heavily — MainWindowViewModel — merged without a hunk in common. Verified after merging: the solution restores locked and builds clean, and 304 shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The first two counts are higher than before the merge because main's own tests came with it and pass alongside these. |
||
|
|
6728a0a597 |
Let the desktop client replace itself, and give the repository one version
Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.
**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.
**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.
**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.
**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.
Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.
The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.
----
Three defects surfaced on the way, none of them in the feature being built.
**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.
**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.
**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.
All three are in docs/platform-flags.md, which is where the next person will look.
----
Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.
Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
|
||
|
|
52596aac76 |
Ask the Linux WebView for the one mode it can draw in this window
The terminal renders nothing on Linux, and does it in the most misleading way available: the page loads, the scripts run, the renderer connects, InvokeScript answers. Everything works except the pixels, so it reads as a broken terminal rather than as a host with nowhere to paint. Measured on Fedora 44 with Avalonia.Controls.WebView 12.0.1, by a spike that hosts a NativeWebView and reads AdapterInfo. The backend is WebKitGTK 2.52.5 — not the WPE one this repository's platform notes predicted, and Fedora packages no WPE WebKit at all, so that path was never going to be the answer here. In its default mode the adapter reports SupportedScenarios = NativeDialog: a window of its own, and nothing that can be hosted in place. Identical under X11 and Wayland, so it is the adapter's answer rather than a session problem. Setting ExperimentalOffscreen on the GTK environment arguments changes the same adapter's answer to OffscreenRenderer — the compositor-drawn mode, which is what the NativeWebViewCompositorHost mentioned in those same notes exists to host. MainWindow now sets it as the environment is settled. Windows and macOS are untouched by construction rather than by an OS check: the argument is a GTK type there and the handler does nothing. The platform notes carried this as "unproven, and still the largest risk in the plan". They carry the measurement now, including the part that is still unproven and the reason the spike could not settle it. What is NOT verified is that it now paints. An XWayland root capture is black under a Wayland compositor and RenderTargetBitmap does not capture a compositor surface, so both ways of looking at it from here failed. It needs eyes on a running client, and if the terminal is still blank the next question is whether it takes input at all — that separates "not drawing" from "not hosted". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ffab2be22a |
Drop the formatting step, which spent minutes agreeing with the build
`dotnet format --verify-no-changes` re-analysed the whole solution before the build did, to reach a verdict the build reaches on its own: IDE0055 is an error in .editorconfig, EnforceCodeStyleInBuild is on and warnings are errors, so a misformatted file fails the build step. What the separate step bought was hearing about it a few minutes earlier, and it charged those minutes on every run. Checked rather than assumed, because the whole justification rests on it: appending a badly-spaced member to a source file produces three `error IDE0055` lines and a failed build with no format step in sight. Three places said the old arrangement out loud and would now be wrong on their own — the comment on the IDE0055 line, the conventions list in the README, and a note in platform-flags telling people to run dotnet format before pushing or CI would fail them. They say the build enforces it now. dotnet format is still how to fix what the build complains about; it just no longer gates anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cf1a321d1e |
Pin the font the layout suite measures, and let the slice say it means plaintext
Two failures left on the runner, with nothing in common except that both only appear on a machine unlike the one anybody develops on. The runner is Alpine, musl, inside a container, with no fonts installed at all — and that combination is now reproducible locally, which is how these were fixed rather than guessed at. Both are verified by running the suite in it. The layout suite had two causes stacked, and the first hid the second completely. Missing libfontconfig stops libSkiaSharp loading, which the last commit fixed and which then revealed the real one: Avalonia takes its default font family from the platform, and on an image with no fonts there is no answer, so FontManager throws "Default font family name can't be null or empty" inside AppBuilder.SetupUnsafe — before a single test body runs, for all sixty-eight of them, naming none of their subjects. WithInterFont does not prevent it: it registers a collection without nominating a default. HeadlessApp's own comment already claimed it measured "the same Inter font the application registers", which was an intention the code never carried out. Both heads now name it, through FontManagerOptions.DefaultFamilyName. That is worth more than getting CI green: a suite whose entire job is measuring text was taking its metrics from whatever the machine happened to have — Segoe UI here, DejaVu there — and reporting the two as one number. It also means the application uses the font it has been shipping and declining to use since it first referenced the package; almost nothing moves visually, because App.axaml already sets MonoFont on essentially everything that draws. The end-to-end slice was the product being right and the test leaning on an accident. ServerConnection permits an http authority only when it is loopback. Testcontainers reports the host a container can actually be reached at, so running the suite directly gives localhost and passes, while running it inside a container gives the bridge gateway 172.17.0.1 and is refused — correctly, since a client that accepted plaintext metadata from a routable address would be a weakness for everyone who is not a test. Loosening that rule was the wrong repair. The slice now passes configureOidc and says out loud that it accepts plaintext from the Keycloak it started itself. Verified by reproducing the runner rather than approximating it: dotnet/sdk:10.0-alpine, musl-x64, fc-list returning zero, the docker socket mounted so Testcontainers resolves the gateway exactly as it does in CI. The whole solution passes there — 19 suites, 0 failures, 4 skipped — and the end-to-end failure was confirmed causal by reverting only that one file and watching it fail again in the same container. The layout suite also still passes on a Fedora desktop with 595 fonts, so the two agree now. Not verified on Windows, and it should be said plainly rather than left to be discovered: pinning the family changed the measured metrics there too, so a tight layout assertion could have moved. platform-flags.md records that, and corrects the entry the last commit added — "libfontconfig, and nothing else" was true of the container it was tested in and false of the runner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
43d76d0f2d |
Let the suite run on Linux, and fix the three things that stopped it
The pipeline finally reached the tests and found four failures. None was the pipeline's, and only one of the four was a test being fussy about a platform rather than telling the truth about one. The local pane's roots bar was the real bug. LocalDirectory.Roots built it from DriveInfo.GetDrives on every platform, and its own summary — "the drives on Windows, and the root elsewhere" — had been describing an intention rather than the code for as long as nobody ran it off Windows. On Unix that call answers with every mount the kernel holds: /proc, /sys/fs/bpf, one per installed snap, /run/user/1000/doc, some forty on an ordinary laptop. The transfers screen draws a button per root, so the bar ran to about five thousand pixels inside an eight-hundred pixel window. Anybody running the Linux build has been looking at that. Filtering GetDrives is not the fix and the comment now says why at length, because it is the obvious thing to try: DriveType answers Fixed for / and /home and equally for every squashfs snap, for efivarfs and for tracefs, while /boot/efi comes back Removable, and DriveFormat would need a hand-kept list of every virtual filesystem Linux might grow. So Unix now names what somebody would want instead of subtracting what they would not — the root, their home, and whatever is mounted under /run/media/<user>, /media, /mnt or /Volumes. Anything else is still reachable by navigating from /, which is what the pane is for. Windows is untouched. ClientPathsTests looked for "odoSSH" in the profile directory. ClientPaths spells it DodoSSH on Windows and dodossh on Unix deliberately, one per platform convention, and that substring was clever enough to survive either spelling of the leading D while still only ever matching one of them. Now OrdinalIgnoreCase. WhyTheWindowItselfIsNeverShown asserted a COMException with HResult RPC_E_CHANGED_MODE, which is WebView2 refusing an MTA thread — a Win32 component raising a COM error. On Linux the adapter is a different implementation with no apartment to disagree about, so showing the window works and Should.Throw catches nothing. Skipped there rather than loosened to accept both outcomes: the assertion is the documentation in that test, and one that passed everywhere would have stopped recording the constraint it exists to record. The fourth was CI's alone, and the diagnosis is the useful part. All 69 layout tests failed on the runner while 6 failed here, which looked like missing fonts and was not: Avalonia's headless renderer is Skia, libSkiaSharp.so links against libfontconfig, and without it the suite dies in HeadlessUnitTestSession with a TypeInitializationException on SKImageInfo naming none of its actual subjects. The job installs the one library now. Verified in a container where fc-list returns zero and the suite passes regardless, because the application carries Inter itself — fonts were never the problem, only the thing that would have looked for them. The whole solution now passes on Linux: 19 suites, 1295 tests, 0 failures, 4 skipped, the end-to-end Testcontainers suite included. README and platform-flags.md said testing was Windows-only, which CI now contradicts on every push, so both say what is true instead and the two findings are written down where the next person will look for them. macOS is still untested and now says so on its own rather than hiding inside "not Windows". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d07b336868 |
Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain hid every open terminal and the strip that named them. A connection you had opened was invisible from four of the five screens. The window now has two surfaces rather than one: a nav rail that says which page you are on, and a terminal strip that is always there and switches the whole content area to a shell. Screen keeps meaning "which page" and never becomes a sixth kind of page, which is why this is two properties instead of one enum with a terminal member in it. Every screen lives inside one wrapper panel that collapses when a terminal is showing. That is not tidiness — the WebView hosts a Win32 child window that composites above everything Avalonia draws, so a screen left visible over its rectangle is a screen sliced in half, and this window has shipped that defect once already. One decision point, IsTerminalShowing, and a nested panel rather than five compound bindings nobody would remember to extend. The focus choreography is the part no test in this repo can see. Every reveal path now focuses in the same turn the WebView appeared, so all three of them post at DispatcherPriority.Loaded and let the native control re-push its bounds first. Going the other way had a real bug: the screen-changed branch called a bare Focus() where it had to release the keyboard from the native child, so switching from a terminal to Files silently ate the first keystrokes. Rare before this commit and the primary gesture after it. The tab strip grew a cross inside each tab, a plus that opens the quick-connect palette, and middle-click close. Nested buttons are correct here: Avalonia handles a left press on the cross and deliberately does not handle other buttons, which is exactly what lets middle-click bubble up from the cross as well as the tab. The test is PointerUpdateKind rather than IsMiddleButtonPressed, because the latter reports button state and is also true for a left press made while the middle button happens to be held. The handler is on the tab and not the strip, so the background closes nothing by construction. Plus opens the palette rather than a flyout, since a menu dropping into the WebView's rectangle may or may not composite above a child HWND and this repo does not make rendering claims it has not photographed. Everything a user reads now says keychain. The wire, the database and the cryptographic spec still say vault, deliberately: renaming those is a migration and a protocol change for a word. That split is written down rather than left to be rediscovered as an inconsistency. Four things that were squeezed into the keychain's category rail, or into nothing at all, now have screens. Pinned host keys get one, with fingerprints never truncated and a filter that matches them, because comparing what you have against what the operator published is the whole workflow; the approved date is read out of the item's UUIDv7 rather than added as a column, and says so, since it means first approval and not last use. Keys can be generated in the client, which needed the openssh-key-v1 container written by hand — there is no BCL or NSec helper, and the PKCS#8 route is unverified in the SSH library this uses. The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is Blowfish with a swizzle, in a project whose crypto is otherwise entirely libsodium, for a protection the key's own remarks argue is redundant inside a vault. Generation fills the existing editor and stops, so SAVE stays the one thing that writes. ~/.ssh/config can be imported behind a preview that is ticked per row and writes nothing until the button; IdentityFile records the path and imports the key material only on an explicit opt-in, because reading somebody's private key into a vault is precisely the act this product exists to make deliberate. Match blocks and ProxyJump are reported rather than obeyed — one cannot be evaluated statically and the other has nothing behind it to route with, and a preview that implied otherwise would be worse than one that admits it. Files can be dragged in all four directions that are honestly available. Remote to Explorer does not ship and is not pretended to: the shell wants the bytes during the drop, which needs a virtual file and a native COM data object, outside what Avalonia offers. Note for the next person that Avalonia 12 replaced the drag model outright — DataObject and DataFormats are no-op stubs and IDataObject is not in the reference assembly, so every tutorial written for 11 does not compile here. Hosts can be grouped, flat and never nested. A parent id merged as a scalar lets two offline clients each re-parent A under B and B under A, producing a cycle inside an encrypted payload that no server can police and every reader would have to detect for ever. Membership lives in that payload rather than in the one plaintext concession ADR 0001 allows, whose test is that the relay cannot function without it — nothing on the server reads a group, so what plaintext would hand over is a clustering of the estate for nothing. The plaintext column reserved for it is dropped, provably always null, and the server now refuses a client that sends one; it was never populated, was copied on apply, and was not cleared on delete, so a group id would have outlived the host it described. Snippets insert through xterm rather than through the pump, because xterm is the only thing that knows whether the remote has bracketed paste on, and that is what makes a shell treat embedded newlines as text instead of as execute. The host process moves opaque bytes and never parses output, so it would have to guess, and guessing wrong runs every line. Running is off by default and the copy says the text goes into whatever is there — the terminal has no notion of being at a prompt, and may be in vi or at a password prompt with echo off, so the Enter the user presses themselves is the entire safety property. Connections and keychain changes are recorded as synced encrypted items, which is what makes them auditable by a team later and costs the server knowledge of connection rate and timing from row counts alone. ADR 0001 already concedes it cannot hide that class of metadata; the trade is now written into it rather than left implicit. A connection entry is written once, at close, which is what makes a synced log tractable: nothing to merge, one outbox row, no chance of colliding with itself. Live sessions come from memory, not from the log. The write is void by contract and posts to a bounded channel, because putting an encrypt-and-write on the teardown path of every session is how closing the application comes to take four seconds. A ticket opened before a lock still closes afterwards, since a shell outlives the vault. The activity log hooks the one generic repository every kind writes through, so it cannot miss a caller — which is also why the log kinds themselves declare they are not audited, or the first entry would write an entry about writing an entry. It records the names of the fields that changed and never their values; a log with an old password in it would be a plaintext credential store with no vault around it. Retention is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop rather than on a second timer. That log traffic then broke the status line, which is worth recording because the fix is a shape and not a patch: background sync counted its own log rows as pushed items, so the quiet rule stopped being quiet and every action's message was overwritten a second later by a sync report. The report now separates log rows from user items and the rule reads the latter. S3 buckets appear as a remote in the file browser, behind the same interface an SFTP session implements, so the queue and both panes did not have to learn what they are talking to. Uploads go through a pipe, because the queue wants to write and the SDK wants to read; memory is then bounded by the part size instead of buffering a file to disk twice. Finally, the Windows device key store moved out of the session project, which was the one thing keeping it from being portable — everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the vault code meant a second head could not reference it without dragging Windows along. The seam that made the move free was already there. docs/android-port.md is the audit behind that: what ports, what does not, in order of cost, the four decisions taken, and an inventory of every screen and state the interface has to carry, written so a design can be made from it directly. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1240 tests at zero warnings, including the end-to-end suite against real containers. The manual checks that headless Avalonia cannot make — the drag from Explorer, a generated key against a real host, twelve tabs at the minimum window width — are listed in docs/manual-checks.md and are still outstanding. |
||
|
|
e3fd3e1728 |
Sync and authenticate with SSH keys on the client
Completes the client half of SSH keys: they sync alongside hosts, appear in their own list, and can be selected to authenticate a connection instead of typing a password. The reconciler and the repository were Host-typed throughout, so the choice was to generalise them or to keep a second copy per item type. Generalised, because ItemReconciler's whole premise is that the pull and the push paths must answer the same collision the same way — two copies would drift the first time one of them was fixed. What is genuinely per-type now arrives through IItemKind<TSecret>: the cipher, the merge, the plaintext columns, and the noun to use when telling a person what happened to their item. Generic where the server's IItemKind is not, and for the reason that reverses there — the client needs the concrete type, because it merges field by field. The pull filter is derived from the same registry that builds the reconcilers. That is the specific failure being designed out: an item type that encrypts, merges and lists perfectly and is never once requested from the server, so it works on the machine that made it and exists nowhere else. No client cache migration. The item table's primary key and the outbox's unique index already carry the entity type, and AadResourceTypes already mapped SshKey — so a host and a key may share an id and never see each other's rows, which SshKeySyncTests now arranges deliberately. A key hands the server nothing in plaintext. There is a public_key_fingerprint column and it would be accepted; leaving it null is deliberate. A fingerprint is not secret but it is a stable identifier for a key pair, so filling it would let an operator tell which of their users hold the same key and correlate one across vaults, for a column nothing reads. The design allows itself one plaintext concession — the relay address, which the relay cannot work without — and this is not that. A key is chosen per connection rather than bound to a host, which works the way ssh -i does. Binding one needs a field on HostSecret and therefore a payload schema bump, which makes every host written afterwards read-only on an older build; worth doing deliberately rather than as a side effect of adding keys. Three things this found, all of them by being falsified rather than by review: - Making the reconciler generic silently turned a record comparison into reference equality, because == on a type parameter is not value equality. The effect would have been a conflict recorded on every pass for an unacknowledged create that had in fact landed. Sabotaging the fix left all 73 tests passing — nothing covered that branch — so ConflictMatrixTests now has AnUnacknowledgedCreateThatDidLand_IsDroppedQuietly, which fails without it. - A test asserting that a blank passphrase reaches SSH.NET as null was vacuous: it exercised the editor, not the credential path, and passed with the guard deleted. Resolved by making SshKeySecret.Passphrase normalise an empty string to null, so there is one spelling of one state — which also keeps two clients from producing different payload bytes for an identical key. That exposed a wider gap: SshKeySecret, its codec and its merge had no direct unit tests at all. They have 25 now. - The reason first given for that normalisation was false. It claimed SSH.NET rejects a passphrase supplied for an unprotected key; measured against a real sshd it ignores it and authenticates anyway. Corrected everywhere it was stated and recorded in docs/platform-flags.md. The same test file also closes a real hole: SshPrivateKeyCredential had never been exercised against a server, because the existing key test builds SSH.NET's auth method directly and bypasses the path a vault-held key actually takes. Only one editor may be open at a time. Both sit in the same 340-pixel column as Auto rows and their heights together exceed it at the window's minimum size, so two open editors put the lower one's Save and Cancel past the bottom edge — the same failure this window already shipped once with the setup screens. Expressed as a state rule because that is the only form of it this repository can check: nothing here loads a .axaml. The refusal keeps what was typed, since in the key editor that is a pasted private key the user may have nowhere else. The end-to-end slice now carries a key as well as a host, so both item types go through the real API, the real PostgreSQL and the real crypto in one pass — the three hand-kept mappings between enums that do not line up are the reason that is worth doing rather than trusting the unit suites. 735 tests green, including the container-backed SSH and end-to-end suites. Zero warnings, dotnet format clean. |
||
|
|
586cb303d5 |
Merge branch 'claude/gallant-brahmagupta-1f8244'
Writes down that locking the vault leaves shells running, and shows the count on the unlock screen rather than leaving it to be inferred. Conflict resolution: - ShellFlowTests' fixture keeps main's FakeSshConnectionFactory. The branch added an IdleSshConnectionFactory for exactly what main's fake already does — a shell that is open, silent and never closes on its own — so FakeSshConnections.cs is dropped rather than merged, leaving one fake SSH stack in the suite instead of two that would drift apart. - MainWindowViewModel and TerminalWorkspace: both sides added their own members, so both are kept. - TerminalWorkspaceTests was added by both branches, with the renderer gate on one side and session lifetime on the other. Merged into one class over one set of helpers; the gate tests now use FakeConnectionFactory rather than an NSubstitute stub, since the suite already has the fake. gallant's polling Timeout constant is PollTimeout, which no longer reads as the renderer's. - platform-flags.md keeps main's measured focus section and drops the short "nothing hands the terminal keyboard focus" entry the branch still carried, which that section supersedes. One genuine disagreement between the branches, left visible rather than flattened: this branch measured that a collapsed WebView cannot be typed into and attributed it to a hidden WS_CHILD window being ineligible for keyboard focus, while main's focus work measured Win32 focus still held by that hidden window and added a lock path that moves the keyboard off it. Both results stand; the mechanism sentence now defers to the focus entry, which makes the input barrier something the lock path maintains rather than something the platform guarantees. Full suite green, including the container-backed SSH tests. |
||
|
|
74341d41e0 |
Merge branch 'claude/distracted-ritchie-53fc70'
Bounds the renderer wait, so a WebView2 that never initialises reports itself instead of hanging Connect with the busy flag stuck. Conflict resolution, all of it in the App test suite, which main had changed under the branch when sleepy-chebyshev landed: - The workspace fixture keeps main's fake SSH factory and its FakeRenderer-aware page, and takes the branch's RendererTimeout on top. One second rather than the branch's 250 ms, because the timeout now also bounds FakeRenderer's own wait for the attach it just made. - FakeRenderer arrived on main after the branch was cut and still called the no-argument WaitForRendererAsync. Both sides merged cleanly and left the build broken; it now passes its own token. - ConnectingWithNoRenderer's remark claimed the suite never starts the workspace and never attaches a renderer. Both are false here, so it now says what is true of the test: it is the one connect test that attaches no renderer. |
||
|
|
9270d0cba5 | Merge branch 'claude/sleepy-chebyshev-cda68d' | ||
|
|
d459dac600 |
Stop a dead WebView2 hanging Connect with the busy flag stuck
VaultViewModel.ConnectAsync awaited TerminalWorkspace.WaitForRendererAsync
with no timeout and no token, and RunAsync clears IsBusy only after the
work returns. Whether the renderer attaches at all depends on a runtime
this application does not install: with a missing or policy-blocked
Evergreen runtime, or an AppContainer that cannot reach loopback, the
socket never arrives — so Connect never returned, the window stayed
disabled on "Connecting…" for the rest of the session, and nothing on
screen said why. Left out of
|
||
|
|
5a899afd78 |
Decide what Lock does to a running shell, and say it
Pressing Lock nulled and disposed the vault view model and touched nothing else. TerminalWorkspace is injected from App.axaml.cs and outlives every lock, so the SSH connection, the pty and the pump all kept running while the window said "Unlock your vault" — and since |
||
|
|
dbddbcd711 |
Hand the terminal the keyboard on connect, and take it back on lock
After a successful connect the first keystrokes went to the shell's UI rather
than the remote shell. The page's own term.focus() focuses the textarea inside
the document, which does nothing while the window's keyboard focus is still on
the Connect button, so the terminal had to be clicked before it would accept
anything.
The obvious guess about the fix — that reaching a native child window needs
SetFocus through P/Invoke — is backwards, and measuring it first is what kept
this small. NativeWebView overrides Focusable to true and its OnGotFocus calls
the adapter's Focus(), which on Windows is
ICoreWebView2Controller::MoveFocus(PROGRAMMATIC). So a plain Avalonia
Terminal.Focus() really does move Win32 focus into WebView2. Measured in a
standalone harness with no DodoSSH code, on the same 340,* grid as the shell,
reporting GetFocus() and the page's own document.hasFocus() at each step: focus
lands on the Chrome_WidgetWin_1 child and the page reports hasFocus: true.
It is the return trip the package does not implement. OnLostFocus calls the
adapter's ResignFocus(), and on Windows that method body is empty, so Avalonia's
focus and Win32's diverge: after textBox.Focus() the focused element is the text
box while the keyboard is still on WebView2 — a caret that silently receives
nothing. Window.Activate() and Window.Focus() were both measured and neither
recovers it, so the hand-back is a SetFocus on the top-level, in
Views/NativeKeyboardFocus.cs. A real mouse click does recover it, because
Avalonia's window sets focus on pointer input, which is why this is invisible to
anyone who clicks before typing.
That turned up a worse defect than the one being fixed, and it shipped in
|
||
|
|
ea271d980a |
Give the realm's users their roles, and sign in as one in the E2E suite
Signing in failed at the token exchange with `400 Offline tokens not allowed
for the user or client`. A user declared in a realm import gets no role
mappings at all unless realmRoles lists them — not even the realm's own
default-roles composite, which Keycloak grants automatically to a user created
through the admin API or the registration form. alice and bob had none, and
offline_access lives inside that composite, which the desktop client requests.
Verified against the running Keycloak: alice's role-mappings were {} before and
resolve to default-roles-dodossh, offline_access, uma_authorization after.
The authorization request succeeds and the failure lands one step later, at the
code redemption, which makes it read like a client bug. It is not.
The E2E suite could not catch this because it created its own account through
the admin API — exercising a provisioning path no real user takes, and passing
while the account the README tells you to use could not sign in at all. It now
signs in as the realm's own alice, which is sound because the Keycloak and
PostgreSQL containers are per-run so the account is pristine, and this assembly
holds one test. Removing the roles again fails it with exactly the reported
message; that is what makes the coverage real rather than nominal.
Two traps recorded in docs/platform-flags.md, the second found by shipping it
for a moment: Keycloak's RealmRepresentation deserialises with
FAIL_ON_UNKNOWN_PROPERTIES enabled, so the "_comment" key I first used to
explain the roles inside the JSON did not get ignored — the import threw and
the container refused to start. Explanations go in the docs, not in the realm
file.
|
||
|
|
f80b3d4351 |
Harden the WebView collapse, and replace its evidence with a measurement
An adversarial review of
|
||
|
|
7226e70b8a |
Record that a sub-path server URL is silently dropped
Found while sweeping for the stale default. The client uses the typed address only as HttpClient.BaseAddress and every request path is root-absolute, so https://example.test/dodossh reaches https://example.test/api/v1/... with the prefix discarded and no error — which rules out hosting under a sub-path, the usual arrangement behind a proxy fronting several services. The server already publishes a canonical apiBaseUrl the client could normalise against and ignores. Recorded rather than fixed: it is a deployment-shape decision, not a bug in the screen that prompted this. |
||
|
|
0500e43e02 |
Stop the terminal's WebView painting over the setup screens
The shell layered its setup and unlock screens over the terminal, which does not work: NativeWebView attaches a real Win32 child HWND through NativeControlHost, and a child window composites above everything its parent paints regardless of visual-tree z-order. The cards rendered sliced at the terminal column's left edge; at the window's default width every one of their buttons fell inside the WebView's rectangle, so the flow could only be completed by keyboard, and a click in that region handed Win32 focus to WebView2 so the text boxes silently stopped accepting keystrokes. The WebView is now collapsed while the vault is not unlocked. The comment that previously forbade this — hiding it means never realising it — was wrong: NativeControlHost creates the native attachment on attach to the visual tree, never consulting layout or visibility, and NativeWebView replays a Source assigned before its adapter exists. A collapsed WebView still starts WebView2, loads the page and lets the renderer attach. Confirmed: 35 msedgewebview2 processes with the control collapsed. What the first connection after unlocking actually depends on is the existing await on WaitForRendererAsync, since the data plane drops frames when no renderer is attached. Also fixes the second visible defect: the default server URL was https://localhost:7217, the API's *second* launch profile, while the README, its appsettings and a plain `dotnet run` all use http://localhost:5233 — so nothing was listening, and an HTTPS client against a plaintext port reports "The SSL connection could not be established", which reads as a certificate problem. The default now matches, a missing scheme is rejected by name instead of parsing as scheme "localhost", and that specific TLS failure now suggests http://. Both new tests fail when the fixes are reverted. Corrections to claims I made earlier and should not have: - docs/platform-flags.md asserted the opposite of the mechanism above and cited an established msedgewebview2 connection as verification. That observation was taken while the overlay was showing but, because of this very bug, the WebView was uncovered and in plain view — so it confirmed only that a visible WebView is realised. A process-level check cannot verify a rendering claim. The entry was also filed under "Local cache". - ITerminalHost was documented as the live seam the app plugs into, with a stub standing in for headless tests. It has no implementation anywhere and no test uses it; the view navigates the control directly. It also counted Avalonia.Controls.WebView and NativeWebView as two interchangeable backends when they are one component, with the Linux backend backwards. - The README claimed the shell's whole path was covered by tests. Its state machine is; its layout is covered by nothing, and a headless test could not have caught this — headless has no native window, so it would have rendered correctly and confirmed the wrong belief. Verified by screenshotting the running app: the card renders complete and centred at the default size, with the button clickable. |
||
|
|
34304b989b |
Make the end-to-end suite self-contained with Testcontainers
It needed a hand-started stack and an opt-in flag, so it ran on one machine and never in CI. It now brings up PostgreSQL, Keycloak and an OpenSSH server itself, applies the committed migrations and starts the API as a child process, which makes it part of the ordinary test run at ~25s. The API runs as a process rather than through WebApplicationFactory. The client builds its own HttpClient for a URL the user typed, so there is no seam to hand a test handler through without inventing one that exists only for tests — and a test host would replace the entry point, Kestrel and the content root, so it would never prove that Program.cs composes or that the committed appsettings is found and layered in the documented order. Running out of the API's own output directory is what makes its configuration real. The suite still consumes what ships: the realm file from deploy/keycloak, the EF migrations, the API's own appsettings. Only Oidc:Authority is overridden, because the container's port is assigned at start. Falsified by reintroducing the wildcard-port redirect URI the realm once had — Keycloak rejects the authorization request and the suite fails at sign-in, which is what proves the committed file is the one imported. Skipping the migration step likewise fails, and the failure names the pending migration. A fresh Keycloak per run also sidesteps the --import-realm trap: editing the realm file and rerunning now always tests the edit. DodoDbContextFactory gains a Create(connectionString) so the fixture and dotnet ef place the migrations history table in exactly one place. If they disagreed the API would report every migration pending, which is how the readiness gate catches it. |
||
|
|
1d262b7ccc |
Run M1's end-to-end slice, and fix the two bugs it found
The whole vertical slice now runs against a real Keycloak, a real API, a real PostgreSQL and a real sshd: sign in through the browser flow, enroll with the identity-provider key binding, unlock, create a host, sync it, read it back on a second machine, unlock again with no network, accept an unseen host key, and open an interactive shell. Opt-in, because it needs the development stack; skipped with a message naming the commands. It found two bugs on its first run, and both are the same class: two sides of a stub agreeing with each other about something the specification never said. **The API never applied DodoSshJsonContext to its HTTP JSON options.** Minimal APIs therefore used the framework's web defaults, which write an enum as a number. Every request DTO carrying one failed to bind against a client writing the specified string form — which is the entire sync surface, unreachable from the real client, with a 400 naming only the parameter. The documented guarantee that request bodies reject unmapped members was likewise not in effect anywhere. Nothing caught it because the API tests posted with PostAsJsonAsync's defaults, so they and the server had independently settled on integers. Those tests now serialise through the contract, which is the deeper fix: removing the new configuration fails 13 of them. Copying settings into options a host owns is itself the hazard the context warns about, so ApplyTo lives beside the settings it mirrors and ApplyToTests pins the transformation, including that inserting the resolver leaves the caller's own in place. **The realm registered a loopback redirect URI Keycloak rejects.** `http://127.0.0.1:*/callback` looks more explicit than the RFC 8252 form and is broken: Keycloak's wildcards are trailing-only, so the `*` parses as a literal port and every authorization request came back "Invalid parameter: redirect_uri". Providers ignore the port for loopback hosts, which is the whole mechanism, so the correct registration is `http://127.0.0.1/callback` — path pinned, port free. The value the server advertises through the discovery document said the same wrong thing and now says the right one. Two smaller things, both documented in docs/platform-flags.md: - --import-realm skips a realm that already exists, so editing the realm file and restarting Keycloak changes nothing and serves stale configuration. The container has to be recreated. The compose comment claimed the opposite. - Keycloak marks its session cookies Secure even over plain HTTP, because SameSite=None requires it. A spec-conformant client drops them and the login POST answers 400 with no message; browsers complete the flow only because they exempt loopback. Harmless for the product, fatal for automation, so ScriptedBrowser carries the cookies by hand and says why. Also: the server enforces a 64 MiB floor on the passphrase KDF, so this suite cannot use the 8 MiB profile the other client suites take for speed. Those only get away with it because their in-memory servers have no policy — worth knowing rather than rediscovering. 638 tests. The solution-wide run stays green with the stack down: exit code 8 means "no tests ran", which the platform reports as failure, so the opt-in project ignores exactly that code. |
||
|
|
49f617b450 |
Wire the Avalonia shell to the vault
The host list now comes from the vault instead of from a form. A fresh machine takes a server URL, signs in through the browser, enrolls, and from then on opens with the passphrase alone. DodoSSH.Client.Session is the composition layer: where a profile lives, how it unlocks, and how a machine gets one. ClientPaths picks a non-roaming per-OS directory — %LOCALAPPDATA% and never %APPDATA%, because a SQLite cache that roams between two machines is a corrupt one, and each machine's outbox is its own. SessionOpener needs no transport at all and could not reach one if it wanted to; that is the offline unlock, asserted rather than asserted about. A wrong passphrase, a stale KDF and a grant revoked by a rekey are three different answers, because the remedies are three different things and telling someone to retype a passphrase that was never the problem is worse than saying nothing. The shell's states are the onboarding story. The recovery code gets its own state that cannot be clicked past: it exists for one moment, losing it with the passphrase loses the vault, and there is no server-side reset by design. It is dropped from memory on confirmation rather than merely hidden. Sign-in is a delegate over IVaultServer, so the whole state machine runs in a test against an in-memory server — no browser, no identity provider, no toolkit. The view models are plain observable objects, which is what makes that possible. What it does not cover is whether the XAML binds to the right names; that needs a rendered tree and Avalonia.Headless, and is its own piece of work. Three things found by doing it rather than by reading it: - Pooled SQLite connections keep the database file open after the last context is disposed. On Windows that means locked, so the application could never replace its own cache — and a test could not clean up after itself, which is how it surfaced. Dispose now clears the pool. - EF's SQLite provider puts the database in WAL mode, so the cache is three files. A comment in ClientCacheFactory claimed the opposite; reading PRAGMA journal_mode off a real launch settled it. WAL is the right mode here — a sync pass writes while the interface reads — so the comment was wrong on the merits as well as on the fact. - Enrolling a device key with nowhere to keep the private half would put a wrap on the server nobody can open and make the device list claim this machine can unlock without a passphrase. Device binding is now optional and the shell declines it until the OS keystore is wired. Verified on Windows: the client created %LOCALAPPDATA%\DodoSSH\cache.db and migrated it on first launch, and msedgewebview2 held an established connection to the data plane while the unlock overlay covered it — which is the point of covering the WebView rather than collapsing it, since a NativeWebView that is never laid out is never realised. 630 tests, up from 593. The recovery-code gate and the offline unlock were each verified by breaking them and watching the right test fail. Still to do for M1's actual definition of done: the manual run against the real API and a real Keycloak. Credentials are not a synced entity type yet, so a connection still asks for a password, and the interface says so rather than implying otherwise. |
||
|
|
8d2416a602 |
Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed. DodoSSH.Client.Domain holds the decrypted item model and the three-way merge, with no I/O at all — so the suite that decides whether a credential can be lost runs in milliseconds with nothing to mock. Scalars defer to the server on a genuine clash so every replica resolves the same triple identically and two clients cannot ping-pong; directives merge per name so two people each adding one both keep theirs; the jump chain merges as a whole value because its order is the route. Whatever loses is returned rather than dropped. DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are already ciphertext, so an encrypted file would protect protected bytes at the cost of a native dependency. It keeps the server's state and the outbox in separate tables, which is what preserves the common ancestor a merge needs. One pending operation per item, enforced by a unique index. DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts — a change with no local work pending is plumbed as ciphertext — so a first sync of thousands of items does not run twice as many AEAD operations for nothing. Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The specification has required a per-item data key since crypto.md §3, the columns have existed since the first migration and DshAad.ItemPayload binds the id, but this record had nowhere to put either — so a spec-compliant item could not be transmitted at all. Found by writing the client that has to produce one. Also closes a hole in AadResourceType, which had no value for the HostTag and HostCredential that SyncEntityType has always listed. Four bugs the tests found, not review: - SQLite refuses to order or compare its own DateTimeOffset mapping, and throws at execution rather than model build. Collecting tombstones and listing conflicts are both that shape, so this was a crash waiting for the first user with a deleted host. Timestamps are integers now, by convention so a later field cannot be the one left unconverted. - SQLitePCLRaw 2.1.11, which EF resolves, is covered by GHSA-2m69-gcr7-jv3q. Pinned forward as a family. - Resurrecting content from a remote deletion cleared the original before queueing the copy. Two transactions, so a crash between them lost the work; reversed, and the rescued id is derived from the tombstone so a replay coalesces instead of duplicating. - Several equality assertions went through Shouldly's ShouldBe, which compares IEnumerable element-wise and so tested nothing about the Equals these types exist to provide. Corrected; the falsification that caught it went from 2 failures to 6. The push response's cursor is deliberately ignored. It sits after this client's own writes, so adopting it skips anything another client committed at a lower sequence in the window between a pull and a push — permanently. Re-reading one's own writes is idempotent and costs a page. The Contracts doc that invited the shortcut now says so. 593 tests, up from 448. The delete-versus-edit rules, the ancestor retention, the fresh operation id on coalesce and the cursor safeguard were each verified by breaking them and watching the right test fail. |
||
|
|
5fccd53824 |
Add the Avalonia app and the xterm renderer, and fix two real bugs
The terminal works end to end. A new integration test drives a real sshd in a container through a real PTY, the real pump, the real loopback WebSocket with its token and origin checks, and a ClientWebSocket standing in for the page: the login banner arrives, typed input round-trips, and `stty size` reports the 100x30 the session asked for. The only untested link left is xterm drawing bytes it was handed. The WebView is de-risked on Windows, which was the plan's largest risk. Not by assertion: with the app running there is an established TCP connection from msedgewebview2 to the data plane port, so WebView2 launched, navigated to the loopback page, executed terminal.js, and completed the WebSocket handshake against the real token and origin checks. Linux remains unproven and the package's own release notes now corroborate the concern -- Linux uses a WPE backend, and it ships a NativeWebDialog described as useful where embedded WebViews may be unavailable. Two bugs found by building it, both of which would have shipped: - ShellStream.Write buffers and needs an explicit Flush. Without one a keystroke is accepted, reported as written, and never reaches the remote: the terminal displays output perfectly and simply stops responding to input. SSH.NET's own WriteLine flushes, which is why the earlier spike never hit it. Found by isolating the pump against real SSH and reading BytesRead=51 -- banner and prompt through, nothing after. - The Windows app manifest needs a supportedOS list, or Avalonia's native control host fails outright and the terminal never starts. Also fixed a genuinely flaky test I happened to catch: SyncCursorTests tampered with the *last* base64url character, whose low bits the decoder ignores when the input length is not a multiple of three -- so a tampered cursor sometimes decoded to identical bytes and verified. It failed roughly one run in thirty, depending on a random key. Now tampers the penultimate character, which is fully significant at every length; 40 consecutive runs are clean. xterm 6.0.0 plus the fit and webgl addons are vendored as UMD bundles rather than built with npm, so a clean clone needs only the .NET SDK. Provenance and licences are recorded next to them, along with the UMD global names terminal.js depends on -- a bundle that switched to ES modules would load without error and leave Terminal undefined. The renderer acknowledges output from term.write's completion callback, not on receipt. Acknowledging early would return flow-control credit for bytes the screen has not caught up with, which is the one thing the credit window exists to measure. TerminalWorkspace moved into DodoSSH.Client.Terminal: it has no Avalonia dependency, and having it there is what let the end-to-end test exist at all. 404 tests pass, zero warnings on a clean rebuild, format clean. |
||
|
|
94f66be5e8 |
Add the OIDC client: PKCE loopback sign-in and the key binding flow
Authorization Code with PKCE on a loopback redirect, per RFC 6749, RFC 7636 and RFC 8252. Zero package references: the flow is fully specified, and the one thing a library would own for us -- nonce generation and validation -- is exactly what the key binding needs to control. Duende's OidcClient generates and validates its own nonce as an internal detail, and the binding requires the nonce be a specific value: the hash of the key statement being enrolled. Fighting that is worse than owning the flow. AuthorizeKeyBindingAsync is the client half of the primary trust anchor. It runs a second authorization with nonce set to the statement hash and prompt=login, so the ID token that returns is the provider's signature over exactly those public keys, attesting to a user present now rather than to a session opened at some unknown earlier time. It requests only openid -- a second refresh token would be one more long-lived credential for no benefit -- and rejects a token whose nonce is not the one it asked for, because enrolling that would store evidence verifying against keys we are not publishing. The nonce is read without validating the ID token's signature. Sanctioned by OIDC Core 3.1.3.7: for a token received by direct communication with the token endpoint, TLS server authentication may stand in for signature checking. That reasoning does not extend to another user's binding, which arrives via the DodoSSH server and must be verified against JWKS fetched directly -- the directory work in M3. Raw TcpListener rather than HttpListener for the redirect: an ephemeral port can be bound and read atomically instead of picking one and hoping it is still free, there is no HTTP.SYS URL-ACL question on Windows, and the whole surface is one request line. It answers 404 on other paths and keeps waiting, because a browser asks for /favicon.ico first and treating that as the callback would abort every sign-in. 127.0.0.1 rather than localhost: RFC 8252 permits either, but the name resolves through the hosts file. 20 tests, driving the real listener over TCP with a fake browser that actually fetches the redirect -- injecting a fabricated callback would skip the parsing, path filtering and response writing that can break. Mostly negative, because the loopback port is reachable by every local process: a response with the wrong state is rejected *and* never reaches the token endpoint, metadata declaring an issuer other than its own authority is rejected (RFC 8414 3.3, without which a mix-up attack works), a provider offering only 'plain' is fatal rather than a silent downgrade, and the verifier sent is checked against the challenge advertised so PKCE is not theatre that only fails in production. Two bugs caught by writing the tests: the authorize URL builder dropped client_id entirely after a refactor, and CancellationTokenSource.CancelAfter has no TimeProvider overload -- so the browser timeout is now constructed with the clock and a test can advance it instead of waiting five minutes. |
||
|
|
885fb17bdc |
Clear the SSH gate: window-change reaches the remote, and licence as MIT
Licence is MIT, set solution-wide rather than only on the packable project: DodoSSH.Contracts is published so clients can build against it, and a package with no licence expression is one a corporate policy scanner rejects outright. The SSH.NET spike is the M1 client gate and it passes. SSH.NET 2025.1.0 exposes ShellStream.ChangeWindowSize, but a method existing is not the remote observing it, so the tests read `stty size` back from a real sshd after resizing rather than asserting the call did not throw. Repeated resizes each take effect too, which matters because dragging a window edge produces a stream of them. The IChannelSession fallback is not needed. Also verified against a real sshd: password and public-key auth, that the host key arrives as a raw blob we can fingerprint ourselves rather than reading SSH.NET's MD5 property, and that refusing the key via CanTrust actually aborts the connection -- without which the TOFU dialog would be decoration. Kept as a permanent suite, not deleted after the spike. An upgrade that silently stopped sending the request would present as wrapped output only after a resize, which is easy to misattribute to the terminal emulator. Two bugs in the test itself, both worth naming because either would have been read as "resize does not work": - A PTY emits CRLF, and the anchored regex rejected the CR. The output visibly contained `24 80` while the match failed. - Each read can begin with output still buffered from the previous command, including its size line. Taking the first match would have reported the pre-resize size. platform-flags.md now records window-change as resolved rather than unverified -- a stale flag is worse than none -- plus the three real SSH.NET limits found on the way: ShellStream does not override ReadAsync so every idle session parks a pool thread, one connection cannot serve both SshClient and SftpClient, and agent forwarding needs an upstream change. |
||
|
|
b7325b78ca |
Record the platform flags that were only in conversation
Development and testing are Windows-only, so anything unverified elsewhere needs to be written down or it gets assumed to work. Several of these have already cost time once: PostgreSQL 18 moving its data directory silently gives a carried-over compose file an empty volume, and a loopback-bound Tomcat beat Docker's 0.0.0.0 publish for `localhost`, making every Keycloak realm 404 while the container looked healthy. The largest entry is the Avalonia WebView on Linux, which remains the biggest risk in the plan and is why the terminal sits behind ITerminalHost. Also records two things this milestone deliberately left undone -- no rate limiting on the enrollment and sync write paths until M2, and /me not touching last_seen_at_utc -- so neither reads later as an oversight. |