Public Access
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.
673 lines
51 KiB
Markdown
673 lines
51 KiB
Markdown
# Platform flags
|
||
|
||
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
|
||
have already cost time once. Development is Windows-first, but **the full test suite now runs on
|
||
Linux in CI on every change**, so a Linux claim here is usually a measurement now rather than a
|
||
suspicion. **macOS is still untested**, and anything marked *unverified* has not run on the platform
|
||
in question and must not be assumed to work.
|
||
|
||
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
|
||
has been verified or made moot — not when it merely stops being convenient.
|
||
|
||
## Cryptography
|
||
|
||
**`ChaCha20Poly1305.IsSupported` is false on macOS**, and on Windows builds before 10.0.20142.
|
||
This is why the client uses NSec (libsodium) rather than the BCL for content encryption; see
|
||
docs/crypto.md §1. *Already mitigated* — but if a BCL AEAD path is ever added as a fallback it
|
||
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
|
||
any vault on macOS.
|
||
|
||
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
|
||
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
|
||
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
|
||
because a cost that is comfortable on a desktop can make unlock unusable on a low-power laptop —
|
||
and the parameters are stored per user at enrollment, so a bad default is a per-user migration.
|
||
|
||
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
|
||
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
|
||
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
|
||
or notarization fails with an error that does not name the offending file.
|
||
|
||
## Desktop client
|
||
|
||
**The local pane's roots bar is built differently per platform, and has to be.** On Windows it is the
|
||
ready drives, from `DriveInfo.GetDrives`. On Unix that same call answers with every mount the kernel
|
||
holds — around forty on an ordinary laptop, counting `/proc`, `/sys/fs/bpf`, one per installed snap and
|
||
`/run/user/1000/doc` — and the transfers screen draws a button per root, so the bar ran to roughly five
|
||
thousand pixels inside an eight-hundred pixel window. *Fixed* in `LocalDirectory.Roots`, which on Unix
|
||
returns the root, the user's home, and whatever is mounted under `/run/media/<user>`, `/media`, `/mnt`
|
||
or `/Volumes`. Do not try to filter `GetDrives` instead: `DriveType` reports `Fixed` for `/` and `/home`
|
||
but also 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 may grow.
|
||
Found by the layout suite on its first Linux run, which is the argument for that suite existing.
|
||
|
||
**The WebView runs on Windows.** `Avalonia.Controls.WebView` 12.0.1 (MIT, no licence key) hosts the
|
||
terminal page: WebView2 launches, navigates to the loopback page, runs its JavaScript and completes the
|
||
WebSocket handshake. Verified by observing an established TCP connection from `msedgewebview2` to the data
|
||
plane port.
|
||
|
||
Note precisely what that evidence covers, because it was once stretched to cover more: every clause above
|
||
is about the process and the socket. It says nothing about how the control **composites** with
|
||
Avalonia-drawn content, which is the axis on which it does not behave like an ordinary control — see the
|
||
next entry.
|
||
|
||
**A native child window cannot be covered by Avalonia content, on any platform that hosts it windowed.**
|
||
`NativeWebView` attaches a real Win32 child HWND through `NativeControlHost` — on Windows the backend
|
||
creates a `WS_CHILD` holder window and `SetParent`s WebView2's HWND into it — and a child window paints
|
||
above everything its parent draws, whatever the visual tree's z-order says. This is by design and
|
||
acknowledged upstream: *"NativeControlHost places native controls over Avalonia content just like WPF one
|
||
does. So it suffers from the same airspace problem"* (Avalonia's maintainer,
|
||
[#6605](https://github.com/AvaloniaUI/Avalonia/issues/6605), still open). Reproduced in a 60-line standalone
|
||
app with no DodoSSH code: a `340,*` grid, a `NativeWebView` in column 1 and an opaque `Border` as a later
|
||
`Panel` sibling renders the overlay sliced dead on x=340.
|
||
|
||
Layering a screen over the terminal therefore does nothing: the WebView's rectangle stays on top. In this
|
||
shell that sliced the setup
|
||
and unlock cards at the terminal column's left edge, put every one of their buttons inside the WebView's
|
||
rectangle at the window's default width — so the flow could only be completed by keyboard — and handed
|
||
Win32 focus to WebView2 on any click in that region, which makes a text box stop accepting keystrokes with
|
||
no visible cause. That last symptom is the focus asymmetry documented further down, not a separate fault:
|
||
focus crosses into the WebView readily and does not come back on its own.
|
||
|
||
The fix is to collapse the control, not to cover it: `IsVisible="{Binding IsUnlocked}"` on the
|
||
`NativeWebView`. That is safe, and this is the part worth recording, because the opposite was asserted here
|
||
for a while:
|
||
|
||
- `NativeControlHost` creates the native attachment from **attach to the visual tree**, not from layout and
|
||
not from visibility. Its `UpdateHost` never reads `IsEffectivelyVisible`; only
|
||
`TryUpdateNativeControlPosition` does, choosing `HideWithSize` over `ShowInBounds`.
|
||
- `NativeWebView` stashes a `Source` assigned before its adapter exists and replays it once created, so
|
||
navigation is never lost to ordering. The shell already depends on that replay.
|
||
- So a collapsed WebView still starts WebView2, still loads the page and still lets the renderer attach its
|
||
socket. Measured on Windows in a harness mirroring the data plane's handshake, with `IsVisible=false` set
|
||
before the window was ever shown: adapter created, `GET /`, then **the WebSocket 101 sent** — the moment
|
||
`RendererAttached` fires — followed by frames arriving over the socket, all while hidden. A cold WebView2
|
||
profile behaves the same. Revealing it recomputes bounds within about 7 ms, on one `ResizeObserver`
|
||
callback, over the same socket.
|
||
|
||
It must be `IsVisible`, not removal from the tree. Detaching runs `DestroyNativeControl` and takes the
|
||
whole WebView2 process tree with it, so conditional content or a template swap would pay a cold start on
|
||
every unlock. Hiding merely does
|
||
`SetWindowPos(holder, …, SWP_HIDEWINDOW)`. Negative `Margin` also works as a runtime toggle;
|
||
`RenderTransform` does **not**, because `NativeControlHost` never watches it.
|
||
|
||
Note the earlier version of this bullet cited "35 `msedgewebview2` processes" as the confirmation. A
|
||
process count cannot show that a socket was accepted — it is the same shape of mistake as the one
|
||
described below, one level down.
|
||
|
||
The previous version of this entry claimed the reverse — that hiding it would mean never realising it — and
|
||
cited the `msedgewebview2` connection as verification. That observation was made while the overlay was
|
||
showing but, because of the airspace behaviour above, the WebView was in fact uncovered and in plain view.
|
||
It confirmed only that a *visible* WebView is realised, which nobody disputed, and could not discriminate
|
||
the case it was attached to. A process-level check cannot verify a rendering claim; that needs a
|
||
screenshot, and this defect shipped because one was never taken.
|
||
|
||
**What the first connection after unlocking actually depends on** is the `await
|
||
workspace.WaitForRendererAsync(cancellationToken)` in `VaultViewModel.ConnectAsync`, because
|
||
`TerminalDataPlane.SendAsync` drops frames when no renderer is attached rather than queueing them. That
|
||
await is the invariant; the control's visibility is not.
|
||
|
||
It is now bounded — `TerminalWorkspaceOptions.RendererTimeout`, 15 s, plus the command's own token —
|
||
because whether the renderer attaches at all depends on a runtime this application does not install. A
|
||
missing or policy-blocked Evergreen runtime, or an AppContainer that cannot reach loopback, previously
|
||
left Connect waiting forever with `IsBusy` stuck and nothing on screen to explain it. The gate is
|
||
unchanged; only the wait is. Why 15 s and not less: attaching is near-instant in the normal case (the page
|
||
attaches while the unlock screen is still up), but a cold WebView2 profile creates a user-data directory
|
||
and starts its process tree first, and reporting a broken runtime to someone whose runtime was merely slow
|
||
is the worse error. The timeout is caught in `VaultViewModel` and reported as a message naming WebView2,
|
||
because `TimeoutException.Message` is "The operation has timed out" and names nothing.
|
||
|
||
**Hiding the WebView does not pause it.** With the holder window hidden, the page keeps
|
||
`visibilityState: "visible"` and `requestAnimationFrame` keeps firing at roughly 115/s — Chromium does not
|
||
treat a hidden child HWND as a hidden page. That is *why* the handshake completes while collapsed, so it is
|
||
load-bearing rather than merely wasteful, but it means a locked DodoSSH is still animating a full-size
|
||
off-screen page. Worth revisiting if idle power ever matters.
|
||
|
||
**A degenerate pane size reaches the remote pty.** The fit addon floors its proposal at 2 columns by 1 row
|
||
rather than refusing, so any path that fits a terminal with almost no viewport sends `window-change` for a
|
||
2x1 window and permanently mangles the wrapped scrollback. Reachable today by minimising, and — once splits
|
||
land — by dragging a splitter to the edge. `terminal.js` now skips the fit below 40 px in either axis.
|
||
Related, and now fixed: the conflict log was an `ItemsControl` with no `ScrollViewer` and no `MaxHeight` on
|
||
an `Auto` row, so enough conflicts squeezed the row below it toward nothing. It survived that long because
|
||
it lived in `MainWindow.axaml`, which no test can lay out. Moving it into `HostsScreen.axaml` — a
|
||
`UserControl`, and therefore measurable — is what surfaced it; it now has both, and
|
||
`TheHostsScreenFitsWithAConflictLogTooLongToShow` fails without them.
|
||
|
||
**Keyboard focus crosses into the WebView by itself and does not come back.** This is the asymmetry to
|
||
know; the connect-focus bug that led here was only its first symptom. Measured on Windows with a harness
|
||
that reports `GetFocus()`, the class name of the window holding it, and the page's own
|
||
`document.hasFocus()` at each step.
|
||
|
||
- **Into the page: nothing custom is needed.** `NativeWebView` overrides `Focusable` to true and its
|
||
`OnGotFocus` calls the adapter's `Focus()`, which on Windows is
|
||
`ICoreWebView2Controller::MoveFocus(PROGRAMMATIC)`. A plain Avalonia `Terminal.Focus()` therefore moves
|
||
real Win32 focus to the `Chrome_WidgetWin_1` child and the page reports `hasFocus: true`. No `SetFocus`
|
||
P/Invoke and no COM work — the package version of this entry that assumed otherwise was wrong. The
|
||
control also replays a `Focus()` that arrived before its adapter existed, and re-asserts itself: while
|
||
it holds Win32 focus its `GotFocus` handler pulls Avalonia's *logical* focus back onto the control. Worth
|
||
stating positively, because the reasonable guess before measuring — that crossing into a child HWND must
|
||
need `SetFocus` — is the wrong way round: it is the return trip that needs it.
|
||
- **Out of the page: the package does nothing at all.** `OnLostFocus` calls the adapter's `ResignFocus()`,
|
||
and on Windows that method is **empty**. So `someTextBox.Focus()` moves Avalonia's focused element while
|
||
Win32 focus stays on WebView2: a text box with a caret that silently receives nothing. `Window.Activate()`
|
||
and `Window.Focus()` were both measured and neither recovers it. The hand-back has to be
|
||
`SetFocus(topLevelHwnd)` — see `Views/NativeKeyboardFocus.cs`. A real mouse click *does* recover it,
|
||
because Avalonia's window sets focus on pointer input, which is exactly why this is invisible to anyone
|
||
who clicks before typing.
|
||
- **Collapsing the control does not release the keyboard.** With `IsVisible=false` the holder window is
|
||
hidden but Win32 focus stays on it — measured as focus held by a window reporting `visible=False`, with
|
||
Avalonia's focused element becoming `(none)`. So locking the vault after touching the terminal left the
|
||
unlock passphrase box eating keystrokes. The lock path now hands the keyboard back and focuses that box.
|
||
- **`Focus()` on a collapsed control is a no-op and is not replayed on reveal.** Order matters: reveal,
|
||
then focus. Focus does survive a lock/unlock cycle when done that way.
|
||
- **There is no Tab-out.** The package subscribes `ICoreWebView2Controller::add_MoveFocusRequested` and its
|
||
handler body is empty, so WebView2's request to move focus off itself is discarded; xterm eats Tab
|
||
anyway. The way out is `Ctrl+Shift+F6`, intercepted in `terminal.js` and sent to the host as a web
|
||
message — measured arriving verbatim in `WebMessageReceivedEventArgs.Body`. It has to be handled in the
|
||
page, because once the child window owns Win32 focus Avalonia sees no key events and no `KeyBinding`
|
||
could fire. Not Escape, and not a bare F6: both are keys a TUI legitimately binds, and Ctrl+Shift is the
|
||
range terminal emulators conventionally keep for themselves.
|
||
|
||
None of this is covered by a test, and cannot be here: headless Avalonia has no native window, so a
|
||
headless test renders and focuses correctly and would confirm the wrong belief. What the suite covers is
|
||
the plumbing that drives it — that connecting asks for focus once per session, that a failed connect does
|
||
not, and that locking stops the forwarding.
|
||
|
||
**The lock/unlock cycle does not resize the pane at all, and the 40 px guard is not what makes it safe.**
|
||
Measured on Windows with a live shell, against a real `sshd` in a container, in a harness mirroring
|
||
`MainWindow.axaml`'s `340,*` grid: with the `NativeWebView` collapsed by `IsVisible=false`, the page still
|
||
reports `paneWidth: 840, paneHeight: 760`, unchanged `cols`/`rows`, and `visibilityState: "visible"`.
|
||
Hiding is `SetWindowPos(holder, …, SWP_HIDEWINDOW)`, which does not resize the holder, so no
|
||
`ResizeObserver` callback fires, no fit runs, and **no `window-change` reaches the remote** — before,
|
||
during or after the cycle. `stty size` on the remote answered `50 118` both before locking and after
|
||
unlocking, and the renderer's own buffer came back byte for byte, wrapped lines included.
|
||
|
||
The guard's irrelevance here was established rather than assumed: the same run with
|
||
`MINIMUM_FITTABLE_PIXELS` patched to `0` — the guard fully disabled — produced an identical clean result.
|
||
So the guard is still worth keeping for the paths it was written for, minimising and a splitter dragged to
|
||
the edge, but it is **not** on the lock path and must not be cited as the reason locking is safe. It was
|
||
described that way when it landed.
|
||
|
||
Two further results from the same harness, both about the deliberate decision that shells outlive a lock
|
||
(README, `MainWindowViewModel.LockAsync`):
|
||
|
||
- **A collapsed WebView is not typed into.** With the harness confirmed as the foreground window and all
|
||
twelve injected `SendInput` events accepted, not one character of the probe reached the remote pty, and a
|
||
`Ctrl-U` afterwards answered `BEL` — nothing was sitting in the remote's line editor either. So the lock
|
||
screen is a real input barrier even though the session behind it is live, and that is what makes
|
||
surviving the lock defensible rather than merely convenient. The *mechanism* is not what this run
|
||
concluded: it read the result as a hidden `WS_CHILD` window being ineligible for keyboard focus, but the
|
||
focus entry above measured Win32 focus still held by the hidden holder window, and the lock path now
|
||
moves the keyboard off it deliberately. Take the barrier as measured here and the reason from there —
|
||
which also means the barrier is something the lock path maintains, not something the platform guarantees.
|
||
- **The session survives the cycle in the real control, not only in tests.** `LiveSessionCount` was 1
|
||
before, during and after, and the shell accepted a command again immediately on unlock.
|
||
|
||
*Suspected, seen once, not reproduced:* on the first run — before the harness learned to wait for the
|
||
window's scale to settle — the window opened at 2558x1367 px and the page reported a 2202x1328 pane
|
||
(312x88 characters) for a window 1180 logical units wide, which looks like physical pixels arriving where
|
||
CSS pixels were expected. A later re-push to 1177x672 then reflowed the wrapped line and split it in two.
|
||
Both events straddled a DPI settle rather than the lock, and three later runs at `RenderScaling 1.00`
|
||
never showed it. If a user reports mangled scrollback after moving the window between displays of
|
||
different scale, start here.
|
||
|
||
**WebView2 will not initialise when the host executable sits under a very long path.**
|
||
`CreateCoreWebView2Environment` fails with `COMException 0x80080005 CO_E_SERVER_EXEC_FAILURE` ("Server
|
||
execution failed") and the terminal never appears. Hit while building the harness above: the same binary
|
||
that failed from a ~230-character directory ran first time from `%TEMP%\h`. The exact threshold was not
|
||
established and the mechanism is unconfirmed — the user data folder is created beside the executable by
|
||
default and the browser process is launched with paths derived from it, so `MAX_PATH` is the obvious
|
||
suspect.
|
||
|
||
*Measured for the packaged layout, so this stops being a worry and becomes a number.* Velopack installs to
|
||
`%LOCALAPPDATA%\DodoSSH.Desktop\current\`, and `…\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe` is
|
||
**64 characters** against the ~230 that reproduced the failure — about 180 characters of headroom, and a
|
||
40-character corporate username adds 35 of them back. The shipped installer is not at risk. Two things
|
||
would reopen it and neither is in the plan: a self-extracting single-file publish, whose native libraries
|
||
land under a hashed temp path, and `%LOCALAPPDATA%` folder-redirected to a deep UNC path in a domain.
|
||
Manual check 16.2 measures it on the real machine rather than trusting this paragraph.
|
||
|
||
**WebView2's user data folder must be kept out of the install directory.** It defaults to a directory
|
||
beside the host executable, which under Velopack is inside `current\` — and `current\` is *replaced* by
|
||
every update. Left alone, the browser profile would be destroyed on each one, so the first connect after
|
||
every update would pay a cold WebView2 start: a fresh user-data directory and a new process tree, which is
|
||
the slow path `RendererTimeout`'s fifteen seconds was sized for, arriving at the exact moment somebody is
|
||
most ready to believe the update broke the terminal. `Program.Main` sets `WEBVIEW2_USER_DATA_FOLDER` to
|
||
`%LOCALAPPDATA%\DodoSSH\WebView2` — under the profile directory, which Velopack never touches. Check 16.8
|
||
is what would notice it regressing, and it is worth having because the symptom is "slow but working", which
|
||
gets dismissed as a fluke.
|
||
|
||
**The install root and the profile directory must not be the same folder.** Velopack removes
|
||
`%LOCALAPPDATA%\<packId>` entirely on uninstall, and `ClientPaths` puts `cache.db` (plus `-wal` and `-shm`),
|
||
`settings.json` and `device.key` in `%LOCALAPPDATA%\DodoSSH`. So a pack id of `DodoSSH` — the obvious
|
||
choice — would have made the uninstaller delete the vault cache and the outbox of changes not yet pushed,
|
||
silently, which is the thing the application will not do without a counted confirmation. The pack id is
|
||
`DodoSSH.Desktop` for that reason and no other; `--packTitle` supplies the name people see, so nothing is
|
||
lost. Do not "tidy" it. See [ADR 0013](adr/0013-desktop-distribution-and-updates.md) and check 16.9.
|
||
|
||
**The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a
|
||
downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child
|
||
window for native control host"* — so the WebView, and therefore the terminal, does not start at all.
|
||
`[STAThread]` on `Main` is equally mandatory: WebView2 checks the apartment state and refuses to
|
||
initialise on an MTA thread.
|
||
|
||
**WebView2 spawns a process tree, not a process.** Around 35 processes were observed for one embedded
|
||
view. That is the concrete reason the design uses one WebView hosting N terminals rather than one per
|
||
tab: twenty tabs would mean twenty of those trees.
|
||
|
||
**The Linux WebView needs `ExperimentalOffscreen`, or the terminal is blank.** This entry used to predict
|
||
that WPE (`libwpewebkit-2.0`) would be the Linux backend and be too rarely installed; the prediction was
|
||
wrong in its details and right about the outcome. Measured on Fedora 44 with
|
||
`Avalonia.Controls.WebView` 12.0.1, by a spike that hosts a `NativeWebView` and reads `AdapterInfo`:
|
||
|
||
- The adapter is **WebKitGTK 2.52.5**, not WPE, and it reports `IsSupported = True`. Fedora packages no
|
||
WPE WebKit at all — `dnf search wpe` returns a computer-algebra package and nothing else — so the WPE
|
||
path is not merely rare there, it is unavailable.
|
||
- In its default mode that adapter reports **`SupportedScenarios = NativeDialog`**: a window of its own,
|
||
and nothing that can be hosted in place. The same under X11 and under Wayland, so this is the adapter's
|
||
answer rather than a session-type problem.
|
||
- Setting **`ExperimentalOffscreen`** on `GtkWebViewEnvironmentRequestedEventArgs` changes the same
|
||
adapter's answer to **`OffscreenRenderer`** — the compositor-drawn mode, which is what
|
||
`NativeWebViewCompositorHost` (mentioned below as an unknown) exists to host. `MainWindow` sets it; see
|
||
`OnTerminalEnvironmentRequested`, which is a no-op on Windows and macOS by type rather than by OS check.
|
||
- In both modes the page loads and `InvokeScript` answers, which is the trap: **the failure has no
|
||
diagnostics.** Everything except the pixels works, so it reads as the terminal being broken rather than
|
||
as the host having nowhere to draw. `AdapterInfo.SupportedScenarios` is the thing to look at first.
|
||
|
||
*Still unverified:* whether the offscreen mode actually paints, and how it behaves for input, IME and
|
||
resizing. The spike could not answer it — an XWayland root capture is black under a Wayland compositor
|
||
and `RenderTargetBitmap` does not capture a compositor surface — so it needs eyes on a running client.
|
||
macOS 15 remains untested entirely.
|
||
|
||
`ITerminalHost` was supposed to be the seam that keeps a backend swap cheap, and it is **declared but not
|
||
implemented** — nothing in the application uses it, and the view navigates `NativeWebView.Source` directly.
|
||
Swapping backends today means editing `MainWindow.axaml` and its code-behind. That is a small job, but do
|
||
not plan around a seam that is currently only a file.
|
||
|
||
**`Avalonia.Diagnostics` has no 12.x release** (latest is 11.3.18), so the developer tools overlay is
|
||
unavailable on Avalonia 12. Development-only, so nothing ships differently — but debugging a layout
|
||
problem currently means reasoning rather than inspecting.
|
||
|
||
**The xterm bundles are vendored, not built.** `@xterm/xterm` 6.0.0 with the fit and webgl addons, all
|
||
MIT, committed as UMD bundles under `WebAssets/vendor` and embedded as Avalonia resources. No npm or
|
||
esbuild step, so a clean clone builds with the .NET SDK alone. The cost is that upgrades are a manual
|
||
re-download; the licence and versions are recorded here so that stays visible.
|
||
|
||
**Bracketed paste is the renderer's to decide, and it is why snippets go through a frame.** xterm tracks
|
||
`\e[?2004h` from the remote's own output and `Terminal.paste(text)` wraps the text in paste markers only
|
||
when the mode is on — which is what makes a shell treat embedded newlines as text rather than as "run
|
||
this". The host process cannot make that decision: `TerminalDataPlane` moves opaque bytes and never parses
|
||
output, so writing a snippet straight into the pump would mean guessing, and guessing wrong executes every
|
||
line of a multi-line command. Hence `TerminalServerOpcode.Paste`. Two consequences worth keeping:
|
||
|
||
- The Enter for a snippet marked as running goes through `Terminal.input('\r')`, **outside** the wrapper. A
|
||
`\r` appended to the pasted text is bracketed with it and arrives as a literal character, so nothing runs.
|
||
- Against a remote with bracketed paste *off* — a raw `sh`, or a session inside an editor — a multi-line
|
||
snippet does run line by line, and nothing can prevent that. It is a property of the terminal protocol,
|
||
not of this client, which is why the screen says "types this into whatever is there".
|
||
|
||
Both methods were confirmed present on the public API of the vendored `@xterm/xterm` 6.0.0 bundle before
|
||
being written against; neither is reachable from any test in this repository, so they are in
|
||
`docs/manual-checks.md` as checks 3.6–3.8.
|
||
|
||
**SSH.NET's `window-change` is verified working** as of 2025.1.0 — resolved, not a flag.
|
||
`ShellStream.ChangeWindowSize(columns, rows, width, height)` exists and the remote genuinely
|
||
observes it: `PtyAndResizeSpikeTests` reads `stty size` back from a real sshd after resizing, and
|
||
repeated resizes each take effect. The `IChannelSession` fallback is not needed. That suite stays
|
||
in place as a regression guard, because an upgrade that silently stopped sending the request would
|
||
present as wrapped output only after a resize — easy to misattribute to the terminal emulator.
|
||
|
||
**`ShellStream.Write` buffers and requires 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 a spike that used it never
|
||
hit this. `SshNetShellSession.WriteAsync` now flushes per write; batching would be wrong anyway, since
|
||
a terminal has to put a keystroke on the wire immediately.
|
||
|
||
**`ShellStream` does not override `ReadAsync`.** The base `Stream` implementation therefore runs
|
||
the blocking `Read` on a thread-pool thread, so every open session parks one thread for as long as
|
||
it is idle. Fine for the handful of tabs M1 targets; revisit before advertising many concurrent
|
||
sessions, since the fix is either an upstream change or driving `IChannelSession` directly.
|
||
|
||
**A passphrase supplied for an unprotected private key is silently ignored, not refused.**
|
||
`PrivateKeyFile(stream, passphrase)` on an unencrypted PKCS#1 RSA key loads it and the connection
|
||
authenticates exactly as if no passphrase had been given — measured against a real `sshd` in
|
||
`KeyAuthenticationTests.APassphraseOnAnUnprotectedKey_IsIgnoredRatherThanRefused`, which was written
|
||
expecting the opposite and corrected to match. Two consequences, and the second is the one that bites: a
|
||
stray passphrase does no harm, so nothing downstream needs to defend against it; but equally nothing
|
||
downstream will *report* one, so if a user swears they set a passphrase and the key opens without it, no
|
||
error will ever say so. Only established for that armour and that algorithm; whether the OpenSSH format's
|
||
`none` cipher path behaves the same way is untested. `SshKeySecret.Passphrase` still normalises an empty
|
||
string to null, for the reasons stated there — one representation of one state — and not for this.
|
||
|
||
**SSH.NET cannot share one connection between `SshClient` and `SftpClient`.** A shell plus SFTP to
|
||
the same host means two TCP connections, two authentications and — later — two relay sockets.
|
||
Connect SFTP lazily and reuse the cached decrypted credential so the user is not prompted twice.
|
||
|
||
**Agent forwarding is de-scoped from v1.** It needs an upstream SSH.NET change. A vault-backed
|
||
agent of our own plus ProxyJump covers the real use cases.
|
||
|
||
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
|
||
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
|
||
|
||
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
|
||
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
|
||
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
||
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
||
|
||
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
|
||
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
||
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
||
no `runFullTrust`, no elevation and no execution alias, so the process stays an ordinary desktop process
|
||
and WebView2 stays out of an AppContainer. That is reasoning, not measurement; manual check 16.4 is the
|
||
measurement, because if a package identity ever did appear the symptom would be the terminal hanging and
|
||
then reporting the WebView2 message after fifteen seconds, which reads like a broken runtime rather than
|
||
like packaging.
|
||
|
||
**Linux ships AppImage and Flatpak first**, specifically so the WebKit runtime is bundled rather
|
||
than assumed present on the user's machine.
|
||
|
||
**Opening the system browser depends on the platform handler.** `SystemBrowserLauncher` uses
|
||
`UseShellExecute`, which delegates to `ShellExecute` on Windows, `open` on macOS and `xdg-open` on
|
||
Linux. *Unverified off Windows:* `xdg-open` comes from `xdg-utils`, which is not guaranteed on a
|
||
minimal desktop or inside a Flatpak sandbox — where the portal is the correct route instead. If
|
||
sign-in silently does nothing on Linux, this is the first thing to check. `IBrowserLauncher` exists
|
||
so a platform-specific opener can be substituted without touching the flow.
|
||
|
||
## Identity provider
|
||
|
||
**A loopback redirect URI must be registered without a port, not with a wildcard port.** Keycloak — and
|
||
providers implementing RFC 8252 §7.3 generally — ignores the port when the registered redirect URI's host
|
||
is a loopback literal, which is what lets a native client bind an ephemeral port. Registering
|
||
`http://127.0.0.1:*/callback` looks more explicit and is *broken*: the `*` is parsed as a literal port and
|
||
every real authorization request comes back `400 Invalid parameter: redirect_uri`. Keycloak's wildcard
|
||
support is trailing-only, so a `*` in the middle of a URI never means what it looks like.
|
||
|
||
Register `http://127.0.0.1/callback`. Keep the path — it is the part that stops another process on the
|
||
machine having an authorization code delivered to a different endpoint. `Oidc:LoopbackRedirectPattern`,
|
||
which the server advertises through `/.well-known/dodossh-configuration`, says the same thing so an
|
||
operator configuring a different provider copies something that works.
|
||
|
||
Found by running the sign-in against a real Keycloak; every test until then used a stub that accepted
|
||
whatever it was given.
|
||
|
||
**Keycloak marks its session cookies `Secure` even over plain HTTP**, because `SameSite=None` is only
|
||
legal alongside `Secure`. A spec-conformant HTTP client therefore refuses to store them from an `http://`
|
||
origin — .NET's `CookieContainer` drops every one silently — and the login form POST then comes back
|
||
`400` with no explanation at all. Browsers complete the flow because they treat loopback as a trustworthy
|
||
origin and make the exception.
|
||
|
||
This does not affect the product: the client uses the system browser, which makes that exception. It does
|
||
affect any non-browser automation against a development Keycloak, which has to carry the cookies by hand
|
||
(see `ScriptedBrowser`) or be given HTTPS. Two hours of "the credentials must be wrong".
|
||
|
||
**A user declared in a realm import gets no roles unless `realmRoles` says so** — not even the realm's own
|
||
`default-roles-<realm>` composite, which Keycloak grants automatically to a user created through the admin
|
||
API or the registration form. The realm file's `alice` and `bob` therefore had no role mappings at all, and
|
||
because `offline_access` lives inside that composite and the desktop client requests that scope, the very
|
||
first sign-in died at the token exchange with `400 Offline tokens not allowed for the user or client`. The
|
||
authorization succeeds and the failure lands one step later, which makes it read like a client bug.
|
||
|
||
Add `"realmRoles": ["default-roles-dodossh"]` to every user the file declares. And note the asymmetry,
|
||
because it is what let this ship: `DodoSSH.SystemTests` used to create its own account through the admin
|
||
API, so it exercised a provisioning path no real user takes and passed while the documented `alice` could
|
||
not sign in at all. The suite now signs in as the realm's own account, and removing these roles fails it.
|
||
|
||
**Keycloak rejects unknown fields in a realm file.** `RealmRepresentation` deserialises with
|
||
`FAIL_ON_UNKNOWN_PROPERTIES` enabled, so a `"_comment"` key — the usual way to annotate JSON that has no
|
||
comment syntax — does not merely get ignored: the import throws
|
||
`Unrecognized field ... not marked as ignorable` and **the container refuses to start at all**. Explanations
|
||
about the realm belong here or in the compose file, never in the realm JSON.
|
||
|
||
**`--import-realm` skips a realm that already exists.** Editing `deploy/keycloak/realm-dodossh.json` and
|
||
running `docker compose restart keycloak` therefore changes nothing, and the stale configuration keeps
|
||
being served — which reads exactly like the edit being wrong. `start-dev` keeps its state in an H2
|
||
database inside the container, so the realm has to be recreated along with it:
|
||
`docker compose rm -sf keycloak && docker compose up -d keycloak`. Cost an otherwise inexplicable
|
||
debugging detour.
|
||
|
||
`DodoSSH.SystemTests` is immune to this by construction — its Keycloak is created and destroyed per run —
|
||
which is a second reason the end-to-end suite starts its own containers rather than reusing the developer's
|
||
stack. Editing the realm file and rerunning the suite always tests the edit.
|
||
|
||
## Local cache
|
||
|
||
**The cache location is per-OS and must stay non-roaming.** `ClientPaths` chooses it:
|
||
`%LOCALAPPDATA%\DodoSSH` on Windows, `~/Library/Application Support/DodoSSH` on macOS,
|
||
`$XDG_DATA_HOME/dodossh` or `~/.local/share/dodossh` on Linux. It must **not** land anywhere that syncs
|
||
to a cloud drive or roams: two machines writing one SQLite file through a file-sync client corrupts it,
|
||
and the whole point of the outbox is that each machine has its own. That is also why Windows uses
|
||
`%LOCALAPPDATA%` and not `%APPDATA%`, which roams in a domain environment.
|
||
|
||
The platform branches are explicit rather than delegating to
|
||
`Environment.SpecialFolder.LocalApplicationData` everywhere, because on macOS the runtime maps that to
|
||
`~/.local/share` rather than to `~/Library/Application Support`. *Verified on Windows only* — the client
|
||
created `%LOCALAPPDATA%\DodoSSH\cache.db` and migrated it on first launch. The macOS and Linux branches
|
||
are reasoned, not run.
|
||
|
||
**SQLite timestamps are stored as integers, deliberately.** EF's default `DateTimeOffset` mapping for
|
||
SQLite is a text form it then refuses to order or compare, so any query that sorts or filters by time
|
||
throws at execution rather than at model build. `UnixMillisecondsConverter` is applied as a convention
|
||
so a timestamp added later cannot be the one left unconverted. This is provider behaviour, not
|
||
platform behaviour, but it cost a debugging session and will again if the converter is removed.
|
||
|
||
**The cache is three files, not one.** EF Core's SQLite provider puts the database in WAL mode, which is
|
||
the right mode here — a background sync pass writes while the interface reads, and under the default
|
||
rollback journal those reads would fail busy — but it means `cache.db` is accompanied by `cache.db-wal`
|
||
and `cache.db-shm`. Any backup, export or uninstall routine that touches only `cache.db` is wrong.
|
||
Verified by launching the client and reading `PRAGMA journal_mode`, after a comment in the code claimed
|
||
the opposite.
|
||
|
||
**Pooled SQLite connections keep the file open after the last context is disposed.** On Windows that
|
||
means locked, so the application cannot delete or replace its own cache and a test cannot clean up after
|
||
itself. `ClientCacheFactory.Dispose` clears the pool for exactly this reason; removing that line makes
|
||
the failure appear only on Windows.
|
||
|
||
**No SQLCipher, on any platform.** The rows are already ciphertext from the server, so an encrypted
|
||
database file would protect bytes that are protected already at the cost of a native dependency and a
|
||
licence obligation — and `bundle_e_sqlcipher` was deprecated in SQLitePCLRaw 3.0. The consequence to
|
||
be honest about: the cache offers no protection against another process running as the same user. See
|
||
`LocalCacheProtector` for what it does and does not defend against.
|
||
|
||
## Build and CI
|
||
|
||
**The layout suite needs two unrelated things on a bare image, and each hides the other.** Both were
|
||
found the slow way, one per CI run, because the first masks the second entirely.
|
||
|
||
The first is `libfontconfig`. Avalonia's headless renderer is Skia, and the `libSkiaSharp.so` the test
|
||
project copies into its own output links against it; without it the native library never loads and all
|
||
69 tests fail inside `HeadlessUnitTestSession` with a `TypeInitializationException` on
|
||
`SkiaSharp.SKImageInfo` naming none of their actual subjects. The CI job installs the package.
|
||
|
||
The second only becomes visible once the first is fixed, and is not about a package at all. Avalonia
|
||
takes its default font family from the platform, and on an image with no fonts installed there is no
|
||
answer — `FontManager` throws "Default font family name can't be null or empty" during
|
||
`AppBuilder.SetupUnsafe`, again before any test body runs and again for all 69. `WithInterFont` does not
|
||
help by itself: it registers a collection without naming a default. Both `Program.BuildAvaloniaApp` and
|
||
the layout suite's `HeadlessApp` now set `FontManagerOptions.DefaultFamilyName` to
|
||
`avares://Avalonia.Fonts.Inter/Assets#Inter` explicitly, which owes the host nothing because the font
|
||
travels in the package.
|
||
|
||
Pinning it is worth more than the CI fix. A suite that measures text was taking its metrics from
|
||
whatever the machine happened to have — Segoe UI on Windows, DejaVu on Linux — and reporting both as
|
||
one number. *Verified* on Alpine musl with `fc-list` returning zero and on a Fedora desktop with 595
|
||
fonts, which now agree. **Unverified on Windows:** the pinning changed the metrics there too, so a
|
||
tight assertion could conceivably have moved.
|
||
|
||
**The end-to-end suite must state its plaintext exemption rather than inherit it.** `ServerConnection`
|
||
allows an `http` OIDC authority only when it is loopback, which is a sound rule the suite cannot lean
|
||
on: Testcontainers reports the host the container is actually reachable at, so running the tests
|
||
directly gives `localhost` and passes, while running them *inside* a container — which is what a
|
||
containerised CI runner does — gives the bridge gateway `172.17.0.1` and is refused. That refusal is
|
||
the product being correct; a client that quietly accepted plaintext metadata from a routable address
|
||
would be a real weakness. `M1VerticalSliceTests` therefore passes `configureOidc` to set
|
||
`RequireHttpsMetadata = false` for the throwaway Keycloak it starts itself, and the rule stays as strict
|
||
as it was for everyone else.
|
||
|
||
**Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI.
|
||
macOS runners have no Docker daemon, and the Windows CI job is deliberately build-only. So
|
||
anything proved by an integration test is proved on Linux only — which is the right place for
|
||
server code, and no coverage at all for client platform behaviour.
|
||
|
||
**The end-to-end suite launches the API's own launcher executable**, falling back to `dotnet exec` on the
|
||
assembly. The fallback exists for one reason: a checkout or artefact copy that lost the execute bit
|
||
produces a `Win32Exception` on Linux and nothing whatsoever on Windows. *Verified on Windows only* — the
|
||
launcher path is what runs here, so the fallback itself is reasoned rather than exercised. If the suite
|
||
fails in CI with a permission error before any container work, that is the path to look at.
|
||
|
||
**It also depends on `Server:PublicBaseUrl` being knowable before startup.** The port is chosen by binding
|
||
a loopback socket and releasing it, because the API reads that URL at startup and advertises it to clients,
|
||
so it cannot be discovered from Kestrel afterwards. The window for another process to take the port is a
|
||
few milliseconds; if the suite ever fails with an address-in-use, this is why, and a retry is the fix
|
||
rather than a redesign.
|
||
|
||
**A RID must never reach the committed lock files, and the obvious fix for a RID-specific publish puts
|
||
one there.** `dotnet publish -r win-x64` resolves a graph the committed `packages.lock.json` files do not
|
||
describe — they carry a `net10.0` target and nothing else — so under locked mode it fails NU1004. The
|
||
obvious answer is `<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>` on the desktop head plus a
|
||
`--force-evaluate` to regenerate. **That is wrong here, and it was tried and reverted.**
|
||
|
||
A RID declared on one project flows to every project it references transitively while restoring, so the
|
||
regenerated lock files for `DodoSSH.Contracts` and `DodoSSH.Crypto` grew a `net10.0/win-x64` target as
|
||
well — and those two are built by the *server*. The API's Dockerfile restores them with no RID and
|
||
`--locked-mode`, so it failed:
|
||
|
||
```
|
||
error NU1004: The project's runtime identifiers have changed from.
|
||
Project's runtime identifiers: , lock file's runtime identifiers win-x64.
|
||
```
|
||
|
||
Packaging the desktop client had broken the server's image build, and nothing but the `image` job would
|
||
have caught it. Found by running `docker build` locally rather than by reading the lock files.
|
||
|
||
So the RID stays out of the committed state, and the two commands that need one — the release script's
|
||
publish and the `windows publish still resolves` step in `ci.yml` — pass `-p:RestoreLockedMode=false` for
|
||
themselves alone. That restore rewrites the lock files as a side effect, which does not matter on a runner
|
||
whose checkout is discarded and does matter on a developer's machine, so the release script runs
|
||
`git checkout -- '*packages.lock.json'` afterwards. `-p:RestorePackagesWithLockFile=false` is not an
|
||
alternative: it fails NU1005 whenever a lock file already exists.
|
||
|
||
**The Android head's lock file is outside the solution, so nothing checks it until the android job runs
|
||
— and the android job was broken for an unrelated reason for the whole of the release that went stale.**
|
||
`DodoSSH.Client.Android` is deliberately not in `DodoSSH.slnx` (it needs a workload the other two jobs
|
||
have no reason to install), so `dotnet restore DodoSSH.slnx --locked-mode` — the gate that keeps every
|
||
other lock file honest — has never seen it. Its only gate is the android job's own restore, and that job
|
||
could not reach the restore step at all while the runner had no JDK and no SDK.
|
||
|
||
The result: `MinVer` was added to `Directory.Build.props` for the desktop updater and reached fourteen
|
||
lock files. The fifteenth was not restorable on a runner, so it silently kept a graph from three releases
|
||
earlier, and the first thing the repaired job did was fail:
|
||
|
||
```
|
||
error NU1004: The project's runtime identifiers have changed from.
|
||
Project's runtime identifiers: android-arm64;android-x64, lock file's runtime identifiers android-arm64.
|
||
```
|
||
|
||
Two changes at once, which is why the message names both: the missing `MinVer`, and an ABI set that grew
|
||
when the `-r android-arm64` pin came off the packaging step. `--force-evaluate` on that project alone is
|
||
the fix, and — unlike the `win-x64` case above — it is safe to commit: the RIDs land in the Android
|
||
project's own lock file and the fourteen it references are rewritten byte-identically, because an
|
||
`android-*` RID is not a graph any of them has a package for. Check that with `git status` rather than
|
||
believing it; it is the same mechanism that broke the server's image build, and it happens to land
|
||
harmlessly here rather than by design.
|
||
|
||
The lasting hazard is the first paragraph and not the fix. Any change to a shared props file is a change
|
||
to a lock file this repository cannot verify from a machine without the Android workload, and it will go
|
||
on being noticed later than every other one.
|
||
|
||
**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 global properties, and MSBuild property
|
||
names are case-insensitive — so `ARG VERSION` in a build stage sets `Version` for every project built in
|
||
it, with no line anywhere saying so. The workflow passes `main-<short sha>` on a main build, which is a
|
||
fine docker tag and not a version, and the publish died with `NETSDK1018: Invalid NuGet version string`
|
||
pointing at `DodoSSH.Contracts` — a project nobody had touched. The build stage's argument is therefore
|
||
`ASSEMBLY_VERSION`, passed empty except on a tag build; the `VERSION` arg in the final stage is only ever
|
||
an OCI label and never meets MSBuild. Renaming is the entire fix, and the reason it is written down is that
|
||
the symptom names the wrong project and the cause is invisible.
|
||
|
||
**System.Text.Json's source generator does not honour property initializers on a record.** Defaults for a
|
||
`ClientSettings`-style record must live on the **constructor parameters**, not on property initializers,
|
||
and getting it wrong fails silently in the worst direction. The generator emits an
|
||
`ObjectWithParameterizedConstructorCreator` — it treats the init-only properties as constructor arguments
|
||
and builds `new ClientSettings() { A = (T)args[0], … }`, so the initializer runs and is then overwritten by
|
||
`args`, which for a member absent from the JSON is the CLR default. Measured: a `settings.json` of `{}`
|
||
read back `TerminalFontSize` 0 (clamped up to the 8px floor, not the 13px the renderer draws at) and, once
|
||
it existed, `AutomaticUpdateChecks` false. **Reflection-based deserialisation of the same JSON answers 13
|
||
and true**, which is what makes it so easy to miss — every way of checking it by hand is right except the
|
||
one that ships. `JsonSourceGenerationMode.Metadata` does not help; it was tried. It stayed invisible while
|
||
there was one setting, because that setting was written on every save and so was never absent; it went live
|
||
the moment a second one was added, since every existing profile lacks the new key.
|
||
`ASettingAbsentFromTheFile_ComesBackAsItsDeclaredDefault` fails without the fix.
|
||
|
||
**`[CallerFilePath]` is rewritten to `/_/...` under `ContinuousIntegrationBuild`.** Any test that
|
||
locates a fixture by source path passes locally and fails in CI. Copy fixtures to the output
|
||
directory and read them via `AppContext.BaseDirectory` instead; `GoldenVectorTests` shows the
|
||
pattern.
|
||
|
||
**Formatting fails the build rather than a separate step.** `IDE0055` is an error in
|
||
`.editorconfig` and `EnforceCodeStyleInBuild` is on, so `dotnet build` reports misformatted code
|
||
the way it reports a type error. CI used to run `dotnet format --verify-no-changes` as well; it
|
||
was removed for spending minutes to reach a verdict the build reaches anyway. `dotnet format` is
|
||
still how to *fix* what the build complains about — it just no longer gates anything itself.
|
||
|
||
## Deployment
|
||
|
||
**PostgreSQL 18 moved its data directory** to `/var/lib/postgresql`, not `/var/lib/postgresql/data`
|
||
as in 17 and earlier. A compose file carried over from an older version silently gets an empty
|
||
volume — the database appears to work and loses everything on restart. Relevant to any compose
|
||
file other than `deploy/docker-compose.dev.yml`, which is already correct.
|
||
|
||
**Keycloak in the dev stack listens on host port 18080, not 8080.** On this machine an unrelated
|
||
Apache Tomcat holds `127.0.0.1:8080`, and a loopback-specific bind wins over Docker's `0.0.0.0`
|
||
publish when resolving `localhost` — so every realm request returned 404 while the container
|
||
looked healthy. If discovery fails against a locally-published container, check for another
|
||
process bound specifically to loopback before suspecting the container.
|
||
|
||
**A path prefix in the server URL is silently discarded.** The client uses the typed address only as
|
||
`HttpClient.BaseAddress` and every request path is root-absolute (`/api/v1/meta`,
|
||
`/.well-known/dodossh-configuration`, …), so `https://example.test/dodossh` reaches
|
||
`https://example.test/api/v1/...` and the prefix is dropped without a word. That rules out hosting DodoSSH
|
||
under a sub-path — which is exactly what a reverse proxy in front of several services usually does. Nothing
|
||
trims or normalises the typed URL either, and it is the raw string, not the parsed form, that becomes the
|
||
local cache's identity. The server already publishes a canonical `apiBaseUrl` in its discovery document
|
||
that the client could normalise against and currently ignores.
|
||
|
||
**`Sync:CursorSigningKey` generates an ephemeral per-process key when unset.** Fine for a single
|
||
node; on a multi-node deployment cursors issued by one node are rejected by another, so clients
|
||
resync from the beginning repeatedly. Must be configured explicitly before running more than one
|
||
instance. `WarnOnRiskyConfiguration` logs this at startup.
|
||
|
||
**Rate limiting is not implemented yet** (M2). `POST /api/v1/me/enrollment` and the sync endpoints
|
||
are reachable by any authenticated caller at any rate. Enrollment requires a valid access token
|
||
and is idempotent, so the exposure is resource consumption rather than a credential-guessing
|
||
surface — but it is still an unmetered write path.
|
||
|
||
**`/api/v1/me` does not update `last_seen_at_utc`.** Deliberate: a GET that writes on every call is
|
||
a smell, and nothing depends on the value yet. Revisit when device management lands, since that is
|
||
the first feature that needs it.
|
||
|
||
**`ApplicationDisplayVersion` cannot be set from a target, so the Android head shipped `1.0.0`.**
|
||
`Xamarin.Android.Common.targets` reads it in a plain top-level `PropertyGroup` —
|
||
`<_AndroidVersionName>$(ApplicationDisplayVersion)</_AndroidVersionName>` — which is *evaluation*, not a
|
||
target. Every project property is already final before any target runs, so the MinVer-derived value set in
|
||
`UseTheDerivedVersionForAndroid` was assigned after the only thing that reads it had finished. MinVer cannot
|
||
run at evaluation time, so no arrangement of the public property works. The tell is that
|
||
`-getProperty:ApplicationDisplayVersion` answers correctly while `aapt2 dump badging` on the packaged APK
|
||
says `versionName='1.0.0'` — measured, and the reason a `-getProperty` check cannot catch this class of bug.
|
||
The fix is to assign `_AndroidVersionName` from a target hooked `BeforeTargets="_GenerateJavaStubs"`; an
|
||
internal name, taken deliberately over passing `-p:ApplicationDisplayVersion` from every caller and leaving
|
||
an ordinary `dotnet build` lying about its version.
|
||
|
||
**Nothing found so far varies the Android launcher name per build.** Four mechanisms were tried and all
|
||
four produce the same label. `AndroidManifestPlaceholders` is wired to the manifest task and does not reach
|
||
`android:label` — measured on a build whose placeholder property evaluated to `appLabel=DodoSSH nightly`
|
||
and whose APK reported `DodoSSH`. `ApplicationTitle`, the documented property, feeds an `ApplicationLabel`
|
||
task parameter that the label already on the application element wins against. A second resource directory
|
||
under `Resources/` is picked up by the SDK's own glob as a *qualifier* and fails the build outright with
|
||
`APT2142: invalid configuration 'nightly'`. And an `AndroidResource` `Remove`/`Include` swap does nothing
|
||
from the project body — the SDK's glob is added by `Sdk.targets`, imported below it, so the removal runs
|
||
before the item exists — while the same swap inside a target that runs before `UpdateAndroidResources` also
|
||
had no effect. Underneath all of it: the launcher shows the *activity's* label, and that one is a string in
|
||
a C# attribute. The two channels ADR 0014 defines therefore share a launcher name, and are told apart by
|
||
the package name in Android's app info, by the version, and by the channel the application names on its own
|
||
preferences screen.
|