Public Access
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.
This commit is contained in:
+30
-8
@@ -105,14 +105,18 @@ so a platform-specific opener can be substituted without touching the flow.
|
||||
|
||||
## Local cache
|
||||
|
||||
**The cache file has no location yet.** `ClientCacheFactory.ForFile` takes a full path and the
|
||||
application does not yet choose one, because nothing wires the cache into the shell so far. When it
|
||||
does, the path must be per-OS — `%LOCALAPPDATA%` on Windows, `~/Library/Application Support` on macOS,
|
||||
`$XDG_DATA_HOME` or `~/.local/share` on Linux — and it must **not** land in a directory that syncs to
|
||||
a cloud drive. 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. `Environment.SpecialFolder.LocalApplicationData`
|
||||
maps correctly on all three, but on Linux it ignores `XDG_DATA_HOME` and returns `~/.local/share`
|
||||
unconditionally. *Unverified off Windows.*
|
||||
**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
|
||||
@@ -120,12 +124,30 @@ throws at execution rather than at model build. `UnixMillisecondsConverter` is a
|
||||
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.
|
||||
|
||||
**A `NativeWebView` that is never laid out is never realised.** The shell covers the terminal with its
|
||||
setup and unlock screens rather than collapsing it with `IsVisible`, because the control hosts a real
|
||||
child window and hiding it would leave the terminal blank on the first connection after unlocking.
|
||||
Verified on Windows: with the unlock overlay showing, `msedgewebview2` still had an established
|
||||
connection to the data plane port, so the page had loaded and completed its WebSocket handshake.
|
||||
|
||||
## Build and CI
|
||||
|
||||
**Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI.
|
||||
|
||||
Reference in New Issue
Block a user