Public Access
Merge branch 'claude/desktop-auto-updater-0264a3'
This commit is contained in:
@@ -8,6 +8,13 @@
|
||||
"dotnet-ef"
|
||||
],
|
||||
"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."
|
||||
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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
@@ -104,6 +114,31 @@ jobs:
|
||||
- name: restore
|
||||
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
|
||||
# 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
|
||||
@@ -150,6 +185,34 @@ jobs:
|
||||
- name: test
|
||||
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
|
||||
# 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
|
||||
@@ -241,7 +304,17 @@ jobs:
|
||||
"give the runner an image with node 20 or newer if actions misbehave."
|
||||
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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
@@ -281,7 +354,7 @@ jobs:
|
||||
# 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.
|
||||
# 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.
|
||||
# This APK is a build check. It is not something anybody installs.
|
||||
- name: package
|
||||
@@ -343,7 +416,17 @@ jobs:
|
||||
"give the runner an image with node 20 or newer if actions misbehave."
|
||||
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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# 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
|
||||
@@ -449,8 +532,18 @@ jobs:
|
||||
;;
|
||||
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 "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "assemblyVersion=$assembly_version" >> "$GITHUB_OUTPUT"
|
||||
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
echo "Tagging: $tags"
|
||||
|
||||
@@ -462,6 +555,7 @@ jobs:
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
VERSION: ${{ steps.tags.outputs.version }}
|
||||
ASSEMBLY_VERSION: ${{ steps.tags.outputs.assemblyVersion }}
|
||||
REVISION: ${{ github.sha }}
|
||||
CREATED: ${{ steps.tags.outputs.created }}
|
||||
run: |
|
||||
@@ -477,6 +571,7 @@ jobs:
|
||||
--pull \
|
||||
--file src/DodoSSH.Api/Dockerfile \
|
||||
--build-arg "VERSION=$VERSION" \
|
||||
--build-arg "ASSEMBLY_VERSION=$ASSEMBLY_VERSION" \
|
||||
--build-arg "REVISION=$REVISION" \
|
||||
--build-arg "CREATED=$CREATED" \
|
||||
"${args[@]}" \
|
||||
@@ -547,3 +642,20 @@ jobs:
|
||||
- name: log out
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
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>
|
||||
</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">
|
||||
<Company>DodoTech</Company>
|
||||
<Product>DodoSSH</Product>
|
||||
@@ -51,6 +60,14 @@
|
||||
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" />
|
||||
</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">
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)BannedSymbols.txt" Visible="false" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -145,6 +145,40 @@
|
||||
terminal data plane — is Pipelines and channel code rather than view models.
|
||||
-->
|
||||
<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 Label="Analyzers">
|
||||
<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/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
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
misformatted file fails the build itself rather than a separate CI step.
|
||||
- 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
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
## 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
|
||||
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.
|
||||
---
|
||||
|
||||
## 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
|
||||
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
|
||||
suspect. Relevant to packaging: an installer that lands under a deep per-user path would break the
|
||||
terminal with an error that names nothing.
|
||||
suspect.
|
||||
|
||||
*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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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 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 \
|
||||
--no-restore \
|
||||
--configuration Release \
|
||||
--output /app \
|
||||
-p:MinVerSkip=true \
|
||||
${ASSEMBLY_VERSION:+-p:Version="$ASSEMBLY_VERSION"} \
|
||||
-p:UseAppHost=false
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"FastEndpoints.Attributes": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.2.0",
|
||||
|
||||
@@ -20,8 +20,14 @@
|
||||
<TargetPlatformVersion>36</TargetPlatformVersion>
|
||||
|
||||
<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>
|
||||
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
|
||||
|
||||
<!--
|
||||
False here for the same reason the desktop head sets it false: this process formats timestamps
|
||||
@@ -30,6 +36,31 @@
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</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>
|
||||
<!-- The phone's control styles. The palette they draw from lives in Shell and is shared. -->
|
||||
<AvaloniaResource Include="Theme/**" />
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"dodossh.client.auth": {
|
||||
"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)
|
||||
{
|
||||
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.
|
||||
var connections = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var workspace = new TerminalWorkspace(
|
||||
new AvaloniaTerminalAssetProvider(),
|
||||
connections,
|
||||
TimeProvider.System);
|
||||
|
||||
workspace.Start();
|
||||
var workspace = StartedWorkspace(connections);
|
||||
|
||||
var browser = new SystemBrowserLauncher();
|
||||
|
||||
// Chosen once, here, because it is a property of the machine and not of any session. A computer with
|
||||
// a usable TPM gets the store that keeps a device key behind a Windows consent prompt; anything else
|
||||
// gets one that reports itself unavailable, so unlock keeps asking for the passphrase. See ADR 0007.
|
||||
// Both chosen once, here, because each is a property of the machine rather than of any session. A
|
||||
// computer with a usable TPM gets the store that keeps a device key behind a Windows consent prompt;
|
||||
// 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 updates = UpdateChannels.ForThisMachine();
|
||||
|
||||
var viewModel = new MainWindowViewModel(
|
||||
paths,
|
||||
@@ -120,7 +136,8 @@ internal sealed partial class DodoSshApp : Application
|
||||
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
|
||||
copyToClipboard: ClipboardWriter(desktop));
|
||||
copyToClipboard: ClipboardWriter(desktop),
|
||||
updates: updates);
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
|
||||
@@ -5,6 +5,35 @@
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<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
|
||||
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" />
|
||||
</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>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
@@ -38,6 +94,14 @@
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||
<PackageReference Include="Avalonia.Controls.WebView" />
|
||||
<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>
|
||||
|
||||
@@ -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.Media;
|
||||
using DodoSSH.Client.Session;
|
||||
using Velopack;
|
||||
|
||||
namespace DodoSSH.Client.App;
|
||||
|
||||
@@ -10,11 +12,78 @@ internal static class Program
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>STAThread</c> is required, not decorative: WebView2 checks the apartment state and refuses
|
||||
/// to initialise on an MTA thread. Without it the terminal is simply blank on Windows.
|
||||
/// 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>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <remarks>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
fires only for its own IsVisible.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<Grid RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<views:TitleBar Grid.Row="0" />
|
||||
|
||||
@@ -334,7 +334,24 @@
|
||||
|
||||
</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>
|
||||
|
||||
|
||||
@@ -61,6 +61,79 @@
|
||||
|
||||
<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"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
@@ -185,7 +258,7 @@
|
||||
<TextBlock Classes="gap"
|
||||
Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." />
|
||||
<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"
|
||||
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"
|
||||
|
||||
@@ -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"?>
|
||||
<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" />
|
||||
|
||||
<!--
|
||||
|
||||
@@ -72,6 +72,18 @@
|
||||
"resolved": "5.6.0",
|
||||
"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": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.27548.20260419",
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"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, )",
|
||||
"resolved": "5.6.0",
|
||||
"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",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.2",
|
||||
|
||||
@@ -19,8 +19,38 @@ namespace DodoSSH.Client.Session;
|
||||
/// belongs in the cache, behind the passphrase.
|
||||
/// </para>
|
||||
/// </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>
|
||||
/// <remarks>
|
||||
/// 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>
|
||||
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>
|
||||
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",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"Microsoft.Data.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"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 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.
|
||||
|
||||
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.App.Tests" />
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.App.Layout.Tests" />
|
||||
|
||||
@@ -288,6 +288,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
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>
|
||||
/// The tab standing in for each connection that has been asked for and has not answered yet.
|
||||
/// </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
|
||||
/// operating system — see the field it is kept in for the one that it is not right for.
|
||||
/// </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(
|
||||
ClientPaths paths,
|
||||
ClientCacheFactory caches,
|
||||
@@ -359,7 +375,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Argon2Profile? passphraseProfile = null,
|
||||
ResumeHandler? resume = null,
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
string? deviceName = null)
|
||||
string? deviceName = null,
|
||||
IUpdateChannel? updates = null)
|
||||
{
|
||||
this.paths = paths;
|
||||
this.caches = caches;
|
||||
@@ -404,6 +421,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
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 —
|
||||
// a phone draws its terminal buttons from this, and a size that arrived a moment later would show
|
||||
// as the interface correcting itself.
|
||||
@@ -412,6 +431,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
_ = 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>
|
||||
/// <para>
|
||||
/// 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>
|
||||
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>
|
||||
/// Shells that were left running when the vault was locked.
|
||||
/// </summary>
|
||||
@@ -1625,6 +1683,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
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
|
||||
{
|
||||
paths.EnsureCreated();
|
||||
@@ -2467,6 +2531,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
workspace.SessionEnded -= OnWorkspaceSessionEnded;
|
||||
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();
|
||||
|
||||
// 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",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"Avalonia.BuildServices": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.2",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "Direct",
|
||||
"requested": "[26.4.0, )",
|
||||
|
||||
@@ -60,6 +60,12 @@
|
||||
"SQLitePCLRaw.core": "2.1.11"
|
||||
}
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"Humanizer.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.14.1",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"Microsoft.Data.Sqlite.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "10.0.10",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.2",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.2",
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
"requested": "[10.0.10, )",
|
||||
"resolved": "10.0.10",
|
||||
"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",
|
||||
"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": {
|
||||
"type": "Direct",
|
||||
"requested": "[26.4.0, )",
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.3, )",
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -73,6 +73,18 @@ internal static class LayoutHarness
|
||||
/// </remarks>
|
||||
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>
|
||||
/// What a setup card leaves its contents: its maximum width, less the padding on both sides.
|
||||
/// </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",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
@@ -468,15 +474,7 @@
|
||||
"xunit.v3.runner.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"dodossh.client.api": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Auth": "[1.0.0, )",
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.app": {
|
||||
"DodoSSH": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Avalonia": "[12.1.1, )",
|
||||
@@ -491,7 +489,16 @@
|
||||
"DodoSSH.Client.Shell": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[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": {
|
||||
@@ -771,6 +778,12 @@
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"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",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
@@ -457,15 +463,7 @@
|
||||
"xunit.v3.runner.common": "[3.2.2]"
|
||||
}
|
||||
},
|
||||
"dodossh.client.api": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Auth": "[1.0.0, )",
|
||||
"DodoSSH.Contracts": "[1.0.0, )",
|
||||
"DodoSSH.Crypto": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.app": {
|
||||
"DodoSSH": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Avalonia": "[12.1.1, )",
|
||||
@@ -480,7 +478,16 @@
|
||||
"DodoSSH.Client.Shell": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[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": {
|
||||
@@ -760,6 +767,12 @@
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"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",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.3, )",
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"MinVer": {
|
||||
"type": "Direct",
|
||||
"requested": "[7.0.0, )",
|
||||
"resolved": "7.0.0",
|
||||
"contentHash": "2lMTCQl5bGP4iv0JNkockPnyllC6eHLz+CoK2ICvalvHod+exXSxueu9hq+zNkU7bZBJf8wMfeRC/Edn8AGmEg=="
|
||||
},
|
||||
"NSubstitute": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.0.0, )",
|
||||
|
||||
Reference in New Issue
Block a user