Public Access
Desktop: show the right vault's people, leave settings on lock, say when an import fell short, and stop host names being cut to four letters
- Choosing a vault while the screen was busy went through a gate that did nothing, so the members
panel kept showing the previous vault's people under the new name. The read now starts regardless;
the selection generation discards answers for an earlier choice.
- Locking or signing out from Settings left the window in settings mode under the lock card, with no
title bar to drag, minimise or close, and unlock returned to Settings. Leaving the unlocked state
leaves Settings and closes the members panel; Escape closes that panel before leaving Settings.
- Both importers reported success when the keychain was busy and skipped the batch, or failed partway.
ImportOutcome now carries refused hosts and why a batch stopped, and the screens say so.
- Host cards: the sync badge took 70 of 214 pixels from the name ("bastion" drew as "bast…", two k8s
workers both as "k8s…"); it now sits on the address line. Tag chips wrap and trim inside the card.
Keychain names trim inside their column instead of printing over TYPE, and the Security page's
caption wraps inside its card.
This commit is contained in:
1 parent
0e8fe4fc80
commit
a4b81da597
11 files changed
+314
-68
No files matched your search
@@ -683,7 +683,7 @@
|
|||||||
TextTrimming never fires and the name would run on past the card's own
|
TextTrimming never fires and the name would run on past the card's own
|
||||||
border instead of trimming. See the longer note this carried in v4.
|
border instead of trimming. See the longer note this carried in v4.
|
||||||
-->
|
-->
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
|
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
|
||||||
FontWeight="Bold" Foreground="{StaticResource Text}"
|
FontWeight="Bold" Foreground="{StaticResource Text}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
@@ -708,17 +708,27 @@
|
|||||||
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
|
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
|
||||||
Width="9" Height="9" />
|
Width="9" Height="9" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Border Grid.Column="2" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
|
<!--
|
||||||
|
"not synced" and its kin sit on the address line. Beside the name, where it
|
||||||
|
used to be, it took seventy of the card's pixels from the one thing a card
|
||||||
|
is read for: "bastion" drew as "bast…", and k8s-worker-a and k8s-worker-b
|
||||||
|
both drew as "k8s…". The address can give the room up — it trims, and the
|
||||||
|
tooltip and the drawer both carry it whole.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}" FontSize="12.5"
|
||||||
|
Foreground="{StaticResource TextGhost}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
|
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="12.5"
|
|
||||||
Foreground="{StaticResource TextGhost}"
|
|
||||||
TextTrimming="CharacterEllipsis" />
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Which vault this host is in, and only when there is more than one to be
|
Which vault this host is in, and only when there is more than one to be
|
||||||
in. It decides who else can see the host and where an edit goes back to,
|
in. It decides who else can see the host and where an edit goes back to,
|
||||||
@@ -734,8 +744,13 @@
|
|||||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
|
||||||
Margin="0,8,0,0">
|
Margin="0,8,0,0">
|
||||||
|
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4"
|
<!--
|
||||||
VerticalAlignment="Bottom">
|
A DockPanel rather than a horizontal StackPanel. A horizontal StackPanel
|
||||||
|
measures its children with infinite width, so the tags' WrapPanel below
|
||||||
|
never wrapped and ran past the card's edge; the last child of a DockPanel
|
||||||
|
gets the width that is actually left, and wraps in it.
|
||||||
|
-->
|
||||||
|
<DockPanel Grid.Column="0" VerticalAlignment="Bottom">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The pin badge: how many folders are pinned on this host, reading
|
The pin badge: how many folders are pinned on this host, reading
|
||||||
@@ -743,8 +758,8 @@
|
|||||||
property's own remarks. Only when there is at least one; a badge reading
|
property's own remarks. Only when there is at least one; a badge reading
|
||||||
zero would be a fact nobody asked for.
|
zero would be a fact nobody asked for.
|
||||||
-->
|
-->
|
||||||
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
|
<Border DockPanel.Dock="Left" Classes="chip" Height="19" CornerRadius="5"
|
||||||
IsVisible="{Binding HasPins}">
|
Padding="6,2" Margin="0,0,4,4" IsVisible="{Binding HasPins}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="3">
|
<StackPanel Orientation="Horizontal" Spacing="3">
|
||||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
||||||
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
||||||
@@ -767,12 +782,13 @@
|
|||||||
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
|
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
|
||||||
Margin="0,0,4,4">
|
Margin="0,0,4,4">
|
||||||
<TextBlock Text="{Binding}" FontSize="11"
|
<TextBlock Text="{Binding}" FontSize="11"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</StackPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
HostRowViewModel.LastConnectedText is filled from the connection log on
|
HostRowViewModel.LastConnectedText is filled from the connection log on
|
||||||
|
|||||||
@@ -275,7 +275,7 @@
|
|||||||
item this application stores carries a timestamp; see the file-level remark above and
|
item this application stores carries a timestamp; see the file-level remark above and
|
||||||
docs/design-import-gaps.md.
|
docs/design-import-gaps.md.
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="2,2.2*,1*,1*,1.4*" Margin="0,10,16,6"
|
<Grid Grid.Row="1" ColumnDefinitions="2,3*,1.1*,0.9*,1.2*" Margin="0,10,16,6"
|
||||||
IsVisible="{Binding HasVaultItems}">
|
IsVisible="{Binding HasVaultItems}">
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
|
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
|
||||||
Margin="12,0,8,0" />
|
Margin="12,0,8,0" />
|
||||||
@@ -290,17 +290,23 @@
|
|||||||
Margin="8,0,8,10">
|
Margin="8,0,8,10">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||||||
<Grid ColumnDefinitions="2,2.2*,1*,1*,1.4*" Height="40" Margin="6,0,10,0">
|
<Grid ColumnDefinitions="2,3*,1.1*,0.9*,1.2*" Height="40" Margin="6,0,10,0">
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" Margin="10,0,8,0"
|
<!--
|
||||||
VerticalAlignment="Center">
|
A Grid rather than a horizontal StackPanel: the StackPanel measured the name with infinite
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15"
|
width, so TextTrimming never fired and a long name printed over the TYPE and USED BY
|
||||||
|
columns beside it.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="10,0,8,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||||
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
|
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="12.5" FontWeight="Medium"
|
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
|
||||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||||
</StackPanel>
|
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Name}" />
|
||||||
|
</Grid>
|
||||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left"
|
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left"
|
||||||
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
|
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
|
||||||
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
|
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
|
||||||
|
|||||||
@@ -244,6 +244,13 @@ internal sealed partial class MainWindow : Window
|
|||||||
viewModel.CloseImportCommand.Execute(null);
|
viewModel.CloseImportCommand.Execute(null);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
// The members dialog on Settings → Vaults, by the same rule: it is drawn over the page, so it goes
|
||||||
|
// first and settings stays.
|
||||||
|
else if (e.Key == Key.Escape && viewModel.IsSettingsMode && viewModel.Vaults.IsMembersPanelOpen)
|
||||||
|
{
|
||||||
|
viewModel.Vaults.CloseMembersPanelCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
|
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
|
||||||
{
|
{
|
||||||
viewModel.LeaveSettingsCommand.Execute(null);
|
viewModel.LeaveSettingsCommand.Execute(null);
|
||||||
|
|||||||
@@ -46,19 +46,21 @@
|
|||||||
<TextBlock Classes="settingstitle" Text="Security" />
|
<TextBlock Classes="settingstitle" Text="Security" />
|
||||||
|
|
||||||
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
|
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
<!-- A Grid, so the caption is measured against the card's width and wraps inside it. -->
|
||||||
<Border Width="44" Height="44" CornerRadius="12" Background="#103A2F">
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<Border Grid.Column="0" Width="44" Height="44" CornerRadius="12" Background="#103A2F"
|
||||||
|
Margin="0,0,16,0" VerticalAlignment="Top">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="20"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="20"
|
||||||
Foreground="{StaticResource Live}"
|
Foreground="{StaticResource Live}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Border>
|
</Border>
|
||||||
<StackPanel Spacing="6" VerticalAlignment="Center">
|
<StackPanel Grid.Column="1" Spacing="6" VerticalAlignment="Center">
|
||||||
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
|
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
|
||||||
Foreground="{StaticResource Text}" />
|
Foreground="{StaticResource Text}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||||
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
|
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Classes="settingssection" Text="UNLOCKING" />
|
<TextBlock Classes="settingssection" Text="UNLOCKING" />
|
||||||
|
|||||||
@@ -566,7 +566,7 @@ internal sealed partial class HetznerImportViewModel(
|
|||||||
// and visible rather than harmless and confusing.
|
// and visible rather than harmless and confusing.
|
||||||
Refresh(resetSelection: true);
|
Refresh(resetSelection: true);
|
||||||
|
|
||||||
Status = WhatWasStored(outcome.Hosts);
|
Status = WhatWasStored(outcome.Hosts) + outcome.Caveats;
|
||||||
|
|
||||||
Step = HetznerImportStep.Done;
|
Step = HetznerImportStep.Done;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,27 @@ namespace DodoSSH.Client.Shell.ViewModels;
|
|||||||
/// </param>
|
/// </param>
|
||||||
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
|
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
|
||||||
|
|
||||||
/// <summary>What an import stored.</summary>
|
/// <summary>What an import stored, and what it did not.</summary>
|
||||||
/// <param name="Hosts">Hosts written.</param>
|
/// <param name="Hosts">Hosts written.</param>
|
||||||
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
|
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
|
||||||
internal sealed record ImportOutcome(int Hosts, int Keys);
|
/// <param name="Refused">Hosts skipped because they would not validate — no hostname, a bad port.</param>
|
||||||
|
/// <param name="Failure">Why the batch stopped before the end, or null if it did not.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// The last two are what the importing screen needs to not claim a success it did not have. The vault's own
|
||||||
|
/// status line says these things too, but it is hidden in settings mode, which is where both importers live.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed record ImportOutcome(int Hosts, int Keys, int Refused = 0, string? Failure = null)
|
||||||
|
{
|
||||||
|
/// <summary>What to add after the screen's own sentence about what was stored.</summary>
|
||||||
|
internal string Caveats =>
|
||||||
|
(Refused switch
|
||||||
|
{
|
||||||
|
0 => string.Empty,
|
||||||
|
1 => " 1 was skipped because it is not a host this keychain can connect to.",
|
||||||
|
_ => $" {Refused} were skipped because they are not hosts this keychain can connect to.",
|
||||||
|
})
|
||||||
|
+ (Failure is { } failure ? $" The import stopped early: {failure}" : string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -409,7 +426,7 @@ internal sealed partial class ImportViewModel(
|
|||||||
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
|
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
|
||||||
};
|
};
|
||||||
|
|
||||||
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}";
|
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}{outcome.Caveats}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1795,6 +1795,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
ActiveSettingsPage = null;
|
ActiveSettingsPage = null;
|
||||||
IsImportOpen = false;
|
IsImportOpen = false;
|
||||||
IsHetznerImportOpen = false;
|
IsHetznerImportOpen = false;
|
||||||
|
|
||||||
|
// Drawn over Settings → Vaults. Left open, it was still up the next time Settings opened, over
|
||||||
|
// whichever page that was.
|
||||||
|
vaults.IsMembersPanelOpen = false;
|
||||||
Screen = settingsReturnScreen;
|
Screen = settingsReturnScreen;
|
||||||
Surface = settingsReturnSurface;
|
Surface = settingsReturnSurface;
|
||||||
}
|
}
|
||||||
@@ -5107,6 +5111,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
Screen = ShellScreen.Hosts;
|
Screen = ShellScreen.Hosts;
|
||||||
Surface = ShellSurface.Page;
|
Surface = ShellSurface.Page;
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Settings is a mode of the unlocked window, and it brings its own title bar while the main one
|
||||||
|
// is hidden. Locking or signing out from it — LOCK NOW is on Preferences, sign-out on Account —
|
||||||
|
// left the window in settings mode under the lock card: no title bar, so nothing to drag, minimise
|
||||||
|
// or close, and an unlock that came back to Settings rather than to Hosts.
|
||||||
|
LeaveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -8838,53 +8838,94 @@ internal sealed partial class VaultViewModel(
|
|||||||
ArgumentNullException.ThrowIfNull(requests);
|
ArgumentNullException.ThrowIfNull(requests);
|
||||||
|
|
||||||
var target = vaultId ?? session.ActiveVaultId;
|
var target = vaultId ?? session.ActiveVaultId;
|
||||||
var hosts = 0;
|
var tally = new ImportTally();
|
||||||
var keys = 0;
|
var ran = false;
|
||||||
var refused = 0;
|
string? failure = null;
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
|
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
ran = true;
|
||||||
// front: the second host naming a file has to land on the id the first one produced, and
|
|
||||||
// nothing is reloaded until the batch finishes.
|
|
||||||
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
foreach (var request in requests)
|
try
|
||||||
{
|
{
|
||||||
if (!request.Host.TryValidate(out _))
|
await ImportEachAsync(requests, target, tally, cancellationToken).ConfigureAwait(true);
|
||||||
{
|
}
|
||||||
refused++;
|
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||||
continue;
|
and not OperationCanceledException)
|
||||||
}
|
{
|
||||||
|
// Reported, and the hosts written before it are still reloaded below: they are in the
|
||||||
var (keyId, created) = await BindImportedKeyAsync(
|
// keychain whether or not the rest followed, and the screen has to say both.
|
||||||
request, bound, target, cancellationToken)
|
failure = exception.Message;
|
||||||
.ConfigureAwait(true);
|
|
||||||
|
|
||||||
if (created)
|
|
||||||
{
|
|
||||||
keys++;
|
|
||||||
}
|
|
||||||
|
|
||||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
|
||||||
|
|
||||||
await session.Hosts
|
|
||||||
.CreateAsync(target, host, cancellationToken)
|
|
||||||
.ConfigureAwait(true);
|
|
||||||
|
|
||||||
hosts++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Status = DescribeImport(hosts, keys, refused);
|
Status = DescribeImport(tally.Hosts, tally.Keys, tally.Refused)
|
||||||
|
+ (failure is null ? string.Empty : $" The import stopped early: {failure}");
|
||||||
}).ConfigureAwait(true);
|
}).ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (!ran)
|
||||||
|
{
|
||||||
|
// RunAsync returns at once while anything else is running — a sync pass, a save — and the
|
||||||
|
// screen asking would otherwise have said it imported nothing as though that were the result.
|
||||||
|
failure = "the keychain was busy with something else. Nothing was imported; try again.";
|
||||||
|
}
|
||||||
|
|
||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
return new ImportOutcome(hosts, keys);
|
return new ImportOutcome(tally.Hosts, tally.Keys, tally.Refused, failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes each host of an import, and any key it brought, counting as it goes.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Counted into <paramref name="tally"/> rather than returned, so that when a write fails partway the
|
||||||
|
/// caller still knows how many landed before it — which is what it has to tell the person.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task ImportEachAsync(
|
||||||
|
IReadOnlyList<ImportedHostRequest> batch,
|
||||||
|
Guid into,
|
||||||
|
ImportTally tally,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
||||||
|
// front: the second host naming a file has to land on the id the first one produced, and nothing
|
||||||
|
// is reloaded until the batch finishes.
|
||||||
|
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var request in batch)
|
||||||
|
{
|
||||||
|
if (!request.Host.TryValidate(out _))
|
||||||
|
{
|
||||||
|
tally.Refused++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (keyId, created) = await BindImportedKeyAsync(request, bound, into, token)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (created)
|
||||||
|
{
|
||||||
|
tally.Keys++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||||
|
|
||||||
|
await session.Hosts.CreateAsync(into, host, token).ConfigureAwait(true);
|
||||||
|
|
||||||
|
tally.Hosts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What one import has written so far.</summary>
|
||||||
|
private sealed class ImportTally
|
||||||
|
{
|
||||||
|
internal int Hosts { get; set; }
|
||||||
|
|
||||||
|
internal int Keys { get; set; }
|
||||||
|
|
||||||
|
internal int Refused { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
|
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
|
||||||
|
|||||||
@@ -1639,11 +1639,32 @@ internal sealed partial class VaultsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
||||||
// from a list box, which has no cancellation token and no way to await. Wrapped here rather than
|
// from a list box, which has no cancellation token and no way to await.
|
||||||
// inside the read, because the read is also awaited from inside a reload that already holds the
|
//
|
||||||
// busy gate — and a second RunAsync there would find the gate held by its own caller and skip the
|
// ◆ Not through RunAsync. Its busy gate returns at once while anything else runs — the first load,
|
||||||
// members list entirely.
|
// a share, a reload — so a vault chosen in that moment never had its people read, and the panel
|
||||||
_ = RunAsync(() => LoadSelectedAsync(CancellationToken.None));
|
// went on showing the previous vault's under the new one's name, with SHARE and REVOKE acting on
|
||||||
|
// that mixture. The read does not need the gate: LoadSelectedAsync bumps the selection generation,
|
||||||
|
// and every answer for an earlier selection is discarded by it.
|
||||||
|
_ = LoadSelectionAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the newly selected vault's people, turning a failure into a sentence.</summary>
|
||||||
|
private async Task LoadSelectionAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await LoadSelectedAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (DodoSshApiException exception)
|
||||||
|
{
|
||||||
|
Status = exception.Message;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||||
|
and not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Status = exception.Message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnPendingActionChanged(VaultActionRequest? value)
|
partial void OnPendingActionChanged(VaultActionRequest? value)
|
||||||
|
|||||||
@@ -1871,6 +1871,31 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
/// connect and restored afterwards — and the dial itself goes through the ordinary connect path, so the
|
/// connect and restored afterwards — and the dial itself goes through the ordinary connect path, so the
|
||||||
/// host key gate and the log entry are the ones every other connection gets.
|
/// host key gate and the log entry are the ones every other connection gets.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <remarks>
|
||||||
|
/// A sync pass reloads the vault, which empties its host list and fills it again — and the picker used to
|
||||||
|
/// re-find its selection from the empty moment, landing on the first host every minute. CONNECT then
|
||||||
|
/// dialled that host with the password typed for the one chosen.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheFilesHostPicker_KeepsTheChosenHostAndItsPasswordAcrossAVaultReload()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
await AddHostAsync(vault, "staging-db");
|
||||||
|
|
||||||
|
shell.Transfers.Attach(vault, knownHosts);
|
||||||
|
|
||||||
|
var chosen = shell.Transfers.Hosts.Single(row => row.Host.Label == "staging-db");
|
||||||
|
shell.Transfers.SelectedHost = chosen;
|
||||||
|
shell.Transfers.TypedPassword = "typed for staging";
|
||||||
|
|
||||||
|
shell.Transfers.Hosts[0].ShouldNotBe(chosen, "otherwise falling back to the first proves nothing");
|
||||||
|
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
shell.Transfers.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(chosen.EntityId);
|
||||||
|
shell.Transfers.TypedPassword.ShouldBe("typed for staging");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReconnectingALostFileSession_DialsAgainAndGoesBackToTheDirectoryItWasShowing()
|
public async Task ReconnectingALostFileSession_DialsAgainAndGoesBackToTheDirectoryItWasShowing()
|
||||||
{
|
{
|
||||||
@@ -5705,6 +5730,16 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
.Single(row => string.Equals(row.Alias, "web-01", StringComparison.Ordinal))
|
.Single(row => string.Equals(row.Alias, "web-01", StringComparison.Ordinal))
|
||||||
.IsSelected.ShouldBeTrue();
|
.IsSelected.ShouldBeTrue();
|
||||||
|
|
||||||
|
// A keychain busy with something else — a sync pass, a save — used to skip the whole batch and let this
|
||||||
|
// screen say "Imported 0 host(s)" as though that were the result.
|
||||||
|
vault.IsBusy = true;
|
||||||
|
|
||||||
|
await import.ImportCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.IsBusy = false;
|
||||||
|
vault.Hosts.Count.ShouldBe(1);
|
||||||
|
import.Status.ShouldContain("Nothing was imported");
|
||||||
|
|
||||||
await import.ImportCommand.ExecuteAsync(null);
|
await import.ImportCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
|
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
|
||||||
@@ -10628,6 +10663,35 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.Screen.ShouldBe(ShellScreen.Keychain);
|
shell.Screen.ShouldBe(ShellScreen.Keychain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// LOCK NOW is on the Preferences page. Settings mode hides the main title bar and draws its own, which
|
||||||
|
/// the lock card then covers — so a lock that stayed in settings mode left a window with nothing to drag,
|
||||||
|
/// minimise or close, and an unlock that came back to Settings rather than to Hosts.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task LockingFromSettings_LeavesSettingsModeAndUnlockingLandsOnHosts()
|
||||||
|
{
|
||||||
|
await ReadyToConnectAsync();
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||||
|
|
||||||
|
shell.EnterSettingsCommand.Execute(SettingsPage.Vaults);
|
||||||
|
await shell.Vaults.LoadAsync(Token);
|
||||||
|
shell.Vaults.OpenMembersPanelCommand.Execute(shell.Vaults.Vaults[0]);
|
||||||
|
shell.Vaults.IsMembersPanelOpen.ShouldBeTrue("otherwise its closing proves nothing");
|
||||||
|
|
||||||
|
await shell.LockCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.State.ShouldBe(ShellState.Locked);
|
||||||
|
shell.IsSettingsMode.ShouldBeFalse("the lock card needs the main title bar above it");
|
||||||
|
shell.Vaults.IsMembersPanelOpen.ShouldBeFalse();
|
||||||
|
|
||||||
|
shell.Passphrase = Passphrase;
|
||||||
|
await shell.UnlockCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.IsSettingsMode.ShouldBeFalse();
|
||||||
|
shell.Screen.ShouldBe(ShellScreen.Hosts);
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Settings mode collapses the terminal the same way any other page does — <see cref="ShellSurface.Page"/>
|
/// Settings mode collapses the terminal the same way any other page does — <see cref="ShellSurface.Page"/>
|
||||||
/// and <see cref="ShellSurface.Terminal"/> are exclusive by construction — and "Back to application" has
|
/// and <see cref="ShellSurface.Terminal"/> are exclusive by construction — and "Back to application" has
|
||||||
|
|||||||
@@ -2177,10 +2177,70 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
|||||||
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
vaults.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A vault chosen while anything else on the screen was running — a reload, a share — went through a busy
|
||||||
|
/// gate that answered by doing nothing, so its people were never read and the panel kept showing the
|
||||||
|
/// previous vault's under the new one's name. SHARE and REVOKE then acted on that mixture.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AVaultChosenWhileAReloadIsRunning_ShowsItsOwnPeople()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var vaults = shell.Vaults;
|
||||||
|
server.AddAccount("bob@example.com", "Bob Example");
|
||||||
|
|
||||||
|
await CreateVaultAsync(vaults, "Alone");
|
||||||
|
var alone = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
await CreateVaultAsync(vaults, "Shared with Bob");
|
||||||
|
var shared = vaults.SelectedVault!.VaultId;
|
||||||
|
|
||||||
|
vaults.NewMemberEmail = "bob@example.com";
|
||||||
|
await vaults.AddMemberCommand.ExecuteAsync(null);
|
||||||
|
vaults.Members.Count.ShouldBe(2, vaults.Status);
|
||||||
|
|
||||||
|
vaults.SelectedVault = vaults.Vaults.Single(row => row.VaultId == alone);
|
||||||
|
await EventuallyAsync(() => vaults.Members.Count == 1, "the vault on its own has only its owner");
|
||||||
|
|
||||||
|
var gate = new TaskCompletionSource();
|
||||||
|
server.MemberReadGate = gate;
|
||||||
|
|
||||||
|
var reload = vaults.LoadAsync(Token);
|
||||||
|
|
||||||
|
vaults.IsBusy.ShouldBeTrue("the reload is what holds the screen busy while its read is held");
|
||||||
|
|
||||||
|
vaults.SelectedVault = vaults.Vaults.Single(row => row.VaultId == shared);
|
||||||
|
|
||||||
|
gate.SetResult();
|
||||||
|
server.MemberReadGate = null;
|
||||||
|
await reload;
|
||||||
|
|
||||||
|
await EventuallyAsync(() => vaults.Members.Count == 2, "Bob is in the vault now showing");
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Through the form rather than straight at the command, because the name is what the form is for —
|
/// Through the form rather than straight at the command, because the name is what the form is for —
|
||||||
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
|
/// and because the form is now the only way in: there is no separate "make a team" step behind it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <summary>Waits for something a fire-and-forget read will make true, within a bound.</summary>
|
||||||
|
private static async Task EventuallyAsync(Func<bool> condition, string because)
|
||||||
|
{
|
||||||
|
var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10);
|
||||||
|
|
||||||
|
while (TimeProvider.System.GetUtcNow() < deadline)
|
||||||
|
{
|
||||||
|
if (condition())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(20), Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ShouldAssertException(because);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The armour a key is stored in, which this suite never parses and only round-trips.</summary>
|
/// <summary>The armour a key is stored in, which this suite never parses and only round-trips.</summary>
|
||||||
private static string PrivateKey(string body) =>
|
private static string PrivateKey(string body) =>
|
||||||
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
|
$"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||||
|
|||||||
Reference in new issue
Block a user