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:
2026-07-29 11:02:19 +02:00
parent 8d2416a602
commit 49f617b450
33 changed files with 5405 additions and 210 deletions
@@ -0,0 +1,550 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App.ViewModels;
/// <summary>One host, as a row in the list.</summary>
/// <remarks>
/// Carries the decrypted <see cref="HostSecret"/> so opening the editor needs no second decryption, and
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
/// an item a newer client wrote that must not be re-encoded here.
/// </remarks>
internal sealed class HostRowViewModel(VaultHost host)
{
internal Guid EntityId => host.EntityId;
internal HostSecret Host => host.Host;
internal string Label => host.Host.Label;
internal string Address => string.Create(
CultureInfo.InvariantCulture,
$"{host.Host.Username ?? ""}@{host.Host.Hostname}:{host.Host.Port}");
internal bool HasUnsyncedChanges => host.HasUnsyncedChanges;
internal bool IsBlocked => host.IsBlocked;
internal bool IsReadOnly => host.IsReadOnly;
/// <summary>A short marker for the row, so the list says what it knows without a tooltip.</summary>
internal string Badge => host switch
{
{ IsBlocked: true } => "rejected",
{ IsReadOnly: true } => "newer version",
{ HasUnsyncedChanges: true } => "not synced",
_ => string.Empty,
};
}
/// <summary>A conflict, as a row.</summary>
internal sealed class ConflictRowViewModel(ConflictNotice notice)
{
internal Guid Id => notice.Id;
internal string Summary => notice.Summary;
/// <summary>
/// The overridden values, one line each.
/// </summary>
/// <remarks>
/// This is the whole justification for resolving a conflict automatically. If these were not shown,
/// the merge would be last-writer-wins with a longer explanation.
/// </remarks>
internal string Detail => notice.Fields.Count == 0
? string.Empty
: string.Join(
Environment.NewLine,
// Not named 'field': C# 14 made that a contextual keyword inside a property accessor, and
// this whole expression is one.
notice.Fields.Select(entry => entry.DiscardedWasRemoval
? $"{entry.Field}: a removal was overridden; '{entry.Kept}' was kept"
: $"{entry.Field}: kept '{entry.Kept}', discarded '{entry.Discarded}'"));
internal bool HasDetail => notice.Fields.Count > 0;
}
/// <summary>
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
/// </summary>
/// <remarks>
/// <para>
/// The list is the local mirror with unpushed changes laid over it, so an edit appears immediately and a
/// delete disappears immediately whether or not the network is there. Syncing is a separate, explicit
/// action; nothing here blocks on a server.
/// </para>
/// <para>
/// <b>Credentials are not in the vault yet.</b> <c>SyncEntityType.Credential</c> exists in the contract
/// but is not synced, so connecting still asks for a password each time. That is a real M1 limitation
/// rather than a design choice, and the interface says so rather than implying the vault holds more than
/// it does.
/// </para>
/// </remarks>
internal sealed partial class VaultViewModel(
VaultSession session,
TerminalWorkspace workspace,
IKnownHostStore knownHosts,
Func<IVaultServer?> connection) : ObservableObject, IAsyncDisposable
{
private bool disposed;
/// <summary>The hosts to show, unpushed local state included.</summary>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
/// <summary>Whatever the merge had to override and the user has not acknowledged.</summary>
internal ObservableCollection<ConflictRowViewModel> Conflicts { get; } = [];
internal string VaultName =>
session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Vault";
[ObservableProperty]
private HostRowViewModel? selectedHost;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private int pendingChanges;
[ObservableProperty]
private int unreadableItems;
[ObservableProperty]
private bool isBusy;
// ---- The editor ----
[ObservableProperty]
private bool isEditing;
[ObservableProperty]
private string editorLabel = string.Empty;
[ObservableProperty]
private string editorHostname = string.Empty;
[ObservableProperty]
private int editorPort = HostSecret.DefaultPort;
[ObservableProperty]
private string editorUsername = string.Empty;
[ObservableProperty]
private string editorNotes = string.Empty;
[ObservableProperty]
private bool editorRelayEnabled;
/// <summary>The item being edited, or null when creating.</summary>
private Guid? editingEntityId;
// ---- Connecting ----
/// <remarks>
/// Typed per connection because credentials are not a synced entity type yet. Never persisted.
/// </remarks>
[ObservableProperty]
private string connectPassword = string.Empty;
[ObservableProperty]
private HostKeyPresentation? pendingHostKey;
[ObservableProperty]
private string? hostKeyMismatch;
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
internal bool HasConflicts => Conflicts.Count > 0;
/// <summary>Reads the vault into the list.</summary>
internal async Task LoadAsync(CancellationToken cancellationToken)
{
var listing = await session.Hosts
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
var selectedId = SelectedHost?.EntityId;
Hosts.Clear();
foreach (var host in listing.Hosts.OrderBy(host => host.Host.Label, StringComparer.CurrentCulture))
{
Hosts.Add(new HostRowViewModel(host));
}
// Selection survives a reload. Losing it on every sync would move the terminal's target out from
// under the user.
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
UnreadableItems = listing.Unreadable;
PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true);
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
Status = Hosts.Count == 0
? "No hosts yet. Add one."
: $"{Hosts.Count} host(s) in {VaultName}.";
}
/// <summary>Runs a synchronisation pass, if there is a server to talk to.</summary>
[RelayCommand]
private async Task SyncAsync(CancellationToken cancellationToken)
{
if (connection() is not { } server)
{
Status = "Offline. Changes are queued and will be sent after you sign in.";
return;
}
await RunAsync(
"Synchronising…",
async () =>
{
var report = await session.SyncAsync(server.Sync, cancellationToken).ConfigureAwait(true);
await LoadAsync(cancellationToken).ConfigureAwait(true);
Status = Describe(report);
}).ConfigureAwait(true);
}
/// <summary>Starts a new host.</summary>
[RelayCommand]
private void NewHost()
{
editingEntityId = null;
EditorLabel = string.Empty;
EditorHostname = string.Empty;
EditorPort = HostSecret.DefaultPort;
EditorUsername = string.Empty;
EditorNotes = string.Empty;
EditorRelayEnabled = false;
IsEditing = true;
Status = "Adding a host.";
}
/// <summary>Opens the selected host for editing.</summary>
[RelayCommand]
private void EditSelectedHost()
{
if (SelectedHost is not { } row)
{
return;
}
if (row.IsReadOnly)
{
// Re-encoding would drop fields this build has no concept of, so the honest answer is to
// refuse rather than to silently lose a colleague's data.
Status = "This host was written by a newer version of DodoSSH. Update before editing it.";
return;
}
editingEntityId = row.EntityId;
EditorLabel = row.Host.Label;
EditorHostname = row.Host.Hostname;
EditorPort = row.Host.Port;
EditorUsername = row.Host.Username ?? string.Empty;
EditorNotes = row.Host.Notes ?? string.Empty;
EditorRelayEnabled = row.Host.RelayEnabled;
IsEditing = true;
Status = $"Editing {row.Label}.";
}
/// <summary>Abandons the editor.</summary>
[RelayCommand]
private void CancelEdit()
{
IsEditing = false;
editingEntityId = null;
Status = string.Empty;
}
/// <summary>Stores the editor's contents, encrypted, and queues it for the server.</summary>
[RelayCommand]
private async Task SaveHostAsync(CancellationToken cancellationToken)
{
var host = BuildHost();
if (!host.TryValidate(out var error))
{
Status = error;
return;
}
await RunAsync(
"Saving…",
async () =>
{
if (editingEntityId is { } entityId)
{
await session.Hosts
.UpdateAsync(session.ActiveVaultId, entityId, host, cancellationToken)
.ConfigureAwait(true);
}
else
{
editingEntityId = await session.Hosts
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
.ConfigureAwait(true);
}
IsEditing = false;
await LoadAsync(cancellationToken).ConfigureAwait(true);
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
editingEntityId = null;
Status = $"Saved '{host.Label}'. It will sync when you are online.";
}).ConfigureAwait(true);
}
/// <summary>Queues a tombstone for the selected host.</summary>
[RelayCommand]
private async Task DeleteHostAsync(CancellationToken cancellationToken)
{
if (SelectedHost is not { } row)
{
return;
}
await RunAsync(
"Deleting…",
async () =>
{
await session.Hosts
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
await LoadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Deleted '{row.Label}'.";
}).ConfigureAwait(true);
}
/// <summary>Opens a terminal on the selected host.</summary>
[RelayCommand]
private async Task ConnectAsync(CancellationToken cancellationToken)
{
if (SelectedHost is not { } row)
{
Status = "Choose a host first.";
return;
}
if (string.IsNullOrEmpty(row.Host.Username))
{
Status = "This host has no username. Edit it and add one.";
return;
}
PendingHostKey = null;
HostKeyMismatch = null;
await RunAsync(
$"Connecting to {row.Label}…",
() => OpenSessionAsync(row, cancellationToken)).ConfigureAwait(true);
}
/// <summary>Pins the offered host key and retries.</summary>
[RelayCommand]
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
{
if (PendingHostKey is not { } presentation)
{
return;
}
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
PendingHostKey = null;
await ConnectAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
[RelayCommand]
private void RejectHostKey()
{
PendingHostKey = null;
Status = "The host key was not trusted, so nothing was connected.";
}
/// <summary>
/// Marks every shown conflict as seen.
/// </summary>
/// <remarks>
/// Acknowledged rather than deleted, so the discarded values stay retrievable afterwards. Someone who
/// dismisses this and realises a minute later that they wanted the other value should still be able to
/// get it.
/// </remarks>
[RelayCommand]
private async Task AcknowledgeAllConflictsAsync(CancellationToken cancellationToken)
{
foreach (var conflict in Conflicts.ToArray())
{
await session.AcknowledgeConflictAsync(conflict.Id, cancellationToken).ConfigureAwait(true);
}
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
await session.DisposeAsync().ConfigureAwait(false);
}
/// <remarks>
/// The renderer has to be attached before a session opens: the transport drops frames when nothing is
/// connected, so a session opened earlier would lose its <c>SessionOpened</c> frame and then stream
/// output at a terminal that was never created.
/// </remarks>
private async Task OpenSessionAsync(HostRowViewModel row, CancellationToken cancellationToken)
{
try
{
await workspace.WaitForRendererAsync().ConfigureAwait(true);
var request = new SshConnectionRequest(
row.Host.Hostname,
row.Host.Port,
row.Host.Username!,
new SshPasswordCredential(ConnectPassword));
await workspace
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
.ConfigureAwait(true);
Status = $"Connected to {row.Label}.";
}
catch (SshHostKeyUnknownException exception)
{
// First contact. The user has to decide, and they need the fingerprint to do it.
PendingHostKey = exception.Presentation;
Status = "This host has not been seen before.";
}
catch (SshHostKeyMismatchException exception)
{
HostKeyMismatch = exception.Message;
Status = "The host key has changed. The connection was refused.";
}
}
private HostSecret BuildHost() =>
new()
{
Label = EditorLabel.Trim(),
Hostname = EditorHostname.Trim(),
Port = EditorPort,
Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(),
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
RelayEnabled = EditorRelayEnabled,
};
private async Task LoadConflictsAsync(CancellationToken cancellationToken)
{
var notices = await session.ReadConflictsAsync(cancellationToken).ConfigureAwait(true);
Conflicts.Clear();
foreach (var notice in notices)
{
Conflicts.Add(new ConflictRowViewModel(notice));
}
OnPropertyChanged(nameof(HasConflicts));
}
/// <remarks>
/// Deliberately reports the things a user has to act on rather than a count of successes. A pass that
/// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point
/// of recording those is that somebody sees them.
/// </remarks>
private static string Describe(SyncReport report)
{
if (!report.NeedsAttention)
{
return report.Pulled == 0 && report.Pushed == 0
? "Already up to date."
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.";
}
var notes = new List<string>();
if (report.Resurrected > 0)
{
notes.Add($"{report.Resurrected} host(s) deleted elsewhere were kept under a new name");
}
if (report.DeletesAbandoned > 0)
{
notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit");
}
if (report.Parked > 0)
{
notes.Add($"{report.Parked} change(s) were refused and need attention");
}
if (report.Unreadable > 0)
{
notes.Add($"{report.Unreadable} item(s) could not be decrypted");
}
if (report.RekeyRequired)
{
notes.Add("this vault was rekeyed and your access needs re-issuing");
}
return "Synchronised, but: " + string.Join("; ", notes) + ".";
}
private async Task RunAsync(string busyMessage, Func<Task> work)
{
if (IsBusy)
{
return;
}
IsBusy = true;
Status = busyMessage;
try
{
await work().ConfigureAwait(true);
}
catch (OperationCanceledException)
{
Status = "Cancelled.";
}
catch (Exception exception)
{
Status = exception.Message;
}
finally
{
IsBusy = false;
}
}
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
OnPropertyChanged(nameof(HasPendingHostKey));
partial void OnHostKeyMismatchChanged(string? value) =>
OnPropertyChanged(nameof(HasHostKeyMismatch));
}