Files
DodoSSH/src/DodoSSH.Client.App/App.axaml.cs
T
jaap-jan 6e39807cc0 Reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the
SSH server on the machine's own loopback, so the server needs no open SSH
port. Shells and file transfers both go this way.

Client, in Client.Session:

- SpelunkTunnel implements Spelunk's contract: POST
  {api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
  and {"port":N}, then a WebSocket to the API's own origin at the path
  Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
  upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
  is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
  agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
  could not reach the port, or is too old: "Unknown stream kind") and 4503
  (agent disconnected). Only a path is taken from the ticket response, so it
  cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
  so the connect path has one shape for both.

Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.

The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.

Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.

UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.

dodossh://spelunk/connect?api=&server=&name=&host= links:

- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
  running passes it down the doorbell as "dodossh.open-link <link>", which
  the running instance parses again, bounded, and otherwise ignores whole.
  The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
  otherwise the editor opens on a new, unsaved Spelunk host, with the key
  pre-chosen only when another host already sends it to that Spelunk. A link
  that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
  hooks and removes it on uninstall only if it still points at this
  executable; the macOS plist template declares CFBundleURLTypes and the app
  takes the URL from Avalonia's activation event; the Linux desktop entry
  gets %u and MimeType=x-scheme-handler/dodossh.
2026-09-28 21:25:04 +02:00

