Public Access
Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
This commit is contained in:
@@ -6,6 +6,8 @@ using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Import;
|
||||
using DodoSSH.Client.ObjectStore;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
@@ -61,7 +63,7 @@ internal enum ShellState
|
||||
/// </remarks>
|
||||
internal enum ShellScreen
|
||||
{
|
||||
/// <summary>The host list and the terminals, which is where the application opens.</summary>
|
||||
/// <summary>The host list, which is where the application opens.</summary>
|
||||
Hosts = 0,
|
||||
|
||||
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
|
||||
@@ -75,6 +77,52 @@ internal enum ShellScreen
|
||||
|
||||
/// <summary>Preferences.</summary>
|
||||
Preferences = 4,
|
||||
|
||||
/// <summary>The host keys this keychain has approved.</summary>
|
||||
/// <remarks>
|
||||
/// Appended rather than slotted in beside the keychain screen it came out of. These values are written
|
||||
/// into <c>NavRail.axaml</c> as <c>x:Static</c> literals and read by tests; renumbering them would be a
|
||||
/// silent change to what every one of those means.
|
||||
/// </remarks>
|
||||
KnownHosts = 5,
|
||||
|
||||
/// <summary>Importing hosts from the machine's own <c>~/.ssh/config</c>.</summary>
|
||||
/// <remarks>
|
||||
/// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task
|
||||
/// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for
|
||||
/// something almost nobody is looking at.
|
||||
/// </remarks>
|
||||
Import = 6,
|
||||
|
||||
/// <summary>The saved commands in this keychain.</summary>
|
||||
/// <inheritdoc cref="KnownHosts" path="/remarks" />
|
||||
Snippets = 7,
|
||||
|
||||
/// <summary>What has been connected to, and what has been changed.</summary>
|
||||
/// <inheritdoc cref="KnownHosts" path="/remarks" />
|
||||
Logs = 8,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two properties rather than a sixth <see cref="ShellScreen"/>, and the reason is that a terminal is not a
|
||||
/// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can
|
||||
/// be opened from any screen — and when it is dismissed the user expects to be back where they were, which
|
||||
/// means "which page" has to survive "a terminal is showing". Folding the terminal into
|
||||
/// <see cref="ShellScreen"/> would need a private field remembering the page underneath, which is this pair
|
||||
/// with one half hidden.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal enum ShellSurface
|
||||
{
|
||||
/// <summary>The screen named by <see cref="MainWindowViewModel.Screen"/>.</summary>
|
||||
Page = 0,
|
||||
|
||||
/// <summary>The pane of the tab named by <see cref="MainWindowViewModel.SelectedTab"/>.</summary>
|
||||
Terminal = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -126,6 +174,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private readonly TimeProvider clock;
|
||||
private readonly Argon2Profile? passphraseProfile;
|
||||
|
||||
/// <remarks>
|
||||
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
|
||||
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
|
||||
/// rather than one that fails silently — see <see cref="VaultViewModel"/>.
|
||||
/// </remarks>
|
||||
private readonly Func<string, Task>? copyToClipboard;
|
||||
|
||||
/// <remarks>
|
||||
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
|
||||
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
|
||||
@@ -134,6 +189,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly TransfersViewModel transfers;
|
||||
|
||||
/// <summary>
|
||||
/// Where connections are recorded, for as long as a vault is open to record them into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
|
||||
/// and for the same reason: the thing that calls it — the workspace — outlives every lock.
|
||||
/// </remarks>
|
||||
private readonly ConnectionRecorder connectionLog;
|
||||
|
||||
private readonly TeamsViewModel teams;
|
||||
|
||||
private IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
@@ -188,7 +254,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
TimeProvider clock,
|
||||
ISftpSessionFactory sftpSessions,
|
||||
Argon2Profile? passphraseProfile = null,
|
||||
ResumeHandler? resume = null)
|
||||
ResumeHandler? resume = null,
|
||||
Func<string, Task>? copyToClipboard = null)
|
||||
{
|
||||
this.paths = paths;
|
||||
this.caches = caches;
|
||||
@@ -199,9 +266,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.resume = resume;
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
this.copyToClipboard = copyToClipboard;
|
||||
|
||||
transfers = new TransfersViewModel(sftpSessions, clock);
|
||||
|
||||
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
|
||||
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
|
||||
// running after it, and the entry it eventually produces belongs to the vault it was made in.
|
||||
connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
|
||||
this.workspace.ConnectionLog = connectionLog;
|
||||
|
||||
// Both dependencies as functions rather than values: the connection arrives after sign-in and the
|
||||
// session after unlock, and both go away again on lock. Capturing either would give this screen a
|
||||
// reference that outlives what it points at — which for a session means holding vault keys past the
|
||||
// moment locking is supposed to have zeroed them.
|
||||
teams = new TeamsViewModel(() => connection, () => Vault?.Session);
|
||||
|
||||
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||
@@ -273,6 +353,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private VaultViewModel? vault;
|
||||
|
||||
/// <summary>The approved-host-keys screen, which exists exactly as long as the vault behind it does.</summary>
|
||||
/// <remarks>
|
||||
/// Assigned from <see cref="OnVaultChanged"/> and nowhere else, so the three paths that open or close a
|
||||
/// vault — unlocking, locking and signing out — cannot get out of step with it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private KnownHostsViewModel? knownHostsScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private ImportViewModel? importScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private SnippetsViewModel? snippetsScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private LogsViewModel? logsScreen;
|
||||
|
||||
/// <summary>
|
||||
/// The teams screen, which the window binds to whether or not a vault is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: the screen reads a
|
||||
/// server rather than a vault, and both of its dependencies are fetched through a function at the
|
||||
/// moment they are needed. That means a lock does not have to tear it down and an unlock does not have
|
||||
/// to rebuild it, and the list it is showing survives both.
|
||||
/// </remarks>
|
||||
internal TeamsViewModel Teams => teams;
|
||||
|
||||
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
|
||||
/// <remarks>
|
||||
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
|
||||
@@ -370,9 +481,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
// ---- Which screen is showing ----
|
||||
|
||||
/// <summary>
|
||||
/// Which of the nav rail's screens the page area holds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This always names a page, even while a terminal is showing over it — see <see cref="ShellSurface"/>.
|
||||
/// It is what dismissing a terminal returns to.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private ShellScreen screen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the page area is showing rather than a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
|
||||
/// <c>IsHostsScreen && IsShowingPages</c> in a binding, so the alternative is five compound
|
||||
/// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse
|
||||
/// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons
|
||||
/// cannot be clicked. See <see cref="IsTerminalShowing"/>.
|
||||
/// </remarks>
|
||||
internal bool IsShowingPages => Surface is ShellSurface.Page;
|
||||
|
||||
internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
@@ -387,6 +517,51 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsImportScreen => Screen is ShellScreen.Import;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsLogsScreen => Screen is ShellScreen.Logs;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the nav rail should light its Hosts entry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not the same question as <see cref="IsHostsScreen"/>, and the rail has to ask this one. A terminal
|
||||
/// opened from the hosts screen leaves <see cref="Screen"/> on Hosts — deliberately, so closing the tab
|
||||
/// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a
|
||||
/// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks
|
||||
/// at once is one too many.
|
||||
/// </remarks>
|
||||
internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsVaultShowing => IsShowingPages && IsVaultScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsTeamShowing => IsShowingPages && IsTeamScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal's WebView may be on screen at this instant.
|
||||
/// </summary>
|
||||
@@ -396,16 +571,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
|
||||
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
|
||||
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
|
||||
/// locked vault (the unlock card), a screen that is not Hosts (the vault, team, transfers and preferences
|
||||
/// screens all use the full width), and the quick-connect palette.
|
||||
/// locked vault (the unlock card), the page area (every screen uses the full width), and the
|
||||
/// quick-connect palette.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not gated on there being a tab.</b> That was tried, so that the empty terminal could carry a
|
||||
/// sentence saying what to do — and it puts the WebView's first appearance in the same turn as the
|
||||
/// <c>Focus()</c> that hands it the keyboard, which is the one moment on the connect path that has to
|
||||
/// work. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing a control
|
||||
/// that became visible microseconds earlier is a race against exactly the thing it depends on. The
|
||||
/// empty-state sentence lives in the tab strip instead, which Avalonia draws and nothing occludes.
|
||||
/// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
|
||||
/// rectangle, so exactly one of <see cref="IsShowingPages"/> and this may be true. That is why
|
||||
/// <see cref="Surface"/> exists as a single enum rather than as two independent flags a caller could set
|
||||
/// to the same value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
|
||||
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
|
||||
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
|
||||
/// Avalonia draws and nothing occludes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
|
||||
/// files screen, or clicking a tab while a page is showing, both flip this from false to true and then
|
||||
/// want the keyboard. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing
|
||||
/// microseconds ahead of that pass races the thing the focus depends on. The view answers that by
|
||||
/// posting the focus at <c>DispatcherPriority.Loaded</c> — see <c>MainWindow.axaml.cs</c>. It is not
|
||||
/// answered here, and it cannot be: this property has no way to know when layout ran.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
|
||||
@@ -414,11 +601,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// safe — that detaches it and destroys the whole WebView2 process tree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsTerminalShowing => IsUnlocked && IsHostsScreen && !IsSearching;
|
||||
internal bool IsTerminalShowing => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
|
||||
|
||||
/// <inheritdoc cref="ShellSurface" />
|
||||
[ObservableProperty]
|
||||
private ShellSurface surface;
|
||||
|
||||
/// <summary>Points the nav rail at a screen.</summary>
|
||||
/// <remarks>
|
||||
/// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me
|
||||
/// something else" and a rail click that changed a screen nobody could see would do nothing visible.
|
||||
/// The tab itself is untouched: its shell goes on running and the strip goes on naming it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ShowScreen(ShellScreen target) => Screen = target;
|
||||
private void ShowScreen(ShellScreen target)
|
||||
{
|
||||
Screen = target;
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
|
||||
// ---- Open terminals ----
|
||||
|
||||
@@ -470,6 +670,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
|
||||
}
|
||||
|
||||
// The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the
|
||||
// terminal showing — the neighbour above is what it shows — but closing the last one would otherwise
|
||||
// leave a visible WebView with no pane in it, which reads as the application having broken.
|
||||
if (Tabs.Count == 0)
|
||||
{
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
|
||||
RaiseTabState();
|
||||
|
||||
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
|
||||
@@ -566,7 +774,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
CloseSearch();
|
||||
|
||||
// The hosts page, and the page rather than a terminal, before the connect is awaited. An unknown or
|
||||
// changed host key is answered by a prompt drawn on that page, and the palette can be opened from any
|
||||
// screen — so connecting from the files screen without this would put the question behind the screen
|
||||
// that asked it, with the connection blocked on an answer the user cannot reach. The session opening
|
||||
// is what moves the surface to the terminal, and only if there is one.
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
|
||||
|
||||
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
|
||||
@@ -746,7 +960,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Creating your vault. This deliberately takes a moment…",
|
||||
"Creating your keychain. This deliberately takes a moment…",
|
||||
async () =>
|
||||
{
|
||||
var chosen = Passphrase;
|
||||
@@ -796,7 +1010,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
if (Passphrase.Length == 0)
|
||||
{
|
||||
StatusMessage = "Enter your vault passphrase.";
|
||||
StatusMessage = "Enter your keychain passphrase.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -950,23 +1164,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
// Before the vault view model, so the first connection after an unlock already knows which host keys
|
||||
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
|
||||
// keeps it off the SSH handshake thread.
|
||||
try
|
||||
{
|
||||
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
|
||||
// session is vault keys left in memory for the life of the process, which is precisely what
|
||||
// unlocking must be able to undo.
|
||||
await session.DisposeAsync().ConfigureAwait(true);
|
||||
throw;
|
||||
}
|
||||
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Vault = new VaultViewModel(session, workspace, knownHosts, () => connection, ReconnectAsync);
|
||||
Vault = new VaultViewModel(
|
||||
session,
|
||||
workspace,
|
||||
knownHosts,
|
||||
() => connection,
|
||||
ReconnectAsync,
|
||||
copyToClipboard,
|
||||
connectionLog);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||
@@ -984,7 +1191,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
// After the load, because what the transfers screen takes from the vault is the host list and an
|
||||
// empty one would leave its picker blank until the next unlock.
|
||||
transfers.Attach(Vault, knownHosts);
|
||||
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
|
||||
|
||||
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
|
||||
// running while the vault was closed, so some of these hosts are connected before their rows are a
|
||||
@@ -1007,6 +1214,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Vault.StartAutoSync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points the two process-lifetime stores at the session that has just opened.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is
|
||||
/// called by the workspace — so both are attached here rather than constructed per session, and both are
|
||||
/// released together on every path that closes a vault.
|
||||
/// </remarks>
|
||||
private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
// Before the vault view model, so the first connection after an unlock already knows which host keys
|
||||
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
|
||||
// keeps it off the SSH handshake thread.
|
||||
try
|
||||
{
|
||||
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
|
||||
// session is vault keys left in memory for the life of the process, which is precisely what
|
||||
// unlocking must be able to undo.
|
||||
await session.DisposeAsync().ConfigureAwait(true);
|
||||
throw;
|
||||
}
|
||||
|
||||
// The actor is the account that unlocked, which is what makes this an audit record rather than a
|
||||
// list of events with nobody attached to them.
|
||||
connectionLog.Open(session, session.Profile.UserId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
|
||||
/// </summary>
|
||||
@@ -1214,6 +1452,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// reappearing behind a lock screen.
|
||||
knownHosts.Close();
|
||||
|
||||
// Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is
|
||||
// about to be disposed. Tickets already open keep the repository they were opened against, so a
|
||||
// shell still running closes out into the vault it was actually made in.
|
||||
connectionLog.Close();
|
||||
|
||||
// Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
|
||||
// holding references to them. What it does not give up is its connection or its queue — a transfer
|
||||
// in flight is exactly the work this method exists not to destroy.
|
||||
@@ -1264,7 +1507,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
(false, _) =>
|
||||
"Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
|
||||
+ "counted from here, because the vault is locked.",
|
||||
+ "counted from here, because the keychain is locked.",
|
||||
(true, 0) =>
|
||||
"Everything this machine has changed has reached the server, so nothing will be lost.",
|
||||
(true, 1) =>
|
||||
@@ -1328,6 +1571,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
// As Lock does, and before the session it reads from goes.
|
||||
knownHosts.Close();
|
||||
connectionLog.Close();
|
||||
|
||||
// The same detach locking does, and the same reasoning carried one step further: the host
|
||||
// rows go because the vault behind them is about to be disposed, and the session and its
|
||||
@@ -1364,8 +1608,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
RaiseSyncState();
|
||||
|
||||
StatusMessage = "Signed out. This machine's copy of the vault has been deleted; the vault "
|
||||
+ "itself is untouched. Sign in to set this machine up again.";
|
||||
StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the "
|
||||
+ "keychain itself is untouched. Sign in to set this machine up again.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -1412,6 +1656,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
knownHosts.Close();
|
||||
|
||||
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
|
||||
// rather than a completed channel. Disposed rather than merely closed, because it owns a background
|
||||
// task — and it waits only as long as that task takes to stop, never for the queue to drain.
|
||||
workspace.ConnectionLog = null;
|
||||
await connectionLog.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
// Before the vault, and it waits: a transfer still writing has an open remote file and an open local
|
||||
// one, and a process that exits while those are in flight leaves a part file longer than the bytes
|
||||
// that reached it.
|
||||
@@ -1547,6 +1797,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
|
||||
}
|
||||
|
||||
// Built from the vault and thrown away with it, here rather than at each of the three places a
|
||||
// vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
|
||||
// would keep a disposed vault alive and repaint a screen nobody can reach.
|
||||
KnownHostsScreen?.Detach();
|
||||
KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
|
||||
ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());
|
||||
|
||||
SnippetsScreen?.Detach();
|
||||
SnippetsScreen = newValue is null
|
||||
? null
|
||||
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
|
||||
|
||||
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
|
||||
|
||||
RaiseSyncState();
|
||||
}
|
||||
|
||||
@@ -1579,13 +1843,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The tab is added before the event is forwarded, so the handler that hands the terminal the keyboard
|
||||
/// runs against a tab strip that already shows the session it is focusing.
|
||||
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
|
||||
/// them and nothing more — everything about becoming a tab is in <see cref="AdoptTab"/>.
|
||||
/// </remarks>
|
||||
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
|
||||
{
|
||||
var tab = new TerminalTabViewModel(e.SessionId, e.Label, e.Address);
|
||||
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e) =>
|
||||
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
|
||||
|
||||
/// <summary>
|
||||
/// Takes a newly opened session into the tab strip and shows it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One method rather than one per way of opening a session, so the order of these four steps is decided
|
||||
/// once. It is not arbitrary: the tab is in the strip before the event is forwarded, so the handler that
|
||||
/// hands the terminal the keyboard runs against a strip that already shows what it is focusing.
|
||||
/// </remarks>
|
||||
private void AdoptTab(TerminalTabViewModel tab)
|
||||
{
|
||||
Tabs.Add(tab);
|
||||
RaiseTabState();
|
||||
|
||||
@@ -1594,6 +1867,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// and load-bearing for every one after it.
|
||||
SelectedTab = tab;
|
||||
|
||||
// The surface, but deliberately not the screen. A session opened from the files screen shows its
|
||||
// terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or
|
||||
// clicking away comes back to the transfer that is presumably still running.
|
||||
Surface = ShellSurface.Terminal;
|
||||
|
||||
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -1617,15 +1895,50 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
RefreshConnectedHosts();
|
||||
|
||||
// The snippets screen names the terminal its buttons will type into, and it has no way to learn that
|
||||
// a different tab is selected — the tab list is the shell's, and a subscription the other way would
|
||||
// be a screen keeping the shell alive.
|
||||
SnippetsScreen?.TargetChanged();
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
_ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Brings one terminal's pane to the front.</summary>
|
||||
/// <summary>Which terminal a snippet would go into right now.</summary>
|
||||
/// <remarks>
|
||||
/// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
|
||||
/// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked,
|
||||
/// say, the most recently opened would send a command somewhere the user is not looking.
|
||||
/// </remarks>
|
||||
/// <summary>The connections that are open and therefore have no log entry yet.</summary>
|
||||
/// <remarks>
|
||||
/// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and
|
||||
/// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent —
|
||||
/// an SFTP session has no tab at all, and a tab whose remote hung up still has one.
|
||||
/// </remarks>
|
||||
private IReadOnlyList<LiveConnection> LiveConnections() =>
|
||||
[
|
||||
.. connectionLog.Open().Select(open => new LiveConnection(
|
||||
open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)),
|
||||
];
|
||||
|
||||
private InsertTarget CurrentInsertTarget() =>
|
||||
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
|
||||
|
||||
/// <summary>Brings one terminal's pane to the front, and shows it.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come
|
||||
/// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see
|
||||
/// would answer only one of those.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void SelectTab(TerminalTabViewModel tab) => SelectedTab = tab;
|
||||
private void SelectTab(TerminalTabViewModel tab)
|
||||
{
|
||||
SelectedTab = tab;
|
||||
Surface = ShellSurface.Terminal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a tab dead when its shell ends on its own.
|
||||
@@ -1689,10 +2002,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
RaiseSyncState();
|
||||
|
||||
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
|
||||
// The hosts screen is what this application is for.
|
||||
// The hosts screen is what this application is for. The surface as well as the screen: shells outlive
|
||||
// a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to
|
||||
// the application would not be what "unlocked" looks like.
|
||||
if (value is ShellState.Unlocked)
|
||||
{
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1702,12 +2018,57 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// directions, and raising only the one that became true leaves the old button lit.
|
||||
/// </remarks>
|
||||
partial void OnScreenChanged(ShellScreen value)
|
||||
{
|
||||
RaiseSurfaceState();
|
||||
|
||||
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
|
||||
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
|
||||
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
|
||||
if (value is ShellScreen.Logs && LogsScreen is { } logs)
|
||||
{
|
||||
_ = logs.RefreshCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
// Teams are read from the server rather than from the vault, so there is nothing to show until
|
||||
// somebody asks for it — and asking for it on every unlock would be a request per launch for a
|
||||
// screen most people never open. Fire-and-forget because a property change cannot await, and
|
||||
// because the view model turns every failure into its own status line rather than throwing.
|
||||
if (value is ShellScreen.Team)
|
||||
{
|
||||
_ = teams.LoadAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnScreenChanged" />
|
||||
partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
|
||||
|
||||
/// <remarks>
|
||||
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
|
||||
/// read <see cref="Screen"/> and <see cref="Surface"/> together, so which of the two moved does not
|
||||
/// narrow what became stale.
|
||||
/// </remarks>
|
||||
private void RaiseSurfaceState()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsHostsScreen));
|
||||
OnPropertyChanged(nameof(IsTransfersScreen));
|
||||
OnPropertyChanged(nameof(IsVaultScreen));
|
||||
OnPropertyChanged(nameof(IsTeamScreen));
|
||||
OnPropertyChanged(nameof(IsPreferencesScreen));
|
||||
OnPropertyChanged(nameof(IsKnownHostsScreen));
|
||||
OnPropertyChanged(nameof(IsImportScreen));
|
||||
OnPropertyChanged(nameof(IsSnippetsScreen));
|
||||
OnPropertyChanged(nameof(IsLogsScreen));
|
||||
|
||||
OnPropertyChanged(nameof(IsShowingPages));
|
||||
OnPropertyChanged(nameof(IsHostsShowing));
|
||||
OnPropertyChanged(nameof(IsTransfersShowing));
|
||||
OnPropertyChanged(nameof(IsVaultShowing));
|
||||
OnPropertyChanged(nameof(IsTeamShowing));
|
||||
OnPropertyChanged(nameof(IsPreferencesShowing));
|
||||
OnPropertyChanged(nameof(IsKnownHostsShowing));
|
||||
OnPropertyChanged(nameof(IsSnippetsShowing));
|
||||
OnPropertyChanged(nameof(IsLogsShowing));
|
||||
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user