Public Access
Add the Avalonia app and the xterm renderer, and fix two real bugs
The terminal works end to end. A new integration test drives a real sshd in a container through a real PTY, the real pump, the real loopback WebSocket with its token and origin checks, and a ClientWebSocket standing in for the page: the login banner arrives, typed input round-trips, and `stty size` reports the 100x30 the session asked for. The only untested link left is xterm drawing bytes it was handed. The WebView is de-risked on Windows, which was the plan's largest risk. Not by assertion: with the app running there is an established TCP connection from msedgewebview2 to the data plane port, so WebView2 launched, navigated to the loopback page, executed terminal.js, and completed the WebSocket handshake against the real token and origin checks. Linux remains unproven and the package's own release notes now corroborate the concern -- Linux uses a WPE backend, and it ships a NativeWebDialog described as useful where embedded WebViews may be unavailable. Two bugs found by building it, both of which would have shipped: - ShellStream.Write buffers and needs an explicit Flush. Without one a keystroke is accepted, reported as written, and never reaches the remote: the terminal displays output perfectly and simply stops responding to input. SSH.NET's own WriteLine flushes, which is why the earlier spike never hit it. Found by isolating the pump against real SSH and reading BytesRead=51 -- banner and prompt through, nothing after. - The Windows app manifest needs a supportedOS list, or Avalonia's native control host fails outright and the terminal never starts. Also fixed a genuinely flaky test I happened to catch: SyncCursorTests tampered with the *last* base64url character, whose low bits the decoder ignores when the input length is not a multiple of three -- so a tampered cursor sometimes decoded to identical bytes and verified. It failed roughly one run in thirty, depending on a random key. Now tampers the penultimate character, which is fully significant at every length; 40 consecutive runs are clean. xterm 6.0.0 plus the fit and webgl addons are vendored as UMD bundles rather than built with npm, so a clean clone needs only the .NET SDK. Provenance and licences are recorded next to them, along with the UMD global names terminal.js depends on -- a bundle that switched to ES modules would load without error and leave Terminal undefined. The renderer acknowledges output from term.write's completion callback, not on receipt. Acknowledging early would return flow-control credit for bytes the screen has not caught up with, which is the one thing the credit window exists to measure. TerminalWorkspace moved into DodoSSH.Client.Terminal: it has no Avalonia dependency, and having it there is what let the end-to-end test exist at all. 404 tests pass, zero warnings on a clean rebuild, format clean.
This commit is contained in:
@@ -73,6 +73,25 @@
|
|||||||
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
|
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
|
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Avalonia 12.1.0, with the WebView control on 12.0.1 — the latest it has shipped. Its
|
||||||
|
dependency is Avalonia >= 12.0.0 with no upper bound and it targets net10.0, so the skew
|
||||||
|
is fine. Checked rather than assumed, because a control package lagging the core version
|
||||||
|
is exactly where a silent runtime mismatch would hide.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="Avalonia" Version="12.1.0" />
|
||||||
|
<PackageVersion Include="Avalonia.Desktop" Version="12.1.0" />
|
||||||
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.0" />
|
||||||
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.0" />
|
||||||
|
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.1" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Source-generated MVVM, so there is no reflection and trimming stays viable. ReactiveUI's one
|
||||||
|
real advantage is observable composition over streams, and the place that would help — the
|
||||||
|
terminal data plane — is Pipelines and channel code rather than view models.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup Label="Analyzers">
|
<ItemGroup Label="Analyzers">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<Project Path="src/DodoSSH.Domain/DodoSSH.Domain.csproj" />
|
<Project Path="src/DodoSSH.Domain/DodoSSH.Domain.csproj" />
|
||||||
<Project Path="src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" />
|
<Project Path="src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj" />
|
||||||
<Project Path="src/DodoSSH.Api/DodoSSH.Api.csproj" />
|
<Project Path="src/DodoSSH.Api/DodoSSH.Api.csproj" />
|
||||||
|
<Project Path="src/DodoSSH.Client.App/DodoSSH.Client.App.csproj" />
|
||||||
<Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
|
<Project Path="src/DodoSSH.Client.Auth/DodoSSH.Client.Auth.csproj" />
|
||||||
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
<Project Path="src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||||
<Project Path="src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
<Project Path="src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||||
|
|||||||
@@ -41,14 +41,22 @@ The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/)
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
DodoSSH.Contracts DTOs shared with the client — the real API contract
|
DodoSSH.Contracts DTOs shared with the client — the real API contract
|
||||||
DodoSSH.Crypto DSH1 envelope, AAD derivation, key wrapping
|
DodoSSH.Crypto DSH1 envelope, AAD derivation, the key hierarchy
|
||||||
DodoSSH.Domain entities and invariants, no EF
|
DodoSSH.Domain entities and invariants, no EF
|
||||||
DodoSSH.Infrastructure DbContext, configurations, migrations
|
DodoSSH.Infrastructure DbContext, configurations, migrations
|
||||||
DodoSSH.Api the host
|
DodoSSH.Api the server
|
||||||
|
DodoSSH.Client.Auth OIDC code+PKCE on a loopback redirect, and the key binding
|
||||||
|
DodoSSH.Client.Ssh connections, PTY shells, host key trust
|
||||||
|
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
|
||||||
|
DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit
|
||||||
tests/ one test project per source project
|
tests/ one test project per source project
|
||||||
docs/adr/ architecture decision records
|
docs/adr/ architecture decision records
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Everything under `src/DodoSSH.Client.*` except `App` is deliberately free of Avalonia. That is the
|
||||||
|
seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a UI
|
||||||
|
toolkit or a browser engine — which is most of why they are testable at all.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
Requires the .NET SDK pinned in [`global.json`](global.json) (10.0.x).
|
Requires the .NET SDK pinned in [`global.json`](global.json) (10.0.x).
|
||||||
@@ -89,10 +97,13 @@ off-Windows.
|
|||||||
|
|
||||||
- **M0 — foundation.** Repo structure, build conventions, CI, ADRs. *Done.*
|
- **M0 — foundation.** Repo structure, build conventions, CI, ADRs. *Done.*
|
||||||
- **M1 — vertical slice.** OIDC login → enroll → create a host → open a shell.
|
- **M1 — vertical slice.** OIDC login → enroll → create a host → open a shell.
|
||||||
*Backend done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and
|
*Server done:* the DSH1 crypto core, the data model, sync push/pull for hosts, `/me`, and
|
||||||
enrollment with the identity-provider key binding. *Remaining:* the desktop client, and the two
|
enrollment with the identity-provider key binding.
|
||||||
spikes that gate it — the Linux WebView and SSH.NET's `window-change`. Both need a Linux and a
|
*Client done:* the key hierarchy, the OIDC flow with the key binding, SSH connections with host key
|
||||||
macOS machine, so neither has run yet.
|
trust, the terminal data plane, and an Avalonia shell whose terminal works end to end against a real
|
||||||
|
`sshd`.
|
||||||
|
*Remaining:* the encrypted local cache and the sync client, which are what let the app read hosts
|
||||||
|
from the vault instead of a form. The client currently connects to a host you type in.
|
||||||
- **M2 — full personal vault**, robust sync, relay.
|
- **M2 — full personal vault**, robust sync, relay.
|
||||||
- **M3 — teams**, sharing, ACLs.
|
- **M3 — teams**, sharing, ACLs.
|
||||||
- **M4 — hardening and ops**, packaging, self-hosting guide.
|
- **M4 — hardening and ops**, packaging, self-hosting guide.
|
||||||
|
|||||||
+37
-7
@@ -28,13 +28,37 @@ or notarization fails with an error that does not name the offending file.
|
|||||||
|
|
||||||
## Desktop client
|
## Desktop client
|
||||||
|
|
||||||
**The Avalonia WebView on Linux is unproven, and is the single largest risk in the plan.** The
|
**The WebView works on Windows.** `Avalonia.Controls.WebView` 12.0.1 (MIT, no licence key) hosts the
|
||||||
official control uses WPE WebKit (`libwpewebkit-2.0`); the community `NativeWebView` uses WebKitGTK
|
terminal page successfully: WebView2 launches, navigates to the loopback page, runs its JavaScript and
|
||||||
(`libwebkit2gtk-4.1`), which is far more widely installed. Avalonia's own documentation
|
completes the WebSocket handshake. Verified by observing an established TCP connection from
|
||||||
contradicts itself on whether offscreen rendering works there. *Unverified:* the spike must cover
|
`msedgewebview2` to the data plane port.
|
||||||
Ubuntu on both Wayland and X11, Fedora KDE, and macOS 15. This is why the terminal sits behind
|
|
||||||
`ITerminalHost` — that abstraction is what preserves the option to swap backends, and it should
|
**The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a
|
||||||
not be collapsed away for convenience.
|
downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child
|
||||||
|
window for native control host"* — so the WebView, and therefore the terminal, does not start at all.
|
||||||
|
`[STAThread]` on `Main` is equally mandatory: WebView2 checks the apartment state and refuses to
|
||||||
|
initialise on an MTA thread.
|
||||||
|
|
||||||
|
**WebView2 spawns a process tree, not a process.** Around 35 processes were observed for one embedded
|
||||||
|
view. That is the concrete reason the design uses one WebView hosting N terminals rather than one per
|
||||||
|
tab: twenty tabs would mean twenty of those trees.
|
||||||
|
|
||||||
|
**The Avalonia WebView on Linux remains unproven, and is still the largest risk in the plan.** The
|
||||||
|
package's own release notes say `NativeWebView` gained Linux support via a **WPE** backend
|
||||||
|
(`libwpewebkit-2.0`), which is much less widely installed than WebKitGTK — and it ships a separate
|
||||||
|
`NativeWebDialog` described as *"particularly useful for platforms like Linux where embedded WebView
|
||||||
|
controls might not be available"*, which is the vendor confirming the concern. *Unverified:* a spike
|
||||||
|
must cover Ubuntu on both Wayland and X11, Fedora KDE, and macOS 15. This is why the terminal sits
|
||||||
|
behind `ITerminalHost`; that seam should not be collapsed away for convenience.
|
||||||
|
|
||||||
|
**`Avalonia.Diagnostics` has no 12.x release** (latest is 11.3.18), so the developer tools overlay is
|
||||||
|
unavailable on Avalonia 12. Development-only, so nothing ships differently — but debugging a layout
|
||||||
|
problem currently means reasoning rather than inspecting.
|
||||||
|
|
||||||
|
**The xterm bundles are vendored, not built.** `@xterm/xterm` 6.0.0 with the fit and webgl addons, all
|
||||||
|
MIT, committed as UMD bundles under `WebAssets/vendor` and embedded as Avalonia resources. No npm or
|
||||||
|
esbuild step, so a clean clone builds with the .NET SDK alone. The cost is that upgrades are a manual
|
||||||
|
re-download; the licence and versions are recorded here so that stays visible.
|
||||||
|
|
||||||
**SSH.NET's `window-change` is verified working** as of 2025.1.0 — resolved, not a flag.
|
**SSH.NET's `window-change` is verified working** as of 2025.1.0 — resolved, not a flag.
|
||||||
`ShellStream.ChangeWindowSize(columns, rows, width, height)` exists and the remote genuinely
|
`ShellStream.ChangeWindowSize(columns, rows, width, height)` exists and the remote genuinely
|
||||||
@@ -43,6 +67,12 @@ repeated resizes each take effect. The `IChannelSession` fallback is not needed.
|
|||||||
in place as a regression guard, because an upgrade that silently stopped sending the request would
|
in place as a regression guard, because an upgrade that silently stopped sending the request would
|
||||||
present as wrapped output only after a resize — easy to misattribute to the terminal emulator.
|
present as wrapped output only after a resize — easy to misattribute to the terminal emulator.
|
||||||
|
|
||||||
|
**`ShellStream.Write` buffers and requires an explicit `Flush`.** Without one a keystroke is accepted,
|
||||||
|
reported as written, and never reaches the remote — the terminal displays output perfectly and simply
|
||||||
|
stops responding to input. SSH.NET's own `WriteLine` flushes, which is why a spike that used it never
|
||||||
|
hit this. `SshNetShellSession.WriteAsync` now flushes per write; batching would be wrong anyway, since
|
||||||
|
a terminal has to put a keystroke on the wire immediately.
|
||||||
|
|
||||||
**`ShellStream` does not override `ReadAsync`.** The base `Stream` implementation therefore runs
|
**`ShellStream` does not override `ReadAsync`.** The base `Stream` implementation therefore runs
|
||||||
the blocking `Read` on a thread-pool thread, so every open session parks one thread for as long as
|
the blocking `Read` on a thread-pool thread, so every open session parks one thread for as long as
|
||||||
it is idle. Fine for the handful of tabs M1 targets; revisit before advertising many concurrent
|
it is idle. Fine for the handful of tabs M1 targets; revisit before advertising many concurrent
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="DodoSSH.Client.App.DodoSshApp"
|
||||||
|
RequestedThemeVariant="Dark">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Dark by default rather than following the system. A terminal is a dark surface either way, and
|
||||||
|
a light chrome around a dark terminal is the worst of both: the eye adapts to the bright frame
|
||||||
|
and then cannot read the text.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
</Application.Styles>
|
||||||
|
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using DodoSSH.Client.App.Terminal;
|
||||||
|
using DodoSSH.Client.App.ViewModels;
|
||||||
|
using DodoSSH.Client.App.Views;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
Compose(desktop);
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Composed by hand rather than through a container. The graph is four objects deep, and an
|
||||||
|
/// indirection to read through would buy nothing at this size.
|
||||||
|
/// <para>
|
||||||
|
/// The workspace is a local captured by the closures below rather than a field, so this type does
|
||||||
|
/// not own a disposable it has no good place to dispose — an Avalonia <c>Application</c> has no
|
||||||
|
/// disposal hook of its own.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var knownHosts = new InMemoryKnownHostStore();
|
||||||
|
|
||||||
|
var workspace = new TerminalWorkspace(
|
||||||
|
new AvaloniaTerminalAssetProvider(),
|
||||||
|
new SshNetConnectionFactory(knownHosts),
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
workspace.Start();
|
||||||
|
|
||||||
|
desktop.MainWindow = new MainWindow
|
||||||
|
{
|
||||||
|
DataContext = new MainWindowViewModel(workspace, knownHosts),
|
||||||
|
};
|
||||||
|
|
||||||
|
var shuttingDown = false;
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
desktop.ShutdownRequested += async (_, e) =>
|
||||||
|
{
|
||||||
|
if (shuttingDown)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shuttingDown = true;
|
||||||
|
e.Cancel = true;
|
||||||
|
|
||||||
|
await workspace.DisposeAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
|
desktop.Shutdown();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
False here, unlike every server project. The root Directory.Build.props sets it true because
|
||||||
|
the API is container-hosted, UTC-only and has no business formatting anything for a human.
|
||||||
|
This process does: it shows timestamps and host names to a person who expects their own
|
||||||
|
locale's conventions. Invariant globalization would quietly render every date in a format
|
||||||
|
that is correct for nobody.
|
||||||
|
-->
|
||||||
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||||
|
<PackageReference Include="Avalonia.Controls.WebView" />
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||||
|
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!--
|
||||||
|
The renderer's files, including the vendored xterm bundles. Embedded rather than copied to
|
||||||
|
disk so there is no separate deployment step and nothing on disk for another process to
|
||||||
|
tamper with between builds.
|
||||||
|
-->
|
||||||
|
<AvaloniaResource Include="WebAssets/**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Entry point.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>STAThread</c> is required, not decorative: WebView2 checks the apartment state and refuses
|
||||||
|
/// to initialise on an MTA thread. Without it the terminal is simply blank on Windows.
|
||||||
|
/// </remarks>
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args) =>
|
||||||
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
|
/// <summary>Used by the designer as well as by <see cref="Main"/>.</summary>
|
||||||
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
|
AppBuilder.Configure<DodoSshApp>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Avalonia.Platform;
|
||||||
|
using DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Terminal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves the renderer's files from the assembly's embedded resources.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Read once at startup and cached. The files are a few hundred kilobytes in total, dominated by the
|
||||||
|
/// xterm bundle, and a terminal that stalled on a resource stream read while output was arriving
|
||||||
|
/// would be a strange way to save a rounding error of memory.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class AvaloniaTerminalAssetProvider : ITerminalAssetProvider
|
||||||
|
{
|
||||||
|
private const string ResourceRoot = "avares://DodoSSH.Client.App/WebAssets";
|
||||||
|
|
||||||
|
private static readonly (string Path, string File, string ContentType)[] Files =
|
||||||
|
[
|
||||||
|
(TerminalDataPlane.PagePath, "terminal.html", "text/html; charset=utf-8"),
|
||||||
|
("/terminal.js", "terminal.js", "text/javascript; charset=utf-8"),
|
||||||
|
("/terminal.css", "terminal.css", "text/css; charset=utf-8"),
|
||||||
|
("/vendor/xterm.js", "vendor/xterm.js", "text/javascript; charset=utf-8"),
|
||||||
|
("/vendor/xterm.css", "vendor/xterm.css", "text/css; charset=utf-8"),
|
||||||
|
("/vendor/addon-fit.js", "vendor/addon-fit.js", "text/javascript; charset=utf-8"),
|
||||||
|
("/vendor/addon-webgl.js", "vendor/addon-webgl.js", "text/javascript; charset=utf-8"),
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly Dictionary<string, TerminalAsset> assets = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
internal AvaloniaTerminalAssetProvider()
|
||||||
|
{
|
||||||
|
foreach (var (path, file, contentType) in Files)
|
||||||
|
{
|
||||||
|
using var stream = AssetLoader.Open(new Uri($"{ResourceRoot}/{file}", UriKind.Absolute));
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
stream.CopyTo(buffer);
|
||||||
|
|
||||||
|
assets[path] = new TerminalAsset(contentType, buffer.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public TerminalAsset? Find(string path) => assets.GetValueOrDefault(path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using DodoSSH.Client.App.Terminal;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
using DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shell: connect to a host, and surface host key trust decisions.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Hosts are typed in directly for now. Reading them from the encrypted vault needs the local cache
|
||||||
|
/// and the sync client, which are the next pieces; this exists to prove the terminal path end to end
|
||||||
|
/// and is deliberately obvious about being temporary rather than looking like a finished feature.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The two host key states are modelled separately and behave differently, which is the point. An
|
||||||
|
/// unknown host offers a Trust button. A changed key offers nothing — see
|
||||||
|
/// <see cref="SshHostKeyMismatchException"/> for why there is no "continue anyway" here.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class MainWindowViewModel(
|
||||||
|
TerminalWorkspace workspace,
|
||||||
|
IKnownHostStore knownHosts) : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty]
|
||||||
|
private string host = "127.0.0.1";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int port = 22;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string username = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string password = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string status = "Enter a host and connect.";
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isConnecting;
|
||||||
|
|
||||||
|
/// <summary>The key awaiting the user's decision, or null when there is none.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private HostKeyPresentation? pendingHostKey;
|
||||||
|
|
||||||
|
/// <summary>Set when a pinned key changed, which is a dead end rather than a prompt.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? hostKeyMismatch;
|
||||||
|
|
||||||
|
/// <summary>Where the embedded browser should navigate.</summary>
|
||||||
|
public Uri TerminalPageUrl => workspace.PageUrl;
|
||||||
|
|
||||||
|
/// <summary>Whether the trust prompt should be visible.</summary>
|
||||||
|
public bool HasPendingHostKey => PendingHostKey is not null;
|
||||||
|
|
||||||
|
/// <summary>Whether the mismatch banner should be visible.</summary>
|
||||||
|
public bool HasHostKeyMismatch => HostKeyMismatch is not null;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ConnectAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Username))
|
||||||
|
{
|
||||||
|
Status = "A username is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsConnecting = true;
|
||||||
|
PendingHostKey = null;
|
||||||
|
HostKeyMismatch = null;
|
||||||
|
Status = $"Connecting to {Host}:{Port}…";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// The renderer has to be attached first: the transport drops frames when nothing is
|
||||||
|
// connected, so a session opened earlier would lose its SessionOpened frame and then
|
||||||
|
// stream output at a terminal that was never created.
|
||||||
|
await workspace.WaitForRendererAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
|
var request = new SshConnectionRequest(
|
||||||
|
Host,
|
||||||
|
Port,
|
||||||
|
Username,
|
||||||
|
new SshPasswordCredential(Password));
|
||||||
|
|
||||||
|
await workspace
|
||||||
|
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
Status = $"Connected to {Host}:{Port}.";
|
||||||
|
}
|
||||||
|
catch (SshHostKeyUnknownException exception)
|
||||||
|
{
|
||||||
|
// First contact. The user has to decide, and they need the fingerprint to do it.
|
||||||
|
PendingHostKey = exception.Presentation;
|
||||||
|
Status = "This host has not been seen before.";
|
||||||
|
}
|
||||||
|
catch (SshHostKeyMismatchException exception)
|
||||||
|
{
|
||||||
|
HostKeyMismatch = exception.Message;
|
||||||
|
Status = "The host key has changed. The connection was refused.";
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Status = exception.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsConnecting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pins the offered key and retries.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (PendingHostKey is not { } presentation)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
PendingHostKey = null;
|
||||||
|
|
||||||
|
await ConnectAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void RejectHostKey()
|
||||||
|
{
|
||||||
|
PendingHostKey = null;
|
||||||
|
Status = "The host key was not trusted, so nothing was connected.";
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
||||||
|
OnPropertyChanged(nameof(HasPendingHostKey));
|
||||||
|
|
||||||
|
partial void OnHostKeyMismatchChanged(string? value) =>
|
||||||
|
OnPropertyChanged(nameof(HasHostKeyMismatch));
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.MainWindow"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
Title="DodoSSH"
|
||||||
|
Width="1100"
|
||||||
|
Height="720"
|
||||||
|
MinWidth="640"
|
||||||
|
MinHeight="400"
|
||||||
|
Background="#10131a">
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*">
|
||||||
|
|
||||||
|
<!-- Connection bar. Replaced by the host list once the vault is wired up. -->
|
||||||
|
<Border Grid.Row="0" Padding="10,8" Background="#171b24">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBox Text="{Binding Host}" PlaceholderText="host" Width="200" VerticalAlignment="Center" />
|
||||||
|
<NumericUpDown Value="{Binding Port}" Minimum="1" Maximum="65535"
|
||||||
|
FormatString="0" Width="110" VerticalAlignment="Center" />
|
||||||
|
<TextBox Text="{Binding Username}" PlaceholderText="username" Width="150" VerticalAlignment="Center" />
|
||||||
|
<TextBox Text="{Binding Password}" PlaceholderText="password" PasswordChar="•"
|
||||||
|
Width="170" VerticalAlignment="Center" />
|
||||||
|
<Button Content="Connect"
|
||||||
|
Command="{Binding ConnectCommand}"
|
||||||
|
IsEnabled="{Binding !IsConnecting}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock Text="{Binding Status}" Foreground="#7b8394"
|
||||||
|
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
|
||||||
|
other is a refusal. Presenting a changed key with a "continue" button is how users are taught
|
||||||
|
to click through the one warning that matters.
|
||||||
|
-->
|
||||||
|
<StackPanel Grid.Row="1">
|
||||||
|
|
||||||
|
<Border Padding="10,8" Background="#2b2410" IsVisible="{Binding HasPendingHostKey}">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||||
|
Foreground="#e8dcb0" TextWrapping="Wrap" />
|
||||||
|
<SelectableTextBlock Text="{Binding PendingHostKey.Fingerprint}"
|
||||||
|
FontFamily="ui-monospace,Consolas,monospace"
|
||||||
|
Foreground="#f4ecd0" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Content="Trust and connect" Command="{Binding TrustHostKeyCommand}" />
|
||||||
|
<Button Content="Cancel" Command="{Binding RejectHostKeyCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Padding="10,8" Background="#3a1418" IsVisible="{Binding HasHostKeyMismatch}">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="The host key changed and the connection was refused."
|
||||||
|
Foreground="#f3c9cd" FontWeight="SemiBold" />
|
||||||
|
<SelectableTextBlock Text="{Binding HostKeyMismatch}"
|
||||||
|
Foreground="#f3c9cd" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Text="If the server was legitimately rebuilt, remove its pinned key in the host's settings first. There is deliberately no way to continue from here."
|
||||||
|
Foreground="#d59aa1" TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
|
||||||
|
process, so twenty tabs would cost twenty renderer processes.
|
||||||
|
-->
|
||||||
|
<NativeWebView Grid.Row="2" x:Name="Terminal" />
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using DodoSSH.Client.App.ViewModels;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
internal sealed partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
// Navigation happens once the data context is known, because the URL carries the port the
|
||||||
|
// loopback listener was assigned. Setting Source in XAML would need a constant port, and a
|
||||||
|
// fixed port is one that another process can already be holding.
|
||||||
|
DataContextChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
if (DataContext is MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
Terminal.Source = viewModel.TerminalPageUrl;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
The page is the terminal surface and nothing else. No chrome, no scrollbars of its own: the
|
||||||
|
window, tabs and splits are Avalonia's job, and duplicating any of it here would mean two
|
||||||
|
layout systems disagreeing about the same pixels.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--dodo-background: #10131a;
|
||||||
|
--dodo-foreground: #d5d8de;
|
||||||
|
--dodo-muted: #7b8394;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--dodo-background);
|
||||||
|
color: var(--dodo-foreground);
|
||||||
|
font-family: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Every session gets a pane, and all but the active one are hidden rather than destroyed. On a
|
||||||
|
dropped connection the DOM and its scrollback must survive: rebuilding the terminal would
|
||||||
|
discard everything the user was reading, which is the one thing they cannot get back.
|
||||||
|
*/
|
||||||
|
.pane {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
/* A little breathing room, and it keeps the WebGL canvas off the window edge. */
|
||||||
|
padding: 4px 2px 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane[data-active="true"] {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane > .xterm {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--dodo-muted);
|
||||||
|
background: color-mix(in srgb, var(--dodo-background) 88%, white);
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--dodo-background) 70%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Once a session is running the banner is noise, so it only shows when it has something to say. */
|
||||||
|
#status:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>DodoSSH terminal</title>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A restrictive CSP. The page is served over loopback from our own process, but it hosts a
|
||||||
|
terminal — arbitrary remote output is written into it — so the cheapest insurance against a
|
||||||
|
future mistake is forbidding anything this page does not already need. No remote origins,
|
||||||
|
no inline script, no eval. 'self' covers the loopback origin the host serves from, and the
|
||||||
|
WebSocket connects to that same origin.
|
||||||
|
-->
|
||||||
|
<meta http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'none';
|
||||||
|
script-src 'self';
|
||||||
|
style-src 'self';
|
||||||
|
font-src 'self';
|
||||||
|
img-src 'self' data:;
|
||||||
|
connect-src ws://127.0.0.1:*;
|
||||||
|
base-uri 'none';
|
||||||
|
form-action 'none'">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/vendor/xterm.css">
|
||||||
|
<link rel="stylesheet" href="/terminal.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
The token and socket URL are substituted by the host when it serves this file, so neither is
|
||||||
|
ever written to disk and neither appears in a URL.
|
||||||
|
-->
|
||||||
|
<div id="root" data-token="__DODOSSH_TOKEN__" data-socket="__DODOSSH_SOCKET__"></div>
|
||||||
|
<div id="status" role="status" aria-live="polite">Connecting…</div>
|
||||||
|
|
||||||
|
<script src="/vendor/xterm.js"></script>
|
||||||
|
<script src="/vendor/addon-fit.js"></script>
|
||||||
|
<script src="/vendor/addon-webgl.js"></script>
|
||||||
|
<script src="/terminal.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
The renderer half of the terminal data plane.
|
||||||
|
|
||||||
|
Two things here are load-bearing and easy to get wrong:
|
||||||
|
|
||||||
|
1. Output is acknowledged from term.write's completion callback, never on receipt. The
|
||||||
|
acknowledgement returns flow-control credit to the host, so acknowledging early would tell
|
||||||
|
the host the screen has caught up when it has not — and the whole point of the credit
|
||||||
|
window is that it reflects what has actually been rendered. Acknowledge on receipt and a
|
||||||
|
remote running `yes` grows this page's memory until the tab dies.
|
||||||
|
|
||||||
|
2. Output is written as a Uint8Array, not a string. xterm decodes UTF-8 itself and carries
|
||||||
|
partial sequences across writes. Decoding here would corrupt any multi-byte character that
|
||||||
|
happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
|
||||||
|
conditions that are hardest to reproduce.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SERVER_OUTPUT = 1;
|
||||||
|
const SERVER_SESSION_OPENED = 2;
|
||||||
|
const SERVER_SESSION_CLOSED = 3;
|
||||||
|
|
||||||
|
const CLIENT_INPUT = 1;
|
||||||
|
const CLIENT_ACKNOWLEDGE = 2;
|
||||||
|
const CLIENT_RESIZE = 3;
|
||||||
|
|
||||||
|
const HEADER_LENGTH = 5;
|
||||||
|
const SCROLLBACK_LINES = 5000;
|
||||||
|
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
const statusBanner = document.getElementById('status');
|
||||||
|
|
||||||
|
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement}>} */
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
/** @type {WebSocket | null} */
|
||||||
|
let socket = null;
|
||||||
|
|
||||||
|
function setStatus(text) {
|
||||||
|
statusBanner.textContent = text ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a frame: opcode, big-endian session id, then payload. */
|
||||||
|
function frame(opcode, sessionId, payload) {
|
||||||
|
const body = payload ?? new Uint8Array(0);
|
||||||
|
const buffer = new ArrayBuffer(HEADER_LENGTH + body.length);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
|
||||||
|
view.setUint8(0, opcode);
|
||||||
|
view.setUint32(1, sessionId);
|
||||||
|
new Uint8Array(buffer, HEADER_LENGTH).set(body);
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(opcode, sessionId, payload) {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(frame(opcode, sessionId, payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendAcknowledgement(sessionId, byteCount) {
|
||||||
|
const payload = new Uint8Array(4);
|
||||||
|
new DataView(payload.buffer).setUint32(0, byteCount);
|
||||||
|
send(CLIENT_ACKNOWLEDGE, sessionId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendResize(sessionId, term, pane) {
|
||||||
|
const payload = new Uint8Array(8);
|
||||||
|
const view = new DataView(payload.buffer);
|
||||||
|
|
||||||
|
// Columns before rows, matching the SSH request. Swapping them produces a terminal that is 24
|
||||||
|
// columns by 80 rows, which reads as a rendering bug rather than a protocol one.
|
||||||
|
view.setUint16(0, term.cols);
|
||||||
|
view.setUint16(2, term.rows);
|
||||||
|
view.setUint16(4, pane.clientWidth);
|
||||||
|
view.setUint16(6, pane.clientHeight);
|
||||||
|
|
||||||
|
send(CLIENT_RESIZE, sessionId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSession(sessionId) {
|
||||||
|
const pane = document.createElement('div');
|
||||||
|
pane.className = 'pane';
|
||||||
|
pane.dataset.sessionId = String(sessionId);
|
||||||
|
root.appendChild(pane);
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
allowProposedApi: true,
|
||||||
|
convertEol: false,
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 13,
|
||||||
|
scrollback: SCROLLBACK_LINES,
|
||||||
|
// Matches terminal.css, so the canvas and the page agree on the background.
|
||||||
|
theme: { background: '#10131a', foreground: '#d5d8de' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fit = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(pane);
|
||||||
|
|
||||||
|
// WebGL where it is available. Falling back rather than failing matters because a software
|
||||||
|
// renderer is slow but usable, whereas a blank pane is not — and remote desktops and VMs
|
||||||
|
// routinely have no usable GPU context.
|
||||||
|
try {
|
||||||
|
term.loadAddon(new WebglAddon.WebglAddon());
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
send(CLIENT_INPUT, sessionId, new TextEncoder().encode(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
term.onResize(() => sendResize(sessionId, term, pane));
|
||||||
|
|
||||||
|
const session = { term, fit, pane };
|
||||||
|
sessions.set(sessionId, session);
|
||||||
|
|
||||||
|
activate(sessionId);
|
||||||
|
resize(session, sessionId);
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(sessionId) {
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
session.pane.dataset.active = String(id === sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = sessions.get(sessionId);
|
||||||
|
if (active) {
|
||||||
|
active.term.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize(session, sessionId) {
|
||||||
|
// fit() throws if the pane has no layout yet, which happens on the very first frame.
|
||||||
|
try {
|
||||||
|
session.fit.fit();
|
||||||
|
sendResize(sessionId, session.term, session.pane);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not fit the terminal yet.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFrame(buffer) {
|
||||||
|
if (buffer.byteLength < HEADER_LENGTH) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const opcode = view.getUint8(0);
|
||||||
|
const sessionId = view.getUint32(1);
|
||||||
|
const payload = new Uint8Array(buffer, HEADER_LENGTH);
|
||||||
|
|
||||||
|
switch (opcode) {
|
||||||
|
case SERVER_SESSION_OPENED:
|
||||||
|
createSession(sessionId);
|
||||||
|
setStatus('');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SERVER_OUTPUT: {
|
||||||
|
const session = sessions.get(sessionId) ?? createSession(sessionId);
|
||||||
|
|
||||||
|
// The callback is the acknowledgement, and payload.length is the byte count the host
|
||||||
|
// reserved credit for. Both must be the raw byte length, not a decoded character count.
|
||||||
|
session.term.write(payload, () => sendAcknowledgement(sessionId, payload.length));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SERVER_SESSION_CLOSED: {
|
||||||
|
const session = sessions.get(sessionId);
|
||||||
|
const reason = new TextDecoder().decode(payload);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
// The pane and its scrollback stay. The user was probably reading the last thing the
|
||||||
|
// remote said, and that is usually why the session ended.
|
||||||
|
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
|
||||||
|
session.term.options.cursorBlink = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(reason);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// A newer host than this page. Ignored rather than fatal.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const token = root.dataset.token;
|
||||||
|
const url = root.dataset.socket;
|
||||||
|
|
||||||
|
// The token travels as a subprotocol rather than a query parameter, which keeps it out of
|
||||||
|
// anything that logs URLs.
|
||||||
|
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
||||||
|
socket.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => setStatus(''));
|
||||||
|
socket.addEventListener('message', (event) => handleFrame(event.data));
|
||||||
|
|
||||||
|
socket.addEventListener('close', () => {
|
||||||
|
setStatus('Disconnected from DodoSSH.');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('error', () => {
|
||||||
|
setStatus('The terminal connection failed.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
|
||||||
|
// window is being dragged, and a single callback coalesces them for free.
|
||||||
|
new ResizeObserver(() => {
|
||||||
|
for (const [sessionId, session] of sessions) {
|
||||||
|
resize(session, sessionId);
|
||||||
|
}
|
||||||
|
}).observe(root);
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', () => socket?.close());
|
||||||
|
|
||||||
|
connect();
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Vendored third-party assets
|
||||||
|
|
||||||
|
Committed rather than fetched at build time, so a clean clone builds with the .NET SDK alone — no
|
||||||
|
node, no npm, no esbuild step. The trade-off is that upgrades are a manual re-download, which is
|
||||||
|
exactly why the provenance is written down here.
|
||||||
|
|
||||||
|
All of the following are MIT licensed, the same licence as this repository.
|
||||||
|
|
||||||
|
| File | Package | Version | Source |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `xterm.js` | `@xterm/xterm` | 6.0.0 | `https://unpkg.com/@xterm/xterm@6.0.0/lib/xterm.js` |
|
||||||
|
| `xterm.css` | `@xterm/xterm` | 6.0.0 | `https://unpkg.com/@xterm/xterm@6.0.0/css/xterm.css` |
|
||||||
|
| `addon-fit.js` | `@xterm/addon-fit` | 0.11.0 | `https://unpkg.com/@xterm/addon-fit@0.11.0/lib/addon-fit.js` |
|
||||||
|
| `addon-webgl.js` | `@xterm/addon-webgl` | 0.19.0 | `https://unpkg.com/@xterm/addon-webgl@0.19.0/lib/addon-webgl.js` |
|
||||||
|
|
||||||
|
Copyright (c) 2017 The xterm.js authors. See <https://github.com/xtermjs/xterm.js> for the full
|
||||||
|
licence text.
|
||||||
|
|
||||||
|
These are the UMD builds, so they attach to `globalThis`: `Terminal`, `FitAddon.FitAddon` and
|
||||||
|
`WebglAddon.WebglAddon`. `terminal.js` depends on those exact names, so check them after any upgrade
|
||||||
|
— a bundle that switched to ES modules would load without error and leave `Terminal` undefined.
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
1. Download the four files at the new versions and update the table above.
|
||||||
|
2. Confirm the UMD global names are unchanged.
|
||||||
|
3. Run the terminal suite, then start the app and open a session: nothing here is covered by the
|
||||||
|
.NET tests, because the renderer they exercise is a `ClientWebSocket` standing in for this page.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
|
||||||
|
//# sourceMappingURL=addon-fit.js.map
|
||||||
File diff suppressed because one or more lines are too long
+285
@@ -0,0 +1,285 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||||
|
* https://github.com/chjj/term.js
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Originally forked from (with the author's permission):
|
||||||
|
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||||
|
* http://bellard.org/jslinux/
|
||||||
|
* Copyright (c) 2011 Fabrice Bellard
|
||||||
|
* The original design remains. The terminal itself
|
||||||
|
* has been extended to include xterm CSI codes, among
|
||||||
|
* other features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default styles for xterm.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
cursor: text;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.focus,
|
||||||
|
.xterm:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helpers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
/**
|
||||||
|
* The z-index of the helpers must be higher than the canvases in order for
|
||||||
|
* IMEs to appear on top.
|
||||||
|
*/
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helper-textarea {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
left: -9999em;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -5;
|
||||||
|
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view {
|
||||||
|
/* TODO: Composition position got messed up somewhere */
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||||
|
background-color: #000;
|
||||||
|
overflow-y: scroll;
|
||||||
|
cursor: default;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-char-measure-element {
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9999em;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.enable-mouse-events {
|
||||||
|
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.xterm-cursor-pointer,
|
||||||
|
.xterm .xterm-cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.column-select.focus {
|
||||||
|
/* Column selection mode */
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility:not(.debug),
|
||||||
|
.xterm .xterm-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree {
|
||||||
|
font-family: monospace;
|
||||||
|
user-select: text;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree > div {
|
||||||
|
transform-origin: left;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .live-region {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-dim {
|
||||||
|
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||||
|
* explicitly in the generated class and reset to 1 here */
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-underline-1 { text-decoration: underline; }
|
||||||
|
.xterm-underline-2 { text-decoration: double underline; }
|
||||||
|
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||||
|
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||||
|
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||||
|
|
||||||
|
.xterm-overline {
|
||||||
|
text-decoration: overline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||||
|
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||||
|
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||||
|
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||||
|
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||||
|
|
||||||
|
.xterm-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-overview-ruler {
|
||||||
|
z-index: 8;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-top {
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
|
||||||
|
|
||||||
|
/* xterm.js customization: Override xterm's cursor style */
|
||||||
|
.xterm .xterm-scrollable-element > .scrollbar {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Arrows */
|
||||||
|
.xterm .xterm-scrollable-element > .scrollbar > .scra {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-scrollable-element > .visible {
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
/* Background rule added for IE9 - to allow clicks on dom node */
|
||||||
|
background:rgba(0,0,0,0);
|
||||||
|
|
||||||
|
transition: opacity 100ms linear;
|
||||||
|
/* In front of peek view */
|
||||||
|
z-index: 11;
|
||||||
|
}
|
||||||
|
.xterm .xterm-scrollable-element > .invisible {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.xterm .xterm-scrollable-element > .invisible.fade {
|
||||||
|
transition: opacity 800ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollable Content Inset Shadow */
|
||||||
|
.xterm .xterm-scrollable-element > .shadow {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.xterm .xterm-scrollable-element > .shadow.top {
|
||||||
|
display: block;
|
||||||
|
top: 0;
|
||||||
|
left: 3px;
|
||||||
|
height: 3px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
|
||||||
|
}
|
||||||
|
.xterm .xterm-scrollable-element > .shadow.left {
|
||||||
|
display: block;
|
||||||
|
top: 3px;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 3px;
|
||||||
|
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||||
|
}
|
||||||
|
.xterm .xterm-scrollable-element > .shadow.top-left-corner {
|
||||||
|
display: block;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 3px;
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
.xterm .xterm-scrollable-element > .shadow.top.left {
|
||||||
|
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="DodoSSH.Client.App" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Required, not optional. Without a supportedOS list Windows reports a downlevel version to the
|
||||||
|
process, and Avalonia's native control host then fails outright with "Unable to create child
|
||||||
|
window for native control host" — so the WebView, and therefore the terminal, does not start at
|
||||||
|
all. The failure names the manifest, which is the only reason it is quick to diagnose.
|
||||||
|
-->
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows Vista -->
|
||||||
|
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
|
||||||
|
<!-- Windows 7 -->
|
||||||
|
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
|
||||||
|
<!-- Windows 8 -->
|
||||||
|
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
|
||||||
|
<!-- Windows 8.1 -->
|
||||||
|
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
|
||||||
|
<!-- Windows 10 and 11 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<!--
|
||||||
|
Per-monitor v2, so text stays crisp when a window moves between displays of different
|
||||||
|
scale. A terminal is almost entirely text, so bitmap-stretched glyphs are more obvious
|
||||||
|
here than in most applications.
|
||||||
|
-->
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"dependencies": {
|
||||||
|
"net10.0": {
|
||||||
|
"Avalonia": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[12.1.0, )",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "an4ugAy2q6GTdaFl635V8W/LKrWNL+mnSFUprbgyf8m1Zzf9WgoGaF5ajGv5i4gXefMZrzsUPV1QU+kKrgwCWg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia.BuildServices": "11.3.2",
|
||||||
|
"Avalonia.Remote.Protocol": "12.1.0",
|
||||||
|
"MicroCom.Runtime": "0.11.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Controls.WebView": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[12.0.1, )",
|
||||||
|
"resolved": "12.0.1",
|
||||||
|
"contentHash": "GrCIpIIBL7ueFDsNu3lyYc1mgO3QGGl1c1MCK8YAgjaNZwF9PV5PF2UB3lm1uuqj/MWOKNhemLwcSDLyYv0JjQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Desktop": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[12.1.0, )",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "mxhz50At61IBQbB/bCo5JGp53rPi3GerGO9mFo/v93uBHa4J3cz3NSSqnVWSyQXKbPCwQhrogfEFWbKBgecy1w==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "12.1.0",
|
||||||
|
"Avalonia.HarfBuzz": "12.1.0",
|
||||||
|
"Avalonia.Native": "12.1.0",
|
||||||
|
"Avalonia.Skia": "12.1.0",
|
||||||
|
"Avalonia.Win32": "12.1.0",
|
||||||
|
"Avalonia.X11": "12.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Fonts.Inter": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[12.1.0, )",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "2mK5Rv6aMWgXfQ2JZOq1Wo2bTNAfiidg2GO4b3MgLRN89ezfvfsSfp4P5Pl4ssRcWWwdV0jGzIw8n0xc9B26VA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "12.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Themes.Fluent": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[12.1.0, )",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "MVi5L9HymnNm+gP2aNXNcyrP2iKGJWFubQ5Bv8/Przflxca1aIT9QBpTUV6c3olA9rLYFY7MJRR/C/BZaUhemQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "12.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CommunityToolkit.Mvvm": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[8.4.2, )",
|
||||||
|
"resolved": "8.4.2",
|
||||||
|
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
|
||||||
|
},
|
||||||
|
"Meziantou.Analyzer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[3.0.134, )",
|
||||||
|
"resolved": "3.0.134",
|
||||||
|
"contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
|
||||||
|
},
|
||||||
|
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[5.6.0, )",
|
||||||
|
"resolved": "5.6.0",
|
||||||
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
|
},
|
||||||
|
"Avalonia.Angle.Windows.Natives": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "2.1.27548.20260419",
|
||||||
|
"contentHash": "l17nI3XVDN3oMnpjf2pnmJg0YTwK4m6NLsn/itAjDMdObTFxN77D5F1M9sRMSfViSY3KKcse1ROczwgoWLJsnA=="
|
||||||
|
},
|
||||||
|
"Avalonia.BuildServices": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "11.3.2",
|
||||||
|
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
|
||||||
|
},
|
||||||
|
"Avalonia.FreeDesktop": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "89mrS7dSYtisJrjQufCOomHeAynlVVKZ+dq4leKtzHXXKVoWsE0Nb2ymiNYPMlirwzwpemflGj+K34opwyLeJQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "12.1.0",
|
||||||
|
"Tmds.DBus.Protocol": "0.94.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.FreeDesktop.AtSpi": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "WWahMjzKtDl2PGHa8mS6NHIVMT+JNKVIeT5xMLp9SBTMvjHNpEbXTX3PNbbIQ7hRMSETAJ/PAnvgOzatGktEKQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "12.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.HarfBuzz": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "uWPa/kg+fmqhrUR5GzFC2ZL2MPszxcEy14hTSwu3VhjwTnaVarUozoPmggQjZG4A4s6w2bcGepsYYaCM/eOoMA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "12.1.0",
|
||||||
|
"HarfBuzzSharp": "8.3.1.3",
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.3",
|
||||||
|
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Native": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "mfNMtGP7rEVWSZoii0l40mlNhgNR6ZISTvHP7OAMn5YQHiK66EjfN26SL/kLnz1buy2VtMIiGVBbIttL8FYCZg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "12.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Remote.Protocol": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "p6OKt6O7vOub4TS2pAjaeW0Y13oxrPs4uixeVZpJByiSQKKk+LyApN5yRy2JerpfTMtI86Y5pNwugyKTHZJnAw=="
|
||||||
|
},
|
||||||
|
"Avalonia.Skia": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "K63pwExQkcjVbsYJOiOq0hYAO4G5d7T42yK8MGNrvwBKv/bJVlV14jGvV4wXcsuYAU8IWlOHgqq5sMUiDfj4vw==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "12.1.0",
|
||||||
|
"HarfBuzzSharp": "8.3.1.3",
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.3",
|
||||||
|
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.3",
|
||||||
|
"SkiaSharp": "3.119.4",
|
||||||
|
"SkiaSharp.NativeAssets.Linux": "3.119.4",
|
||||||
|
"SkiaSharp.NativeAssets.WebAssembly": "3.119.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "D0xxPtFeOK8cKK991ul92rlFtDO3II0E44dHM0ix4/8LVc9+1LaMdbf1eMnp1RclFwX2bt7HPhbySnrG5uArxA==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "12.1.0",
|
||||||
|
"Avalonia.Angle.Windows.Natives": "2.1.27548.20260419"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Avalonia.X11": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "12.1.0",
|
||||||
|
"contentHash": "6+YHVGf44ictmGj88diMCw9pC9tiwnMlUgosDi0VDmyUQFuy/mJIa4J0rZu6G+UMbjGuyLDQLmtvU4tTOhLMPg==",
|
||||||
|
"dependencies": {
|
||||||
|
"Avalonia": "12.1.0",
|
||||||
|
"Avalonia.FreeDesktop": "12.1.0",
|
||||||
|
"Avalonia.FreeDesktop.AtSpi": "12.1.0",
|
||||||
|
"Avalonia.Skia": "12.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.3",
|
||||||
|
"contentHash": "NGZ2+ZVNPM+NdHB/asW0/ykWngyHWwcqjrbN2nDeH1B/aptPGlCUl8wkQ2cSJxw5fdWgdmIPmNuTPWpLwNVXWg==",
|
||||||
|
"dependencies": {
|
||||||
|
"HarfBuzzSharp.NativeAssets.Win32": "8.3.1.3",
|
||||||
|
"HarfBuzzSharp.NativeAssets.macOS": "8.3.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.3",
|
||||||
|
"contentHash": "RI6A1LgmooU30+4QIyFt5rmBCzP0VzTR+587IJSGvYIsHHWlahFufihYxtraLfsIhW7I8dn6+xX+DZGygOPKWQ=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.3",
|
||||||
|
"contentHash": "KPTq0xnslkI6nAo0jh3ptcQPJvZZr7MWYXa2jUe4SnHc9q+JlHElmNXp0sfFoiTgoCX7WOYpYsurypuH9Gehxw=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.WebAssembly": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.3",
|
||||||
|
"contentHash": "w2QfdNm9Uz/sUa0B5D+OnVQhyq3G/fBq6ibQMdWBlQqqwh0g0/5j3RFvYqZAmRZ5+RzvjVe8o8SFFnWYUSkuxA=="
|
||||||
|
},
|
||||||
|
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.3.1.3",
|
||||||
|
"contentHash": "bx8CE8Js+XGX8PUxAHCBDEORt5aaBYtMN4Hr9QFs57Xithh6yjUyYqksizH6eRDhJkwsGI+SXWmPmMm8lZC9Pw=="
|
||||||
|
},
|
||||||
|
"MicroCom.Runtime": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "0.11.6",
|
||||||
|
"contentHash": "NdNWGDiZ6eS/Mf/9+QHR91cj1K7Hy+PX9yrHI/zM7xFYuj9IWT2uxtB6sCHjrnxAeLV9fut1R6zHDUGKX6f9lQ=="
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.0.2",
|
||||||
|
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
|
||||||
|
},
|
||||||
|
"Microsoft.Extensions.Logging.Abstractions": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "8.0.3",
|
||||||
|
"contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.119.4",
|
||||||
|
"contentHash": "53NOSUZ1Us+91Sm0uCkIivh/k7jOowRErZT2sIWwPFN9mLUvdxnE6rS4sWo4255+Rd2MWUSF+j0NMZHD6Cke+Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"SkiaSharp.NativeAssets.Win32": "3.119.4",
|
||||||
|
"SkiaSharp.NativeAssets.macOS": "3.119.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Linux": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.119.4",
|
||||||
|
"contentHash": "UAyVzbqNfZsZbKbzj68zXLyUyF/SbTKmzTfOO6qDu++dtIUMMTzPBe8oOuzU/DiewpfKoUUlOSsJmqWc6blxBw=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.macOS": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.119.4",
|
||||||
|
"contentHash": "fgBOWEqbY012x7gMfJU4ezgz6dfhJb30Z6YdW35h85Zoe39+a8YNbAAwL29ihPfWoppg5AjvyKNzD1oCvlqWwA=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.WebAssembly": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.119.4",
|
||||||
|
"contentHash": "S1HOxtBbD4bYDtA2e9WH5TX+lxqRrTPvKjrjttRhxnHNNu7YY8VFo/LeCP7tNqoTA6PV+8vsvNbmRUEC2ip8RQ=="
|
||||||
|
},
|
||||||
|
"SkiaSharp.NativeAssets.Win32": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "3.119.4",
|
||||||
|
"contentHash": "XOpbx/4CReO2wYsq2s6rbvdauc6dntG4Zv499sHGTJ87bwZaFXszFkwql3+FIZMc8kUPeaj3Mx2ezIJmo8a1Kg=="
|
||||||
|
},
|
||||||
|
"Tmds.DBus.Protocol": {
|
||||||
|
"type": "Transitive",
|
||||||
|
"resolved": "0.94.1",
|
||||||
|
"contentHash": "11YMr7FnAbL83bQmVxlhbIKHvSLxjO81D12Ej0QMSGXMDTxNA9MTOa4MQxx43nv5el/efuPHwzyrj6a5ha2gug=="
|
||||||
|
},
|
||||||
|
"dodossh.client.ssh": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"SSH.NET": "[2025.1.0, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dodossh.client.terminal": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"BouncyCastle.Cryptography": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2.6.2, )",
|
||||||
|
"resolved": "2.6.2",
|
||||||
|
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||||
|
},
|
||||||
|
"SSH.NET": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[2025.1.0, )",
|
||||||
|
"resolved": "2025.1.0",
|
||||||
|
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||||
|
"dependencies": {
|
||||||
|
"BouncyCastle.Cryptography": "2.6.2",
|
||||||
|
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,8 +209,23 @@ internal sealed class SshNetShellSession(ShellStream shell) : ISshShellSession
|
|||||||
shell.ReadAsync(buffer, cancellationToken);
|
shell.ReadAsync(buffer, cancellationToken);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
/// <remarks>
|
||||||
shell.WriteAsync(data, cancellationToken);
|
/// The flush is mandatory, not an optimisation. <c>ShellStream.Write</c> accumulates into an
|
||||||
|
/// internal buffer and sends nothing until flushed, so without this a keystroke is accepted,
|
||||||
|
/// reported as written, and never reaches the remote — the terminal simply stops responding to
|
||||||
|
/// input while still displaying output perfectly. SSH.NET's own <c>WriteLine</c> flushes for this
|
||||||
|
/// reason, which is why the spike tests never hit it.
|
||||||
|
/// <para>
|
||||||
|
/// Flushed per write rather than batched: a terminal has to put a keystroke on the wire
|
||||||
|
/// immediately, and there is nothing to coalesce — a human types far below any rate at which
|
||||||
|
/// batching would matter.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public async ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await shell.WriteAsync(data, cancellationToken).ConfigureAwait(false);
|
||||||
|
await shell.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Resize(TerminalSize size)
|
public void Resize(TerminalSize size)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The embedded browser that renders terminals.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Deliberately tiny. Everything the renderer needs — its files, its connection token, its socket
|
||||||
|
/// URL — arrives over the loopback HTTP server, so the only thing the host has to do is navigate.
|
||||||
|
/// That is what keeps three WebView backends interchangeable: the official
|
||||||
|
/// <c>Avalonia.Controls.WebView</c>, the community <c>NativeWebView</c> whose Linux backend is the
|
||||||
|
/// more widely installed WebKitGTK, and CEF as the heavyweight escape hatch.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It is also what makes the terminal testable headlessly. Avalonia's headless platform has no
|
||||||
|
/// WebView at all, so a stub implementing this interface stands in — and because the interface is one
|
||||||
|
/// method, the stub cannot drift from the real thing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface ITerminalHost
|
||||||
|
{
|
||||||
|
/// <summary>Points the embedded browser at the renderer page.</summary>
|
||||||
|
Task NavigateAsync(Uri pageUrl, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the loopback data plane and every live terminal session.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One data plane and one renderer page for the whole application, with a session id per terminal.
|
||||||
|
/// Not one WebView per tab: each WebView2 is a separate browser process, so twenty tabs would mean
|
||||||
|
/// twenty renderer processes and several hundred megabytes for a working set a user would call
|
||||||
|
/// ordinary. Splits and tabs are layout inside the single page.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class TerminalWorkspace : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly TerminalDataPlane dataPlane;
|
||||||
|
private readonly ISshConnectionFactory connections;
|
||||||
|
private readonly TimeProvider clock;
|
||||||
|
private readonly Dictionary<uint, LiveSession> sessions = [];
|
||||||
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
|
|
||||||
|
private uint nextSessionId = 1;
|
||||||
|
private Task? server;
|
||||||
|
private int disposed;
|
||||||
|
|
||||||
|
public TerminalWorkspace(
|
||||||
|
ITerminalAssetProvider assets,
|
||||||
|
ISshConnectionFactory connections,
|
||||||
|
TimeProvider clock)
|
||||||
|
{
|
||||||
|
this.connections = connections;
|
||||||
|
this.clock = clock;
|
||||||
|
|
||||||
|
dataPlane = new TerminalDataPlane(assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Where the WebView should navigate.</summary>
|
||||||
|
public Uri PageUrl => dataPlane.PageUrl;
|
||||||
|
|
||||||
|
/// <summary>Starts the loopback listener.</summary>
|
||||||
|
public void Start() => server = dataPlane.RunAsync(lifetime.Token);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Waits until the renderer page has attached its socket.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A session opened before the renderer attaches would have its <c>SessionOpened</c> frame
|
||||||
|
/// dropped — the transport discards frames when nothing is connected — leaving output arriving
|
||||||
|
/// for a terminal that was never created.
|
||||||
|
/// </remarks>
|
||||||
|
public Task WaitForRendererAsync() => dataPlane.RendererAttached;
|
||||||
|
|
||||||
|
/// <summary>Connects to a host and starts a terminal for it.</summary>
|
||||||
|
/// <returns>The session id, which identifies this terminal in the renderer.</returns>
|
||||||
|
public async Task<uint> OpenSessionAsync(
|
||||||
|
SshConnectionRequest request,
|
||||||
|
TerminalSize size,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var connection = await connections.ConnectAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
ISshShellSession shell;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
shell = await connection.OpenShellAsync(size, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await connection.DisposeAsync().ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionId = nextSessionId++;
|
||||||
|
var pump = new TerminalSessionPump(sessionId, shell, dataPlane, clock);
|
||||||
|
|
||||||
|
dataPlane.Register(sessionId, pump);
|
||||||
|
|
||||||
|
// Registered before running, so an acknowledgement that arrives with the very first output
|
||||||
|
// frame has somewhere to go.
|
||||||
|
var run = RunSessionAsync(sessionId, pump);
|
||||||
|
|
||||||
|
sessions[sessionId] = new LiveSession(connection, pump, run);
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Closes one terminal.</summary>
|
||||||
|
public async Task CloseSessionAsync(uint sessionId)
|
||||||
|
{
|
||||||
|
if (!sessions.Remove(sessionId, out var session))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dataPlane.Unregister(sessionId);
|
||||||
|
|
||||||
|
await session.Pump.DisposeAsync().ConfigureAwait(false);
|
||||||
|
await session.Connection.DisposeAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await session.Run.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected: disposing the pump cancels its run.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref disposed, 1) == 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var sessionId in sessions.Keys.ToArray())
|
||||||
|
{
|
||||||
|
await CloseSessionAsync(sessionId).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||||
|
await dataPlane.DisposeAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (server is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await server.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected: the accept loop is stopped by cancelling it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lifetime.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await pump.RunAsync(lifetime.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
dataPlane.Unregister(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record LiveSession(ISshConnection Connection, TerminalSessionPump Pump, Task Run);
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
<ProjectReference Include="../../src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
For the end-to-end test that runs a real SSH session through the real data plane. It lives
|
||||||
|
here rather than in the terminal suite because this is the project that already owns the
|
||||||
|
OpenSSH container, and duplicating that fixture would mean two containers per test run.
|
||||||
|
-->
|
||||||
|
<ProjectReference Include="../../src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Ssh.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The pump against a real shell, with the transport replaced by a recorder.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Sits between the pump's unit tests, which use a fake shell, and the full end-to-end test, which
|
||||||
|
/// adds the loopback socket. Its value is diagnostic: when output does not reach a renderer, this
|
||||||
|
/// says whether the pump and SSH.NET are producing anything at all.
|
||||||
|
/// </remarks>
|
||||||
|
[Collection(SshCollection.Name)]
|
||||||
|
public sealed class PumpOverRealSshTests(SshServerFixture fixture)
|
||||||
|
{
|
||||||
|
/// <summary>Trusts the container's host key, then connects. First contact is refused by design.</summary>
|
||||||
|
private async Task<ISshConnection> ConnectTrustedAsync()
|
||||||
|
{
|
||||||
|
var knownHosts = new InMemoryKnownHostStore();
|
||||||
|
var factory = new SshNetConnectionFactory(knownHosts);
|
||||||
|
|
||||||
|
var request = new SshConnectionRequest(
|
||||||
|
fixture.Host,
|
||||||
|
fixture.Port,
|
||||||
|
SshServerFixture.Username,
|
||||||
|
new SshPasswordCredential(SshServerFixture.Password));
|
||||||
|
|
||||||
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
|
await factory.ConnectAsync(request, TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
return await factory.ConnectAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePump_ForwardsRealShellOutput()
|
||||||
|
{
|
||||||
|
await using var connection = await ConnectTrustedAsync();
|
||||||
|
|
||||||
|
var shell = await connection.OpenShellAsync(
|
||||||
|
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var transport = new CountingTransport();
|
||||||
|
|
||||||
|
await using var pump = new TerminalSessionPump(
|
||||||
|
1,
|
||||||
|
shell,
|
||||||
|
transport,
|
||||||
|
TimeProvider.System,
|
||||||
|
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(20) });
|
||||||
|
|
||||||
|
transport.Pump = pump;
|
||||||
|
|
||||||
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// Marker split so the PTY's echo of the command line does not satisfy the match.
|
||||||
|
await pump.WriteInputAsync(
|
||||||
|
Encoding.UTF8.GetBytes("echo \"DODO\"\"SSH-OK\"\n"),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(20);
|
||||||
|
|
||||||
|
while (TimeProvider.System.GetUtcNow() < deadline
|
||||||
|
&& !transport.Text.Contains("DODOSSH-OK", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.Text.Contains("DODOSSH-OK", StringComparison.Ordinal).ShouldBeTrue(
|
||||||
|
$"BytesRead={pump.BytesRead}, FramesSent={pump.FramesSent}, "
|
||||||
|
+ $"OutputFrames={transport.OutputFrames}, Text=<{transport.Text}>");
|
||||||
|
|
||||||
|
await pump.DisposeAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await run;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected: disposing the pump cancels its run.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Accumulates output and acknowledges it, as a keeping-up renderer would.</summary>
|
||||||
|
private sealed class CountingTransport : ITerminalTransport
|
||||||
|
{
|
||||||
|
private readonly StringBuilder text = new();
|
||||||
|
private readonly Lock gate = new();
|
||||||
|
|
||||||
|
internal TerminalSessionPump? Pump { get; set; }
|
||||||
|
|
||||||
|
internal int OutputFrames { get; private set; }
|
||||||
|
|
||||||
|
internal string Text
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return text.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TerminalFrame.TryRead(frame.Span, out var opcode, out _, out var payload)
|
||||||
|
|| opcode != (byte)TerminalServerOpcode.Output)
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
OutputFrames++;
|
||||||
|
text.Append(Encoding.UTF8.GetString(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
Pump?.Acknowledge((uint)payload.Length);
|
||||||
|
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Ssh.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A real SSH session, through the real data plane, to a stand-in renderer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Everything the desktop client does when a user opens a terminal, minus the pixels: a real
|
||||||
|
/// <c>sshd</c> in a container, a real pseudo-terminal, the real loopback WebSocket with its token and
|
||||||
|
/// origin checks, and a <see cref="ClientWebSocket"/> standing in for the page. If a shell prompt
|
||||||
|
/// arrives here and typed input round-trips, the only untested link left is xterm drawing bytes it was
|
||||||
|
/// handed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Worth having because the alternative is driving a GUI. The WebView's own participation is
|
||||||
|
/// verifiable separately — it opens a TCP connection to this same port — but that says nothing about
|
||||||
|
/// whether an SSH session's output reaches it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Collection(SshCollection.Name)]
|
||||||
|
public sealed class TerminalEndToEndTests(SshServerFixture fixture)
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The real page is an Avalonia resource in the app project. This test stands in for the renderer
|
||||||
|
/// itself, so a placeholder-bearing stub is all the transport needs.
|
||||||
|
/// </remarks>
|
||||||
|
private static InMemoryTerminalAssetProvider StubAssets() =>
|
||||||
|
new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[TerminalDataPlane.PagePath] = new(
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
Encoding.UTF8.GetBytes(
|
||||||
|
$"<html data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
|
||||||
|
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></html>")),
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AShellSessionReachesTheRenderer_AndInputReachesTheRemote()
|
||||||
|
{
|
||||||
|
var knownHosts = new InMemoryKnownHostStore();
|
||||||
|
|
||||||
|
await using var workspace = new TerminalWorkspace(
|
||||||
|
StubAssets(),
|
||||||
|
new SshNetConnectionFactory(knownHosts),
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
workspace.Start();
|
||||||
|
|
||||||
|
// The token comes from the served page, exactly as the real renderer obtains it.
|
||||||
|
var token = await ReadTokenAsync(workspace.PageUrl);
|
||||||
|
|
||||||
|
using var renderer = await AttachAsync(workspace.PageUrl, token);
|
||||||
|
await workspace.WaitForRendererAsync();
|
||||||
|
|
||||||
|
var sessionId = await OpenTrustedSessionAsync(workspace, knownHosts);
|
||||||
|
|
||||||
|
// SessionOpened tells the renderer to create a terminal before any output arrives for it.
|
||||||
|
var opened = await ReceiveAsync(renderer);
|
||||||
|
opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
|
||||||
|
opened.SessionId.ShouldBe(sessionId);
|
||||||
|
|
||||||
|
// The login banner and prompt arrive unprompted, acknowledged as the page does from
|
||||||
|
// term.write's callback.
|
||||||
|
var banner = await ReadOutputUntilAsync(renderer, sessionId, "$", acknowledge: true);
|
||||||
|
banner.ShouldContain("OpenSSH");
|
||||||
|
|
||||||
|
// Marker split so the PTY's echo of the command line does not satisfy the match.
|
||||||
|
await SendAsync(
|
||||||
|
renderer,
|
||||||
|
sessionId,
|
||||||
|
(byte)TerminalClientOpcode.Input,
|
||||||
|
Encoding.UTF8.GetBytes("echo \"DODO\"\"SSH-OK\"; stty size\n"));
|
||||||
|
|
||||||
|
var output = await ReadOutputUntilAsync(renderer, sessionId, "DODOSSH-OK", acknowledge: true);
|
||||||
|
|
||||||
|
output.ShouldContain("DODOSSH-OK");
|
||||||
|
|
||||||
|
// The size requested when the session opened is the size the remote sees, which means the
|
||||||
|
// pty-req carried it rather than the terminal silently defaulting to 80x24.
|
||||||
|
output.Replace('\r', '\n').ShouldContain("30 100");
|
||||||
|
|
||||||
|
await workspace.CloseSessionAsync(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trusts the container's host key, then opens a session.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The refused first attempt is part of the assertion, not setup noise: a host with no pinned key
|
||||||
|
/// must not connect, and the fingerprint the user would be shown has to be in the exception.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<uint> OpenTrustedSessionAsync(
|
||||||
|
TerminalWorkspace workspace,
|
||||||
|
InMemoryKnownHostStore knownHosts)
|
||||||
|
{
|
||||||
|
var request = new SshConnectionRequest(
|
||||||
|
fixture.Host,
|
||||||
|
fixture.Port,
|
||||||
|
SshServerFixture.Username,
|
||||||
|
new SshPasswordCredential(SshServerFixture.Password));
|
||||||
|
|
||||||
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
|
await workspace.OpenSessionAsync(
|
||||||
|
request, TerminalSize.Default, TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
unknown.Presentation.Fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix);
|
||||||
|
|
||||||
|
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
return await workspace.OpenSessionAsync(
|
||||||
|
request,
|
||||||
|
new TerminalSize(100, 30, 1000, 750),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadTokenAsync(Uri pageUrl)
|
||||||
|
{
|
||||||
|
using var client = new HttpClient();
|
||||||
|
var page = await client.GetStringAsync(pageUrl, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
const string Marker = "data-token=\"";
|
||||||
|
var start = page.IndexOf(Marker, StringComparison.Ordinal) + Marker.Length;
|
||||||
|
var end = page.IndexOf('"', start);
|
||||||
|
|
||||||
|
return page[start..end];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ClientWebSocket> AttachAsync(Uri pageUrl, string token)
|
||||||
|
{
|
||||||
|
var socket = new ClientWebSocket();
|
||||||
|
|
||||||
|
socket.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
|
||||||
|
socket.Options.AddSubProtocol($"token.{token}");
|
||||||
|
socket.Options.SetRequestHeader(
|
||||||
|
"Origin",
|
||||||
|
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{pageUrl.Port}"));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await socket.ConnectAsync(
|
||||||
|
new Uri($"ws://127.0.0.1:{pageUrl.Port}{TerminalDataPlane.SocketPath}"),
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
socket.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task SendAsync(
|
||||||
|
ClientWebSocket socket,
|
||||||
|
uint sessionId,
|
||||||
|
byte opcode,
|
||||||
|
byte[] payload) =>
|
||||||
|
socket.SendAsync(
|
||||||
|
TerminalFrame.Create(opcode, sessionId, payload),
|
||||||
|
WebSocketMessageType.Binary,
|
||||||
|
endOfMessage: true,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Bounded by its own timeout rather than relying on a caller's deadline. A blocking receive is
|
||||||
|
/// where a missing frame turns into a hung test run instead of a failure with a message, and a hang
|
||||||
|
/// tells you nothing about which frame never came.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveAsync(
|
||||||
|
ClientWebSocket socket)
|
||||||
|
{
|
||||||
|
var buffer = new byte[256 * 1024];
|
||||||
|
|
||||||
|
using var deadline = new CancellationTokenSource(Timeout);
|
||||||
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
|
deadline.Token,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
WebSocketReceiveResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await socket.ReceiveAsync(buffer, linked.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (deadline.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new TimeoutException($"No terminal frame arrived within {Timeout}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TerminalFrame.TryRead(
|
||||||
|
buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload)
|
||||||
|
.ShouldBeTrue();
|
||||||
|
|
||||||
|
return (opcode, sessionId, payload.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads output frames until the text appears, acknowledging each as the page does.</summary>
|
||||||
|
private static async Task<string> ReadOutputUntilAsync(
|
||||||
|
ClientWebSocket socket,
|
||||||
|
uint sessionId,
|
||||||
|
string expected,
|
||||||
|
bool acknowledge)
|
||||||
|
{
|
||||||
|
var accumulated = new StringBuilder();
|
||||||
|
var deadline = TimeProvider.System.GetUtcNow() + Timeout;
|
||||||
|
|
||||||
|
while (TimeProvider.System.GetUtcNow() < deadline)
|
||||||
|
{
|
||||||
|
var frame = await ReceiveAsync(socket);
|
||||||
|
|
||||||
|
if (frame.Opcode == (byte)TerminalServerOpcode.SessionClosed)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"The session closed before '{expected}' arrived: "
|
||||||
|
+ $"{Encoding.UTF8.GetString(frame.Payload)}\nSeen so far:\n{accumulated}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.Opcode != (byte)TerminalServerOpcode.Output)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (acknowledge)
|
||||||
|
{
|
||||||
|
// Returning credit is what keeps the pump reading. Without it the session stalls at
|
||||||
|
// the window size and this loop would time out on a working implementation.
|
||||||
|
await SendAsync(
|
||||||
|
socket,
|
||||||
|
sessionId,
|
||||||
|
(byte)TerminalClientOpcode.Acknowledge,
|
||||||
|
TerminalFrame.CreateAcknowledgementPayload((uint)frame.Payload.Length));
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulated.Append(Encoding.UTF8.GetString(frame.Payload));
|
||||||
|
|
||||||
|
if (accumulated.ToString().Contains(expected, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return accumulated.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TimeoutException($"'{expected}' did not arrive within {Timeout}.\n{accumulated}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -302,6 +302,12 @@
|
|||||||
"SSH.NET": "[2025.1.0, )"
|
"SSH.NET": "[2025.1.0, )"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dodossh.client.terminal": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
"BouncyCastle.Cryptography": {
|
"BouncyCastle.Cryptography": {
|
||||||
"type": "CentralTransitive",
|
"type": "CentralTransitive",
|
||||||
"requested": "[2.6.2, )",
|
"requested": "[2.6.2, )",
|
||||||
|
|||||||
@@ -58,7 +58,15 @@ public sealed class SyncCursorTests
|
|||||||
public void RejectsATamperedTag()
|
public void RejectsATamperedTag()
|
||||||
{
|
{
|
||||||
var cursor = SyncCursor.Encode(Key, VaultId, 100);
|
var cursor = SyncCursor.Encode(Key, VaultId, 100);
|
||||||
var tampered = cursor[..^1] + (cursor[^1] == 'A' ? 'B' : 'A');
|
|
||||||
|
// The second-to-last character, never the last one. Base64 encodes 3 bytes per 4 characters,
|
||||||
|
// so when the input length is not a multiple of 3 the final character carries bits that
|
||||||
|
// decode to nothing — and altering only those produces a different string that decodes to
|
||||||
|
// identical bytes, verifies fine, and makes this test pass or fail depending on the random
|
||||||
|
// key. The penultimate character is fully significant at every input length.
|
||||||
|
var tampered = cursor[..^2] + (cursor[^2] == 'A' ? 'B' : 'A') + cursor[^1];
|
||||||
|
|
||||||
|
tampered.Equals(cursor, StringComparison.Ordinal).ShouldBeFalse();
|
||||||
|
|
||||||
SyncCursor.TryDecode(Key, tampered, VaultId, out _).ShouldBeFalse();
|
SyncCursor.TryDecode(Key, tampered, VaultId, out _).ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user