using System.Collections.ObjectModel;
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Api;
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;
/// One host, as a row in the list.
///
/// Carries the decrypted 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.
///
internal sealed class HostRowViewModel(VaultItem host)
{
internal Guid EntityId => host.EntityId;
internal HostSecret Host => host.Secret;
internal string Label => host.Secret.Label;
internal string Address => string.Create(
CultureInfo.InvariantCulture,
$"{host.Secret.Username ?? "—"}@{host.Secret.Hostname}:{host.Secret.Port}");
internal bool HasUnsyncedChanges => host.HasUnsyncedChanges;
internal bool IsBlocked => host.IsBlocked;
internal bool IsReadOnly => host.IsReadOnly;
/// How this host authenticates, in one word.
///
/// Worth a word in the list because the two behave differently at the moment of connecting: one needs
/// the password box filled in and the other does not, and a user staring at an empty password box on a
/// key-authenticated host has no other way to know it is not needed.
///
internal string Authentication => host.Secret.SshKeyId is null ? "password" : "key";
/// A short marker for the row, so the list says what it knows without a tooltip.
internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges);
}
/// An entry in the host editor's key picker.
/// The key's item id, or null for password authentication.
/// What to show.
///
/// A sentinel entry rather than a nullable selection, because a ComboBox with nothing selected and a
/// ComboBox meaning "no key" look identical and are not the same thing — the first is a host whose binding
/// has not been decided, the second is a decision.
///
internal sealed record SshKeyChoice(Guid? EntityId, string Label)
{
/// The "use a password" entry, always first.
internal static SshKeyChoice None { get; } = new(null, "Password (no key)");
///
/// A stand-in for a key the host names and the vault no longer has.
///
///
/// Kept in the list, and kept selected, so that opening a host to change its port does not silently
/// convert it to password authentication on save. The id is preserved; only the label admits the
/// problem.
///
internal static SshKeyChoice Missing(Guid entityId) => new(entityId, "(a key that is no longer here)");
}
/// One SSH key, as a row in the list.
///
///
/// Carries the decrypted , as the host row carries its host, so opening the
/// editor or connecting with the key needs no second decryption.
///
///
/// Nothing here exposes the private key to the view. is what the editor and the
/// connect path read, and the members the XAML binds are the label, a description and a badge. That is not
/// a security boundary — the same object holds the material either way — but it does mean no template,
/// tooltip or accessibility surface can end up rendering a private key by being pointed at the obvious
/// property.
///
///
internal sealed class SshKeyRowViewModel(VaultItem key)
{
internal Guid EntityId => key.EntityId;
internal SshKeySecret Key => key.Secret;
internal string Label => key.Secret.Label;
/// What the list shows under the name: what is known about the key, never the key.
internal string Description => key.Secret switch
{
{ Passphrase: not null, PublicKey: not null } => "passphrase · public half stored",
{ Passphrase: not null } => "passphrase · no public half",
{ PublicKey: not null } => "no passphrase · public half stored",
_ => "no passphrase · no public half",
};
internal bool HasUnsyncedChanges => key.HasUnsyncedChanges;
internal bool IsBlocked => key.IsBlocked;
internal bool IsReadOnly => key.IsReadOnly;
internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges);
}
/// The one-word marker a row shows for its sync state.
///
/// Shared by both row types rather than written twice, because the three states mean the same thing for
/// every item type and a list where one kind said "not synced" and the other "unsynced" would read as two
/// different conditions.
///
internal static class ItemBadge
{
internal static string For(bool isBlocked, bool isReadOnly, bool hasUnsyncedChanges) =>
(isBlocked, isReadOnly, hasUnsyncedChanges) switch
{
(true, _, _) => "rejected",
(_, true, _) => "newer version",
(_, _, true) => "not synced",
_ => string.Empty,
};
}
/// A conflict, as a row.
internal sealed class ConflictRowViewModel(ConflictNotice notice)
{
internal Guid Id => notice.Id;
internal string Summary => notice.Summary;
///
/// The overridden values, one line each.
///
///
/// 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.
///
// The lambda parameter is 'entry' rather than the obvious 'field': C# 14 made that a contextual
// keyword inside a property accessor, and this whole expression is one.
internal string Detail => notice.Fields.Count == 0
? string.Empty
: string.Join(
Environment.NewLine,
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;
}
///
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
///
///
///
/// 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. Nothing here waits on a server to
/// show a change.
///
///
/// Syncing then happens on its own: once when the vault opens, straight after any local change, and on a
/// timer while it stays open. The Sync button remains, because a person who has just been handed a
/// credential wants to know now rather than within the minute — but nothing depends on it being pressed.
/// A background pass is deliberately quieter than the button: see .
///
///
/// Keys and host key trust are in the vault; passwords are not yet. An SSH key is a synced item, so
/// it is stored once and available on every machine, and so is a known host key — approving a fingerprint
/// here approves it on every device and survives a restart. Credentials are synced as well, but nothing in
/// this interface can create one, so password authentication still asks 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.
///
///
/// A key belongs to a host. Each host names the key it authenticates with, or none, and that choice
/// is a field in its encrypted payload — so it follows the host to every machine rather than being made
/// again per connection. The cost is a payload schema version, paid only by hosts that actually bind a key:
/// see HostSecretCodec.CurrentSchemaVersion.
///
///
internal sealed partial class VaultViewModel(
VaultSession session,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
Func connection) : ObservableObject, IAsyncDisposable
{
///
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
/// server almost nothing; the number that matters is how stale a teammate's change may look, and a
/// minute is short enough not to be noticed. Anything much shorter would be polling for its own sake,
/// and a change made on this machine does not wait for the timer anyway — saving pushes immediately.
///
private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1);
/// Serialises every synchronisation pass, whether a button pressed it or a timer did.
private readonly SemaphoreSlim syncGate = new(1, 1);
private CancellationTokenSource? autoSync;
private Task? autoSyncLoop;
private bool disposed;
/// The hosts to show, unpushed local state included.
internal ObservableCollection Hosts { get; } = [];
/// The SSH keys to show, unpushed local state included.
internal ObservableCollection Keys { get; } = [];
/// Whatever the merge had to override and the user has not acknowledged.
internal ObservableCollection Conflicts { get; } = [];
internal string VaultName =>
session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Vault";
[ObservableProperty]
private HostRowViewModel? selectedHost;
[ObservableProperty]
private SshKeyRowViewModel? selectedKey;
[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;
///
/// What the key picker offers: password, then every key in the vault.
///
///
/// Rebuilt when the editor opens rather than kept in step with the key list. A background sync could
/// pull a new key while a host is being edited, and having the picker's contents change under the user
/// mid-edit is worse than the list being a minute stale — the two editors cannot be open at once, so
/// the only way to add a key is to close this one anyway.
///
internal ObservableCollection EditorKeyChoices { get; } = [];
[ObservableProperty]
private SshKeyChoice? editorSelectedKey;
/// The item being edited, or null when creating.
private Guid? editingEntityId;
///
/// Whether the editor is showing a host that could have a pinned key to forget.
///
///
/// Read by the editor to hide the button while a host is being created, where there is nothing to
/// withdraw yet. It does not claim a pin exists — answering that would mean a second question to the
/// known-host store for a button's visibility, and the command already says plainly when there was
/// nothing to forget.
///
internal bool CanForgetHostKey => IsEditing && editingEntityId is not null;
// ---- The key editor ----
// A second set of editor state rather than a shared one. The two editors hold unrelated fields, and
// sharing them would mean a half-typed host reappearing inside a key editor.
[ObservableProperty]
private bool isEditingKey;
[ObservableProperty]
private string keyEditorLabel = string.Empty;
///
/// Bound to a text box the user pastes a private key into, so this holds key material for as long as
/// the editor is open, and clears it. Neither that nor anything else here
/// can wipe it — see SshKeySecret, which explains why a .NET string is the honest choice for
/// this and what it does not buy.
///
[ObservableProperty]
private string keyEditorPrivateKey = string.Empty;
[ObservableProperty]
private string keyEditorPassphrase = string.Empty;
[ObservableProperty]
private string keyEditorPublicKey = string.Empty;
[ObservableProperty]
private string keyEditorNotes = string.Empty;
/// The key being edited, or null when creating.
private Guid? editingKeyId;
// ---- Connecting ----
///
/// Typed per connection because nothing in this interface can create a vault credential yet — not because
/// the vault cannot hold one. Never persisted.
///
[ObservableProperty]
private string connectPassword = string.Empty;
/// Whether the selected host authenticates with a key, so the password box can say so.
internal bool SelectedHostUsesAKey => SelectedHost?.Host.SshKeyId is not null;
[ObservableProperty]
private HostKeyPresentation? pendingHostKey;
[ObservableProperty]
private string? hostKeyMismatch;
///
/// Raised once a terminal session is open and its renderer has it.
///
///
/// An event rather than a property because handing the terminal the keyboard is something that
/// happens, not something that is true: connecting a second host while one is already open has to
/// move focus again, and no state change describes that. Raised on the UI thread — every await on
/// the path from the command to here uses ConfigureAwait(true) — so a handler may touch
/// controls directly.
///
internal event EventHandler? SessionOpened;
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
internal bool HasConflicts => Conflicts.Count > 0;
/// Reads the vault into the list and says what is in it.
internal async Task LoadAsync(CancellationToken cancellationToken)
{
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = (Hosts.Count, Keys.Count) switch
{
(0, 0) => "No hosts yet. Add one.",
(0, var keys) => $"No hosts yet, and {keys} key(s) in {VaultName}.",
(var hosts, 0) => $"{hosts} host(s) in {VaultName}.",
var (hosts, keys) => $"{hosts} host(s) and {keys} key(s) in {VaultName}.",
};
}
///
/// Reads the vault into the list, silently.
///
///
/// Separate from because every caller except the first load has something
/// better to say afterwards — a save, a deletion, or a sync report — and a background pass has nothing
/// to say at all. Rebuilding the list used to repaint the status line unconditionally, which made
/// "the background pass is quiet" false on the one path that mattered.
///
private async Task ReloadAsync(CancellationToken cancellationToken)
{
var unreadable = await ReloadHostsAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
UnreadableItems = unreadable;
PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true);
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
}
/// How many hosts would not decrypt.
private async Task ReloadHostsAsync(CancellationToken cancellationToken)
{
var listing = await session.Hosts
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
var selectedId = SelectedHost?.EntityId;
Hosts.Clear();
foreach (var host in listing.Items.OrderBy(host => host.Secret.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();
return listing.Unreadable;
}
/// How many keys would not decrypt.
///
/// Unlike the host list, the selection is not defaulted to the first row. A key selection is
/// what authenticates with, and quietly selecting one on load would
/// mean a connection made with a key the user never chose.
///
private async Task ReloadKeysAsync(CancellationToken cancellationToken)
{
var listing = await session.SshKeys
.ListAsync(session.ActiveVaultId, cancellationToken)
.ConfigureAwait(true);
var selectedId = SelectedKey?.EntityId;
Keys.Clear();
foreach (var key in listing.Items.OrderBy(key => key.Secret.Label, StringComparer.CurrentCulture))
{
Keys.Add(new SshKeyRowViewModel(key));
}
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId);
return listing.Unreadable;
}
/// Runs a synchronisation pass, if there is a server to talk to.
[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 SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
// Null means a background pass held the gate. Saying so beats reporting a sync that this
// press did not perform.
Status = report is null
? "A synchronisation is already running."
: Describe(report);
}).ConfigureAwait(true);
}
///
/// Starts syncing in the background until the vault is disposed.
///
///
/// Explicit rather than started from the constructor, so that a test can drive
/// a pass at a time instead of racing a timer.
///
internal void StartAutoSync()
{
if (autoSync is not null)
{
return;
}
autoSync = new CancellationTokenSource();
autoSyncLoop = RunAutoSyncLoopAsync(autoSync.Token);
}
///
/// One background synchronisation pass, which stays out of the way.
///
///
///
/// Deliberately not routed through . That would raise the busy flag every
/// interval — disabling Connect and Save for the duration — and repaint the status line with
/// "Synchronising…" while the user was reading something else. A background pass that makes the
/// application feel intermittently broken is worse than a Sync button.
///
///
/// So it is silent unless it has something to say: the status line changes only when the pass actually
/// moved an item or produced something needing attention. It also yields to the user — a pass is
/// skipped outright while a command is running, rather than queueing behind it.
///
///
internal async Task AutoSyncAsync(CancellationToken cancellationToken)
{
if (IsBusy || connection() is not { } server)
{
return;
}
try
{
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
if (report is not null && (report.Pulled > 0 || report.Pushed > 0 || report.NeedsAttention))
{
Status = Describe(report);
}
}
catch (OperationCanceledException)
{
// Locking, or closing.
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Swallowed on purpose, and this is the one place in the view model where that is right: a
// laptop that has been closed all afternoon would otherwise replace whatever the user was
// reading with a socket error once a minute. The failure is not hidden — the account bar
// already shows when there is no connection, and pressing Sync reports the real reason.
}
}
///
/// The gate is shared with the manual command, so a press and a tick can never overlap. Taken with a
/// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by
/// waiting for it, and queueing them would turn a slow server into a backlog of identical work.
///
private async Task SyncOnceAsync(ISyncApi api, CancellationToken cancellationToken)
{
if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true))
{
return null;
}
try
{
var report = await session.SyncAsync(api, cancellationToken).ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
// snapshot rather than reading per lookup — so a pass that pulled a pin has to hand it over here,
// or a host a colleague approved stays a first-contact prompt until the next unlock.
await knownHosts.RefreshAsync(cancellationToken).ConfigureAwait(true);
return report;
}
finally
{
syncGate.Release();
}
}
///
/// ConfigureAwait(true) throughout, and that is load-bearing rather than habit: the loop is
/// started from the UI thread, so every continuation returns to it and the observable collections
/// rebuilds are still only ever touched from one thread. A
/// ConfigureAwait(false) here would mutate them from a timer thread, which Avalonia will
/// eventually notice in a way that looks like a rendering bug.
///
private async Task RunAutoSyncLoopAsync(CancellationToken cancellationToken)
{
using var timer = new PeriodicTimer(AutoSyncInterval);
try
{
// A pass on open, before the first tick. A vault edited on another machine should be current by
// the time the user has finished reading the list, not a minute afterwards.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
{
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
}
catch (OperationCanceledException)
{
// Locking, or closing.
}
}
/// Starts a new host.
[RelayCommand]
private void NewHost()
{
if (KeyEditorIsInTheWay())
{
return;
}
editingEntityId = null;
EditorLabel = string.Empty;
EditorHostname = string.Empty;
EditorPort = HostSecret.DefaultPort;
EditorUsername = string.Empty;
EditorNotes = string.Empty;
EditorRelayEnabled = false;
BuildKeyChoices(boundKeyId: null);
IsEditing = true;
Status = "Adding a host.";
}
/// Opens the selected host for editing.
[RelayCommand]
private void EditSelectedHost()
{
if (SelectedHost is not { } row || KeyEditorIsInTheWay())
{
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;
BuildKeyChoices(row.Host.SshKeyId);
IsEditing = true;
Status = $"Editing {row.Label}.";
}
/// Abandons the editor.
[RelayCommand]
private void CancelEdit()
{
IsEditing = false;
editingEntityId = null;
Status = string.Empty;
}
/// Stores the editor's contents, encrypted, and queues it for the server.
[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 ReloadAsync(cancellationToken).ConfigureAwait(true);
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
editingEntityId = null;
Status = connection() is null
? $"Saved '{host.Label}'. It will sync when you are online."
: $"Saved '{host.Label}'.";
}).ConfigureAwait(true);
// Pushed now rather than at the next tick. A change the user just made is the one they are most
// likely to be about to look for on another machine.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// Queues a tombstone for the selected host.
[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 ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Deleted '{row.Label}'.";
}).ConfigureAwait(true);
// As with saving: a tombstone is worth pushing straight away, so the item does not reappear on
// another machine that syncs before the next tick.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// Starts a new SSH key.
[RelayCommand]
private void NewKey()
{
if (HostEditorIsInTheWay())
{
return;
}
editingKeyId = null;
ClearKeyEditor();
IsEditingKey = true;
Status = "Adding an SSH key.";
}
/// Opens the selected key for editing.
///
/// The private key is loaded into the editor, which is the only way an edit can preserve it: the
/// codec has no notion of a partial update, so saving re-encodes every field.
///
[RelayCommand]
private void EditSelectedKey()
{
if (SelectedKey is not { } row || HostEditorIsInTheWay())
{
return;
}
if (row.IsReadOnly)
{
Status = "This key was written by a newer version of DodoSSH. Update before editing it.";
return;
}
editingKeyId = row.EntityId;
KeyEditorLabel = row.Key.Label;
KeyEditorPrivateKey = row.Key.PrivateKeyPem;
KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty;
KeyEditorPublicKey = row.Key.PublicKey ?? string.Empty;
KeyEditorNotes = row.Key.Notes ?? string.Empty;
IsEditingKey = true;
Status = $"Editing {row.Label}.";
}
/// Abandons the key editor, clearing the material out of it.
[RelayCommand]
private void CancelKeyEdit()
{
IsEditingKey = false;
editingKeyId = null;
ClearKeyEditor();
Status = string.Empty;
}
/// Stores the key editor's contents, encrypted, and queues it for the server.
[RelayCommand]
private async Task SaveKeyAsync(CancellationToken cancellationToken)
{
var key = BuildKey();
if (!key.TryValidate(out var reason))
{
Status = reason;
return;
}
await RunAsync(
"Saving…",
async () =>
{
if (editingKeyId is { } entityId)
{
await session.SshKeys
.UpdateAsync(session.ActiveVaultId, entityId, key, cancellationToken)
.ConfigureAwait(true);
}
else
{
editingKeyId = await session.SshKeys
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
.ConfigureAwait(true);
}
IsEditingKey = false;
ClearKeyEditor();
await ReloadAsync(cancellationToken).ConfigureAwait(true);
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == editingKeyId);
editingKeyId = null;
Status = connection() is null
? $"Saved '{key.Label}'. It will sync when you are online."
: $"Saved '{key.Label}'.";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// Queues a tombstone for the selected key.
[RelayCommand]
private async Task DeleteKeyAsync(CancellationToken cancellationToken)
{
if (SelectedKey is not { } row)
{
return;
}
await RunAsync(
"Deleting…",
async () =>
{
await session.SshKeys
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Deleted '{row.Label}'.";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// Opens a terminal on the selected host.
[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;
}
if (TryBuildCredential(row.Host) is not { } credential)
{
// Refused rather than quietly falling back to the password box. A host set up for key-only
// access that silently starts offering a password is the failure worth ruling out — the user
// asked for one thing and got another, and the host is the last place that would say so.
Status = $"'{row.Label}' authenticates with an SSH key that is not in this vault any more. "
+ "Edit the host to choose another key, or set it back to a password.";
return;
}
PendingHostKey = null;
HostKeyMismatch = null;
await RunAsync(
$"Connecting to {row.Label}…",
() => OpenSessionAsync(row, credential, cancellationToken)).ConfigureAwait(true);
}
///
/// Pins the offered host key and retries.
///
///
/// The pin goes into the vault, so this writes to the local cache and queues a change for every other
/// machine — which is why the write is guarded and the connection is only retried once it has landed.
/// It used to be a dictionary insert that could not fail; reporting a failed write as a failed
/// connection would send the user looking at the host.
///
[RelayCommand]
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
{
if (PendingHostKey is not { } presentation)
{
return;
}
try
{
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
}
catch (OperationCanceledException)
{
Status = "Cancelled.";
return;
}
catch (Exception exception)
{
Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
return;
}
PendingHostKey = null;
await ConnectAsync(cancellationToken).ConfigureAwait(true);
// After connecting, not before. A pin is worth pushing straight away — the same host on another
// machine should not ask again — but not at the cost of delaying the connection the user asked for.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// Dismisses the trust prompt without pinning anything.
[RelayCommand]
private void RejectHostKey()
{
PendingHostKey = null;
Status = "The host key was not trusted, so nothing was connected.";
}
///
/// Withdraws trust from every key pinned for the host being edited.
///
///
///
/// The counterpart to trust that now outlives the process, and the reason it exists at all: a mismatch is
/// a hard refusal with no way past it, so a server that is legitimately rebuilt would be unreachable for
/// ever without this. It is deliberately here — in the host's editor, reached by choosing to edit
/// a host — and not on the refusal itself. A "forget this key" button next to the warning is the same
/// button as "continue anyway" with two clicks instead of one.
///
///
/// Applies to the host's saved address rather than whatever the editor's boxes currently hold.
/// The pin belongs to the endpoint that was actually dialled, and someone halfway through retyping a
/// hostname has not moved it yet.
///
///
[RelayCommand]
private async Task ForgetHostKeyAsync(CancellationToken cancellationToken)
{
if (editingEntityId is not { } entityId
|| Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
{
return;
}
var address = row.Host.Hostname;
var port = row.Host.Port;
await RunAsync(
$"Forgetting the pinned host key for {address}…",
async () =>
{
var forgotten = await knownHosts
.ForgetAsync(address, port, cancellationToken)
.ConfigureAwait(true);
// The refusal that sent the user here is about a pin that no longer exists.
HostKeyMismatch = null;
PendingChanges = await session
.PendingChangeCountAsync(cancellationToken)
.ConfigureAwait(true);
if (forgotten == 0)
{
Status = $"Nothing was pinned for {address}:{port}.";
return;
}
Status = $"Forgot the pinned host key for {address}:{port}. The next connection will "
+ "ask you to check its fingerprint again.";
}).ConfigureAwait(true);
// Pushed straight away, as a save or a deletion is: a withdrawal that stayed on this machine would
// leave the other ones refusing to connect to a server that has been rebuilt.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
///
/// Marks every shown conflict as seen.
///
///
/// 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.
///
[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);
}
///
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
// The loop is stopped and awaited before the session goes, not merely signalled. A pass in flight
// holds the vault keys and the cache; letting it run on into a disposed session is how locking
// turns into an ObjectDisposedException on a background thread that nobody sees.
if (autoSync is not null)
{
await autoSync.CancelAsync().ConfigureAwait(false);
}
if (autoSyncLoop is not null)
{
await autoSyncLoop.ConfigureAwait(false);
}
autoSync?.Dispose();
syncGate.Dispose();
await session.DisposeAsync().ConfigureAwait(false);
}
///
/// 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 SessionOpened frame and then stream
/// output at a terminal that was never created. That wait is bounded and takes this command's token, so
/// a renderer that never arrives ends as a message rather than as a window stuck on "Connecting…".
///
private async Task OpenSessionAsync(
HostRowViewModel row,
SshCredential credential,
CancellationToken cancellationToken)
{
try
{
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
var request = new SshConnectionRequest(
row.Host.Hostname,
row.Host.Port,
row.Host.Username!,
credential);
await workspace
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
.ConfigureAwait(true);
Status = $"Connected to {row.Label}.";
// Only now, and only on success. 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 without this the first keystrokes of the session go to the shell's
// UI instead of the remote shell.
SessionOpened?.Invoke(this, EventArgs.Empty);
}
catch (TimeoutException)
{
// The renderer never attached, so nothing was connected. Reported here rather than left to
// RunAsync's generic handler because TimeoutException says only "The operation has timed out",
// and the one thing worth saying is where to look: a runtime this application does not install.
Status = "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
+ "runtime is probably missing or blocked; install it and try again.";
}
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.";
}
}
///
/// How this host authenticates, or null when it names a key the vault does not have.
///
///
///
/// The key material is handed over as UTF-8 bytes, which is what PrivateKeyFile reads from a
/// MemoryStream — so the key reaches SSH.NET without ever becoming a file on disk. The
/// passphrase goes with it: a key stored in the vault together with its passphrase is the whole point
/// of a vault, and SshKeySecret says why. It is passed straight through with no empty-to-null
/// check, because SshKeySecret.Passphrase cannot hold an empty string.
///
///
/// Null is a refusal, not a fallback, and the caller must treat it as one. A dangling reference means a
/// key was deleted on another machine — plausible, and no reason to start sending a password to a host
/// somebody deliberately set up not to accept one.
///
///
private SshCredential? TryBuildCredential(HostSecret host)
{
if (host.SshKeyId is not { } keyId)
{
return new SshPasswordCredential(ConnectPassword);
}
if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key)
{
return null;
}
return new SshPrivateKeyCredential(
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase);
}
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,
// Whatever the picker holds, including the id of a key that has gone missing. Reading it from
// the picker rather than carrying the original through is what lets a binding be removed at all,
// and preserving a missing id is what stops an unrelated edit removing one by accident.
SshKeyId = EditorSelectedKey?.EntityId,
};
///
/// Fills the key picker, keeping whatever the host is currently bound to selectable.
///
/// The key the host names, or null for password authentication.
///
/// A bound key that is no longer in the vault gets a placeholder entry rather than being dropped. Without
/// one the picker would open on "Password (no key)", and someone editing the host's port would convert it
/// to password authentication by saving — which is the quiet version of the failure the connect path
/// refuses outright.
///
private void BuildKeyChoices(Guid? boundKeyId)
{
EditorKeyChoices.Clear();
EditorKeyChoices.Add(SshKeyChoice.None);
foreach (var key in Keys)
{
EditorKeyChoices.Add(new SshKeyChoice(key.EntityId, key.Label));
}
if (boundKeyId is { } bound && EditorKeyChoices.All(choice => choice.EntityId != bound))
{
EditorKeyChoices.Add(SshKeyChoice.Missing(bound));
}
EditorSelectedKey = EditorKeyChoices.FirstOrDefault(choice => choice.EntityId == boundKeyId)
?? SshKeyChoice.None;
}
///
/// The private key is not trimmed. Its armour is whitespace-significant and a client that tidied it up
/// would eventually tidy a format it did not fully understand — the same reason
/// SshKeySecret.PrivateKeyPem stores it verbatim. Everything else is trimmed, because a label
/// with a trailing space sorts oddly and reads as a different name.
///
private SshKeySecret BuildKey() =>
new()
{
Label = KeyEditorLabel.Trim(),
PrivateKeyPem = KeyEditorPrivateKey,
// Not trimmed and not emptied: leading or trailing spaces are legitimate in a passphrase, and
// the record turns an empty one into null on its own.
Passphrase = KeyEditorPassphrase,
PublicKey = string.IsNullOrWhiteSpace(KeyEditorPublicKey) ? null : KeyEditorPublicKey.Trim(),
Notes = string.IsNullOrWhiteSpace(KeyEditorNotes) ? null : KeyEditorNotes,
};
///
/// Whether the key editor has to be dealt with before another one can open.
///
///
///
/// Only one editor open at a time, and this is a layout constraint rather than a style rule. Both
/// editors sit in the same 340-pixel column as Auto rows, and their desired heights together
/// exceed the column at the window's minimum height — so opening both pushes the lower one's Save and
/// Cancel past the bottom edge, where they cannot be clicked. That is the same failure this window has
/// already shipped once, when the setup screens rendered sliced with their buttons unreachable, and it is
/// the failure that nothing in this repository can catch: no test loads a .axaml. Making it a
/// state rule instead of a sizing hope is what makes it testable at all.
///
///
/// Refused rather than resolved by closing the other editor, because closing it would silently discard
/// what was typed there — and in the key editor that is a pasted private key the user may have nowhere
/// else. One sentence and one click is the cheaper of the two.
///
///
private bool KeyEditorIsInTheWay()
{
if (!IsEditingKey)
{
return false;
}
Status = "Finish or cancel the SSH key you are editing first.";
return true;
}
///
private bool HostEditorIsInTheWay()
{
if (!IsEditing)
{
return false;
}
Status = "Finish or cancel the host you are editing first.";
return true;
}
private void ClearKeyEditor()
{
KeyEditorLabel = string.Empty;
KeyEditorPrivateKey = string.Empty;
KeyEditorPassphrase = string.Empty;
KeyEditorPublicKey = string.Empty;
KeyEditorNotes = string.Empty;
}
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));
}
///
/// 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.
///
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();
// "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind
// would send someone looking through the wrong list for something that was not there.
if (report.Resurrected > 0)
{
notes.Add($"{report.Resurrected} item(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 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 OnSelectedHostChanged(HostRowViewModel? value) =>
OnPropertyChanged(nameof(SelectedHostUsesAKey));
///
/// The editing id is set before this flips in every path that opens the editor, and cleared after it
/// flips back in every path that closes one, so this notification always observes the pair in a
/// consistent state.
///
partial void OnIsEditingChanged(bool value) =>
OnPropertyChanged(nameof(CanForgetHostKey));
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
OnPropertyChanged(nameof(HasPendingHostKey));
partial void OnHostKeyMismatchChanged(string? value) =>
OnPropertyChanged(nameof(HasHostKeyMismatch));
}