Public Access
Merge branch 'claude/desktop-auto-updater-0264a3'
This commit is contained in:
@@ -8,6 +8,13 @@
|
|||||||
"dotnet-ef"
|
"dotnet-ef"
|
||||||
],
|
],
|
||||||
"rollForward": false
|
"rollForward": false
|
||||||
|
},
|
||||||
|
"vpk": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"commands": [
|
||||||
|
"vpk"
|
||||||
|
],
|
||||||
|
"rollForward": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-1
@@ -91,7 +91,17 @@ jobs:
|
|||||||
"give the runner an image with node 20 or newer if actions misbehave."
|
"give the runner an image with node 20 or newer if actions misbehave."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# fetch-depth 0, and it is load-bearing rather than tidy. MinVer derives the version from the
|
||||||
|
# nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
|
||||||
|
# it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
|
||||||
|
# whether an installed client is out of date by comparing versions, which makes a plausible wrong
|
||||||
|
# answer here a client that never updates.
|
||||||
|
#
|
||||||
|
# Repeated in all three jobs, like the node preamble above and for the same reason. Change one
|
||||||
|
# copy, change all three.
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
with:
|
with:
|
||||||
@@ -104,6 +114,31 @@ jobs:
|
|||||||
- name: restore
|
- name: restore
|
||||||
run: dotnet restore DodoSSH.slnx --locked-mode
|
run: dotnet restore DodoSSH.slnx --locked-mode
|
||||||
|
|
||||||
|
# The version comes from the tag, so on a tag build there are two ways to say the same number
|
||||||
|
# and they can disagree — a moved tag, a tag on the wrong commit, or a checkout that somehow
|
||||||
|
# still lost its history. What makes that worth a step of its own is that the disagreement is
|
||||||
|
# silent everywhere else: MinVer answers 0.0.0-alpha.0.N rather than failing, the build goes
|
||||||
|
# green, the package is cut, and the symptom arrives weeks later as clients that never update.
|
||||||
|
#
|
||||||
|
# -getProperty evaluates without building, so this costs a second and runs before the build.
|
||||||
|
- name: the tag and the version agree
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="${GITHUB_REF#refs/tags/v}"
|
||||||
|
declared="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||||
|
-getProperty:Version -nologo | tr -d '[:space:]')"
|
||||||
|
|
||||||
|
if [ "$tag" != "$declared" ]; then
|
||||||
|
echo "The tag says v$tag and MinVer computed $declared." >&2
|
||||||
|
echo >&2
|
||||||
|
echo "These come from the same place, so a mismatch means the checkout did not see the" >&2
|
||||||
|
echo "tag it is building — most likely fetch-depth, which must stay 0 in every job here." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "v$declared"
|
||||||
|
|
||||||
# No `dotnet format --verify-no-changes` step. It re-analysed the whole solution before
|
# No `dotnet format --verify-no-changes` step. It re-analysed the whole solution before
|
||||||
# the build did, for minutes, to check something the build already checks: IDE0055 is an
|
# the build did, for minutes, to check something the build already checks: IDE0055 is an
|
||||||
# error in .editorconfig and TreatWarningsAsErrors is on, so a misformatted file fails
|
# error in .editorconfig and TreatWarningsAsErrors is on, so a misformatted file fails
|
||||||
@@ -150,6 +185,34 @@ jobs:
|
|||||||
- name: test
|
- name: test
|
||||||
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
||||||
|
|
||||||
|
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
||||||
|
# failure mode is a restore graph or a native asset that resolves for net10.0 and not for
|
||||||
|
# net10.0/win-x64, which nobody would see until a person was halfway through cutting a release
|
||||||
|
# on a Windows machine. vpk can pack a Windows package from Linux; only signing needs Windows,
|
||||||
|
# and this repository signs nothing yet, so proving the publish here is worth the minutes.
|
||||||
|
#
|
||||||
|
# RestoreLockedMode=false for this command only, and it is not a loosened gate. The committed
|
||||||
|
# lock files are deliberately RID-free: declaring win-x64 on the desktop head writes a
|
||||||
|
# net10.0/win-x64 target into every project it references transitively, which includes
|
||||||
|
# DodoSSH.Contracts and DodoSSH.Crypto — and the API's Dockerfile restores those with no RID
|
||||||
|
# under locked mode, so the image job would fail NU1004. The gate is the locked solution
|
||||||
|
# restore at the top of this job, which is unchanged.
|
||||||
|
#
|
||||||
|
# It rewrites the lock files as it goes; nothing after this step reads them, and the runner's
|
||||||
|
# checkout is thrown away. The release script does the same thing and puts them back, because
|
||||||
|
# there the tree is somebody's working copy.
|
||||||
|
#
|
||||||
|
# After the tests rather than before them, so a red suite does not first spend a hundred
|
||||||
|
# megabytes pulling a win-x64 runtime pack. main and tags only, for the same reason: a break
|
||||||
|
# found by the person about to release is found early enough.
|
||||||
|
- name: the windows publish still resolves
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
run: >
|
||||||
|
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
|
||||||
|
--configuration Release --runtime win-x64 --self-contained true
|
||||||
|
-p:RestoreLockedMode=false
|
||||||
|
--output "$RUNNER_TEMP/win-x64-check"
|
||||||
|
|
||||||
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
# This includes the end-to-end suite, which starts PostgreSQL, Keycloak and an OpenSSH
|
||||||
# server through Testcontainers and runs the API as a child process — so it needs a
|
# server through Testcontainers and runs the API as a child process — so it needs a
|
||||||
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
# Docker daemon and gets one here. That is why the tests run on ubuntu rather than
|
||||||
@@ -241,7 +304,17 @@ jobs:
|
|||||||
"give the runner an image with node 20 or newer if actions misbehave."
|
"give the runner an image with node 20 or newer if actions misbehave."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# fetch-depth 0, and it is load-bearing rather than tidy. MinVer derives the version from the
|
||||||
|
# nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
|
||||||
|
# it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
|
||||||
|
# whether an installed client is out of date by comparing versions, which makes a plausible wrong
|
||||||
|
# answer here a client that never updates.
|
||||||
|
#
|
||||||
|
# Repeated in all three jobs, like the node preamble above and for the same reason. Change one
|
||||||
|
# copy, change all three.
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
with:
|
with:
|
||||||
@@ -281,7 +354,7 @@ jobs:
|
|||||||
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile.
|
# resolves for net10.0 but has nothing to dex. Neither shows up in a compile.
|
||||||
#
|
#
|
||||||
# Debug-signed on purpose, and it has to stay that way: no keystore secret, no AndroidKeyStore=true.
|
# Debug-signed on purpose, and it has to stay that way: no keystore secret, no AndroidKeyStore=true.
|
||||||
# docs/adr/0010-android-distribution.md puts the release key on a machine that is not a runner,
|
# docs/adr/0011-android-distribution.md puts the release key on a machine that is not a runner,
|
||||||
# because a signing key reachable from a workflow is a key held by everyone who can change one.
|
# because a signing key reachable from a workflow is a key held by everyone who can change one.
|
||||||
# This APK is a build check. It is not something anybody installs.
|
# This APK is a build check. It is not something anybody installs.
|
||||||
- name: package
|
- name: package
|
||||||
@@ -343,7 +416,17 @@ jobs:
|
|||||||
"give the runner an image with node 20 or newer if actions misbehave."
|
"give the runner an image with node 20 or newer if actions misbehave."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# fetch-depth 0, and it is load-bearing rather than tidy. MinVer derives the version from the
|
||||||
|
# nearest v* tag, and checkout's default shallow clone has no tags at all — so it would not fail,
|
||||||
|
# it would quietly answer 0.0.0-alpha.0.N and every build would ship that. Velopack decides
|
||||||
|
# whether an installed client is out of date by comparing versions, which makes a plausible wrong
|
||||||
|
# answer here a client that never updates.
|
||||||
|
#
|
||||||
|
# Repeated in all three jobs, like the node preamble above and for the same reason. Change one
|
||||||
|
# copy, change all three.
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
# The daemon and the client are two different things to have, and this runner had only
|
# The daemon and the client are two different things to have, and this runner had only
|
||||||
# one of them. Testcontainers reaches Docker straight over /var/run/docker.sock from a
|
# one of them. Testcontainers reaches Docker straight over /var/run/docker.sock from a
|
||||||
@@ -449,8 +532,18 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# The version MSBuild is allowed to see, which is not the same string as the one above.
|
||||||
|
# `version` is a docker tag and is `main-<short sha>` on a main build; handing that to
|
||||||
|
# -p:Version fails the publish with NETSDK1018. So this is set only when it is a real
|
||||||
|
# version, and the Dockerfile leaves the SDK default alone when it is empty.
|
||||||
|
assembly_version=""
|
||||||
|
case "$GITHUB_REF" in
|
||||||
|
refs/tags/v*) assembly_version="${GITHUB_REF#refs/tags/v}" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
echo "tags=$tags" >> "$GITHUB_OUTPUT"
|
echo "tags=$tags" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "assemblyVersion=$assembly_version" >> "$GITHUB_OUTPUT"
|
||||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
echo "Tagging: $tags"
|
echo "Tagging: $tags"
|
||||||
|
|
||||||
@@ -462,6 +555,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TAGS: ${{ steps.tags.outputs.tags }}
|
TAGS: ${{ steps.tags.outputs.tags }}
|
||||||
VERSION: ${{ steps.tags.outputs.version }}
|
VERSION: ${{ steps.tags.outputs.version }}
|
||||||
|
ASSEMBLY_VERSION: ${{ steps.tags.outputs.assemblyVersion }}
|
||||||
REVISION: ${{ github.sha }}
|
REVISION: ${{ github.sha }}
|
||||||
CREATED: ${{ steps.tags.outputs.created }}
|
CREATED: ${{ steps.tags.outputs.created }}
|
||||||
run: |
|
run: |
|
||||||
@@ -477,6 +571,7 @@ jobs:
|
|||||||
--pull \
|
--pull \
|
||||||
--file src/DodoSSH.Api/Dockerfile \
|
--file src/DodoSSH.Api/Dockerfile \
|
||||||
--build-arg "VERSION=$VERSION" \
|
--build-arg "VERSION=$VERSION" \
|
||||||
|
--build-arg "ASSEMBLY_VERSION=$ASSEMBLY_VERSION" \
|
||||||
--build-arg "REVISION=$REVISION" \
|
--build-arg "REVISION=$REVISION" \
|
||||||
--build-arg "CREATED=$CREATED" \
|
--build-arg "CREATED=$CREATED" \
|
||||||
"${args[@]}" \
|
"${args[@]}" \
|
||||||
@@ -547,3 +642,20 @@ jobs:
|
|||||||
- name: log out
|
- name: log out
|
||||||
if: always() && github.event_name != 'pull_request'
|
if: always() && github.event_name != 'pull_request'
|
||||||
run: docker logout registry-docker.dodotech.cloud
|
run: docker logout registry-docker.dodotech.cloud
|
||||||
|
|
||||||
|
# There is no job here that publishes the desktop client, and there is not going to be one. Two
|
||||||
|
# independent reasons, and both need saying because someone will fix one and think they are done.
|
||||||
|
#
|
||||||
|
# The smaller one is mechanical: vpk stamps and embeds the Setup.exe and Update.exe stubs with Windows
|
||||||
|
# tooling, and every job in this file is runs-on: [linux]. A Windows runner would answer that.
|
||||||
|
#
|
||||||
|
# The larger one is that a Windows runner would not answer the other. Velopack clients fetch from the
|
||||||
|
# release feed and do not verify a package signature when they apply it, so whoever can write a release
|
||||||
|
# on this repository can publish an update that every installed client downloads and runs. That is the
|
||||||
|
# same capability as the signing key, reached through a different door — and docs/adr/0011 rule 1 puts
|
||||||
|
# that capability on a machine which is not a runner, because a workflow secret is held by everyone who
|
||||||
|
# can change a workflow file. See docs/adr/0013-desktop-distribution-and-updates.md.
|
||||||
|
#
|
||||||
|
# What cuts a release is scripts/release-windows.ps1, run by a person. What this file does is prove the
|
||||||
|
# thing still builds and packages, which is the same division of labour the android job above already
|
||||||
|
# has: it packages an APK nobody installs, so that a link-time break fails here rather than later.
|
||||||
|
|||||||
@@ -33,6 +33,15 @@
|
|||||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Label="Version">
|
||||||
|
<!--
|
||||||
|
The version comes from the nearest v* tag; see the MinVer entry in Directory.Packages.props for
|
||||||
|
why it is derived rather than written down. The prefix matches the tags ci.yml already triggers
|
||||||
|
on, so `git tag v0.1.0` is the whole act of choosing a version.
|
||||||
|
-->
|
||||||
|
<MinVerTagPrefix>v</MinVerTagPrefix>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Label="Assembly metadata">
|
<PropertyGroup Label="Assembly metadata">
|
||||||
<Company>DodoTech</Company>
|
<Company>DodoTech</Company>
|
||||||
<Product>DodoSSH</Product>
|
<Product>DodoSSH</Product>
|
||||||
@@ -51,6 +60,14 @@
|
|||||||
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" />
|
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup Label="Versioning">
|
||||||
|
<!--
|
||||||
|
PrivateAssets="all" because this is a build-time task and not something a consumer of
|
||||||
|
DodoSSH.Contracts should inherit.
|
||||||
|
-->
|
||||||
|
<PackageReference Include="MinVer" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup Label="Banned API list">
|
<ItemGroup Label="Banned API list">
|
||||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" Visible="false" />
|
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" Visible="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -145,6 +145,40 @@
|
|||||||
terminal data plane — is Pipelines and channel code rather than view models.
|
terminal data plane — is Pipelines and channel code rather than view models.
|
||||||
-->
|
-->
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
|
<!--
|
||||||
|
Packaging and self-update for the Windows desktop head. MIT, and on net10.0 it declares no
|
||||||
|
dependencies at all — the whole package is one managed assembly, so it restores and compiles
|
||||||
|
on the Linux runner that builds the solution even though the thing it produces only runs on
|
||||||
|
Windows. That mattered enough to check: a per-OS conditional PackageReference is not available
|
||||||
|
here, because it would make packages.lock.json depend on the operating system and CI's locked
|
||||||
|
restore would then fail on whichever platform did not write it.
|
||||||
|
|
||||||
|
MSIX would have been the platform-native choice and is ruled out rather than deprioritised: a
|
||||||
|
packaged app runs WebView2 in an AppContainer where loopback is blocked, and the terminal data
|
||||||
|
plane is a loopback WebSocket. See docs/platform-flags.md.
|
||||||
|
|
||||||
|
The update feed this is pointed at is the project's own forge and never a DodoSSH deployment.
|
||||||
|
That is ADR 0011 rule 2, and it is the reason the repository URL in VelopackUpdateSource is a
|
||||||
|
constant rather than a setting: an operator who could answer the update check could pin a
|
||||||
|
chosen user to a known-vulnerable build. See docs/adr/0013-desktop-distribution-and-updates.md.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="Velopack" Version="1.2.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup Label="Versioning">
|
||||||
|
<!--
|
||||||
|
One version for the whole repository, derived from the nearest v* git tag. The tag was already
|
||||||
|
the version of record — ci.yml's "work out the tags" step parses refs/tags/v* for the docker
|
||||||
|
image — and nothing set an assembly version at all, so every binary reported the SDK's default
|
||||||
|
1.0.0 and the API served that as its ServerVersion. Deriving from the tag makes those one
|
||||||
|
number instead of two that can disagree.
|
||||||
|
|
||||||
|
MinVer's one real failure mode is that it answers plausibly rather than failing: a shallow
|
||||||
|
clone with no tags yields 0.0.0-alpha.0.N. Here that is not cosmetic — Velopack compares the
|
||||||
|
version baked into a package against the one it is running, so a wrong answer is a client that
|
||||||
|
never updates. Hence two guards in ci.yml: fetch-depth 0 on every checkout, and a step on tag
|
||||||
|
builds that fails if the computed version and the tag disagree.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="MinVer" Version="7.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup Label="Analyzers">
|
<ItemGroup Label="Analyzers">
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ docs/design-import-gaps.md what the client's design asked for and this build has
|
|||||||
docs/platform-flags.md what differs off Windows, and the gotchas that have cost time
|
docs/platform-flags.md what differs off Windows, and the gotchas that have cost time
|
||||||
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
docs/manual-checks.md what no test can reach, and what to look for when checking by hand
|
||||||
docs/android-port.md the Android head: what was decided, what is built, what is left
|
docs/android-port.md the Android head: what was decided, what is built, what is left
|
||||||
|
scripts/ release-windows.ps1 — builds, packs and publishes the Windows client
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
Everything under `src/DodoSSH.Client.*` except the two heads and `Shell` is deliberately free of Avalonia.
|
||||||
@@ -107,6 +108,40 @@ The tests need a Docker daemon. Everything that touches the database, the identi
|
|||||||
server uses Testcontainers rather than a stub or a shared instance, so there is nothing to start first and
|
server uses Testcontainers rather than a stub or a shared instance, so there is nothing to start first and
|
||||||
nothing to clean up after — but with no daemon those suites fail rather than skip.
|
nothing to clean up after — but with no daemon those suites fail rather than skip.
|
||||||
|
|
||||||
|
## Installing on Windows
|
||||||
|
|
||||||
|
The desktop client is published on the project's own release page as a `Setup.exe`. It installs per user,
|
||||||
|
under `%LOCALAPPDATA%\DodoSSH.Desktop`, and never asks for an administrator.
|
||||||
|
|
||||||
|
**It will warn you, and here is exactly what the warning means.** The build is not yet signed with a code
|
||||||
|
signing certificate, so Windows SmartScreen shows *"Windows protected your PC"* the first time you run the
|
||||||
|
installer; **More info → Run anyway** gets past it. That is the honest state of things rather than something
|
||||||
|
to click through blindly — it is a statement that Microsoft has not seen this file before, and it will stop
|
||||||
|
appearing when the project buys a certificate.
|
||||||
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) says what that costs and when it happens. The
|
||||||
|
warning is once per person: updates from inside the application do not raise it.
|
||||||
|
|
||||||
|
**Updates.** The client checks the project's release page every six hours, downloads a newer build in the
|
||||||
|
background, and then waits. Nothing is ever installed while you are using it — a downloaded update runs
|
||||||
|
after a restart you ask for, or the next time you start DodoSSH anyway. Restarting does end every shell you
|
||||||
|
have open, which locking deliberately does not, so the choice of when is left to you. You can turn the
|
||||||
|
checking off on PREFERENCES → UPDATES.
|
||||||
|
|
||||||
|
**Where it comes from matters, and it is worth one paragraph.** A DodoSSH server will never offer you the
|
||||||
|
client, and one that does is not one to trust. Whoever hands you the binary can hand you a binary that
|
||||||
|
copies your passphrase — the client is where your credentials are in plaintext, by construction — and the
|
||||||
|
operator of a deployment is precisely the party the trust model is about. An operator may tell you where to
|
||||||
|
get it. They are not where it comes from, and the update check inside the application points at the
|
||||||
|
project's own forge and nowhere else. See [ADR 0011](docs/adr/0011-android-distribution.md) rule 2.
|
||||||
|
|
||||||
|
**Uninstalling removes the application and leaves your vault cache** at `%LOCALAPPDATA%\DodoSSH`, so
|
||||||
|
reinstalling asks for your passphrase rather than starting over. Use **Sign out** inside the application if
|
||||||
|
you want the machine to genuinely forget everything — an uninstall is not a sign-out, and does not withdraw
|
||||||
|
this machine's device key from your account.
|
||||||
|
|
||||||
|
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
|
||||||
|
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
Three commands, in order. The first is once per machine.
|
Three commands, in order. The first is once per machine.
|
||||||
@@ -576,6 +611,11 @@ keychain plus a terminal — and the spike that gates all of it.
|
|||||||
|
|
||||||
### Conventions the build enforces
|
### Conventions the build enforces
|
||||||
|
|
||||||
|
- One version for the whole repository, derived from the nearest `v*` tag by MinVer. A tag build whose
|
||||||
|
computed version disagrees with the tag fails CI, and every checkout uses `fetch-depth: 0` — without it
|
||||||
|
MinVer answers `0.0.0-alpha.0.N` rather than failing, and a wrong version here is a client that never
|
||||||
|
updates.
|
||||||
|
|
||||||
- Warnings are errors, formatting included: `IDE0055` is an error in `.editorconfig`, so a
|
- Warnings are errors, formatting included: `IDE0055` is an error in `.editorconfig`, so a
|
||||||
misformatted file fails the build itself rather than a separate CI step.
|
misformatted file fails the build itself rather than a separate CI step.
|
||||||
- Package versions are centralised in `Directory.Packages.props`; `packages.lock.json` is
|
- Package versions are centralised in `Directory.Packages.props`; `packages.lock.json` is
|
||||||
@@ -746,6 +786,20 @@ keychain plus a terminal — and the spike that gates all of it.
|
|||||||
serving the client binary, which hands it to the one party the whole trust model is about. An installed
|
serving the client binary, which hands it to the one party the whole trust model is about. An installed
|
||||||
Android app can only ever be updated by a package signed with the same key, so this is the first
|
Android app can only ever be updated by a package signed with the same key, so this is the first
|
||||||
release's decision to make and nobody else's afterwards.
|
release's decision to make and nobody else's afterwards.
|
||||||
|
|
||||||
|
**The Windows desktop half is built.** Velopack packaging for `win-x64`, a `Setup.exe` that installs per
|
||||||
|
user with no administrator prompt, and a client that checks the project's own forge every six hours,
|
||||||
|
fetches a newer build in the background, and then waits for a restart the user presses — because a
|
||||||
|
restart ends every shell, and this application has gone to some trouble to make locking not do that. The
|
||||||
|
version is now one number for the whole repository, derived from the `v*` tag by MinVer, which is also
|
||||||
|
the first time the API has reported a true `serverVersion`. Releases are cut by a person rather than by
|
||||||
|
CI: the token that writes a release is, for an updater that trusts its feed, the same capability as the
|
||||||
|
signing key, which [ADR 0011](docs/adr/0011-android-distribution.md) rule 1 keeps off runners. See
|
||||||
|
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
||||||
|
[Installing on Windows](#installing-on-windows) for what a user sees.
|
||||||
|
|
||||||
|
Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the
|
||||||
|
first release aimed at strangers), and macOS and Linux packaging.
|
||||||
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
- **M5 — multi-provider OIDC**, identity key rotation, per-item content keys.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# ADR 0013 — Distributing the desktop client, and letting it replace itself
|
||||||
|
|
||||||
|
- Status: accepted
|
||||||
|
- Date: 2026-08-04
|
||||||
|
- Builds on: [ADR 0001](0001-e2ee-trust-model.md), [ADR 0011](0011-android-distribution.md)
|
||||||
|
- Settles: the desktop half of M4 packaging, which [ADR 0011](0011-android-distribution.md#consequences)
|
||||||
|
left open
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
[ADR 0011](0011-android-distribution.md) settled the phone and said explicitly what it was not settling:
|
||||||
|
"M4's desktop packaging inherits rule 2 and not the rest." This is that inheritance, plus the thing the
|
||||||
|
phone deliberately does not have.
|
||||||
|
|
||||||
|
The desktop head was not packaged at all. There were no tags, no `Version` property anywhere in the
|
||||||
|
repository, and therefore no version: every assembly reported the SDK's default `1.0.0`, and the API had
|
||||||
|
been serving that string to every client that asked it for `serverVersion`. There was no installer and no
|
||||||
|
way for anybody holding a copy to learn that a newer one existed.
|
||||||
|
|
||||||
|
That is tolerable while nobody has it. It stops being tolerable at the first release, and it stops in a
|
||||||
|
particular direction: this client holds the plaintext of a team's infrastructure credentials, by
|
||||||
|
construction (ADR 0001). For software like that, "the fix shipped and the user never got it" is a security
|
||||||
|
outcome. ADR 0011 named the cost of shipping outside a store — "no discovery, no automatic update" — and
|
||||||
|
accepted it for Android because the platform left no better option. The desktop leaves a better option, and
|
||||||
|
this ADR takes it.
|
||||||
|
|
||||||
|
The new thing an updater introduces is a capability that did not exist before: **something that can replace
|
||||||
|
the binary on a user's machine, without the user choosing each build.** ADR 0001 models the party who wants
|
||||||
|
the secrets as attacking the client rather than the crypto, and ADR 0011 turns that into a question of
|
||||||
|
custody — *which parties can ship one person a build?* An updater is a second answer to that question, so
|
||||||
|
it needs the same treatment as the signing key, and it is easy to get wrong because the dangerous part does
|
||||||
|
not look like a key.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. Velopack, per-user, under `%LOCALAPPDATA%`, with no administrator prompt
|
||||||
|
|
||||||
|
`docs/platform-flags.md` had already ruled out the platform-native option, and it is worth restating
|
||||||
|
because it is the kind of decision that gets reopened: **MSIX is not deprioritised, it is impossible
|
||||||
|
here.** A packaged application runs WebView2 in an AppContainer where loopback connections are blocked
|
||||||
|
without a `CheckNetIsolation` exemption, and the terminal data plane *is* a loopback WebSocket. MSIX would
|
||||||
|
not degrade the product, it would remove the terminal.
|
||||||
|
|
||||||
|
Velopack's path was checked against that and reintroduces nothing: `Setup.exe` is an ordinary Win32
|
||||||
|
executable that unpacks a directory and creates shortcuts, there is no package manifest and no package
|
||||||
|
identity, and the process therefore stays an ordinary desktop process. Manual check 16.4 is what would
|
||||||
|
notice if that ever changed, because it connects a real shell from the installed build.
|
||||||
|
|
||||||
|
### 2. The pack id is `DodoSSH.Desktop`, and it is irreversible
|
||||||
|
|
||||||
|
Velopack installs to `%LOCALAPPDATA%\<packId>` and **removes that entire directory on uninstall**.
|
||||||
|
`ClientPaths.DataDirectory` is `%LOCALAPPDATA%\DodoSSH`, and it holds `cache.db` with its `-wal` and `-shm`
|
||||||
|
companions, `settings.json`, and `device.key`.
|
||||||
|
|
||||||
|
So the obvious pack id would have installed the application on top of the user's encrypted cache, and
|
||||||
|
uninstalling would have deleted the device key and the outbox — the changes this machine has made and not
|
||||||
|
yet pushed, which `MainWindowViewModel.SignOutWarning` already describes to users as existing "nowhere else
|
||||||
|
in the world". The application refuses to delete that without a counted confirmation; an uninstaller would
|
||||||
|
have done it silently.
|
||||||
|
|
||||||
|
Velopack's own guidance is to keep persistent files in roaming `%APPDATA%`. **That guidance is declined**,
|
||||||
|
and `ClientPaths` already explains why: two machines writing one SQLite file through a file-sync client
|
||||||
|
corrupts it, and the outbox is per-machine by design. The install moves; the profile does not.
|
||||||
|
|
||||||
|
Like the Android package id, this is a one-way door — it is the identity an installed client matches an
|
||||||
|
update against, so changing it later orphans every existing install: still running, never updated, and
|
||||||
|
invisible to the new one.
|
||||||
|
|
||||||
|
### 3. The release is cut by a person, and the forge token never goes near CI
|
||||||
|
|
||||||
|
This is the paragraph that will be argued with, so it is the one written most carefully.
|
||||||
|
|
||||||
|
The tempting argument is that a Gitea write token is not a signing key, and so — unlike the Android
|
||||||
|
keystore ADR 0011 rule 1 keeps off runners — it could live in a CI secret and let a tag cut a release.
|
||||||
|
|
||||||
|
**It does not survive contact with what the token can do.** Velopack clients fetch from the configured
|
||||||
|
source over TLS and do not verify a package signature when they apply it. So anyone who can write a release
|
||||||
|
on this repository can publish an update that every installed client downloads and runs. That is precisely
|
||||||
|
the capability ADR 0011 rule 1 places on a machine which is not a runner, reached through a different door —
|
||||||
|
and a workflow secret is held by everyone who can change a workflow file, which for a repository with any
|
||||||
|
contributors is a wider set than it looks.
|
||||||
|
|
||||||
|
So: `scripts/release-windows.ps1`, run by a person on a Windows machine, in two phases. Phase one builds and
|
||||||
|
packs and stops. Phase two, a separate invocation, asks for the token and uploads. The split exists so that
|
||||||
|
what reaches users has been installed and started by a human first, and so that the credential is in memory
|
||||||
|
only for the minutes that need it.
|
||||||
|
|
||||||
|
There is a second, smaller reason the release could not be a CI job here anyway: `vpk` stamps and embeds
|
||||||
|
the `Setup.exe` and `Update.exe` stubs with Windows tooling, and every job in `ci.yml` is
|
||||||
|
`runs-on: [linux]`. Both reasons are recorded because somebody will fix one and believe they are done.
|
||||||
|
|
||||||
|
What CI does gain is the same thing the `android` job already does — it proves the artefact still builds.
|
||||||
|
A `win-x64` publish runs on main and on tags, so a restore graph that resolves for `net10.0` and not for
|
||||||
|
`net10.0/win-x64` fails there rather than under a person midway through a release.
|
||||||
|
|
||||||
|
### 4. The update check points at the project's forge, and the address is a constant
|
||||||
|
|
||||||
|
ADR 0011 rule 2 carries over unchanged, and its update clause carries with it: "If an update check is ever
|
||||||
|
added it points at the project's domain." Here that is `git.dodotech.cloud`, and it is a `const` in
|
||||||
|
`VelopackUpdateChannel` rather than a setting.
|
||||||
|
|
||||||
|
**The constant is the mechanism, not a convention.** A configurable feed address is exactly the knob that
|
||||||
|
would let an operator — or an edit to a plaintext `settings.json` — point the update path at the
|
||||||
|
deployment, and an operator who can answer "is there a newer version" can answer "no" forever, pinning a
|
||||||
|
chosen user to a build with a known hole while holding no key at all. Making it unsettable is that rule
|
||||||
|
expressed in a way nobody has to remember.
|
||||||
|
|
||||||
|
`ClientSettings` therefore stores whether to check, and nothing else: no address, no channel, no token. A
|
||||||
|
private release repository is incompatible with this design and that is worth knowing rather than
|
||||||
|
discovering, because the token would have to be readable before the vault is unlocked, and this file is the
|
||||||
|
one place that can be read then — which is the one place a token may not go.
|
||||||
|
|
||||||
|
### 5. `MinClientVersion` stays unread, and if it is ever read it may not fetch
|
||||||
|
|
||||||
|
`MetaResponse.MinClientVersion` has existed since M1, is served, is fetched on every sign-in, and is read
|
||||||
|
by nothing. Hanging the updater off it would have been natural and would have been wrong — and note the
|
||||||
|
shape of the error, because it is not "inherits an existing risk". Today the field controls nothing at all.
|
||||||
|
The moment an update check is conditioned on it, rule 2's sentence applies verbatim and the risk is
|
||||||
|
*created*.
|
||||||
|
|
||||||
|
The split to preserve:
|
||||||
|
|
||||||
|
- A deployment may say **"I will not serve a client this old"** and draw a remediation screen. That is the
|
||||||
|
deployment describing itself, which is legitimate and is what the field's own documentation asks for.
|
||||||
|
That screen may carry a sentence and a link to the project's release page.
|
||||||
|
- It may **not** carry a button that triggers a check or a download. A link is the user going somewhere; a
|
||||||
|
fetch is the operator's answer steering this process.
|
||||||
|
|
||||||
|
Under the design as built, an operator withholding or deflating the value achieves nothing — the check runs
|
||||||
|
on its own timer and never consults the deployment. Inflating it denies service to their own users, which
|
||||||
|
they can already do by turning the server off.
|
||||||
|
|
||||||
|
### 6. Self-contained, and not single-file
|
||||||
|
|
||||||
|
Self-contained because .NET 10 is recent enough that almost no machine has the runtime, and because the
|
||||||
|
usual objection — that runtime security patches then require an application update — is answered by the
|
||||||
|
feature this ADR is about. Velopack's deltas are per-file and the runtime files do not change between our
|
||||||
|
releases, so the runtime costs almost nothing per update; it is paid on first install.
|
||||||
|
|
||||||
|
Not single-file, for four independent reasons: `platform-flags.md` records that libsodium ships native
|
||||||
|
binaries per RID and complicates single-file publishing, and `libe_sqlite3`, `libSkiaSharp` and
|
||||||
|
`libHarfBuzzSharp` do the same; a bundle changes wholly on every build, so deltas stop working; Velopack is
|
||||||
|
a directory-based updater by design; and a self-extracting bundle puts the executable under a temp path
|
||||||
|
deep enough to hit the WebView2 long-path failure that same document records. Not trimmed — EF Core is not
|
||||||
|
trim-safe and `TreatWarningsAsErrors` turns every `IL2xxx` into a build break, so trimming is a project
|
||||||
|
rather than a flag.
|
||||||
|
|
||||||
|
**Native symbol files are excluded and ours are kept**, which is worth recording as a decision because the
|
||||||
|
numbers are so lopsided: `libSkiaSharp.pdb` and `libHarfBuzzSharp.pdb` are 100 MB of debug symbols for
|
||||||
|
third-party native code nobody here will step through, and all fifteen of our own managed PDBs together are
|
||||||
|
0.93 MB. Dropping the two took a publish from 227 MB to 127 MB. Keeping ours means an
|
||||||
|
`Exception.ToString()` carries file names and line numbers, which for a self-hosted product is the whole
|
||||||
|
diagnostic channel — the way a fault gets reported is a user pasting a stack into an issue.
|
||||||
|
|
||||||
|
### 7. One version, derived from the tag
|
||||||
|
|
||||||
|
MinVer, with `MinVerTagPrefix` of `v`, matching the tags CI already triggers on. The tag was already the
|
||||||
|
version of record for the API's container image; this makes it the version of record for everything,
|
||||||
|
including the client's own `AssemblyInformationalVersion`, which is what the preferences screen prints and
|
||||||
|
what Velopack compares.
|
||||||
|
|
||||||
|
MinVer's one sharp edge is that it answers plausibly rather than failing: a shallow clone with no tags
|
||||||
|
yields `0.0.0-alpha.0.N`. Here a wrong version is a client that never updates, so it is guarded twice —
|
||||||
|
`fetch-depth: 0` on every checkout, and a step that fails a tag build when the computed version and the tag
|
||||||
|
disagree.
|
||||||
|
|
||||||
|
The Windows application manifest's `assemblyIdentity` version is **deliberately** left at `1.0.0.0`. It is a
|
||||||
|
side-by-side activation field this application does not use and nothing reads; what a person sees comes from
|
||||||
|
the PE version resource, which MSBuild fills from `FileVersion`.
|
||||||
|
|
||||||
|
### 8. Unsigned for now, with a named trigger
|
||||||
|
|
||||||
|
Every installer will raise SmartScreen's "Windows protected your PC" until reputation accrues. The cost is
|
||||||
|
smaller and more precisely bounded than the reflex suggests, and the bound is worth knowing: Mark-of-the-Web
|
||||||
|
is applied by the *browser* that downloads `Setup.exe`, so the warning lands at first install only. In-app
|
||||||
|
updates are fetched by the application's own HTTP client and applied by `Update.exe` from a local file, and
|
||||||
|
never trip it. **One dialog per user per lifetime, not one per update.**
|
||||||
|
|
||||||
|
The trigger for buying an OV certificate on a hardware token is the first release the README invites a
|
||||||
|
stranger to install. ADR 0011 already describes Authenticode as the easy custody case — the key stays with
|
||||||
|
the developer — and the post-2023 requirement that it live on FIPS 140-2 Level 2 hardware enforces "offline,
|
||||||
|
never in CI" physically rather than by policy. `--signParams` is the single line in the release script that
|
||||||
|
changes.
|
||||||
|
|
||||||
|
**Azure Trusted Signing is not the quiet default and needs its own ADR.** It is cheaper and has no hardware
|
||||||
|
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
||||||
|
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
|
||||||
|
told where the release page is — but from the first install onward the client keeps itself current without
|
||||||
|
anybody deciding to go and look. That is the sharpest edge of the Android decision, blunted on the platform
|
||||||
|
where it could be.
|
||||||
|
|
||||||
|
**Nothing is ever installed while the application is running.** A fetched update waits for a restart the
|
||||||
|
user presses, or for the next launch they were going to do anyway. This is not caution for its own sake:
|
||||||
|
this application deliberately keeps shells running across a lock, and argues in `LockAsync` that a lock
|
||||||
|
which destroyed work would simply stop being used. A restart does end every shell, so it is a decision that
|
||||||
|
belongs to the person whose job is running in one — and the interface says so, in the same words the close
|
||||||
|
button already uses.
|
||||||
|
|
||||||
|
**The release is a manual step, and that will feel slow.** It is roughly ten minutes of a person's attention
|
||||||
|
per release, and the convenience being refused is the entire point of CI. Where the two collide the custody
|
||||||
|
argument wins, exactly as it does for the Android keystore.
|
||||||
|
|
||||||
|
**The forge grows by about 125 MB per release, forever.** Measured on the first real pack rather than
|
||||||
|
estimated: a 127 MB publish directory compresses to a 60 MB full `.nupkg` and a 64 MB `Setup.exe`, and both
|
||||||
|
are uploaded. Velopack needs the previous full package present to build a delta against, so pruning old
|
||||||
|
releases has a real cost — a client several versions behind falls back to a full download. A 64 MB asset is
|
||||||
|
also large enough to meet whatever body-size limit fronts `git.dodotech.cloud` (nginx's
|
||||||
|
`client_max_body_size` defaults to 1 MB), which is worth checking before the first upload rather than
|
||||||
|
discovering after ten minutes of transfer.
|
||||||
|
|
||||||
|
**Uninstalling leaves the vault cache behind, on purpose.** `%LOCALAPPDATA%\DodoSSH` survives, so a
|
||||||
|
reinstall finds an enrolled machine and asks for the passphrase — which is what somebody reinstalling wants.
|
||||||
|
The honest cost is that `device.key` is left too, and the account goes on listing a device that is no longer
|
||||||
|
installed. That is a wart rather than a hole: the blob is decryptable only by this machine's TPM, and Sign
|
||||||
|
Out is the button that withdraws the registration. An uninstall is not a sign-out and must not pretend to
|
||||||
|
be one.
|
||||||
|
|
||||||
|
**Contracts and client now share a version number.** `DodoSSH.Contracts` is packable, so a contracts-only
|
||||||
|
change ships as a client version bump. One number is the point; this is the price of it.
|
||||||
|
|
||||||
|
**The Android head's `versionName` now follows the same tag**, while its integer `versionCode` stays
|
||||||
|
hand-bumped, because Android needs a monotonic integer and SemVer does not provide one. Nothing else about
|
||||||
|
ADR 0011 changes.
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
- **MSIX.** Not a preference. It would break the terminal outright; see decision 1.
|
||||||
|
- **A CI job that cuts the release on a tag.** The convenience is the point of CI and the custody is the
|
||||||
|
point of ADR 0011; where they collide the custody wins. See decision 3, and note that a Windows runner
|
||||||
|
answers the mechanical objection while leaving the real one untouched.
|
||||||
|
- **A configurable update feed.** Named here because it is the change somebody will propose in good faith,
|
||||||
|
for a self-hosted product where making things configurable is usually right. It is the one setting that
|
||||||
|
would hand the operator the capability the whole trust model is about.
|
||||||
|
- **The deployment serving the installer.** Refused again, for the third ADR running, because it is
|
||||||
|
genuinely the nicest onboarding available and will be proposed again.
|
||||||
|
- **Conditioning updates on `MinClientVersion`.** See decision 5. It would create the attack rather than
|
||||||
|
inherit it.
|
||||||
|
- **Azure Trusted Signing**, for now, on custody grounds rather than on mechanics. See decision 8.
|
||||||
|
- **A hand-edited version property.** Considered seriously against MinVer, and it loses on the thing that
|
||||||
|
matters here: the tag is already the version of record for the container image, and two places to write
|
||||||
|
one number is a pair that can disagree. MinVer's silent-wrong-answer failure mode is real and is what the
|
||||||
|
two CI guards in decision 7 exist for.
|
||||||
@@ -1604,3 +1604,126 @@ client reconnects with a fresh one, which should be invisible.
|
|||||||
**Failure means:** notices stopping at roughly the token's lifetime is the reconnection not asking for a new
|
**Failure means:** notices stopping at roughly the token's lifetime is the reconnection not asking for a new
|
||||||
token — it would be dialling with the spent one and being closed again immediately. A burst of reconnection
|
token — it would be dialling with the spent one and being closed again immediately. A burst of reconnection
|
||||||
attempts in the server log is the same defect seen from the other end.
|
attempts in the server log is the same defect seen from the other end.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 16 — Installing the desktop client, and being updated by it
|
||||||
|
|
||||||
|
Nothing in this phase is reachable by a test, and not for the usual reason. There is no installed
|
||||||
|
application in CI, no `%LOCALAPPDATA%` worth inspecting, and the update path only exists across two builds
|
||||||
|
published minutes apart — so what is being checked is the shape of a release, which only exists once
|
||||||
|
somebody has cut one. What *is* automated is the machinery underneath: `UpdateFlowTests` drives every state
|
||||||
|
of the view model against a fake channel, and pins the one promise that matters
|
||||||
|
(`AReadyUpdate_IsNeverAppliedOnItsOwn`); `UpdateBannerTests` measures the banner at the window's minimum
|
||||||
|
width. Neither can install anything.
|
||||||
|
|
||||||
|
Walk it once per release, and in order — 16.6 onwards needs 16.1 to have happened.
|
||||||
|
|
||||||
|
Run `pwsh -File scripts/release-windows.ps1` first. It stops after packing, on purpose, so that everything
|
||||||
|
below happens before anything reaches a user.
|
||||||
|
|
||||||
|
### 16.1 The installer needs no administrator, and lands beside the vault rather than on it · **the one that would destroy data**
|
||||||
|
|
||||||
|
Run `Releases\DodoSSH.Desktop-win-Setup.exe` from an ordinary account. Then look at `%LOCALAPPDATA%`.
|
||||||
|
|
||||||
|
**Pass:** no UAC prompt; `%LOCALAPPDATA%\DodoSSH.Desktop\current\DodoSSH.exe` exists; a Start-menu entry
|
||||||
|
reading **DodoSSH**; and `%LOCALAPPDATA%\DodoSSH` either absent (a fresh machine) or **untouched**.
|
||||||
|
|
||||||
|
**Failure means:** a UAC prompt is a per-machine install, which is not what was designed. Anything written
|
||||||
|
into `%LOCALAPPDATA%\DodoSSH` is the pack id having drifted back to `DodoSSH`, and that is the serious one —
|
||||||
|
the uninstaller removes its whole install root, so it would take the vault cache and the outbox with it.
|
||||||
|
See [ADR 0013](adr/0013-desktop-distribution-and-updates.md) decision 2.
|
||||||
|
|
||||||
|
### 16.2 The installed path is short, measured rather than assumed
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
"$env:LOCALAPPDATA\DodoSSH.Desktop\current\DodoSSH.exe".Length
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pass:** under 100. It is 64 on an ordinary profile.
|
||||||
|
|
||||||
|
**Failure means:** folder redirection, or a very long profile path. The terminal is about to fail with
|
||||||
|
`CO_E_SERVER_EXEC_FAILURE` and name nothing — see the long-path entry in `platform-flags.md`, which is the
|
||||||
|
reason this check is a number rather than a shrug.
|
||||||
|
|
||||||
|
### 16.3 The window opens and carries its own icon
|
||||||
|
|
||||||
|
**Pass:** the Start-menu shortcut launches it, and the taskbar and Alt-Tab show the dodo mark rather than a
|
||||||
|
generic icon.
|
||||||
|
|
||||||
|
**Failure means:** `--icon` or `ApplicationIcon` did not survive packaging. Cosmetic, and the first thing
|
||||||
|
anybody notices.
|
||||||
|
|
||||||
|
### 16.4 A terminal connects from the installed build · **the one that would catch an AppContainer**
|
||||||
|
|
||||||
|
Sign in, unlock, open a shell against a real host, and type.
|
||||||
|
|
||||||
|
**Pass:** characters reach the remote and output comes back.
|
||||||
|
|
||||||
|
**Failure means:** if it hangs and then reports the WebView2 message after about fifteen seconds, the
|
||||||
|
renderer never attached — check whether packaging has given the process a package identity, which would put
|
||||||
|
WebView2 in an AppContainer where the loopback data plane cannot connect. That is the failure MSIX was ruled
|
||||||
|
out for, and this is the check that would find it in the Velopack path. `RendererTimeout` is where the
|
||||||
|
fifteen seconds comes from.
|
||||||
|
|
||||||
|
### 16.5 The version on screen is the version that was built
|
||||||
|
|
||||||
|
Right-click `DodoSSH.exe` → Properties → Details, and open PREFERENCES → UPDATES.
|
||||||
|
|
||||||
|
**Pass:** File version reads the tag (`0.1.0.0`), product **DodoSSH**, company **DodoTech**, and the
|
||||||
|
preferences screen prints the same number.
|
||||||
|
|
||||||
|
**Failure means:** `0.0.0.0` is MinVer never seeing a tag — a shallow clone, or `fetch-depth` having been
|
||||||
|
dropped from a checkout. `1.0.0.0` is somebody having wired the app manifest's inert `assemblyIdentity`
|
||||||
|
version to the real one. A version on screen that differs from the file properties means the two are being
|
||||||
|
read from different places, which is the thing having one number was for.
|
||||||
|
|
||||||
|
### 16.6 A second release produces a delta, not only a full package
|
||||||
|
|
||||||
|
Tag `v0.1.1` and run the script again.
|
||||||
|
|
||||||
|
**Pass:** `Releases\` holds both a `*-full.nupkg` and a `*-delta.nupkg`, and the delta is a small fraction
|
||||||
|
of the full.
|
||||||
|
|
||||||
|
**Failure means:** no delta at all is `vpk download gitea` having found nothing to build one against — the
|
||||||
|
previous release did not come down, so every user is about to fetch a ~60 MB full package for a one-line
|
||||||
|
change. The
|
||||||
|
script warns rather than failing when that is legitimate, which is the first release only.
|
||||||
|
|
||||||
|
### 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 PREFERENCES rather than waiting six hours.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
Press **RESTART NOW**: the application closes and reopens as 0.1.1, still enrolled, with the host change
|
||||||
|
intact.
|
||||||
|
|
||||||
|
**Failure means:** no banner is a channel mismatch between `vpk pack --channel` and
|
||||||
|
`VelopackUpdateChannel.ReleaseChannel`, which fails silently by design — the check succeeds, finds nothing,
|
||||||
|
and reports the client up to date forever. A banner sliced at the terminal's left edge is the occlusion rule
|
||||||
|
having been broken, and the fallback is to move the offer into the titlebar instead. Coming back as 0.1.0 is
|
||||||
|
the swap having been blocked, usually by a process still holding a file under `current\`. Being asked to
|
||||||
|
enrol again means the profile directory did not survive, which is 16.1's failure arriving late.
|
||||||
|
|
||||||
|
### 16.8 The first connect after an update is not a cold start
|
||||||
|
|
||||||
|
Immediately after 16.7, connect to a host.
|
||||||
|
|
||||||
|
**Pass:** the terminal appears about as quickly as it did before the update.
|
||||||
|
|
||||||
|
**Failure means:** the WebView2 user data folder is back inside `current\` and was destroyed by the update —
|
||||||
|
see the entry in `platform-flags.md`. Slow but working, so it gets dismissed as a fluke unless somebody is
|
||||||
|
looking for it, which is why it is a numbered check rather than a note.
|
||||||
|
|
||||||
|
### 16.9 Uninstalling removes the application and leaves the vault · **the data-loss check**
|
||||||
|
|
||||||
|
Settings → Apps → DodoSSH → Uninstall.
|
||||||
|
|
||||||
|
**Pass:** `%LOCALAPPDATA%\DodoSSH.Desktop` is gone, and `%LOCALAPPDATA%\DodoSSH` still holds `cache.db`,
|
||||||
|
`cache.db-wal` and `cache.db-shm` — all three, per the entry that says any routine touching only the first
|
||||||
|
is wrong. Reinstalling then asks for the passphrase rather than for a server.
|
||||||
|
|
||||||
|
**Failure means:** the cache going with the application is the pack-id collision, and whoever ran this has
|
||||||
|
lost their offline unlock and any change that was still in the outbox. That is the failure ADR 0013
|
||||||
|
decision 2 exists to prevent, and it is why 16.1 checks the same thing from the other end.
|
||||||
|
|||||||
+86
-2
@@ -217,8 +217,33 @@ execution failed") and the terminal never appears. Hit while building the harnes
|
|||||||
that failed from a ~230-character directory ran first time from `%TEMP%\h`. The exact threshold was not
|
that failed from a ~230-character directory ran first time from `%TEMP%\h`. The exact threshold was not
|
||||||
established and the mechanism is unconfirmed — the user data folder is created beside the executable by
|
established and the mechanism is unconfirmed — the user data folder is created beside the executable by
|
||||||
default and the browser process is launched with paths derived from it, so `MAX_PATH` is the obvious
|
default and the browser process is launched with paths derived from it, so `MAX_PATH` is the obvious
|
||||||
suspect. Relevant to packaging: an installer that lands under a deep per-user path would break the
|
suspect.
|
||||||
terminal with an error that names nothing.
|
|
||||||
|
*Measured for the packaged layout, so this stops being a worry and becomes a number.* Velopack installs to
|
||||||
|
`%LOCALAPPDATA%\DodoSSH.Desktop\current\`, and `…\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe` is
|
||||||
|
**64 characters** against the ~230 that reproduced the failure — about 180 characters of headroom, and a
|
||||||
|
40-character corporate username adds 35 of them back. The shipped installer is not at risk. Two things
|
||||||
|
would reopen it and neither is in the plan: a self-extracting single-file publish, whose native libraries
|
||||||
|
land under a hashed temp path, and `%LOCALAPPDATA%` folder-redirected to a deep UNC path in a domain.
|
||||||
|
Manual check 16.2 measures it on the real machine rather than trusting this paragraph.
|
||||||
|
|
||||||
|
**WebView2's user data folder must be kept out of the install directory.** It defaults to a directory
|
||||||
|
beside the host executable, which under Velopack is inside `current\` — and `current\` is *replaced* by
|
||||||
|
every update. Left alone, the browser profile would be destroyed on each one, so the first connect after
|
||||||
|
every update would pay a cold WebView2 start: a fresh user-data directory and a new process tree, which is
|
||||||
|
the slow path `RendererTimeout`'s fifteen seconds was sized for, arriving at the exact moment somebody is
|
||||||
|
most ready to believe the update broke the terminal. `Program.Main` sets `WEBVIEW2_USER_DATA_FOLDER` to
|
||||||
|
`%LOCALAPPDATA%\DodoSSH\WebView2` — under the profile directory, which Velopack never touches. Check 16.8
|
||||||
|
is what would notice it regressing, and it is worth having because the symptom is "slow but working", which
|
||||||
|
gets dismissed as a fluke.
|
||||||
|
|
||||||
|
**The install root and the profile directory must not be the same folder.** Velopack removes
|
||||||
|
`%LOCALAPPDATA%\<packId>` entirely on uninstall, and `ClientPaths` puts `cache.db` (plus `-wal` and `-shm`),
|
||||||
|
`settings.json` and `device.key` in `%LOCALAPPDATA%\DodoSSH`. So a pack id of `DodoSSH` — the obvious
|
||||||
|
choice — would have made the uninstaller delete the vault cache and the outbox of changes not yet pushed,
|
||||||
|
silently, which is the thing the application will not do without a counted confirmation. The pack id is
|
||||||
|
`DodoSSH.Desktop` for that reason and no other; `--packTitle` supplies the name people see, so nothing is
|
||||||
|
lost. Do not "tidy" it. See [ADR 0013](adr/0013-desktop-distribution-and-updates.md) and check 16.9.
|
||||||
|
|
||||||
**The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a
|
**The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a
|
||||||
downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child
|
downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child
|
||||||
@@ -329,6 +354,15 @@ AppContainer where loopback connections are blocked without a `CheckNetIsolation
|
|||||||
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
||||||
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
|
||||||
|
|
||||||
|
*Checked rather than assumed, now that Velopack is actually wired up:* its Windows path does not
|
||||||
|
reintroduce the thing MSIX was ruled out for. `Setup.exe` is an ordinary Win32 executable that unpacks a
|
||||||
|
directory under `%LOCALAPPDATA%` and creates shortcuts — there is no `AppxManifest`, no package identity,
|
||||||
|
no `runFullTrust`, no elevation and no execution alias, so the process stays an ordinary desktop process
|
||||||
|
and WebView2 stays out of an AppContainer. That is reasoning, not measurement; manual check 16.4 is the
|
||||||
|
measurement, because if a package identity ever did appear the symptom would be the terminal hanging and
|
||||||
|
then reporting the WebView2 message after fifteen seconds, which reads like a broken runtime rather than
|
||||||
|
like packaging.
|
||||||
|
|
||||||
**Linux ships AppImage and Flatpak first**, specifically so the WebKit runtime is bundled rather
|
**Linux ships AppImage and Flatpak first**, specifically so the WebKit runtime is bundled rather
|
||||||
than assumed present on the user's machine.
|
than assumed present on the user's machine.
|
||||||
|
|
||||||
@@ -486,6 +520,56 @@ so it cannot be discovered from Kestrel afterwards. The window for another proce
|
|||||||
few milliseconds; if the suite ever fails with an address-in-use, this is why, and a retry is the fix
|
few milliseconds; if the suite ever fails with an address-in-use, this is why, and a retry is the fix
|
||||||
rather than a redesign.
|
rather than a redesign.
|
||||||
|
|
||||||
|
**A RID must never reach the committed lock files, and the obvious fix for a RID-specific publish puts
|
||||||
|
one there.** `dotnet publish -r win-x64` resolves a graph the committed `packages.lock.json` files do not
|
||||||
|
describe — they carry a `net10.0` target and nothing else — so under locked mode it fails NU1004. The
|
||||||
|
obvious answer is `<RuntimeIdentifiers>win-x64</RuntimeIdentifiers>` on the desktop head plus a
|
||||||
|
`--force-evaluate` to regenerate. **That is wrong here, and it was tried and reverted.**
|
||||||
|
|
||||||
|
A RID declared on one project flows to every project it references transitively while restoring, so the
|
||||||
|
regenerated lock files for `DodoSSH.Contracts` and `DodoSSH.Crypto` grew a `net10.0/win-x64` target as
|
||||||
|
well — and those two are built by the *server*. The API's Dockerfile restores them with no RID and
|
||||||
|
`--locked-mode`, so it failed:
|
||||||
|
|
||||||
|
```
|
||||||
|
error NU1004: The project's runtime identifiers have changed from.
|
||||||
|
Project's runtime identifiers: , lock file's runtime identifiers win-x64.
|
||||||
|
```
|
||||||
|
|
||||||
|
Packaging the desktop client had broken the server's image build, and nothing but the `image` job would
|
||||||
|
have caught it. Found by running `docker build` locally rather than by reading the lock files.
|
||||||
|
|
||||||
|
So the RID stays out of the committed state, and the two commands that need one — the release script's
|
||||||
|
publish and the `windows publish still resolves` step in `ci.yml` — pass `-p:RestoreLockedMode=false` for
|
||||||
|
themselves alone. That restore rewrites the lock files as a side effect, which does not matter on a runner
|
||||||
|
whose checkout is discarded and does matter on a developer's machine, so the release script runs
|
||||||
|
`git checkout -- '*packages.lock.json'` afterwards. `-p:RestorePackagesWithLockFile=false` is not an
|
||||||
|
alternative: it fails NU1005 whenever a lock file already exists.
|
||||||
|
|
||||||
|
**A Docker `ARG` named `VERSION` silently sets MSBuild's `Version`.** An `ARG` is an environment variable
|
||||||
|
for the rest of the stage, MSBuild reads environment variables as global properties, and MSBuild property
|
||||||
|
names are case-insensitive — so `ARG VERSION` in a build stage sets `Version` for every project built in
|
||||||
|
it, with no line anywhere saying so. The workflow passes `main-<short sha>` on a main build, which is a
|
||||||
|
fine docker tag and not a version, and the publish died with `NETSDK1018: Invalid NuGet version string`
|
||||||
|
pointing at `DodoSSH.Contracts` — a project nobody had touched. The build stage's argument is therefore
|
||||||
|
`ASSEMBLY_VERSION`, passed empty except on a tag build; the `VERSION` arg in the final stage is only ever
|
||||||
|
an OCI label and never meets MSBuild. Renaming is the entire fix, and the reason it is written down is that
|
||||||
|
the symptom names the wrong project and the cause is invisible.
|
||||||
|
|
||||||
|
**System.Text.Json's source generator does not honour property initializers on a record.** Defaults for a
|
||||||
|
`ClientSettings`-style record must live on the **constructor parameters**, not on property initializers,
|
||||||
|
and getting it wrong fails silently in the worst direction. The generator emits an
|
||||||
|
`ObjectWithParameterizedConstructorCreator` — it treats the init-only properties as constructor arguments
|
||||||
|
and builds `new ClientSettings() { A = (T)args[0], … }`, so the initializer runs and is then overwritten by
|
||||||
|
`args`, which for a member absent from the JSON is the CLR default. Measured: a `settings.json` of `{}`
|
||||||
|
read back `TerminalFontSize` 0 (clamped up to the 8px floor, not the 13px the renderer draws at) and, once
|
||||||
|
it existed, `AutomaticUpdateChecks` false. **Reflection-based deserialisation of the same JSON answers 13
|
||||||
|
and true**, which is what makes it so easy to miss — every way of checking it by hand is right except the
|
||||||
|
one that ships. `JsonSourceGenerationMode.Metadata` does not help; it was tried. It stayed invisible while
|
||||||
|
there was one setting, because that setting was written on every save and so was never absent; it went live
|
||||||
|
the moment a second one was added, since every existing profile lacks the new key.
|
||||||
|
`ASettingAbsentFromTheFile_ComesBackAsItsDeclaredDefault` fails without the fix.
|
||||||
|
|
||||||
**`[CallerFilePath]` is rewritten to `/_/...` under `ContinuousIntegrationBuild`.** Any test that
|
**`[CallerFilePath]` is rewritten to `/_/...` under `ContinuousIntegrationBuild`.** Any test that
|
||||||
locates a fixture by source path passes locally and fails in CI. Copy fixtures to the output
|
locates a fixture by source path passes locally and fails in CI. Copy fixtures to the output
|
||||||
directory and read them via `AppContext.BaseDirectory` instead; `GoldenVectorTests` shows the
|
directory and read them via `AppContext.BaseDirectory` instead; `GoldenVectorTests` shows the
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Builds, packages and publishes the Windows desktop client.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Run by a person, on a Windows machine that is not a CI runner. That is not an accident of tooling —
|
||||||
|
docs/adr/0011-android-distribution.md rule 1 puts the capability to ship somebody a build on a machine
|
||||||
|
which is not a runner, and docs/adr/0013-desktop-distribution-and-updates.md explains why the token that
|
||||||
|
writes a Gitea release is that capability: Velopack clients trust their feed and do not verify a package
|
||||||
|
signature when they apply it, so whoever can write a release can ship an update every install runs.
|
||||||
|
|
||||||
|
Two phases, and the split is the design rather than a convenience.
|
||||||
|
|
||||||
|
1. Without -Upload: builds, packs, and stops. Nothing has left this machine.
|
||||||
|
Install the Setup.exe it names, and walk Phase 16 of docs/manual-checks.md.
|
||||||
|
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
|
||||||
|
so the bytes that reach users are the bytes that were installed and checked.
|
||||||
|
|
||||||
|
The token is prompted for rather than read from a file or an environment variable, and only in the phase
|
||||||
|
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
|
||||||
|
in a shell's memory the better.
|
||||||
|
|
||||||
|
.PARAMETER Upload
|
||||||
|
Publish the packages already in Releases/ instead of building.
|
||||||
|
|
||||||
|
.PARAMETER SkipTests
|
||||||
|
Skip the test run. For a re-pack of a tag CI has already gone green on.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
pwsh -File scripts/release-windows.ps1
|
||||||
|
pwsh -File scripts/release-windows.ps1 -Upload
|
||||||
|
#>
|
||||||
|
#Requires -Version 7.0
|
||||||
|
|
||||||
|
# PowerShell 7, and stated so the failure is a clear message rather than a confusing one: this script reads
|
||||||
|
# $IsWindows, which does not exist in Windows PowerShell 5.1 and under Set-StrictMode would throw about an
|
||||||
|
# unset variable — sending the reader after a typo rather than after the shell they are using.
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch] $Upload,
|
||||||
|
[switch] $SkipTests
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# Velopack's identity for this application, and it is effectively irreversible.
|
||||||
|
#
|
||||||
|
# It is what an installed client matches an update against and the directory it installs into, so changing
|
||||||
|
# it later orphans every existing install — still running, never updated, invisible to the new one.
|
||||||
|
#
|
||||||
|
# DodoSSH.Desktop and not DodoSSH, for a specific reason worth keeping next to the value: Velopack installs
|
||||||
|
# to %LOCALAPPDATA%\<packId> and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
|
||||||
|
# where ClientPaths keeps the encrypted cache, the outbox of changes not yet pushed, and the device key.
|
||||||
|
# Sharing the directory would mean the uninstaller silently taking a user's un-synced work with it.
|
||||||
|
$PackId = 'DodoSSH.Desktop'
|
||||||
|
|
||||||
|
# What a person sees, in the Start menu and in Add/Remove Programs. The distinct pack id costs nothing here.
|
||||||
|
$PackTitle = 'DodoSSH'
|
||||||
|
$PackAuthors = 'DodoTech'
|
||||||
|
|
||||||
|
# The project's own forge. Never a DodoSSH deployment — ADR 0011 rule 2. The same URL is a constant in
|
||||||
|
# VelopackUpdateChannel, and the two have to agree or the client polls somewhere nothing is published.
|
||||||
|
$RepoUrl = 'https://git.dodotech.cloud/DodoTech/DodoSSH'
|
||||||
|
|
||||||
|
# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it
|
||||||
|
# unsaid on both sides would work too — but unsaid here and stated there is how a feed goes quiet with no
|
||||||
|
# error at all: the client checks, finds nothing, and reports itself up to date forever.
|
||||||
|
$Channel = 'win'
|
||||||
|
|
||||||
|
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$Project = Join-Path $RepoRoot 'src/DodoSSH.Client.App/DodoSSH.Client.App.csproj'
|
||||||
|
$PublishDir = Join-Path $RepoRoot 'publish/win-x64'
|
||||||
|
$ReleasesDir = Join-Path $RepoRoot 'Releases'
|
||||||
|
|
||||||
|
function Write-Step([string] $Message) {
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-With([string] $Message) {
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host $Message -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $IsWindows) {
|
||||||
|
# vpk stamps and embeds the Setup.exe and Update.exe stubs with Windows tooling. This is the smaller of
|
||||||
|
# the two reasons a runner cannot do this job; see the comment at the foot of .github/workflows/ci.yml
|
||||||
|
# for the larger one.
|
||||||
|
Stop-With 'This builds a Windows package and has to run on Windows.'
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location $RepoRoot
|
||||||
|
try {
|
||||||
|
# ---- What is being released -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
$version = (& dotnet msbuild $Project -getProperty:Version -nologo) -replace '\s', ''
|
||||||
|
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||||
|
Stop-With 'Could not read the version from MSBuild.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = "v$version"
|
||||||
|
|
||||||
|
Write-Step "DodoSSH $version ($PackId, channel $Channel)"
|
||||||
|
|
||||||
|
if ($Upload) {
|
||||||
|
# ---- Phase 2: publish what phase 1 built ------------------------------------------------------
|
||||||
|
|
||||||
|
$setup = Get-ChildItem $ReleasesDir -Filter '*Setup*.exe' -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if (-not $setup) {
|
||||||
|
Stop-With "Nothing to upload: $ReleasesDir has no Setup executable. Run this without -Upload first."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "About to publish the contents of $ReleasesDir to $RepoUrl as $tag."
|
||||||
|
Write-Host 'Only do this once you have installed it and walked Phase 16 of docs/manual-checks.md.'
|
||||||
|
|
||||||
|
# Read-Host -AsSecureString so the token is never echoed and never lands in the shell's history.
|
||||||
|
$secure = Read-Host -Prompt 'Gitea token (write:repository)' -AsSecureString
|
||||||
|
$token = [System.Net.NetworkCredential]::new('', $secure).Password
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($token)) {
|
||||||
|
Stop-With 'No token given.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
|
||||||
|
# fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
|
||||||
|
# same tag, so a release candidate is a prerelease in both channels or in neither.
|
||||||
|
$uploadArgs = @(
|
||||||
|
'upload', 'gitea',
|
||||||
|
'--repoUrl', $RepoUrl,
|
||||||
|
'--token', $token,
|
||||||
|
'--outputDir', $ReleasesDir,
|
||||||
|
'--channel', $Channel,
|
||||||
|
'--releaseName', $tag,
|
||||||
|
'--tag', $tag,
|
||||||
|
'--merge',
|
||||||
|
'--publish'
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($version -match '-') {
|
||||||
|
$uploadArgs += '--pre'
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step 'Uploading'
|
||||||
|
& dotnet vpk @uploadArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
|
||||||
|
|
||||||
|
Write-Step "Published $tag."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Phase 1: build and pack ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
if ((git status --porcelain) -ne $null) {
|
||||||
|
Stop-With 'The working tree is not clean. A release is cut from a commit, not from a desk.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$headTag = git describe --exact-match --tags HEAD 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($headTag)) {
|
||||||
|
Stop-With "HEAD is not tagged. Tag it $tag first, or change the version and tag that."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($headTag -ne $tag) {
|
||||||
|
# Cannot happen while MinVer is deriving the version from this very tag, and checked anyway: the
|
||||||
|
# day somebody pins a version by hand this is the guard that notices.
|
||||||
|
Stop-With "HEAD is tagged $headTag but the computed version is $version."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step 'Restoring tools'
|
||||||
|
& dotnet tool restore
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'dotnet tool restore failed.' }
|
||||||
|
|
||||||
|
Write-Step 'Restoring packages (locked, exactly as CI does)'
|
||||||
|
& dotnet restore (Join-Path $RepoRoot 'DodoSSH.slnx') --locked-mode
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed. A lock file that only works on Linux fails here.' }
|
||||||
|
|
||||||
|
Write-Step 'Building'
|
||||||
|
& dotnet build (Join-Path $RepoRoot 'DodoSSH.slnx') --no-restore --configuration Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'Build failed.' }
|
||||||
|
|
||||||
|
if (-not $SkipTests) {
|
||||||
|
# The end-to-end suite starts containers and takes minutes. It is run here anyway rather than taken
|
||||||
|
# on trust from CI, because a tag is the one build nobody is watching — the same argument ci.yml
|
||||||
|
# already makes for running the whole workflow on a tag.
|
||||||
|
Write-Step 'Testing'
|
||||||
|
& dotnet test (Join-Path $RepoRoot 'DodoSSH.slnx') --no-build --configuration Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'Tests failed.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step 'Publishing win-x64'
|
||||||
|
if (Test-Path $PublishDir) { Remove-Item $PublishDir -Recurse -Force }
|
||||||
|
|
||||||
|
# Self-contained: .NET 10 is recent enough that almost no machine has the runtime, and the usual
|
||||||
|
# objection — that runtime patches then need an application update — is answered by the updater this
|
||||||
|
# very script exists to feed. Not single-file: the native libraries ship per RID, deltas would stop
|
||||||
|
# working, and a self-extracting bundle puts the executable under a temp path deep enough to break
|
||||||
|
# WebView2 (docs/platform-flags.md).
|
||||||
|
# RestoreLockedMode=false, and the lock files put back straight afterwards. Both halves need saying.
|
||||||
|
#
|
||||||
|
# A RID-specific publish resolves a graph the committed lock files do not describe, because they are
|
||||||
|
# deliberately kept RID-free: declaring win-x64 on the project writes a net10.0/win-x64 target into
|
||||||
|
# every project it references transitively, including DodoSSH.Contracts and DodoSSH.Crypto — and the
|
||||||
|
# API's Dockerfile then restores those with no RID under locked mode and fails NU1004. Packaging the
|
||||||
|
# desktop client would have broken the server's image build. See the comment in the head's csproj.
|
||||||
|
#
|
||||||
|
# So this one command restores unlocked. It is a supervised build, from a tag, run by a person; the
|
||||||
|
# gate that matters is the locked solution restore two steps above, which is untouched and is the same
|
||||||
|
# command CI runs.
|
||||||
|
& dotnet publish $Project `
|
||||||
|
--configuration Release `
|
||||||
|
--runtime win-x64 `
|
||||||
|
--self-contained true `
|
||||||
|
--output $PublishDir `
|
||||||
|
-p:RestoreLockedMode=false
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'Publish failed.' }
|
||||||
|
|
||||||
|
# An unlocked restore rewrites the lock files it walked, adding the win-x64 target. Left there, the
|
||||||
|
# next commit would carry exactly the change that breaks the image build — so they go back. Safe to do
|
||||||
|
# bluntly because this script refuses to run on a dirty tree, so anything modified here is its own.
|
||||||
|
& git checkout -- '*packages.lock.json'
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'Could not restore the lock files after publishing.' }
|
||||||
|
|
||||||
|
# Checked rather than assumed. A publish directory without Velopack.dll would pack into an installer for
|
||||||
|
# an application that never checks for updates — which looks completely normal until the next release
|
||||||
|
# goes out and nobody receives it.
|
||||||
|
foreach ($required in @('DodoSSH.exe', 'Velopack.dll')) {
|
||||||
|
if (-not (Test-Path (Join-Path $PublishDir $required))) {
|
||||||
|
Stop-With "$required is missing from $PublishDir."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sizeMb = [math]::Round(((Get-ChildItem $PublishDir -Recurse -File | Measure-Object Length -Sum).Sum / 1MB), 1)
|
||||||
|
Write-Host " $sizeMb MB in $((Get-ChildItem $PublishDir -Recurse -File).Count) files"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $ReleasesDir | Out-Null
|
||||||
|
|
||||||
|
# The previous release, so a delta can be built against it. Tolerated when it finds nothing: the first
|
||||||
|
# release has no predecessor, and a hard failure here would make cutting it impossible.
|
||||||
|
Write-Step 'Fetching the previous release, for deltas'
|
||||||
|
& dotnet vpk download gitea --repoUrl $RepoUrl --outputDir $ReleasesDir --channel $Channel
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host ' Nothing came down. This package will be full-only, which is right for a first release.' -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step 'Packing'
|
||||||
|
|
||||||
|
# No --signParams. Every installer therefore raises SmartScreen's "Windows protected your PC" on first
|
||||||
|
# run, once per user — Mark-of-the-Web is applied by the browser that downloads Setup.exe, so in-app
|
||||||
|
# updates, which this application fetches itself and applies from a local file, never trip it.
|
||||||
|
#
|
||||||
|
# This is the one line that changes when a certificate is bought. See ADR 0013 for what it costs and
|
||||||
|
# what the trigger for buying one is.
|
||||||
|
& dotnet vpk pack `
|
||||||
|
--packId $PackId `
|
||||||
|
--packVersion $version `
|
||||||
|
--packDir $PublishDir `
|
||||||
|
--packTitle $PackTitle `
|
||||||
|
--packAuthors $PackAuthors `
|
||||||
|
--mainExe 'DodoSSH.exe' `
|
||||||
|
--icon (Join-Path $RepoRoot 'src/DodoSSH.Client.App/Assets/dodossh.ico') `
|
||||||
|
--channel $Channel `
|
||||||
|
--outputDir $ReleasesDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk pack failed.' }
|
||||||
|
|
||||||
|
Write-Step 'Built, and deliberately not uploaded'
|
||||||
|
|
||||||
|
Get-ChildItem $ReleasesDir -File |
|
||||||
|
Sort-Object Length -Descending |
|
||||||
|
Select-Object Name, @{ n = 'MB'; e = { [math]::Round($_.Length / 1MB, 1) } } |
|
||||||
|
Format-Table -AutoSize
|
||||||
|
|
||||||
|
Write-Host 'Next:'
|
||||||
|
Write-Host " 1. Install the Setup executable above and walk Phase 16 of docs/manual-checks.md."
|
||||||
|
Write-Host ' 2. Then: pwsh -File scripts/release-windows.ps1 -Upload'
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -50,10 +50,37 @@ RUN dotnet restore src/DodoSSH.Api/DodoSSH.Api.csproj --locked-mode
|
|||||||
COPY BannedSymbols.txt .editorconfig ./
|
COPY BannedSymbols.txt .editorconfig ./
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
|
|
||||||
|
# The version, handed in rather than derived, because there is no repository in here to derive
|
||||||
|
# it from: MinVer reads git tags, and .dockerignore excludes .git/ deliberately — the context is
|
||||||
|
# the repository root and copying the whole history into every image build would be absurd.
|
||||||
|
#
|
||||||
|
# Without this the build still succeeds (MINVER1001 is a warning, and TreatWarningsAsErrors does
|
||||||
|
# not escalate a task warning), and that is the trap: the image would be built with the SDK's
|
||||||
|
# fallback version and GET /api/v1/meta would report 0.0.0-alpha.0 as its serverVersion, which is
|
||||||
|
# a lie told quietly. The tag is already parsed by the workflow for the image tags, so it is the
|
||||||
|
# same number, passed one step further.
|
||||||
|
#
|
||||||
|
# MinVerSkip because there is nothing here for it to do, and it should not warn about it either.
|
||||||
|
#
|
||||||
|
# ASSEMBLY_VERSION and emphatically not VERSION, which is the trap this block exists to avoid and
|
||||||
|
# which cost a build to find. An ARG is an environment variable for the rest of the stage, MSBuild
|
||||||
|
# reads environment variables as global properties, and property names are case-insensitive — so an
|
||||||
|
# `ARG VERSION` in a build stage silently sets MSBuild's `Version` for every project in it. With the
|
||||||
|
# workflow passing `main-<short sha>` on a main build, that is not a version the SDK will accept, and
|
||||||
|
# the publish dies with NETSDK1018 "Invalid NuGet version string" pointing at DodoSSH.Contracts, a
|
||||||
|
# project nobody changed. The name is the whole fix; the ARG in the final stage below is only ever a
|
||||||
|
# label and never meets MSBuild.
|
||||||
|
#
|
||||||
|
# The workflow passes this empty except on a tag build, so a main image keeps the SDK default rather
|
||||||
|
# than carrying a version that is not one.
|
||||||
|
ARG ASSEMBLY_VERSION=""
|
||||||
|
|
||||||
RUN dotnet publish src/DodoSSH.Api/DodoSSH.Api.csproj \
|
RUN dotnet publish src/DodoSSH.Api/DodoSSH.Api.csproj \
|
||||||
--no-restore \
|
--no-restore \
|
||||||
--configuration Release \
|
--configuration Release \
|
||||||
--output /app \
|
--output /app \
|
||||||
|
-p:MinVerSkip=true \
|
||||||
|
${ASSEMBLY_VERSION:+-p:Version="$ASSEMBLY_VERSION"} \
|
||||||
-p:UseAppHost=false
|
-p:UseAppHost=false
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -44,6 +44,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"FastEndpoints.Attributes": {
|
"FastEndpoints.Attributes": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.2.0",
|
"resolved": "8.2.0",
|
||||||
|
|||||||
@@ -20,8 +20,14 @@
|
|||||||
<TargetPlatformVersion>36</TargetPlatformVersion>
|
<TargetPlatformVersion>36</TargetPlatformVersion>
|
||||||
|
|
||||||
<ApplicationId>dev.dodotech.dodossh</ApplicationId>
|
<ApplicationId>dev.dodotech.dodossh</ApplicationId>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
versionCode, and it stays a hand-bumped literal. Android requires a monotonically increasing
|
||||||
|
integer and SemVer does not give you one — deriving it from the version would work until the day
|
||||||
|
a patch number reached 10 and the arithmetic went backwards, which is the sort of failure that
|
||||||
|
surfaces as an upload Google refuses for reasons it will not explain.
|
||||||
|
-->
|
||||||
<ApplicationVersion>1</ApplicationVersion>
|
<ApplicationVersion>1</ApplicationVersion>
|
||||||
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
False here for the same reason the desktop head sets it false: this process formats timestamps
|
False here for the same reason the desktop head sets it false: this process formats timestamps
|
||||||
@@ -30,6 +36,31 @@
|
|||||||
<InvariantGlobalization>false</InvariantGlobalization>
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
versionName, which is the string a person sees in Android's app info, taken from the same v* tag
|
||||||
|
as everything else so the two heads cannot claim different versions of one product.
|
||||||
|
|
||||||
|
It has to be a target rather than a property, and that is the whole reason this is nine lines
|
||||||
|
instead of one. MinVer computes the version in a target of its own, so at evaluation time $(Version)
|
||||||
|
is still the SDK's default 1.0.0 — an ApplicationDisplayVersion set in a PropertyGroup would read
|
||||||
|
as though it were derived and would ship 1.0.0 forever. Running after MinVer is what makes it true.
|
||||||
|
|
||||||
|
Major.Minor.Patch, without MinVer's prerelease or build-metadata parts: versionName is a free string
|
||||||
|
to Android, but it is shown to users, and "0.2.0-alpha.0.7+1a2b3c4" is not a version anybody can
|
||||||
|
read back to you over a support conversation.
|
||||||
|
-->
|
||||||
|
<Target Name="UseTheDerivedVersionForAndroid" AfterTargets="MinVer" DependsOnTargets="MinVer">
|
||||||
|
<PropertyGroup>
|
||||||
|
<!--
|
||||||
|
Guarded, because the failure without it is silent and absurd: MinVerMajor and its two siblings
|
||||||
|
are empty until MinVer has run, so an unguarded assignment yields the versionName ".." — three
|
||||||
|
characters that are a legal Android versionName and are what the phone would then show. Measured,
|
||||||
|
not imagined; invoking this target on its own produced exactly that.
|
||||||
|
-->
|
||||||
|
<ApplicationDisplayVersion Condition="'$(MinVerMajor)' != ''">$(MinVerMajor).$(MinVerMinor).$(MinVerPatch)</ApplicationDisplayVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- The phone's control styles. The palette they draw from lives in Shell and is shared. -->
|
<!-- The phone's control styles. The palette they draw from lives in Shell and is shared. -->
|
||||||
<AvaloniaResource Include="Theme/**" />
|
<AvaloniaResource Include="Theme/**" />
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"dodossh.client.auth": {
|
"dodossh.client.auth": {
|
||||||
"type": "Project"
|
"type": "Project"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -72,6 +72,24 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>The terminal workspace, with its loopback listener already up.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Extracted so that constructing it and starting it cannot drift apart: the data plane's socket has to
|
||||||
|
/// be listening before the renderer attaches, and a workspace handed out un-started is one whose first
|
||||||
|
/// connect fails for a reason nothing on screen would explain.
|
||||||
|
/// </remarks>
|
||||||
|
private static TerminalWorkspace StartedWorkspace(SshNetConnectionFactory connections)
|
||||||
|
{
|
||||||
|
var workspace = new TerminalWorkspace(
|
||||||
|
new AvaloniaTerminalAssetProvider(),
|
||||||
|
connections,
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
workspace.Start();
|
||||||
|
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
var paths = ClientPaths.Default;
|
var paths = ClientPaths.Default;
|
||||||
@@ -87,19 +105,17 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
// and the same host key decision, and composing two would mean two snapshots of the pins.
|
// and the same host key decision, and composing two would mean two snapshots of the pins.
|
||||||
var connections = new SshNetConnectionFactory(knownHosts);
|
var connections = new SshNetConnectionFactory(knownHosts);
|
||||||
|
|
||||||
var workspace = new TerminalWorkspace(
|
var workspace = StartedWorkspace(connections);
|
||||||
new AvaloniaTerminalAssetProvider(),
|
|
||||||
connections,
|
|
||||||
TimeProvider.System);
|
|
||||||
|
|
||||||
workspace.Start();
|
|
||||||
|
|
||||||
var browser = new SystemBrowserLauncher();
|
var browser = new SystemBrowserLauncher();
|
||||||
|
|
||||||
// Chosen once, here, because it is a property of the machine and not of any session. A computer with
|
// Both chosen once, here, because each is a property of the machine rather than of any session. A
|
||||||
// a usable TPM gets the store that keeps a device key behind a Windows consent prompt; anything else
|
// computer with a usable TPM gets the store that keeps a device key behind a Windows consent prompt;
|
||||||
// gets one that reports itself unavailable, so unlock keeps asking for the passphrase. See ADR 0007.
|
// anything else gets one that reports itself unavailable, so unlock keeps asking for the passphrase
|
||||||
|
// (ADR 0007). The update channel answers the same shape of question about how this copy was
|
||||||
|
// installed, and a build run from a checkout likewise gets one that says so. See ADR 0013.
|
||||||
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
|
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
|
||||||
|
var updates = UpdateChannels.ForThisMachine();
|
||||||
|
|
||||||
var viewModel = new MainWindowViewModel(
|
var viewModel = new MainWindowViewModel(
|
||||||
paths,
|
paths,
|
||||||
@@ -120,7 +136,8 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
|
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
|
||||||
.ConfigureAwait(false),
|
.ConfigureAwait(false),
|
||||||
|
|
||||||
copyToClipboard: ClipboardWriter(desktop));
|
copyToClipboard: ClipboardWriter(desktop),
|
||||||
|
updates: updates);
|
||||||
|
|
||||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,35 @@
|
|||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The project is named for its layer and the executable is named for the product. Users see the
|
||||||
|
executable — in Task Manager, in a shortcut's target, in the SmartScreen dialog an unsigned
|
||||||
|
installer raises — and DodoSSH.Client.App.exe reads as an implementation detail leaking out.
|
||||||
|
|
||||||
|
Safe to change now and not later: it becomes the main executable recorded in every Velopack
|
||||||
|
package, and changing it after a release means the updater looks for an executable that is no
|
||||||
|
longer there. Nothing else depends on the name — no avares://DodoSSH.Client.App URI exists, the
|
||||||
|
window icon is the assembly-relative /Assets/dodossh.ico, and WindowsDeviceKeyStore names its
|
||||||
|
CNG key with a literal.
|
||||||
|
-->
|
||||||
|
<AssemblyName>DodoSSH</AssemblyName>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
No RuntimeIdentifiers here, and that is the considered answer rather than an omission.
|
||||||
|
|
||||||
|
Declaring win-x64 is the obvious way to let a self-contained publish restore, and it was tried:
|
||||||
|
it works, and it also writes a net10.0/win-x64 target into the lock file of every project this
|
||||||
|
one references transitively — which includes DodoSSH.Contracts and DodoSSH.Crypto, which the
|
||||||
|
server builds too. The API's Dockerfile then restores those projects with no RID and locked
|
||||||
|
mode, and fails NU1004 on a lock file that has grown a runtime identifier the server knows
|
||||||
|
nothing about. The desktop head's packaging would have broken the server's image build.
|
||||||
|
|
||||||
|
So the RID stays out of the committed state entirely, and the publish that needs one asks for
|
||||||
|
it unlocked — see scripts/release-windows.ps1 and the CI step that proves the same thing. The
|
||||||
|
locked restore that gates every ordinary build is untouched, which is the property worth
|
||||||
|
keeping. See docs/platform-flags.md.
|
||||||
|
-->
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The icon on the executable itself — what Explorer, the Start menu and a pinned taskbar button
|
The icon on the executable itself — what Explorer, the Start menu and a pinned taskbar button
|
||||||
draw, all of which read it from the PE resource and never start the process. Window.Icon in
|
draw, all of which read it from the PE resource and never start the process. Window.Icon in
|
||||||
@@ -31,6 +60,33 @@
|
|||||||
<AvaloniaResource Include="Assets/dodossh.ico" />
|
<AvaloniaResource Include="Assets/dodossh.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Two native symbol files, and they are the reason a self-contained publish weighed 227 MB.
|
||||||
|
|
||||||
|
Measured on a win-x64 publish: libSkiaSharp.pdb is 80.1 MB and libHarfBuzzSharp.pdb is 19.9 MB, so
|
||||||
|
the two of them are 100 MB of debug symbols for third-party native code nobody here will ever step
|
||||||
|
through. Dropping them takes the publish to about 127 MB, which is most of what a first install
|
||||||
|
costs somebody on a slow connection.
|
||||||
|
|
||||||
|
Our own symbols stay, and the distinction is the point rather than a compromise. All fifteen managed
|
||||||
|
PDBs together are 0.93 MB, and with them present an Exception.ToString() carries file names and line
|
||||||
|
numbers — which for a self-hosted product is the whole diagnostic channel, because the way a fault
|
||||||
|
gets reported is a user pasting a stack into an issue.
|
||||||
|
|
||||||
|
Named one by one instead of matched by a pattern. A rule like "drop every .pdb whose assembly is
|
||||||
|
native" would be shorter and would silently start dropping ours the day a managed library ships a
|
||||||
|
file this heuristic misreads, and a build that quietly stops carrying line numbers is not a thing
|
||||||
|
anybody notices until they need them.
|
||||||
|
-->
|
||||||
|
<Target Name="DropNativeSymbolsFromPublish" AfterTargets="ComputeResolvedFilesToPublishList">
|
||||||
|
<ItemGroup>
|
||||||
|
<ResolvedFileToPublish
|
||||||
|
Remove="@(ResolvedFileToPublish)"
|
||||||
|
Condition="'%(Filename)%(Extension)' == 'libSkiaSharp.pdb'
|
||||||
|
or '%(Filename)%(Extension)' == 'libHarfBuzzSharp.pdb'" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" />
|
<PackageReference Include="Avalonia" />
|
||||||
<PackageReference Include="Avalonia.Desktop" />
|
<PackageReference Include="Avalonia.Desktop" />
|
||||||
@@ -38,6 +94,14 @@
|
|||||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||||
<PackageReference Include="Avalonia.Controls.WebView" />
|
<PackageReference Include="Avalonia.Controls.WebView" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
|
<!--
|
||||||
|
Here and in no other project. The Android head must never reference it — that head's
|
||||||
|
distribution is settled by ADR 0011 and has no updater — and DodoSSH.Client.Shell is shared
|
||||||
|
between the two heads, so the one file that names Velopack lives in Platform/ beside
|
||||||
|
WindowsDeviceKeyStore, which is the same shape of thing: a Windows-only implementation of an
|
||||||
|
interface declared in DodoSSH.Client.Session.
|
||||||
|
-->
|
||||||
|
<PackageReference Include="Velopack" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using Velopack;
|
||||||
|
using Velopack.Sources;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chooses the update channel this machine can actually use.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Decided once, at composition, from a property of the machine — the same shape as
|
||||||
|
/// <c>DesktopDeviceKeyStores.ForThisMachine</c>, and for the same reason: whether this copy can replace
|
||||||
|
/// itself does not change while it runs, and a check repeated at each call site is a check somebody
|
||||||
|
/// eventually forgets.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class UpdateChannels
|
||||||
|
{
|
||||||
|
/// <summary>The channel for this machine, or one that reports itself unavailable.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Two conditions, and the second is the one that matters in development. Velopack's
|
||||||
|
/// <c>IsInstalled</c> is false when the process is not running from an installed layout — which is
|
||||||
|
/// every <c>dotnet run</c>, every build started from an IDE, and every copy somebody extracted from
|
||||||
|
/// an archive by hand. Reaching into the updater from one of those does not fail politely.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Constructing an <see cref="UpdateManager"/> is what answers the question, and constructing one is
|
||||||
|
/// cheap — it reads the layout on disk and talks to nothing. The network is not touched until
|
||||||
|
/// somebody asks for a check.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static IUpdateChannel ForThisMachine()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return new UnavailableUpdateChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var manager = VelopackUpdateChannel.CreateManager();
|
||||||
|
|
||||||
|
return manager.IsInstalled
|
||||||
|
? new VelopackUpdateChannel(manager)
|
||||||
|
: new UnavailableUpdateChannel();
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// A machine whose install layout cannot be read is a machine with no updater, which is a
|
||||||
|
// state this application already knows how to be in. Refusing to start an SSH client over
|
||||||
|
// it would be the wrong trade by a wide margin.
|
||||||
|
return new UnavailableUpdateChannel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Windows update channel, backed by Velopack against the project's own forge.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The one file in the repository that names Velopack. It lives beside <c>WindowsDeviceKeyStore</c>
|
||||||
|
/// rather than in a project of its own because it is the same kind of thing — a Windows-only
|
||||||
|
/// implementation of an interface declared in <c>DodoSSH.Client.Session</c> — and because
|
||||||
|
/// <c>DodoSSH.Client.Shell</c> is shared with the Android head, which must never acquire an updater.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Where builds come from, and it is a constant on purpose.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>This must never become a setting.</b> ADR 0011 rule 2 says the deployment a client signs in to
|
||||||
|
/// is never where the client comes from, and it says the same about the update check: an operator who
|
||||||
|
/// can answer "is there a newer version" can answer "no" forever, and pin a chosen user to a build
|
||||||
|
/// with a known hole without holding any key. A configurable feed URL is exactly the knob that would
|
||||||
|
/// hand them that, whether through a settings screen or through somebody editing the plaintext
|
||||||
|
/// settings.json by hand. A constant is that rule expressed structurally rather than as a convention
|
||||||
|
/// somebody has to keep.
|
||||||
|
/// </remarks>
|
||||||
|
private const string RepositoryUrl = "https://git.dodotech.cloud/DodoTech/DodoSSH";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The release channel to read, and it is stated rather than left to the default.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A contract with <c>scripts/release-windows.ps1</c>, which passes the same word to <c>vpk pack</c>.
|
||||||
|
/// It happens to be Velopack's Windows default, so leaving it unsaid on both sides would work too —
|
||||||
|
/// but unsaid on one side and stated on the other is how a feed goes quiet with no error anywhere:
|
||||||
|
/// the check succeeds, finds nothing, and reports that the client is up to date forever.
|
||||||
|
/// </remarks>
|
||||||
|
private const string ReleaseChannel = "win";
|
||||||
|
|
||||||
|
private readonly UpdateManager manager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The last thing a check found, kept so that a download and an apply can name it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Velopack's <c>UpdateInfo</c> carries the delta chain and the target asset, and none of that should
|
||||||
|
/// cross the seam — the shell has no use for it and a test would have to construct it. So the record
|
||||||
|
/// handed upwards is a version string, and this is where the real answer waits to be matched back up.
|
||||||
|
/// </remarks>
|
||||||
|
private UpdateInfo? found;
|
||||||
|
|
||||||
|
internal VelopackUpdateChannel(UpdateManager manager) => this.manager = manager;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsSupported => true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// From the assembly rather than from <c>manager.CurrentVersion</c>, so that this and the version an
|
||||||
|
/// un-updatable build reports come from one place. Two ways of answering the same question is how
|
||||||
|
/// they come to disagree.
|
||||||
|
/// </remarks>
|
||||||
|
public string CurrentVersion => ClientVersion.Current;
|
||||||
|
|
||||||
|
internal static UpdateManager CreateManager() =>
|
||||||
|
new(
|
||||||
|
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: false),
|
||||||
|
new UpdateOptions { ExplicitChannel = ReleaseChannel });
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// CheckForUpdatesAsync takes no token of its own, so cancellation is observed on either side of
|
||||||
|
// it rather than during. The call is one HTTPS request against a small JSON document; the worst
|
||||||
|
// case is a lock-up already bounded by the handler's own timeout.
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var update = await manager.CheckForUpdatesAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (update is null)
|
||||||
|
{
|
||||||
|
found = null;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
found = update;
|
||||||
|
|
||||||
|
return new AvailableUpdate(update.TargetFullRelease.Version.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task DownloadAsync(
|
||||||
|
AvailableUpdate update,
|
||||||
|
IProgress<int> progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(update);
|
||||||
|
ArgumentNullException.ThrowIfNull(progress);
|
||||||
|
|
||||||
|
// Velopack reports progress as an Action<int> and the rest of this codebase speaks IProgress<T>,
|
||||||
|
// so the adaptation happens here rather than leaking the older shape into the view models.
|
||||||
|
return manager.DownloadUpdatesAsync(Matched(update), progress.Report, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ApplyAndRestart(AvailableUpdate update)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(update);
|
||||||
|
|
||||||
|
// Does not return: the process is replaced. Anything that needed to happen before the window
|
||||||
|
// closes has to have happened already — see the shell's restart command, which disposes first.
|
||||||
|
manager.ApplyUpdatesAndRestart(Matched(update).TargetFullRelease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The guard exists because the seam narrows <c>UpdateInfo</c> down to a version string, so nothing in
|
||||||
|
/// the type system stops a caller inventing one. Every legitimate caller passes back exactly what
|
||||||
|
/// <see cref="CheckAsync"/> returned; a mismatch is a bug in this application rather than anything a
|
||||||
|
/// user did, which is why it throws rather than resolving to some safe-looking default.
|
||||||
|
/// </remarks>
|
||||||
|
private UpdateInfo Matched(AvailableUpdate update)
|
||||||
|
{
|
||||||
|
if (found is not { } info
|
||||||
|
|| !string.Equals(info.TargetFullRelease.Version.ToString(), update.Version, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"No update matching {update.Version} has been found by this channel. "
|
||||||
|
+ "Call CheckAsync and pass back what it returned.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using Velopack;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App;
|
namespace DodoSSH.Client.App;
|
||||||
|
|
||||||
@@ -10,11 +12,78 @@ internal static class Program
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <c>STAThread</c> is required, not decorative: WebView2 checks the apartment state and refuses
|
/// <c>STAThread</c> is required, not decorative: WebView2 checks the apartment state and refuses
|
||||||
/// to initialise on an MTA thread. Without it the terminal is simply blank on Windows.
|
/// to initialise on an MTA thread. Without it the terminal is simply blank on Windows. It applies to
|
||||||
|
/// everything below, which is why the Velopack call lives inside this method rather than in an entry
|
||||||
|
/// point of its own.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[STAThread]
|
[STAThread]
|
||||||
public static void Main(string[] args) =>
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
// First, before Avalonia is even configured.
|
||||||
|
//
|
||||||
|
// The installer, the updater and the uninstaller all re-run this executable with arguments that
|
||||||
|
// mean "do the install bookkeeping and stop". Run() is what notices, does it, and exits — so on
|
||||||
|
// those runs nothing below happens at all, and that is the point rather than a side effect:
|
||||||
|
// DodoSshApp.Compose opens the SQLite cache and starts the terminal workspace's listening socket,
|
||||||
|
// and a silent installer run that reached either would be a background process holding the cache
|
||||||
|
// file open during the very file operations the installer is performing.
|
||||||
|
//
|
||||||
|
// There are deliberately no OnFirstRun or OnAfterUpdate hooks. A hook process has no passphrase,
|
||||||
|
// so the cache is bytes it cannot read, and the one thing that would want doing after an update —
|
||||||
|
// a schema migration — already runs on every ordinary launch from MainWindowViewModel.StartAsync,
|
||||||
|
// before unlock and touching no encrypted content.
|
||||||
|
VelopackApp.Build().Run();
|
||||||
|
|
||||||
|
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||||
|
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// WebView2 defaults this to a directory next to the host executable. Under a Velopack install that is
|
||||||
|
/// <c>%LOCALAPPDATA%\DodoSSH.Desktop\current\</c>, and <c>current\</c> is <em>replaced</em> by every
|
||||||
|
/// update — so the browser profile would be destroyed on each one, and the first connect afterwards
|
||||||
|
/// would pay a cold WebView2 start: a new user data directory and a fresh process tree, which is the
|
||||||
|
/// slow path <c>TerminalWorkspaceOptions.RendererTimeout</c>'s fifteen seconds was sized for. It would
|
||||||
|
/// land at the exact moment somebody is most ready to believe the update broke the terminal.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The profile directory is the right home because Velopack never touches it — the pack id is
|
||||||
|
/// deliberately not <c>DodoSSH</c>, so the install root and <c>ClientPaths.DataDirectory</c> are
|
||||||
|
/// siblings rather than the same folder. See docs/adr/0013-desktop-distribution-and-updates.md.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// An environment variable rather than the control's own options, because it is read by the WebView2
|
||||||
|
/// loader before any of this application's UI exists, and because it needs no reference to whichever
|
||||||
|
/// WebView package the terminal happens to be hosted by.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void KeepTheWebViewProfileOutOfTheInstallDirectory()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var folder = Path.Combine(ClientPaths.Default.DataDirectory, "WebView2");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
|
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", folder);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Left unset, which puts the profile back beside the executable. That is a slow first connect
|
||||||
|
// after each update, not a broken terminal, and refusing to start an SSH client over it would
|
||||||
|
// be the wrong trade.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Used by the designer as well as by <see cref="Main"/>.</summary>
|
/// <summary>Used by the designer as well as by <see cref="Main"/>.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
fires only for its own IsVisible.
|
fires only for its own IsVisible.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
<views:TitleBar Grid.Row="0" />
|
<views:TitleBar Grid.Row="0" />
|
||||||
|
|
||||||
@@ -334,7 +334,24 @@
|
|||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<views:StatusBar Grid.Row="2" />
|
<!--
|
||||||
|
A fourth row, and a row rather than an overlay for the reason the occlusion rule above gives: this
|
||||||
|
appears while a terminal may be open, and anything drawn in the WebView's rectangle is sliced. Taking
|
||||||
|
height from the row above moves the native control's bounds instead of covering it, which is the one
|
||||||
|
arrangement that works — the same one TitleBar and StatusBar already rely on.
|
||||||
|
|
||||||
|
It is a separate control because nothing in this file can be measured by a test, and a strip with two
|
||||||
|
buttons and a version string of unknown length is exactly the shape that arranges one of them off the
|
||||||
|
edge. See UpdateBanner.axaml.
|
||||||
|
|
||||||
|
FallbackValue, for the reason the WebView and the connecting card carry one: a compiled binding with
|
||||||
|
no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner
|
||||||
|
announcing an update that does not exist.
|
||||||
|
-->
|
||||||
|
<views:UpdateBanner Grid.Row="2"
|
||||||
|
IsVisible="{Binding Updates.IsBannerShowing, FallbackValue=False}" />
|
||||||
|
|
||||||
|
<views:StatusBar Grid.Row="3" />
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,79 @@
|
|||||||
|
|
||||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" Text="UPDATES" FontSize="14" FontWeight="SemiBold"
|
||||||
|
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Not on the design at all, unlike everything else here. It arrived with packaging: an installed
|
||||||
|
client can replace itself, and the moment that is true the question of where a replacement comes
|
||||||
|
from stops being theoretical. The answer is the security content of this section rather than a
|
||||||
|
footnote to it, which is why it is printed under the version instead of hidden in a tooltip.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="mono" Text="{Binding Updates.CurrentVersion}" FontSize="12" Margin="0,8,0,0"
|
||||||
|
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="hint" FontSize="11" Margin="0,4,0,0"
|
||||||
|
Text="Builds come from the project's own release page, and never from the server you sign in to. That is deliberate: whoever hands you the client can hand you a client that copies your passphrase, and the operator of a DodoSSH deployment is the party the trust model is about. A deployment may tell you where to get it. It is not where it comes from." />
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||||
|
<TextBlock Text="Check for updates" Foreground="{StaticResource Text}" FontSize="13"
|
||||||
|
FontWeight="Medium" />
|
||||||
|
<TextBlock Classes="hint" FontSize="11"
|
||||||
|
Text="Asks the release page whether there is a newer build, and downloads it if there is. Nothing is ever installed while you are using it — a downloaded update waits for a restart you ask for, or for the next time you start DodoSSH." />
|
||||||
|
</StackPanel>
|
||||||
|
<Button x:Name="CheckNowButton" Grid.Column="1" Classes="ghost" Content="CHECK NOW"
|
||||||
|
Command="{Binding Updates.CheckNowCommand}"
|
||||||
|
IsEnabled="{Binding Updates.CanCheckNow}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||||
|
<TextBlock Text="Check on its own" Foreground="{StaticResource Text}" FontSize="13"
|
||||||
|
FontWeight="Medium" />
|
||||||
|
<TextBlock Classes="hint" FontSize="11"
|
||||||
|
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
|
||||||
|
</StackPanel>
|
||||||
|
<CheckBox x:Name="AutomaticUpdatesToggle" Grid.Column="1" VerticalAlignment="Top"
|
||||||
|
IsChecked="{Binding Updates.IsAutomatic}"
|
||||||
|
IsEnabled="{Binding Updates.IsSupported}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- The only other ProgressBar in the application is the transfers one; same height, same brushes. -->
|
||||||
|
<ProgressBar Height="4" Minimum="0" Maximum="100" Margin="0,12,0,0"
|
||||||
|
Value="{Binding Updates.DownloadPercent}"
|
||||||
|
Foreground="{StaticResource Accent}" Background="{StaticResource Raised}"
|
||||||
|
IsVisible="{Binding Updates.IsDownloading}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The restart, with the sentence the banner only has room for in a tooltip. This screen scrolls, so
|
||||||
|
this is where the warning can be as long as it needs to be — and it needs to be, because this
|
||||||
|
application has spent a lot of words teaching that locking keeps shells running.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0" IsVisible="{Binding Updates.IsReady}">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||||
|
<TextBlock Text="{Binding Updates.ReadyHeadline}" Foreground="{StaticResource Text}"
|
||||||
|
FontSize="13" FontWeight="Medium" TextWrapping="Wrap" />
|
||||||
|
<TextBlock Classes="hint" FontSize="11" Text="{Binding Updates.RestartWarning}" />
|
||||||
|
</StackPanel>
|
||||||
|
<Button Grid.Column="1" Classes="accent" Content="RESTART NOW"
|
||||||
|
Command="{Binding Updates.RestartNowCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
||||||
|
Text="{Binding Updates.Status}"
|
||||||
|
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The HasNoDeviceKeyOption precedent, one section up: a machine that gets none of the above is told
|
||||||
|
why rather than shown three controls that cannot do anything.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
||||||
|
Text="This copy of DodoSSH cannot replace itself, so none of the above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
||||||
|
IsVisible="{Binding Updates.IsUnsupported}" />
|
||||||
|
|
||||||
|
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||||
|
|
||||||
<TextBlock Classes="mono" Text="TERMINAL" FontSize="14" FontWeight="SemiBold"
|
<TextBlock Classes="mono" Text="TERMINAL" FontSize="14" FontWeight="SemiBold"
|
||||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||||
|
|
||||||
@@ -185,7 +258,7 @@
|
|||||||
<TextBlock Classes="gap"
|
<TextBlock Classes="gap"
|
||||||
Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." />
|
Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." />
|
||||||
<TextBlock Classes="gap"
|
<TextBlock Classes="gap"
|
||||||
Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the keychain." />
|
Text="A beta channel — there is one release channel, and a switch offering a second would be a preference with nothing behind it." />
|
||||||
<TextBlock Classes="gap"
|
<TextBlock Classes="gap"
|
||||||
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
|
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
|
||||||
<TextBlock Classes="gap"
|
<TextBlock Classes="gap"
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.UpdateBanner"
|
||||||
|
x:DataType="vm:UpdateViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The strip that says a newer build has been fetched, and asks when.
|
||||||
|
|
||||||
|
── WHERE IT LIVES, WHICH IS THE WHOLE DESIGN ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
A row of MainWindow's root grid, between the content and the status bar. Not an overlay, and that is not
|
||||||
|
a preference: NativeWebView hosts a real Win32 child window that composites above everything Avalonia
|
||||||
|
paints in the same rectangle, so anything drawn over the terminal is sliced at its left edge with its
|
||||||
|
buttons unreachable — a defect this window has shipped once. See the occlusion rule in MainWindow.axaml
|
||||||
|
and docs/platform-flags.md.
|
||||||
|
|
||||||
|
A sibling row is the arrangement that is already proven here twice over: TitleBar sits above the
|
||||||
|
terminal and StatusBar below it, and both draw and take clicks correctly. Taking height from the row the
|
||||||
|
WebView is in moves its bounds rather than covering it, which is what NativeControlHost re-pushes on
|
||||||
|
layout.
|
||||||
|
|
||||||
|
The cost, stated rather than discovered: the terminal gets 48 fewer pixels while this is up, so the grid
|
||||||
|
reflows and the remote is told it has fewer rows. That is the same reflow any window resize causes and
|
||||||
|
the renderer already handles it — and the alternative is the arrangement that does not work at all.
|
||||||
|
|
||||||
|
── WHY IT IS ITS OWN FILE ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Nothing inside MainWindow can be laid out by a test — WebView2's adapter refuses the headless session's
|
||||||
|
thread, see LayoutHarnessTests.WhyTheWindowItselfIsNeverShown — so markup left there is markup nobody can
|
||||||
|
measure. This control has two buttons and a version string of unknown length in one fixed-height row,
|
||||||
|
which is exactly the shape that arranges something off the right edge. UpdateBannerTests measures it.
|
||||||
|
|
||||||
|
── ONE LINE HIGH ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Fixed height and trimmed rather than wrapped, for the reason StatusBar gives about itself and with more
|
||||||
|
force: a message that grew this row would shrink the terminal further, and it would do it while somebody
|
||||||
|
is reading it.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Border Height="48" Background="{StaticResource AccentWash}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto" Margin="14,0">
|
||||||
|
|
||||||
|
<Border Grid.Column="0" Classes="chip" BorderBrush="{StaticResource Accent}">
|
||||||
|
<TextBlock Classes="mono" Text="UPDATE" FontSize="10" FontWeight="SemiBold"
|
||||||
|
LetterSpacing="1" Foreground="{StaticResource Accent}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="1" Margin="12,0,16,0" VerticalAlignment="Center"
|
||||||
|
FontSize="13" Foreground="{StaticResource Text}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
Text="{Binding ReadyHeadline}" />
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
LATER is honest, which is what makes it safe to offer at all. The dismissal lasts this run, the
|
||||||
|
preferences row goes on offering the restart, and the build that has already been fetched is what
|
||||||
|
starts next time regardless — so nothing is given up by pressing it, and the tooltip says so
|
||||||
|
rather than leaving somebody to wonder whether they have just refused the update.
|
||||||
|
-->
|
||||||
|
<Button Classes="ghost" Content="LATER" Command="{Binding DismissBannerCommand}"
|
||||||
|
ToolTip.Tip="Hides this until the next launch. The update is already downloaded and will be running the next time you start DodoSSH, so nothing is lost by waiting." />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The warning is on the tooltip rather than in the strip because it is a sentence and this is one
|
||||||
|
line — and because its whole job is to be read before the button is pressed, not after. The long
|
||||||
|
form is on the preferences screen, which scrolls.
|
||||||
|
-->
|
||||||
|
<Button x:Name="RestartNowButton" Classes="accent" Content="RESTART NOW"
|
||||||
|
Command="{Binding RestartNowCommand}"
|
||||||
|
ToolTip.Tip="{Binding RestartWarning}" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
internal sealed partial class UpdateBanner : UserControl
|
||||||
|
{
|
||||||
|
public UpdateBanner() => InitializeComponent();
|
||||||
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<!--
|
||||||
|
Both attributes here are side-by-side activation fields, and this application does not use
|
||||||
|
side-by-side activation, so nothing reads either of them. They are deliberately not kept in step
|
||||||
|
with anything.
|
||||||
|
|
||||||
|
The version a person sees comes from the PE version resource, which MSBuild fills from
|
||||||
|
FileVersion, which MinVer fills from the nearest v* tag. The version an update decision is made on
|
||||||
|
is the one baked into the Velopack package. Neither passes through here, and wiring this attribute
|
||||||
|
to the real version would be machinery maintaining a value with no reader.
|
||||||
|
|
||||||
|
The name likewise stays DodoSSH.Client.App even though the assembly is now called DodoSSH.
|
||||||
|
-->
|
||||||
<assemblyIdentity version="1.0.0.0" name="DodoSSH.Client.App" />
|
<assemblyIdentity version="1.0.0.0" name="DodoSSH.Client.App" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -72,6 +72,18 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
|
"Velopack": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[1.2.0, )",
|
||||||
|
"resolved": "1.2.0",
|
||||||
|
"contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw=="
|
||||||
|
},
|
||||||
"Avalonia.Angle.Windows.Natives": {
|
"Avalonia.Angle.Windows.Natives": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "2.1.27548.20260419",
|
"resolved": "2.1.27548.20260419",
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
"requested": "[5.6.0, )",
|
"requested": "[5.6.0, )",
|
||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
"requested": "[5.6.0, )",
|
"requested": "[5.6.0, )",
|
||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"dodossh.client.domain": {
|
"dodossh.client.domain": {
|
||||||
"type": "Project"
|
"type": "Project"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.2",
|
"resolved": "8.0.2",
|
||||||
|
|||||||
@@ -19,8 +19,38 @@ namespace DodoSSH.Client.Session;
|
|||||||
/// belongs in the cache, behind the passphrase.
|
/// belongs in the cache, behind the passphrase.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record ClientSettings
|
/// <param name="TerminalFontSize">The terminal font size, in CSS pixels.</param>
|
||||||
|
/// <param name="AutomaticUpdateChecks">
|
||||||
|
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
|
||||||
|
/// </param>
|
||||||
|
public sealed record ClientSettings(
|
||||||
|
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
|
||||||
|
bool AutomaticUpdateChecks = true)
|
||||||
{
|
{
|
||||||
|
/*
|
||||||
|
A positional record, and the defaults live on the parameters rather than on property initializers.
|
||||||
|
That is not a style choice — it is the only place System.Text.Json will read them from, and getting
|
||||||
|
it wrong fails silently.
|
||||||
|
|
||||||
|
What the source generator emits for a record is an ObjectWithParameterizedConstructorCreator: it
|
||||||
|
treats the init-only properties as constructor arguments and builds the object as
|
||||||
|
|
||||||
|
new ClientSettings() { TerminalFontSize = (int)args[0], AutomaticUpdateChecks = (bool)args[1] }
|
||||||
|
|
||||||
|
so a property initializer does run — and is then immediately overwritten by args, which for a member
|
||||||
|
absent from the JSON is the CLR default. Measured before this was fixed: a settings file of {} read
|
||||||
|
back TerminalFontSize 0, clamped to the 8px floor rather than the 13px the renderer draws at, and
|
||||||
|
AutomaticUpdateChecks false. Reflection-based deserialisation of the same JSON answers 13 and true,
|
||||||
|
which is what makes it so easy to miss. Setting GenerationMode to Metadata does not help either; it
|
||||||
|
was tried.
|
||||||
|
|
||||||
|
Constructor parameter defaults are carried into the generated metadata, so absence resolves to the
|
||||||
|
value declared here. ASettingAbsentFromTheFile_ComesBackAsItsDeclaredDefault fails without this.
|
||||||
|
|
||||||
|
The type is still used exactly as before — new ClientSettings(), and `with` expressions — because
|
||||||
|
every parameter is optional.
|
||||||
|
*/
|
||||||
|
|
||||||
/// <summary>The size a terminal draws at with nothing stored.</summary>
|
/// <summary>The size a terminal draws at with nothing stored.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Matches the renderer's own default, and has to: the page creates panes at its own constant until
|
/// Matches the renderer's own default, and has to: the page creates panes at its own constant until
|
||||||
@@ -43,8 +73,34 @@ public sealed record ClientSettings
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public const int MaximumTerminalFontSize = 32;
|
public const int MaximumTerminalFontSize = 32;
|
||||||
|
|
||||||
/// <summary>The terminal font size, in CSS pixels.</summary>
|
/*
|
||||||
public int TerminalFontSize { get; init; } = DefaultTerminalFontSize;
|
AutomaticUpdateChecks: why it is on by default, and the three neighbours it deliberately does not
|
||||||
|
have. Prose rather than XML doc because the property is declared in the parameter list above, and a
|
||||||
|
param tag is the wrong shape for several paragraphs.
|
||||||
|
|
||||||
|
On by default. A client that quietly runs a year behind is the failure ADR 0011 names as the real
|
||||||
|
cost of distributing outside a store, and the desktop has the mechanism to avoid it — so the
|
||||||
|
default should use it. It costs little to leave on, because a check downloads and never installs:
|
||||||
|
nothing happens to a running application except a line offering a restart.
|
||||||
|
|
||||||
|
Three neighbours this deliberately does not have, because each would be a mistake worth
|
||||||
|
naming rather than an omission.
|
||||||
|
|
||||||
|
No feed address. Where builds come from is a constant in the code, and ADR 0011 rule 2 is
|
||||||
|
why: an operator who could point this at themselves could pin a chosen user to a build with a known
|
||||||
|
hole. This file is plaintext and hand-editable, which is exactly what makes it the wrong home for
|
||||||
|
that value.
|
||||||
|
|
||||||
|
No last-checked timestamp. The rule above about nothing secret is not only about secrets —
|
||||||
|
a record of when this machine last contacted the project's forge is a small fact about a person
|
||||||
|
that this file does not otherwise carry. It also buys little: the check runs on a timer measured
|
||||||
|
from launch, so any machine that runs at all is current within hours. What the screen shows is
|
||||||
|
remembered for the session and no longer.
|
||||||
|
|
||||||
|
No channel switch. There is one release channel. A toggle offering a second would be a
|
||||||
|
preference with nothing behind it, which is the thing the preferences screen's own opening comment
|
||||||
|
warns against.
|
||||||
|
*/
|
||||||
|
|
||||||
/// <summary>Brings a value inside the range this type will store.</summary>
|
/// <summary>Brings a value inside the range this type will store.</summary>
|
||||||
public static int ClampTerminalFontSize(int pixels) =>
|
public static int ClampTerminalFontSize(int pixels) =>
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A build newer than the one running.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A version string and nothing else, so that whatever the update framework hands back never crosses this
|
||||||
|
/// seam. The implementation keeps its own richer answer privately and matches on the version when it is
|
||||||
|
/// handed one of these back; everything above only ever needs the number to print.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Version">What the newer build calls itself.</param>
|
||||||
|
public sealed record AvailableUpdate(string Version);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where newer builds of this client come from.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The release channel is the project's own, and never the deployment the client is signed in to.</b>
|
||||||
|
/// That is ADR 0011 rule 2 — see <c>docs/adr/0011-android-distribution.md</c> — and it is a security
|
||||||
|
/// property rather than a preference:
|
||||||
|
/// an operator who could answer the update check could pin a chosen user to a build with a known hole, by
|
||||||
|
/// withholding the answer, without holding any key at all. The implementation's feed address is therefore
|
||||||
|
/// a constant and not a setting, and this interface has no member that would let a caller name one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>An interface rather than a delegate</b>, which is a deliberate departure from the shell's habit —
|
||||||
|
/// <c>SignInHandler</c>, <c>resume</c> and <c>copyToClipboard</c> are all delegates so that the view models
|
||||||
|
/// stay drivable by a test with no network. That shape fits a single call. This is four operations, an
|
||||||
|
/// availability question and an order they have to happen in, and four delegates in a constructor is worse
|
||||||
|
/// than one interface. <see cref="IDeviceKeyStore"/> is the same shape in the same assembly and settled the
|
||||||
|
/// same trade.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Nothing here throws for the ordinary failures. A forge that cannot be reached is a laptop on a train,
|
||||||
|
/// not a fault, and the caller's answer is to try again later.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IUpdateChannel
|
||||||
|
{
|
||||||
|
/// <summary>Whether this copy of the client is one that can replace itself.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// False for a build run from a source checkout, for a copy somebody unzipped by hand, and for every
|
||||||
|
/// platform this has not been built for. Asked before offering anything, because an offer that cannot
|
||||||
|
/// be carried out is worse than no offer.
|
||||||
|
/// </remarks>
|
||||||
|
bool IsSupported { get; }
|
||||||
|
|
||||||
|
/// <summary>What this build calls itself.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Answered even when <see cref="IsSupported"/> is false. Which version is running is a fact worth
|
||||||
|
/// printing on a build that cannot update itself — arguably more so, since somebody will have to
|
||||||
|
/// replace it by hand.
|
||||||
|
/// </remarks>
|
||||||
|
string CurrentVersion { get; }
|
||||||
|
|
||||||
|
/// <summary>Asks the release channel whether there is anything newer.</summary>
|
||||||
|
/// <returns>
|
||||||
|
/// The newer build, or <see langword="null"/> if this one is current. Null is also the honest answer
|
||||||
|
/// when the channel cannot be reached at all: the caller does the same thing either way, and an
|
||||||
|
/// unreachable forge is not a state a user can act on.
|
||||||
|
/// </returns>
|
||||||
|
Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Fetches an update a previous <see cref="CheckAsync"/> found.</summary>
|
||||||
|
/// <param name="update">The update to fetch. Must be one this source returned.</param>
|
||||||
|
/// <param name="progress">Reports percentage complete.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
Task DownloadAsync(AvailableUpdate update, IProgress<int> progress, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Swaps this build for the downloaded one and starts it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>This does not return.</b> It is the reason the whole capability is behind an interface rather
|
||||||
|
/// than being three lines inline in the view model: a test cannot drive a method that ends the process,
|
||||||
|
/// and the single most important thing to be able to assert about this feature is that a downloaded
|
||||||
|
/// update is <em>never</em> applied unless somebody asked for it. A fake that records the call instead
|
||||||
|
/// of making it is what makes that assertion writable.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="update">The update to apply. Must be one that has been downloaded.</param>
|
||||||
|
void ApplyAndRestart(AvailableUpdate update);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy of the client that cannot replace itself.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Four different situations resolve to this one object, and they are deliberately not distinguished: a
|
||||||
|
/// build started from a source checkout, a copy extracted by hand from an archive, a desktop platform this
|
||||||
|
/// has not been packaged for, and the phone — whose distribution is settled separately by ADR 0011 and has
|
||||||
|
/// no updater at all. All four mean the same thing. <em>This copy was not installed by anything that knows
|
||||||
|
/// how to replace it</em>, and saying so is different from pretending otherwise.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A null object rather than a nullable field on the view model, following
|
||||||
|
/// <see cref="UnavailableDeviceKeyStore"/>: the caller then has one shape to write against, and "no
|
||||||
|
/// updater here" is a thing the interface can express rather than a case every call site has to remember.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class UnavailableUpdateChannel : IUpdateChannel
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsSupported => false;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string CurrentVersion => ClientVersion.Current;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult<AvailableUpdate?>(null);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task DownloadAsync(
|
||||||
|
AvailableUpdate update,
|
||||||
|
IProgress<int> progress,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"This copy of DodoSSH cannot replace itself. Check IsSupported before offering an update.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ApplyAndRestart(AvailableUpdate update) =>
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"This copy of DodoSSH cannot replace itself. Check IsSupported before offering an update.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What this build calls itself.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Read from the assembly rather than from the update framework, so that it answers on a build that has no
|
||||||
|
/// updater — a source checkout, or the phone. The version a user is shown should not depend on whether the
|
||||||
|
/// thing that could replace it happens to be present.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The informational version is the one that carries the whole number, including any prerelease part;
|
||||||
|
/// <c>AssemblyVersion</c> is <c>major.0.0.0</c> by MinVer's design and would print <c>0.0.0</c> for every
|
||||||
|
/// 0.x build. The <c>+sha</c> build metadata is trimmed because it is for a machine, and this string is
|
||||||
|
/// read aloud in support conversations.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class ClientVersion
|
||||||
|
{
|
||||||
|
/// <summary>The running build's version, without build metadata.</summary>
|
||||||
|
public static string Current { get; } = Read();
|
||||||
|
|
||||||
|
private static string Read()
|
||||||
|
{
|
||||||
|
var informational = (Assembly.GetEntryAssembly() ?? typeof(ClientVersion).Assembly)
|
||||||
|
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
|
||||||
|
?.InformationalVersion;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(informational))
|
||||||
|
{
|
||||||
|
return "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
var plus = informational.IndexOf('+', StringComparison.Ordinal);
|
||||||
|
|
||||||
|
return plus < 0 ? informational : informational[..plus];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"Microsoft.Data.Sqlite.Core": {
|
"Microsoft.Data.Sqlite.Core": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "10.0.10",
|
"resolved": "10.0.10",
|
||||||
|
|||||||
@@ -47,8 +47,14 @@
|
|||||||
The view models are internal, as they were when they lived in the desktop head, and both heads plus
|
The view models are internal, as they were when they lived in the desktop head, and both heads plus
|
||||||
the two shell suites are let in explicitly. Making them public instead would turn every rename of a
|
the two shell suites are let in explicitly. Making them public instead would turn every rename of a
|
||||||
command into a compatibility question about an assembly nobody consumes.
|
command into a compatibility question about an assembly nobody consumes.
|
||||||
|
|
||||||
|
The first entry is an assembly name and not a project name, which is why it does not read
|
||||||
|
DodoSSH.Client.App: that project sets AssemblyName to DodoSSH, so the executable is named for the
|
||||||
|
product rather than for its layer. The two suites below keep their project names because they do
|
||||||
|
not set one. Getting this wrong does not fail here — it fails as a wall of CS0122 in the head,
|
||||||
|
naming every view model and explaining none of it.
|
||||||
-->
|
-->
|
||||||
<InternalsVisibleTo Include="DodoSSH.Client.App" />
|
<InternalsVisibleTo Include="DodoSSH" />
|
||||||
<InternalsVisibleTo Include="DodoSSH.Client.Android" />
|
<InternalsVisibleTo Include="DodoSSH.Client.Android" />
|
||||||
<InternalsVisibleTo Include="DodoSSH.Client.App.Tests" />
|
<InternalsVisibleTo Include="DodoSSH.Client.App.Tests" />
|
||||||
<InternalsVisibleTo Include="DodoSSH.Client.App.Layout.Tests" />
|
<InternalsVisibleTo Include="DodoSSH.Client.App.Layout.Tests" />
|
||||||
|
|||||||
@@ -288,6 +288,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
private readonly VaultsViewModel vaults;
|
private readonly VaultsViewModel vaults;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where newer builds come from, and how far one has got.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A process-lifetime object like <see cref="transfers"/>, and for a reason that is its own rather than
|
||||||
|
/// borrowed: this one outlives a lock because the release channel is not the vault.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly UpdateViewModel updateScreen;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The tab standing in for each connection that has been asked for and has not answered yet.
|
/// The tab standing in for each connection that has been asked for and has not answered yet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -347,6 +356,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// What to call this machine. Optional, and the default is right for every head that runs on a desktop
|
/// What to call this machine. Optional, and the default is right for every head that runs on a desktop
|
||||||
/// operating system — see the field it is kept in for the one that it is not right for.
|
/// operating system — see the field it is kept in for the one that it is not right for.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="updates">
|
||||||
|
/// Where newer builds of this client come from. Optional, and the default is a channel that reports
|
||||||
|
/// itself unavailable — which is a deliberate difference from <paramref name="deviceKeys"/>, which every
|
||||||
|
/// head passes explicitly. With an optional parameter, "the phone has no updater" is enforced by the
|
||||||
|
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
|
||||||
|
/// the Android head's distribution separately, so it must never acquire one by accident.
|
||||||
|
/// </param>
|
||||||
internal MainWindowViewModel(
|
internal MainWindowViewModel(
|
||||||
ClientPaths paths,
|
ClientPaths paths,
|
||||||
ClientCacheFactory caches,
|
ClientCacheFactory caches,
|
||||||
@@ -359,7 +375,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
Argon2Profile? passphraseProfile = null,
|
Argon2Profile? passphraseProfile = null,
|
||||||
ResumeHandler? resume = null,
|
ResumeHandler? resume = null,
|
||||||
Func<string, Task>? copyToClipboard = null,
|
Func<string, Task>? copyToClipboard = null,
|
||||||
string? deviceName = null)
|
string? deviceName = null,
|
||||||
|
IUpdateChannel? updates = null)
|
||||||
{
|
{
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
this.caches = caches;
|
this.caches = caches;
|
||||||
@@ -404,6 +421,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
settings = new ClientSettingsStore(paths);
|
settings = new ClientSettingsStore(paths);
|
||||||
|
|
||||||
|
updateScreen = CreateUpdateScreen(updates);
|
||||||
|
|
||||||
// Read straight away rather than at first use, so the value is right before anything can read it —
|
// Read straight away rather than at first use, so the value is right before anything can read it —
|
||||||
// a phone draws its terminal buttons from this, and a size that arrived a moment later would show
|
// a phone draws its terminal buttons from this, and a size that arrived a moment later would show
|
||||||
// as the interface correcting itself.
|
// as the interface correcting itself.
|
||||||
@@ -412,6 +431,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
_ = TellRendererTheFontSizeAsync();
|
_ = TellRendererTheFontSizeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the updater, kept for the life of the process like the workspace and the transfer queue.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A method rather than four more lines in the constructor, because the restart delegate needs a
|
||||||
|
/// paragraph of its own and the constructor is already at the length the analyzers allow.
|
||||||
|
/// </remarks>
|
||||||
|
private UpdateViewModel CreateUpdateScreen(IUpdateChannel? updates)
|
||||||
|
{
|
||||||
|
// The channel is captured rather than reached through the view model, which keeps the restart
|
||||||
|
// delegate free of a reference to the object it is being handed to.
|
||||||
|
var channel = updates ?? new UnavailableUpdateChannel();
|
||||||
|
|
||||||
|
return new UpdateViewModel(
|
||||||
|
channel,
|
||||||
|
settings,
|
||||||
|
clock,
|
||||||
|
() => workspace.LiveSessionCount,
|
||||||
|
|
||||||
|
// Everything this application does on the way out, and only then the swap. Applying an update
|
||||||
|
// ends the process, and disposing this view model is what zeroes the identity keys, the vault
|
||||||
|
// keys and the cache key — so the other order would leave them sitting in a memory image the
|
||||||
|
// installer is about to write over, and would abandon a transfer still writing to a part file.
|
||||||
|
restart: async update =>
|
||||||
|
{
|
||||||
|
await DisposeAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
|
channel.ApplyAndRestart(update);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The page starts at its own default and has no way to know what was stored, so somebody has to tell
|
/// The page starts at its own default and has no way to know what was stored, so somebody has to tell
|
||||||
@@ -568,6 +618,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal TransfersViewModel Transfers => transfers;
|
internal TransfersViewModel Transfers => transfers;
|
||||||
|
|
||||||
|
/// <summary>Where newer builds come from, which the window binds whether or not a vault is open.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Bound from the titlebar's banner and from the preferences screen, and it answers on a locked shell
|
||||||
|
/// too — the banner is drawn outside the unlocked half of the window on purpose, because a machine left
|
||||||
|
/// locked overnight is exactly the one that will have found an update by morning.
|
||||||
|
/// </remarks>
|
||||||
|
internal UpdateViewModel Updates => updateScreen;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Shells that were left running when the vault was locked.
|
/// Shells that were left running when the vault was locked.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1625,6 +1683,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal async Task StartAsync(CancellationToken cancellationToken)
|
internal async Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// Before anything that can return early, and outside the try: looking for a newer build does not
|
||||||
|
// depend on there being a profile, a server or a vault, and a machine that never gets past the setup
|
||||||
|
// screen is still one that should not be running a build with a hole in it. Start() is a no-op on a
|
||||||
|
// copy that cannot replace itself.
|
||||||
|
updateScreen.Start();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
paths.EnsureCreated();
|
paths.EnsureCreated();
|
||||||
@@ -2467,6 +2531,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||||
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
|
||||||
|
|
||||||
|
// Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
|
||||||
|
// vault because the restart path disposes this whole object and then applies the update — so a
|
||||||
|
// check still running would be writing into a view model the process is about to replace.
|
||||||
|
await updateScreen.DisposeAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
knownHosts.Close();
|
knownHosts.Close();
|
||||||
|
|
||||||
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
|
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where an update has got to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An enum rather than a handful of booleans, for the reason <c>ShellSurface</c> gives: there is then no
|
||||||
|
/// way to write the state where two of these are true at once.
|
||||||
|
/// <para>
|
||||||
|
/// <b>There is deliberately no <c>Available</c> member.</b> The policy is to check and fetch in one motion,
|
||||||
|
/// so "found but not yet fetched" is a state nobody is ever looking at — and a state with nothing that can
|
||||||
|
/// be in it would describe a different product, one that asks permission before using the network. If a
|
||||||
|
/// reason to pause between the two ever arrives — a metered connection is the obvious one — that is when
|
||||||
|
/// the member earns its place, and the shape of this enum is the record of it not having arrived yet.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal enum UpdateState
|
||||||
|
{
|
||||||
|
/// <summary>This copy cannot replace itself, so none of the rest can happen.</summary>
|
||||||
|
Unsupported = 0,
|
||||||
|
|
||||||
|
/// <summary>Nothing in progress.</summary>
|
||||||
|
Idle = 1,
|
||||||
|
|
||||||
|
/// <summary>Asking the release channel.</summary>
|
||||||
|
Checking = 2,
|
||||||
|
|
||||||
|
/// <summary>Fetching a newer build.</summary>
|
||||||
|
Downloading = 3,
|
||||||
|
|
||||||
|
/// <summary>Fetched, and waiting for somebody to say when.</summary>
|
||||||
|
Ready = 4,
|
||||||
|
|
||||||
|
/// <summary>Something the user asked for did not work.</summary>
|
||||||
|
Failed = 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks for newer builds, fetches them, and waits to be told when.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing here ever installs anything on its own.</b> A fetched update sits until the user presses
|
||||||
|
/// restart, or until the application is next started for their own reasons. That is the whole policy, and
|
||||||
|
/// it is a policy rather than an implementation detail: this application deliberately keeps shells running
|
||||||
|
/// across a lock — see <c>MainWindowViewModel.LockAsync</c>, which argues that a lock destroying work would
|
||||||
|
/// simply stop being used — and a restart does not keep them. Something that took the decision away would
|
||||||
|
/// be ending a person's session to save them a click.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It lives as long as the process, not as long as a vault.</b> Unlike the screens built per unlock, and
|
||||||
|
/// unlike <c>VaultViewModel</c>'s own sync loop, where builds come from has nothing to do with whether a
|
||||||
|
/// keychain is open — so this is constructed once and disposed at shutdown, and its loop keeps running
|
||||||
|
/// while the vault is locked. A laptop left locked for a week should still come back current.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It does not speak through <c>Announce</c> or the vault's status line.</b> Those carry saves, syncs,
|
||||||
|
/// refusals and conflicts, and an update has nothing to do with any of them; the banner appearing is the
|
||||||
|
/// announcement. Hence a <see cref="Status"/> of its own, which the preferences screen reads.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposable
|
||||||
|
{
|
||||||
|
/// <summary>How often to look, once the first pass has happened.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Six hours. This is a request against the project's own forge for a product that ships rarely, so
|
||||||
|
/// hourly would be traffic without information; a day would mean a machine that is only ever awake in
|
||||||
|
/// the morning could sit a week behind.
|
||||||
|
/// </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;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A function rather than the workspace itself, so this view model needs no terminal to exist and a
|
||||||
|
/// test can say "three shells are open" without opening any.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly Func<int> liveSessionCount;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// What to do when the user presses restart. See <see cref="RestartNowAsync"/> for why this is not
|
||||||
|
/// simply a call into the channel.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly Func<AvailableUpdate, Task> restart;
|
||||||
|
|
||||||
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
|
|
||||||
|
private Task? loop;
|
||||||
|
private AvailableUpdate? ready;
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
internal UpdateViewModel(
|
||||||
|
IUpdateChannel updates,
|
||||||
|
ClientSettingsStore settings,
|
||||||
|
TimeProvider clock,
|
||||||
|
Func<int> liveSessionCount,
|
||||||
|
Func<AvailableUpdate, Task> restart)
|
||||||
|
{
|
||||||
|
this.updates = updates;
|
||||||
|
this.settings = settings;
|
||||||
|
this.clock = clock;
|
||||||
|
this.liveSessionCount = liveSessionCount;
|
||||||
|
this.restart = restart;
|
||||||
|
|
||||||
|
CurrentVersion = updates.CurrentVersion;
|
||||||
|
isAutomatic = settings.Read().AutomaticUpdateChecks;
|
||||||
|
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What this build calls itself.</summary>
|
||||||
|
internal string CurrentVersion { get; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private UpdateState state;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string? readyVersion;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private int downloadPercent;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private string status = string.Empty;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isAutomatic;
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Per run, and deliberately not persisted. LATER means "not now" and must not quietly come to mean
|
||||||
|
/// "never": the preferences row goes on offering the restart, and the build that was fetched is applied
|
||||||
|
/// at the next ordinary launch whatever this says.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isBannerDismissed;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
private DateTimeOffset? lastChecked;
|
||||||
|
|
||||||
|
internal bool IsSupported => State is not UpdateState.Unsupported;
|
||||||
|
|
||||||
|
internal bool IsUnsupported => State is UpdateState.Unsupported;
|
||||||
|
|
||||||
|
internal bool IsChecking => State is UpdateState.Checking;
|
||||||
|
|
||||||
|
internal bool IsDownloading => State is UpdateState.Downloading;
|
||||||
|
|
||||||
|
internal bool IsReady => State is UpdateState.Ready;
|
||||||
|
|
||||||
|
internal bool CanCheckNow => IsSupported && State is not (UpdateState.Checking or UpdateState.Downloading);
|
||||||
|
|
||||||
|
internal bool IsBannerShowing => IsReady && !IsBannerDismissed;
|
||||||
|
|
||||||
|
/// <summary>What the banner says.</summary>
|
||||||
|
internal string ReadyHeadline => ReadyVersion is { Length: > 0 } version
|
||||||
|
? $"DodoSSH {version} is ready to install."
|
||||||
|
: "An update is ready to install.";
|
||||||
|
|
||||||
|
/// <summary>What restarting costs, in the terms this application has already taught.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The contrast is the point. This application tells people in several places that locking keeps their
|
||||||
|
/// shells running — it is the reason locking is safe to use mid-job — so the one moment that stops being
|
||||||
|
/// true is a moment it owes them a sentence. The close button's tooltip already says the same thing in
|
||||||
|
/// the same words.
|
||||||
|
/// </remarks>
|
||||||
|
internal string RestartWarning
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var open = liveSessionCount();
|
||||||
|
|
||||||
|
return open switch
|
||||||
|
{
|
||||||
|
0 => "Nothing is connected, so this closes and reopens straight away.",
|
||||||
|
1 => "Restarting closes the shell you have open. A lock keeps shells running; a restart does not.",
|
||||||
|
_ => string.Create(
|
||||||
|
CultureInfo.CurrentCulture,
|
||||||
|
$"Restarting closes the {open} shells you have open. A lock keeps shells running; a restart does not."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>When this run last asked, for as long as this run lasts.</summary>
|
||||||
|
internal string LastCheckedSummary => LastChecked is { } at
|
||||||
|
? $"Last checked {at.ToLocalTime().ToString("f", CultureInfo.CurrentCulture)}."
|
||||||
|
: "Not checked yet.";
|
||||||
|
|
||||||
|
partial void OnStateChanged(UpdateState value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsSupported));
|
||||||
|
OnPropertyChanged(nameof(IsUnsupported));
|
||||||
|
OnPropertyChanged(nameof(IsChecking));
|
||||||
|
OnPropertyChanged(nameof(IsDownloading));
|
||||||
|
OnPropertyChanged(nameof(IsReady));
|
||||||
|
OnPropertyChanged(nameof(CanCheckNow));
|
||||||
|
OnPropertyChanged(nameof(IsBannerShowing));
|
||||||
|
OnPropertyChanged(nameof(RestartWarning));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIsBannerDismissedChanged(bool value) => OnPropertyChanged(nameof(IsBannerShowing));
|
||||||
|
|
||||||
|
partial void OnReadyVersionChanged(string? value) => OnPropertyChanged(nameof(ReadyHeadline));
|
||||||
|
|
||||||
|
partial void OnLastCheckedChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastCheckedSummary));
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Read-modify-write against the file rather than against a field, so a setting this build does not
|
||||||
|
/// know about — written by a newer one, or by hand — survives this one storing its own.
|
||||||
|
/// </remarks>
|
||||||
|
partial void OnIsAutomaticChanged(bool value) =>
|
||||||
|
settings.Write(settings.Read() with { AutomaticUpdateChecks = value });
|
||||||
|
|
||||||
|
/// <summary>Starts looking, on a timer.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Called from the shell's own start rather than from the constructor, so that constructing this object
|
||||||
|
/// starts nothing — which is what lets a test drive <see cref="CheckOnceAsync"/> a pass at a time
|
||||||
|
/// instead of racing a timer.
|
||||||
|
/// </remarks>
|
||||||
|
internal void Start()
|
||||||
|
{
|
||||||
|
if (!updates.IsSupported || loop is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop = RunCheckLoopAsync(lifetime.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunCheckLoopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(CheckInterval, clock);
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
await CheckOnceAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Shutdown.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One pass of the loop.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Quiet by construction.</b> A pass that finds nothing writes nothing, and a pass that cannot reach
|
||||||
|
/// the forge writes nothing either — an unreachable release page is a laptop on a train, it is not news,
|
||||||
|
/// and it heals itself in six hours. The same discipline as <c>VaultViewModel.AutoSyncAsync</c>: what
|
||||||
|
/// nobody asked for may only speak when it has something to say.
|
||||||
|
/// </remarks>
|
||||||
|
internal async Task CheckOnceAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await FetchAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// Deliberately silent, and deliberately back to Idle rather than Failed: Failed is for
|
||||||
|
// something a person is waiting on an answer to.
|
||||||
|
State = UpdateState.Idle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The answer always arrives, including "you are on the latest build", because somebody pressed a
|
||||||
|
/// button and a button that appears to do nothing is worse than one that reports no news.
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CheckNowAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!CanCheckNow)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var found = await FetchAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (found is null)
|
||||||
|
{
|
||||||
|
Status = $"DodoSSH {CurrentVersion} is the latest build.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
State = UpdateState.Idle;
|
||||||
|
Status = "Cancelled.";
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
State = UpdateState.Failed;
|
||||||
|
Status = exception.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Checks, and fetches whatever it finds.</summary>
|
||||||
|
/// <returns>The update found, or null.</returns>
|
||||||
|
private async Task<AvailableUpdate?> FetchAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
State = UpdateState.Checking;
|
||||||
|
Status = string.Empty;
|
||||||
|
|
||||||
|
var found = await updates.CheckAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
LastChecked = clock.GetUtcNow();
|
||||||
|
|
||||||
|
if (found is null)
|
||||||
|
{
|
||||||
|
State = UpdateState.Idle;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
State = UpdateState.Downloading;
|
||||||
|
DownloadPercent = 0;
|
||||||
|
|
||||||
|
var progress = new Progress<int>(percent => DownloadPercent = percent);
|
||||||
|
|
||||||
|
await updates.DownloadAsync(found, progress, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
DownloadPercent = 100;
|
||||||
|
ready = found;
|
||||||
|
ReadyVersion = found.Version;
|
||||||
|
IsBannerDismissed = false;
|
||||||
|
State = UpdateState.Ready;
|
||||||
|
Status = $"DodoSSH {found.Version} is downloaded and will run after a restart.";
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Hands the update to whoever was given the job at composition rather than applying it here, and the
|
||||||
|
/// reason is ordering: applying ends the process, and the vault has to be disposed first because that
|
||||||
|
/// is what zeroes the identity keys, the vault keys and the cache key. This view model does not know
|
||||||
|
/// about any of that and should not learn.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A delegate taken in the constructor, like <c>TransfersViewModel</c>'s <c>addBucket</c> and the
|
||||||
|
/// shell's own sign-in handler. An event would have been the other option and is worse here: there is
|
||||||
|
/// exactly one subscriber, it is known at construction, and a second one would mean two things racing
|
||||||
|
/// to end the same process.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task RestartNowAsync()
|
||||||
|
{
|
||||||
|
if (ready is not { } update)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await restart(update).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void DismissBanner() => IsBannerDismissed = true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disposed = true;
|
||||||
|
|
||||||
|
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (loop is { } running)
|
||||||
|
{
|
||||||
|
// Awaited rather than abandoned, so that a pass in flight is finished with before the
|
||||||
|
// application tears down what it is writing into.
|
||||||
|
await running.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
lifetime.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"Avalonia.BuildServices": {
|
"Avalonia.BuildServices": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "11.3.2",
|
"resolved": "11.3.2",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSec.Cryptography": {
|
"NSec.Cryptography": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[26.4.0, )",
|
"requested": "[26.4.0, )",
|
||||||
|
|||||||
@@ -60,6 +60,12 @@
|
|||||||
"SQLitePCLRaw.core": "2.1.11"
|
"SQLitePCLRaw.core": "2.1.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"Humanizer.Core": {
|
"Humanizer.Core": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "2.14.1",
|
"resolved": "2.14.1",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"Microsoft.Data.Sqlite.Core": {
|
"Microsoft.Data.Sqlite.Core": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "10.0.10",
|
"resolved": "10.0.10",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.2",
|
"resolved": "8.0.2",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.0.2",
|
"resolved": "8.0.2",
|
||||||
|
|||||||
@@ -25,6 +25,12 @@
|
|||||||
"requested": "[10.0.10, )",
|
"requested": "[10.0.10, )",
|
||||||
"resolved": "10.0.10",
|
"resolved": "10.0.10",
|
||||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||||
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@
|
|||||||
"resolved": "10.0.10",
|
"resolved": "10.0.10",
|
||||||
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
"contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSec.Cryptography": {
|
"NSec.Cryptography": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[26.4.0, )",
|
"requested": "[26.4.0, )",
|
||||||
|
|||||||
@@ -13,6 +13,12 @@
|
|||||||
"requested": "[5.6.0, )",
|
"requested": "[5.6.0, )",
|
||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,12 @@
|
|||||||
"Newtonsoft.Json": "13.0.3"
|
"Newtonsoft.Json": "13.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"Npgsql.EntityFrameworkCore.PostgreSQL": {
|
"Npgsql.EntityFrameworkCore.PostgreSQL": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[10.0.3, )",
|
"requested": "[10.0.3, )",
|
||||||
|
|||||||
@@ -25,6 +25,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ internal static class LayoutHarness
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal const double TerminalTabsHeight = 42;
|
internal const double TerminalTabsHeight = 42;
|
||||||
|
|
||||||
|
/// <summary>The update banner's fixed height, from <c>UpdateBanner.axaml</c>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Deliberately <em>not</em> part of <see cref="ScreenHeight"/>'s budget, unlike the three constants
|
||||||
|
/// above it. The titlebar, the tab strip and the status bar are unconditional — every screen pays them
|
||||||
|
/// on every launch, which is what makes subtracting them honest. This one is up only while an update is
|
||||||
|
/// waiting to be installed, so folding it into the budget would have every screen measured against a
|
||||||
|
/// height it usually has more than. What it does mean is that a screen shown with the banner up gets 48
|
||||||
|
/// fewer pixels than the suite otherwise checks, which is the trade this row makes and the reason it is
|
||||||
|
/// one line high.
|
||||||
|
/// </remarks>
|
||||||
|
internal const double UpdateBannerHeight = 48;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What a setup card leaves its contents: its maximum width, less the padding on both sides.
|
/// What a setup card leaves its contents: its maximum width, less the padding on both sides.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using DodoSSH.Client.App.Views;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Layout.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The update banner, measured at the width the window is allowed to shrink to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the control in the application least protected by anything else. It is a fixed-height row with
|
||||||
|
/// two buttons on the right and a version string of unknown length in the middle, and it is <em>not</em>
|
||||||
|
/// inside a <see cref="ScrollViewer"/> — so the harness's scrolling exemption does not apply and both
|
||||||
|
/// buttons are genuinely measured. The failure it exists to catch is RESTART NOW pushed off the right edge
|
||||||
|
/// by a long version, which nobody would see until the day there was an update to install.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It cannot be measured through <c>MainWindow</c>, which is why the banner is its own control: WebView2's
|
||||||
|
/// adapter refuses the headless session's thread. See <c>LayoutHarnessTests.WhyTheWindowItselfIsNeverShown</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class UpdateBannerTests
|
||||||
|
{
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A channel that reports an update is ready, so the banner has something to draw.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Local to this suite rather than shared with <c>DodoSSH.Client.App.Tests</c>: what a layout test needs
|
||||||
|
/// is a view model in a given state, and threading the other suite's fake through a project reference
|
||||||
|
/// would couple two suites that otherwise share nothing.
|
||||||
|
/// </remarks>
|
||||||
|
private sealed class ReadyChannel : IUpdateChannel
|
||||||
|
{
|
||||||
|
public bool IsSupported => true;
|
||||||
|
|
||||||
|
public string CurrentVersion => "1.0.0";
|
||||||
|
|
||||||
|
public Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken) =>
|
||||||
|
Task.FromResult<AvailableUpdate?>(new AvailableUpdate(LongVersion));
|
||||||
|
|
||||||
|
public Task DownloadAsync(
|
||||||
|
AvailableUpdate update,
|
||||||
|
IProgress<int> progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
progress.Report(100);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyAndRestart(AvailableUpdate update)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A version longer than anything this project should ever tag, which is the point.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// MinVer produces exactly this shape on an untagged commit — <c>0.0.0-alpha.0.114</c> — and a release
|
||||||
|
/// candidate would produce something close to it. A banner that only fits <c>1.2.3</c> would look fine
|
||||||
|
/// in every screenshot and break on the first prerelease anybody actually shipped.
|
||||||
|
/// </remarks>
|
||||||
|
private const string LongVersion = "10.20.30-release-candidate.4";
|
||||||
|
|
||||||
|
private static async Task<UpdateViewModel> ReadyViewModelAsync()
|
||||||
|
{
|
||||||
|
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-banner-{Guid.CreateVersion7():N}");
|
||||||
|
|
||||||
|
var updates = new UpdateViewModel(
|
||||||
|
new ReadyChannel(),
|
||||||
|
new ClientSettingsStore(new ClientPaths(directory)),
|
||||||
|
TimeProvider.System,
|
||||||
|
() => 3,
|
||||||
|
restart: _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
if (Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheUpdateBannerFitsAtTheWindowsMinimumWidth()
|
||||||
|
{
|
||||||
|
var updates = await ReadyViewModelAsync();
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var banner = new UpdateBanner { DataContext = updates };
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
banner, LayoutHarness.MinimumWidth, LayoutHarness.UpdateBannerHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Held to the number <see cref="LayoutHarness.UpdateBannerHeight"/> states, the same way the titlebar,
|
||||||
|
/// tab strip and status bar are. A banner that grew with its contents would shrink the terminal by an
|
||||||
|
/// amount nothing predicts, and would do it while somebody is reading it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheUpdateBannerIsTheHeightItSaysItIs()
|
||||||
|
{
|
||||||
|
var updates = await ReadyViewModelAsync();
|
||||||
|
|
||||||
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
var banner = new UpdateBanner { DataContext = updates };
|
||||||
|
|
||||||
|
var window = LayoutHarness.HostAtMinimumSize(
|
||||||
|
banner, LayoutHarness.MinimumWidth, LayoutHarness.UpdateBannerHeight);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
banner.Bounds.Height.ShouldBe(LayoutHarness.UpdateBannerHeight);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
@@ -468,15 +474,7 @@
|
|||||||
"xunit.v3.runner.common": "[3.2.2]"
|
"xunit.v3.runner.common": "[3.2.2]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dodossh.client.api": {
|
"DodoSSH": {
|
||||||
"type": "Project",
|
|
||||||
"dependencies": {
|
|
||||||
"DodoSSH.Client.Auth": "[1.0.0, )",
|
|
||||||
"DodoSSH.Contracts": "[1.0.0, )",
|
|
||||||
"DodoSSH.Crypto": "[1.0.0, )"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dodossh.client.app": {
|
|
||||||
"type": "Project",
|
"type": "Project",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Avalonia": "[12.1.1, )",
|
"Avalonia": "[12.1.1, )",
|
||||||
@@ -491,7 +489,16 @@
|
|||||||
"DodoSSH.Client.Shell": "[1.0.0, )",
|
"DodoSSH.Client.Shell": "[1.0.0, )",
|
||||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||||
"DodoSSH.Client.Transfer": "[1.0.0, )"
|
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
||||||
|
"Velopack": "[1.2.0, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dodossh.client.api": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"DodoSSH.Client.Auth": "[1.0.0, )",
|
||||||
|
"DodoSSH.Contracts": "[1.0.0, )",
|
||||||
|
"DodoSSH.Crypto": "[1.0.0, )"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dodossh.client.auth": {
|
"dodossh.client.auth": {
|
||||||
@@ -771,6 +778,12 @@
|
|||||||
"BouncyCastle.Cryptography": "2.6.2",
|
"BouncyCastle.Cryptography": "2.6.2",
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Velopack": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[1.2.0, )",
|
||||||
|
"resolved": "1.2.0",
|
||||||
|
"contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A release channel that answers whatever the test tells it to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b><see cref="ApplyAndRestart"/> records the call instead of making it, and that is the whole reason
|
||||||
|
/// <c>IUpdateChannel</c> exists as an interface.</b> The real one replaces the running process and never
|
||||||
|
/// returns, so a test could not observe it at all — and the single most important thing to be able to
|
||||||
|
/// assert about this feature is a negative: that a downloaded update is <em>never</em> applied unless
|
||||||
|
/// somebody pressed the button. A counter is what makes that assertion writable.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="HoldDownload"/> exists for the same reason in the other direction: without a way to stop a
|
||||||
|
/// download halfway, the Downloading state would be a state no test could ever catch the view model in.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class FakeUpdateChannel : IUpdateChannel
|
||||||
|
{
|
||||||
|
/// <summary>What the next check finds. Null means this build is current.</summary>
|
||||||
|
internal AvailableUpdate? Available { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When set, checking throws it.</summary>
|
||||||
|
internal Exception? CheckFailure { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When set, downloading throws it.</summary>
|
||||||
|
internal Exception? DownloadFailure { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When set, a download waits on it before completing.</summary>
|
||||||
|
internal TaskCompletionSource? HoldDownload { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The percentages a download reports on its way through.</summary>
|
||||||
|
internal IReadOnlyList<int> ProgressSteps { get; set; } = [25, 50, 100];
|
||||||
|
|
||||||
|
internal int Checks { get; private set; }
|
||||||
|
|
||||||
|
internal int Downloads { get; private set; }
|
||||||
|
|
||||||
|
internal int Restarts { get; private set; }
|
||||||
|
|
||||||
|
internal AvailableUpdate? RestartedWith { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsSupported { get; set; } = true;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string CurrentVersion { get; set; } = "1.0.0";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Checks++;
|
||||||
|
|
||||||
|
return CheckFailure is { } failure
|
||||||
|
? Task.FromException<AvailableUpdate?>(failure)
|
||||||
|
: Task.FromResult(Available);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DownloadAsync(
|
||||||
|
AvailableUpdate update,
|
||||||
|
IProgress<int> progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Downloads++;
|
||||||
|
|
||||||
|
if (DownloadFailure is { } failure)
|
||||||
|
{
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (HoldDownload is { } gate)
|
||||||
|
{
|
||||||
|
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var percent in ProgressSteps)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
progress.Report(percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ApplyAndRestart(AvailableUpdate update)
|
||||||
|
{
|
||||||
|
Restarts++;
|
||||||
|
RestartedWith = update;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finding a newer build, fetching it, and not installing it until somebody says so.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The view model is driven a pass at a time through <c>CheckOnceAsync</c> rather than through its timer,
|
||||||
|
/// which is the same split <c>VaultViewModel</c> makes between its sync pass and its sync loop and for the
|
||||||
|
/// same reason: a suite that waited on a <c>PeriodicTimer</c> would be testing the clock.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The load-bearing test here is <see cref="AReadyUpdate_IsNeverAppliedOnItsOwn"/>. Everything else is
|
||||||
|
/// about how the feature behaves; that one is about the promise it makes.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class UpdateFlowTests : IDisposable
|
||||||
|
{
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private readonly string directory =
|
||||||
|
Path.Combine(Path.GetTempPath(), $"dodossh-updates-{Guid.CreateVersion7():N}");
|
||||||
|
|
||||||
|
private readonly FakeUpdateChannel channel = new();
|
||||||
|
|
||||||
|
private int liveSessions;
|
||||||
|
private int restarts;
|
||||||
|
|
||||||
|
private ClientPaths Paths => new(directory);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The real settings store over a real temporary directory, not a stand-in. Persistence is one of the
|
||||||
|
/// things being asserted, and the file is the thing that persists.
|
||||||
|
/// </remarks>
|
||||||
|
private UpdateViewModel Build() =>
|
||||||
|
new(
|
||||||
|
channel,
|
||||||
|
new ClientSettingsStore(Paths),
|
||||||
|
TimeProvider.System,
|
||||||
|
() => liveSessions,
|
||||||
|
restart: update =>
|
||||||
|
{
|
||||||
|
restarts++;
|
||||||
|
channel.ApplyAndRestart(update);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ABuildThatCannotReplaceItself_OffersNothing()
|
||||||
|
{
|
||||||
|
channel.IsSupported = false;
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
updates.State.ShouldBe(UpdateState.Unsupported);
|
||||||
|
updates.IsUnsupported.ShouldBeTrue();
|
||||||
|
updates.CanCheckNow.ShouldBeFalse();
|
||||||
|
updates.IsBannerShowing.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ABuildThatCannotReplaceItself_StillKnowsItsOwnVersion()
|
||||||
|
{
|
||||||
|
channel.IsSupported = false;
|
||||||
|
channel.CurrentVersion = "0.4.2";
|
||||||
|
|
||||||
|
Build().CurrentVersion.ShouldBe("0.4.2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ABackgroundPassThatFindsNothing_SaysNothing()
|
||||||
|
{
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
channel.Checks.ShouldBe(1);
|
||||||
|
updates.State.ShouldBe(UpdateState.Idle);
|
||||||
|
updates.Status.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APressedCheckThatFindsNothing_SaysSo()
|
||||||
|
{
|
||||||
|
channel.CurrentVersion = "1.2.0";
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckNowCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
updates.State.ShouldBe(UpdateState.Idle);
|
||||||
|
updates.Status.ShouldContain("1.2.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnUpdateThatIsFound_IsFetchedWithoutBeingAskedAbout()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
channel.Downloads.ShouldBe(1);
|
||||||
|
updates.State.ShouldBe(UpdateState.Ready);
|
||||||
|
updates.ReadyVersion.ShouldBe("1.3.0");
|
||||||
|
updates.DownloadPercent.ShouldBe(100);
|
||||||
|
updates.IsBannerShowing.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The whole policy in one assertion. A fetched update sits until a person presses the button, or until
|
||||||
|
/// the application is next started for their own reasons — because a restart ends every shell, and this
|
||||||
|
/// application has spent a lot of design effort on shells surviving a lock.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AReadyUpdate_IsNeverAppliedOnItsOwn()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
updates.State.ShouldBe(UpdateState.Ready);
|
||||||
|
channel.Restarts.ShouldBe(0);
|
||||||
|
restarts.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RestartingApplies_TheUpdateThatWasFound()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
await updates.RestartNowCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
channel.Restarts.ShouldBe(1);
|
||||||
|
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RestartingWithNothingReady_DoesNothing()
|
||||||
|
{
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.RestartNowCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
channel.Restarts.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DismissingTheBanner_KeepsTheOfferOnPreferences()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
updates.DismissBannerCommand.Execute(null);
|
||||||
|
|
||||||
|
updates.IsBannerShowing.ShouldBeFalse();
|
||||||
|
updates.IsReady.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A forge that cannot be reached is a laptop on a train. Nothing asked, so nothing is said, and the
|
||||||
|
/// state goes back to Idle rather than to Failed — Failed is reserved for something a person is waiting
|
||||||
|
/// on an answer to.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ABackgroundCheckThatFails_IsNotAnnounced()
|
||||||
|
{
|
||||||
|
channel.CheckFailure = new HttpRequestException("no such host");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
updates.State.ShouldBe(UpdateState.Idle);
|
||||||
|
updates.Status.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APressedCheckThatFails_SaysWhy()
|
||||||
|
{
|
||||||
|
channel.CheckFailure = new HttpRequestException("no such host");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckNowCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
updates.State.ShouldBe(UpdateState.Failed);
|
||||||
|
updates.Status.ShouldBe("no such host");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TurningOffAutomaticChecks_StopsThePassDoingAnything()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
updates.IsAutomatic = false;
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
channel.Checks.ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A second view model over the same <see cref="ClientPaths"/>, which is how this repository tests that
|
||||||
|
/// a preference reached the disk — the same shape as the terminal font size's own persistence test.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void TurningOffAutomaticChecks_IsStillOffOnTheNextLaunch()
|
||||||
|
{
|
||||||
|
var first = Build();
|
||||||
|
first.IsAutomatic.ShouldBeTrue();
|
||||||
|
|
||||||
|
first.IsAutomatic = false;
|
||||||
|
|
||||||
|
Build().IsAutomatic.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Storing one preference does not discard the others.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Pins the read-modify-write in <c>OnIsAutomaticChanged</c>, which nothing else covered: writing this
|
||||||
|
/// setting reads the file first, so a font size chosen earlier is still there afterwards. Writing the
|
||||||
|
/// view model's own state instead would silently reset every preference it does not hold.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Note the limit of what read-modify-write buys here, because the comment it guards is easy to read
|
||||||
|
/// as promising more. It preserves settings <em>this build knows about</em>. A key written by a newer
|
||||||
|
/// build is dropped, because the store deserialises with
|
||||||
|
/// <c>JsonUnmappedMemberHandling.Skip</c> — unmapped members are skipped rather than carried, so they
|
||||||
|
/// do not survive a round trip. That is asserted below rather than left as an assumption, so that
|
||||||
|
/// anybody who needs forward-compatibility discovers the cost here instead of in the field.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void StoringOnePreference_KeepsTheOthersThisBuildKnows()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
File.WriteAllText(
|
||||||
|
Paths.SettingsFile,
|
||||||
|
"""{"terminalFontSize":19,"somethingOnlyANewerBuildKnows":"keep me"}""");
|
||||||
|
|
||||||
|
Build().IsAutomatic = false;
|
||||||
|
|
||||||
|
var reread = new ClientSettingsStore(Paths).Read();
|
||||||
|
|
||||||
|
reread.TerminalFontSize.ShouldBe(19);
|
||||||
|
reread.AutomaticUpdateChecks.ShouldBeFalse();
|
||||||
|
|
||||||
|
// The honest limit, stated as an assertion: an unmapped key does not survive.
|
||||||
|
File.ReadAllText(Paths.SettingsFile).ShouldNotContain("somethingOnlyANewerBuildKnows");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A setting missing from the file reads back as the default the record declares.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This is the regression test for a defect that was already in the tree and could not bite until a
|
||||||
|
/// second preference existed. The JSON source generator builds a record through a synthesised
|
||||||
|
/// parameterised constructor and assigns <em>every</em> property from its argument array, so a member
|
||||||
|
/// absent from the file arrived as the CLR default and overwrote whatever a property initializer had
|
||||||
|
/// set. A settings file of <c>{}</c> read back a font size of 0 — clamped to the 8px floor rather than
|
||||||
|
/// the 13px the renderer draws at — and, once it existed, update checks off.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It is asserted on both settings, not just the new one, because the mechanism has nothing to do with
|
||||||
|
/// either: it is a property of how this record is deserialised, and the next preference somebody adds
|
||||||
|
/// inherits it. The fix is that the defaults live on the constructor parameters, which is the only
|
||||||
|
/// place the generator reads them from.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A file that exists and lacks the key is the case that matters, and it is not the same as no file at
|
||||||
|
/// all — <c>Read</c> short-circuits to a fresh record when the file is missing, which is why the bug
|
||||||
|
/// hid. Every machine that has ever run this application has a settings.json without the newer key.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ASettingAbsentFromTheFile_ComesBackAsItsDeclaredDefault()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
var store = new ClientSettingsStore(Paths);
|
||||||
|
|
||||||
|
File.WriteAllText(Paths.SettingsFile, "{}");
|
||||||
|
|
||||||
|
var fromEmptyObject = store.Read();
|
||||||
|
|
||||||
|
fromEmptyObject.AutomaticUpdateChecks.ShouldBeTrue();
|
||||||
|
fromEmptyObject.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize);
|
||||||
|
|
||||||
|
// The realistic shape: a file written by the build before this feature existed.
|
||||||
|
File.WriteAllText(Paths.SettingsFile, """{"terminalFontSize":19}""");
|
||||||
|
|
||||||
|
var fromOlderBuild = store.Read();
|
||||||
|
|
||||||
|
fromOlderBuild.TerminalFontSize.ShouldBe(19);
|
||||||
|
fromOlderBuild.AutomaticUpdateChecks.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheRestartWarning_CountsTheShellsItWouldClose()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
liveSessions = 0;
|
||||||
|
updates.RestartWarning.ShouldContain("Nothing is connected");
|
||||||
|
|
||||||
|
liveSessions = 1;
|
||||||
|
updates.RestartWarning.ShouldContain("the shell you have open");
|
||||||
|
|
||||||
|
liveSessions = 3;
|
||||||
|
updates.RestartWarning.ShouldContain("the 3 shells you have open");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ADownloadInFlight_IsAStateTheScreenCanShow()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
channel.HoldDownload = new TaskCompletionSource();
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
var pass = updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
// The gate is what makes this observable at all; without it the download would be over before
|
||||||
|
// anything could look.
|
||||||
|
while (updates.State is not UpdateState.Downloading)
|
||||||
|
{
|
||||||
|
await Task.Yield();
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.IsDownloading.ShouldBeTrue();
|
||||||
|
updates.CanCheckNow.ShouldBeFalse();
|
||||||
|
|
||||||
|
channel.HoldDownload.SetResult();
|
||||||
|
await pass;
|
||||||
|
|
||||||
|
updates.State.ShouldBe(UpdateState.Ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APassWhileOneIsAlreadyReady_DoesNotAskAgain()
|
||||||
|
{
|
||||||
|
channel.Available = new AvailableUpdate("1.3.0");
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
await updates.CheckOnceAsync(Token);
|
||||||
|
|
||||||
|
channel.Checks.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DisposingStopsTheLoop()
|
||||||
|
{
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
updates.Start();
|
||||||
|
|
||||||
|
await updates.DisposeAsync();
|
||||||
|
await updates.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
@@ -457,15 +463,7 @@
|
|||||||
"xunit.v3.runner.common": "[3.2.2]"
|
"xunit.v3.runner.common": "[3.2.2]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dodossh.client.api": {
|
"DodoSSH": {
|
||||||
"type": "Project",
|
|
||||||
"dependencies": {
|
|
||||||
"DodoSSH.Client.Auth": "[1.0.0, )",
|
|
||||||
"DodoSSH.Contracts": "[1.0.0, )",
|
|
||||||
"DodoSSH.Crypto": "[1.0.0, )"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dodossh.client.app": {
|
|
||||||
"type": "Project",
|
"type": "Project",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Avalonia": "[12.1.1, )",
|
"Avalonia": "[12.1.1, )",
|
||||||
@@ -480,7 +478,16 @@
|
|||||||
"DodoSSH.Client.Shell": "[1.0.0, )",
|
"DodoSSH.Client.Shell": "[1.0.0, )",
|
||||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||||
"DodoSSH.Client.Transfer": "[1.0.0, )"
|
"DodoSSH.Client.Transfer": "[1.0.0, )",
|
||||||
|
"Velopack": "[1.2.0, )"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dodossh.client.api": {
|
||||||
|
"type": "Project",
|
||||||
|
"dependencies": {
|
||||||
|
"DodoSSH.Client.Auth": "[1.0.0, )",
|
||||||
|
"DodoSSH.Contracts": "[1.0.0, )",
|
||||||
|
"DodoSSH.Crypto": "[1.0.0, )"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dodossh.client.auth": {
|
"dodossh.client.auth": {
|
||||||
@@ -760,6 +767,12 @@
|
|||||||
"BouncyCastle.Cryptography": "2.6.2",
|
"BouncyCastle.Cryptography": "2.6.2",
|
||||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Velopack": {
|
||||||
|
"type": "CentralTransitive",
|
||||||
|
"requested": "[1.2.0, )",
|
||||||
|
"resolved": "1.2.0",
|
||||||
|
"contentHash": "Rz67gJL619fSBS6omaSINUxyDuwhIxkm5mmubf7uLd5Qgi6LLKaKCha+QFP6n+Bw/UjA0vutnH4JQfYzn6ANtw=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"Npgsql.EntityFrameworkCore.PostgreSQL": {
|
"Npgsql.EntityFrameworkCore.PostgreSQL": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[10.0.3, )",
|
"requested": "[10.0.3, )",
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||||
},
|
},
|
||||||
|
"MinVer": {
|
||||||
|
"type": "Direct",
|
||||||
|
"requested": "[7.0.0, )",
|
||||||
|
"resolved": "7.0.0",
|
||||||
|
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||||
|
},
|
||||||
"NSubstitute": {
|
"NSubstitute": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[6.0.0, )",
|
"requested": "[6.0.0, )",
|
||||||
|
|||||||
Reference in New Issue
Block a user