475 lines
21 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input.Platform;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.Terminal;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App;
/// <summary>
/// The Avalonia application.
/// </summary>
/// <remarks>
/// Named <c>DodoSshApp</c> rather than the conventional <c>App</c> only because the assembly's root
/// namespace already ends in <c>App</c>, and a type whose name matches its namespace forces every
/// ambiguous reference to be fully qualified.
/// </remarks>
internal sealed partial class DodoSshApp : Application
{
/// <summary>
/// Where a <c>dodossh://</c> link goes once the shell exists; null until then.
/// </summary>
/// <remarks>
/// Links can arrive before there is anything to hand them to — macOS delivers the one an application was
/// opened with as an event shortly after launch, which may be while the splash is still up — so until
/// <see cref="Compose"/> sets this, the latest is kept in <see cref="waitingLink"/>. Both are touched on
/// the UI thread only.
/// </remarks>
private static Action<SpelunkLink>? openLink;
/// <summary>A link that arrived before <see cref="openLink"/> was set. See there.</summary>
private static SpelunkLink? waitingLink;
/// <inheritdoc />
public override void Initialize() => AvaloniaXamlLoader.Load(this);
/// <inheritdoc />
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
ListenForOpenedLinks();
ShowSomethingWhileComposing(desktop);
}
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// Takes the links the platform hands a running application, which on macOS is every link.
/// </summary>
/// <remarks>
/// <para>
/// Windows and Linux start the executable with the link as an argument, and <c>Program.Main</c> reads it
/// there — or passes it to the instance already running. macOS does neither: Launch Services sends the
/// running application, or the one it has just started, an "open URL" event, which Avalonia raises as an
/// activation. The scheme is declared for it in build/macos's Info.plist template.
/// </para>
/// <para>
/// Checked exactly as a launch argument is, by <see cref="SpelunkLink.TryParse"/>; anything else is
/// ignored. Where the lifetime offers no activation at all this does nothing, which is the answer on the
/// desktops that deliver links as arguments instead.
/// </para>
/// </remarks>
private void ListenForOpenedLinks()
{
if (TryGetFeature(typeof(IActivatableLifetime)) is not IActivatableLifetime activatable)
{
return;
}
activatable.Activated += (_, e) =>
{
if (e is ProtocolActivatedEventArgs { Kind: ActivationKind.OpenUri, Uri: { } uri }
&& SpelunkLink.TryParse(uri.OriginalString, out var link))
{
Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link));
}
};
}
/// <summary>Hands a link to the shell, or keeps it until there is one. UI thread only.</summary>
private static void Receive(SpelunkLink link)
{
if (openLink is { } open)
{
open(link);
}
else
{
waitingLink = link;
}
}
/// <summary>
/// Puts a window on screen first, and composes the application behind it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The splash <em>is</em> the main window for a moment, which is what makes this work at all.</b> The
/// desktop lifetime shows whatever <c>MainWindow</c> holds once this method returns and then runs the
/// message loop; a window created here and merely shown would be a window with no lifetime attached, and
/// with <c>ShutdownMode.OnLastWindowClose</c> the first close of anything would end the process. Handing
/// the property over is one assignment, and Avalonia reads it lazily.
/// </para>
/// <para>
/// <b>And composition is posted rather than called.</b> It is synchronous and takes long enough to be
/// worth a splash in the first place — which is the whole argument for this method — so running it here
/// would block the very dispatcher turn that has to paint the thing. Background priority puts it behind
/// rendering, which is what makes this a painted window rather than merely a shown one.
/// </para>
/// </remarks>
private static void ShowSomethingWhileComposing(IClassicDesktopStyleApplicationLifetime desktop)
{
var splash = new SplashWindow();
desktop.MainWindow = splash;
// Fully qualified, and it has to be: Application inherits an instance Dispatcher property from
// AvaloniaObject, member lookup beats the namespace, and a static method here cannot reach an
// instance property — so the unqualified name is a compile error rather than the type. Same below.
Avalonia.Threading.Dispatcher.UIThread.Post(
() => ComposeBehind(desktop, splash),
DispatcherPriority.Background);
}
/// <remarks>
/// <para>
/// The order at the end is not interchangeable. <c>ShutdownMode</c> is <c>OnLastWindowClose</c>, so the
/// real window is shown while the splash is still open and the splash is closed afterwards; closing the
/// only window there is would end the process instead of handing it over.
/// </para>
/// <para>
/// <b>A failure is caught rather than left to the dispatcher, and the splash is exactly why.</b> Before
/// this method existed a composition that threw took the process with it and left nothing behind. Now
/// there is something to leave behind — so an exception escaping a posted callback would abandon a window
/// saying "Starting…" over a process that had stopped, which is worse than the crash it replaced. What
/// arrives instead is the sentence and a way to close it.
/// </para>
/// </remarks>
private static void ComposeBehind(
IClassicDesktopStyleApplicationLifetime desktop,
SplashWindow splash)
{
try
{
Compose(desktop);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
splash.CouldNotStart(exception.Message);
return;
}
desktop.MainWindow?.Show();
splash.Close();
}
/// <remarks>
/// <para>
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
/// indirection to read through would buy nothing at this size.
/// </para>
/// <para>
/// Everything disposable is a local captured by the closures below rather than a field, because an
/// Avalonia <c>Application</c> has no disposal hook of its own and a type that owned them would have
/// nowhere honest to release them.
/// </para>
/// </remarks>
/// <summary>
/// Puts one line of text on the system clipboard.
/// </summary>
/// <remarks>
/// The clipboard is reached through the window, and at composition time there is no window yet — hence
/// a closure that looks it up on each call rather than a reference captured now. A machine with no
/// clipboard falls through silently here; the view model is the one that decides what to say, and it
/// distinguishes "no clipboard on this machine" from "copied" because they are different answers.
/// <para>
/// A delegate rather than handing the view model an <c>IClipboard</c>, so that nothing in the view
/// models needs a visual and every test that drives them stays window-free.
/// </para>
/// </remarks>
private static Func<string, Task> ClipboardWriter(IClassicDesktopStyleApplicationLifetime desktop) =>
async text =>
{
if (TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard })
{
await clipboard.SetTextAsync(text).ConfigureAwait(false);
}
};
/// <summary>Reads this machine's clipboard.</summary>
/// <remarks>
/// The other direction, looked up per call for the same reason and with the same fall-through. One
/// caller: the terminal's right-click paste, which the renderer cannot serve itself — see
/// <c>TerminalClientOpcode.ClipboardPaste</c>.
/// </remarks>
private static Func<Task<string?>> ClipboardReader(IClassicDesktopStyleApplicationLifetime desktop) =>
async () => TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard }
? await clipboard.TryGetTextAsync().ConfigureAwait(false)
: null;
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
/// <remarks>
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
/// be listening before the renderer attaches, and a workspace handed out un-started is one whose first
/// connect fails for a reason nothing on screen would explain.
/// </remarks>
private static TerminalWorkspace StartedWorkspace(SshNetConnectionFactory connections)
{
var workspace = new TerminalWorkspace(
new AvaloniaTerminalAssetProvider(),
connections,
TimeProvider.System);
workspace.Start();
return workspace;
}
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
{
// ForChannel rather than Default, so a nightly keeps its cache, outbox and device key somewhere
// the release build never opens. See ClientPaths.ForChannel for why sharing them is the failure
// worth spending a directory on.
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
// Composed here, once, because the connection factory below needs it now and outlives every unlock;
// the vault behind it is attached and detached as one is opened and locked. See VaultKnownHostStore
// for why the handshake is answered from a snapshot rather than by reading the vault per lookup.
var knownHosts = new VaultKnownHostStore();
// One factory for both kinds of connection. Shells and file transfers start with the same handshake
// and the same host key decision, and composing two would mean two snapshots of the pins.
var connections = new SshNetConnectionFactory(knownHosts);
var workspace = StartedWorkspace(connections);
var browser = new SystemBrowserLauncher();
// Both chosen once, here, because each is a property of the machine rather than of any session. A
// Windows box with a usable TPM gets a consent prompt, a Mac with a Secure Enclave gets Touch ID, a
// Linux box whose TPM this user may open gets a PIN the TPM checks — see PinPrompt — and anything
// else gets a store that reports itself unavailable, so unlock keeps asking for the passphrase
// (ADR 0007). The update channel answers the same shape of question about how this copy was
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0013.
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths, PinPrompt(desktop));
var updates = UpdateChannels.ForThisMachine();
var viewModel = BuildShell(desktop, paths, caches, workspace, knownHosts, deviceKeys, connections,
browser, updates);
desktop.MainWindow = new MainWindow { DataContext = viewModel };
// The other half of Program's claim on this profile: a second launch was refused, and this is what
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
// A second launch's dodossh:// link comes the same way as this launch's own and macOS's: through
// Receive on the UI thread, then MainWindowViewModel.OpenLink, which holds it until a vault is open.
var others = SingleInstance.Listen(
paths,
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)),
link => Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link)));
openLink = viewModel.OpenLink;
if ((waitingLink ?? Program.LaunchLink) is { } first)
{
waitingLink = null;
viewModel.OpenLink(first);
}
// Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why
// discarding the task here is safe rather than merely convenient.
_ = viewModel.StartAsync(CancellationToken.None);
WireShutdown(desktop, viewModel, workspace, caches, others);
}
/// <summary>
/// Raises the window somebody has just asked for by launching the application again.
/// </summary>
/// <remarks>
/// Un-minimised first, because activating a minimised window leaves it minimised and the launch would
/// look ignored. <c>Activate</c> is a request rather than a guarantee — Windows refuses the foreground to
/// a process that did not receive the input, and what happens then is a flashing taskbar button, which is
/// still an answer. That is the platform's own rule about focus stealing and not a thing to work around.
/// </remarks>
private static void BringToFront(Window? window)
{
if (window is null)
{
return;
}
if (window.WindowState is WindowState.Minimized)
{
window.WindowState = WindowState.Normal;
}
window.Show();
window.Activate();
}
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
/// <remarks>
/// Split out of <see cref="Compose"/> for length rather than for reuse — it has one caller, and the
/// method it came out of is at the limit the analyzers set.
/// </remarks>
private static MainWindowViewModel BuildShell(
IClassicDesktopStyleApplicationLifetime desktop,
ClientPaths paths,
ClientCacheFactory caches,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
IDeviceKeyStore deviceKeys,
SshNetConnectionFactory connections,
SystemBrowserLauncher browser,
IUpdateChannel updates) =>
new(
paths,
caches,
workspace,
knownHosts,
deviceKeys,
async (url, cancellationToken) => await ServerConnection
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
.ConfigureAwait(false),
TimeProvider.System,
connections,
passphraseProfile: null,
// The other half of signing in: a refresh grant, no browser, and nobody present. It is what
// makes a launch after the first one arrive online rather than merely enrolled.
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
.ConfigureAwait(false),
copyToClipboard: ClipboardWriter(desktop),
readClipboard: ClipboardReader(desktop),
updates: updates,
openInEditor: OpenInDefaultEditorAsync,
watchFile: WatchFile);
/// <summary>
/// Hands a local file to whatever this machine opens that kind of file with.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
/// Services binding depending on which desktop this is, and watching a file is a platform object with
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
private static Task OpenInDefaultEditorAsync(string path)
{
using var opened = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
return Task.CompletedTask;
}
/// <summary>
/// Watches one file for saves, until the handle is disposed.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The directory is watched and the name filtered, which is not the same as watching the file.</b>
/// A great many editors do not write in place: they write a temporary file beside the original and
/// rename it over the top, which to a watcher pointed at an inode is the file disappearing. Watching the
/// containing directory for that name catches the rename as a <c>Created</c>, which is what makes this
/// work with the editors people actually use.
/// </para>
/// <para>
/// <b>Every kind of change is one callback, and the shell debounces nothing.</b> One save can raise two
/// or three events; what the shell does with each is stat the host and compare, which is a round trip
/// but a small one, and the alternative — a timer here — is a delay before somebody's edit reaches the
/// server for the sake of saving it.
/// </para>
/// </remarks>
private static FileSystemWatcher WatchFile(string path, Action onChanged)
{
var watcher = new FileSystemWatcher(
Path.GetDirectoryName(path)!, Path.GetFileName(path))
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName,
};
watcher.Changed += (_, _) => onChanged();
watcher.Created += (_, _) => onChanged();
watcher.Renamed += (_, _) => onChanged();
watcher.EnableRaisingEvents = true;
return watcher;
}
/// <summary>How the Linux device key store asks for a PIN, and the only thing that ever calls it.</summary>
/// <remarks>
/// The lifetime is captured rather than the window, so this can be composed before there is one —
/// composition runs first, and <c>DevicePinWindow.AskAsync</c> answers null when there is nothing to
/// sit over. Windows and macOS never reach it: on those platforms the consent dialog is the operating
/// system's and this process is not in the conversation. See ADR 0007.
/// </remarks>
private static Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> PinPrompt(
IClassicDesktopStyleApplicationLifetime desktop) =>
(purpose, cancellationToken) =>
DevicePinWindow.AskAsync(() => desktop.MainWindow, purpose, cancellationToken);
/// <remarks>
/// Shutdown is deferred rather than blocked on. Sessions hold SSH connections and a listening socket, and
/// blocking the UI thread on their disposal is how an application comes to take several seconds to close —
/// or deadlocks, if any of that disposal needs the UI thread.
/// </remarks>
private static void WireShutdown(
IClassicDesktopStyleApplicationLifetime desktop,
MainWindowViewModel viewModel,
TerminalWorkspace workspace,
ClientCacheFactory caches,
IAsyncDisposable others)
{
var shuttingDown = false;
desktop.ShutdownRequested += async (_, e) =>
{
if (shuttingDown)
{
return;
}
shuttingDown = true;
e.Cancel = true;
// The view model first: it holds the vault session, and disposing that is what zeroes the
// identity keys, the vault keys and the cache key.
await viewModel.DisposeAsync().ConfigureAwait(true);
await workspace.DisposeAsync().ConfigureAwait(true);
// Awaited rather than dropped, so the pipe name is free before this process is. It matters for
// one caller and matters a lot there: an update relaunches this application, and a successor
// that found the name still taken would be a second instance refusing itself.
await others.DisposeAsync().ConfigureAwait(true);
caches.Dispose();
desktop.Shutdown();
};
}
}