Public Access
A connected phone was drawing five rows of chrome around the thing the user opened it for. The vault header at 56, the terminal's own tab strip at 52, a connection line at 36, the shells strip at 46 and the four-entry bottom bar at 64: at 360dp that is about a third of the display, and every row of it was about somewhere the user was not. What replaces them is one 52-pixel bar drawn by the surface itself — back on the left, the session pills, and a `+` across from them — and then the terminal. Three of those rows belong to `PhoneShell` and each is now bound on `IsShowingPages`. That is the same question asked once rather than three conditions that could drift: the surface is either a page or a terminal, and these are the chrome a page has. The header needed a wrapper because Avalonia's bindings have no "and" and it already had a condition of its own; the strip needed one for the same reason. The bottom bar had none and is bound directly. The back arrow goes to the page the terminal was opened over rather than to Hosts by name, because the system back gesture already picks that and an arrow landing somewhere else would be the second of two answers to one question. The bar's `+` raises a sheet offering the three connections this application can make — a shell, a host's files over SFTP, a bucket — since SFTP and S3 used to be two taps through the bottom bar's MORE and the bar is not on screen here. A control that replaced it and led to one of the three would have quietly removed the other two. Two things moved rather than being dropped. The text-size buttons are pinned at the right-hand end of the accessory key row, outside its scroller: the connection line existed to keep them from scrolling out of reach, and being outside the scroller answers that argument rather than abandoning it. The dialled address moved onto the connecting card, which is the moment it is worth reading — what is being connected to, before anything has answered — and after that the shell's own prompt says it more accurately than a header derived from the keychain ever did. The sheet collapses the renderer rather than covering it. Whether Android's `WebView` composites above Avalonia content the way Win32's child window does is still unverified — `docs/android-port.md` has said so since the port — so this follows the desktop's palette and gives up the rectangle outright, which is correct under either answer. It collapses `IsTerminalShowing` and not `IsTerminalSurface`, because the bar the sheet was raised from is part of that surface and dropping it would take the bar, the tabs and the whole arrangement with it, leaving the sheet floating over the page underneath. `OnSurfaceChanged` is the one place the flag is lowered, and that is the load- bearing half. Every way out of a terminal ends there — a destination, the files screen, the palette connecting to a host, closing the last tab, a lock — and each of them would otherwise leave a sheet set over a page. Not merely untidy: the flag holds the renderer blank, so the next return to the terminal would draw the menu again over a rectangle kept blank by it. Opening is refused off the terminal surface for the same reason from the other direction. The back gesture gains a guard above the switch, in the shape of the editor guard that arrived with the phone's `+`. It is nearer than any of them: with no header and no bottom bar, while the menu is up that gesture is the only way off it other than the scrim and CANCEL. The bottom bar's Terminal entry lost its `IsCurrent` binding. The bar is collapsed on that surface, so the binding could only ever be read as false, and a rule about a state the control cannot be in is a claim that it can. Three tests in `ShellFlowTests`, which is where shared state-machine behaviour for this head goes: the collapse and its recovery, the refusal to open over a page, and the sheet lowering both by a menu entry and by a route it was never wired to. Everything visual needs a device, so it is phase 11 of `docs/manual-checks.md` — and 11.2 is the check that would finally settle the compositing question this head has carried as unverified since the port.
2690 lines
119 KiB
C#
2690 lines
119 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Collections.Specialized;
|
|
using System.ComponentModel;
|
|
using System.Security.Authentication;
|
|
using Avalonia.Threading;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using DodoSSH.Client.Api;
|
|
using DodoSSH.Client.Auth;
|
|
using DodoSSH.Client.Import;
|
|
using DodoSSH.Client.ObjectStore;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
using DodoSSH.Crypto;
|
|
|
|
namespace DodoSSH.Client.Shell.ViewModels;
|
|
|
|
/// <summary>Which of the shell's mutually exclusive screens is showing.</summary>
|
|
internal enum ShellState
|
|
{
|
|
/// <summary>Reading the cache to find out whether this machine is enrolled.</summary>
|
|
Starting = 0,
|
|
|
|
/// <summary>Nothing is cached. The user has to name a server and sign in, which needs a network.</summary>
|
|
NeedsServer = 1,
|
|
|
|
/// <summary>Signed in, but the account has no vault key yet.</summary>
|
|
NeedsEnrollment = 2,
|
|
|
|
/// <summary>
|
|
/// Showing the recovery code, and refusing to move on until the user confirms they have it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A separate state rather than a dismissible banner, because this is the only moment the code exists.
|
|
/// Losing it along with the passphrase means the vault is unrecoverable and there is no server-side
|
|
/// reset by design — so this is the one screen a user must not be able to click past.
|
|
/// </remarks>
|
|
ShowingRecoveryCode = 3,
|
|
|
|
/// <summary>Enrolled. The passphrase opens the vault, with or without a network.</summary>
|
|
Locked = 4,
|
|
|
|
/// <summary>Open.</summary>
|
|
Unlocked = 5,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Which of the unlocked application's screens the nav rail is pointing at.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Only meaningful while <see cref="ShellState.Unlocked"/>. The setup and unlock screens are
|
|
/// <see cref="ShellState"/>, and the two are deliberately different things: one is how far through getting
|
|
/// in you are, the other is what you are looking at once you are.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="Team"/> is in this list without anything behind it, which is stated on the screen itself
|
|
/// rather than hidden by dropping it from the rail. See <c>docs/design-import-gaps.md</c>: teams are M3, and
|
|
/// a rail that quietly had four entries would make its eventual arrival look like a new product rather than
|
|
/// a milestone. <see cref="Transfers"/> was the other one until M2 built it.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal enum ShellScreen
|
|
{
|
|
/// <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>
|
|
Transfers = 1,
|
|
|
|
/// <summary>Everything in the vault that is not a host.</summary>
|
|
Vault = 2,
|
|
|
|
/// <summary>Shared vaults and the people in them. Nothing implements it yet.</summary>
|
|
Team = 3,
|
|
|
|
/// <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>
|
|
/// The phone's hub for everything the bottom bar has no room for.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Drawn by the Android head alone. The desktop has a nav rail with room for every destination, so it has
|
|
/// nothing to put behind a hub and never sets this; the v2 phone design has four slots and nine places to
|
|
/// go, so five of them live one tap deeper. It is a member of the shared enum rather than a phone-local
|
|
/// bool because it is a value of <see cref="Screen"/> like any other — the back arrow on each of those
|
|
/// five screens comes here, and a second notion of "where am I" is how the two would disagree.
|
|
/// </para>
|
|
/// <inheritdoc cref="KnownHosts" path="/remarks" />
|
|
/// </remarks>
|
|
More = 9,
|
|
|
|
/// <summary>
|
|
/// Objects in an S3-compatible bucket.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The same screen as <see cref="Transfers"/> over the same <see cref="TransfersViewModel"/>, and a
|
|
/// separate destination anyway. What differs is only which picker is offered, so the two do not each
|
|
/// need a screen — but they do each need a name, because the v2 design puts SFTP and S3 side by side in
|
|
/// the hub, and a single "files" entry that silently remembered which kind you last opened would be a
|
|
/// destination whose meaning depended on history. Entering either sets
|
|
/// <see cref="TransfersViewModel.Remote"/>; see <see cref="OnScreenChanged"/>.
|
|
/// </remarks>
|
|
Buckets = 10,
|
|
}
|
|
|
|
/// <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>
|
|
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The order of these states is the product's onboarding story. A fresh machine needs a server URL and one
|
|
/// browser sign-in; everything about the identity provider comes from
|
|
/// <c>/.well-known/dodossh-configuration</c>, so the user never configures an authority or a client id.
|
|
/// After that the network is optional — the cached salt and wrapped bundle mean the passphrase alone
|
|
/// unlocks, which is the state the application spends nearly all of its life in.
|
|
/// </para>
|
|
/// <para>
|
|
/// Key derivation runs on a worker thread. At the shipped profile it is a third of a second of solid CPU,
|
|
/// and doing that on the UI thread would freeze the window at exactly the moment the user is watching it.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisposable
|
|
{
|
|
private readonly ClientPaths paths;
|
|
private readonly ClientCacheFactory caches;
|
|
private readonly TerminalWorkspace workspace;
|
|
|
|
/// <remarks>
|
|
/// The concrete store rather than <c>IKnownHostStore</c>, because this is where its lifecycle belongs:
|
|
/// the interface is what the handshake asks, and opening a vault behind it, refreshing it and forgetting
|
|
/// it are this state machine's business. The same instance was handed to the connection factory when the
|
|
/// application was composed.
|
|
/// </remarks>
|
|
private readonly VaultKnownHostStore knownHosts;
|
|
|
|
/// <remarks>
|
|
/// Whatever this machine can keep a device key in, chosen once at composition. An interface because the
|
|
/// answer is a platform decision — see ADR 0007 — and because a machine with no TPM gets a store that
|
|
/// reports itself unavailable rather than a null this state machine would have to check for.
|
|
/// </remarks>
|
|
private readonly IDeviceKeyStore deviceKeys;
|
|
|
|
private readonly SignInHandler signIn;
|
|
|
|
/// <remarks>
|
|
/// Optional, and null is not merely "not configured": a shell with no resume handler is one that can
|
|
/// only be online because somebody signed in during this run, which is what every test that asserts
|
|
/// offline behaviour relies on.
|
|
/// </remarks>
|
|
private readonly ResumeHandler? resume;
|
|
|
|
private readonly TimeProvider clock;
|
|
private readonly Argon2Profile? passphraseProfile;
|
|
|
|
/// <remarks>
|
|
/// Built here from the paths rather than taken as a dependency, because it holds preferences and not
|
|
/// state: there is nothing for a head to substitute, and a constructor parameter every head would pass
|
|
/// the same value to is a parameter that only ever makes the heads longer.
|
|
/// </remarks>
|
|
private readonly ClientSettingsStore settings;
|
|
|
|
/// <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
|
|
/// a transfer in flight any more than it closes a running shell. See <see cref="LockAsync"/>. The vault
|
|
/// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list.
|
|
/// </remarks>
|
|
private readonly TransfersViewModel transfers;
|
|
|
|
/// <summary>
|
|
/// Where connections are recorded, for as long as a vault is open to record them into.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
|
|
/// and for the same reason: the thing that calls it — the workspace — outlives every lock.
|
|
/// </remarks>
|
|
private readonly ConnectionRecorder connectionLog;
|
|
|
|
private readonly TeamsViewModel teams;
|
|
|
|
/// <summary>
|
|
/// The tab standing in for each connection that has been asked for and has not answered yet.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Keyed on the attempt rather than on the host, because connecting no longer holds the vault and two
|
|
/// attempts against the same machine are a thing a user can now do by clicking twice. An entry lives
|
|
/// exactly as long as the attempt: it goes when the session opens, when the connection is refused, and
|
|
/// when the user closes the tab out from under it.
|
|
/// </remarks>
|
|
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
|
|
|
|
private IVaultServer? connection;
|
|
|
|
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
|
private string? rememberedToken;
|
|
|
|
/// <summary>Guards against two resume attempts overlapping.</summary>
|
|
/// <remarks>
|
|
/// A plain flag rather than a semaphore because every caller is on the UI thread — the sync loop and
|
|
/// the Sync button — and what has to be prevented is a second attempt starting while the first is
|
|
/// waiting on a token endpoint, not a data race.
|
|
/// </remarks>
|
|
private bool resuming;
|
|
|
|
private bool disposed;
|
|
|
|
/// <summary>
|
|
/// Establishes a connection to a server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A delegate rather than a direct call to <see cref="ServerConnection.SignInAsync"/>, so this whole
|
|
/// state machine can be driven by a test against an in-memory server. Sign-in is the one step that
|
|
/// genuinely needs a browser and a network, and letting it be the reason nothing else is testable
|
|
/// would be the wrong trade.
|
|
/// </remarks>
|
|
internal delegate Task<IVaultServer> SignInHandler(Uri serverUrl, CancellationToken cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Re-establishes a connection from a remembered sign-in, without a browser.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A delegate for the same reason <see cref="SignInHandler"/> is one: a real resume needs discovery
|
|
/// and a token endpoint, and making that the only way to reach this state machine would put the whole
|
|
/// "comes back online by itself" behaviour out of reach of a test.
|
|
/// </remarks>
|
|
internal delegate Task<IVaultServer> ResumeHandler(
|
|
Uri serverUrl,
|
|
string refreshToken,
|
|
CancellationToken cancellationToken);
|
|
|
|
/// <param name="sftpSessions">
|
|
/// How file-transfer sessions are opened. The same object as the connection factory in the composed
|
|
/// application — one type implements both — and a separate parameter because it is a separate capability
|
|
/// and the tests that drive this state machine have no use for it.
|
|
/// </param>
|
|
internal MainWindowViewModel(
|
|
ClientPaths paths,
|
|
ClientCacheFactory caches,
|
|
TerminalWorkspace workspace,
|
|
VaultKnownHostStore knownHosts,
|
|
IDeviceKeyStore deviceKeys,
|
|
SignInHandler signIn,
|
|
TimeProvider clock,
|
|
ISftpSessionFactory sftpSessions,
|
|
Argon2Profile? passphraseProfile = null,
|
|
ResumeHandler? resume = null,
|
|
Func<string, Task>? copyToClipboard = null)
|
|
{
|
|
this.paths = paths;
|
|
this.caches = caches;
|
|
this.workspace = workspace;
|
|
this.knownHosts = knownHosts;
|
|
this.deviceKeys = deviceKeys;
|
|
this.signIn = signIn;
|
|
this.resume = resume;
|
|
this.clock = clock;
|
|
this.passphraseProfile = passphraseProfile;
|
|
this.copyToClipboard = copyToClipboard;
|
|
|
|
transfers = new TransfersViewModel(sftpSessions, clock);
|
|
|
|
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
|
|
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
|
|
// running after it, and the entry it eventually produces belongs to the vault it was made in.
|
|
connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
|
|
this.workspace.ConnectionLog = connectionLog;
|
|
|
|
// Both dependencies as functions rather than values: the connection arrives after sign-in and the
|
|
// session after unlock, and both go away again on lock. Capturing either would give this screen a
|
|
// reference that outlives what it points at — which for a session means holding vault keys past the
|
|
// moment locking is supposed to have zeroed them.
|
|
teams = new TeamsViewModel(() => connection, () => Vault?.Session);
|
|
|
|
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
|
|
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
|
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
|
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
|
|
|
|
settings = new ClientSettingsStore(paths);
|
|
|
|
// Read straight away rather than at first use, so the value is right before anything can read it —
|
|
// a phone draws its terminal buttons from this, and a size that arrived a moment later would show
|
|
// as the interface correcting itself.
|
|
TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
|
|
|
|
_ = TellRendererTheFontSizeAsync();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The page starts at its own default and has no way to know what was stored, so somebody has to tell
|
|
/// it — and it cannot be told before its socket exists. Waiting on the renderer is the only ordering
|
|
/// available; nothing else knows when the page is there.
|
|
/// </para>
|
|
/// <para>
|
|
/// Failure is silence on purpose. A launch where no terminal is ever opened still runs this, and a
|
|
/// renderer that never attached is not a fault in that case — it is the ordinary shape of a session
|
|
/// spent in the keychain. The size is sent again by every change, so nothing is permanently lost.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task TellRendererTheFontSizeAsync()
|
|
{
|
|
try
|
|
{
|
|
await workspace.WaitForRendererAsync(CancellationToken.None).ConfigureAwait(false);
|
|
|
|
await workspace
|
|
.SetFontSizeAsync(TerminalFontSize, CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
|
|
{
|
|
// No renderer this run. Nothing to tell.
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Marshalled, because this arrives on the data plane's receive loop — see
|
|
/// <see cref="TerminalWorkspace.FontSizeStepRequested"/> — and everything it touches is a view model
|
|
/// property somebody's interface is bound to.
|
|
/// </remarks>
|
|
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
|
|
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
|
|
|
|
[ObservableProperty]
|
|
private ShellState state = ShellState.Starting;
|
|
|
|
[ObservableProperty]
|
|
private string statusMessage = "Opening the local cache…";
|
|
|
|
[ObservableProperty]
|
|
private bool isBusy;
|
|
|
|
/// <summary>Whether the unlock screen should offer a gesture instead of the passphrase.</summary>
|
|
[ObservableProperty]
|
|
private bool canUnlockWithDevice;
|
|
|
|
/// <summary>Whether an unlocked vault should offer to register this machine.</summary>
|
|
[ObservableProperty]
|
|
private bool canRegisterDevice;
|
|
|
|
/// <summary>Whether this machine has a device key to withdraw.</summary>
|
|
[ObservableProperty]
|
|
private bool canForgetDevice;
|
|
|
|
/// <summary>
|
|
/// Whether this machine can neither register a device key nor withdraw one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Not the negation of either flag on its own, which is exactly why it is worth a name. The two are
|
|
/// independent: a machine with no TPM cannot register, and a machine already registered has nothing to
|
|
/// register either — and only the second has something to take back. Both false at once is the one case
|
|
/// that means "this machine has nowhere to keep a key", which is worth saying out loud on a preferences
|
|
/// screen where the alternative is a section with no controls in it and no explanation.
|
|
/// </remarks>
|
|
internal bool HasNoDeviceKeyOption => !CanRegisterDevice && !CanForgetDevice;
|
|
|
|
/// <summary>
|
|
/// The address offered on first launch, before anything is enrolled.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Two defaults, because the two audiences never overlap. A release build is installed by somebody
|
|
/// signing in to the hosted deployment, and typing its address is the only thing standing between
|
|
/// them and a working application. A debug build is run from a clone, next to
|
|
/// <c>dotnet run --project src/DodoSSH.Api</c>, and shipping the hosted address there would point
|
|
/// every development launch at production — which is worse than an inconvenience, since sign-in is
|
|
/// the step that enrolls a device.
|
|
/// </para>
|
|
/// <para>
|
|
/// Note the schemes. <c>http</c> locally is not an oversight: the API's first launch profile — the
|
|
/// one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing an
|
|
/// HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
|
|
/// sends people looking for a certificate problem. See <see cref="ExplainSignInFailure" />.
|
|
/// </para>
|
|
/// </remarks>
|
|
#if DEBUG
|
|
internal const string DefaultServerUrl = "http://localhost:5233";
|
|
#else
|
|
internal const string DefaultServerUrl = "https://ssh.dodotech.cloud";
|
|
#endif
|
|
|
|
[ObservableProperty]
|
|
private string serverUrl = DefaultServerUrl;
|
|
|
|
[ObservableProperty]
|
|
private string passphrase = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string confirmPassphrase = string.Empty;
|
|
|
|
/// <summary>Shown once, immediately after enrolling, and never stored anywhere.</summary>
|
|
[ObservableProperty]
|
|
private string? recoveryCode;
|
|
|
|
[ObservableProperty]
|
|
private bool recoveryCodeWrittenDown;
|
|
|
|
/// <summary>Who this machine is enrolled as, readable without the passphrase.</summary>
|
|
[ObservableProperty]
|
|
private string? accountName;
|
|
|
|
[ObservableProperty]
|
|
private VaultViewModel? vault;
|
|
|
|
/// <summary>The approved-host-keys screen, which exists exactly as long as the vault behind it does.</summary>
|
|
/// <remarks>
|
|
/// Assigned from <see cref="OnVaultChanged"/> and nowhere else, so the three paths that open or close a
|
|
/// vault — unlocking, locking and signing out — cannot get out of step with it.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private KnownHostsViewModel? knownHostsScreen;
|
|
|
|
/// <inheritdoc cref="KnownHostsScreen" />
|
|
[ObservableProperty]
|
|
private ImportViewModel? importScreen;
|
|
|
|
/// <inheritdoc cref="KnownHostsScreen" />
|
|
[ObservableProperty]
|
|
private SnippetsViewModel? snippetsScreen;
|
|
|
|
/// <inheritdoc cref="KnownHostsScreen" />
|
|
[ObservableProperty]
|
|
private LogsViewModel? logsScreen;
|
|
|
|
/// <summary>
|
|
/// The teams screen, which the window binds to whether or not a vault is open.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: the screen reads a
|
|
/// server rather than a vault, and both of its dependencies are fetched through a function at the
|
|
/// moment they are needed. That means a lock does not have to tear it down and an unlock does not have
|
|
/// to rebuild it, and the list it is showing survives both.
|
|
/// </remarks>
|
|
internal TeamsViewModel Teams => teams;
|
|
|
|
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
|
|
/// <remarks>
|
|
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
|
|
/// the whole shell is — but the object behind it is what holds a transfer that is still running, so a
|
|
/// property that went null on lock would be a transfer nothing could report on afterwards.
|
|
/// </remarks>
|
|
internal TransfersViewModel Transfers => transfers;
|
|
|
|
/// <summary>
|
|
/// Shells that were left running when the vault was locked.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Refreshed by <see cref="LockAsync"/>, which is where the policy this reports is explained.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private int liveSessionCount;
|
|
|
|
internal bool HasLiveSessions => LiveSessionCount > 0;
|
|
|
|
/// <summary>The count as a sentence, because a bare number on a lock screen explains nothing.</summary>
|
|
internal string LiveSessionSummary => LiveSessionCount == 1
|
|
? "1 shell is still connected and still running."
|
|
: $"{LiveSessionCount} shells are still connected and still running.";
|
|
|
|
/// <summary>Where the embedded browser should navigate.</summary>
|
|
internal Uri TerminalPageUrl => workspace.PageUrl;
|
|
|
|
/// <summary>
|
|
/// Types into a terminal on behalf of something that is not the keyboard.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Exposed on the shell rather than reached through the workspace directly, because the workspace is a
|
|
/// composition-root object and a view has no business holding one — the same reason tabs go through
|
|
/// here rather than through <c>TerminalWorkspace.CloseSessionAsync</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// The Android head's accessory key row is what needs it: a software keyboard has no Ctrl, Esc, Tab or
|
|
/// arrows, so those keys are drawn and their bytes sent from here. Ordinary typing never comes this
|
|
/// way — it goes from the renderer straight down the socket.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal ValueTask SendTerminalInputAsync(uint sessionId, ReadOnlyMemory<byte> data) =>
|
|
workspace.SendInputAsync(sessionId, data, CancellationToken.None);
|
|
|
|
/// <summary>
|
|
/// How large the terminal draws, in CSS pixels.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A font size rather than a zoom, and the difference is the whole design. Zoom scales what is already
|
|
/// drawn, so the remote goes on wrapping to a width that is no longer on screen; changing the font size
|
|
/// refits the grid and tells the far end how many columns it now has. That is why this is one number
|
|
/// owned here and pushed to the renderer, rather than a gesture the page handles alone.
|
|
/// </para>
|
|
/// <para>
|
|
/// Owned by the shell rather than by the page for two reasons that pull the same way: it has to survive
|
|
/// a relaunch, and it has to be reachable from a button on a phone that has no keyboard to press
|
|
/// Ctrl+plus with. The page's chords arrive here as steps — see
|
|
/// <see cref="TerminalFontSizeStepEventArgs"/> — so both routes end in this property.
|
|
/// </para>
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private int terminalFontSize = ClientSettings.DefaultTerminalFontSize;
|
|
|
|
/// <summary>Whether the terminal could be drawn larger than it is.</summary>
|
|
internal bool CanEnlargeTerminalFont => TerminalFontSize < ClientSettings.MaximumTerminalFontSize;
|
|
|
|
/// <summary>Whether the terminal could be drawn smaller than it is.</summary>
|
|
internal bool CanShrinkTerminalFont => TerminalFontSize > ClientSettings.MinimumTerminalFontSize;
|
|
|
|
/// <summary>Draws the terminal one point larger.</summary>
|
|
[RelayCommand]
|
|
private void EnlargeTerminalFont() => StepTerminalFontSize(1);
|
|
|
|
/// <summary>Draws the terminal one point smaller.</summary>
|
|
[RelayCommand]
|
|
private void ShrinkTerminalFont() => StepTerminalFontSize(-1);
|
|
|
|
/// <summary>Puts the terminal back to the size it ships at.</summary>
|
|
/// <remarks>
|
|
/// Worth a command of its own rather than leaving people to count clicks back. A terminal that has been
|
|
/// made unreadable is hard to make readable again by eye, which is the state this exists for.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void ResetTerminalFont() => ApplyTerminalFontSize(ClientSettings.DefaultTerminalFontSize);
|
|
|
|
/// <param name="step">Points to move by, or zero to return to the default.</param>
|
|
private void StepTerminalFontSize(int step) => ApplyTerminalFontSize(
|
|
step == 0 ? ClientSettings.DefaultTerminalFontSize : TerminalFontSize + step);
|
|
|
|
/// <remarks>
|
|
/// One path for every route in — the phone's buttons, the page's chords, and the stored value read at
|
|
/// startup — so clamping, persisting and telling the renderer happen once each rather than three times
|
|
/// with one of them eventually forgotten.
|
|
/// </remarks>
|
|
private void ApplyTerminalFontSize(int pixels)
|
|
{
|
|
var clamped = ClientSettings.ClampTerminalFontSize(pixels);
|
|
|
|
// Told anyway when nothing moved. A step at the cap is a no-op here, but the page may have been
|
|
// reloaded since the last frame — and a renderer at the wrong size is worse than a redundant frame.
|
|
TerminalFontSize = clamped;
|
|
|
|
// Fire and forget: a socket that is not there yet is the ordinary case at startup, and a font size
|
|
// is not worth blocking a button handler on.
|
|
_ = workspace.SetFontSizeAsync(clamped, CancellationToken.None).AsTask();
|
|
|
|
// Read-modify-write against the file rather than against a field, so a setting this build does not
|
|
// know about — written by a newer one, or by hand — survives this one storing its own.
|
|
settings.Write(settings.Read() with { TerminalFontSize = clamped });
|
|
}
|
|
|
|
partial void OnTerminalFontSizeChanged(int value)
|
|
{
|
|
OnPropertyChanged(nameof(CanEnlargeTerminalFont));
|
|
OnPropertyChanged(nameof(CanShrinkTerminalFont));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Forwarded from <see cref="VaultViewModel.SessionOpened"/> rather than exposed there directly,
|
|
/// because <see cref="Vault"/> is replaced on every unlock and the view would have to re-subscribe
|
|
/// each time. This shell is the window's data context for the life of the process, so one
|
|
/// subscription is enough.
|
|
/// </remarks>
|
|
internal event EventHandler? TerminalSessionOpened;
|
|
|
|
internal bool IsStarting => State == ShellState.Starting;
|
|
|
|
internal bool IsNeedingServer => State == ShellState.NeedsServer;
|
|
|
|
internal bool IsNeedingEnrollment => State == ShellState.NeedsEnrollment;
|
|
|
|
internal bool IsShowingRecoveryCode => State == ShellState.ShowingRecoveryCode;
|
|
|
|
internal bool IsLocked => State == ShellState.Locked;
|
|
|
|
/// <summary>Whether the unlock card itself is showing, rather than the confirmation over it.</summary>
|
|
/// <remarks>
|
|
/// Its own property because the markup cannot express <c>IsLocked && !IsConfirmingSignOut</c>,
|
|
/// and the two cards genuinely swap rather than stack: the unlock card is already near the height the
|
|
/// window guarantees at its minimum size, so putting a second question underneath it would push
|
|
/// buttons off a screen with nothing to scroll.
|
|
/// </remarks>
|
|
internal bool IsAskingForThePassphrase => IsLocked && !IsConfirmingSignOut;
|
|
|
|
internal bool IsUnlocked => State == ShellState.Unlocked;
|
|
|
|
/// <summary>Whether a connection to the server is currently held.</summary>
|
|
internal bool IsOnline => connection is not null;
|
|
|
|
/// <summary>
|
|
/// Whether everything this machine has changed has reached the server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The design's titlebar says "SYNCED" beside a green dot, unconditionally. This is the honest version
|
|
/// of that claim, and it is deliberately conservative: true only while a connection is held, the last
|
|
/// pass actually reached the server, and the outbox is empty.
|
|
/// </para>
|
|
/// <para>
|
|
/// The middle condition is the one that is easy to leave out, and was. Holding an <c>IVaultServer</c>
|
|
/// proves a sign-in once succeeded and nothing more — it is obtained once and never dropped — so a
|
|
/// laptop whose lid has been shut all afternoon still has one, with an empty outbox, which is precisely
|
|
/// the shape of a green light that is lying. See <c>VaultViewModel.LastSyncFailed</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// It still does not mean this machine has a colleague's change from a second ago. Nothing short of a
|
|
/// completed pull could say that, and the pull runs on a one-minute timer. What it means is that this
|
|
/// machine can reach the server and has nothing stuck.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal bool IsFullySynced => IsOnline && Vault is { PendingChanges: 0, LastSyncFailed: false };
|
|
|
|
/// <summary>The same fact as a word, for the titlebar.</summary>
|
|
internal string SyncLabel => (IsOnline, Vault?.LastSyncFailed ?? true, Vault?.PendingChanges ?? 0) switch
|
|
{
|
|
(false, _, _) => "OFFLINE",
|
|
(true, true, _) => "UNREACHABLE",
|
|
(true, false, 0) => "SYNCED",
|
|
(true, false, 1) => "1 PENDING",
|
|
(true, false, var pending) => $"{pending} PENDING",
|
|
};
|
|
|
|
// ---- 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" />
|
|
internal bool IsTransfersScreen => Screen is ShellScreen.Transfers;
|
|
|
|
/// <inheritdoc cref="IsHostsScreen" />
|
|
internal bool IsVaultScreen => Screen is ShellScreen.Vault;
|
|
|
|
/// <inheritdoc cref="IsHostsScreen" />
|
|
internal bool IsTeamScreen => Screen is ShellScreen.Team;
|
|
|
|
/// <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;
|
|
|
|
/// <inheritdoc cref="IsHostsScreen" />
|
|
internal bool IsMoreScreen => Screen is ShellScreen.More;
|
|
|
|
/// <inheritdoc cref="IsHostsScreen" />
|
|
internal bool IsBucketsScreen => Screen is ShellScreen.Buckets;
|
|
|
|
/// <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;
|
|
|
|
/// <inheritdoc cref="IsHostsShowing" />
|
|
internal bool IsMoreShowing => IsShowingPages && IsMoreScreen;
|
|
|
|
/// <inheritdoc cref="IsHostsShowing" />
|
|
internal bool IsBucketsShowing => IsShowingPages && IsBucketsScreen;
|
|
|
|
/// <summary>
|
|
/// Whether the phone's MORE tab should light.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The hub and everything behind it, because a bottom bar that went dark the moment you opened one of
|
|
/// its destinations would be a bar that only ever lights three of its four entries. This is the one
|
|
/// place where "which tab" and "which screen" are deliberately not the same question — the other three
|
|
/// tabs are each exactly one screen, and this one is six.
|
|
///
|
|
/// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
|
|
/// the rail and never asks this.
|
|
/// </remarks>
|
|
internal bool IsMoreSurface =>
|
|
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
|
|
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences;
|
|
|
|
/// <summary>
|
|
/// Whether the terminal's WebView may be on screen at this instant.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>This is an occlusion rule, not a styling one.</b> The WebView is a native child window on Windows,
|
|
/// 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), the page area (every screen uses the full width), the quick-connect
|
|
/// palette, and the phone's connect sheet.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The sheet is here rather than in <see cref="IsTerminalSurface"/>, and the palette is not.</b> The
|
|
/// palette replaces the whole surface, so collapsing everything the terminal half draws is right. The
|
|
/// sheet is raised from the terminal's own top bar and that bar has to stay on screen behind it —
|
|
/// dropping the surface would take the bar, the tabs and the phone's whole chrome with it and leave the
|
|
/// sheet floating over the page underneath. So only the renderer's rectangle is given up.
|
|
/// </para>
|
|
/// <para>
|
|
/// <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
|
|
/// than on show, so WebView2 still starts, still loads the page and still lets the renderer connect
|
|
/// while this is false; only the bounds are withheld. Removing the control from the tree would not be
|
|
/// safe — that detaches it and destroys the whole WebView2 process tree.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal bool IsTerminalShowing =>
|
|
IsTerminalSurface && !IsConnectSheetOpen && SelectedTab is { HasSession: true };
|
|
|
|
/// <summary>
|
|
/// Whether the terminal half of the window is the half being shown, pane or no pane.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Every condition in <see cref="IsTerminalShowing"/> except the one about there being a session, and it
|
|
/// is worth its own name because a tab exists before its session does — see
|
|
/// <see cref="TerminalTabViewModel"/>. This is what "the user is looking at the terminal" means; the
|
|
/// other two say which of the two things that can be in that rectangle is drawn.
|
|
/// </remarks>
|
|
internal bool IsTerminalSurface => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
|
|
|
|
/// <summary>
|
|
/// Whether the card that stands in for a pane is showing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The other half of <see cref="IsTerminalShowing"/>, and exclusive with it by construction: a selected
|
|
/// tab either has a session or it does not. It covers both of the states in which it does not — still
|
|
/// connecting, and failed — because both are a tab with something to say and nothing to draw it in.
|
|
/// </para>
|
|
/// <para>
|
|
/// It obeys the same occlusion rule as everything else in that rectangle, which is why it has to turn the
|
|
/// terminal off rather than merely draw over it. See <see cref="IsTerminalShowing"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal bool IsConnectingShowing => IsTerminalSurface && SelectedTab is { HasSession: false };
|
|
|
|
/// <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;
|
|
Surface = ShellSurface.Page;
|
|
}
|
|
|
|
/// <summary>Switches to the terminal surface.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The other half of <see cref="ShowScreenCommand"/>, and it exists because the phone's bottom bar
|
|
/// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a
|
|
/// session or clicking a tab — because its tab strip is always on screen and is itself the way back.
|
|
/// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a
|
|
/// button, and a button needs a command.
|
|
/// </para>
|
|
/// <para>
|
|
/// Not gated on there being a tab, for the same reason <see cref="IsTerminalShowing"/> is not: closing
|
|
/// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal
|
|
/// screen carries an empty state anyway, which is worth being able to reach deliberately.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void ShowTerminal() => Surface = ShellSurface.Terminal;
|
|
|
|
/// <summary>
|
|
/// Whether the phone's connect menu is open over the terminal.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Drawn by the Android head alone, and shell state rather than something that view could hold on its
|
|
/// own for the reason <see cref="IsSearching"/> is: it has to collapse the renderer while it is up. See
|
|
/// <see cref="IsTerminalShowing"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// It exists because the phone gives a terminal the whole screen. The bottom bar and the vault header
|
|
/// are gone while a shell is showing, so the three things that bar was the way to — a host, a host's
|
|
/// files, a bucket — need a way back that is not "leave the terminal first and remember what you were
|
|
/// doing". The menu is that, and every entry on it is one of the two navigation commands above.
|
|
/// </para>
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool isConnectSheetOpen;
|
|
|
|
/// <summary>Raises the connect menu over the terminal.</summary>
|
|
/// <remarks>
|
|
/// Gated on the terminal surface rather than merely trusting its only button to be off screen otherwise.
|
|
/// The flag collapses the renderer, so one set while a page was showing would be a sheet nobody can see
|
|
/// holding a terminal hidden that nothing would put back.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void OpenConnectSheet()
|
|
{
|
|
if (!IsTerminalSurface)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsConnectSheetOpen = true;
|
|
}
|
|
|
|
/// <summary>Lowers the connect menu, leaving the terminal where it was.</summary>
|
|
/// <remarks>
|
|
/// The scrim, the CANCEL row and the system back gesture all come here. Choosing an entry does not, and
|
|
/// does not need to: every entry navigates, and leaving the terminal surface lowers the sheet on its own
|
|
/// — see <see cref="OnSurfaceChanged"/>, which is what makes "the sheet is only ever up over a terminal"
|
|
/// true of routes nobody wrote it for.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void CloseConnectSheet() => IsConnectSheetOpen = false;
|
|
|
|
// ---- Open terminals ----
|
|
|
|
/// <summary>
|
|
/// Every terminal that has been opened this run, in the order they were opened.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// On the shell rather than on the vault, and that follows from the lock policy rather than from
|
|
/// convenience. Locking disposes the vault and leaves shells running, so tabs rebuilt per unlock would
|
|
/// lose sessions that are still connected — the very sessions <see cref="LiveSessionCount"/> exists to
|
|
/// admit to. This object is the window's data context for the life of the process, and so is this list.
|
|
/// </remarks>
|
|
internal ObservableCollection<TerminalTabViewModel> Tabs { get; } = [];
|
|
|
|
[ObservableProperty]
|
|
private TerminalTabViewModel? selectedTab;
|
|
|
|
internal bool HasTabs => Tabs.Count > 0;
|
|
|
|
private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));
|
|
|
|
/// <summary>
|
|
/// Closes one terminal, ending its shell.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
|
|
/// close button and not a menu item: closing the window somebody's job is running in should take exactly
|
|
/// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task CloseTabAsync(TerminalTabViewModel tab)
|
|
{
|
|
if (tab is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Removed first, so the workspace's SessionEnded — which fires as the pump unwinds — finds no tab to
|
|
// mark dead and does nothing. The alternative ordering leaves a window in which a tab that is on its
|
|
// way out is repainted as disconnected.
|
|
var index = Tabs.IndexOf(tab);
|
|
Tabs.Remove(tab);
|
|
|
|
if (ReferenceEquals(SelectedTab, tab))
|
|
{
|
|
// The neighbour, preferring the one on the left, which is where the eye already is.
|
|
SelectedTab = Tabs.Count == 0
|
|
? null
|
|
: 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
|
|
// changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
|
|
// terminal just went would keep a lit dot until something else happened to move the selection.
|
|
RefreshConnectedHosts();
|
|
|
|
// What the rectangle holds is decided by the selected tab's state, and the line above may well have
|
|
// moved the selection from a card to a pane or the other way round.
|
|
RaiseTerminalState();
|
|
|
|
if (!tab.HasSession)
|
|
{
|
|
// Nothing to end: this tab is a connection that has not happened, or one that never will. The
|
|
// attempt is forgotten so a handshake still in flight does not come back and reopen a tab the
|
|
// user has just dismissed — it becomes a session with no tab, which OnVaultSessionOpened adopts
|
|
// rather than drops, because a running shell nothing names is worse than a tab that reappears.
|
|
foreach (var attemptId in attempts
|
|
.Where(entry => ReferenceEquals(entry.Value, tab))
|
|
.Select(entry => entry.Key)
|
|
.ToArray())
|
|
{
|
|
attempts.Remove(attemptId);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
|
|
}
|
|
|
|
// ---- Quick connect ----
|
|
|
|
/// <summary>Whether the quick-connect palette is open over the window.</summary>
|
|
/// <remarks>
|
|
/// It has to collapse the terminal while it is open — see <see cref="IsTerminalShowing"/> — which is why
|
|
/// this is shell state rather than something a view could hold on its own.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool isSearching;
|
|
|
|
[ObservableProperty]
|
|
private string searchText = string.Empty;
|
|
|
|
/// <summary>
|
|
/// The hosts the palette is offering, best match first.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The design's box says "search hosts · run command". Only the first half is here: a command palette
|
|
/// needs commands to run, and this application has no snippet or saved-command item type — see
|
|
/// <c>docs/design-import-gaps.md</c>. Offering an empty command list under a box that promised one is
|
|
/// worse than a box that promises only what it does.
|
|
/// </remarks>
|
|
internal ObservableCollection<HostRowViewModel> SearchResults { get; } = [];
|
|
|
|
[ObservableProperty]
|
|
private HostRowViewModel? selectedSearchResult;
|
|
|
|
/// <summary>Whether the palette has anything to offer.</summary>
|
|
/// <remarks>
|
|
/// A property rather than <c>{Binding !SearchResults.Count}</c> in the markup. Avalonia's <c>!</c> is a
|
|
/// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
|
|
/// its default of true, and "No host matches that" is shown permanently — under a list of matches.
|
|
/// </remarks>
|
|
internal bool HasSearchResults => SearchResults.Count > 0;
|
|
|
|
/// <summary>Opens the palette, or closes it if it is already open.</summary>
|
|
[RelayCommand]
|
|
private void ToggleSearch()
|
|
{
|
|
if (IsSearching)
|
|
{
|
|
CloseSearch();
|
|
return;
|
|
}
|
|
|
|
if (!IsUnlocked)
|
|
{
|
|
return;
|
|
}
|
|
|
|
SearchText = string.Empty;
|
|
RefreshSearchResults();
|
|
IsSearching = true;
|
|
}
|
|
|
|
/// <summary>Dismisses the palette without connecting.</summary>
|
|
[RelayCommand]
|
|
private void CloseSearch()
|
|
{
|
|
IsSearching = false;
|
|
SearchText = string.Empty;
|
|
SearchResults.Clear();
|
|
SelectedSearchResult = null;
|
|
|
|
OnPropertyChanged(nameof(HasSearchResults));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Selects the highlighted host and connects to it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Goes through the vault's own <c>ConnectCommand</c> rather than opening a session directly, so the
|
|
/// palette inherits every refusal that path already makes — a dangling key binding, a host with no
|
|
/// username, a host key that has changed. A second connect path would be a second place for those to be
|
|
/// forgotten.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task ConnectToSearchResultAsync()
|
|
{
|
|
if (Vault is not { } vault || SelectedSearchResult is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
CloseSearch();
|
|
|
|
// The hosts page, because that is where this connection's questions get asked. An unknown or changed
|
|
// host key is answered by a prompt drawn on that page and the palette opens from any screen, so
|
|
// connecting from the files screen without this would leave the question behind the screen that asked
|
|
// it. The surface does not stay here — the tab that appears for the attempt takes it — and it does not
|
|
// need to: a refusal that needs an answer puts the page back, which is where this leaves the screen.
|
|
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
|
|
// generates ExecuteAsync(object? parameter) that ignores the argument and supplies a token from its
|
|
// own source — so passing this one would read as cancellation plumbing that is not there.
|
|
await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one
|
|
/// that merely contains it, and both come before a match found only in the address. Typing three
|
|
/// characters of a name people use daily should not put that host third.
|
|
/// </remarks>
|
|
private void RefreshSearchResults()
|
|
{
|
|
SearchResults.Clear();
|
|
|
|
if (Vault is not { } vault)
|
|
{
|
|
SelectedSearchResult = null;
|
|
return;
|
|
}
|
|
|
|
var query = SearchText.Trim();
|
|
|
|
var matches = query.Length == 0
|
|
? vault.Hosts.AsEnumerable()
|
|
: vault.Hosts
|
|
.Select(host => (host, rank: Rank(host, query)))
|
|
.Where(candidate => candidate.rank < int.MaxValue)
|
|
.OrderBy(candidate => candidate.rank)
|
|
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
|
|
.Select(candidate => candidate.host);
|
|
|
|
foreach (var host in matches.Take(8))
|
|
{
|
|
SearchResults.Add(host);
|
|
}
|
|
|
|
SelectedSearchResult = SearchResults.FirstOrDefault();
|
|
|
|
OnPropertyChanged(nameof(HasSearchResults));
|
|
}
|
|
|
|
private static int Rank(HostRowViewModel host, string query)
|
|
{
|
|
if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
if (host.Label.Contains(query, StringComparison.CurrentCultureIgnoreCase))
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|
|
? 2
|
|
: int.MaxValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Brings the schema up to date and works out which screen to show.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Migrating happens before unlock and touches no encrypted content — only the shape of the tables.
|
|
/// That is the point of migrating rather than recreating: a user who upgrades while offline must still
|
|
/// be able to open their vault.
|
|
/// </remarks>
|
|
internal async Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
paths.EnsureCreated();
|
|
await caches.MigrateAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
var profile = await Opener().ReadProfileAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
if (profile is null)
|
|
{
|
|
State = ShellState.NeedsServer;
|
|
StatusMessage = "Sign in to a DodoSSH server to set this machine up.";
|
|
return;
|
|
}
|
|
|
|
AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject;
|
|
ServerUrl = profile.ServerUrl;
|
|
State = ShellState.Locked;
|
|
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
|
|
|
|
// Both halves have to hold: a wrap in the cache, and a machine still willing to hand the key
|
|
// back. Offering the button without the second would prompt for a key that is not there; without
|
|
// the first it would prompt for a wrap that is not there. Neither failure is one a user could
|
|
// make sense of, so the button simply does not appear.
|
|
CanUnlockWithDevice = profile.DeviceWrappedPrivateKey is not null
|
|
&& await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
{
|
|
State = ShellState.NeedsServer;
|
|
StatusMessage = $"The local cache could not be opened: {exception.Message}";
|
|
}
|
|
}
|
|
|
|
/// <summary>Discovers the server and runs the browser sign-in.</summary>
|
|
[RelayCommand]
|
|
private async Task SignInAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var url))
|
|
{
|
|
StatusMessage = "That is not a valid server URL.";
|
|
return;
|
|
}
|
|
|
|
// Checked separately from parsing, because "localhost:5233" parses perfectly well as an absolute
|
|
// URI whose scheme is "localhost" — and then fails much later with something unrelated to the
|
|
// actual mistake.
|
|
if (url.Scheme is not ("http" or "https"))
|
|
{
|
|
StatusMessage = $"A server URL has to start with http:// or https://, not {url.Scheme}:.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Opening your browser to sign in…",
|
|
explain: exception => ExplainSignInFailure(exception, url),
|
|
work: async () =>
|
|
{
|
|
connection?.Dispose();
|
|
connection = null;
|
|
|
|
connection = await signIn(url, cancellationToken).ConfigureAwait(true);
|
|
|
|
OnPropertyChanged(nameof(IsOnline));
|
|
RaiseSyncState();
|
|
|
|
// The browser is finished with, and what follows is a round trip to the DodoSSH server
|
|
// that can take a while or fail on its own. Saying so is the difference between a wait
|
|
// and a hang: a screen still reading "Opening your browser to sign in…" while the server
|
|
// is the thing struggling sends the user back to a browser that did nothing wrong.
|
|
StatusMessage = $"Signed in. Asking {url.Host} about your account…";
|
|
|
|
var outcome = await Provisioner()!
|
|
.RefreshAsync(ServerUrl, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
|
|
StatusMessage = outcome.Message;
|
|
|
|
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
|
|
{
|
|
State = ShellState.NeedsEnrollment;
|
|
return;
|
|
}
|
|
|
|
// An unlocked vault stays unlocked. This command is reachable from the preferences screen
|
|
// of a running application — it is how somebody whose sign-in expired gets back online —
|
|
// and moving the state machine to Locked there would throw an unlock screen over an open
|
|
// vault whose keys are still in memory, which is neither locked nor honest.
|
|
if (IsUnlocked)
|
|
{
|
|
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
|
|
return;
|
|
}
|
|
|
|
State = ShellState.Locked;
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Creates the identity key and the personal vault.</summary>
|
|
[RelayCommand]
|
|
private async Task EnrollAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (Provisioner() is not { } provisioner)
|
|
{
|
|
StatusMessage = "Sign in first.";
|
|
return;
|
|
}
|
|
|
|
if (!ValidateNewPassphrase())
|
|
{
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Creating your keychain. This deliberately takes a moment…",
|
|
async () =>
|
|
{
|
|
var chosen = Passphrase;
|
|
|
|
var outcome = await Task
|
|
.Run(
|
|
() => provisioner.EnrollAsync(
|
|
ServerUrl,
|
|
chosen,
|
|
Environment.MachineName,
|
|
"Personal",
|
|
cancellationToken),
|
|
cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
ConfirmPassphrase = string.Empty;
|
|
RecoveryCode = outcome.RecoveryCode;
|
|
RecoveryCodeWrittenDown = false;
|
|
StatusMessage = outcome.Message;
|
|
|
|
// A brand-new account always yields a code. An account someone else already enrolled does
|
|
// not, and there is nothing to show.
|
|
State = RecoveryCode is null ? ShellState.Locked : ShellState.ShowingRecoveryCode;
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Leaves the recovery-code screen, once the user says they have it.</summary>
|
|
[RelayCommand]
|
|
private void ConfirmRecoveryCode()
|
|
{
|
|
if (!RecoveryCodeWrittenDown)
|
|
{
|
|
StatusMessage = "Confirm you have written the recovery code down first.";
|
|
return;
|
|
}
|
|
|
|
// Cleared from memory as well as from the screen. It was never persisted, and keeping it in a view
|
|
// model for the rest of the session would undo that.
|
|
RecoveryCode = null;
|
|
State = ShellState.Locked;
|
|
StatusMessage = "Unlock with the passphrase you just chose.";
|
|
}
|
|
|
|
/// <summary>Opens the vault.</summary>
|
|
[RelayCommand]
|
|
private async Task UnlockAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (Passphrase.Length == 0)
|
|
{
|
|
StatusMessage = "Enter your keychain passphrase.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Unlocking…",
|
|
async () =>
|
|
{
|
|
var entered = Passphrase;
|
|
|
|
// Off the UI thread: Argon2id at the shipped profile is a third of a second of solid CPU
|
|
// and would otherwise freeze the window mid-unlock.
|
|
var outcome = await Task
|
|
.Run(() => Opener().UnlockAsync(entered, cancellationToken), cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
StatusMessage = outcome.Message;
|
|
|
|
if (!outcome.IsUnlocked)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Passphrase = string.Empty;
|
|
|
|
await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
|
|
/// <remarks>
|
|
/// No <c>Task.Run</c>, unlike the passphrase path: there is no Argon2 to pay for here, and the work that
|
|
/// does block is a Windows consent dialog which belongs on the UI thread anyway.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task UnlockWithDeviceAsync(CancellationToken cancellationToken)
|
|
{
|
|
await RunAsync(
|
|
"Waiting for Windows…",
|
|
async () =>
|
|
{
|
|
var outcome = await Opener()
|
|
.UnlockWithDeviceAsync(deviceKeys, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
StatusMessage = outcome.Message;
|
|
|
|
if (!outcome.IsUnlocked)
|
|
{
|
|
// A declined gesture leaves the passphrase box exactly where it was, which is the whole
|
|
// fallback: the user types instead. Nothing about the screen changes but the message.
|
|
return;
|
|
}
|
|
|
|
await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers this machine so a later launch can unlock with a gesture.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Needs a network, because the wrap has to reach the server — a wrap that exists only here would be
|
|
/// lost with the cache file and could never be revoked. Needs an unlocked vault too, because only an
|
|
/// open session can seal the bundle.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task RegisterDeviceAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (Vault is not { } vault || connection is null)
|
|
{
|
|
StatusMessage = "Sign in first: registering this machine has to reach the server.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Waiting for Windows…",
|
|
async () =>
|
|
{
|
|
var name = Environment.MachineName;
|
|
|
|
var registered = await vault.Session
|
|
.RegisterDeviceAsync(connection.Account, deviceKeys, name, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
if (!registered)
|
|
{
|
|
StatusMessage = "This machine has nowhere to keep a device key.";
|
|
return;
|
|
}
|
|
|
|
CanRegisterDevice = false;
|
|
CanForgetDevice = true;
|
|
StatusMessage = $"'{name}' can now unlock without your passphrase.";
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Withdraws this machine's device key, here and on the account.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Offered without a confirmation prompt, which is deliberate. The cost of pressing it by accident is one
|
|
/// passphrase and one re-registration; the cost of a confirmation dialog is a moment's hesitation at the
|
|
/// point somebody has realised a machine is in the wrong hands. Reversible and urgent beats guarded.
|
|
/// </para>
|
|
/// <para>
|
|
/// Works offline, and says so. What decides whether this machine may unlock itself is entirely local, so
|
|
/// the useful half always happens — the account being told is the half that can be out of reach.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task ForgetDeviceAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (Vault is not { } vault)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Waiting for Windows…",
|
|
async () =>
|
|
{
|
|
var revocation = await vault.Session
|
|
.ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
CanForgetDevice = false;
|
|
|
|
// Not re-offered here even though it is now true, because registering probes the TPM and
|
|
// this is not the moment to do it: somebody who has just withdrawn a device is not about to
|
|
// add one back, and the offer reappears on the next unlock.
|
|
StatusMessage = revocation switch
|
|
{
|
|
DeviceRevocation.Complete =>
|
|
"This machine no longer unlocks without your passphrase, and the account no longer "
|
|
+ "lists it.",
|
|
DeviceRevocation.LocalOnly =>
|
|
"This machine no longer unlocks without your passphrase. You are offline, so the "
|
|
+ "account still lists it — sign in and withdraw it again to finish.",
|
|
_ => "There was no device key on this machine.",
|
|
};
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes ownership of a freshly opened session, whichever door opened it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Shared by both unlock paths rather than duplicated, because the ordering in here is load-bearing and
|
|
/// a second copy would be a second chance to get it wrong.
|
|
/// </remarks>
|
|
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
|
|
{
|
|
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
|
|
|
|
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
|
|
// has not already registered one. Asked once here rather than recomputed, because the answer
|
|
// involves a TPM probe.
|
|
CanRegisterDevice = session.Profile.DeviceWrappedPrivateKey is null
|
|
&& await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
// The other side of the same fact, and it needs its own flag rather than the negation of that one:
|
|
// "not offered because this machine has no TPM" and "not offered because it is already registered"
|
|
// are both !CanRegisterDevice, and only the second has anything to withdraw.
|
|
CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;
|
|
|
|
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
// 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, 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
|
|
// second old.
|
|
RefreshConnectedHosts();
|
|
|
|
// After the first load, so the list is on screen before anything talks to a server. The loop is
|
|
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
|
|
// observable collections single-threaded.
|
|
//
|
|
// Its first pass is also what brings this machine online: the pass asks ReconnectAsync for a
|
|
// server, and that is where a remembered sign-in is resumed. Nothing here has to know whether
|
|
// this unlock followed a sign-in or a cold launch on a train.
|
|
//
|
|
// Deliberately not awaited here, and not done before this point either. Resuming is a discovery
|
|
// call and a token exchange — a network round trip, and on an unreachable network a slow one —
|
|
// and unlocking must never wait on one. Everything the unlock screen promises about working
|
|
// offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
|
|
// the titlebar says OFFLINE until the round trip this starts has an answer.
|
|
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>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Handed to the vault, which asks once per synchronisation pass. That cadence is the whole design:
|
|
/// there is no connectivity monitor and no reconnect backoff, because a pass a minute already is one,
|
|
/// and a machine that comes back from a closed lid is online again within a minute of having a
|
|
/// network — with nothing pressed and no browser opened.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Resuming needs an unlocked vault, and that is deliberate rather than incidental.</b> The
|
|
/// remembered refresh token is sealed under the vault's own cache key, so this can only succeed after
|
|
/// somebody has opened the vault — a stolen laptop yields a cache file that cannot reach the account
|
|
/// any more than it can read the hosts.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every failure returns null and stays quiet, with one exception: a provider that <em>refuses</em> the
|
|
/// token is not a transient condition and will refuse it again once a minute forever, so that one is
|
|
/// said out loud and the token is dropped.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<IVaultServer?> ReconnectAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (connection is { } held)
|
|
{
|
|
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
|
|
return held;
|
|
}
|
|
|
|
if (resume is not { } handler || resuming || Vault is not { } vault)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
resuming = true;
|
|
|
|
try
|
|
{
|
|
return await ResumeAsync(vault, handler, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
finally
|
|
{
|
|
resuming = false;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Split from <see cref="ReconnectAsync"/> only so the guard, the flag and the attempt are three short
|
|
/// things rather than one long one. Everything about why this behaves as it does is up there.
|
|
/// </remarks>
|
|
private async Task<IVaultServer?> ResumeAsync(
|
|
VaultViewModel vault,
|
|
ResumeHandler handler,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var token = await vault.Session
|
|
.ReadRememberedSignInAsync(cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
if (token is null
|
|
|| !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var resumed = await handler(server, token, cancellationToken).ConfigureAwait(true);
|
|
|
|
connection = resumed;
|
|
rememberedToken = token;
|
|
|
|
OnPropertyChanged(nameof(IsOnline));
|
|
RaiseSyncState();
|
|
|
|
// The refresh that just happened may have rotated the token, and the rotated one is the only
|
|
// one the next launch can use.
|
|
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
return resumed;
|
|
}
|
|
catch (OidcException exception)
|
|
{
|
|
// The provider answered and said no: the session was revoked, or the token was rotated and
|
|
// this machine kept the old one. Retrying costs a round trip a minute and can only ever get
|
|
// the same answer, so the token goes and the user is told the one thing that fixes it.
|
|
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
Announce($"Your sign-in has expired, so this machine is offline: {exception.Message} "
|
|
+ "Sign in again from Preferences to start syncing.");
|
|
|
|
return null;
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// Everything else is a machine with no network, a server that is down, or a vault that was
|
|
// locked mid-attempt — all of which are ordinary and all of which resolve themselves. The
|
|
// titlebar already says OFFLINE; a socket error once a minute would say nothing more.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the connection's current refresh token into the vault, if it has changed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Called on every pass rather than driven by an event, because providers rotate the token inside a
|
|
/// refresh that happens on whatever thread an API call was made from — and a value read once a minute
|
|
/// is current enough for something only a relaunch reads. A failure here costs one browser sign-in on
|
|
/// the next launch and nothing else, which is not worth interrupting anybody over.
|
|
/// </remarks>
|
|
private async Task RememberSignInAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (connection?.RefreshToken is not { } token
|
|
|| Vault is not { } vault
|
|
|| string.Equals(token, rememberedToken, StringComparison.Ordinal))
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await vault.Session.RememberSignInAsync(token, cancellationToken).ConfigureAwait(true);
|
|
rememberedToken = token;
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// Left unremembered. The application is signed in for this run either way.
|
|
}
|
|
}
|
|
|
|
/// <summary>Drops the remembered sign-in, so nothing tries to resume it again.</summary>
|
|
private async Task ForgetSignInAsync(CancellationToken cancellationToken)
|
|
{
|
|
rememberedToken = null;
|
|
|
|
if (Vault is not { } vault)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// A cache that will not take the deletion is one the next launch will fail to resume from and
|
|
// then delete itself. Nothing here is worth a message.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Says something wherever the user is looking.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The shell's own message is on the setup and unlock cards, and the status bar shows the vault's — so
|
|
/// a message about the connection, which is the shell's business but only interesting while somebody
|
|
/// is using an open vault, has to go to both or it is invisible half the time.
|
|
/// </remarks>
|
|
private void Announce(string message)
|
|
{
|
|
StatusMessage = message;
|
|
|
|
if (Vault is { } vault)
|
|
{
|
|
vault.Status = message;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Closes the vault and forgets every key it held. Open shells keep running.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Lock is a vault operation, and deliberately not a disconnect.</b> The reason a person locks is
|
|
/// that they are walking away from the machine, which is exactly the moment a long upgrade, build or
|
|
/// transfer is most likely to be in flight — so killing every shell would make Lock a button that
|
|
/// destroys work, and the predictable response is to stop using it and leave the vault open instead.
|
|
/// The same argument decides it for the idle auto-lock this will grow: an unattended timeout that
|
|
/// terminated a running job would be worse than the exposure it removes.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>What "locked" therefore describes.</b> Disposing the vault zeroes the identity keys, the vault
|
|
/// keys and the cache key, so nothing on disk can be read without the passphrase again. It says
|
|
/// nothing about this machine's access to remote hosts: an SSH channel authenticated at connect time
|
|
/// needs no vault key to keep running, and the credential it used was already spent. Locking cannot
|
|
/// retroactively un-authorise a session any more than revocation can — the same honest limit the
|
|
/// README records for a removed team member. So a locked DodoSSH still holds open, authenticated
|
|
/// channels, and <see cref="LiveSessionCount"/> is shown on the unlock screen rather than left to be
|
|
/// inferred from a terminal that the lock screen hides.
|
|
/// </para>
|
|
/// <para>
|
|
/// The count is a snapshot taken here. While locked it can only fall — opening a session needs the
|
|
/// vault — so a stale value over-reports and never under-reports, which is the safe direction for a
|
|
/// warning of this kind.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task LockAsync()
|
|
{
|
|
// First, and before the session it read from goes: a synchronisation pass may be in flight, and it
|
|
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
|
|
// 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.
|
|
transfers.Detach();
|
|
|
|
if (Vault is { } open)
|
|
{
|
|
Vault = null;
|
|
await open.DisposeAsync().ConfigureAwait(true);
|
|
}
|
|
|
|
LiveSessionCount = workspace.LiveSessionCount;
|
|
|
|
// A confirmation armed on the preferences screen must not survive onto the unlock screen, where
|
|
// the same card is offered with a warning it can no longer count.
|
|
IsConfirmingSignOut = false;
|
|
|
|
State = ShellState.Locked;
|
|
StatusMessage = "Locked.";
|
|
}
|
|
|
|
// ---- Signing out ----
|
|
|
|
/// <summary>Whether the sign-out confirmation is showing.</summary>
|
|
/// <remarks>
|
|
/// A state rather than a dialog, for the same reason the recovery code is a screen: this is the one
|
|
/// action in the application that destroys something a user cannot get back from here — an unpushed
|
|
/// change — and it has to be able to say what is about to go before it goes.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool isConfirmingSignOut;
|
|
|
|
/// <summary>
|
|
/// What signing out costs, on this machine, right now.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The count is the part worth having. Everything else in the vault is on the server and comes back
|
|
/// with the next sign-in; an operation still in the outbox exists nowhere else in the world, and
|
|
/// "your changes will be lost" without a number leaves somebody guessing whether it means theirs.
|
|
/// </para>
|
|
/// <para>
|
|
/// A locked vault cannot be counted — the outbox is sealed under the key the vault holds — so it gets
|
|
/// the honest form of the same warning rather than a zero it has not earned.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal string SignOutWarning => (IsUnlocked, Vault?.PendingChanges ?? 0) switch
|
|
{
|
|
(false, _) =>
|
|
"Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
|
|
+ "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) =>
|
|
"1 change has not reached the server yet and will be lost. Sync first to keep it.",
|
|
(true, var pending) =>
|
|
$"{pending} changes have not reached the server yet and will be lost. Sync first to keep them.",
|
|
};
|
|
|
|
/// <summary>Asks whether the user means it.</summary>
|
|
[RelayCommand]
|
|
private void SignOut()
|
|
{
|
|
// Taken now so the card can disclose it, on the same reasoning as the lock screen's: signing out
|
|
// does not close a shell any more than locking does, and a screen that sends somebody back to
|
|
// "connect to your server" while their upgrade is still running should say so.
|
|
LiveSessionCount = workspace.LiveSessionCount;
|
|
|
|
OnPropertyChanged(nameof(SignOutWarning));
|
|
|
|
IsConfirmingSignOut = true;
|
|
}
|
|
|
|
/// <summary>Thinks better of it.</summary>
|
|
[RelayCommand]
|
|
private void CancelSignOut() => IsConfirmingSignOut = false;
|
|
|
|
/// <summary>
|
|
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>What this does and does not destroy.</b> It empties the local cache — the profile, the wrapped
|
|
/// bundle, the item mirror, the outbox and the conflict log — and forgets this machine's device key
|
|
/// here and on the account. The vault itself is on the server and is untouched, which is what makes
|
|
/// this safe to offer beside a passphrase box: somebody who has forgotten their passphrase can reset
|
|
/// this machine and sign in again, and the only thing they lose is what this machine had not yet sent.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Ordered so that a failure cannot leave a half-signed-out machine.</b> The device is withdrawn
|
|
/// while there is still a session and a connection to withdraw it through; the vault is closed before
|
|
/// the cache under it is emptied; and the cache is emptied last, because it is the step that makes
|
|
/// this machine unenrolled and everything before it is a courtesy that a wiped profile makes moot.
|
|
/// </para>
|
|
/// <para>
|
|
/// It does not end the session at the identity provider — there is no back channel to it from here,
|
|
/// and pretending otherwise would be the sort of claim this project writes down instead of implying.
|
|
/// The refresh token this machine held is dropped and never used again; the provider's own session
|
|
/// outlives it, which is what the preferences screen says out loud.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task ConfirmSignOutAsync(CancellationToken cancellationToken)
|
|
{
|
|
await RunAsync(
|
|
"Signing out…",
|
|
async () =>
|
|
{
|
|
IsConfirmingSignOut = false;
|
|
|
|
await WithdrawThisMachineAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
// 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
|
|
// queue stay because a transfer in flight is somebody's work. Signing out is the strongest
|
|
// thing this application does to itself and it still does not destroy that, for exactly the
|
|
// reason it does not close a shell — quitting DodoSSH is what ends both.
|
|
transfers.Detach();
|
|
|
|
if (Vault is { } open)
|
|
{
|
|
Vault = null;
|
|
await open.DisposeAsync().ConfigureAwait(true);
|
|
}
|
|
|
|
connection?.Dispose();
|
|
connection = null;
|
|
rememberedToken = null;
|
|
|
|
await caches.ResetAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
LiveSessionCount = workspace.LiveSessionCount;
|
|
|
|
AccountName = null;
|
|
Passphrase = string.Empty;
|
|
ConfirmPassphrase = string.Empty;
|
|
RecoveryCode = null;
|
|
RecoveryCodeWrittenDown = false;
|
|
CanUnlockWithDevice = false;
|
|
CanRegisterDevice = false;
|
|
CanForgetDevice = false;
|
|
|
|
State = ShellState.NeedsServer;
|
|
|
|
OnPropertyChanged(nameof(IsOnline));
|
|
RaiseSyncState();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Best effort, and swallowed on purpose. Withdrawing the device is the tidy half of signing out — the
|
|
/// half that stops the account listing a machine whose key is about to be deleted — and a server that
|
|
/// cannot be reached, or a keystore that declines, must not be able to strand somebody on a screen
|
|
/// they asked to leave. The half that decides whether this machine can let itself in happens anyway,
|
|
/// because the profile holding the wrap is emptied a moment later.
|
|
/// </remarks>
|
|
private async Task WithdrawThisMachineAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
if (Vault is { } vault)
|
|
{
|
|
await vault.Session
|
|
.ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
|
return;
|
|
}
|
|
|
|
await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// Nothing to report: the wipe below is what signing out actually is.
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
disposed = true;
|
|
|
|
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
|
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
|
|
|
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.
|
|
await transfers.DisposeAsync().ConfigureAwait(false);
|
|
|
|
if (Vault is { } open)
|
|
{
|
|
await open.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
connection?.Dispose();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The passphrase is the entire defence for the vault — docs/crypto.md §2 says so plainly, and no
|
|
/// server-side reset exists. A length floor is a crude check and still the one that matters most.
|
|
/// </remarks>
|
|
private bool ValidateNewPassphrase()
|
|
{
|
|
if (Passphrase.Length < 12)
|
|
{
|
|
StatusMessage = "Use a passphrase of at least 12 characters.";
|
|
return false;
|
|
}
|
|
|
|
if (!string.Equals(Passphrase, ConfirmPassphrase, StringComparison.Ordinal))
|
|
{
|
|
StatusMessage = "The two passphrases do not match.";
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Sync limits come from the server when there is one, so a batch is never larger than this
|
|
/// particular deployment accepts. Offline, the defaults apply and nothing is pushed anyway.
|
|
/// </remarks>
|
|
private SessionOpener Opener() => new(caches, clock, connection?.SyncOptions);
|
|
|
|
private AccountProvisioner? Provisioner() =>
|
|
connection is null
|
|
? null
|
|
: new AccountProvisioner(
|
|
connection.Account, connection.KeyBinding, caches, clock, passphraseProfile);
|
|
|
|
/// <remarks>
|
|
/// Every command funnels through here so the busy flag and the failure message are handled once. A
|
|
/// command that forgot either would leave the window permanently disabled or silently doing nothing.
|
|
/// </remarks>
|
|
/// <param name="busyMessage">Shown while the work runs.</param>
|
|
/// <param name="work">The work.</param>
|
|
/// <param name="explain">
|
|
/// Turns a failure into something a user can act on. Optional, because most failures here already
|
|
/// carry their own explanation; the ones that do not are the ones crossing into another process's
|
|
/// vocabulary, where the exception describes a symptom and not the mistake.
|
|
/// </param>
|
|
private async Task RunAsync(
|
|
string busyMessage,
|
|
Func<Task> work,
|
|
Func<Exception, string>? explain = null)
|
|
{
|
|
if (IsBusy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsBusy = true;
|
|
StatusMessage = busyMessage;
|
|
|
|
try
|
|
{
|
|
await work().ConfigureAwait(true);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
StatusMessage = "Cancelled.";
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
StatusMessage = explain?.Invoke(exception) ?? exception.Message;
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Two cases earn a translation rather than the exception's own words, and both are cases where the
|
|
/// exception names a symptom belonging to somebody else's process.
|
|
/// </para>
|
|
/// <para>
|
|
/// Pointing an HTTPS client at a plaintext port reports "The SSL connection could not be
|
|
/// established", which sends people looking for a certificate problem. The scheme is the mistake, and
|
|
/// the development stack serves HTTP, so this is the first thing a new user will hit.
|
|
/// </para>
|
|
/// <para>
|
|
/// A 5xx from the DodoSSH server is the other. By the time it arrives the browser flow has already
|
|
/// succeeded — the identity provider authenticated the user and the tokens are in hand — so "The
|
|
/// server returned 500" read underneath a sign-in button is naturally taken as the sign-in having
|
|
/// failed, and the search starts in the wrong place. Naming which server, and saying that its own
|
|
/// logs hold the reason, is the whole content of the fix; the client cannot know more than that.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static string ExplainSignInFailure(Exception exception, Uri server)
|
|
{
|
|
if (exception is DodoSshApiException api && (int)api.StatusCode >= 500)
|
|
{
|
|
return $"Signing in worked. The DodoSSH server at {server.Host} then failed while answering "
|
|
+ $"for your account ({(int)api.StatusCode}), which is a fault on the server rather than "
|
|
+ "anything to fix here — its own logs carry the reason.";
|
|
}
|
|
|
|
var secureChannelFailed = exception is HttpRequestException
|
|
&& exception.GetBaseException() is AuthenticationException;
|
|
|
|
if (secureChannelFailed && server.Scheme is "https")
|
|
{
|
|
var plain = new UriBuilder(server) { Scheme = "http" }.Uri;
|
|
|
|
return $"{exception.Message} {server.Host} answered, but not with TLS. If this is a "
|
|
+ $"development server it probably serves plain HTTP — try {plain.GetLeftPart(UriPartial.Authority)}.";
|
|
}
|
|
|
|
return exception.Message;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// One place for the subscription, so unlocking, locking and disposing all route through it rather
|
|
/// than each remembering to detach.
|
|
/// </remarks>
|
|
partial void OnVaultChanged(VaultViewModel? oldValue, VaultViewModel? newValue)
|
|
{
|
|
if (oldValue is not null)
|
|
{
|
|
oldValue.PropertyChanged -= OnVaultPropertyChanged;
|
|
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
|
|
|
|
// The three connection events are kept while an attempt is still in flight, and that is not an
|
|
// oversight. Locking does not end a handshake any more than it ends a shell — the workspace is
|
|
// what holds both, and it outlives every vault — so a connection started just before a lock still
|
|
// has an answer coming, and the tab standing in for it is still in the strip afterwards, because
|
|
// tabs are this object's rather than the vault's. Detaching here would strand that tab on
|
|
// "connecting…" for ever and leave the session it eventually opened with nothing in the window
|
|
// naming it, and so no way to close it. The subscription dies with the vault once the attempt
|
|
// resolves: the vault holds the handler, not the other way round.
|
|
if (attempts.Count == 0)
|
|
{
|
|
oldValue.ConnectionStarting -= OnVaultConnectionStarting;
|
|
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
|
|
oldValue.SessionOpened -= OnVaultSessionOpened;
|
|
}
|
|
}
|
|
|
|
if (newValue is not null)
|
|
{
|
|
newValue.ConnectionStarting += OnVaultConnectionStarting;
|
|
newValue.ConnectionFailed += OnVaultConnectionFailed;
|
|
newValue.SessionOpened += OnVaultSessionOpened;
|
|
newValue.PropertyChanged += OnVaultPropertyChanged;
|
|
|
|
// The host list is rebuilt from scratch on every synchronisation pass, and a rebuilt row starts
|
|
// disconnected — so without this the status dots go out once a minute underneath terminals that
|
|
// are still open. The rows belong to the vault and the connection state belongs to the shell,
|
|
// which is exactly why the shell has to repaint them rather than the vault carrying the flag.
|
|
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();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// One property is watched rather than all of them: the titlebar's sync state is the vault's outbox
|
|
/// depth, which lives on the vault, and re-raising the shell's two derived properties on every
|
|
/// notification a busy vault produces would repaint the titlebar on every keystroke in an editor.
|
|
/// </remarks>
|
|
private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (string.Equals(e.PropertyName, nameof(VaultViewModel.PendingChanges), StringComparison.Ordinal)
|
|
|| string.Equals(e.PropertyName, nameof(VaultViewModel.LastSyncFailed), StringComparison.Ordinal))
|
|
{
|
|
RaiseSyncState();
|
|
}
|
|
}
|
|
|
|
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
|
|
RefreshConnectedHosts();
|
|
|
|
private void RaiseSyncState()
|
|
{
|
|
OnPropertyChanged(nameof(IsFullySynced));
|
|
OnPropertyChanged(nameof(SyncLabel));
|
|
|
|
// The same fact from a third direction: what signing out would cost is the outbox depth, and a
|
|
// confirmation card left showing a count from before the last pass would be quoting a number that
|
|
// has since been sent.
|
|
OnPropertyChanged(nameof(SignOutWarning));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Puts a tab in the strip for a connection that has only just been asked for.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This is what stops connecting looking like the application having stopped. The tab appears in the same
|
|
/// turn as the click, carrying its own status, and the window switches to it — so a handshake against a
|
|
/// machine that is asleep is a card that says which machine, rather than a status line under a window
|
|
/// that does nothing for thirty seconds.
|
|
/// </para>
|
|
/// <para>
|
|
/// Kept by attempt id rather than by label: several connections can be in flight now that one does not
|
|
/// hold the vault, and two of them can perfectly well be to the same host.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
|
|
{
|
|
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
|
attempts[e.AttemptId] = tab;
|
|
|
|
AdoptTab(tab);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
|
|
/// them and nothing more.
|
|
/// </remarks>
|
|
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
|
|
{
|
|
if (!attempts.Remove(e.AttemptId, out var tab))
|
|
{
|
|
// No tab was opened for this attempt, which means the user closed the connecting tab while the
|
|
// handshake was still running. The session is real and has to be adopted rather than dropped:
|
|
// dropping it would leave a shell running with nothing in the window naming it.
|
|
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
|
|
RefreshConnectedHosts();
|
|
return;
|
|
}
|
|
|
|
tab.Opened(e.SessionId);
|
|
|
|
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
|
|
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
|
|
RaiseTerminalState();
|
|
|
|
// Now, and not when the tab appeared. Activating tells the renderer which pane to show, and there was
|
|
// no pane to name until this line.
|
|
Activate(tab);
|
|
|
|
RefreshConnectedHosts();
|
|
|
|
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Answers a connection that did not become a session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Two outcomes, because there are two kinds of not-connecting. A refusal stays in the strip as a tab
|
|
/// carrying its reason — connecting no longer holds the window, so the user may be three screens away by
|
|
/// now, and the status line they are not looking at is not where a failure should end. A host key
|
|
/// question is not a refusal: it is a prompt on the hosts screen, so the tab goes and the window is put
|
|
/// back where the question is being asked.
|
|
/// </remarks>
|
|
private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e)
|
|
{
|
|
if (!attempts.Remove(e.AttemptId, out var tab))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!e.IsAwaitingAnAnswer)
|
|
{
|
|
tab.Failed(e.Reason);
|
|
RaiseTerminalState();
|
|
return;
|
|
}
|
|
|
|
var index = Tabs.IndexOf(tab);
|
|
Tabs.Remove(tab);
|
|
RaiseTabState();
|
|
|
|
if (ReferenceEquals(SelectedTab, tab))
|
|
{
|
|
// The neighbour, preferring the one on the left, exactly as closing a tab by hand does.
|
|
SelectedTab = Tabs.Count == 0
|
|
? null
|
|
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
|
|
}
|
|
|
|
// The screen the question is drawn on, and the page rather than a terminal. A connection can be
|
|
// started from the palette on any screen, so without this the prompt would be behind whatever the
|
|
// user was looking at, with the connection waiting on an answer they cannot reach.
|
|
Screen = ShellScreen.Hosts;
|
|
Surface = ShellSurface.Page;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes a tab into the strip and shows it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// One method rather than one per way of opening a session, so the order of these steps is decided once.
|
|
/// It is not arbitrary: the tab is in the strip before anything is told about it, so a handler runs
|
|
/// against a strip that already shows what it is about.
|
|
/// </remarks>
|
|
private void AdoptTab(TerminalTabViewModel tab)
|
|
{
|
|
Tabs.Add(tab);
|
|
RaiseTabState();
|
|
|
|
// Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged — for a tab
|
|
// that has one. A tab that is still connecting has none, and selecting it shows the card instead.
|
|
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;
|
|
|
|
if (tab.HasSession)
|
|
{
|
|
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Everything the selection decides, in the order it has to be decided in: which tab is marked, what the
|
|
/// terminal's rectangle holds, which hosts show as connected, and finally the frame that tells the
|
|
/// renderer. See <see cref="Activate"/> for why the last of those is not awaited.
|
|
/// </remarks>
|
|
partial void OnSelectedTabChanged(TerminalTabViewModel? value)
|
|
{
|
|
foreach (var tab in Tabs)
|
|
{
|
|
tab.IsSelected = ReferenceEquals(tab, value);
|
|
}
|
|
|
|
// Which of the two things can be in the terminal's rectangle depends on the selected tab having a
|
|
// session, so moving the selection is one of the ways that answer changes. It also repaints the
|
|
// strip's active mark, which follows the selection and the surface together.
|
|
RaiseTerminalState();
|
|
|
|
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)
|
|
{
|
|
Activate(value);
|
|
}
|
|
}
|
|
|
|
/// <summary>Tells the renderer which pane to show.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Fire-and-forget, and it has to be: one caller is a property setter, and a selection that awaited a
|
|
/// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs
|
|
/// one wrong pane until the next click; blocking the setter would cost the tab strip.
|
|
/// </para>
|
|
/// <para>
|
|
/// The workspace's own token is not available here, so this passes none. The send is a single frame on
|
|
/// an already-open socket and returns immediately when there is no renderer.
|
|
/// </para>
|
|
/// <para>
|
|
/// A tab with no session is skipped rather than sent as session zero, which is not a pane the renderer
|
|
/// has: selecting a tab that is still connecting shows the card, and there is nothing to activate until
|
|
/// the handshake finishes.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void Activate(TerminalTabViewModel tab)
|
|
{
|
|
if (!tab.HasSession)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
|
|
}
|
|
|
|
/// <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;
|
|
Surface = ShellSurface.Terminal;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks a tab dead when its shell ends on its own.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Marshalled onto the UI thread, because the workspace raises this from whichever thread the session's
|
|
/// pump finished on and the tab list is only ever touched from one. The tab stays: its pane still holds
|
|
/// the scrollback, and the renderer has already written the reason into it.
|
|
/// </remarks>
|
|
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
|
{
|
|
tab.IsLive = false;
|
|
}
|
|
|
|
RefreshConnectedHosts();
|
|
});
|
|
|
|
/// <summary>
|
|
/// Repaints the host list's status dots from the tab list.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
|
|
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
|
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
|
|
/// wrong than a dot that goes dark when the vault is reopened.
|
|
/// </remarks>
|
|
private void RefreshConnectedHosts()
|
|
{
|
|
if (Vault is not { } vault)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var host in vault.Hosts)
|
|
{
|
|
host.IsConnected = Tabs.Any(
|
|
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
|
}
|
|
}
|
|
|
|
partial void OnLiveSessionCountChanged(int value)
|
|
{
|
|
OnPropertyChanged(nameof(HasLiveSessions));
|
|
OnPropertyChanged(nameof(LiveSessionSummary));
|
|
}
|
|
|
|
partial void OnStateChanged(ShellState value)
|
|
{
|
|
OnPropertyChanged(nameof(IsStarting));
|
|
OnPropertyChanged(nameof(IsNeedingServer));
|
|
OnPropertyChanged(nameof(IsNeedingEnrollment));
|
|
OnPropertyChanged(nameof(IsShowingRecoveryCode));
|
|
OnPropertyChanged(nameof(IsLocked));
|
|
OnPropertyChanged(nameof(IsAskingForThePassphrase));
|
|
OnPropertyChanged(nameof(IsUnlocked));
|
|
RaiseTerminalState();
|
|
OnPropertyChanged(nameof(SignOutWarning));
|
|
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 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;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Every screen flag, on every change, for the same reason the vault column raises all four of its
|
|
/// section flags: a rail lighting the current screen and a body showing it are one fact read from two
|
|
/// directions, and raising only the one that became true leaves the old button lit.
|
|
/// </remarks>
|
|
partial void OnScreenChanged(ShellScreen value)
|
|
{
|
|
RaiseSurfaceState();
|
|
|
|
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
|
|
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
|
|
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
|
|
if (value is ShellScreen.Logs && LogsScreen is { } logs)
|
|
{
|
|
_ = logs.RefreshCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
// Teams are read from the server rather than from the vault, so there is nothing to show until
|
|
// somebody asks for it — and asking for it on every unlock would be a request per launch for a
|
|
// screen most people never open. Fire-and-forget because a property change cannot await, and
|
|
// because the view model turns every failure into its own status line rather than throwing.
|
|
if (value is ShellScreen.Team)
|
|
{
|
|
_ = teams.LoadAsync(CancellationToken.None);
|
|
}
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// Goes to the file screen with one of the two kinds of remote offered.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The phone splits SFTP and S3 into two destinations over this one view model; the desktop has a
|
|
/// single FILES screen with the toggle on it. So the kind is set here, by the thing that navigates,
|
|
/// rather than in <see cref="OnScreenChanged"/> — which would have made every arrival at
|
|
/// <see cref="ShellScreen.Transfers"/> force the picker back to hosts, including the desktop's own nav
|
|
/// rail arriving at a screen with a bucket already open.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>It will not change the kind while something is open.</b> There is one session behind both
|
|
/// destinations, so switching the picker under a live one would leave a screen titled S3 listing an
|
|
/// SFTP host's files. Refusing and saying so is the honest half of sharing a view model between two
|
|
/// destinations; the screen keeps showing what is actually open.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void ShowFiles(RemoteKind kind)
|
|
{
|
|
// Refusing means staying put, not arriving somewhere and saying no. Moving Screen anyway would put
|
|
// the S3 entry in the sidebar over a screen still listing an SFTP host — two pieces of chrome
|
|
// disagreeing about where you are, which is worse than the navigation simply not happening.
|
|
if (Transfers.IsConnected && Transfers.Remote != kind)
|
|
{
|
|
Transfers.Status = kind is RemoteKind.Bucket
|
|
? "An SFTP session is open. Close it before opening a bucket."
|
|
: "A bucket is open. Close it before connecting to a host.";
|
|
|
|
// Still show the screen the open session belongs to, so the message is somewhere it can be
|
|
// read — the button that was pressed is in the sidebar, which is on screen either way.
|
|
Screen = Transfers.Remote is RemoteKind.Bucket ? ShellScreen.Buckets : ShellScreen.Transfers;
|
|
Surface = ShellSurface.Page;
|
|
return;
|
|
}
|
|
|
|
Transfers.Remote = kind;
|
|
Screen = kind is RemoteKind.Bucket ? ShellScreen.Buckets : ShellScreen.Transfers;
|
|
Surface = ShellSurface.Page;
|
|
}
|
|
|
|
/// <inheritdoc cref="OnScreenChanged" />
|
|
/// <remarks>
|
|
/// <b>The one place the connect sheet is lowered by something other than a tap.</b> Every way out of a
|
|
/// terminal ends here — a rail or bottom-bar destination, the files screen, the palette connecting to a
|
|
/// host, closing the last tab, a lock — and each of them would otherwise leave the flag set on a shell
|
|
/// showing a page. That is not merely untidy: the flag collapses the renderer, so the next return to the
|
|
/// terminal would draw the sheet again over a rectangle held blank by it.
|
|
/// </remarks>
|
|
partial void OnSurfaceChanged(ShellSurface value)
|
|
{
|
|
if (value is not ShellSurface.Terminal)
|
|
{
|
|
IsConnectSheetOpen = false;
|
|
}
|
|
|
|
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(IsMoreScreen));
|
|
OnPropertyChanged(nameof(IsBucketsScreen));
|
|
|
|
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(IsMoreShowing));
|
|
OnPropertyChanged(nameof(IsBucketsShowing));
|
|
OnPropertyChanged(nameof(IsMoreSurface));
|
|
|
|
RaiseTerminalState();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-reads what the terminal's rectangle should hold, and which tab is lit.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// One method for all four, because they are one fact read from four directions: the surface, the
|
|
/// selection and the selected tab's own state decide together whether a pane, a card or a page is drawn —
|
|
/// and the strip's active mark has to agree with the answer. Raising a subset is how one of them ends up
|
|
/// pointing at something nobody can see.
|
|
/// </remarks>
|
|
private void RaiseTerminalState()
|
|
{
|
|
OnPropertyChanged(nameof(IsTerminalSurface));
|
|
OnPropertyChanged(nameof(IsTerminalShowing));
|
|
OnPropertyChanged(nameof(IsConnectingShowing));
|
|
|
|
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
|
|
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
|
|
// on screen; see TerminalTabViewModel.IsShowing.
|
|
foreach (var tab in Tabs)
|
|
{
|
|
tab.IsShowing = IsTerminalSurface && ReferenceEquals(tab, SelectedTab);
|
|
}
|
|
}
|
|
|
|
partial void OnIsSearchingChanged(bool value) => RaiseTerminalState();
|
|
|
|
/// <inheritdoc cref="OnIsSearchingChanged" />
|
|
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
|
|
|
|
/// <remarks>
|
|
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
|
|
/// <see cref="IsAskingForThePassphrase"/>.
|
|
/// </remarks>
|
|
partial void OnIsConfirmingSignOutChanged(bool value) =>
|
|
OnPropertyChanged(nameof(IsAskingForThePassphrase));
|
|
|
|
partial void OnCanRegisterDeviceChanged(bool value) =>
|
|
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
|
|
|
|
partial void OnCanForgetDeviceChanged(bool value) =>
|
|
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
|
|
|
|
partial void OnSearchTextChanged(string value) => RefreshSearchResults();
|
|
}
|