Public Access
Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain hid every open terminal and the strip that named them. A connection you had opened was invisible from four of the five screens. The window now has two surfaces rather than one: a nav rail that says which page you are on, and a terminal strip that is always there and switches the whole content area to a shell. Screen keeps meaning "which page" and never becomes a sixth kind of page, which is why this is two properties instead of one enum with a terminal member in it. Every screen lives inside one wrapper panel that collapses when a terminal is showing. That is not tidiness — the WebView hosts a Win32 child window that composites above everything Avalonia draws, so a screen left visible over its rectangle is a screen sliced in half, and this window has shipped that defect once already. One decision point, IsTerminalShowing, and a nested panel rather than five compound bindings nobody would remember to extend. The focus choreography is the part no test in this repo can see. Every reveal path now focuses in the same turn the WebView appeared, so all three of them post at DispatcherPriority.Loaded and let the native control re-push its bounds first. Going the other way had a real bug: the screen-changed branch called a bare Focus() where it had to release the keyboard from the native child, so switching from a terminal to Files silently ate the first keystrokes. Rare before this commit and the primary gesture after it. The tab strip grew a cross inside each tab, a plus that opens the quick-connect palette, and middle-click close. Nested buttons are correct here: Avalonia handles a left press on the cross and deliberately does not handle other buttons, which is exactly what lets middle-click bubble up from the cross as well as the tab. The test is PointerUpdateKind rather than IsMiddleButtonPressed, because the latter reports button state and is also true for a left press made while the middle button happens to be held. The handler is on the tab and not the strip, so the background closes nothing by construction. Plus opens the palette rather than a flyout, since a menu dropping into the WebView's rectangle may or may not composite above a child HWND and this repo does not make rendering claims it has not photographed. Everything a user reads now says keychain. The wire, the database and the cryptographic spec still say vault, deliberately: renaming those is a migration and a protocol change for a word. That split is written down rather than left to be rediscovered as an inconsistency. Four things that were squeezed into the keychain's category rail, or into nothing at all, now have screens. Pinned host keys get one, with fingerprints never truncated and a filter that matches them, because comparing what you have against what the operator published is the whole workflow; the approved date is read out of the item's UUIDv7 rather than added as a column, and says so, since it means first approval and not last use. Keys can be generated in the client, which needed the openssh-key-v1 container written by hand — there is no BCL or NSec helper, and the PKCS#8 route is unverified in the SSH library this uses. The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is Blowfish with a swizzle, in a project whose crypto is otherwise entirely libsodium, for a protection the key's own remarks argue is redundant inside a vault. Generation fills the existing editor and stops, so SAVE stays the one thing that writes. ~/.ssh/config can be imported behind a preview that is ticked per row and writes nothing until the button; IdentityFile records the path and imports the key material only on an explicit opt-in, because reading somebody's private key into a vault is precisely the act this product exists to make deliberate. Match blocks and ProxyJump are reported rather than obeyed — one cannot be evaluated statically and the other has nothing behind it to route with, and a preview that implied otherwise would be worse than one that admits it. Files can be dragged in all four directions that are honestly available. Remote to Explorer does not ship and is not pretended to: the shell wants the bytes during the drop, which needs a virtual file and a native COM data object, outside what Avalonia offers. Note for the next person that Avalonia 12 replaced the drag model outright — DataObject and DataFormats are no-op stubs and IDataObject is not in the reference assembly, so every tutorial written for 11 does not compile here. Hosts can be grouped, flat and never nested. A parent id merged as a scalar lets two offline clients each re-parent A under B and B under A, producing a cycle inside an encrypted payload that no server can police and every reader would have to detect for ever. Membership lives in that payload rather than in the one plaintext concession ADR 0001 allows, whose test is that the relay cannot function without it — nothing on the server reads a group, so what plaintext would hand over is a clustering of the estate for nothing. The plaintext column reserved for it is dropped, provably always null, and the server now refuses a client that sends one; it was never populated, was copied on apply, and was not cleared on delete, so a group id would have outlived the host it described. Snippets insert through xterm rather than through the pump, because xterm is the only thing that knows whether the remote has bracketed paste on, and that is what makes a shell treat embedded newlines as text instead of as execute. The host process moves opaque bytes and never parses output, so it would have to guess, and guessing wrong runs every line. Running is off by default and the copy says the text goes into whatever is there — the terminal has no notion of being at a prompt, and may be in vi or at a password prompt with echo off, so the Enter the user presses themselves is the entire safety property. Connections and keychain changes are recorded as synced encrypted items, which is what makes them auditable by a team later and costs the server knowledge of connection rate and timing from row counts alone. ADR 0001 already concedes it cannot hide that class of metadata; the trade is now written into it rather than left implicit. A connection entry is written once, at close, which is what makes a synced log tractable: nothing to merge, one outbox row, no chance of colliding with itself. Live sessions come from memory, not from the log. The write is void by contract and posts to a bounded channel, because putting an encrypt-and-write on the teardown path of every session is how closing the application comes to take four seconds. A ticket opened before a lock still closes afterwards, since a shell outlives the vault. The activity log hooks the one generic repository every kind writes through, so it cannot miss a caller — which is also why the log kinds themselves declare they are not audited, or the first entry would write an entry about writing an entry. It records the names of the fields that changed and never their values; a log with an old password in it would be a plaintext credential store with no vault around it. Retention is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop rather than on a second timer. That log traffic then broke the status line, which is worth recording because the fix is a shape and not a patch: background sync counted its own log rows as pushed items, so the quiet rule stopped being quiet and every action's message was overwritten a second later by a sync report. The report now separates log rows from user items and the rule reads the latter. S3 buckets appear as a remote in the file browser, behind the same interface an SFTP session implements, so the queue and both panes did not have to learn what they are talking to. Uploads go through a pipe, because the queue wants to write and the SDK wants to read; memory is then bounded by the part size instead of buffering a file to disk twice. Finally, the Windows device key store moved out of the session project, which was the one thing keeping it from being portable — everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the vault code meant a second head could not reference it without dragging Windows along. The seam that made the move free was already there. docs/android-port.md is the audit behind that: what ports, what does not, in order of cost, the four decisions taken, and an inventory of every screen and state the interface has to carry, written so a design can be made from it directly. dotnet build, dotnet test and dotnet format --verify-no-changes are all clean: 1240 tests at zero warnings, including the end-to-end suite against real containers. The manual checks that headless Avalonia cannot make — the drag from Explorer, a generated key against a real host, twelve tabs at the minimum window width — are listed in docs/manual-checks.md and are still outstanding.
This commit is contained in:
@@ -329,13 +329,32 @@
|
||||
runs horizontally and a left bar on a row of tabs reads as a divider between them.
|
||||
-->
|
||||
<Style Selector="Button.tab">
|
||||
<Setter Property="Padding" Value="12,0" />
|
||||
<!-- Less on the right than the left: the close box lives inside the tab and brings its own margin. -->
|
||||
<Setter Property="Padding" Value="12,0,7,0" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The button that opens a connection. A tab in every respect but the marks a tab carries: no active
|
||||
state, because it is never the thing showing, and no right border, because it is not separating
|
||||
itself from anything.
|
||||
-->
|
||||
<Style Selector="Button.tab.plus">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.plus /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="BorderThickness" Value="0,2,0,0" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" />
|
||||
@@ -348,6 +367,34 @@
|
||||
<Setter Property="BorderThickness" Value="0,2,0,0" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A pair of buttons standing in for a two-way choice, inside a pane rather than down a rail. Not the
|
||||
.cat style, which stretches to fill a 176-pixel rail row and would be wrong at this width — and which
|
||||
the category rail's own test counts, so borrowing it would have made this a fourth category.
|
||||
-->
|
||||
<Style Selector="Button.choice">
|
||||
<Setter Property="Padding" Value="10,5" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="9.5" />
|
||||
<Setter Property="LetterSpacing" Value="0.5" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.choice /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="Button.choice:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="Button.choice.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentWash}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
|
||||
<!-- The close box on a tab, and the window controls. Square, quiet, and red only where it means it. -->
|
||||
<Style Selector="Button.close /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
@@ -357,6 +404,15 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The one inside a tab, as opposed to the ones in the titlebar. Rounded and small, because a square
|
||||
full-height red panel inside a tab reads as a divider between two tabs rather than as part of one —
|
||||
which is what it looked like while it was a sibling of the tab instead of a child.
|
||||
-->
|
||||
<Style Selector="Button.close.inline /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Text input. Fluent draws a filled box with a thick focus underline; this design draws a hairline field
|
||||
that changes border colour, and the two do not sit together in one row.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.App.Terminal;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
using DodoSSH.Client.App.Views;
|
||||
@@ -47,6 +50,28 @@ internal sealed partial class DodoSshApp : Application
|
||||
/// nowhere honest to release them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// Puts one line of text on the system clipboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The clipboard is reached through the window, and at composition time there is no window yet — hence
|
||||
/// a closure that looks it up on each call rather than a reference captured now. A machine with no
|
||||
/// clipboard falls through silently here; the view model is the one that decides what to say, and it
|
||||
/// distinguishes "no clipboard on this machine" from "copied" because they are different answers.
|
||||
/// <para>
|
||||
/// A delegate rather than handing the view model an <c>IClipboard</c>, so that nothing in the view
|
||||
/// models needs a visual and every test that drives them stays window-free.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static Func<string, Task> ClipboardWriter(IClassicDesktopStyleApplicationLifetime desktop) =>
|
||||
async text =>
|
||||
{
|
||||
if (TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard })
|
||||
{
|
||||
await clipboard.SetTextAsync(text).ConfigureAwait(false);
|
||||
}
|
||||
};
|
||||
|
||||
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var paths = ClientPaths.Default;
|
||||
@@ -74,7 +99,7 @@ internal sealed partial class DodoSshApp : Application
|
||||
// Chosen once, here, because it is a property of the machine and not of any session. A computer with
|
||||
// a usable TPM gets the store that keeps a device key behind a Windows consent prompt; anything else
|
||||
// gets one that reports itself unavailable, so unlock keeps asking for the passphrase. See ADR 0007.
|
||||
var deviceKeys = DeviceKeyStores.ForThisMachine(paths);
|
||||
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
|
||||
|
||||
var viewModel = new MainWindowViewModel(
|
||||
paths,
|
||||
@@ -93,7 +118,9 @@ internal sealed partial class DodoSshApp : Application
|
||||
// makes a launch after the first one arrive online rather than merely enrolled.
|
||||
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
|
||||
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
.ConfigureAwait(false),
|
||||
|
||||
copyToClipboard: ClipboardWriter(desktop));
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
|
||||
@@ -25,8 +25,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DodoSSH.Client.Import/DodoSSH.Client.Import.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
|
||||
</ItemGroup>
|
||||
@@ -55,3 +57,4 @@
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Picks the device key store this desktop machine can actually offer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
|
||||
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
|
||||
/// passphrase — which is the honest answer rather than a degraded one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
||||
/// to live in <c>DodoSSH.Client.Session</c>, which was the one thing keeping that project from being
|
||||
/// portable: everything else in it is platform-neutral, and a Windows CNG dependency in the middle of the
|
||||
/// vault code meant a second head could not reference it without dragging Windows along. The seam that
|
||||
/// makes the move free is <see cref="IDeviceKeyStore"/>, which was already there — the session takes a
|
||||
/// store and has never known which one. See <c>docs/android-port.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DesktopDeviceKeyStores
|
||||
{
|
||||
/// <summary>The best store this machine supports.</summary>
|
||||
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
return OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()
|
||||
? new WindowsDeviceKeyStore(paths)
|
||||
: new UnavailableDeviceKeyStore();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the device key encrypted to a TPM-resident key whose use requires the user's consent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The consent is enforced by CNG, not by this class</b>, and that distinction is the entire security
|
||||
/// value. A store that read a DPAPI blob after showing its own prompt would be trivially bypassed:
|
||||
/// malware running as the user would skip the prompt, read the file and call
|
||||
/// <c>CryptUnprotectData</c> itself. Here the unwrapping key lives in the TPM under
|
||||
/// <see cref="CngUIProtectionLevels.ProtectKey"/>, so the Windows consent dialog is a condition of
|
||||
/// <em>using</em> the key. Malware can ask; it cannot answer, and the attempt is visible.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is a refinement of what ADR 0007 describes, and stronger than it: the ADR reasoned about a
|
||||
/// gesture gating a protected blob and did not notice that a gate inside the process is not a gate. The
|
||||
/// mechanism recorded there has been corrected to match this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The TPM key is RSA rather than the ECDH one might expect, because it is used to encrypt 32 bytes and
|
||||
/// nothing else. OAEP over a 2048-bit key carries 190, so there is no need for an agreement step, and no
|
||||
/// need for the device key itself to be an algorithm the TPM understands — which is what keeps the DSH1
|
||||
/// device wrap unchanged at X25519. See ADR 0007 for why changing that is a separate decision.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Both ends prompt, and that was measured rather than assumed.</b> Encrypting uses only the public
|
||||
/// half, so it was reasonable to expect sealing to be silent — it is not. <c>CngKey.Create</c> with this
|
||||
/// UI policy blocks on a dialog at <em>creation</em>, because <c>ProtectKey</c> means "protect this key
|
||||
/// with a PIN", and Windows asks the user to set that up there and then. So registering a device shows one
|
||||
/// setup dialog and every unlock shows a consent dialog.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That is the right shape for an opt-in feature, but it has two consequences worth knowing before
|
||||
/// touching this file. Every method except <see cref="IsSupported"/> and the empty case of
|
||||
/// <see cref="TryLoadAsync"/> needs an interactive desktop, so <b>none of them can be exercised by an
|
||||
/// automated test</b> — see <c>WindowsDeviceKeyStoreTests</c> for where that line falls. And a caller must
|
||||
/// treat <see cref="SaveAsync"/> as user-facing: it belongs on a UI thread, behind a button somebody
|
||||
/// pressed, never on a background pass.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
||||
{
|
||||
/// <remarks>
|
||||
/// Versioned, so a future change of algorithm or padding can create a new key beside the old one
|
||||
/// rather than failing to open blobs written by a previous build. A device that cannot be opened
|
||||
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice
|
||||
/// their gesture had stopped working.
|
||||
/// </remarks>
|
||||
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
||||
|
||||
private const string PlatformProvider = "Microsoft Platform Crypto Provider";
|
||||
|
||||
/// <remarks>Shown in the Windows consent dialog, so it has to read as a sentence to a person.</remarks>
|
||||
private const string ConsentPrompt = "Unlock your DodoSSH vault";
|
||||
|
||||
private readonly ClientPaths paths;
|
||||
|
||||
/// <summary>Creates the store.</summary>
|
||||
public WindowsDeviceKeyStore(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
this.paths = paths;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this machine has a TPM the platform provider will hold a key in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Probed by creating a throwaway key and deleting it, rather than by asking the provider whether it
|
||||
/// exists. The provider is registered on machines with no usable TPM as well, and reports itself
|
||||
/// present right up to the point where creating a key fails — so the only honest test is the one that
|
||||
/// does the thing. No UI policy on the probe, so nothing prompts.
|
||||
/// </remarks>
|
||||
internal static bool IsSupported()
|
||||
{
|
||||
var probe = $"DodoSSH.Probe.{Guid.CreateVersion7():N}";
|
||||
|
||||
try
|
||||
{
|
||||
using var key = CngKey.Create(
|
||||
CngAlgorithm.Rsa,
|
||||
probe,
|
||||
new CngKeyCreationParameters { Provider = new CngProvider(PlatformProvider) });
|
||||
|
||||
key.Delete();
|
||||
return true;
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (PlatformNotSupportedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||
ValueTask.FromResult(IsSupported());
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SaveAsync(
|
||||
ReadOnlyMemory<byte> devicePrivateKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var key = OpenOrCreate();
|
||||
using var rsa = new RSACng(key);
|
||||
|
||||
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
|
||||
|
||||
paths.EnsureCreated();
|
||||
|
||||
await File.WriteAllBytesAsync(paths.DeviceKeyFile, sealedKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(paths.DeviceKeyFile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Unseal(sealedKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (File.Exists(paths.DeviceKeyFile))
|
||||
{
|
||||
File.Delete(paths.DeviceKeyFile);
|
||||
}
|
||||
|
||||
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
||||
{
|
||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
||||
key.Delete();
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// This is the call that prompts. Every failure becomes null, and the set is wider than it looks: the
|
||||
/// key may be gone, the user may have cancelled, the TPM may be locked out after too many wrong PINs,
|
||||
/// or the blob may predate a key that has since been replaced. None of them are distinguishable to a
|
||||
/// user and all have the same remedy, so none of them are worth telling apart here — see
|
||||
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
||||
/// </remarks>
|
||||
private static byte[]? Unseal(byte[] sealedKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
||||
using var rsa = new RSACng(key);
|
||||
|
||||
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The UI policy is set at creation and cannot be added afterwards, which is why this opens an existing
|
||||
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
|
||||
/// silently is the one behaviour this whole file exists to prevent.
|
||||
/// </remarks>
|
||||
private static CngKey OpenOrCreate()
|
||||
{
|
||||
var provider = new CngProvider(PlatformProvider);
|
||||
|
||||
if (CngKey.Exists(KeyName, provider))
|
||||
{
|
||||
return CngKey.Open(KeyName, provider);
|
||||
}
|
||||
|
||||
return CngKey.Create(
|
||||
CngAlgorithm.Rsa,
|
||||
KeyName,
|
||||
new CngKeyCreationParameters
|
||||
{
|
||||
Provider = provider,
|
||||
UIPolicy = new CngUIPolicy(CngUIProtectionLevels.ProtectKey, ConsentPrompt),
|
||||
|
||||
// Machine-wide would put one key behind every account on the computer. This key stands for
|
||||
// "this user, on this machine", which is what a device wrap means.
|
||||
KeyCreationOptions = CngKeyCreationOptions.None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Import;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
||||
/// <remarks>
|
||||
/// The checkbox is the whole point of this type. Nothing is written until somebody has looked at the list
|
||||
/// and pressed the button, which is what makes reading a file out of the user's home directory an offer
|
||||
/// rather than an action.
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
{
|
||||
private readonly ImportedHost host;
|
||||
|
||||
internal ImportRowViewModel(ImportedHost host, bool alreadyPresent)
|
||||
{
|
||||
this.host = host;
|
||||
AlreadyPresent = alreadyPresent;
|
||||
|
||||
// A host already in the keychain starts unticked. Importing it again is allowed — a second bookmark
|
||||
// for one machine is a thing people genuinely want — but it should take a click rather than be the
|
||||
// default.
|
||||
IsSelected = !alreadyPresent;
|
||||
}
|
||||
|
||||
internal ImportedHost Host => host;
|
||||
|
||||
internal string Alias => host.Alias;
|
||||
|
||||
internal string Address => host.Address;
|
||||
|
||||
/// <summary>Whether a host with this address is already in the keychain.</summary>
|
||||
internal bool AlreadyPresent { get; }
|
||||
|
||||
internal string Badge => AlreadyPresent ? "already here" : string.Empty;
|
||||
|
||||
internal bool HasBadge => AlreadyPresent;
|
||||
|
||||
/// <summary>How this would authenticate, in the terms the preview can honestly offer.</summary>
|
||||
/// <remarks>
|
||||
/// "a key on disk" rather than "a key", because nothing is imported: the path is recorded and the host
|
||||
/// will ask for a password until somebody binds it to a keychain key. Saying "key" here would promise a
|
||||
/// connection that does not work.
|
||||
/// </remarks>
|
||||
internal string Authentication => host.IdentityFiles.Count switch
|
||||
{
|
||||
0 => "password",
|
||||
1 => $"a key on disk · {host.IdentityFiles[0]}",
|
||||
var count => $"{count} keys on disk · {host.IdentityFiles[0]}",
|
||||
};
|
||||
|
||||
internal bool HasWarnings => host.Warnings.Count > 0;
|
||||
|
||||
internal string Warnings => string.Join(" ", host.Warnings);
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isSelected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reading <c>~/.ssh/config</c> and offering what it found.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two steps, and the first one writes nothing.</b> Scanning reads the file and shows what it means;
|
||||
/// importing is a separate press. That split is the feature: an <c>ssh_config</c> is a file this
|
||||
/// application did not write and may contain forty entries for machines that no longer exist, so the
|
||||
/// interesting question is not "can it be parsed" but "which of these did you actually want".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing reads a private key.</b> An <c>IdentityFile</c> becomes a directive and a note recording the
|
||||
/// path. Pulling someone's <c>~/.ssh/id_ed25519</c> into a keychain as a side effect of importing a config
|
||||
/// is the one thing this screen must not do quietly; there is a GENERATE KEY button on the keychain screen
|
||||
/// for making one deliberately, and pasting an existing one is a deliberate act too.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
|
||||
{
|
||||
internal ObservableCollection<ImportRowViewModel> Rows { get; } = [];
|
||||
|
||||
/// <summary>What was skipped or flattened, at document level.</summary>
|
||||
internal ObservableCollection<string> Warnings { get; } = [];
|
||||
|
||||
/// <summary>The file this would read, shown so nobody has to guess which one it means.</summary>
|
||||
internal string ConfigPath => locator.ConfigPath;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasScanned;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
internal bool HasRows => Rows.Count > 0;
|
||||
|
||||
internal bool HasWarnings => Warnings.Count > 0;
|
||||
|
||||
internal int SelectedCount => Rows.Count(row => row.IsSelected);
|
||||
|
||||
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
|
||||
|
||||
/// <summary>Reads the file and shows what it found. Writes nothing.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Rows.Clear();
|
||||
Warnings.Clear();
|
||||
HasScanned = false;
|
||||
|
||||
if (!locator.Exists)
|
||||
{
|
||||
Status = $"There is no {locator.ConfigPath} on this machine.";
|
||||
RaiseListState();
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
var import = await locator.ReadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
foreach (var host in import.Hosts)
|
||||
{
|
||||
Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host)));
|
||||
}
|
||||
|
||||
foreach (var warning in import.Warnings)
|
||||
{
|
||||
Warnings.Add(warning);
|
||||
}
|
||||
|
||||
HasScanned = true;
|
||||
|
||||
Status = Rows.Count == 0
|
||||
? "Nothing in that file could be imported as a host."
|
||||
: $"Found {Rows.Count} host(s). Nothing is stored until you press the button below.";
|
||||
}
|
||||
catch (IOException failure)
|
||||
{
|
||||
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
|
||||
}
|
||||
catch (UnauthorizedAccessException failure)
|
||||
{
|
||||
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
RaiseListState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stores the ticked hosts.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ImportAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var chosen = Rows.Where(row => row.IsSelected).ToList();
|
||||
|
||||
if (chosen.Count == 0)
|
||||
{
|
||||
Status = "Nothing is ticked.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
var imported = await vault
|
||||
.ImportHostsAsync([.. chosen.Select(row => row.Host.ToSecret())], cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
|
||||
// pressing the button twice harmless and visible rather than harmless and confusing.
|
||||
foreach (var row in Rows.ToList())
|
||||
{
|
||||
Rows[Rows.IndexOf(row)] = new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host));
|
||||
}
|
||||
|
||||
Status = $"Imported {imported} host(s). They are on the Hosts screen.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
RaiseListState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ticks or unticks everything at once.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleAll()
|
||||
{
|
||||
var target = SelectedCount < Rows.Count;
|
||||
|
||||
foreach (var row in Rows)
|
||||
{
|
||||
row.IsSelected = target;
|
||||
}
|
||||
|
||||
RaiseListState();
|
||||
}
|
||||
|
||||
internal void NoteSelectionChanged() => RaiseListState();
|
||||
|
||||
/// <remarks>
|
||||
/// Matched on where a host points rather than on what it is called. Two entries with different aliases
|
||||
/// for one machine are the ordinary shape of an <c>ssh_config</c>, and matching on the name would offer
|
||||
/// to import a duplicate of something already stored under another name.
|
||||
/// </remarks>
|
||||
private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing =>
|
||||
string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase)
|
||||
&& existing.Host.Port == host.Port
|
||||
&& string.Equals(existing.Host.Username, host.Username, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private void RaiseListState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasRows));
|
||||
OnPropertyChanged(nameof(HasWarnings));
|
||||
OnPropertyChanged(nameof(SelectedCount));
|
||||
OnPropertyChanged(nameof(ImportLabel));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Sync;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <summary>One pinned host key, as a row in the list.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
|
||||
/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
|
||||
/// leaves its pin, and so does changing a host's address. Both are correct as <em>trust</em> decisions: the
|
||||
/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
|
||||
/// What was wrong was that nothing ever showed them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
|
||||
/// of pinning one is to compare it with what they published.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class KnownHostRowViewModel(VaultItem<KnownHostSecret> pin, bool isDialledByAHost)
|
||||
{
|
||||
internal Guid EntityId => pin.EntityId;
|
||||
|
||||
internal KnownHostSecret Pin => pin.Secret;
|
||||
|
||||
internal string Host => pin.Secret.Host;
|
||||
|
||||
internal int Port => pin.Secret.Port;
|
||||
|
||||
internal string Algorithm => pin.Secret.Algorithm;
|
||||
|
||||
/// <summary>The endpoint and algorithm, which is what a pin actually identifies.</summary>
|
||||
internal string Label => pin.Secret.Label;
|
||||
|
||||
/// <summary>The fingerprint, in full.</summary>
|
||||
/// <remarks>
|
||||
/// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
|
||||
/// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
|
||||
/// this whole mechanism exists to replace.
|
||||
/// </remarks>
|
||||
internal string Fingerprint => pin.Secret.Fingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Whether any host in this vault actually dials the endpoint this pin is for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
|
||||
/// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
|
||||
/// worth deleting on the user's behalf.
|
||||
/// </remarks>
|
||||
internal bool IsDialledByAHost { get; } = isDialledByAHost;
|
||||
|
||||
internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
|
||||
|
||||
internal string Badge => IsDialledByAHost
|
||||
? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
|
||||
: "no host uses this";
|
||||
|
||||
/// <summary>
|
||||
/// When this pin was approved, as far as anything here can tell.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Derived from the entity id, which this client mints with <see cref="Guid.CreateVersion7()"/> — see
|
||||
/// <see cref="Uuid7Timestamp"/>. No vault item carries a timestamp, so the alternative was no column at
|
||||
/// all. Two honest limits, both stated on the screen rather than only here: it is when the pin was
|
||||
/// created and not when it was last re-approved, and an id minted by anything that does not use v7
|
||||
/// renders as a dash rather than as a guess.
|
||||
/// </remarks>
|
||||
internal string Approved => Uuid7Timestamp.Of(EntityId) is { } stamped
|
||||
? stamped.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
|
||||
: "—";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The host keys this keychain has approved, and how to withdraw one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A wrapper over the vault rather than a view model of its own.</b> Everything about a pin — reading
|
||||
/// them, forgetting one, pushing the change — already lives on <see cref="VaultViewModel"/>, wired into its
|
||||
/// reload and its automatic sync. Lifting that out would mean re-deriving that wiring and keeping two
|
||||
/// copies of it in step. What is genuinely this screen's own is the part below: a filter and the collection
|
||||
/// it produces, neither of which the vault has any use for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The filter matches fingerprints, deliberately.</b> The workflow this screen exists for is "the
|
||||
/// operator published SHA256:xyz — do I have that one?", and a filter that searched only host names would
|
||||
/// answer a question nobody is asking.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultViewModel vault;
|
||||
|
||||
internal KnownHostsViewModel(VaultViewModel vault)
|
||||
{
|
||||
this.vault = vault;
|
||||
|
||||
// The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
|
||||
// copy of a trust decision is the one kind of staleness that matters here.
|
||||
vault.KnownHostPins.CollectionChanged += OnPinsChanged;
|
||||
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>The pins this filter admits, in the order the vault produced them.</summary>
|
||||
/// <remarks>
|
||||
/// A second collection rather than a filtered view over the first, which is the idiom the host sidebar
|
||||
/// already uses: a view would have to be re-sorted and re-notified anyway, and the vault's own ordering
|
||||
/// — host, then port, then algorithm — is the one worth keeping.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<KnownHostRowViewModel> VisiblePins { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string filter = string.Empty;
|
||||
|
||||
/// <summary>The row the list has selected, mirrored onto the vault so its command can act on it.</summary>
|
||||
/// <remarks>
|
||||
/// Pushed down rather than duplicated: <c>ForgetPinCommand</c> reads <c>VaultViewModel.SelectedKnownHost</c>
|
||||
/// and there is no reason for it to learn about this screen.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private KnownHostRowViewModel? selected;
|
||||
|
||||
internal bool HasPins => vault.KnownHostPins.Count > 0;
|
||||
|
||||
internal bool HasVisiblePins => VisiblePins.Count > 0;
|
||||
|
||||
internal bool HasSelection => Selected is not null;
|
||||
|
||||
/// <summary>What the whole list amounts to, in one line.</summary>
|
||||
/// <remarks>
|
||||
/// The unused count is the one worth putting here. A pin nothing dials is not a defect — reaching a
|
||||
/// machine without a bookmark for it is ordinary — but it is the only thing about this list a person
|
||||
/// might want to act on, and counting them is cheaper than reading a badge column.
|
||||
/// </remarks>
|
||||
internal string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
var total = vault.KnownHostPins.Count;
|
||||
|
||||
if (total == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var unused = vault.KnownHostPins.Count(pin => !pin.IsDialledByAHost);
|
||||
var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
|
||||
|
||||
return unused == 0
|
||||
? pins
|
||||
: string.Create(CultureInfo.CurrentCulture, $"{pins} · {unused} that no host dials");
|
||||
}
|
||||
}
|
||||
|
||||
internal string EmptyMessage => HasPins
|
||||
? "No approved host key matches that."
|
||||
: "Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you to "
|
||||
+ "check — approving it puts it here.";
|
||||
|
||||
/// <summary>Withdraws trust in the selected pin.</summary>
|
||||
/// <remarks>
|
||||
/// Forwarded, because the vault's version does three things in an order that matters: forget, reload,
|
||||
/// then push. The push is the load-bearing one — the machines still refusing to connect to a rebuilt
|
||||
/// server are the other ones.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ForgetSelectedAsync()
|
||||
{
|
||||
if (Selected is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
|
||||
|
||||
partial void OnFilterChanged(string value) => Rebuild();
|
||||
|
||||
partial void OnSelectedChanged(KnownHostRowViewModel? value)
|
||||
{
|
||||
vault.SelectedKnownHost = value;
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
}
|
||||
|
||||
private void OnPinsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
|
||||
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
|
||||
// writes that null straight back before the refill can matter.
|
||||
var selectedId = Selected?.EntityId;
|
||||
|
||||
VisiblePins.Clear();
|
||||
|
||||
foreach (var pin in vault.KnownHostPins.Where(Matches))
|
||||
{
|
||||
VisiblePins.Add(pin);
|
||||
}
|
||||
|
||||
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(HasPins));
|
||||
OnPropertyChanged(nameof(HasVisiblePins));
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
}
|
||||
|
||||
private bool Matches(KnownHostRowViewModel pin)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Filter))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var needle = Filter.Trim();
|
||||
|
||||
return Contains(pin.Host, needle)
|
||||
|| Contains(pin.Algorithm, needle)
|
||||
|| Contains(pin.Fingerprint, needle)
|
||||
|| Contains(pin.Port.ToString(CultureInfo.InvariantCulture), needle);
|
||||
}
|
||||
|
||||
private static bool Contains(string haystack, string needle) =>
|
||||
haystack.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Sync;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <summary>Which log the screen is showing.</summary>
|
||||
internal enum LogSection
|
||||
{
|
||||
/// <summary>Connections that were made.</summary>
|
||||
Connections,
|
||||
|
||||
/// <summary>Changes made to keychain items.</summary>
|
||||
Activity,
|
||||
}
|
||||
|
||||
/// <summary>One connection, as a row.</summary>
|
||||
internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> entry, bool isLive)
|
||||
{
|
||||
internal Guid EntityId => entry.EntityId;
|
||||
|
||||
internal string HostLabel => entry.Secret.HostLabel;
|
||||
|
||||
internal string Address => entry.Secret.Address;
|
||||
|
||||
/// <summary>When it started, in the reader's own conventions.</summary>
|
||||
/// <remarks>
|
||||
/// The user's locale, unlike the transfers screen's deliberately invariant UTC column — and the
|
||||
/// difference is the reason each is right. There, two panes are read against one another and a
|
||||
/// sortable, unambiguous format wins; here there is one column and it answers "when was I on that
|
||||
/// machine", which is a question about the reader's own day. <c>InvariantGlobalization</c> is false in
|
||||
/// the client csproj precisely so this works.
|
||||
/// </remarks>
|
||||
internal string Started =>
|
||||
entry.Secret.StartedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// How long it lasted, or that it has not finished.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>"still open" and not a dash.</b> A dash reads as "nothing was recorded", and the two are opposite
|
||||
/// facts — one is an entry the log is missing, the other is a connection that is happening now. A live
|
||||
/// session has no entry at all until it closes, so this state comes from the workspace rather than from
|
||||
/// the vault; see <see cref="LogsViewModel"/>.
|
||||
/// </remarks>
|
||||
internal string Duration => isLive
|
||||
? "still open"
|
||||
: Humanise(entry.Secret.Duration);
|
||||
|
||||
internal bool IsLive => isLive;
|
||||
|
||||
internal string Outcome => entry.Secret.Outcome switch
|
||||
{
|
||||
ConnectionOutcome.Failed => "failed",
|
||||
ConnectionOutcome.Refused => "host key refused",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
internal bool HasOutcome => Outcome.Length > 0;
|
||||
|
||||
/// <summary>Whether this was a terminal or the file browser.</summary>
|
||||
internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal";
|
||||
|
||||
internal string DeviceName => entry.Secret.DeviceName;
|
||||
|
||||
/// <remarks>
|
||||
/// Rounded to whole units and never to more than two of them. A connection log is read to answer "about
|
||||
/// how long was I on that machine", and "1h 4m" answers it where "1:04:37.482" makes the reader do the
|
||||
/// rounding themselves.
|
||||
/// </remarks>
|
||||
private static string Humanise(TimeSpan duration)
|
||||
{
|
||||
if (duration < TimeSpan.FromMinutes(1))
|
||||
{
|
||||
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalSeconds}s");
|
||||
}
|
||||
|
||||
if (duration < TimeSpan.FromHours(1))
|
||||
{
|
||||
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalMinutes}m");
|
||||
}
|
||||
|
||||
return string.Create(
|
||||
CultureInfo.CurrentCulture, $"{(int)duration.TotalHours}h {duration.Minutes}m");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One keychain change, as a row.</summary>
|
||||
internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry)
|
||||
{
|
||||
internal Guid EntityId => entry.EntityId;
|
||||
|
||||
internal string ItemLabel => entry.Secret.ItemLabel;
|
||||
|
||||
internal string ItemKind => entry.Secret.ItemKind;
|
||||
|
||||
internal string Operation => entry.Secret.Operation switch
|
||||
{
|
||||
ActivityOperation.Created => "created",
|
||||
ActivityOperation.Deleted => "deleted",
|
||||
_ => "changed",
|
||||
};
|
||||
|
||||
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
|
||||
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>Which fields changed. Never what they changed to.</summary>
|
||||
internal string ChangedFields => entry.Secret.ChangedFields;
|
||||
|
||||
internal bool HasChangedFields => ChangedFields.Length > 0;
|
||||
|
||||
internal string DeviceName => entry.Secret.DeviceName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What has been connected to, and what has been changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A wrapper over the vault, as the pins and snippets screens are. What is its own is the two lists, the
|
||||
/// section switch and one thing neither log knows: which connections are happening <em>now</em>. An entry is
|
||||
/// written once, when a connection closes, so a live session is not in the vault at all — it is in the
|
||||
/// workspace, and this screen is where the two are put side by side.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Read on demand rather than kept in step.</b> Unlike the host list, a log is not something a background
|
||||
/// sync has to keep fresh on screen — nobody is waiting for their own connection from an hour ago to appear
|
||||
/// — and reading two full logs on every pass would decrypt thousands of entries a minute for a screen
|
||||
/// nobody is looking at.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class LogsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultSession session;
|
||||
private readonly Func<IReadOnlyList<LiveConnection>> live;
|
||||
|
||||
/// <param name="session">The open vault, which holds both logs.</param>
|
||||
/// <param name="live">
|
||||
/// The connections that are open right now. A function rather than a list, because tabs open and close
|
||||
/// while this screen is showing and it is not told about either.
|
||||
/// </param>
|
||||
internal LogsViewModel(VaultSession session, Func<IReadOnlyList<LiveConnection>> live)
|
||||
{
|
||||
this.session = session;
|
||||
this.live = live;
|
||||
}
|
||||
|
||||
/// <summary>Connections, newest first, with anything still open at the top.</summary>
|
||||
internal ObservableCollection<ConnectionLogRowViewModel> Connections { get; } = [];
|
||||
|
||||
/// <summary>Keychain changes, newest first.</summary>
|
||||
internal ObservableCollection<ActivityLogRowViewModel> Activity { get; } = [];
|
||||
|
||||
/// <remarks>
|
||||
/// Settable, and the markup binds two buttons to a command rather than a selector's selection — the same
|
||||
/// idiom the keychain screen's categories use, and for the same reason: a selection binding moves before
|
||||
/// a command can refuse it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private LogSection section;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
internal bool ShowsConnections => Section is LogSection.Connections;
|
||||
|
||||
internal bool ShowsActivity => Section is LogSection.Activity;
|
||||
|
||||
internal bool HasConnections => Connections.Count > 0;
|
||||
|
||||
internal bool HasActivity => Activity.Count > 0;
|
||||
|
||||
internal string EmptyMessage => Section is LogSection.Connections
|
||||
? "Nothing here yet. A connection is recorded when it closes, so an open terminal appears at the "
|
||||
+ "top and gets its line when you close the tab."
|
||||
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
|
||||
+ "names of the fields that changed, never their contents.";
|
||||
|
||||
/// <summary>Shows one of the two logs.</summary>
|
||||
[RelayCommand]
|
||||
private void ShowSection(LogSection section) => Section = section;
|
||||
|
||||
/// <summary>Re-reads both logs.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
Status = string.Empty;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Leaving the screen.
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads both logs into the lists.</summary>
|
||||
internal async Task ReloadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connections = await session.ConnectionLog
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var activity = await session.ActivityLog
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Connections.Clear();
|
||||
|
||||
// The live ones first and above everything, because they are the only rows in this list that are
|
||||
// still changing. They carry no entity id — there is no vault item for them yet — which is why they
|
||||
// are built from a different source and marked as live rather than merged into the same shape.
|
||||
foreach (var open in live())
|
||||
{
|
||||
Connections.Add(new ConnectionLogRowViewModel(
|
||||
new VaultItem<ConnectionLogSecret>(
|
||||
Guid.Empty,
|
||||
new ConnectionLogSecret
|
||||
{
|
||||
HostLabel = open.HostLabel,
|
||||
Address = open.Address,
|
||||
StartedAt = open.StartedAt,
|
||||
DeviceName = open.DeviceName,
|
||||
},
|
||||
Version: 0,
|
||||
HasUnsyncedChanges: false,
|
||||
IsBlocked: false,
|
||||
IsReadOnly: false),
|
||||
isLive: true));
|
||||
}
|
||||
|
||||
foreach (var entry in connections.Items.OrderByDescending(item => item.Secret.StartedAt))
|
||||
{
|
||||
Connections.Add(new ConnectionLogRowViewModel(entry, isLive: false));
|
||||
}
|
||||
|
||||
Activity.Clear();
|
||||
|
||||
foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At))
|
||||
{
|
||||
Activity.Add(new ActivityLogRowViewModel(entry));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasConnections));
|
||||
OnPropertyChanged(nameof(HasActivity));
|
||||
}
|
||||
|
||||
partial void OnSectionChanged(LogSection value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowsConnections));
|
||||
OnPropertyChanged(nameof(ShowsActivity));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A connection that is open right now.</summary>
|
||||
/// <param name="HostLabel">What the host is called.</param>
|
||||
/// <param name="Address">The address as dialled.</param>
|
||||
/// <param name="StartedAt">When it opened.</param>
|
||||
/// <param name="DeviceName">This machine.</param>
|
||||
/// <remarks>
|
||||
/// Supplied by the shell, which owns the tabs. It is deliberately not read out of the vault: a connection
|
||||
/// that is still running has no entry there, because an entry is written once and at close — which is what
|
||||
/// keeps a synced log from needing a merge.
|
||||
/// </remarks>
|
||||
internal sealed record LiveConnection(
|
||||
string HostLabel,
|
||||
string Address,
|
||||
DateTimeOffset StartedAt,
|
||||
string DeviceName);
|
||||
@@ -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,15 @@ 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 IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
@@ -188,7 +252,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 +264,16 @@ 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;
|
||||
|
||||
// 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 +345,26 @@ 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 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 +462,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 +498,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 +552,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 +582,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 +651,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 +755,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 +941,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 +991,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 +1145,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 +1172,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 +1195,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 +1433,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 +1488,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 +1552,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 +1589,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 +1637,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 +1778,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 +1824,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 +1848,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 +1876,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 +1983,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 +1999,48 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <summary>Where a snippet is about to be inserted, and whether it can be.</summary>
|
||||
/// <param name="SessionId">The terminal, or null when there is none open.</param>
|
||||
/// <param name="Label">What that terminal is called, for the button.</param>
|
||||
internal sealed record InsertTarget(uint? SessionId, string Label)
|
||||
{
|
||||
/// <summary>The answer when no tab is open.</summary>
|
||||
internal static InsertTarget None { get; } = new(null, string.Empty);
|
||||
|
||||
/// <summary>Whether there is somewhere to insert into.</summary>
|
||||
internal bool IsAvailable => SessionId is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The saved commands in this keychain, and how to get one into a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A wrapper over the vault, as <c>KnownHostsViewModel</c> is</b>, and for the same reason: reading
|
||||
/// snippets, storing one and pushing the change already live on <see cref="VaultViewModel"/>, wired into its
|
||||
/// reload and its automatic sync. What belongs here is the filter, the editor and the insert — none of which
|
||||
/// the vault has any use for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The safety story is the copy, not the code.</b> A terminal is one input stream with no notion of being
|
||||
/// at a prompt: the remote may be in <c>vi</c>, or at a <c>sudo</c> password prompt with echo off, and
|
||||
/// without shell integration this client cannot tell. So inserting is always "type this into whatever is
|
||||
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
|
||||
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultViewModel vault;
|
||||
private readonly Func<InsertTarget> target;
|
||||
private readonly Func<uint, string, bool, CancellationToken, Task<bool>> insert;
|
||||
|
||||
/// <param name="vault">The open keychain, which owns the list and the writing.</param>
|
||||
/// <param name="target">
|
||||
/// Which terminal is selected right now. A function rather than a value, because the answer changes every
|
||||
/// time the user clicks a tab and this screen is not told about that.
|
||||
/// </param>
|
||||
/// <param name="insert">
|
||||
/// Puts text into a terminal. Injected rather than taking the workspace, so the screen can be tested
|
||||
/// without a renderer — the thing worth testing here is which text goes and whether Enter follows it, and
|
||||
/// neither of those is a property of the transport.
|
||||
/// </param>
|
||||
internal SnippetsViewModel(
|
||||
VaultViewModel vault,
|
||||
Func<InsertTarget> target,
|
||||
Func<uint, string, bool, CancellationToken, Task<bool>> insert)
|
||||
{
|
||||
this.vault = vault;
|
||||
this.target = target;
|
||||
this.insert = insert;
|
||||
|
||||
vault.Snippets.CollectionChanged += OnSnippetsChanged;
|
||||
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>The snippets this filter admits, in the order the vault produced them.</summary>
|
||||
internal ObservableCollection<SnippetRowViewModel> Visible { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string filter = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private SnippetRowViewModel? selected;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isEditing;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorCommand = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorNotes = string.Empty;
|
||||
|
||||
/// <summary>Whether the snippet being edited is one that presses Enter for you.</summary>
|
||||
/// <remarks>
|
||||
/// Off for every new snippet, and the checkbox says what it means rather than what it is called. It is
|
||||
/// per snippet rather than a preference, because <c>ls -la</c> and <c>rm -rf /var/lib/postgresql</c> do
|
||||
/// not want the same answer and one switch would end up left on by whoever needed it for the first.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool editorRunsOnInsert;
|
||||
|
||||
/// <summary>The snippet being edited, or null when the editor would create one.</summary>
|
||||
[ObservableProperty]
|
||||
private Guid? editingId;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
internal bool HasSnippets => vault.Snippets.Count > 0;
|
||||
|
||||
internal bool HasVisible => Visible.Count > 0;
|
||||
|
||||
internal bool HasSelection => Selected is not null;
|
||||
|
||||
/// <summary>Whether there is a terminal to insert into at all.</summary>
|
||||
internal bool CanInsert => HasSelection && target().IsAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// What the insert button says, naming the terminal it will type into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The tab is named on the button on purpose. This screen is not the terminal — the strip above it is —
|
||||
/// so "INSERT" alone would leave the user to work out which of six open tabs is about to receive a
|
||||
/// command, at the moment that is least convenient to be wrong about.
|
||||
/// </remarks>
|
||||
internal string InsertLabel => target() is { IsAvailable: true } open
|
||||
? $"TYPE INTO {open.Label}"
|
||||
: "NO TERMINAL OPEN";
|
||||
|
||||
/// <summary>What the run button says, or empty when the selected snippet does not run.</summary>
|
||||
internal string RunLabel => target() is { IsAvailable: true } open ? $"RUN IN {open.Label}" : string.Empty;
|
||||
|
||||
/// <summary>Whether the selected snippet is one marked as running on its own.</summary>
|
||||
internal bool SelectionRuns => Selected?.RunsOnInsert is true;
|
||||
|
||||
internal string EmptyMessage => HasSnippets
|
||||
? "No snippet matches that."
|
||||
: "Nothing saved yet. A snippet is a command you keep, so you can put it into a terminal without "
|
||||
+ "typing it again.";
|
||||
|
||||
/// <summary>Starts a new snippet.</summary>
|
||||
[RelayCommand]
|
||||
private void New()
|
||||
{
|
||||
EditingId = null;
|
||||
EditorLabel = string.Empty;
|
||||
EditorCommand = string.Empty;
|
||||
EditorNotes = string.Empty;
|
||||
EditorRunsOnInsert = false;
|
||||
IsEditing = true;
|
||||
Status = "Adding a snippet.";
|
||||
}
|
||||
|
||||
/// <summary>Opens the selected snippet for editing.</summary>
|
||||
[RelayCommand]
|
||||
private void Edit()
|
||||
{
|
||||
if (Selected is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = "This snippet was written by a newer version of DodoSSH. Update before editing it.";
|
||||
return;
|
||||
}
|
||||
|
||||
EditingId = row.EntityId;
|
||||
EditorLabel = row.Snippet.Label;
|
||||
EditorCommand = row.Snippet.Command;
|
||||
EditorNotes = row.Snippet.Notes ?? string.Empty;
|
||||
EditorRunsOnInsert = row.Snippet.RunsOnInsert;
|
||||
IsEditing = true;
|
||||
Status = $"Editing {row.Label}.";
|
||||
}
|
||||
|
||||
/// <summary>Abandons the editor.</summary>
|
||||
[RelayCommand]
|
||||
private void Cancel()
|
||||
{
|
||||
IsEditing = false;
|
||||
EditingId = null;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Stores the editor's contents.</summary>
|
||||
[RelayCommand]
|
||||
private async Task SaveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var snippet = new SnippetSecret
|
||||
{
|
||||
Label = EditorLabel.Trim(),
|
||||
|
||||
// Not trimmed, and this is the field where that matters most. A here-document's terminator has
|
||||
// to arrive on a line of its own; tidying the trailing newline off it leaves the shell waiting
|
||||
// for one that never comes, which reads as the snippet having hung the terminal.
|
||||
Command = EditorCommand,
|
||||
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
||||
RunsOnInsert = EditorRunsOnInsert,
|
||||
};
|
||||
|
||||
var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
if (!saved)
|
||||
{
|
||||
Status = vault.Status;
|
||||
return;
|
||||
}
|
||||
|
||||
IsEditing = false;
|
||||
EditingId = null;
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>Deletes the selected snippet.</summary>
|
||||
[RelayCommand]
|
||||
private async Task DeleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Selected is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types the selected snippet into the selected terminal, without pressing Enter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The button that does not run anything, and it is the one a user should reach for. What it inserts
|
||||
/// arrives as pasted text — bracketed, when the remote has asked for that — so a multi-line snippet sits
|
||||
/// at the prompt as text and waits for a person to look at it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private Task InsertAsync(CancellationToken cancellationToken) => SendAsync(false, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Types the selected snippet into the selected terminal and presses Enter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only offered for a snippet whose own <see cref="SnippetSecret.RunsOnInsert"/> is set, so that "this
|
||||
/// one runs" is a decision made once, while writing the snippet, rather than a button sitting next to
|
||||
/// every one of them.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private Task RunAsync(CancellationToken cancellationToken) =>
|
||||
SelectionRuns ? SendAsync(true, cancellationToken) : Task.CompletedTask;
|
||||
|
||||
internal void Detach() => vault.Snippets.CollectionChanged -= OnSnippetsChanged;
|
||||
|
||||
/// <summary>Re-reads which terminal is selected, after the shell says one has changed.</summary>
|
||||
/// <remarks>
|
||||
/// Pushed by the shell rather than observed from here. The tab list belongs to the shell and outlives
|
||||
/// this screen — a session survives locking the keychain — so a subscription in this direction would be
|
||||
/// a screen holding the shell alive.
|
||||
/// </remarks>
|
||||
internal void TargetChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanInsert));
|
||||
OnPropertyChanged(nameof(InsertLabel));
|
||||
OnPropertyChanged(nameof(RunLabel));
|
||||
}
|
||||
|
||||
private async Task SendAsync(bool execute, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Selected is not { } row || target() is not { SessionId: { } sessionId } open)
|
||||
{
|
||||
Status = "Open a terminal first — a snippet has to go somewhere.";
|
||||
return;
|
||||
}
|
||||
|
||||
var delivered = await insert(sessionId, row.Snippet.Command, execute, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Status = delivered
|
||||
? execute
|
||||
? $"Ran '{row.Label}' in {open.Label}."
|
||||
: $"Typed '{row.Label}' into {open.Label}. Press Enter there to run it."
|
||||
: $"{open.Label} is no longer connected, so nothing was sent.";
|
||||
}
|
||||
|
||||
partial void OnFilterChanged(string value) => Rebuild();
|
||||
|
||||
partial void OnSelectedChanged(SnippetRowViewModel? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(CanInsert));
|
||||
OnPropertyChanged(nameof(SelectionRuns));
|
||||
}
|
||||
|
||||
partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating));
|
||||
|
||||
/// <summary>Whether the editor would create a snippet rather than replace one.</summary>
|
||||
internal bool IsCreating => EditingId is null;
|
||||
|
||||
private void OnSnippetsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
|
||||
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
|
||||
// writes that null straight back before the refill can matter.
|
||||
var selectedId = Selected?.EntityId;
|
||||
|
||||
Visible.Clear();
|
||||
|
||||
foreach (var snippet in vault.Snippets.Where(Matches))
|
||||
{
|
||||
Visible.Add(snippet);
|
||||
}
|
||||
|
||||
Selected = Visible.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(HasSnippets));
|
||||
OnPropertyChanged(nameof(HasVisible));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The command is searched as well as the name and the notes, because half of what somebody remembers
|
||||
/// about a saved command is a word that was in it.
|
||||
/// </remarks>
|
||||
private bool Matches(SnippetRowViewModel row)
|
||||
{
|
||||
var needle = Filter.Trim();
|
||||
|
||||
if (needle.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Contains(row.Label) || Contains(row.Snippet.Command) || Contains(row.Snippet.Notes);
|
||||
|
||||
bool Contains(string? value) =>
|
||||
value is not null && value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,24 @@ using System.Globalization;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.ObjectStore;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Transfer;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <summary>What sort of remote the file browser's right-hand pane is showing.</summary>
|
||||
internal enum RemoteKind
|
||||
{
|
||||
/// <summary>A host, over SFTP.</summary>
|
||||
Host,
|
||||
|
||||
/// <summary>An S3-compatible bucket.</summary>
|
||||
Bucket,
|
||||
}
|
||||
|
||||
/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
|
||||
/// <param name="Name">What the segment is called.</param>
|
||||
/// <param name="Path">The absolute path that reaches it.</param>
|
||||
@@ -234,7 +246,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
private VaultViewModel? vault;
|
||||
private VaultKnownHostStore? knownHosts;
|
||||
private ISftpSession? session;
|
||||
private IRemoteFileStore? session;
|
||||
private ConnectionRecorder? connectionLog;
|
||||
|
||||
/// <summary>How a bucket is opened, or null in a build that was not given one.</summary>
|
||||
private IObjectStoreFactory? objectStores;
|
||||
|
||||
/// <summary>The open SFTP connection, as the log will record it, or null when there is none.</summary>
|
||||
/// <remarks>
|
||||
/// Held rather than rebuilt at close time, because by then the session is being disposed and the host
|
||||
/// row it came from may have been replaced by a background sync. The address is the one that was
|
||||
/// actually dialled, which is the whole point of capturing it at connect.
|
||||
/// </remarks>
|
||||
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
|
||||
|
||||
private bool disposed;
|
||||
|
||||
internal TransfersViewModel(ISftpSessionFactory sftp, TimeProvider clock)
|
||||
@@ -243,7 +268,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
// The supplier answers with whatever session is current at the moment a transfer starts, which is
|
||||
// what lets a queue survive a disconnect and reconnect without every queued row failing.
|
||||
queue = new FileTransferQueue(_ => Task.FromResult(RequireSession()), clock);
|
||||
queue = new FileTransferQueue(_ => Task.FromResult<IRemoteFileStore>(RequireSession()), clock);
|
||||
queue.Changed += OnTransferChanged;
|
||||
|
||||
// The three "is there anything in it" flags follow their collections rather than being raised by
|
||||
@@ -265,6 +290,49 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[ObservableProperty]
|
||||
private HostRowViewModel? selectedHost;
|
||||
|
||||
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
|
||||
/// <inheritdoc cref="Hosts" path="/remarks" />
|
||||
internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private ObjectStoreRowViewModel? selectedBucket;
|
||||
|
||||
/// <summary>
|
||||
/// Which sort of remote the right-hand pane is about to open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two buttons and a command rather than one picker holding both kinds, which is the opposite of what
|
||||
/// the host editor's authentication picker does — and the reason is that these two are not
|
||||
/// interchangeable the way a key and a password are. A host brings a password box, a host key prompt and
|
||||
/// a mismatch refusal with it; a bucket brings none of those and has no equivalent. One picker would
|
||||
/// mean a form whose surrounding half appears and disappears with the selection, which is a worse thing
|
||||
/// to look at than two clearly separate choices.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Settable, and the markup binds buttons rather than a selector's selection, for the reason the
|
||||
/// keychain's categories do: a selection binding moves before a command could refuse it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private RemoteKind remote;
|
||||
|
||||
/// <summary>Whether the picker is showing hosts.</summary>
|
||||
internal bool ShowsHostPicker => Remote is RemoteKind.Host;
|
||||
|
||||
/// <summary>Whether the picker is showing buckets.</summary>
|
||||
internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;
|
||||
|
||||
/// <summary>
|
||||
/// What the button that opens the remote says.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// "Connect" is wrong for a bucket and worth not saying: S3 is request-per-operation, so nothing is
|
||||
/// connected and nothing stays open. A word that implied otherwise would make the absence of a
|
||||
/// DISCONNECT step look like a bug rather than the shape of the protocol.
|
||||
/// </remarks>
|
||||
internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT";
|
||||
|
||||
/// <remarks>
|
||||
/// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
|
||||
/// separate authentication, so a password typed to open a terminal has not been offered here — and a
|
||||
@@ -282,6 +350,26 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[ObservableProperty]
|
||||
private bool isConnected;
|
||||
|
||||
/// <summary>
|
||||
/// Whether something is being dragged over the local pane, and whether it would be accepted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two flags rather than one tri-state, because the markup binds visibility and Avalonia has no
|
||||
/// three-way binding — and because the refusing state is worth showing rather than merely not showing
|
||||
/// the accepting one. A pane that lights up nowhere while something is dragged over it reads as a
|
||||
/// window that has stopped responding.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isLocalDropTarget;
|
||||
|
||||
/// <inheritdoc cref="IsLocalDropTarget" />
|
||||
[ObservableProperty]
|
||||
private bool isRemoteDropTarget;
|
||||
|
||||
/// <inheritdoc cref="IsLocalDropTarget" />
|
||||
[ObservableProperty]
|
||||
private bool isRemoteDropRefused;
|
||||
|
||||
/// <summary>The account and endpoint actually dialled, once connected.</summary>
|
||||
[ObservableProperty]
|
||||
private string? connectedTo;
|
||||
@@ -298,7 +386,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
/// <summary>Whether the chosen host will want something typed into the password box.</summary>
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
|
||||
ShowsHostPicker && SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
|
||||
|
||||
// ---- The remote pane ----
|
||||
|
||||
@@ -370,10 +458,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };
|
||||
|
||||
/// <summary>Takes an unlocked vault, so the host list has something in it.</summary>
|
||||
internal void Attach(VaultViewModel openVault, VaultKnownHostStore hostKeys)
|
||||
/// <param name="openVault">The open keychain.</param>
|
||||
/// <param name="hostKeys">The pins this screen's own trust decisions are written to.</param>
|
||||
/// <param name="log">
|
||||
/// Where an SFTP session is recorded, or null to record none. Arrives here rather than being read off
|
||||
/// the vault, for the reason the recorder itself exists: it outlives the vault, and a session still open
|
||||
/// when the keychain locks still ends somewhere.
|
||||
/// </param>
|
||||
internal void Attach(
|
||||
VaultViewModel openVault,
|
||||
VaultKnownHostStore hostKeys,
|
||||
ConnectionRecorder? log = null,
|
||||
IObjectStoreFactory? buckets = null)
|
||||
{
|
||||
vault = openVault;
|
||||
knownHosts = hostKeys;
|
||||
connectionLog = log;
|
||||
objectStores = buckets;
|
||||
|
||||
RefreshHosts();
|
||||
|
||||
@@ -402,13 +503,78 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
knownHosts = null;
|
||||
|
||||
Hosts.Clear();
|
||||
Buckets.Clear();
|
||||
SelectedHost = null;
|
||||
SelectedBucket = null;
|
||||
TypedPassword = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Opens a file-transfer session on the chosen host.</summary>
|
||||
/// <summary>Shows one of the two kinds of remote in the picker.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
private void ShowRemote(RemoteKind kind) => Remote = kind;
|
||||
|
||||
/// <summary>Opens the chosen remote, whichever kind it is.</summary>
|
||||
[RelayCommand]
|
||||
private Task ConnectAsync(CancellationToken cancellationToken) =>
|
||||
Remote is RemoteKind.Bucket
|
||||
? OpenBucketAsync(cancellationToken)
|
||||
: ConnectToHostAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the chosen bucket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// No host key prompt, no password box, and no connect step: S3 is request-per-operation, so the factory
|
||||
/// only builds a client and the first listing is what actually tests the keys and the endpoint. That is
|
||||
/// why the failure this reports is a listing failure rather than a connection one — there is no
|
||||
/// connection to fail.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It goes through the same session field, the same queue and the same panes as a host, because by this
|
||||
/// point it is an <c>IRemoteFileStore</c> like any other. Everything below this method was written for
|
||||
/// SFTP and needed no change.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task OpenBucketAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (objectStores is not { } factory)
|
||||
{
|
||||
Status = "This build cannot open buckets.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedBucket is not { } row)
|
||||
{
|
||||
Status = "Choose a bucket first.";
|
||||
return;
|
||||
}
|
||||
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Opening {row.Label}…",
|
||||
async () =>
|
||||
{
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
session = factory.Open(row.Store);
|
||||
|
||||
IsConnected = true;
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
|
||||
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Opened {row.Label}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Opens a file-transfer session on the chosen host.</summary>
|
||||
private async Task ConnectToHostAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (vault is not { } open || SelectedHost is not { } row)
|
||||
{
|
||||
@@ -457,6 +623,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{request.Username}@{request.Host}:{request.Port}");
|
||||
|
||||
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second
|
||||
// login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
|
||||
// would disagree with the host's — and anybody comparing the two would be right to believe
|
||||
// the host.
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}.";
|
||||
@@ -618,34 +790,147 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[RelayCommand]
|
||||
private void Download()
|
||||
{
|
||||
if (SelectedRemoteEntry is not { IsFile: true } row)
|
||||
if (SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
Status = "Choose a file on the host to download.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = Path.Combine(LocalPath, row.Name);
|
||||
|
||||
queue.Enqueue(TransferDirection.Download, destination, row.FullPath, row.Entry.Length);
|
||||
|
||||
Status = $"Queued {row.Name} for download into {LocalPath}.";
|
||||
QueueDownloads([row]);
|
||||
}
|
||||
|
||||
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
|
||||
[RelayCommand]
|
||||
private void Upload()
|
||||
{
|
||||
if (SelectedLocalEntry is not { IsFile: true } row)
|
||||
if (SelectedLocalEntry is not { } row)
|
||||
{
|
||||
Status = "Choose a file on this machine to upload.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = SftpPath.Combine(RemotePath, row.Name);
|
||||
QueueUploads([row.FullPath]);
|
||||
}
|
||||
|
||||
queue.Enqueue(TransferDirection.Upload, row.FullPath, destination, row.Entry.Length);
|
||||
/// <summary>
|
||||
/// Queues every one of these local paths for upload into the remote directory showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one path both the button and a drop go through, so there is one set of rules about what can be
|
||||
/// queued rather than two that have to agree. The button hands it one path; a drop hands it however many
|
||||
/// were dragged, from this window's own pane or from the file manager.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Directories are skipped and counted.</b> The queue moves files: there is no recursive upload, and
|
||||
/// silently ignoring the folder somebody just dragged would look like a transfer that failed to start.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reported per item, not per drop.</b> The queue refuses to overwrite, so a drop of five files where
|
||||
/// two names already exist is three transfers and two refusals — and "the drop failed" would be wrong
|
||||
/// about all five.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void QueueUploads(IReadOnlyList<string> paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
Status = $"Queued {row.Name} for upload into {RemotePath}.";
|
||||
if (!IsConnected)
|
||||
{
|
||||
Status = "Connect to a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
var queued = 0;
|
||||
var directories = 0;
|
||||
var missing = 0;
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
directories++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Between the drag starting and the drop landing, a file can be moved or deleted — and the
|
||||
// paths in an OS drop come from another process, which is not obliged to be right about them.
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var length = new FileInfo(path).Length;
|
||||
var destination = SftpPath.Combine(RemotePath, Path.GetFileName(path));
|
||||
|
||||
queue.Enqueue(TransferDirection.Upload, path, destination, length);
|
||||
queued++;
|
||||
}
|
||||
|
||||
Status = Describe(queued, "upload into", RemotePath, directories, missing);
|
||||
}
|
||||
|
||||
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
|
||||
/// <inheritdoc cref="QueueUploads" path="/remarks" />
|
||||
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
Status = "Connect to a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
var queued = 0;
|
||||
var directories = 0;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!row.IsFile)
|
||||
{
|
||||
directories++;
|
||||
continue;
|
||||
}
|
||||
|
||||
queue.Enqueue(
|
||||
TransferDirection.Download,
|
||||
Path.Combine(LocalPath, row.Name),
|
||||
row.FullPath,
|
||||
row.Entry.Length);
|
||||
|
||||
queued++;
|
||||
}
|
||||
|
||||
Status = Describe(queued, "download into", LocalPath, directories, missing: 0);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One sentence for both directions and every shape of partial success. What it must never do is stay
|
||||
/// silent about the difference: a drop of six that queued four and reported "queued 4" leaves somebody
|
||||
/// looking for the other two in a queue they are not in.
|
||||
/// </remarks>
|
||||
private static string Describe(int queued, string verb, string destination, int directories, int missing)
|
||||
{
|
||||
var files = queued == 1 ? "1 file" : $"{queued} files";
|
||||
var said = queued == 0
|
||||
? "Nothing was queued."
|
||||
: $"Queued {files} for {verb} {destination}.";
|
||||
|
||||
if (directories > 0)
|
||||
{
|
||||
var folders = directories == 1 ? "1 folder was" : $"{directories} folders were";
|
||||
said += $" {folders} skipped — only files can be transferred.";
|
||||
}
|
||||
|
||||
if (missing > 0)
|
||||
{
|
||||
var gone = missing == 1 ? "1 item was" : $"{missing} items were";
|
||||
said += $" {gone} no longer there.";
|
||||
}
|
||||
|
||||
return said;
|
||||
}
|
||||
|
||||
/// <summary>Stops one transfer.</summary>
|
||||
@@ -913,10 +1198,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}
|
||||
|
||||
SelectedHost ??= Hosts.FirstOrDefault();
|
||||
|
||||
Buckets.Clear();
|
||||
|
||||
foreach (var bucket in open.ObjectStores)
|
||||
{
|
||||
Buckets.Add(bucket);
|
||||
}
|
||||
|
||||
SelectedBucket ??= Buckets.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>The session, or a failure a queue row can carry.</summary>
|
||||
private ISftpSession RequireSession() =>
|
||||
private IRemoteFileStore RequireSession() =>
|
||||
session ?? throw new InvalidOperationException(
|
||||
"This screen is not connected to a host, so there is nowhere to move the file.");
|
||||
|
||||
@@ -928,6 +1222,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// Written whole here rather than through an open/close ticket, because this connection is not one
|
||||
// the terminal workspace ever knew about — it has no session id, and borrowing one would collide
|
||||
// with a real terminal's.
|
||||
if (connected is { } record)
|
||||
{
|
||||
connected = null;
|
||||
|
||||
connectionLog?.Record(
|
||||
record.Address,
|
||||
record.HostLabel,
|
||||
record.HostId,
|
||||
ConnectionKind.Sftp,
|
||||
record.StartedAt,
|
||||
TimeProvider.System.GetUtcNow(),
|
||||
ConnectionOutcome.Closed);
|
||||
}
|
||||
|
||||
IsConnected = false;
|
||||
ConnectedTo = null;
|
||||
RemotePath = string.Empty;
|
||||
@@ -990,6 +1301,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
|
||||
/// <remarks>
|
||||
/// The password box follows this as well as the host, because it is shown only for a host that asks for
|
||||
/// one — and a bucket never does. Without this, switching to BUCKET would leave a password box beside a
|
||||
/// picker that has nothing to do with passwords.
|
||||
/// </remarks>
|
||||
partial void OnRemoteChanged(RemoteKind value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowsHostPicker));
|
||||
OnPropertyChanged(nameof(ShowsBucketPicker));
|
||||
OnPropertyChanged(nameof(ConnectLabel));
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
}
|
||||
|
||||
partial void OnIsConnectedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,10 +65,37 @@
|
||||
-->
|
||||
<ListBox Grid.Row="2" x:Name="HostList" Focusable="True"
|
||||
IsVisible="{Binding AreHostsExpanded}"
|
||||
ItemsSource="{Binding VisibleHosts}"
|
||||
SelectedItem="{Binding SelectedHost}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
ItemsSource="{Binding SidebarRows}"
|
||||
SelectedItem="{Binding SelectedSidebarRow}">
|
||||
|
||||
<!--
|
||||
Two kinds of row in one list, chosen by type. It has to be one ListBox: it owns the selection and it
|
||||
is where keyboard focus lands when the terminal gives it back, neither of which survives a list per
|
||||
group. A vault with no groups produces no heading rows at all, so this is the list it always was.
|
||||
|
||||
The heading is a row rather than a container, which means the control will happily select it. That is
|
||||
turned back into the previous host selection in the view model — see SelectedSidebarRow — because
|
||||
CONNECT, EDIT and DELETE all act on a host and a highlighted heading is not one.
|
||||
-->
|
||||
<ListBox.DataTemplates>
|
||||
|
||||
<DataTemplate DataType="vm:SidebarGroupHeader">
|
||||
<Button Classes="flat grouphead" Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Chevron}" Foreground="{StaticResource TextFaint}"
|
||||
FontSize="8" VerticalAlignment="Center" Margin="0,0,6,0" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="vm:HostRowViewModel">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0,5,10,5">
|
||||
|
||||
<!-- The accent strip a selected row carries; see the style in App.axaml. -->
|
||||
@@ -106,7 +133,8 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
|
||||
</ListBox.DataTemplates>
|
||||
</ListBox>
|
||||
|
||||
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
|
||||
@@ -148,6 +176,20 @@
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<!--
|
||||
Which group this host is filed under. Inside the encrypted payload like everything else here, so
|
||||
the server learns nothing about how the estate is organised — and a group the vault no longer has
|
||||
keeps a placeholder entry, so that editing the port cannot quietly unfile the host.
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
|
||||
SelectedItem="{Binding EditorSelectedGroup}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupChoice">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
|
||||
Content="Connect through the server relay" />
|
||||
<!--
|
||||
@@ -193,7 +235,7 @@
|
||||
-->
|
||||
<Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsConfirmingDeletion}">
|
||||
IsVisible="{Binding IsConfirmingHostDeletion}">
|
||||
<views:ConfirmDeleteCard />
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.HostsScreen"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The hosts screen: the list of machines, and what this application has to say about the one that is
|
||||
selected.
|
||||
|
||||
It used to be the list beside a terminal, and the terminal is no longer here. The tab strip is above
|
||||
every screen now, so a terminal is a surface the whole window switches to rather than a column on this
|
||||
one — see MainWindowViewModel.ShellSurface. What that leaves this screen is the thing its name always
|
||||
promised: an overview.
|
||||
|
||||
In its own file, rather than left in MainWindow.axaml, because nothing inside that window can be laid
|
||||
out by a test — WebView2's adapter refuses the headless session's thread — so markup that stays there
|
||||
is markup nobody can measure. The four blocks in the right column are exactly the ones that most needed
|
||||
measuring: two host key prompts and a conflict log, all three of which appear only in states a person
|
||||
has to reproduce by hand.
|
||||
|
||||
Its data context is the shell, not the vault, so that the sidebar can be handed the vault and everything
|
||||
else can bind Vault.* — the same split MainWindow.axaml had. See MainWindow.axaml's own note on why the
|
||||
two cannot be put on one element.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="268,*">
|
||||
|
||||
<views:HostSidebar Grid.Column="0" x:Name="Sidebar" DataContext="{Binding Vault}" />
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<!--
|
||||
Connecting. A password box only for a host that asks to be — a host bound to a stored credential or
|
||||
a key wants nothing typed here — and a sentence in its place when it does not, because "nothing
|
||||
needs typing" and "something needs typing and the box has not appeared yet" look identical and only
|
||||
one of them is fine.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="12,8" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
|
||||
PasswordChar="•" Width="200" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
||||
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Keychain and bind this host to it in the host's own editor." />
|
||||
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
|
||||
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="1">
|
||||
|
||||
<!--
|
||||
Host key prompts. Unknown and changed look deliberately different: one is a decision, the other is
|
||||
a refusal. Presenting a changed key with a "continue" button is how users are taught to click
|
||||
through the one warning that matters.
|
||||
-->
|
||||
<Border Padding="12,10" Background="{StaticResource WarnWash}"
|
||||
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasPendingHostKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
|
||||
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding Vault.TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding Vault.RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
||||
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose "Forget host key" first. There is deliberately no way to continue from here."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
|
||||
and shown; without this panel it would be last-writer-wins with a longer explanation.
|
||||
|
||||
Bounded and scrollable, which it was not while it lived in the window. It sits on an Auto row above
|
||||
a star row, and an ItemsControl with no ceiling grows without limit — so a pass that merged twenty
|
||||
items pushed everything below it off the bottom of a screen nobody could scroll. It went unnoticed
|
||||
for as long as it did because no test could lay this markup out; that is the other half of why this
|
||||
file exists.
|
||||
-->
|
||||
<Border Padding="12,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasConflicts}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Some changes could not be merged automatically."
|
||||
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
|
||||
<ScrollViewer MaxHeight="180" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictRowViewModel">
|
||||
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
|
||||
CornerRadius="4">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
|
||||
Foreground="{StaticResource TextDim}"
|
||||
IsVisible="{Binding HasDetail}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
|
||||
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The overview proper: what is known about the host the list has selected.
|
||||
|
||||
Every fact here is one the sidebar already computes, and that is deliberate. This column was a
|
||||
terminal until this screen stopped hosting one, and filling it with something that needed new state
|
||||
would be inventing a feature to fill a rectangle. What it is for is the question the screen now has
|
||||
to answer — "which machine is this, and how will it let me in" — before the answer scrolls past in a
|
||||
list of forty.
|
||||
-->
|
||||
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
|
||||
<Panel Margin="24">
|
||||
|
||||
<StackPanel Spacing="10" HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="heading" Text="{Binding Vault.SelectedHost.Label}"
|
||||
VerticalAlignment="Center" />
|
||||
<Border Classes="chip" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHost.IsConnected}">
|
||||
<TextBlock Text="CONNECTED" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Vault.SelectedHost.Address}"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
|
||||
<TextBlock Classes="hint" Text="{Binding Vault.SelectedHost.Authentication}" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" MaxWidth="440" TextWrapping="Wrap"
|
||||
Text="Press CONNECT, or double-click the host in the list. The terminal opens in the strip above and stays there while you look at anything else." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
MaxWidth="440" TextWrapping="Wrap"
|
||||
Text="Choose a host on the left to see what it is and how it authenticates. Ctrl+K searches them by name."
|
||||
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNull}}" />
|
||||
|
||||
</Panel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
Groups: making them, renaming them, and taking them away.
|
||||
|
||||
Here rather than on the Keychain screen, because a group is not a secret — it is how this screen's
|
||||
list is arranged, and the arranging belongs beside the thing arranged. Filing a host into one is done
|
||||
in the host's own editor, on the left, for the same reason its key and its password are.
|
||||
|
||||
One text box for both adding and renaming. A group has exactly one field, so a separate rename form
|
||||
would be this box with a different heading; GroupSaveLabel is what says which of the two is about to
|
||||
happen.
|
||||
-->
|
||||
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<StackPanel Spacing="8">
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="label" Text="GROUPS" Foreground="{StaticResource TextDim}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" VerticalAlignment="Center" TextWrapping="Wrap"
|
||||
Text="Headings for the list on the left. Which group a host is in is part of the host, and stays encrypted." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Horizontal, because a group is a name and a count: a vertical list of one-line rows would take a
|
||||
third of this column to say what a row of chips says in one line.
|
||||
-->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding Vault.HasGroups}">
|
||||
<ListBox ItemsSource="{Binding Vault.Groups}" SelectedItem="{Binding Vault.SelectedGroup}"
|
||||
Background="Transparent" MaxHeight="72">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
|
||||
<StackPanel Margin="2,4" Spacing="1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
|
||||
FontSize="11.5" />
|
||||
<Border Classes="chip warn" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</ScrollViewer>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}">
|
||||
<TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180"
|
||||
FontSize="11" MinHeight="26" Padding="8,3" />
|
||||
<Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}"
|
||||
Command="{Binding Vault.SaveGroupCommand}" />
|
||||
<Button Classes="ghost" Content="RENAME SELECTED" Command="{Binding Vault.EditGroupCommand}" />
|
||||
<Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Swapped for the buttons rather than stacked under them, as the sidebar's own question is, so
|
||||
DELETE cannot be pressed again while its answer is on screen. It asks its own question only: the
|
||||
two panels share one pending deletion, and the sidebar checks the same way.
|
||||
-->
|
||||
<Border Padding="8" Background="{StaticResource DangerWash}" CornerRadius="4"
|
||||
IsVisible="{Binding Vault.IsConfirmingGroupDeletion}">
|
||||
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,27 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The hosts screen: the host list, and an overview of the one that is selected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the shell rather than the vault, unlike <see cref="HostSidebar"/> and
|
||||
/// <see cref="VaultScreen"/>. The sidebar is handed the vault from inside the markup; everything else here
|
||||
/// reaches it through <c>Vault.*</c>. That split is not tidiness — this element's visibility is the shell's
|
||||
/// business and the sidebar's bindings are the vault's, and an element carrying both resolves the first
|
||||
/// against the second, where it does not exist.
|
||||
/// </remarks>
|
||||
internal sealed partial class HostsScreen : UserControl
|
||||
{
|
||||
public HostsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// Forwarded to the sidebar, which answers for itself: the host list can be folded away, and
|
||||
/// <c>Focus()</c> on a collapsed control is measurably a no-op that is not replayed when the control is
|
||||
/// revealed. Nothing in the right column can take the keyboard — it is a heading and three sentences.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget => Sidebar.KeyboardTarget;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.ImportScreen"
|
||||
x:DataType="vm:ImportViewModel">
|
||||
|
||||
<!--
|
||||
Importing ~/.ssh/config.
|
||||
|
||||
A preview and then a button, rather than one action, and that is the whole design. This reads a file
|
||||
the application did not write, out of the user's home directory, and a real ssh_config often holds
|
||||
forty entries for machines that stopped existing years ago. So scanning writes nothing and the list
|
||||
says what each entry means; importing is a separate press on a set somebody has looked at.
|
||||
|
||||
Reachable from the preferences screen and not from the nav rail. It is a task rather than a
|
||||
destination — done once, or once a year — and a seventh rail entry would cost every screen a slot for
|
||||
something almost nobody is looking at.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="IMPORT SSH CONFIG" FontSize="11"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ConfigPath}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="2" Classes="ghost" Content="SCAN" Command="{Binding ScanCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Reads the file and shows what it found. Nothing is stored." />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="11" Margin="14,12,14,0"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<!--
|
||||
What could not be honoured, above the list rather than beside it. Every one of these is a way the
|
||||
import is quieter than the file — an ignored Match block, a dropped ProxyCommand — and a person
|
||||
comparing the two needs to be told before they conclude the parser lost something.
|
||||
-->
|
||||
<Border Grid.Row="2" Margin="14,12,14,0" Padding="10,8" CornerRadius="4"
|
||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||
BorderThickness="1" IsVisible="{Binding HasWarnings}">
|
||||
<ItemsControl ItemsSource="{Binding Warnings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="10"
|
||||
TextWrapping="Wrap" Margin="0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="3" RowDefinitions="Auto,*" Margin="0,12,0,0" IsVisible="{Binding HasRows}">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="14,0,14,6">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="AUTHENTICATION" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
||||
<StackPanel Margin="14,0">
|
||||
<Grid ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="0,7">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="11"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="4" Classes="chip" HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
||||
</Border>
|
||||
</Grid>
|
||||
<TextBlock Classes="hint" Text="{Binding Warnings}" FontSize="9.5" Margin="34,0,0,8"
|
||||
TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
IsVisible="{Binding HasWarnings}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="4" Padding="14,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding HasRows}">
|
||||
<StackPanel Spacing="8">
|
||||
<!--
|
||||
Said before the button, not after. A key path is recorded and the key itself is not read: that is
|
||||
the difference between a bookmark that connects and one that asks for a password, and somebody
|
||||
who is not told will conclude the import was broken.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain. Nothing here reaches into ~/.ssh for private key material." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="TICK ALL / NONE" Command="{Binding ToggleAllCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,37 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Importing hosts from <c>~/.ssh/config</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A task rather than a destination, which is why it is reached from preferences and not from the nav rail.
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportScreen : UserControl
|
||||
{
|
||||
public ImportScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// The count on the import button is derived from the ticks, and a CheckBox bound with
|
||||
// {Binding IsSelected} tells its own row and nothing else. Rather than have every row hold a
|
||||
// reference back to the screen, the screen listens for the event they all bubble.
|
||||
AddHandler(ToggleButton.IsCheckedChangedEvent, OnTickChanged, RoutingStrategies.Bubble);
|
||||
}
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
internal IInputElement KeyboardTarget => this;
|
||||
|
||||
private void OnTickChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ImportViewModel import)
|
||||
{
|
||||
import.NoteSelectionChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.KnownHostsScreen"
|
||||
x:DataType="vm:KnownHostsViewModel">
|
||||
|
||||
<!--
|
||||
The host keys this keychain has approved.
|
||||
|
||||
These were a category on the keychain screen, alongside SSH keys and passwords, and they do not belong
|
||||
there: the other two are things a person creates and edits, and a pin is a decision recorded at the
|
||||
moment of connecting. Nobody goes looking for one in a list of credentials. They are also the only items
|
||||
with a workflow of their own — compare a fingerprint against what the operator published — and that
|
||||
workflow needs a filter and a column layout the shared table could not give them.
|
||||
|
||||
The data layer did not move and did not change. Every pin is still a vault item, still end-to-end
|
||||
encrypted, still synced; see KnownHostSecret. What is here is a screen over VaultViewModel.KnownHostPins.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="*,244">
|
||||
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="HOST KEYS" FontSize="11"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Summary}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
Matches fingerprints as well as host names, which is the point of it. What somebody does with
|
||||
this screen is check whether a published SHA256:… is the one they approved, and searching only
|
||||
by name would answer a different question.
|
||||
-->
|
||||
<TextBox Grid.Column="2" x:Name="PinFilter" Text="{Binding Filter}" Width="240"
|
||||
PlaceholderText="filter by host or fingerprint" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,6,14,6"
|
||||
IsVisible="{Binding HasVisiblePins}">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1"
|
||||
Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="ALGORITHM" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="FINGERPRINT" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="5" Classes="label" Text="APPROVED" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="2" x:Name="PinList" Focusable="True"
|
||||
ItemsSource="{Binding VisiblePins}"
|
||||
SelectedItem="{Binding Selected}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
|
||||
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,7,14,7">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="11"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="9"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<!--
|
||||
Never trimmed, and this column is why the table is laid out the way it is. The only thing
|
||||
anybody does with a fingerprint is compare it character by character against one an operator
|
||||
published; an ellipsis in the middle turns that into a glance, which is the habit the whole
|
||||
mechanism exists to replace.
|
||||
-->
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="340"
|
||||
IsVisible="{Binding !HasVisiblePins}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="14,16" Spacing="6">
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Choose a pinned key to see it in full, and to withdraw it."
|
||||
IsVisible="{Binding !HasSelection}" />
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
|
||||
<Border Classes="chip warn" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding !Selected.IsDialledByAHost}">
|
||||
<TextBlock Text="no host uses this" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="label" Text="FINGERPRINT" Margin="0,12,0,4" />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
|
||||
FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="label" Text="APPROVED" Margin="0,12,0,4" />
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="10"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
<!--
|
||||
Said rather than implied. No vault item carries a timestamp, so this date is read back out of
|
||||
the item's own version 7 id — which records when the pin was created and knows nothing about
|
||||
it being re-approved since. Presenting that as "last used" would be inventing a fact.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Taken from the item's identifier, so it is when this key was first approved — not when it was last checked. Nothing here records that." />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,12,0,0"
|
||||
Text="A pin outlives whatever it was approved for: deleting a host leaves it, and so does changing a host's address. That is deliberate — trust is about the endpoint, not the bookmark." />
|
||||
|
||||
<Button Classes="danger" Content="FORGET THIS HOST KEY" Margin="0,12,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding ForgetSelectedCommand}"
|
||||
ToolTip.Tip="Withdraws trust. The next connection to this endpoint asks you to check the fingerprint again, which is the safe direction to be wrong in — and it is the way back from a server that was legitimately rebuilt." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,26 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The host keys this keychain has approved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is a <c>KnownHostsViewModel</c>, which is a screen-scoped wrapper over the vault rather
|
||||
/// than an owner of anything: the pins, the reload and the withdrawal all still belong to
|
||||
/// <c>VaultViewModel</c>. See that class for why.
|
||||
/// </remarks>
|
||||
internal sealed partial class KnownHostsScreen : UserControl
|
||||
{
|
||||
public KnownHostsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// The filter box rather than the list, unlike the keychain screen. This screen is reached to answer a
|
||||
/// question — is this fingerprint one of mine — and the first thing anybody does is type part of it.
|
||||
/// The box is also always there, where the list is empty on a fresh keychain, and <c>Focus()</c> on a
|
||||
/// collapsed control is a no-op that is not replayed.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget => PinFilter;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.LogsScreen"
|
||||
x:DataType="vm:LogsViewModel">
|
||||
|
||||
<!--
|
||||
What has been connected to, and what has been changed.
|
||||
|
||||
Two logs behind one screen, chosen by two buttons rather than by a selector's selection — the same idiom
|
||||
the keychain screen's categories use, and for the same reason: a selection binding moves before a command
|
||||
can refuse it.
|
||||
|
||||
Both are ordinary synced keychain items, encrypted like everything else. The server holds them and cannot
|
||||
read a single field; what it does learn is that rows exist and when they were written, which ADR 0001
|
||||
records as the metadata this design cannot hide.
|
||||
|
||||
The connections list shows anything still open at the top, marked "still open" rather than with a dash. A
|
||||
dash would read as a missing recording, and the two are opposite facts — an entry is written once, when a
|
||||
connection closes, so a live session is deliberately not in the vault yet.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="LOGS" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Margin="0,0,14,0" />
|
||||
|
||||
<Button Grid.Column="1" Classes="flat cat" Content="CONNECTIONS"
|
||||
Classes.active="{Binding ShowsConnections}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:LogSection.Connections}" />
|
||||
<Button Grid.Column="2" Classes="flat cat" Content="KEYCHAIN"
|
||||
Classes.active="{Binding ShowsActivity}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:LogSection.Activity}" />
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="14,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<Button Grid.Column="4" Classes="ghost" Content="REFRESH" Command="{Binding RefreshCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ Connections ============ -->
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsConnections}">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="14,6,14,6"
|
||||
IsVisible="{Binding HasConnections}">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="LASTED" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="KIND" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="STARTED" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="ConnectionList" Focusable="True"
|
||||
ItemsSource="{Binding Connections}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="0,6,14,6">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="14,0,8,0">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="11" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding Duration}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
Only when there is something to say. A connection that opened and closed says nothing
|
||||
here; one that was refused says so, and that is the row worth finding in a long list.
|
||||
-->
|
||||
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding HasOutcome}">
|
||||
<TextBlock Text="{Binding Outcome}" FontSize="8.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="420"
|
||||
IsVisible="{Binding !HasConnections}" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ Keychain changes ============ -->
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsActivity}">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6"
|
||||
IsVisible="{Binding HasActivity}">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="ITEM" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="TYPE" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="WHAT" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="FIELDS" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="ActivityList" Focusable="True" ItemsSource="{Binding Activity}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
|
||||
<Grid ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="11"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Operation}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<!--
|
||||
The names of the fields that changed, and never what they changed to. A log that recorded
|
||||
an old password would be a plaintext credential store with a vault drawn around it.
|
||||
-->
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasChangedFields}" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="420"
|
||||
IsVisible="{Binding !HasActivity}" />
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,26 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// What has been connected to, and what has been changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is a <c>LogsViewModel</c>, a screen-scoped wrapper over the open session. Both logs are
|
||||
/// ordinary synced keychain items; nothing about them is local.
|
||||
/// </remarks>
|
||||
internal sealed partial class LogsScreen : UserControl
|
||||
{
|
||||
public LogsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// Whichever list is on screen, because this screen has no filter box and a collapsed control cannot
|
||||
/// take focus — <c>Focus()</c> on one is a no-op that nothing replays when it is revealed. The lists are
|
||||
/// focusable explicitly for the same reason the host list is: Avalonia leaves focus to the items, and an
|
||||
/// empty list has none.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget =>
|
||||
DataContext is ViewModels.LogsViewModel { ShowsActivity: true } ? ActivityList : ConnectionList;
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
Focusable="True">
|
||||
|
||||
<!--
|
||||
The shell window: a titlebar it draws itself, a nav rail, one screen at a time, and a status bar.
|
||||
The shell window: a titlebar it draws itself, a nav rail, a tab strip, one surface at a time, and a
|
||||
status bar.
|
||||
|
||||
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
|
||||
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
|
||||
@@ -28,6 +29,13 @@
|
||||
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
||||
chrome worth having.
|
||||
|
||||
TWO SURFACES, ONE RECTANGLE.
|
||||
|
||||
The tab strip is above everything the nav rail leads to, so a terminal opened from any screen stays
|
||||
visible and reachable from every other one. What that costs is that the terminal and the pages now share
|
||||
the area beneath the strip, and exactly one of them may occupy it. That is the whole of ShellSurface: an
|
||||
enum rather than two flags, so there is no way to write the state where both are showing.
|
||||
|
||||
THE OCCLUSION RULE, which every arrangement in this file obeys.
|
||||
|
||||
NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window composites
|
||||
@@ -36,15 +44,16 @@
|
||||
buttons unreachable, which this window has shipped once already.
|
||||
|
||||
So anything that would occupy the terminal's rectangle collapses the terminal instead, and
|
||||
IsTerminalShowing is the one place that decision is made: a locked vault, a screen other than Hosts, or
|
||||
the quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
|
||||
IsTerminalShowing is the one place that decision is made: a locked vault, the page area, or the
|
||||
quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
|
||||
control when the control is attached to the visual tree, not when it is laid out or shown, so WebView2
|
||||
still starts, still loads the page and still lets the renderer attach its socket while it is false. It
|
||||
only swaps ShowInBounds for HideWithSize, and flipping it back re-pushes the bounds.
|
||||
|
||||
What the first connection after unlocking actually depends on is the await in
|
||||
VaultViewModel.ConnectAsync — the data plane drops frames when no renderer is attached, so the gate is
|
||||
that await, never this control's visibility.
|
||||
Note where IsShowingPages is bound: on the one Panel that holds every screen, not on each screen. That
|
||||
is what makes the rule hard to break rather than merely documented — a sixth screen added inside that
|
||||
Panel cannot forget to collapse, because it is not the thing doing the collapsing. Its own IsVisible
|
||||
only chooses between the pages.
|
||||
|
||||
Two nearby alternatives are wrong. Removing the control from the tree instead — conditional content, a
|
||||
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
|
||||
@@ -64,167 +73,118 @@
|
||||
|
||||
<views:NavRail Grid.Column="0" />
|
||||
|
||||
<Panel Grid.Column="1">
|
||||
<!--
|
||||
The rail is full height and the strip is not, so the strip spans exactly the area it navigates.
|
||||
The other arrangement — strip above rail — would put a row of tabs over a column of destinations
|
||||
they have nothing to do with.
|
||||
-->
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*">
|
||||
|
||||
<!-- ============ HOSTS + TERMINAL ============ -->
|
||||
<Grid ColumnDefinitions="268,*" IsVisible="{Binding IsHostsScreen}">
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
|
||||
<views:HostSidebar Grid.Column="0" x:Name="Hosts" DataContext="{Binding Vault}" />
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
<!-- ============ THE PAGES ============ -->
|
||||
<Panel IsVisible="{Binding IsShowingPages}">
|
||||
|
||||
<!--
|
||||
Connecting. A password box only for a host that asks to be — a host bound to a stored
|
||||
credential or a key wants nothing typed here — and a sentence in its place when it does not,
|
||||
because "nothing needs typing" and "something needs typing and the box has not appeared yet"
|
||||
look identical and only one of them is fine.
|
||||
Bound directly rather than wrapped, unlike the two below it: this screen's data context is
|
||||
the shell's, so IsHostsScreen resolves. It hands the vault to the sidebar from inside its
|
||||
own markup.
|
||||
-->
|
||||
<Border Grid.Row="1" Padding="12,8" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
|
||||
PasswordChar="•" Width="200" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
||||
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Vault and bind this host to it in the host's own editor." />
|
||||
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
|
||||
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="2">
|
||||
|
||||
<!--
|
||||
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
|
||||
other is a refusal. Presenting a changed key with a "continue" button is how users are
|
||||
taught to click through the one warning that matters.
|
||||
-->
|
||||
<Border Padding="12,10" Background="{StaticResource WarnWash}"
|
||||
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasPendingHostKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
|
||||
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding Vault.TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding Vault.RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
||||
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose "Forget host key" first. There is deliberately no way to continue from here."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode
|
||||
is kept and shown; without this panel it would be last-writer-wins with a longer
|
||||
explanation.
|
||||
-->
|
||||
<Border Padding="12,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasConflicts}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Some changes could not be merged automatically."
|
||||
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictRowViewModel">
|
||||
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
|
||||
CornerRadius="4">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
|
||||
Foreground="{StaticResource TextDim}"
|
||||
IsVisible="{Binding HasDetail}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
|
||||
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
<views:HostsScreen x:Name="HostsPane" IsVisible="{Binding IsHostsScreen}" />
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
|
||||
process tree, so twenty tabs would cost twenty of them.
|
||||
|
||||
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
|
||||
then falls back to its default of true, and the occlusion comes back silently. Not reachable
|
||||
at runtime — the DataContext is set before the window is shown — but it is what the previewer
|
||||
does.
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and
|
||||
putting both on one element resolves IsVisible against that view model, where
|
||||
IsTransfersScreen does not exist.
|
||||
-->
|
||||
<NativeWebView Grid.Row="3" x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
<!-- ============ KEYCHAIN ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the reason the vault column always was: this
|
||||
element's visibility is the shell's business and its data context is the vault, and put both
|
||||
on one element and IsVisible resolves against the vault as well, where IsVaultScreen does
|
||||
not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsVaultScreen}">
|
||||
<views:VaultScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ HOST KEYS ============ -->
|
||||
<!--
|
||||
Wrapped, like the two above and for the same reason: its data context is the screen's own
|
||||
view model, where IsKnownHostsScreen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsKnownHostsScreen}">
|
||||
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ SNIPPETS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsSnippetsScreen}">
|
||||
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ LOGS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsLogsScreen}">
|
||||
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ TEAM ============ -->
|
||||
<views:NotBuiltScreen IsVisible="{Binding IsTeamScreen}"
|
||||
Title="TEAM"
|
||||
Milestone="MILESTONE M3"
|
||||
Summary="The design shows members, roles, shared keychains and pending invitations. The server has team tables from its first migration and not one endpoint that reads them, and its access service refuses every keychain that is not your own — so there is nobody to list and no shared keychain to open."
|
||||
Instead="Everything you have is yours alone today: your hosts are in the sidebar on the Hosts screen, and your keys, passwords and approved host keys are on the Keychain screen. Sharing a credential means handing it over out of band, and rotating it afterwards.">
|
||||
<views:NotBuiltScreen.Missing>
|
||||
<sys:List x:TypeArguments="x:String">
|
||||
<x:String>Endpoints for teams, membership, roles and invitations — the server exposes eight routes and none of them is about people (DodoSSH.Api).</x:String>
|
||||
<x:String>Access to a keychain somebody else owns: VaultAccessService resolves personal ownership and denies everything else (DodoSSH.Api).</x:String>
|
||||
<x:String>Roles on the wire. VaultSummary carries a nullable TeamId and an opaque permissions flag, and no DTO gives either a meaning (DodoSSH.Contracts).</x:String>
|
||||
<x:String>Per-member facts the design shows — two-factor state, last-active time, avatars — none of which the server records.</x:String>
|
||||
<x:String>Sharing an item, which is the point of the screen: today a keychain key is sealed to one account, and sharing means re-wrapping it for another.</x:String>
|
||||
</sys:List>
|
||||
</views:NotBuiltScreen.Missing>
|
||||
</views:NotBuiltScreen>
|
||||
|
||||
<!-- ============ PREFERENCES ============ -->
|
||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||
|
||||
<!-- ============ IMPORT ============ -->
|
||||
<!--
|
||||
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
|
||||
the others whose data context is their own view model.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsImportScreen}">
|
||||
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
|
||||
process tree, so twenty tabs would cost twenty of them.
|
||||
|
||||
A sibling of the page area rather than a child of any screen, which is the structural half of
|
||||
the tab rework: the terminal belongs to the window now, not to the hosts screen.
|
||||
|
||||
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
|
||||
then falls back to its default of true, and the occlusion comes back silently. Not reachable
|
||||
at runtime — the DataContext is set before the window is shown — but it is what the previewer
|
||||
does.
|
||||
-->
|
||||
<NativeWebView x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and putting
|
||||
both on one element resolves IsVisible against that view model, where IsTransfersScreen does not
|
||||
exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ VAULT ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the reason the vault column always was: this element's
|
||||
visibility is the shell's business and its data context is the vault, and put both on one element
|
||||
and IsVisible resolves against the vault as well, where IsVaultScreen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsVaultScreen}">
|
||||
<views:VaultScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ TEAM ============ -->
|
||||
<views:NotBuiltScreen IsVisible="{Binding IsTeamScreen}"
|
||||
Title="TEAM"
|
||||
Milestone="MILESTONE M3"
|
||||
Summary="The design shows members, roles, shared vaults and pending invitations. The server has team tables from its first migration and not one endpoint that reads them, and its access service refuses every vault that is not your own — so there is nobody to list and no shared vault to open."
|
||||
Instead="Everything you have is yours alone today: your hosts are in the sidebar on the Hosts screen, and your keys, passwords and approved host keys are on the Vault screen. Sharing a credential means handing it over out of band, and rotating it afterwards.">
|
||||
<views:NotBuiltScreen.Missing>
|
||||
<sys:List x:TypeArguments="x:String">
|
||||
<x:String>Endpoints for teams, membership, roles and invitations — the server exposes eight routes and none of them is about people (DodoSSH.Api).</x:String>
|
||||
<x:String>Access to a vault somebody else owns: VaultAccessService resolves personal ownership and denies everything else (DodoSSH.Api).</x:String>
|
||||
<x:String>Roles on the wire. VaultSummary carries a nullable TeamId and an opaque permissions flag, and no DTO gives either a meaning (DodoSSH.Contracts).</x:String>
|
||||
<x:String>Per-member facts the design shows — two-factor state, last-active time, avatars — none of which the server records.</x:String>
|
||||
<x:String>Sharing an item, which is the point of the screen: today a vault key is sealed to one account, and sharing means re-wrapping it for another.</x:String>
|
||||
</sys:List>
|
||||
</views:NotBuiltScreen.Missing>
|
||||
</views:NotBuiltScreen>
|
||||
|
||||
<!-- ============ PREFERENCES ============ -->
|
||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||
|
||||
</Panel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
@@ -262,12 +222,12 @@
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Choose a vault passphrase" />
|
||||
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your vault." />
|
||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
||||
<Button Classes="accent" Content="CREATE MY VAULT" Command="{Binding EnrollCommand}"
|
||||
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
@@ -275,14 +235,14 @@
|
||||
|
||||
<!--
|
||||
Shown once and impossible to skip. This is the only moment the code exists, and losing it
|
||||
together with the passphrase means the vault is unrecoverable — there is no server-side reset by
|
||||
design.
|
||||
together with the passphrase means the keychain is unrecoverable — there is no server-side reset
|
||||
by design.
|
||||
-->
|
||||
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Write this recovery code down" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the vault: nobody — including whoever runs the server — can recover it for you." />
|
||||
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the keychain: nobody — including whoever runs the server — can recover it for you." />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding RecoveryCode}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
@@ -66,14 +67,56 @@ internal sealed partial class MainWindow : Window
|
||||
/// keyboard nowhere: focus does not stay where it was, because collapsing the control it was on clears
|
||||
/// it outright, and the fallback's own <c>Focus()</c> call was failing silently.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The terminal answers first, and it has to, because <see cref="MainWindowViewModel.Screen"/> still
|
||||
/// names a page while a terminal is showing — that is the point of it. Asking the screen would hand the
|
||||
/// keyboard to a host list nobody can see.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private IInputElement KeyboardHome => shell?.Screen switch
|
||||
private IInputElement KeyboardHome => shell switch
|
||||
{
|
||||
ShellScreen.Vault => VaultPane.KeyboardTarget,
|
||||
ShellScreen.Hosts => Hosts.KeyboardTarget,
|
||||
{ IsTerminalShowing: true } => Terminal,
|
||||
{ Screen: ShellScreen.Vault } => VaultPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Asks for the terminal to take the keyboard, once layout has run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Posted, not called.</b> Every path that reaches here has revealed the WebView in this same turn —
|
||||
/// a session opened from another screen, a tab clicked while a page was showing, the palette closing
|
||||
/// back onto a terminal. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so
|
||||
/// focusing microseconds ahead of that pass races exactly the thing the focus depends on, and the
|
||||
/// symptom is silent: a terminal that looks selected and receives nothing until it is clicked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>DispatcherPriority.Loaded</c> runs after layout. It is the same fix and the same reasoning as
|
||||
/// <see cref="QuickConnect"/>'s, which posts its own focus for the same race in the other direction.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Re-checked inside the post rather than trusted from outside it, because a turn is long enough for the
|
||||
/// user to have navigated away — closing the last tab, or clicking the rail — and stealing the keyboard
|
||||
/// into a collapsed WebView would leave the window with nothing focused at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void FocusTerminalWhenLaidOut() =>
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (shell is { IsTerminalShowing: true })
|
||||
{
|
||||
Terminal.Focus();
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard belongs once the vault is no longer open.
|
||||
/// </summary>
|
||||
@@ -160,14 +203,19 @@ internal sealed partial class MainWindow : Window
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A bare <c>Focus()</c> is the whole fix in this direction: <c>NativeWebView.OnGotFocus</c> pushes
|
||||
/// Win32 focus into WebView2 for us. It has to happen while the control is visible, which it is —
|
||||
/// a session can only be opened from the hosts screen of an unlocked vault, and that is exactly the
|
||||
/// state in which the terminal is showing. Focus() on a collapsed control is measurably a no-op and is
|
||||
/// not replayed when it is revealed.
|
||||
/// <c>NativeWebView.OnGotFocus</c> pushes Win32 focus into WebView2 for us, so a <c>Focus()</c> call is
|
||||
/// the whole fix in this direction — but it has to happen while the control is visible, and it no longer
|
||||
/// reliably is at this instant. A session can now be opened from any screen, so this event routinely
|
||||
/// arrives in the same turn that revealed the WebView. Hence the post; see
|
||||
/// <see cref="FocusTerminalWhenLaidOut"/>.
|
||||
/// </remarks>
|
||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => Terminal.Focus();
|
||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||
|
||||
/// <remarks>
|
||||
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
|
||||
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
|
||||
/// point at which "does this one return early" stops being obvious to a reader.
|
||||
/// </remarks>
|
||||
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (shell is not { } viewModel)
|
||||
@@ -175,54 +223,117 @@ internal sealed partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal))
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
var unlocked = viewModel.IsUnlocked;
|
||||
case nameof(MainWindowViewModel.IsUnlocked):
|
||||
OnVaultOpenedOrClosed(viewModel);
|
||||
break;
|
||||
|
||||
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
|
||||
// change, and reacting to all of them would move focus during setup and sign-in.
|
||||
if (wasUnlocked && !unlocked)
|
||||
{
|
||||
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
||||
}
|
||||
case nameof(MainWindowViewModel.IsSearching):
|
||||
OnPaletteToggled(viewModel);
|
||||
break;
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
// One arm for both, deliberately. They mean the same thing to this handler — what the window is
|
||||
// showing may have changed — and answering them separately would make the order of two
|
||||
// PropertyChanged raises decide the outcome. Connecting from the palette moves both.
|
||||
case nameof(MainWindowViewModel.Surface):
|
||||
case nameof(MainWindowViewModel.Screen):
|
||||
OnShowingSomethingElse(viewModel);
|
||||
break;
|
||||
|
||||
case nameof(MainWindowViewModel.SelectedTab):
|
||||
OnSelectedTabChanged(viewModel);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVaultOpenedOrClosed(MainWindowViewModel viewModel)
|
||||
{
|
||||
var unlocked = viewModel.IsUnlocked;
|
||||
|
||||
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
|
||||
// change, and reacting to all of them would move focus during setup and sign-in.
|
||||
if (wasUnlocked && !unlocked)
|
||||
{
|
||||
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
||||
}
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
||||
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
||||
/// which is a moment this handler is measurably ahead of: it runs from the view model's
|
||||
/// <c>PropertyChanged</c>, before the binding that reveals the control, and <c>Focus()</c> on a control
|
||||
/// that is still collapsed is a no-op that is not replayed when it is revealed.
|
||||
/// </remarks>
|
||||
private void OnPaletteToggled(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (viewModel.IsSearching)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is
|
||||
// expected to start typing into immediately — but the palette does that for itself when it becomes
|
||||
// visible, which is a moment this handler is measurably ahead of: it runs from the view model's
|
||||
// PropertyChanged, before the binding that reveals the control, and Focus() on a control that is
|
||||
// still collapsed is a no-op that is not replayed when it is revealed.
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsSearching), StringComparison.Ordinal))
|
||||
// Closing the palette over a terminal reveals the WebView in this same turn, so it needs the posted
|
||||
// focus rather than the immediate one.
|
||||
if (viewModel.IsTerminalShowing)
|
||||
{
|
||||
if (!viewModel.IsSearching)
|
||||
{
|
||||
ReleaseKeyboardTo(KeyboardHome);
|
||||
}
|
||||
FocusTerminalWhenLaidOut();
|
||||
}
|
||||
else
|
||||
{
|
||||
ReleaseKeyboardTo(KeyboardHome);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the keyboard when the window swaps a page for a terminal, or one page for another.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The most common gesture in the window now that the strip spans every screen: a tab and a rail entry
|
||||
/// are both one click away at all times.
|
||||
/// <para>
|
||||
/// <c>ReleaseKeyboardTo</c>, not <c>Focus()</c>, in the page direction — and that is the whole of why
|
||||
/// this method is worth reading. <b>Collapsing the WebView does not release the keyboard.</b> The native
|
||||
/// child window goes on holding Win32 focus, Avalonia then sees no key events at all, and the screen
|
||||
/// that just appeared silently swallows every keystroke. It was a latent defect while leaving a terminal
|
||||
/// was rare; it is the hot path now. See <c>docs/platform-flags.md</c>, and
|
||||
/// <see cref="NativeKeyboardFocus"/> for why only one direction needs the Win32 call.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnShowingSomethingElse(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (!viewModel.IsUnlocked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Switching screens moves the keyboard to whatever the new screen offers, for the same reason:
|
||||
// leaving it on a control that has just been collapsed leaves the window with nothing focused.
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.Screen), StringComparison.Ordinal)
|
||||
&& viewModel.IsUnlocked)
|
||||
if (viewModel.IsTerminalShowing)
|
||||
{
|
||||
KeyboardHome.Focus();
|
||||
return;
|
||||
FocusTerminalWhenLaidOut();
|
||||
}
|
||||
|
||||
// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click
|
||||
// is what took the WebView's Win32 focus away in the first place. term.focus() in the page only
|
||||
// ever reaches document.activeElement, which does nothing for a page that no longer holds the
|
||||
// native focus, so without this the pane looks selected and every keystroke goes to the button
|
||||
// instead of the shell until the user clicks inside the terminal by hand.
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.SelectedTab), StringComparison.Ordinal)
|
||||
&& viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
|
||||
else
|
||||
{
|
||||
Terminal.Focus();
|
||||
ReleaseKeyboardTo(KeyboardHome);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click is
|
||||
/// what took the WebView's Win32 focus away in the first place. <c>term.focus()</c> in the page only
|
||||
/// ever reaches <c>document.activeElement</c>, which does nothing for a page that no longer holds the
|
||||
/// native focus, so without this the pane looks selected and every keystroke goes to the button instead
|
||||
/// of the shell until the user clicks inside the terminal by hand.
|
||||
/// </remarks>
|
||||
private void OnSelectedTabChanged(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
|
||||
{
|
||||
FocusTerminalWhenLaidOut();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
Five destinations down the left edge.
|
||||
Six destinations down the left edge.
|
||||
|
||||
One of them — TEAM — reaches a screen that says it is not built. It is in the rail anyway rather than
|
||||
dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it says
|
||||
@@ -16,6 +16,12 @@
|
||||
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
|
||||
of those hold the selection themselves, so a click moves the highlight before the shell can decide
|
||||
anything. Buttons carry no state and cannot disagree with the screen that is showing.
|
||||
|
||||
Lit from IsXShowing and not from IsXScreen, which are different questions now that the tab strip spans
|
||||
every screen. A terminal opened from here leaves Screen on Hosts — deliberately, so closing the tab comes
|
||||
back — and a rail entry lit while a terminal filled the window would be pointing at a screen that is not
|
||||
showing. So nothing here is lit at all while a terminal is up: the selected tab already carries that
|
||||
mark, in the strip, and two "you are here" marks is one too many.
|
||||
-->
|
||||
|
||||
<Border Width="54" Background="{StaticResource Chrome}"
|
||||
@@ -23,26 +29,44 @@
|
||||
<DockPanel LastChildFill="False">
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,8,0,0">
|
||||
<Button Classes="flat nav" Content="HOSTS" Classes.active="{Binding IsHostsScreen}"
|
||||
<Button Classes="flat nav" Content="HOSTS" Classes.active="{Binding IsHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
|
||||
ToolTip.Tip="Your hosts, and the terminals open on them" />
|
||||
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersScreen}"
|
||||
ToolTip.Tip="Your hosts, and what is known about the one you have selected" />
|
||||
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Transfers}"
|
||||
ToolTip.Tip="Move files to and from a host over SFTP" />
|
||||
<Button Classes="flat nav" Content="VAULT" Classes.active="{Binding IsVaultScreen}"
|
||||
<Button Classes="flat nav" Content="KEYS" Classes.active="{Binding IsVaultShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vault}"
|
||||
ToolTip.Tip="SSH keys, stored passwords, and the host keys you have approved" />
|
||||
<Button Classes="flat nav" Content="TEAM" Classes.active="{Binding IsTeamScreen}"
|
||||
ToolTip.Tip="Your keychain: SSH keys, stored passwords, and the host keys you have approved" />
|
||||
<!--
|
||||
PINS, not HOST KEYS. The rail is 54 pixels wide at mono FontSize 9, which is five characters —
|
||||
and "pins" is what this codebase calls them everywhere else anyway.
|
||||
-->
|
||||
<Button Classes="flat nav" Content="PINS" Classes.active="{Binding IsKnownHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
||||
ToolTip.Tip="Host keys you have approved, and how to withdraw one" />
|
||||
<!-- SNIPS, for the same five-character reason as PINS above. -->
|
||||
<Button Classes="flat nav" Content="SNIPS" Classes.active="{Binding IsSnippetsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
|
||||
ToolTip.Tip="Commands you have saved, and how to put one into a terminal" />
|
||||
<!-- LOGS, four characters, so it needs no abbreviating at all. -->
|
||||
<Button Classes="flat nav" Content="LOGS" Classes.active="{Binding IsLogsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}"
|
||||
ToolTip.Tip="What has been connected to, and what has been changed in this keychain" />
|
||||
<Button Classes="flat nav" Content="TEAM" Classes.active="{Binding IsTeamShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Team}"
|
||||
ToolTip.Tip="Shared vaults and the people in them. Not built yet — see the screen for what is missing." />
|
||||
ToolTip.Tip="Shared keychains and the people in them. Not built yet — see the screen for what is missing." />
|
||||
</StackPanel>
|
||||
|
||||
<Button DockPanel.Dock="Bottom" Classes="flat nav" Content="PREFS"
|
||||
Classes.active="{Binding IsPreferencesScreen}"
|
||||
Classes.active="{Binding IsPreferencesShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}"
|
||||
ToolTip.Tip="Preferences, and this machine's device key" />
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Registers this machine so a later launch can open the vault with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
|
||||
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Content="REGISTER"
|
||||
Command="{Binding RegisterDeviceCommand}"
|
||||
@@ -54,20 +54,20 @@
|
||||
|
||||
<!-- Neither flag is set on a machine that cannot keep a key at all, and that is worth saying. -->
|
||||
<TextBlock Classes="hint" FontSize="10" Margin="0,8,0,0"
|
||||
Text="This machine has nowhere to keep a device key, so the vault will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
||||
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
||||
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="VAULT" FontSize="13" FontWeight="SemiBold"
|
||||
<TextBlock Classes="mono" Text="KEYCHAIN" FontSize="13" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Lock the vault" Foreground="{StaticResource Text}" FontSize="12"
|
||||
<TextBlock Text="Lock the keychain" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Closes the vault and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the vault, not this machine's access to your hosts." />
|
||||
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" />
|
||||
</Grid>
|
||||
@@ -77,7 +77,7 @@
|
||||
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
|
||||
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||
@@ -87,6 +87,18 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Import from ~/.ssh/config" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Reads this machine's OpenSSH configuration and offers what it finds. It shows you the list first and stores nothing until you say so, and it does not read any private key — where a key file is named, the path is recorded as a note." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Content="IMPORT HOSTS"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Import}" />
|
||||
</Grid>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="13" FontWeight="SemiBold"
|
||||
@@ -100,7 +112,7 @@
|
||||
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Deletes this machine's copy of the vault and withdraws its device key, so it goes back to knowing nothing. The vault stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Hidden rather than disabled while the confirmation is up, because the card below carries the
|
||||
@@ -137,7 +149,7 @@
|
||||
<TextBlock Classes="gap"
|
||||
Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the vault." />
|
||||
Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the keychain." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
|
||||
<TextBlock Classes="gap"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="This deletes this machine's copy of the vault — the profile, the cached hosts, keys and passwords, and this machine's device key. Your vault is on the server and is not touched: signing in again brings it all back." />
|
||||
Text="This deletes this machine's copy of the keychain — the profile, the cached hosts, keys and passwords, and this machine's device key. Your keychain is on the server and is not touched: signing in again brings it all back." />
|
||||
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SnippetsScreen"
|
||||
x:DataType="vm:SnippetsViewModel">
|
||||
|
||||
<!--
|
||||
Commands somebody has saved, and how to get one into a terminal.
|
||||
|
||||
The list and the writing belong to the vault, as every other item kind's do; this screen is the filter,
|
||||
the editor and the insert over the top. See SnippetsViewModel.
|
||||
|
||||
The two buttons at the bottom right are the whole safety design, and their wording is load-bearing.
|
||||
A terminal is one input stream with no notion of being at a prompt — the remote may be inside vi, or at
|
||||
a sudo password prompt with echo off — so this application cannot say "run this command", only "type
|
||||
this into whatever is there". RUN appears solely for a snippet whose own flag says it runs, which makes
|
||||
that a decision taken once while writing it rather than a button beside every one of them.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="*,300">
|
||||
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="SNIPPETS" FontSize="11"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Status}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
The command is searched as well as the name: half of what anybody remembers about a saved
|
||||
command is a word that was inside it.
|
||||
-->
|
||||
<TextBox Grid.Column="2" x:Name="SnippetFilter" Text="{Binding Filter}" Width="240"
|
||||
PlaceholderText="filter by name or command" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="SnippetList" Focusable="True"
|
||||
ItemsSource="{Binding Visible}"
|
||||
SelectedItem="{Binding Selected}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*" Margin="0,7,14,7">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
The flag, where the decision is made. A snippet that presses Enter for you is not the
|
||||
same kind of thing as one that does not, and the list is where somebody chooses between
|
||||
them.
|
||||
-->
|
||||
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding RunsOnInsert}">
|
||||
<TextBlock Text="runs immediately" FontSize="8.5" />
|
||||
</Border>
|
||||
<Border Classes="chip warn" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
|
||||
text reads as a single command, which is the thing being decided about on this row.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding Preview}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="360"
|
||||
IsVisible="{Binding !HasVisible}" />
|
||||
|
||||
<Border Grid.Row="2" Padding="14,8" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,1,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="+ NEW SNIPPET" Command="{Binding NewCommand}" />
|
||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="14,16" Spacing="8">
|
||||
|
||||
<!-- ============ The editor ============ -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||
<!--
|
||||
Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator
|
||||
has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell
|
||||
waiting for one that never comes.
|
||||
-->
|
||||
<TextBox Text="{Binding EditorCommand}" PlaceholderText="the command" AcceptsReturn="True"
|
||||
Height="140" TextWrapping="NoWrap" FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="11" />
|
||||
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="48" TextWrapping="Wrap" />
|
||||
<CheckBox IsChecked="{Binding EditorRunsOnInsert}"
|
||||
Content="Press Enter after inserting this" />
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Off means the command is typed at the prompt and waits for you. That single Enter is the only thing standing between a saved command and a running one, so leave it off unless you meant it." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ The selected snippet ============ -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding !IsEditing}">
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Choose a snippet to see it in full and put it into a terminal."
|
||||
IsVisible="{Binding !HasSelection}" />
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Snippet.Command}"
|
||||
FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Snippet.Notes}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap" Margin="0,6,0,0"
|
||||
IsVisible="{Binding Selected.Snippet.Notes, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!--
|
||||
The button names the tab it will type into. This screen is not the terminal — the strip
|
||||
above it is — so "INSERT" alone would leave somebody working out which of six open tabs is
|
||||
about to receive a command, at the moment that is worst to be wrong about.
|
||||
-->
|
||||
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
|
||||
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
|
||||
|
||||
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Left"
|
||||
Command="{Binding RunCommand}"
|
||||
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
|
||||
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,10,0,0"
|
||||
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,24 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The commands this keychain has saved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is a <c>SnippetsViewModel</c>, a screen-scoped wrapper over the vault rather than an
|
||||
/// owner of anything: the list, the storage and the push all still belong to <c>VaultViewModel</c>.
|
||||
/// </remarks>
|
||||
internal sealed partial class SnippetsScreen : UserControl
|
||||
{
|
||||
public SnippetsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// The filter box rather than the list, for the reason the pins screen gives: the box is there on a
|
||||
/// keychain with nothing saved yet, where the list is empty and <c>Focus()</c> on it would be a no-op
|
||||
/// nothing replays.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget => SnippetFilter;
|
||||
}
|
||||
@@ -5,17 +5,20 @@
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The tab strip above the terminal.
|
||||
The tab strip, above every screen.
|
||||
|
||||
Every tab is one pane in the one WebView, so switching is a single frame telling the page which pane to
|
||||
show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and
|
||||
goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing
|
||||
one is the only thing in this application that deliberately ends a session.
|
||||
|
||||
Three of the design's header controls are absent: SPLIT, FORWARDS and SNIPPETS. Splits would need a
|
||||
second pane geometry the renderer does not have, port forwarding does not exist in the SSH layer, and
|
||||
there is no snippet item type in the vault. Three disabled buttons would teach nobody anything; see
|
||||
docs/design-import-gaps.md.
|
||||
It spans the whole window rather than the hosts screen, which is what the strip is for: a connection you
|
||||
opened stays visible and one click away while you are looking at a transfer, a key, or preferences.
|
||||
Clicking a tab switches the window's surface to that terminal — see MainWindowViewModel.ShellSurface.
|
||||
|
||||
Two of the design's header controls are still absent: SPLIT and FORWARDS. Splits would need a second
|
||||
pane geometry the renderer does not have, and port forwarding does not exist in the SSH layer. Two
|
||||
disabled buttons would teach nobody anything; see docs/design-import-gaps.md.
|
||||
|
||||
An ItemsControl of buttons rather than a TabStrip, because the selection lives on the shell — a tab
|
||||
outlives the vault that opened it — and a strip that owned its own selection would be a second copy of
|
||||
@@ -24,10 +27,15 @@
|
||||
|
||||
<Border Height="34" Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<!--
|
||||
Everything in one scrolling row: the tabs, then the button that opens another, then the sentence for
|
||||
when there are none. The strip stays rather than collapsing — a row of chrome that appears and
|
||||
disappears would move every screen up and down by 34 pixels each time the last tab closed.
|
||||
-->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Tabs}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -36,57 +44,85 @@
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<Button Grid.Column="0" Classes="flat tab"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
Classes.active="{Binding IsSelected}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!--
|
||||
Green while the shell behind this tab is running, grey once it has ended. The pane
|
||||
keeps its scrollback either way, which is usually why somebody is still looking at a
|
||||
tab whose dot has gone out.
|
||||
-->
|
||||
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!--
|
||||
The close box is inside the tab, not beside it. Beside it, the two were siblings in a grid:
|
||||
the cross was as tall as the strip and sat outside the tab's own background, so it read as a
|
||||
divider between tabs rather than as part of one, and the tab it belonged to was ambiguous
|
||||
for the tab to its right.
|
||||
|
||||
<Button Grid.Column="1" Classes="flat close" Width="20"
|
||||
VerticalAlignment="Stretch"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="Closes this terminal and ends its shell.">
|
||||
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
Nested buttons work, and it is worth knowing why rather than assuming. Avalonia's
|
||||
Button.OnPointerPressed checks IsLeftButtonPressed, takes the pointer capture and marks the
|
||||
event handled — so a left press on the cross does not also select the tab. It deliberately
|
||||
does not handle any other button, which is exactly what lets a middle press bubble out of
|
||||
the cross and reach the handler below.
|
||||
-->
|
||||
<Button Classes="flat tab"
|
||||
Classes.active="{Binding IsSelected}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
PointerPressed="OnTabPointerPressed"
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!--
|
||||
Green while the shell behind this tab is running, grey once it has ended. The pane
|
||||
keeps its scrollback either way, which is usually why somebody is still looking at a
|
||||
tab whose dot has gone out.
|
||||
-->
|
||||
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
Always drawn, never on hover only. The strip has no other close affordance, and one
|
||||
that appears when the pointer is already over the tab cannot be found by somebody
|
||||
looking for it.
|
||||
-->
|
||||
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
|
||||
<TextBlock Text="✕" FontSize="9" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
Nothing open, and this is where that is said. The strip stays rather than collapsing — a row of
|
||||
chrome that appears and disappears moves the terminal up and down by 34 pixels every time the last
|
||||
tab closes — and it is also the only place near the terminal that can carry a sentence at all: the
|
||||
rectangle below is a native child window, and anything Avalonia draws in it is drawn underneath.
|
||||
-->
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="9.5"
|
||||
Text="no terminals open · choose a host and press Connect, or Ctrl+K"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding !HasTabs}" />
|
||||
<!--
|
||||
Opens the quick-connect palette, which is also what Ctrl+K does — so the tooltip can say that
|
||||
honestly, and there is one way to start a connection rather than two that have to agree.
|
||||
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
|
||||
TextTrimming="CharacterEllipsis" MaxWidth="280"
|
||||
IsVisible="{Binding HasTabs}" />
|
||||
Not a MenuFlyout offering "SSH" and "local shell", which is the nicer-looking answer and is not
|
||||
verifiably safe here: this strip sits directly above the WebView's rectangle, and whether a popup
|
||||
dropping into it composites above a native child window depends on whether Avalonia gives it its
|
||||
own platform window. docs/platform-flags.md records what this project already paid for treating a
|
||||
rendering claim as settled without a screenshot. The palette has no such question — opening it
|
||||
collapses the terminal outright.
|
||||
-->
|
||||
<Button Classes="flat tab plus" Width="30"
|
||||
Command="{Binding ToggleSearchCommand}"
|
||||
ToolTip.Tip="Open a connection · Ctrl+K">
|
||||
<TextBlock Text="+" FontSize="14" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
<!--
|
||||
Nothing open, and this is where that is said. It is also the only place near the terminal that can
|
||||
carry a sentence at all: the rectangle below is a native child window, and anything Avalonia draws
|
||||
in it is drawn underneath.
|
||||
-->
|
||||
<TextBlock Classes="mono" FontSize="9.5"
|
||||
Text="no terminals open · press + or Ctrl+K, or choose a host and press Connect"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding !HasTabs}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -1,9 +1,56 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The tab strip above the terminal.</summary>
|
||||
/// <summary>The tab strip, above every screen.</summary>
|
||||
internal sealed partial class TerminalTabs : UserControl
|
||||
{
|
||||
public TerminalTabs() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Closes a tab on a middle click.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Wired on the tab's own template root, which is the whole answer to "and not on the strip itself".
|
||||
/// A middle press on the background, on the sentence, or on the button that opens a connection reaches
|
||||
/// no handler at all, because there is none there to reach. Nothing has to test what was clicked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><c>PointerUpdateKind</c>, not <c>IsMiddleButtonPressed</c>.</b> The latter reports button
|
||||
/// <em>state</em>: it is equally true for a left press made while the middle button happens to be held,
|
||||
/// and for every press during a middle drag. The question here is which button caused this press, and
|
||||
/// that is the one thing only <c>PointerUpdateKind</c> answers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On press rather than on release, which is what every browser and every terminal does. Matching a
|
||||
/// release to its press would need capture tracking, to buy the ability to change your mind about a
|
||||
/// middle click — a gesture nobody makes by accident and nobody aborts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|
||||
|| DataContext is not MainWindowViewModel shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
|
||||
is not PointerUpdateKind.MiddleButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Handled, so the strip's ScrollViewer does not also take this as the start of a pan.
|
||||
e.Handled = true;
|
||||
|
||||
// Fire-and-forget, as the host sidebar's double-tap connect is: CloseTabCommand is asynchronous —
|
||||
// it waits for the workspace to tear the session down — and an event handler has nowhere to await
|
||||
// it. Its failures are the workspace's to report, not this strip's.
|
||||
shell.CloseTabCommand.Execute(tab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +36,32 @@
|
||||
|
||||
<!-- ============ The host, and the connection ============ -->
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
|
||||
<Grid ColumnDefinitions="Auto,Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
|
||||
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="FILES" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding Hosts}"
|
||||
<!--
|
||||
Which sort of remote. Two buttons rather than one picker holding hosts and buckets together, and
|
||||
the reason is that the two are not interchangeable: a host brings a password box, a host key
|
||||
prompt and a mismatch refusal with it, and a bucket has no equivalent of any of them. One picker
|
||||
would mean half this bar appearing and disappearing with the selection.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="2" Margin="0,0,8,0"
|
||||
IsVisible="{Binding !IsConnected}">
|
||||
<Button Classes="flat cat" Content="HOST" Classes.active="{Binding ShowsHostPicker}"
|
||||
Command="{Binding ShowRemoteCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}" />
|
||||
<Button Classes="flat cat" Content="BUCKET" Classes.active="{Binding ShowsBucketPicker}"
|
||||
Command="{Binding ShowRemoteCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}" />
|
||||
</StackPanel>
|
||||
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding Hosts}"
|
||||
SelectedItem="{Binding SelectedHost}"
|
||||
IsEnabled="{Binding !IsConnected}"
|
||||
IsVisible="{Binding ShowsHostPicker}"
|
||||
PlaceholderText="choose a host">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
@@ -58,26 +75,43 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding Buckets}"
|
||||
SelectedItem="{Binding SelectedBucket}"
|
||||
IsEnabled="{Binding !IsConnected}"
|
||||
IsVisible="{Binding ShowsBucketPicker}"
|
||||
PlaceholderText="choose a bucket">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a different box
|
||||
holding a different value. This connection authenticates separately, so a password typed to open a
|
||||
terminal was never offered here.
|
||||
-->
|
||||
<TextBox Grid.Column="2" Width="150" Margin="6,0,0,0" PasswordChar="•"
|
||||
<TextBox Grid.Column="3" Width="150" Margin="6,0,0,0" PasswordChar="•"
|
||||
Text="{Binding TypedPassword}" PlaceholderText="password"
|
||||
IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
IsEnabled="{Binding !IsConnected}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="accent" Content="CONNECT" Margin="6,0,0,0"
|
||||
<Button Grid.Column="4" Classes="accent" Content="{Binding ConnectLabel}" Margin="6,0,0,0"
|
||||
Command="{Binding ConnectCommand}"
|
||||
IsVisible="{Binding !IsConnected}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
|
||||
<Button Grid.Column="4" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
|
||||
Command="{Binding DisconnectCommand}"
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
|
||||
<Border Grid.Column="4" Classes="chip accent" Margin="8,0,0,0"
|
||||
<Border Grid.Column="5" Classes="chip accent" Margin="8,0,0,0"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBlock Text="{Binding ConnectedTo}" />
|
||||
</Border>
|
||||
@@ -93,7 +127,12 @@
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,64,*">
|
||||
|
||||
<!-- ==== This machine ==== -->
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
|
||||
<!--
|
||||
AllowDrop on the pane rather than on the list, because an empty directory lays its ListBox out at
|
||||
zero height behind the empty-state sentence — a handler on the list would have nothing to hit.
|
||||
This side takes remote rows only; see TransfersScreen.axaml.cs.
|
||||
-->
|
||||
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
@@ -175,6 +214,17 @@
|
||||
Text="Nothing in this folder. Use the trail above to go somewhere else."
|
||||
IsVisible="{Binding !HasLocalEntries}" />
|
||||
|
||||
<!--
|
||||
The drop highlight, over the whole pane and last so it is on top.
|
||||
|
||||
IsHitTestVisible="False" is not optional. An overlay that takes part in hit testing swallows the
|
||||
DragOver events underneath it the moment it appears — so the pointer leaves, the highlight never
|
||||
clears, and the drop lands nowhere.
|
||||
-->
|
||||
<Border Grid.Row="0" Grid.RowSpan="4" IsHitTestVisible="False"
|
||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="2" IsVisible="{Binding IsLocalDropTarget}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ==== The two directions ==== -->
|
||||
@@ -197,7 +247,8 @@
|
||||
</Border>
|
||||
|
||||
<!-- ==== The host ==== -->
|
||||
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,*"
|
||||
DragDrop.AllowDrop="True">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
@@ -315,6 +366,24 @@
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Two highlights rather than one, because refusing is worth showing. Something dragged over a
|
||||
disconnected pane has to say so under the pointer — a pane that lights up nowhere reads as a
|
||||
window that has stopped answering, and the answer arriving after the drop is the answer arriving
|
||||
too late. See the local pane for why neither may hit-test.
|
||||
-->
|
||||
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
|
||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
|
||||
|
||||
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
|
||||
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="11"
|
||||
Foreground="{StaticResource Danger}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
@@ -8,12 +11,42 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// The two-pane file browser and the transfer queue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Its data context is the <c>TransfersViewModel</c>, which the shell owns for the life of the process — a
|
||||
/// transfer in flight has to survive a lock, the same policy that keeps shells running. See
|
||||
/// <c>MainWindowViewModel.LockAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Everything about drag and drop is in this file and nothing about it is policy.</b> The handlers pull
|
||||
/// paths or rows out of a drop and hand them to <c>QueueUploads</c>/<c>QueueDownloads</c>; what may be
|
||||
/// queued, what is skipped and what is said about it all live in the view model, where they can be tested
|
||||
/// without a window. Nothing headless can synthesise a real platform drag, so the wiring below is verified
|
||||
/// by hand — see <c>docs/manual-checks.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TransfersScreen : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// How remote rows travel while being dragged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An in-process format, so the rows themselves cross rather than a list of path strings that would
|
||||
/// have to be looked up again on the other side. It also cannot be confused with a drop from the
|
||||
/// operating system: a file dragged out of the file manager arrives as <c>DataFormat.File</c> and never
|
||||
/// as this, so "did this come from our own remote pane" needs no guessing.
|
||||
/// </remarks>
|
||||
private static readonly DataFormat<RemoteDragPayload> RemoteEntries =
|
||||
DataFormat.CreateInProcessFormat<RemoteDragPayload>("dodossh/remote-entries");
|
||||
|
||||
/// <summary>How far the pointer moves before a press becomes a drag.</summary>
|
||||
/// <remarks>
|
||||
/// Without a threshold every click on a row starts a drag, which makes selecting one impossible.
|
||||
/// </remarks>
|
||||
private const double DragThreshold = 4;
|
||||
|
||||
private PointerPressedEventArgs? pressed;
|
||||
private Point pressedAt;
|
||||
|
||||
public TransfersScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -23,11 +56,32 @@ internal sealed partial class TransfersScreen : UserControl
|
||||
// Enter on a keyboard-navigated row goes through the same commands from the buttons above them.
|
||||
LocalList.DoubleTapped += OnLocalActivated;
|
||||
RemoteList.DoubleTapped += OnRemoteActivated;
|
||||
|
||||
// On the pane rather than on the list. A directory with nothing in it lays its ListBox out at zero
|
||||
// height behind the empty-state sentence, and a drop handler on the list would have nothing to hit.
|
||||
LocalPane.AddHandler(DragDrop.DragOverEvent, OnLocalDragOver);
|
||||
LocalPane.AddHandler(DragDrop.DragLeaveEvent, OnLocalDragLeave);
|
||||
LocalPane.AddHandler(DragDrop.DropEvent, OnLocalDrop);
|
||||
|
||||
RemotePane.AddHandler(DragDrop.DragOverEvent, OnRemoteDragOver);
|
||||
RemotePane.AddHandler(DragDrop.DragLeaveEvent, OnRemoteDragLeave);
|
||||
RemotePane.AddHandler(DragDrop.DropEvent, OnRemoteDrop);
|
||||
|
||||
// Tunnelling, so noting where a press started does not take the press away from the ListBox — a row
|
||||
// still selects, and the drag only begins once the pointer has moved far enough.
|
||||
foreach (var list in new Control[] { LocalList, RemoteList })
|
||||
{
|
||||
list.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
||||
list.AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel);
|
||||
list.AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel);
|
||||
}
|
||||
}
|
||||
|
||||
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
|
||||
|
||||
private void OnLocalActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.OpenLocalCommand.Execute(null);
|
||||
}
|
||||
@@ -40,9 +94,211 @@ internal sealed partial class TransfersScreen : UserControl
|
||||
/// </remarks>
|
||||
private void OnRemoteActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
_ = transfers.OpenRemoteCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Starting a drag ----
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed)
|
||||
{
|
||||
pressed = e;
|
||||
pressedAt = e.GetPosition(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => pressed = null;
|
||||
|
||||
/// <remarks>
|
||||
/// The drag starts here rather than on the press, because a press is also how a row is selected.
|
||||
/// <c>DoDragDropAsync</c> wants the original <c>PointerPressedEventArgs</c>, so it is held from the
|
||||
/// press until either the pointer moves far enough or the button comes back up.
|
||||
/// </remarks>
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (pressed is not { } origin || Transfers is not { } transfers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
pressed = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var moved = e.GetPosition(this) - pressedAt;
|
||||
|
||||
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pressed = null;
|
||||
|
||||
if (ReferenceEquals(sender, RemoteList))
|
||||
{
|
||||
StartRemoteDrag(origin, transfers);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = StartLocalDragAsync(origin, transfers);
|
||||
}
|
||||
}
|
||||
|
||||
private static void StartRemoteDrag(PointerPressedEventArgs origin, TransfersViewModel transfers)
|
||||
{
|
||||
if (transfers.SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var transfer = new DataTransfer();
|
||||
transfer.Add(DataTransferItem.Create(RemoteEntries, new RemoteDragPayload([row])));
|
||||
|
||||
_ = DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Local files travel as the platform's own file format rather than as an in-process one, which is what
|
||||
/// makes a single drag work both onto the remote pane and out into the file manager. It needs a real
|
||||
/// <see cref="IStorageItem"/>, hence the asynchronous lookup — and hence a fire-and-forget call, because
|
||||
/// nothing on a pointer-moved path can await.
|
||||
/// </remarks>
|
||||
private async Task StartLocalDragAsync(PointerPressedEventArgs origin, TransfersViewModel transfers)
|
||||
{
|
||||
if (transfers.SelectedLocalEntry is not { IsFile: true } row
|
||||
|| TopLevel.GetTopLevel(this) is not { } top)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var file = await top.StorageProvider.TryGetFileFromPathAsync(row.FullPath).ConfigureAwait(true);
|
||||
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var transfer = new DataTransfer();
|
||||
transfer.Add(DataTransferItem.CreateFile(file));
|
||||
|
||||
await DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// ---- Accepting a drop ----
|
||||
|
||||
/// <remarks>
|
||||
/// The local pane takes remote rows and nothing else. A file dragged from the file manager onto it
|
||||
/// would be a copy from this machine to this machine, which is not what this screen is for.
|
||||
/// </remarks>
|
||||
private void OnLocalDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
var accepted = e.DataTransfer.Contains(RemoteEntries);
|
||||
|
||||
e.DragEffects = accepted ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsLocalDropTarget = accepted;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnLocalDragLeave(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsLocalDropTarget = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLocalDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (Transfers is not { } transfers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
transfers.IsLocalDropTarget = false;
|
||||
e.Handled = true;
|
||||
|
||||
if (e.DataTransfer.TryGetValue(RemoteEntries) is { } payload)
|
||||
{
|
||||
transfers.QueueDownloads(payload.Rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The remote pane takes files: from the file manager, and from the local pane, which offers the same
|
||||
/// platform format. A drop while disconnected is refused visibly rather than accepted and then
|
||||
/// explained, because a red pane under the pointer is the answer arriving before the drop rather than
|
||||
/// after it.
|
||||
/// </remarks>
|
||||
private void OnRemoteDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
var files = e.DataTransfer.Contains(DataFormat.File);
|
||||
var connected = Transfers is { IsConnected: true };
|
||||
|
||||
e.DragEffects = files && connected ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsRemoteDropTarget = files && connected;
|
||||
transfers.IsRemoteDropRefused = files && !connected;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnRemoteDragLeave(object? sender, DragEventArgs e) => ClearRemoteHighlight();
|
||||
|
||||
private void OnRemoteDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (Transfers is not { } transfers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ClearRemoteHighlight();
|
||||
e.Handled = true;
|
||||
|
||||
if (e.DataTransfer.TryGetFiles() is not { } files)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// TryGetLocalPath, because the queue reads bytes off a real path. A storage item that is not a
|
||||
// local file — one from a cloud provider's virtual folder — has none, and dropping it is a thing
|
||||
// this screen declines rather than a thing it half does.
|
||||
var paths = files
|
||||
.Select(file => file.TryGetLocalPath())
|
||||
.OfType<string>()
|
||||
.ToList();
|
||||
|
||||
transfers.QueueUploads(paths);
|
||||
}
|
||||
|
||||
private void ClearRemoteHighlight()
|
||||
{
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsRemoteDropTarget = false;
|
||||
transfers.IsRemoteDropRefused = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The remote rows carried by one drag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A record wrapping the list rather than the list itself, because <c>DataFormat.CreateInProcessFormat</c>
|
||||
/// keys on the type and a bare <c>IReadOnlyList<T></c> is too general a key to be sure of.
|
||||
/// </remarks>
|
||||
internal sealed record RemoteDragPayload(IReadOnlyList<RemoteEntryRowViewModel> Rows);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Classes="heading" Text="Unlock your keychain" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
|
||||
|
||||
<!--
|
||||
@@ -33,7 +33,7 @@
|
||||
exists for. A single-line TextBox does not handle Enter itself, so nothing is being fought over.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•">
|
||||
PlaceholderText="keychain passphrase" PasswordChar="•">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
@@ -52,7 +52,7 @@
|
||||
Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||
@@ -73,7 +73,7 @@
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
Text="Locking closes the keychain, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the keychain, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the vault is on the server and comes back." />
|
||||
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the keychain is on the server and comes back." />
|
||||
<Button Classes="ghost" Content="RESET THIS MACHINE"
|
||||
Command="{Binding SignOutCommand}" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -2,25 +2,29 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
xmlns:ssh="using:DodoSSH.Client.Ssh"
|
||||
x:Class="DodoSSH.Client.App.Views.VaultScreen"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
<!--
|
||||
Everything in the vault that is not a host: the keys, the stored passwords, and the host keys this user
|
||||
has approved.
|
||||
The keychain: the SSH keys and the stored passwords. Things a person creates and edits.
|
||||
|
||||
Three columns, as the design has them — a category rail, one table, and a detail pane. The table has one
|
||||
shape for every kind, which is what makes the ALL category possible and is why the row projection
|
||||
exists; see VaultItemRowViewModel.
|
||||
|
||||
Two of the design's five categories are not here. IDENTITIES and CERTIFICATES have no item type behind
|
||||
them — the vault holds exactly four kinds and two of those are hosts and pins — so listing them would be
|
||||
two headings that could never have anything under them. HOST KEYS is the other way round: a real,
|
||||
fully-backed category the design has no slot for. Both are recorded in docs/design-import-gaps.md.
|
||||
HOST KEYS was a fourth category here and is now a screen of its own; see KnownHostsScreen. It never fit:
|
||||
the two categories left are things somebody made on purpose, and a pin is a decision recorded at the
|
||||
moment of connecting — nobody goes looking for one in a list of credentials. It also has a workflow the
|
||||
shared table could not serve, which is comparing an untruncated fingerprint against a published one.
|
||||
|
||||
The SCOPES rail below the categories is the vault list, which is real and today has one entry in it. The
|
||||
design shows three, two of them teams; team vaults exist as tables on the server and are refused by its
|
||||
access service, so a rail with three entries would be showing two vaults nothing can open.
|
||||
Two of the design's five categories are still not here. IDENTITIES and CERTIFICATES have no item type
|
||||
behind them, so listing them would be two headings that could never have anything under them. Recorded
|
||||
in docs/design-import-gaps.md.
|
||||
|
||||
The SCOPES rail below the categories is the keychain list, which is real and today has one entry in it.
|
||||
The design shows three, two of them teams; team keychains exist as tables on the server and are refused
|
||||
by its access service, so a rail with three entries would be showing two nothing can open.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="176,*,244">
|
||||
@@ -31,7 +35,7 @@
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,12">
|
||||
|
||||
<TextBlock Classes="label" Text="VAULT" Margin="14,0,14,8" />
|
||||
<TextBlock Classes="label" Text="KEYCHAIN" Margin="14,0,14,8" />
|
||||
|
||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.All}"
|
||||
@@ -66,13 +70,18 @@
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Buckets. A category here rather than a screen of its own, unlike the approved host keys: a bucket
|
||||
is something somebody creates, edits and keeps a secret for, which is what the other two
|
||||
categories are. A pin is a decision recorded at connect time and is not.
|
||||
-->
|
||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.KnownHosts}"
|
||||
Classes.active="{Binding ShowsKnownHosts}">
|
||||
CommandParameter="{x:Static vm:VaultSection.Buckets}"
|
||||
Classes.active="{Binding ShowsBuckets}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0" Classes="rowmark catmark" />
|
||||
<TextBlock Grid.Column="1" Text="HOST KEYS" Margin="12,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding KnownHostPins.Count}"
|
||||
<TextBlock Grid.Column="1" Text="BUCKETS" Margin="12,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding ObjectStores.Count}"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
@@ -93,7 +102,7 @@
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="hint" FontSize="9.5" Margin="14,6,14,0"
|
||||
Text="One vault, because the server grants access to your own and refuses the rest. Sharing is a later milestone." />
|
||||
Text="One keychain, because the server grants access to your own and refuses the rest. Sharing is a later milestone." />
|
||||
|
||||
<!--
|
||||
Items that would not decrypt. Shown here rather than only in the status line because this is the
|
||||
@@ -121,10 +130,18 @@
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding SectionSummary}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
|
||||
<!--
|
||||
Always offered. Every category left on this screen is one things can be added to — the one
|
||||
that was not, HOST KEYS, is now its own screen, and a pin still cannot be typed in there
|
||||
either. See KnownHostsScreen.
|
||||
-->
|
||||
<Button Classes="ghost" Content="GENERATE KEY" Command="{Binding NewGeneratedKeyCommand}"
|
||||
ToolTip.Tip="Makes a new key pair here, so the private half never becomes a file on this disk." />
|
||||
<Button Classes="ghost" Content="+ SSH KEY" Command="{Binding NewKeyCommand}"
|
||||
IsVisible="{Binding CanAddToSection}" />
|
||||
<Button Classes="accent" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}"
|
||||
IsVisible="{Binding CanAddToSection}" />
|
||||
ToolTip.Tip="Pastes in a key you already have." />
|
||||
<Button Classes="ghost" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}" />
|
||||
<Button Classes="accent" Content="+ BUCKET" Command="{Binding NewObjectStoreCommand}"
|
||||
ToolTip.Tip="An S3-compatible bucket, to browse beside a host on the Files screen." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -218,7 +235,7 @@
|
||||
empty rows, this says what is missing in one line.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="9.5" Margin="0,12,0,0"
|
||||
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
|
||||
Text="Keychain items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
||||
IsVisible="{Binding ShowsItemActions}">
|
||||
@@ -226,6 +243,17 @@
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The public half only, and there is no button for the other one. Installing a key means pasting
|
||||
this line into a host's authorized_keys; a private key on the clipboard is a private key in
|
||||
every application on the machine.
|
||||
-->
|
||||
<Button Classes="ghost" Content="COPY PUBLIC KEY" Margin="0,6,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
IsVisible="{Binding SelectedItemIsKey}"
|
||||
Command="{Binding CopyPublicKeyCommand}"
|
||||
ToolTip.Tip="Copies the authorized_keys line for this key, which is what a host needs to let it in." />
|
||||
|
||||
<!--
|
||||
The question DELETE asks, in the place those two buttons were. Here rather than over the
|
||||
screen, because this pane is where the item being deleted is described: the name, the kind and
|
||||
@@ -238,19 +266,50 @@
|
||||
<views:ConfirmDeleteCard />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Making a key, as opposed to pasting one in. A step of its own and a short one: an algorithm, a
|
||||
comment, and a button. What it produces lands in the editor below, unsaved — so there is still
|
||||
exactly one thing on this screen that writes a key, and it is still SAVE.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsGeneratingKey}">
|
||||
<TextBlock Classes="label" Text="NEW SSH KEY" Margin="0,0,0,4" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<!--
|
||||
Buttons and a command rather than a selector bound to the algorithm, which is the same
|
||||
choice the category rail makes and for the same reason: a selector moves its own highlight
|
||||
before anything can refuse, so it can end up showing a choice nobody made.
|
||||
-->
|
||||
<Button Classes="flat choice" Content="ED25519"
|
||||
Classes.active="{Binding GeneratesEd25519}"
|
||||
Command="{Binding ChooseKeyAlgorithmCommand}"
|
||||
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Ed25519}"
|
||||
ToolTip.Tip="What every current OpenSSH prefers. Small, fast, and generated instantly." />
|
||||
<Button Classes="flat choice" Content="RSA 4096"
|
||||
Classes.active="{Binding GeneratesRsa}"
|
||||
Command="{Binding ChooseKeyAlgorithmCommand}"
|
||||
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Rsa4096}"
|
||||
ToolTip.Tip="For servers too old to accept Ed25519. Larger, and a few seconds to generate." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBox Text="{Binding GenerateComment}" PlaceholderText="name — also the key's comment" />
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="This is what the key is called here and what is written into it, so the line on a host says where it came from." />
|
||||
|
||||
<!--
|
||||
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
|
||||
a pin appears because somebody approved a fingerprint at the moment of connecting, which is
|
||||
the one place it can be checked against what the operator published. What it does have is a
|
||||
way out, because a changed host key is refused outright and a rebuilt server would otherwise
|
||||
be unreachable for ever.
|
||||
Said plainly rather than left to be discovered. Writing an encrypted openssh-key-v1 file needs
|
||||
bcrypt_pbkdf, which .NET has no primitive for — and the defence it buys is one this product
|
||||
already makes: a passphrase protects a key file on a disk, and this key is never on one.
|
||||
-->
|
||||
<StackPanel Spacing="6" Margin="0,14,0,0" IsVisible="{Binding SelectedItemIsPin}">
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="Approved when you first connected. A pin outlives the host it was approved for, so one that says no host uses it is a leftover rather than a warning." />
|
||||
<Button Classes="danger" Content="FORGET THIS HOST KEY" HorizontalAlignment="Left"
|
||||
Command="{Binding ForgetPinCommand}"
|
||||
ToolTip.Tip="Withdraws every pinned key for this address, so the next connection asks you to check the fingerprint again. Takes effect immediately." />
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Classes="accent" Content="GENERATE" Command="{Binding GenerateKeyCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGenerateKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
@@ -273,7 +332,7 @@
|
||||
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="44" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a vault: on a disk the passphrase protects the key, and in here your vault passphrase protects both." />
|
||||
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a keychain: on a disk the passphrase protects the key, and in here your keychain passphrase protects both." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
|
||||
@@ -307,6 +366,43 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- The bucket editor. -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditingObjectStore}">
|
||||
<TextBlock Classes="label" Text="BUCKET" Margin="0,0,0,4" />
|
||||
<TextBox Text="{Binding BucketEditorLabel}" PlaceholderText="name" />
|
||||
<TextBox Text="{Binding BucketEditorBucket}" PlaceholderText="bucket" />
|
||||
<TextBox Text="{Binding BucketEditorAccessKeyId}" PlaceholderText="access key id" />
|
||||
<!--
|
||||
Masked, like a password and for the same reason: a secret access key is one. The access key id
|
||||
beside it is an identifier and is shown, which is also why the two are separate boxes.
|
||||
-->
|
||||
<TextBox Text="{Binding BucketEditorSecretAccessKey}" PlaceholderText="secret access key"
|
||||
PasswordChar="•" />
|
||||
<TextBox Text="{Binding BucketEditorRegion}" PlaceholderText="region (e.g. eu-west-1)" />
|
||||
<!--
|
||||
Blank means Amazon, and then the region resolves the host. Anything else is a full URL, which
|
||||
is what makes this work against a self-hosted service.
|
||||
-->
|
||||
<TextBox Text="{Binding BucketEditorEndpoint}"
|
||||
PlaceholderText="endpoint (blank: Amazon S3)" />
|
||||
<CheckBox IsChecked="{Binding BucketEditorUsePathStyle}"
|
||||
Content="Address the bucket as a path" />
|
||||
<!--
|
||||
Said where the decision is made. Getting this wrong produces a DNS failure whose message
|
||||
mentions neither buckets nor this setting, which is the worst kind of thing to leave to a guess.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
|
||||
<TextBox Text="{Binding BucketEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="44" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="Encrypted here, keys and endpoint alike, and never sent to the server in a form it can read. Pick this bucket on the Files screen to browse it." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveObjectStoreCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelObjectStoreEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
@@ -22,6 +22,7 @@ const SERVER_SESSION_OPENED = 2;
|
||||
const SERVER_SESSION_CLOSED = 3;
|
||||
const SERVER_SESSION_ACTIVATED = 4;
|
||||
const SERVER_SESSION_REMOVED = 5;
|
||||
const SERVER_PASTE = 6;
|
||||
|
||||
const CLIENT_INPUT = 1;
|
||||
const CLIENT_ACKNOWLEDGE = 2;
|
||||
@@ -275,6 +276,39 @@ function handleFrame(buffer) {
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_PASTE: {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session || payload.length < 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const execute = payload[0] !== 0;
|
||||
const text = new TextDecoder().decode(payload.subarray(1));
|
||||
|
||||
/*
|
||||
term.paste rather than term.input, and that is the whole reason this frame exists rather than
|
||||
the host writing the bytes into the pump. paste() wraps the text in bracketed-paste markers
|
||||
when the remote has turned that mode on — xterm tracks \e[?2004h from the output stream, which
|
||||
is something only this page sees — and a shell that receives a multi-line command inside those
|
||||
markers treats every newline as text. Without them it treats each one as "run this", so a
|
||||
three-line snippet runs three commands the moment it is inserted.
|
||||
*/
|
||||
session.term.paste(text);
|
||||
|
||||
/*
|
||||
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
|
||||
pasted text would be bracketed along with it and arrive at the shell as a literal carriage
|
||||
return, so nothing would run — which is the failure that looks like the feature working right
|
||||
up until somebody wonders why RUN does not.
|
||||
*/
|
||||
if (execute) {
|
||||
session.term.input('\r');
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_SESSION_CLOSED: {
|
||||
const session = sessions.get(sessionId);
|
||||
const reason = new TextDecoder().decode(payload);
|
||||
|
||||
@@ -349,6 +349,21 @@
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.import": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.objectstore": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, )",
|
||||
"AWSSDK.S3": "[4.0.101.6, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.session": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
@@ -357,12 +372,14 @@
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )"
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
@@ -406,6 +423,21 @@
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"AWSSDK.Core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.100.9, )",
|
||||
"resolved": "4.0.100.9",
|
||||
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
|
||||
},
|
||||
"AWSSDK.S3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.101.6, )",
|
||||
"resolved": "4.0.101.6",
|
||||
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
|
||||
Reference in New Issue
Block a user