Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef12e8cc99 | ||
|
|
963cb7f670 | ||
|
|
68964d8a34 | ||
|
|
6934efb1e6 | ||
|
|
e252d337d1 | ||
|
|
fdea3911c1 | ||
|
|
10f80bded1 | ||
|
|
281f849086 | ||
|
|
25407756c3 | ||
|
|
a763f4b113 | ||
|
|
93e35a0095 | ||
|
|
b80bf23341 | ||
|
|
8c58e5a558 |
@@ -0,0 +1,68 @@
|
||||
<Project>
|
||||
|
||||
<!--
|
||||
◆ THE TRIMMER'S VERSION IS PINNED HERE BECAUSE OTHERWISE THE LOCK FILES ARE NOT LOCKED.
|
||||
|
||||
Microsoft.NET.ILLink.Tasks is not referenced by anything in this repository. The SDK adds it
|
||||
on its own to any project that sets IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
|
||||
DodoSSH.Crypto do, and the Android head gets it from trimming being on by default there — and
|
||||
the version it asks for is whatever the running SDK happens to bundle. That version lives in
|
||||
the SDK's own Microsoft.NETCoreSdk.BundledVersions.props, as a KnownILLinkPack item.
|
||||
|
||||
Which makes it a dependency whose version is a property of the toolchain rather than of this
|
||||
repository, and that is the whole problem: packages.lock.json records it as a Direct reference
|
||||
with a requested range, so the lock file silently means "whichever SDK last ran a restore".
|
||||
global.json says rollForward: latestMinor, so CI's setup-dotnet installs the newest 10.x SDK
|
||||
that exists on the day it runs. The moment .NET ships a servicing release, CI's SDK asks for a
|
||||
version the committed lock files do not have, and the locked-mode restore in ci.yml fails with
|
||||
NU1004 before a single file is compiled.
|
||||
|
||||
That is not hypothetical. It closed the whole pipeline: main's run 125 and every open pull
|
||||
request went red together, on
|
||||
|
||||
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
|
||||
from [10.0.10, ) to [10.0.11, ).
|
||||
|
||||
with nothing in any of those commits touching a package. .NET had shipped SDK 10.0.400, which
|
||||
bundles ILLink 10.0.11 where 10.0.302 bundled 10.0.10, and setup-dotnet installed it the next
|
||||
time anything ran.
|
||||
|
||||
Worse than the outage is the shape of the repair without this pin. Regenerating the lock files
|
||||
holds only until the next servicing release, and it cannot be done from a machine whose newest
|
||||
SDK is older than the runner's: a restore on 10.0.302 writes 10.0.10 straight back and re-breaks
|
||||
CI, so the recorded version becomes a fact about whoever ran restore last rather than about this
|
||||
repository. That is exactly the state locking exists to prevent, and it is not a hypothetical
|
||||
either — every SDK installed on the machine this pin was written on tops out at 10.0.302.
|
||||
|
||||
Pinning it makes the recorded version a decision this repository made, reviewable in a diff
|
||||
like every other version in Directory.Packages.props, and identical on every machine whatever
|
||||
SDK it has. Moving it is then a deliberate edit here plus a regenerated lock file, which is the
|
||||
same ceremony any other dependency bump gets.
|
||||
|
||||
It is an Update on the SDK's item rather than a PackageVersion in Directory.Packages.props, and
|
||||
it has to be: the reference is implicit, so the SDK supplies the version itself and central
|
||||
package management never gets asked. ProcessFrameworkReferences reads @(KnownILLinkPack) when
|
||||
it runs, which is why this lives in Directory.Build.targets — the item does not exist yet while
|
||||
Directory.Build.props is being evaluated.
|
||||
|
||||
Keep this within a patch or two of the runtime the SDK ships. It is the trimming analyzer and
|
||||
the ILLink task, so a small skew is harmless, but a version far behind the framework being
|
||||
analysed is a real way to miss a trim warning.
|
||||
-->
|
||||
<Target Name="PinTheILLinkPackVersion" BeforeTargets="ProcessFrameworkReferences">
|
||||
<!--
|
||||
Inside a target, and not for tidiness. The SDK ships one KnownILLinkPack per target framework
|
||||
and they all share the identity "Microsoft.NET.ILLink.Tasks", so the TargetFramework metadata
|
||||
is the only thing telling net10.0's entry from net8.0's. A condition on %(...) is item
|
||||
batching, which MSBuild permits in a target and rejects during evaluation with MSB4191 — so
|
||||
an ItemGroup at the top of this file cannot express "only the net10.0 one" at all, and the
|
||||
unconditioned Update it would have to become rewrites every framework's entry.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<KnownILLinkPack Update="Microsoft.NET.ILLink.Tasks"
|
||||
Condition="'%(TargetFramework)' == 'net10.0'"
|
||||
ILLinkPackVersion="10.0.11" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -88,8 +88,12 @@
|
||||
-->
|
||||
<PackageVersion Include="NSec.Cryptography" Version="26.4.0" />
|
||||
<PackageVersion Include="libsodium" Version="1.0.22" />
|
||||
<!-- Managed differential oracle for the crypto test suite only. -->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||
<!--
|
||||
Managed differential oracle for the crypto test suite only. Also what SSH.NET pulls in,
|
||||
so central transitive pinning makes this pin its floor too: 2.7.0 is what SSH.NET
|
||||
2026.0.0 asks for, and pinning below that is a downgrade error rather than a preference.
|
||||
-->
|
||||
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Label="Desktop client">
|
||||
<!--
|
||||
@@ -98,8 +102,14 @@
|
||||
agent forwarding (needs an upstream change; de-scoped from v1) and being handed a
|
||||
pre-connected Stream — it performs its own socket connect, which is why the relay and
|
||||
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
|
||||
|
||||
2026.0.0 is the fix for GHSA-q939-rpr3-3284, a path traversal in ScpClient.Download's
|
||||
recursive mode that trusts server-supplied names (everything up to and including
|
||||
2025.1.0 is affected). Nothing here uses ScpClient — transfers go through SftpClient —
|
||||
so this was an upgrade on principle rather than an exposure, and the release notes
|
||||
list no breaking changes over 2025.1.0.
|
||||
-->
|
||||
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
|
||||
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
|
||||
<!--
|
||||
The S3 client, for buckets as a remote in the file browser. First-party, Apache-2.0, and
|
||||
managed only — no native assets — which is the bar this file sets for anything that gets
|
||||
@@ -211,5 +221,20 @@
|
||||
mistakes that cause real authorization holes.
|
||||
-->
|
||||
<PackageVersion Include="WireMock.Net" Version="2.13.0" />
|
||||
<!--
|
||||
A JavaScript interpreter, in managed code, so that terminal.js can be tested as the file that
|
||||
ships rather than as a transcription of it into C#. Test-only and referenced by exactly one
|
||||
project; nothing in src depends on it.
|
||||
|
||||
Chosen over shelling out to node, which is the obvious alternative and needs node present
|
||||
wherever the suite runs — CI installs one already, but a test that only runs when a separate
|
||||
command is remembered is a test that stops being run. Chosen over a headless browser for the
|
||||
same reason several times over.
|
||||
|
||||
What it does not buy: Jint is not Chromium, so this proves the page's own logic and nothing
|
||||
about how WebView2 or Android's WebView behave. That line is drawn in RendererPage's remark and
|
||||
picked up by docs/manual-checks.md 1.10 and 11.12a.
|
||||
-->
|
||||
<PackageVersion Include="Jint" Version="4.16.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+52
-1
@@ -171,6 +171,32 @@ bound to the same side of `IsImportOpen`. SettingsNav lighting a different row w
|
||||
over the importer would be a guard added to `OnKeyDown` for `IsSettingsMode` that the design never asked for
|
||||
and this application's own quick-connect card was built to reach past.
|
||||
|
||||
### 1.10 A terminal left alone for a long time is still a terminal · **needs an hour, or a debugger**
|
||||
|
||||
Open a shell, leave the terminal for another screen — HOSTS, FILES, anything — and leave the application
|
||||
alone for long enough that the window has been in the background for the better part of an hour. Locking the
|
||||
machine or letting it sleep counts and is the easier way to get there. Come back and click the session's
|
||||
tab.
|
||||
|
||||
**Pass:** the pane is exactly where it was and takes input straight away. If anything is shown at all it is
|
||||
`Reconnecting the terminal view…` for a moment, in the second or so before the socket is back — never a
|
||||
status that is still there after that.
|
||||
|
||||
**Both of the page's own rules here are covered by `RendererReconnectionTests`**, which runs `terminal.js`
|
||||
itself in a fake browser — so a failure of this check is more likely to be the WebView behaving unlike that
|
||||
fake than the page's logic being wrong. That is exactly the division: the test owns the logic, this owns the
|
||||
platform.
|
||||
|
||||
**Failure means:** a banner that stays up is the page's retry not running. It is a `setTimeout` chain, and a
|
||||
chain is what a WebView is entitled to throttle or freeze while nobody is looking at the page; `terminal.js`
|
||||
answers that with wake-ups on `visibilitychange`, `focus` and `online`, none of which can be throttled,
|
||||
plus a watchdog for a handshake that never finishes. A banner that flickers on and off every second or two
|
||||
instead is the opposite fault — a reconnect loop, in which each attempt displaces the socket before it
|
||||
through `TerminalDataPlane.UpgradeAsync`'s takeover and the displaced socket's close schedules the next.
|
||||
That is what the "is this still the page's socket" guard in `connect()` exists to stop. A pane that takes no
|
||||
input while the banner is *clear* is neither: the socket is open and dead, which nothing on this page can
|
||||
currently see — see the note at the end of 11.12a.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Known Hosts as its own page
|
||||
@@ -1797,6 +1823,29 @@ worse than saying nothing: the banner exists so this is never silently wrong. If
|
||||
banner is there, the session's credit window was not reset on reattach and the shell is frozen behind it —
|
||||
see `TerminalWorkspace.ReplayAfterAttachAsync`.
|
||||
|
||||
### 11.12a Coming back to a terminal screen left alone for a long while
|
||||
|
||||
The same shape as 11.12 and a different trigger: rather than backgrounding the app, stay in it. With a shell
|
||||
open, leave the terminal for HOSTS, FILES or MORE — which collapses the renderer to GONE, so the page is
|
||||
hidden by Chromium's reckoning — and leave the phone alone for at least ten minutes with the screen off.
|
||||
Then come back to the app and to the terminal.
|
||||
|
||||
**Pass:** the pane is there and takes input at once, or reconnects visibly within about a second of the
|
||||
screen appearing. `Reconnecting the terminal view…` on the way in is fine; still being there once the
|
||||
terminal has been on screen for a couple of seconds is not.
|
||||
|
||||
**Failure means:** the page's retry did not survive being hidden. A hidden WebView has its timers throttled
|
||||
— once a minute after five minutes hidden — and a renderer that was frozen or reclaimed runs none of them,
|
||||
which is why `terminal.js` does not rely on the timer alone: `visibilitychange` is the event that says the
|
||||
screen is back, and it reconnects immediately rather than waiting to be asked twice. Ten minutes is chosen
|
||||
to clear the five-minute threshold with room to spare.
|
||||
|
||||
**Not covered by either check, and worth knowing:** a socket that is *open and dead* — the connection gone
|
||||
without either end noticing, which a suspended renderer can leave behind — shows no banner at all, because
|
||||
`readyState` still reads OPEN and nothing on this page probes further. The symptom is a terminal that looks
|
||||
connected and swallows what is typed. If that is ever seen, it is a different bug from this one and needs a
|
||||
liveness probe rather than a faster retry.
|
||||
|
||||
---
|
||||
|
||||
## Phase 12 — Shared vaults: the operations that span two accounts
|
||||
@@ -2424,7 +2473,9 @@ script warns rather than failing when that is legitimate, which is the first rel
|
||||
### 16.7 The update arrives, and the restart lands in it · **the whole point of the work**
|
||||
|
||||
With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours.
|
||||
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours. Closing and
|
||||
reopening the application does the same thing without the button: the first pass of the loop runs at launch,
|
||||
so a client started after a release finds it without anybody asking.
|
||||
|
||||
**Pass:** the progress bar moves, the banner appears above the status bar, and — the part to actually watch
|
||||
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
|
||||
|
||||
@@ -984,7 +984,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -1044,9 +1044,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -1140,11 +1140,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,12 +87,27 @@
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Two readings stacked against the two on the left, and in the same order: what this row
|
||||
IS above, how it is reached below.
|
||||
|
||||
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
|
||||
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
|
||||
The palette searches every readable vault at once, so without it two hosts a team and a
|
||||
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
|
||||
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
|
||||
vault name would otherwise take its width out of the host name beside it.
|
||||
|
||||
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
|
||||
connect here is SSH, so printing that word would be a constant dressed up as a reading;
|
||||
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
|
||||
-->
|
||||
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
|
||||
HorizontalAlignment="Right" MaxWidth="150"
|
||||
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
|
||||
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
|
||||
FontSize="11" HorizontalAlignment="Right" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -405,7 +405,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -465,9 +465,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -567,11 +567,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,14 +55,14 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -81,11 +81,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -187,9 +187,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -289,11 +289,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2141,8 +2141,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
/// <remarks>
|
||||
/// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one
|
||||
/// that merely contains it, and both come before a match found only in the address. Typing three
|
||||
/// characters of a name people use daily should not put that host third.
|
||||
/// that merely contains it, both come before a match found only in the address, and all three come
|
||||
/// before one found only in the vault's name. Typing three characters of a name people use daily should
|
||||
/// not put that host third. See <see cref="Rank"/>.
|
||||
/// </remarks>
|
||||
private void RefreshSearchResults()
|
||||
{
|
||||
@@ -2175,6 +2176,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(HasSearchResults));
|
||||
}
|
||||
|
||||
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The vault comes last, and only when the row prints it.</b> Its name is the widest of the three
|
||||
/// readings — one word can match every host in a team's vault at once — so a host whose own name or
|
||||
/// address answers the query has to come first, or typing a machine's name would bury it under everybody
|
||||
/// else's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="HostRowViewModel.HasVaultBadge"/> rather than the name itself, so this matches exactly what
|
||||
/// the row shows: a session holding one vault prints no vault on any row, and matching a name nothing
|
||||
/// displays would answer a query with a list whose reason for existing is invisible — type "personal"
|
||||
/// there and every host comes back for no reason anybody can see.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static int Rank(HostRowViewModel host, string query)
|
||||
{
|
||||
if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
|
||||
@@ -2187,8 +2203,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return 1;
|
||||
}
|
||||
|
||||
return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|
||||
? 2
|
||||
if (host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
return host.HasVaultBadge
|
||||
&& host.VaultName.Contains(query, StringComparison.CurrentCultureIgnoreCase)
|
||||
? 3
|
||||
: int.MaxValue;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,16 +74,6 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
|
||||
|
||||
/// <summary>How long to wait before the first pass.</summary>
|
||||
/// <remarks>
|
||||
/// A delay, where <c>VaultViewModel</c>'s sync loop runs a pass immediately. The difference is what the
|
||||
/// user is waiting for: a vault edited on another machine should be current by the time they have
|
||||
/// finished reading the list, whereas nothing anybody does in their first two minutes depends on an
|
||||
/// update. Launch is already contending for the network and the CPU with a schema migration, a resumed
|
||||
/// sign-in and a first sync, at the one moment somebody is watching the window.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly IUpdateChannel updates;
|
||||
private readonly ClientSettingsStore settings;
|
||||
private readonly TimeProvider clock;
|
||||
@@ -242,16 +232,40 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
||||
loop = RunCheckLoopAsync(lifetime.Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The first pass runs at launch, with no delay in front of it.</b> It used to wait two minutes, on
|
||||
/// the argument that nothing anybody does in their first two minutes depends on an update and launch is
|
||||
/// already contending for the network with a schema migration, a resumed sign-in and a first sync. What
|
||||
/// that argument leaves out is the run that is over before the two minutes are: a client opened to reach
|
||||
/// one host and closed again never checks at all, and a machine used that way is exactly the one ADR
|
||||
/// 0011 warns about — quietly a year behind, with the mechanism to fix it switched on and never reached.
|
||||
/// Every start now asks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The yield is what keeps that off the launch path.</b> <see cref="Start"/> is called from
|
||||
/// <c>MainWindowViewModel.StartAsync</c> before the migration, so running the pass inline would put
|
||||
/// whatever the channel does before its own first await — Velopack reads the install layout from disk —
|
||||
/// between the user and their window. Yielding hands the rest of the launch back and lets the check run
|
||||
/// in a later turn, which is the same moment in every sense that matters and none of the cost.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RunCheckLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true);
|
||||
await Task.Yield();
|
||||
|
||||
using var timer = new PeriodicTimer(CheckInterval, clock);
|
||||
|
||||
do
|
||||
{
|
||||
// Task.Yield takes no token, unlike the delay it replaced, so a shutdown that lands while
|
||||
// the loop is waiting to be handed back the thread has to be observed here rather than
|
||||
// only at the next tick. Otherwise an application closed during launch spends its last
|
||||
// moment asking a release channel about a build it is not going to run.
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
await CheckOnceAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true));
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
|
||||
conditions that are hardest to reproduce.
|
||||
|
||||
3. The socket reconnects itself, forever, with backoff. This page's WebView is routinely killed
|
||||
3. The socket reconnects itself, forever, with backoff — and on being looked at again, which is not
|
||||
the same thing and is the half a timer cannot cover. This page's WebView is routinely killed
|
||||
and reloaded by Android under memory pressure or simply for being backgrounded, so "the
|
||||
socket closed" is an ordinary event here, not the end of the terminal's life — see connect().
|
||||
A reloaded page starts with an empty session map, so createSession is idempotent (a session
|
||||
@@ -52,6 +53,22 @@ const SCROLLBACK_LINES = 5000;
|
||||
const RECONNECT_INITIAL_DELAY_MS = 1000;
|
||||
const RECONNECT_MAX_DELAY_MS = 5000;
|
||||
|
||||
/*
|
||||
How long a socket is given to finish its handshake before it is treated as a failure.
|
||||
|
||||
A socket that cannot connect normally says so and says it quickly — 'error' then 'close', within a
|
||||
millisecond or two of a loopback refusal. The case this covers is the one that says nothing: an attempt
|
||||
parked in CONNECTING with no event ever coming, which is what a WebSocket opened by a renderer that is
|
||||
then suspended, or one whose handshake the host never answers, leaves this page holding.
|
||||
|
||||
Without the watchdog that state is terminal, and quietly so. Every retry in this file is scheduled by a
|
||||
close or an error, so an attempt that produces neither schedules nothing: the banner says the view is
|
||||
reconnecting for the rest of the page's life while nothing whatever is reconnecting. Five seconds is
|
||||
generous against a handshake that ordinarily takes about a millisecond, and short against somebody
|
||||
waiting on their terminal to come back.
|
||||
*/
|
||||
const HANDSHAKE_TIMEOUT_MS = 5000;
|
||||
|
||||
/*
|
||||
Styled like the SESSION_CLOSED banner (matching \x1b[38;5;244, the same dim grey), but written by
|
||||
createSession's caller rather than by createSession itself: only a *replay* landing on a pane that
|
||||
@@ -84,14 +101,57 @@ 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}>} */
|
||||
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement, notice: string}>} */
|
||||
const sessions = new Map();
|
||||
|
||||
/** @type {WebSocket | null} */
|
||||
let socket = null;
|
||||
|
||||
function setStatus(text) {
|
||||
statusBanner.textContent = text ?? '';
|
||||
/** Whose pane is showing, or null before there is one — see activate(). */
|
||||
let activeSessionId = null;
|
||||
|
||||
/*
|
||||
── THE BANNER BELONGS TO ONE PANE AT A TIME ─────────────────────────────────────────────────────────
|
||||
There is one #status element for the whole page, because there is one page for every terminal: the
|
||||
panes are stacked in the same box and all but the active one are hidden. What goes in it comes from
|
||||
two sources that are not the same size, and the difference is the whole of this.
|
||||
|
||||
The socket's troubles are the page's. There is a single socket behind every pane, so "the view is
|
||||
reconnecting" is true of whatever is on screen and true of the panes behind it.
|
||||
|
||||
A session's last words are not. "The remote closed the session." is a fact about one terminal and says
|
||||
nothing whatever about the others — so it is held on the session and drawn only while that session's
|
||||
pane is the one showing. Written straight into the shared element, which is what this used to do, it
|
||||
outlived the tab it described: switching to a live terminal left the dead one's epitaph sitting under
|
||||
it, and opening or closing any other tab wiped the message whether or not it belonged to that tab.
|
||||
|
||||
The socket's half wins when both have something to say: a page whose socket is down is not showing
|
||||
live output on any pane, which makes what became of one session the less urgent of the two.
|
||||
*/
|
||||
let transportStatus = statusBanner.textContent ?? '';
|
||||
|
||||
function renderStatus() {
|
||||
const notice = activeSessionId === null ? '' : sessions.get(activeSessionId)?.notice ?? '';
|
||||
|
||||
statusBanner.textContent = transportStatus || notice;
|
||||
}
|
||||
|
||||
/** Says something about the socket, which every pane shares. */
|
||||
function setTransportStatus(text) {
|
||||
transportStatus = text ?? '';
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
/** Records what became of one session, to be drawn only while that session's pane is showing. */
|
||||
function setSessionNotice(sessionId, text) {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.notice = text ?? '';
|
||||
renderStatus();
|
||||
}
|
||||
|
||||
/** Builds a frame: opcode, big-endian session id, then payload. */
|
||||
@@ -292,7 +352,7 @@ function createSession(sessionId) {
|
||||
|
||||
term.onResize(() => sendResize(sessionId, term, pane));
|
||||
|
||||
const session = { term, fit, pane };
|
||||
const session = { term, fit, pane, notice: '' };
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
activate(sessionId);
|
||||
@@ -306,6 +366,11 @@ function activate(sessionId) {
|
||||
session.pane.dataset.active = String(id === sessionId);
|
||||
}
|
||||
|
||||
// The banner follows the pane. Whatever this session has to say for itself replaces whatever the
|
||||
// session that was showing had to say for its own, which is the point of holding it per session.
|
||||
activeSessionId = sessionId;
|
||||
renderStatus();
|
||||
|
||||
const active = sessions.get(sessionId);
|
||||
if (active) {
|
||||
active.term.focus();
|
||||
@@ -373,7 +438,10 @@ function handleFrame(buffer) {
|
||||
session.term.write(REPLAY_BANNER);
|
||||
}
|
||||
|
||||
setStatus('');
|
||||
// This session's own line, and only this one's: a session that is open has nothing to say about
|
||||
// how it ended. The page's own "Connecting…" is cleared by the socket opening, which happens
|
||||
// before any frame can arrive.
|
||||
setSessionNotice(sessionId, '');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -426,7 +494,14 @@ function handleFrame(buffer) {
|
||||
session.pane.remove();
|
||||
sessions.delete(sessionId);
|
||||
|
||||
setStatus('');
|
||||
// The notice went with the session record it was held on, but the page can still be pointing at
|
||||
// the pane that is now gone. Cleared rather than left dangling, so the banner stops describing a
|
||||
// closed tab while the host decides which pane to show next.
|
||||
if (activeSessionId === sessionId) {
|
||||
activeSessionId = null;
|
||||
}
|
||||
|
||||
renderStatus();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -503,14 +578,19 @@ function handleFrame(buffer) {
|
||||
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;
|
||||
if (!session) {
|
||||
// No pane, so there is nothing this page can honestly hang the reason on. It used to go into
|
||||
// the banner anyway, which printed one session's ending underneath whichever pane happened to
|
||||
// be showing at the time.
|
||||
break;
|
||||
}
|
||||
|
||||
setStatus(reason);
|
||||
// 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;
|
||||
|
||||
setSessionNotice(sessionId, reason);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -520,8 +600,47 @@ function handleFrame(buffer) {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
── THE SOCKET COMES BACK BY ITSELF, INCLUDING FOR A PAGE NOBODY HAS LOOKED AT IN AN HOUR ────────────
|
||||
|
||||
Retrying on a timer is the easy half and was the whole of this. The three rules below are what make the
|
||||
retry actually reach a page that has been sitting collapsed — each of them a way this page was found
|
||||
showing "Reconnecting the terminal view…" over a socket that nothing was reconnecting.
|
||||
|
||||
1. ONLY THE CURRENT ATTEMPT'S EVENTS COUNT. connect() abandons whatever socket was attached before it,
|
||||
and an abandoned socket still reports its end: the host aborts it the moment the newcomer upgrades,
|
||||
which is TerminalDataPlane.UpgradeAsync's takeover doing exactly what it is meant to. Counting that
|
||||
close as a fresh failure schedules a retry against a socket that has just succeeded, and the
|
||||
takeover then aborts *that* one, whose close schedules the next — a loop with no fixed point, in
|
||||
which the terminal reconnects every second or so forever and the banner is up for most of it. Every
|
||||
handler below asks whether it is still the page's socket before it does anything.
|
||||
|
||||
2. AN ATTEMPT THAT NEVER FINISHES IS A FAILURE TOO. See HANDSHAKE_TIMEOUT_MS.
|
||||
|
||||
3. COMING BACK IS A REASON TO TRY, NOT ONLY THE CLOCK. The retry is a setTimeout chain, and a chain is
|
||||
precisely what a WebView is entitled to stop running. Chromium throttles timers in a page nobody is
|
||||
looking at — down to once a minute once it has been hidden five minutes — and a renderer that is
|
||||
frozen, or reclaimed and not yet reloaded, runs none of them at all. So the socket drops while nobody
|
||||
is watching, the banner goes up, the retry is scheduled, and the retry is then the one thing not
|
||||
running: coming back shows a terminal that says it is reconnecting and, for as long as that lasts,
|
||||
is not.
|
||||
|
||||
The phone is where this is easiest to reach, because leaving the terminal for another screen
|
||||
collapses its WebView to GONE — see createSession's note on the GPU context — and a WebView with no
|
||||
surface is a page the platform may treat as hidden. Which of the two mechanisms actually bit is not
|
||||
established here, and does not need to be: both end with a timer that will not run, and the fix is
|
||||
not to guess at either but to stop depending on the timer alone.
|
||||
|
||||
Hence the wake-ups at the bottom of this file. What they add is not a faster retry; it is a retry
|
||||
driven by the one thing that cannot be throttled, which is the user arriving.
|
||||
*/
|
||||
|
||||
/** @type {number | null} */
|
||||
let reconnectTimer = null;
|
||||
|
||||
/** @type {number | null} */
|
||||
let handshakeTimer = null;
|
||||
|
||||
let reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
||||
|
||||
/**
|
||||
@@ -535,7 +654,7 @@ function scheduleReconnect() {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Reconnecting the terminal view…');
|
||||
setTransportStatus('Reconnecting the terminal view…');
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
@@ -545,31 +664,112 @@ function scheduleReconnect() {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries the socket again now, if it is down.
|
||||
*
|
||||
* The entry point for "somebody is looking at this page again", and it has to be safe to call as often
|
||||
* as that happens — which on the desktop is every time the window is clicked. A socket that is up makes
|
||||
* this nothing at all.
|
||||
*
|
||||
* An attempt still in CONNECTING is left alone rather than restarted: it may be about to succeed, and
|
||||
* the one that is not is already the handshake watchdog's to give up on.
|
||||
*/
|
||||
function reconnectIfDown() {
|
||||
if (socket !== null
|
||||
&& (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Whatever the timer was going to do, this is doing now. Left pending it would land on top of the
|
||||
// socket this call is about to open, and the takeover that followed is the loop rule 1 describes.
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
// Back to the quick attempt. The wait grew to space out retries during an outage nobody was watching,
|
||||
// and being called at all means somebody is watching now.
|
||||
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const token = root.dataset.token;
|
||||
const url = root.dataset.socket;
|
||||
|
||||
/*
|
||||
Whatever was attached is abandoned here, and closed rather than dropped: the host takes the socket
|
||||
over regardless, but one left open is a connection it has to abort and an event this page then has to
|
||||
ignore. Cleared out of `socket` before the close, so that every handler — including that close,
|
||||
whenever it lands — can already tell the attempt is no longer the page's.
|
||||
*/
|
||||
const abandoned = socket;
|
||||
socket = null;
|
||||
abandoned?.close();
|
||||
|
||||
// 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';
|
||||
const pending = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
|
||||
pending.binaryType = 'arraybuffer';
|
||||
socket = pending;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
setStatus('');
|
||||
/** Whether this attempt is still the page's, rather than one a later connect() has replaced. */
|
||||
const isCurrent = () => socket === pending;
|
||||
|
||||
const forgetHandshakeTimer = () => {
|
||||
if (handshakeTimer !== null) {
|
||||
clearTimeout(handshakeTimer);
|
||||
handshakeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
forgetHandshakeTimer();
|
||||
|
||||
handshakeTimer = setTimeout(() => {
|
||||
handshakeTimer = null;
|
||||
|
||||
// Still CONNECTING with nothing on its way. Only the retry is arranged here; abandoning the attempt
|
||||
// is left to the connect() that retry runs, which is the one place a socket is replaced.
|
||||
if (isCurrent() && pending.readyState === WebSocket.CONNECTING) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}, HANDSHAKE_TIMEOUT_MS);
|
||||
|
||||
pending.addEventListener('open', () => {
|
||||
// A replaced attempt cannot reach here — connect() closes what it abandons, and a socket closed
|
||||
// while connecting never opens — so this is a guard against the ordering rather than a live case.
|
||||
if (!isCurrent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
forgetHandshakeTimer();
|
||||
setTransportStatus('');
|
||||
|
||||
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
|
||||
// attempts within one outage, reset once the outage is actually over.
|
||||
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => handleFrame(event.data));
|
||||
pending.addEventListener('message', (event) => {
|
||||
if (isCurrent()) {
|
||||
handleFrame(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
// Both close and error retry. They are not the same event on every failure — a socket that never
|
||||
// opens can fire only 'error', one that opens and later drops fires only 'close' — and the host
|
||||
// side of this same problem (TerminalDataPlane.UpgradeAsync's takeover) is exactly why retrying is
|
||||
// safe: whichever attempt eventually reaches the host, a fresh valid upgrade always wins the socket.
|
||||
socket.addEventListener('close', scheduleReconnect);
|
||||
socket.addEventListener('error', scheduleReconnect);
|
||||
const retry = () => {
|
||||
if (isCurrent()) {
|
||||
forgetHandshakeTimer();
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
pending.addEventListener('close', retry);
|
||||
pending.addEventListener('error', retry);
|
||||
}
|
||||
|
||||
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
|
||||
@@ -582,4 +782,30 @@ new ResizeObserver(() => {
|
||||
|
||||
window.addEventListener('beforeunload', () => socket?.close());
|
||||
|
||||
/*
|
||||
The ways this page finds out somebody is looking at it again — rule 3 at the top of the transport
|
||||
section. All three end in the same check, and that check is what makes them safe to be as noisy as they
|
||||
are: with a healthy socket every one of them does nothing.
|
||||
|
||||
'visibilitychange' is the phone's. A WebView collapsed to GONE is a hidden page, so coming back to the
|
||||
terminal screen is the event that says so, and it is the same event Chromium lifts its own throttling
|
||||
on — this page simply does not wait to be asked twice.
|
||||
|
||||
'focus' is the desktop's, where the page is *not* marked hidden while the WebView is collapsed
|
||||
(measured; see docs/platform-flags.md) but a window left in the background for hours has had its timers
|
||||
throttled all the same. It fires when the terminal takes the keyboard back, which is the same gesture.
|
||||
|
||||
'online' is neither head's ordinary case, because the socket is loopback and has nothing to do with the
|
||||
network — but the event does follow a machine coming back from sleep, and a page with a dead socket has
|
||||
no reason to ignore any hint that the world has moved.
|
||||
*/
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
reconnectIfDown();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('focus', reconnectIfDown);
|
||||
window.addEventListener('online', reconnectIfDown);
|
||||
|
||||
connect();
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -267,9 +267,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -369,11 +369,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,10 +304,11 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
|
||||
/// Read at construction rather than lazily: by the time an <see cref="SshNetConnection"/> exists,
|
||||
/// <see cref="SshNetConnectionFactory.ConnectAsync"/> has already awaited <c>client.ConnectAsync</c>, so
|
||||
/// <c>ConnectionInfo</c> is already populated and there is no earlier moment reading it would race. SSH.NET
|
||||
/// types the property as a non-nullable <c>string</c>, so this reads straight through rather than coalescing
|
||||
/// a null that the library's own contract says cannot occur.
|
||||
/// types the property as nullable all the same — it is null before the key exchange completes — so the
|
||||
/// coalesce is here to satisfy that annotation, not because this call site expects to see it. Empty is the
|
||||
/// value this codebase already uses for a cipher that could not be read; see <c>MainWindowViewModel.NullIfEmpty</c>.
|
||||
/// </remarks>
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||
|
||||
@@ -24,9 +24,10 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
|
||||
/// <remarks>
|
||||
/// Read at construction, the same way and for the same reason as <c>SshNetConnection.Cipher</c>: this type
|
||||
/// is only ever built after <c>SshNetConnectionFactory.OpenSftpAsync</c> has awaited <c>client.ConnectAsync</c>,
|
||||
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from.
|
||||
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from. The coalesce
|
||||
/// is there for SSH.NET's nullable annotation, which covers the pre-key-exchange window this never sees.
|
||||
/// </remarks>
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory { get; } = homeDirectory;
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "Direct",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
@@ -54,9 +54,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
|
||||
@@ -37,14 +37,14 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -63,11 +63,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,14 +37,14 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -63,11 +63,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1744,9 +1744,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -1847,11 +1847,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -236,6 +236,51 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette searches every vault the session holds a key for, so a row has to say which one it came
|
||||
/// out of. Two machines a team and a person both call <c>prod-db</c> are otherwise two identical rows,
|
||||
/// and Enter takes whichever the ranking happened to put first.
|
||||
/// <para>
|
||||
/// Typed into rather than read off the unfiltered list, because the shared vault's host sorts last — the
|
||||
/// active vault's rows come first — and the list virtualises, so the row this is about might never be
|
||||
/// realised. Narrowing to it also proves the search reaches past the active vault at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AResultSaysWhichVaultItCameOutOf()
|
||||
{
|
||||
await SeedSharedHostAsync();
|
||||
|
||||
await OnThePaletteAsync((palette, window) =>
|
||||
{
|
||||
shell.SearchText = "prod-db";
|
||||
Relayout(window);
|
||||
|
||||
var found = shell.SearchResults.ShouldHaveSingleItem();
|
||||
found.VaultId.ShouldNotBe(session.ActiveVaultId, "the palette reaches past the active vault");
|
||||
|
||||
VisibleTexts(RowFor(palette, found)).ShouldContain("PLATFORM SECRETS", StringComparer.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half of the rule, and the reason the name is a badge rather than a column: a vault named on
|
||||
/// every row of a session that has only one is the same fact repeated, which is noise rather than a
|
||||
/// reading. <c>HostRowViewModel.VaultBadge</c> is empty there, and an empty line has to collapse rather
|
||||
/// than leave a gap above the kind word.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AResultNamesNoVaultWhenThereIsOnlyOneToBeIn()
|
||||
{
|
||||
await OnThePaletteAsync((palette, _) =>
|
||||
{
|
||||
var first = shell.SearchResults[0];
|
||||
|
||||
first.HasVaultBadge.ShouldBeFalse("this fixture's session holds the personal vault alone");
|
||||
VisibleTexts(RowFor(palette, first)).ShouldNotContain("PERSONAL", StringComparer.Ordinal);
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette is a box somebody is expected to start typing into, and for a while it was not: the window
|
||||
/// focused it from the view model's <c>PropertyChanged</c>, which runs before the binding that reveals the
|
||||
@@ -307,6 +352,25 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
.OfType<ListBoxItem>()
|
||||
.First(item => ReferenceEquals(item.DataContext, host));
|
||||
|
||||
/// <summary>What one row actually draws, in order, ignoring the lines that collapsed.</summary>
|
||||
private static List<string> VisibleTexts(Visual row) =>
|
||||
row.GetVisualDescendants()
|
||||
.OfType<TextBlock>()
|
||||
.Where(text => text.IsEffectivelyVisible)
|
||||
.Select(text => text.Text ?? string.Empty)
|
||||
.ToList();
|
||||
|
||||
/// <summary>Runs the layout pass the application's dispatcher would run after the list changed.</summary>
|
||||
/// <remarks>
|
||||
/// Without it the new rows are in the collection but not in the visual tree, so <see cref="RowFor"/>
|
||||
/// finds nothing to look at.
|
||||
/// </remarks>
|
||||
private static void Relayout(Window window)
|
||||
{
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
window.UpdateLayout();
|
||||
}
|
||||
|
||||
private static Point Centre(Visual control, Visual window) =>
|
||||
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||
@@ -325,4 +389,37 @@ public sealed class QuickConnectTests : IAsyncLifetime
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a second, shared vault to the fixture's session and files one host into it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Straight into the session rather than through <c>VaultsViewModel</c>, which is the technique the
|
||||
/// settings pages' suite uses and for the same reason: creating a vault needs a connection, and this
|
||||
/// shell has none. The key is generated on this machine either way, so what the session ends up holding
|
||||
/// is the same thing a real creation leaves behind — see <see cref="StubTeamServer"/>.
|
||||
/// </remarks>
|
||||
private async Task SeedSharedHostAsync()
|
||||
{
|
||||
using var teamServer = new StubTeamServer();
|
||||
|
||||
var shared = await session.CreateTeamVaultAsync(
|
||||
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.SelectedTargetVault =
|
||||
vault.TargetVaults.Single(choice => choice.VaultId == shared.VaultId);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditing.ShouldBeFalse(vault.Status);
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +551,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -663,9 +663,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -771,11 +771,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1765,6 +1765,28 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The palette searches a host's vault name, and only where the row prints it — which a session holding
|
||||
/// one vault never does. Matching it anyway would answer "personal" with the entire keychain, ranked
|
||||
/// behind nothing and explained by nothing on screen. See <c>MainWindowViewModel.Rank</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePalette_DoesNotMatchTheVaultNameWhenThereIsOnlyOneVaultToBeIn()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
vault.VaultName.ShouldBe("Personal", "which is the word this test types");
|
||||
|
||||
shell.ToggleSearchCommand.Execute(null);
|
||||
|
||||
shell.SearchResults.ShouldHaveSingleItem()
|
||||
.HasVaultBadge.ShouldBeFalse("one vault is nothing to tell apart");
|
||||
|
||||
shell.SearchText = "personal";
|
||||
|
||||
shell.SearchResults.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The rail marks where you are, and a terminal is not one of its destinations. Lighting HOSTS while a
|
||||
/// terminal fills the window would point at a screen that is not showing — and the selected tab already
|
||||
|
||||
@@ -403,6 +403,46 @@ public sealed class UpdateFlowTests : IDisposable
|
||||
channel.Checks.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The loop rather than <c>CheckOnceAsync</c>, which is the one thing the rest of this file avoids
|
||||
/// driving — and here it is the whole point, because the claim is about when the first pass happens
|
||||
/// rather than about what it does. The first pass used to wait two minutes, which meant a client opened
|
||||
/// to reach one host and closed again never asked at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It waits on the pass and not on a clock, so there is nothing here to be flaky about: a regression
|
||||
/// that puts a delay back in front of the loop does not fail on a margin, it spins until the suite's own
|
||||
/// cancellation ends it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheFirstPassRunsAtStart_RatherThanOnADelay()
|
||||
{
|
||||
channel.Available = new AvailableUpdate("1.3.0");
|
||||
|
||||
var updates = Build();
|
||||
await using var _ = updates.ConfigureAwait(false);
|
||||
|
||||
updates.Start();
|
||||
|
||||
while (updates.State is not UpdateState.Ready)
|
||||
{
|
||||
Token.ThrowIfCancellationRequested();
|
||||
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
channel.Checks.ShouldBe(1);
|
||||
updates.ReadyVersion.ShouldBe("1.3.0");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Started and disposed with nothing in between, which since the first pass stopped waiting two minutes
|
||||
/// is a race rather than a formality: the loop may be anywhere between its yield and a finished check
|
||||
/// when the cancellation lands. What is asserted is what matters either way — that disposing returns,
|
||||
/// rather than waiting on a pass that will never be allowed to finish.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DisposingStopsTheLoop()
|
||||
{
|
||||
|
||||
@@ -766,6 +766,59 @@ public sealed class VaultSharingTests : IAsyncLifetime
|
||||
vault.Status.ShouldContain("left alone", Case.Insensitive);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The palette reaches every vault the session holds a key for, so the vault's name is one of the things
|
||||
/// somebody can reasonably type into it: "show me the team's machines" is a question a list spanning two
|
||||
/// vaults invites, and until now the only answer was to know one of the machines by name already.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The order is the assertion that matters. A vault name is the widest of the three readings — one word
|
||||
/// matches every host in that vault at once — so the host whose own name carries the word has to come
|
||||
/// first, or typing a machine's name would bury it under everybody else's.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePalette_FindsAHostByItsVaultsName_BehindOneCalledThatItself()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vaults = shell.Vaults;
|
||||
|
||||
await CreateVaultAsync(vaults, "Platform secrets");
|
||||
|
||||
var vault = shell.Vault!;
|
||||
var sharedVaultId = vaults.SelectedVault!.VaultId;
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "platform-gateway";
|
||||
vault.EditorHostname = "gateway.internal";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorSelectedVault =
|
||||
vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditing.ShouldBeFalse(vault.Status);
|
||||
|
||||
shell.ToggleSearchCommand.Execute(null);
|
||||
shell.SearchText = "platform";
|
||||
|
||||
shell.SearchResults
|
||||
.Select(row => row.Label)
|
||||
.ShouldBe(["platform-gateway", "prod-db"], "a host's own name outranks its vault's");
|
||||
|
||||
shell.SearchResults[1].VaultBadge.ShouldBe(
|
||||
"PLATFORM SECRETS", "and the row says what it was found by");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Two hosts for the run above: one in the personal vault under a group, one already in the destination.
|
||||
/// </summary>
|
||||
|
||||
@@ -540,7 +540,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -652,9 +652,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"CommunityToolkit.Mvvm": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -760,11 +760,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"AWSSDK.Core": {
|
||||
@@ -249,9 +249,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -270,11 +270,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -379,9 +379,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -481,11 +481,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "Direct",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
@@ -306,7 +306,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.terminal": {
|
||||
@@ -317,9 +317,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
|
||||
@@ -4,10 +4,44 @@
|
||||
The throughput and backpressure harness the plan requires before any UI exists. Nothing
|
||||
here needs a WebView: the flow control is what is most likely to be wrong, and it is pure
|
||||
logic once ITerminalTransport is a seam.
|
||||
|
||||
And, since RendererReconnectionTests, the renderer's own half of the same protocol — the page
|
||||
that reads these frames, run as a script rather than described in C#. It lives here rather than
|
||||
in a test project of the shell's own because this is where the other end of the socket is
|
||||
tested, and the two halves of "the renderer stays attached" are one mechanism: the host takes
|
||||
a second upgrade over the first, and the page is what makes that second upgrade happen. A
|
||||
project for the shell would still need a reference to this one's subject to say anything.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
A JavaScript engine, so terminal.js can be run as it ships instead of transcribed into C#.
|
||||
See RendererPage for the trade against a node script, and for what an engine that is not
|
||||
Chromium can and cannot prove.
|
||||
-->
|
||||
<PackageReference Include="Jint" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
◆ THE PAGE IS COPIED OUT OF THE SHELL PROJECT, WHICH IS THE ONE ODD THING IN THIS FILE.
|
||||
|
||||
Reaching across for a source file is not something else here does, and the alternatives are
|
||||
worse: referencing DodoSSH.Client.Shell would drag Avalonia into a suite that draws nothing,
|
||||
and a copy of the page checked in beside the tests would be a copy — the thing that quietly
|
||||
stops matching what ships, which is precisely the failure this test exists to catch.
|
||||
|
||||
PreserveNewest rather than Always so that editing the page is what rebuilds it.
|
||||
-->
|
||||
<None Include="../../src/DodoSSH.Client.Shell/WebAssets/terminal.js"
|
||||
Link="Renderer/terminal.js"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
|
||||
<None Include="Renderer/renderer-harness.js" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
'use strict';
|
||||
|
||||
/*
|
||||
The world terminal.js is loaded into by RendererReconnectionTests.
|
||||
|
||||
It fakes exactly what the page's transport touches and nothing else: a WebSocket whose every step is
|
||||
driven from the test, a clock that only moves when a test moves it, and the two DOM objects the page
|
||||
reads at load. There is no Terminal, no fit addon and no WebGL here, because nothing on the reconnection
|
||||
path builds a pane — a test that opens a session will have to add them, and should, rather than this
|
||||
file guessing now at what such a test would want.
|
||||
|
||||
◆ NOTHING HERE MAY DECLARE A NAME terminal.js ALSO DECLARES.
|
||||
|
||||
Both files are evaluated as scripts into the same global scope, and the page's own `const root` against
|
||||
a `var root` here is a SyntaxError before a line of either runs. That is why the page's two elements are
|
||||
`pageRoot` and `pageBanner` below and reached through getElementById, which is how the page reaches them
|
||||
anyway.
|
||||
|
||||
◆ AND THE FAKE SOCKET'S BEHAVIOUR IS THE PART TO GET RIGHT.
|
||||
|
||||
close() is the one method with a rule that is not obvious and that the tests lean on: a socket closed
|
||||
while it is still CONNECTING never fires 'close' at all, per the WebSocket specification, while one
|
||||
closed after it opened does. The page relies on exactly that — connect() closes the attempt it abandons
|
||||
and expects to hear nothing back from it — so a fake that fired 'close' either way would report the loop
|
||||
the page's guards exist to prevent, and one that fired it neither way would hide it.
|
||||
*/
|
||||
|
||||
var clock = { now: 0, next: 1, timers: {} };
|
||||
|
||||
/** Every socket the page has opened, in order, live or dead. */
|
||||
var sockets = [];
|
||||
|
||||
function listeners(target) {
|
||||
target.handlers = {};
|
||||
|
||||
target.addEventListener = function (name, handler) {
|
||||
if (!target.handlers[name]) {
|
||||
target.handlers[name] = [];
|
||||
}
|
||||
|
||||
target.handlers[name].push(handler);
|
||||
};
|
||||
|
||||
target.fire = function (name) {
|
||||
var handlers = target.handlers[name] || [];
|
||||
|
||||
for (var i = 0; i < handlers.length; i++) {
|
||||
handlers[i]({});
|
||||
}
|
||||
};
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function FakeSocket(url, protocols) {
|
||||
this.url = url;
|
||||
this.protocols = protocols;
|
||||
this.readyState = FakeSocket.CONNECTING;
|
||||
this.binaryType = '';
|
||||
|
||||
listeners(this);
|
||||
sockets.push(this);
|
||||
}
|
||||
|
||||
FakeSocket.CONNECTING = 0;
|
||||
FakeSocket.OPEN = 1;
|
||||
FakeSocket.CLOSING = 2;
|
||||
FakeSocket.CLOSED = 3;
|
||||
|
||||
FakeSocket.prototype.send = function () {};
|
||||
|
||||
/** What the page calls. See the note above on why a connecting socket goes quietly. */
|
||||
FakeSocket.prototype.close = function () {
|
||||
if (this.readyState === FakeSocket.CLOSED) {
|
||||
return;
|
||||
}
|
||||
|
||||
var wasConnecting = this.readyState === FakeSocket.CONNECTING;
|
||||
this.readyState = FakeSocket.CLOSED;
|
||||
|
||||
if (!wasConnecting) {
|
||||
this.fire('close');
|
||||
}
|
||||
};
|
||||
|
||||
var pageBanner = { textContent: 'Connecting…' };
|
||||
|
||||
var pageRoot = listeners({
|
||||
dataset: { token: 'test-token', socket: 'ws://127.0.0.1:1/socket' },
|
||||
appendChild: function () {},
|
||||
});
|
||||
|
||||
var document = listeners({
|
||||
hidden: false,
|
||||
getElementById: function (id) { return id === 'root' ? pageRoot : pageBanner; },
|
||||
createElement: function () { return { dataset: {}, style: {}, remove: function () {} }; },
|
||||
});
|
||||
|
||||
var window = listeners({});
|
||||
|
||||
// The page warns through this on paths no reconnection test reaches. Present so that a test which does
|
||||
// reach one fails on its own assertion rather than on a missing global.
|
||||
var console = { warn: function () {}, log: function () {} };
|
||||
|
||||
function setTimeout(callback, delay) {
|
||||
var id = clock.next++;
|
||||
|
||||
clock.timers[id] = { at: clock.now + (delay || 0), callback: callback };
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function clearTimeout(id) {
|
||||
delete clock.timers[id];
|
||||
}
|
||||
|
||||
function ResizeObserver() {
|
||||
this.observe = function () {};
|
||||
}
|
||||
|
||||
// ── What the tests drive the page with ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Runs every timer due within the next `ms`, in the order they fall due.
|
||||
*
|
||||
* One at a time and re-scanned each round rather than collected up front, because a timer's callback
|
||||
* routinely schedules the next one — which is the whole shape of the page's retry — and a snapshot taken
|
||||
* before the first callback ran would miss it.
|
||||
*/
|
||||
function advance(ms) {
|
||||
var target = clock.now + ms;
|
||||
|
||||
for (;;) {
|
||||
var dueId = null;
|
||||
var due = null;
|
||||
|
||||
for (var id in clock.timers) {
|
||||
var timer = clock.timers[id];
|
||||
|
||||
if (timer.at <= target && (due === null || timer.at < due.at)) {
|
||||
due = timer;
|
||||
dueId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (due === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
delete clock.timers[dueId];
|
||||
clock.now = due.at;
|
||||
due.callback();
|
||||
}
|
||||
|
||||
clock.now = target;
|
||||
}
|
||||
|
||||
/** How many sockets the page has opened since it loaded. */
|
||||
function attempts() {
|
||||
return sockets.length;
|
||||
}
|
||||
|
||||
/** What the one status element says — the banner the user sees. */
|
||||
function banner() {
|
||||
return pageBanner.textContent;
|
||||
}
|
||||
|
||||
/** Whether that attempt has been closed, by the page or by the far end. */
|
||||
function isClosed(index) {
|
||||
return sockets[index].readyState === FakeSocket.CLOSED;
|
||||
}
|
||||
|
||||
/** The host accepted the upgrade. */
|
||||
function accept(index) {
|
||||
sockets[index].readyState = FakeSocket.OPEN;
|
||||
sockets[index].fire('open');
|
||||
}
|
||||
|
||||
/** An established socket goes away and the page is told. */
|
||||
function drop(index) {
|
||||
sockets[index].readyState = FakeSocket.CLOSED;
|
||||
sockets[index].fire('close');
|
||||
}
|
||||
|
||||
/** An attempt that never connects: 'error' then 'close', as a refused connection reports itself. */
|
||||
function fail(index) {
|
||||
sockets[index].readyState = FakeSocket.CLOSED;
|
||||
sockets[index].fire('error');
|
||||
sockets[index].fire('close');
|
||||
}
|
||||
|
||||
/**
|
||||
* A close arriving for a socket that died earlier — the host's takeover abort, landing late.
|
||||
*
|
||||
* Separate from drop() because the point of it is the delay: the socket is already dead by the time the
|
||||
* event is delivered, which is what a suspended renderer's queued events look like on the way back.
|
||||
*/
|
||||
function deliverLateClose(index) {
|
||||
sockets[index].readyState = FakeSocket.CLOSED;
|
||||
sockets[index].fire('close');
|
||||
}
|
||||
|
||||
/** The screen the page is on goes away, and comes back. */
|
||||
function becomeHidden() {
|
||||
document.hidden = true;
|
||||
document.fire('visibilitychange');
|
||||
}
|
||||
|
||||
function becomeVisible() {
|
||||
document.hidden = false;
|
||||
document.fire('visibilitychange');
|
||||
}
|
||||
|
||||
function takeFocus() {
|
||||
window.fire('focus');
|
||||
}
|
||||
|
||||
function comeOnline() {
|
||||
window.fire('online');
|
||||
}
|
||||
|
||||
var WebSocket = FakeSocket;
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Globalization;
|
||||
|
||||
using Jint;
|
||||
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One load of <c>terminal.js</c>, in a fake browser, for one test.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The page's own source is what runs.</b> Not a transcription of its logic into C# — that would test a
|
||||
/// copy, and the copy would be the thing that stayed correct. The file is read from the shell project and
|
||||
/// evaluated as it ships, so a change to the page that breaks the reconnection rules fails here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Jint rather than node, and that is a deliberate trade.</b> A node script would be the obvious way to
|
||||
/// run JavaScript and would need node on every machine and in every CI job that runs the suite — so it
|
||||
/// would be a second test command, run separately, and the first thing to be forgotten. This runs inside
|
||||
/// <c>dotnet test</c> with everything else. What it costs is that the engine is not the engine the page
|
||||
/// actually runs in: Jint is not Chromium, so this can prove the page's own logic and can prove nothing
|
||||
/// about how WebView2 or Android's WebView behave. That boundary is exactly where
|
||||
/// <c>docs/manual-checks.md</c> picks up — see 1.10 and 11.12a.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A fresh engine per test, because the page is a script with module-level state and there is no unloading
|
||||
/// it: two tests sharing one engine would share a socket list, a clock and a backoff.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RendererPage
|
||||
{
|
||||
private readonly Engine engine;
|
||||
|
||||
private RendererPage(Engine engine) => this.engine = engine;
|
||||
|
||||
/// <summary>How many sockets the page has opened since it loaded.</summary>
|
||||
/// <remarks>
|
||||
/// The measurement nearly every test here turns on, and it is deliberately a count of *attempts*
|
||||
/// rather than of live sockets: the failures being guarded against are a page that stops trying and a
|
||||
/// page that never stops, and both are counted rather than observed.
|
||||
/// </remarks>
|
||||
public int Attempts => (int)engine.Evaluate("attempts()").AsNumber();
|
||||
|
||||
/// <summary>What the status element says — the banner a user would be looking at.</summary>
|
||||
public string Banner => engine.Evaluate("banner()").AsString();
|
||||
|
||||
/// <summary>
|
||||
/// Loads the harness and then the page, leaving the page exactly as it is a moment after the WebView
|
||||
/// navigated to it: one socket opened and still connecting.
|
||||
/// </summary>
|
||||
public static RendererPage Load()
|
||||
{
|
||||
var browser = new Engine();
|
||||
|
||||
// Order matters and is not incidental: the page connects on its last line, so every global it
|
||||
// touches — the socket constructor above all — has to be in place before it is evaluated.
|
||||
browser.Execute(Read("Renderer/renderer-harness.js"));
|
||||
browser.Execute(Read("Renderer/terminal.js"));
|
||||
|
||||
return new RendererPage(browser);
|
||||
}
|
||||
|
||||
/// <summary>Runs a line of the harness's own vocabulary — <c>accept(0)</c>, <c>advance(1000)</c>.</summary>
|
||||
public void Do(string script) => engine.Execute(script);
|
||||
|
||||
/// <summary>Whether that attempt has been closed, by the page or by the far end.</summary>
|
||||
public bool IsClosed(int attempt) =>
|
||||
engine.Evaluate(
|
||||
string.Create(CultureInfo.InvariantCulture, $"isClosed({attempt})"))
|
||||
.AsBoolean();
|
||||
|
||||
/// <remarks>
|
||||
/// Both files are copied beside the test assembly by the project file, the page out of the shell
|
||||
/// project it belongs to. Read from disk rather than embedded so that the copy which runs here is the
|
||||
/// same bytes the host serves, with nothing in between that could go stale.
|
||||
/// </remarks>
|
||||
private static string Read(string relativePath) =>
|
||||
File.ReadAllText(Path.Combine(AppContext.BaseDirectory, relativePath));
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The renderer page's half of staying attached — <c>terminal.js</c>'s <c>connect()</c> and what drives it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The host's half is <see cref="TerminalDataPlaneTests"/>, and the two are one mechanism: a socket that
|
||||
/// drops is ordinary here, and the page coming back for another is what makes it ordinary. What these
|
||||
/// tests protect is the property that failure of this mechanism has no other symptom — a terminal whose
|
||||
/// page has given up looks exactly like a terminal whose remote has gone quiet, except for a line of text
|
||||
/// nobody reads twice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Four of these were written against a page that failed them — the stale close, the handshake that never
|
||||
/// finishes, and the two wake-ups — and the rest describe behaviour that was already right and is easy to
|
||||
/// break while fixing those. The two that assert a wake-up does *nothing* pass against either version,
|
||||
/// which is the point of them: they are what stops the cure being worse, and they can only ever fail
|
||||
/// against a future change. See <see cref="RendererPage"/> for how the real file is loaded and for what
|
||||
/// this cannot reach.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RendererReconnectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ThePage_ConnectsWhenItLoads()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Attempts.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ADroppedSocket_IsRetriedAndTheBannerClears()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("accept(0)");
|
||||
page.Banner.ShouldBe("");
|
||||
|
||||
page.Do("drop(0)");
|
||||
page.Banner.ShouldStartWith("Reconnecting");
|
||||
|
||||
page.Do("advance(1000)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
|
||||
page.Do("accept(1)");
|
||||
page.Banner.ShouldBe("");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The wait grows within one outage and goes back to a second once a socket has actually opened, so
|
||||
/// that the next outage is not paid for at the previous one's rate.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheWait_GrowsWithinAnOutageAndResetsAfterIt()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("fail(0); advance(1000)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
|
||||
page.Do("fail(1); advance(1999)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
|
||||
page.Do("advance(1)");
|
||||
page.Attempts.ShouldBe(3);
|
||||
|
||||
page.Do("accept(2); drop(2); advance(1000)");
|
||||
page.Attempts.ShouldBe(4);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A close for a socket the page has already replaced must not start a reconnect.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The loop this forbids costs nothing to enter and never leaves: the host aborts the displaced socket
|
||||
/// on every takeover — see <c>TerminalDataPlane.UpgradeAsync</c> — so a stale close that schedules a
|
||||
/// retry displaces the socket that has just succeeded, whose own close schedules the next. The visible
|
||||
/// end of it is a terminal that reconnects every second forever with the banner up for most of it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AStaleClose_DoesNotDisplaceTheSocketThatSucceeded()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("accept(0); drop(0); advance(1000)");
|
||||
page.Do("accept(1)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
|
||||
// The first socket's end, arriving after the page has moved on.
|
||||
page.Do("deliverLateClose(0)");
|
||||
page.Do("advance(60000)");
|
||||
|
||||
page.Attempts.ShouldBe(2);
|
||||
page.Banner.ShouldBe("");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An attempt that never finishes its handshake is given up on rather than waited on forever.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every other retry in the page is scheduled by a close or an error, so a socket that reports neither
|
||||
/// — which is what a renderer suspended mid-handshake leaves behind — used to schedule nothing at all.
|
||||
/// The page then held a banner saying it was reconnecting with no timer pending and no socket coming,
|
||||
/// for the rest of its life.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AHandshakeThatNeverFinishes_IsAbandonedAndRetried()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
// Nothing whatever from the first attempt: no open, no error, no close.
|
||||
page.Do("advance(5000)");
|
||||
page.Attempts.ShouldBe(1);
|
||||
|
||||
page.Do("advance(1000)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
page.IsClosed(0).ShouldBeTrue();
|
||||
|
||||
page.Do("accept(1)");
|
||||
page.Banner.ShouldBe("");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coming back to the page reconnects it, without waiting for a timer that may not be running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The case the whole wake-up path exists for, and the one a test can only approximate: the harness's
|
||||
/// clock stands still here because a real hidden page's clock is throttled rather than stopped, and
|
||||
/// standing still is the honest worst case of that. What is being asserted is that the page does not
|
||||
/// need the clock at all to notice it is back.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void BecomingVisibleAgain_ReconnectsWithoutTheTimer()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("accept(0); becomeHidden(); drop(0)");
|
||||
page.Attempts.ShouldBe(1);
|
||||
|
||||
page.Do("becomeVisible()");
|
||||
page.Attempts.ShouldBe(2);
|
||||
|
||||
// And the timer that was pending when the page woke must not open a third socket on top of the
|
||||
// one that just succeeded — which would be the takeover loop, entered from the other side.
|
||||
page.Do("accept(1); advance(60000)");
|
||||
page.Attempts.ShouldBe(2);
|
||||
page.Banner.ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TakingTheKeyboardBack_AlsoReconnects()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("accept(0); drop(0); takeFocus()");
|
||||
|
||||
page.Attempts.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The wake-ups fire on gestures as ordinary as clicking the window, so the check they make has to be
|
||||
/// the thing that keeps them cheap rather than the frequency. A page whose socket is up must treat all
|
||||
/// of them as nothing at all — anything else would be the takeover loop with a person's mouse driving it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void WakingUpOverAHealthySocket_DoesNothing()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("accept(0)");
|
||||
page.Do("takeFocus(); becomeVisible(); comeOnline(); becomeHidden(); becomeVisible()");
|
||||
|
||||
page.Attempts.ShouldBe(1);
|
||||
page.Banner.ShouldBe("");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An attempt already in flight is left to finish or to time out. Restarting it on every wake-up would
|
||||
/// mean a page being clicked during a slow handshake never completing one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void WakingUpWhileConnecting_LeavesTheAttemptAlone()
|
||||
{
|
||||
var page = RendererPage.Load();
|
||||
|
||||
page.Do("takeFocus(); becomeVisible(); comeOnline()");
|
||||
|
||||
page.Attempts.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -416,6 +416,36 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
||||
TimeProvider.System,
|
||||
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) });
|
||||
|
||||
/// <summary>
|
||||
/// Opens a renderer's socket and returns once the plane is actually holding it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Connected and attached are two different moments, and the gap between them is where this used to
|
||||
/// flake.</b> <c>ClientWebSocket.ConnectAsync</c> completes on the 101, which
|
||||
/// <see cref="TerminalDataPlane.UpgradeAsync"/> writes before it has a <see cref="WebSocket"/> to
|
||||
/// attach — it builds one from the stream and swaps it in a few instructions later, on the accept
|
||||
/// thread. A frame sent in between is dropped, by design rather than by accident: the transport has
|
||||
/// nowhere to put a frame for a renderer that is not there, and queueing it is the unbounded growth the
|
||||
/// credit window exists to prevent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So a test that connected and immediately expected a frame was racing that window on every run. It
|
||||
/// lost one on CI — <c>Output_ReachesTheRenderer</c> read the output frame first and asked why it was
|
||||
/// not the session's opening one, the opening one having been dropped a moment earlier — which is a
|
||||
/// scheduling accident on a loaded machine and says nothing whatever about the transport.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Production does not race it and needs no change: everything that opens a session waits on
|
||||
/// <c>TerminalWorkspace.WaitForRendererAsync</c> first, and that resolves from the same few lines this
|
||||
/// event is raised from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Subscribed before the connection rather than after it, because the event is raised on the accept
|
||||
/// thread and can be over before <c>ConnectAsync</c> has returned here. Bounded, so that a socket that
|
||||
/// never attaches fails this helper rather than hanging the suite in a later receive.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<ClientWebSocket> ConnectAsync(
|
||||
string? token = "",
|
||||
string? origin = null)
|
||||
@@ -433,17 +463,31 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
||||
"Origin",
|
||||
origin ?? string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{plane.Port}"));
|
||||
|
||||
var attached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void OnAttached(object? sender, EventArgs e) => attached.TrySetResult();
|
||||
|
||||
plane.SocketAttached += OnAttached;
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(
|
||||
new Uri($"ws://127.0.0.1:{plane.Port}{TerminalDataPlane.SocketPath}"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await attached.Task.WaitAsync(
|
||||
TimeSpan.FromSeconds(5),
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
plane.SocketAttached -= OnAttached;
|
||||
}
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
@@ -538,10 +538,26 @@ public sealed class TerminalWorkspaceTests
|
||||
new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Attaches the way the real page does: by fetching the served page, reading the token and socket URL
|
||||
/// back out of it, and presenting them on the upgrade — rather than reaching into the workspace for a
|
||||
/// token it does not expose. A shortcut here would prove only that a socket can be opened, not that the
|
||||
/// workspace serves a page a renderer could actually attach with.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// And it waits for the attach rather than only for the handshake, for the reason
|
||||
/// <c>TerminalDataPlaneTests.ConnectAsync</c> sets out at length: the 101 is written before the socket
|
||||
/// is attachable, and a frame sent in between is dropped. A caller that opens a session on the socket
|
||||
/// this returns and then reads its opening frame is exactly the shape that loses that race.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>WaitForRendererAsync</c> answers only for the first renderer ever to attach, so a second call
|
||||
/// returns immediately without proving anything about the second socket. That is enough here and is
|
||||
/// not luck: the only frames a second socket is given are the replay, and the replay is *caused* by the
|
||||
/// attach — <see cref="TerminalWorkspace.RendererReattached"/> and the frames before it cannot be sent
|
||||
/// early. The day a test sends something else down a reattached socket, this needs the data plane's own
|
||||
/// <c>SocketAttached</c>, which the workspace does not forward today.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
|
||||
{
|
||||
@@ -561,6 +577,8 @@ public sealed class TerminalWorkspaceTests
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Jint": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.16.0, )",
|
||||
"resolved": "4.16.0",
|
||||
"contentHash": "YHofgoVtjWzqmG2GsGsp6eYMmcBfGgJOcH+Ki2UdZXNsYgKGnWrK2hSqRSPG/6HqeiipT5YPhzEH+bwxFO+YAQ==",
|
||||
"dependencies": {
|
||||
"Acornima": "1.7.0"
|
||||
}
|
||||
},
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.137, )",
|
||||
@@ -48,6 +57,11 @@
|
||||
"xunit.v3.mtp-v1": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"Acornima": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.7.0",
|
||||
"contentHash": "a2I4O4qkuAdB0oSaGz6/k0n/bXxMbGcLBra5dNTTVSLmTwM7uORA8ebpOrNMmDfFWOMwvuaRL6gPprRV4HTV0w=="
|
||||
},
|
||||
"Castle.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "5.1.1",
|
||||
@@ -217,7 +231,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.terminal": {
|
||||
@@ -228,9 +242,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -249,11 +263,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.transfer": {
|
||||
@@ -228,9 +228,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -249,11 +249,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,9 +390,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -431,11 +431,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -436,7 +436,7 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
"SSH.NET": "[2026.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.storage": {
|
||||
@@ -486,9 +486,9 @@
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
"requested": "[2.7.0, )",
|
||||
"resolved": "2.7.0",
|
||||
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
|
||||
},
|
||||
"EFCore.NamingConventions": {
|
||||
"type": "CentralTransitive",
|
||||
@@ -599,11 +599,11 @@
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"requested": "[2026.0.0, )",
|
||||
"resolved": "2026.0.0",
|
||||
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"BouncyCastle.Cryptography": "2.7.0",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user