Give DodoSSH a phone, and a shared shell for both heads to drive

The Android head from docs/android-port.md, taken as far as its step 6.

Step 3, the spike, is answered and its throwaway screen is gone: libsodium.so and
libe_sqlite3.so are both in the arm64 APK, so NSec resolves its native half on Android
despite shipping no Android build, and the local cache opens. Two findings the audit
could not have had: Avalonia.Controls.WebView only ships net10.0-android36.0, which
settles the open "which Android versions" question at targetSdk 36; and Android has
blocked cleartext HTTP since API 28, so the terminal renderer needs a network security
config scoped to 127.0.0.1 or the WebView loads nothing.

DodoSSH.Client.Shell is new and is why the phone can exist: the view models, the terminal
renderer files and the palette moved there so both heads drive one state machine and draw
from one set of tokens. The desktop head is otherwise untouched and its 144 tests still
pass.

The platform pieces behind interfaces that already existed: the profile directory from
filesDir, a device key wrapped by a StrongBox-backed key that a fingerprint releases, and
a foreground service so a shell outliving a vault lock stays true on a platform that
stops backgrounded processes.

Sign-in is deliberately absent rather than approximated. It needs an app link, because
reusing the desktop loopback listener is the attack RFC 8252 section 8.3 names.
This commit is contained in:
2026-07-31 20:58:48 +02:00
parent 03e902a2d2
commit fe9d7fc289
65 changed files with 3034 additions and 103 deletions
@@ -0,0 +1,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- Same reasoning as the two heads: this code formats timestamps and host names for a person. -->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<!--
Avalonia, but deliberately not Avalonia.Desktop and not a windowing backend. What is actually used
here is Dispatcher, the asset loader and a resource dictionary — none of which imply a window, which
is why this project can be referenced by a phone.
This is the one place the repository's "everything except App is free of Avalonia" rule bends, and it
bends on purpose: the rule existed so the SSH layer, the flow control and the OIDC flow could be
tested without a toolkit, and none of those are here. What is here is the shell's state machine,
which two heads have to agree on exactly.
-->
<PackageReference Include="Avalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
</ItemGroup>
<ItemGroup>
<!--
The view models are internal, as they were when they lived in the desktop head, and both heads plus
the two shell suites are let in explicitly. Making them public instead would turn every rename of a
command into a compatibility question about an assembly nobody consumes.
-->
<InternalsVisibleTo Include="DodoSSH.Client.App" />
<InternalsVisibleTo Include="DodoSSH.Client.Android" />
<InternalsVisibleTo Include="DodoSSH.Client.App.Tests" />
<InternalsVisibleTo Include="DodoSSH.Client.App.Layout.Tests" />
</ItemGroup>
<ItemGroup>
<!--
The renderer's files, including the vendored xterm bundles. They moved here from the desktop head
when the phone head needed the same terminal: two copies of a vendored bundle is how one of them ends
up a version behind. Embedded rather than copied to disk so there is no separate deployment step.
-->
<AvaloniaResource Include="WebAssets/**" />
<AvaloniaResource Include="Theme/**" />
</ItemGroup>
</Project>
@@ -0,0 +1,45 @@
using Avalonia.Platform;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Shell.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.Shell/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,81 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!--
The palette, named for what a colour is for rather than for what it looks like. Every one of these is
from the design; the names are this codebase's, because "#0C0F0E" appearing in nine files is how a
surface ends up two shades off in the tenth.
It lives here, in the shared project, because there are now two heads drawing the same product. Two
copies of a palette is the same failure one file up: the phone's "connected" green drifting from the
desktop's is not a thing anybody would notice until a screenshot sat beside another screenshot.
Five near-black surfaces rather than one, and the difference between them is real work: the window is
the darkest so the terminal reads as the lit thing, chrome sits one step up so the header and status
bar frame it, and the lists sit between the two so a list does not look like part of either.
-->
<Color x:Key="CanvasColor">#0A0C0B</Color>
<SolidColorBrush x:Key="Canvas" Color="{StaticResource CanvasColor}" />
<SolidColorBrush x:Key="Chrome" Color="#0D100F" />
<SolidColorBrush x:Key="Sidebar" Color="#0C0F0E" />
<SolidColorBrush x:Key="Panel" Color="#0F1211" />
<SolidColorBrush x:Key="Raised" Color="#111514" />
<SolidColorBrush x:Key="Field" Color="#121615" />
<!-- Row hover, and the heavier one the chrome's own buttons use. On the phone these are press states. -->
<SolidColorBrush x:Key="Hover" Color="#141817" />
<SolidColorBrush x:Key="ChromeHover" Color="#1A1F1D" />
<!--
Three border weights, and they are not interchangeable. Strong separates one region from another,
subtle separates rows inside one region, and mid is what a control draws around itself.
-->
<SolidColorBrush x:Key="Border" Color="#1E2422" />
<SolidColorBrush x:Key="BorderSubtle" Color="#171C1A" />
<SolidColorBrush x:Key="BorderMid" Color="#2A312E" />
<SolidColorBrush x:Key="BorderHover" Color="#3A423E" />
<SolidColorBrush x:Key="BorderFaint" Color="#232927" />
<!--
The text ramp. Three steps, used consistently: what you read, what you glance at, and what is there
only so its absence would be noticed. A fourth step would be one nobody could tell from its neighbours.
-->
<SolidColorBrush x:Key="Text" Color="#DCE3DF" />
<SolidColorBrush x:Key="TextDim" Color="#7E8A84" />
<SolidColorBrush x:Key="TextFaint" Color="#566059" />
<SolidColorBrush x:Key="TextGhost" Color="#404743" />
<!--
The accent, and the three colours that are allowed to disagree with it. Green means live, connected or
yours; amber means a caveat worth reading; red means refused or destructive; blue is for the one thing
that is neither — a directory, a distinct scope — and is deliberately rare.
-->
<Color x:Key="AccentColor">#3CE88F</Color>
<SolidColorBrush x:Key="Accent" Color="{StaticResource AccentColor}" />
<SolidColorBrush x:Key="AccentSoft" Color="#3CE88F" Opacity="0.35" />
<SolidColorBrush x:Key="AccentWash" Color="#3CE88F" Opacity="0.06" />
<SolidColorBrush x:Key="Warn" Color="#E8B44C" />
<SolidColorBrush x:Key="WarnSoft" Color="#E8B44C" Opacity="0.35" />
<SolidColorBrush x:Key="WarnWash" Color="#E8B44C" Opacity="0.06" />
<SolidColorBrush x:Key="WarnText" Color="#B9A26B" />
<SolidColorBrush x:Key="Danger" Color="#E85D5D" />
<SolidColorBrush x:Key="DangerSoft" Color="#E85D5D" Opacity="0.3" />
<SolidColorBrush x:Key="DangerWash" Color="#E85D5D" Opacity="0.08" />
<SolidColorBrush x:Key="DangerText" Color="#D98A8A" />
<SolidColorBrush x:Key="Info" Color="#5DA9E8" />
<!--
The design asks for IBM Plex Mono and IBM Plex Sans. Neither ships with this application, and neither
is on a stock Windows install or a stock Android one, so requesting them by name would render as
whatever the font fallback chose that day — which is worse than choosing deliberately. Inter is
embedded by both heads and is what they already draw with.
The stack ends in the generic `monospace` rather than a Windows face, which is what makes it work on
both: Android has no Cascadia Mono or Consolas and resolves the generic name to its own mono face.
Named as a resource rather than repeated, because the substitution is the sort of thing that gets
reversed later and should be reversible in one place. See docs/design-import-gaps.md.
-->
<FontFamily x:Key="MonoFont">ui-monospace,Cascadia Mono,Consolas,monospace</FontFamily>
</ResourceDictionary>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// One open terminal, as a tab.
/// </summary>
/// <remarks>
/// <para>
/// A tab is a session id and two strings. It holds no terminal and owns nothing: the pane, its scrollback
/// and the shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is
/// one frame telling the page which pane to show. That is what makes tabs cheap here — the expensive object
/// is the WebView, and there is one of those however many tabs are open.
/// </para>
/// <para>
/// <b>Tabs belong to the shell, not to the vault.</b> Locking disposes the vault and every key it held, and
/// deliberately leaves shells running — so a tab list rebuilt per unlock would lose track of sessions that
/// are still connected, and the unlock screen's count of them would be the only place they appeared. The
/// shell outlives every lock, and so does this.
/// </para>
/// </remarks>
/// <param name="sessionId">Identifies this terminal to the renderer.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">Who this is logged in as, and where.</param>
internal sealed partial class TerminalTabViewModel(uint sessionId, string label, string address)
: ObservableObject
{
internal uint SessionId { get; } = sessionId;
internal string Label { get; } = label;
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
internal string Address { get; } = address;
/// <summary>
/// Whether the shell behind this tab is still running.
/// </summary>
/// <remarks>
/// Cleared when the workspace says the session ended, never inferred from the tab being closed — closing
/// a tab removes it, and a removed tab has nothing left to report. A dead tab is kept on purpose: its
/// pane still holds the scrollback, and the last thing the remote said is usually why the shell ended.
/// </remarks>
[ObservableProperty]
private bool isLive = true;
/// <summary>
/// Whether this is the tab whose pane is showing.
/// </summary>
/// <remarks>
/// A flag on the tab as well as a selection on the shell, because the strip is an
/// <c>ItemsControl</c> of buttons rather than a control that owns a selection — and a button has no
/// <c>:selected</c> pseudo-class to style against. The shell writes it; nothing else does.
/// </remarks>
[ObservableProperty]
private bool isSelected;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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,330 @@
'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 SERVER_SESSION_ACTIVATED = 4;
const SERVER_SESSION_REMOVED = 5;
const CLIENT_INPUT = 1;
const CLIENT_ACKNOWLEDGE = 2;
const CLIENT_RESIZE = 3;
const HEADER_LENGTH = 5;
const SCROLLBACK_LINES = 5000;
/*
The way out of the terminal, for someone using only a keyboard.
It has to be handled here rather than by the host: once this page's window owns Win32 focus, the
host's Avalonia window receives no key events at all, so nothing on that side could hear a shortcut.
Ctrl+Shift+F6 rather than Escape. F6 is the Windows convention for moving to the next pane, but a
bare F6 is a real terminal key that TUIs bind — as is Escape, which vim alone rules out. Ctrl+Shift
is the range terminal emulators conventionally keep for themselves and never forward to the remote,
so qualifying F6 with it keeps the convention without taking a key away from the remote shell.
*/
const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
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);
}
/**
* Asks the host to take keyboard focus back.
*
* Optional by design: the bridge only exists under a real embedded WebView, and this page is also
* openable in a plain browser for debugging, where there is no host to ask.
*/
function releaseFocusToHost() {
window.chrome?.webview?.postMessage(RELEASE_FOCUS_MESSAGE);
}
/**
* Swallows the release-focus shortcut so it never reaches the remote.
*
* Returning false stops xterm processing the event, which is what keeps the chord from being encoded
* and written to the pty.
*/
function handleKey(event) {
if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && event.key === 'F6') {
releaseFocusToHost();
return false;
}
return true;
}
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.attachCustomKeyEventHandler(handleKey);
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();
}
}
// Below this, a pane is not being looked at — it is minimised or dragged to nothing. Fitting anyway would
// be actively harmful rather than merely useless: the fit addon floors its proposal at 2 columns by 1 row,
// so a degenerate viewport reflows the *remote* pty to 2x1 through window-change, and the wrapped
// scrollback that produces cannot be recovered when the pane comes back. A guard rather than a fix for one
// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
// once splits land.
//
// It is *not* what protects the vault's lock screen, which an earlier version of this comment claimed.
// Collapsing the host's WebView hides a native child window without resizing it, so this page's viewport
// does not change, no observer fires and this function is never called — measured with a live shell, and
// confirmed by removing the guard and finding the lock cycle equally clean. See docs/platform-flags.md.
const MINIMUM_FITTABLE_PIXELS = 40;
function resize(session, sessionId) {
const pane = session.pane;
if (pane.clientWidth < MINIMUM_FITTABLE_PIXELS || pane.clientHeight < MINIMUM_FITTABLE_PIXELS) {
return;
}
// 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_ACTIVATED: {
const session = sessions.get(sessionId);
// Ignored for a pane that does not exist. The host sends this when a tab is selected, and a tab
// whose session ended still has its pane — but one the host knows about and this page has not
// created yet cannot be shown, and inventing an empty terminal for it would be worse than waiting
// for the SessionOpened frame that is already on its way.
if (!session) {
break;
}
activate(sessionId);
// Refitted on activation, not only on resize. A hidden pane has no layout, so every resize while
// it was hidden was skipped by the guard in resize() — meaning it comes back holding whatever
// geometry it had when it was last visible, and the remote pty is still sized to match.
resize(session, sessionId);
break;
}
case SERVER_SESSION_REMOVED: {
const session = sessions.get(sessionId);
if (!session) {
break;
}
/*
The tab is gone, so the pane goes with it — and this is the only place that is true. A shell that
ended on its own keeps its pane, because the last thing the remote said is usually why it ended;
a tab the user closed has nothing left to read.
term.dispose() is what actually matters. It releases the WebGL context, and a browser hands out
about sixteen of those: without this, a day of opening and closing terminals ends with panes that
cannot get a renderer, and nothing outside this page would ever say why.
*/
session.term.dispose();
session.pane.remove();
sessions.delete(sessionId);
setStatus('');
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();
+28
View File
@@ -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
View File
@@ -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
+344
View File
@@ -0,0 +1,344 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Avalonia": {
"type": "Direct",
"requested": "[12.1.1, )",
"resolved": "12.1.1",
"contentHash": "o8pZ1oE9AQ6gklpGM0lnOBp/JlVH0J/0mYszBf0GsSAcEnzHNCLM9NnrPZwKu4j2q9oNbVHYzzEPkszQeuqaKw==",
"dependencies": {
"Avalonia.BuildServices": "11.3.2",
"Avalonia.Remote.Protocol": "12.1.1",
"MicroCom.Runtime": "0.11.6"
}
},
"CommunityToolkit.Mvvm": {
"type": "Direct",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.137, )",
"resolved": "3.0.137",
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
},
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
"type": "Direct",
"requested": "[5.6.0, )",
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"Avalonia.BuildServices": {
"type": "Transitive",
"resolved": "11.3.2",
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
},
"Avalonia.Remote.Protocol": {
"type": "Transitive",
"resolved": "12.1.1",
"contentHash": "0u77tnOwJnHtVLu+WBY7T56fN9W8n7++Uq9kHW6J+bfv5y13WZUMVS+PBzoBe32taYvz/oSomDGO1V41AHaFcQ=="
},
"MicroCom.Runtime": {
"type": "Transitive",
"resolved": "0.11.6",
"contentHash": "NdNWGDiZ6eS/Mf/9+QHR91cj1K7Hy+PX9yrHI/zM7xFYuj9IWT2uxtB6sCHjrnxAeLV9fut1R6zHDUGKX6f9lQ=="
},
"Microsoft.Data.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.EntityFrameworkCore.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
},
"Microsoft.EntityFrameworkCore.Analyzers": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
},
"Microsoft.EntityFrameworkCore.Sqlite.Core": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==",
"dependencies": {
"Microsoft.Data.Sqlite.Core": "10.0.10",
"Microsoft.EntityFrameworkCore.Relational": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyModel": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10",
"SQLitePCLRaw.core": "2.1.11"
}
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
"Microsoft.Extensions.Options": "10.0.10",
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "10.0.10",
"Microsoft.Extensions.Logging.Abstractions": "10.0.10",
"Microsoft.Extensions.Options": "10.0.10"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
"Microsoft.Extensions.Primitives": "10.0.10"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
},
"dodossh.client.api": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Auth": "[1.0.0, )",
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )"
}
},
"dodossh.client.auth": {
"type": "Project"
},
"dodossh.client.domain": {
"type": "Project"
},
"dodossh.client.session": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Api": "[1.0.0, )",
"DodoSSH.Client.Auth": "[1.0.0, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Ssh": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Client.Sync": "[1.0.0, )"
}
},
"dodossh.client.ssh": {
"type": "Project",
"dependencies": {
"SSH.NET": "[2025.1.0, )"
}
},
"dodossh.client.storage": {
"type": "Project",
"dependencies": {
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )",
"EFCore.NamingConventions": "[10.0.1, )",
"Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )"
}
},
"dodossh.client.sync": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Api": "[1.0.0, )",
"DodoSSH.Client.Domain": "[1.0.0, )",
"DodoSSH.Client.Storage": "[1.0.0, )",
"DodoSSH.Contracts": "[1.0.0, )",
"DodoSSH.Crypto": "[1.0.0, )"
}
},
"dodossh.client.terminal": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.client.transfer": {
"type": "Project",
"dependencies": {
"DodoSSH.Client.Ssh": "[1.0.0, )"
}
},
"dodossh.contracts": {
"type": "Project"
},
"dodossh.crypto": {
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
"requested": "[10.0.1, )",
"resolved": "10.0.1",
"contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
"Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
}
},
"libsodium": {
"type": "CentralTransitive",
"requested": "[1.0.22, )",
"resolved": "1.0.22",
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
},
"Microsoft.EntityFrameworkCore": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
"Microsoft.EntityFrameworkCore.Analyzers": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10"
}
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10"
}
},
"Microsoft.EntityFrameworkCore.Sqlite": {
"type": "CentralTransitive",
"requested": "[10.0.10, )",
"resolved": "10.0.10",
"contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10",
"Microsoft.Extensions.Caching.Memory": "10.0.10",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
"Microsoft.Extensions.DependencyModel": "10.0.10",
"Microsoft.Extensions.Logging": "10.0.10",
"SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
"SQLitePCLRaw.core": "2.1.11"
}
},
"NSec.Cryptography": {
"type": "CentralTransitive",
"requested": "[26.4.0, )",
"resolved": "26.4.0",
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
"dependencies": {
"libsodium": "[1.0.22, 1.0.23)"
}
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==",
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.12",
"SQLitePCLRaw.provider.e_sqlite3": "2.1.12"
}
},
"SQLitePCLRaw.core": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg=="
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
},
"SQLitePCLRaw.provider.e_sqlite3": {
"type": "CentralTransitive",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==",
"dependencies": {
"SQLitePCLRaw.core": "2.1.12"
}
},
"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"
}
}
}
}
}