Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174ef7c420 | ||
|
|
af0e29a98b |
+276
-3
@@ -787,6 +787,278 @@ jobs:
|
|||||||
|
|
||||||
echo "Published nightly $VERSION with$names"
|
echo "Published nightly $VERSION with$names"
|
||||||
|
|
||||||
|
desktop:
|
||||||
|
name: desktop nightly
|
||||||
|
# Gated on the tests, like the image job and for a stronger version of its reason: this one is
|
||||||
|
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
|
||||||
|
# commit whose suite was red.
|
||||||
|
needs: [build]
|
||||||
|
runs-on: [linux]
|
||||||
|
# main only, and the whole job rather than its last step. A v* tag belongs to the release channel,
|
||||||
|
# which no runner may publish — ADR 0013 rule 3 — and the desktop head is already built and packed
|
||||||
|
# on tags by the build job above, so there is nothing here a tag build would gain.
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
# Writes the rolling nightly release at the end of the job. Job-scoped, so no other job in this file
|
||||||
|
# gains it; see the publish step for what the capability is and why this channel may hold it.
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
# Duplicated from the build job; see the comment there for why it cannot be factored out. There
|
||||||
|
# are four copies now, and any change has to be made in all four.
|
||||||
|
- name: ensure node and git
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
SUDO=""
|
||||||
|
[ "$(id -u)" -eq 0 ] || SUDO="sudo"
|
||||||
|
|
||||||
|
missing=""
|
||||||
|
command -v node >/dev/null 2>&1 || missing="$missing nodejs"
|
||||||
|
command -v git >/dev/null 2>&1 || missing="$missing git"
|
||||||
|
|
||||||
|
if [ -z "$missing" ]; then
|
||||||
|
echo "node $(node --version), git $(git --version)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing:$missing"
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
$SUDO apt-get update -qq
|
||||||
|
$SUDO apt-get install -y --no-install-recommends $missing
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
$SUDO apk add --no-cache $missing
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
$SUDO dnf install -y $missing
|
||||||
|
else
|
||||||
|
echo "No apt-get, apk or dnf here, so node cannot be installed from inside the" >&2
|
||||||
|
echo "job. Point the runner's container.image at something that ships node." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "node $(node --version), git $(git --version)"
|
||||||
|
|
||||||
|
major="$(node --version | sed 's/^v//; s/\..*//')"
|
||||||
|
if [ "$major" -lt 20 ]; then
|
||||||
|
echo "::warning::node $major is older than the runtime these actions target;" \
|
||||||
|
"give the runner an image with node 20 or newer if actions misbehave."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# fetch-depth 0 for the reason the other three jobs give, and here it decides what gets published:
|
||||||
|
# MinVer's answer is this build's version and the number a nightly client compares against.
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||||
|
with:
|
||||||
|
global-json-file: global.json
|
||||||
|
cache: true
|
||||||
|
cache-dependency-path: '**/packages.lock.json'
|
||||||
|
|
||||||
|
# Before the version is read, and that ordering is not tidiness. MinVer arrives as a package, so its
|
||||||
|
# target does not exist until a restore has written obj/*.nuget.g.targets — and `-t:MinVer` on an
|
||||||
|
# unrestored project fails MSB4057 "the target does not exist", which reads like a typo in this file
|
||||||
|
# rather than like a missing restore. The build job's tag check is safe because it runs after that
|
||||||
|
# job's own restore; this job has none, so it needs this one.
|
||||||
|
#
|
||||||
|
# Locked, like the solution restore in the build job. The RID-specific restore the publish needs is
|
||||||
|
# unlocked and asks for that itself, exactly as the build job's publish does.
|
||||||
|
- name: restore
|
||||||
|
run: dotnet restore src/DodoSSH.Client.App/DodoSSH.Client.App.csproj --locked-mode
|
||||||
|
|
||||||
|
# ◆ THE VERSION IS DECIDED ONCE HERE AND THEN FORCED ON EVERYTHING.
|
||||||
|
#
|
||||||
|
# MinVer's own answer is not usable as it stands: until a v* tag exists it is 0.0.0-alpha.0.N, and
|
||||||
|
# vpk refuses to pack anything below 0.0.1. The floor is applied to the *whole build* rather than to
|
||||||
|
# the packaging alone, through MinVerVersionOverride, and that is the part worth understanding.
|
||||||
|
#
|
||||||
|
# Packing a version the assemblies disagree with would put one number in the installer and another
|
||||||
|
# on the preferences screen — the screen a person reads when asked which nightly they are on, and
|
||||||
|
# the number they would then quote into an issue that nobody can match to a build. MinVer sets both
|
||||||
|
# Version and InformationalVersion from the override, so the two cannot drift.
|
||||||
|
#
|
||||||
|
# Monotonic across the boundary, which is what a feed needs: heights keep rising within a floored
|
||||||
|
# version, and the first real tag moves the whole number up past every floored one.
|
||||||
|
- name: the nightly version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# -t:MinVer for the reason the tag check in the build job spells out: without a target named,
|
||||||
|
# -getProperty answers the SDK default and every nightly would claim the same version forever.
|
||||||
|
version="$(dotnet msbuild src/DodoSSH.Client.App/DodoSSH.Client.App.csproj \
|
||||||
|
-getProperty:Version -t:MinVer -nologo | tr -d '[:space:]')"
|
||||||
|
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
echo "Could not read the version from MSBuild." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$version" in
|
||||||
|
0.0.0*)
|
||||||
|
floored="0.0.1${version#0.0.0}"
|
||||||
|
echo "MinVer says $version, which vpk will not pack; this nightly is $floored."
|
||||||
|
version="$floored"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "$version"
|
||||||
|
|
||||||
|
# DodoChannel=nightly is what makes this a different application rather than the same one built
|
||||||
|
# again: it puts the channel in the assembly, which is where DesktopChannel reads it to pick the
|
||||||
|
# feed to poll, whether to accept prereleases, and which profile directory to keep a cache in.
|
||||||
|
#
|
||||||
|
# RestoreLockedMode=false for the RID, exactly as the build job's publish does — see the long note
|
||||||
|
# there. This checkout is thrown away, so the lock files it rewrites go nowhere.
|
||||||
|
- name: publish the nightly
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: >
|
||||||
|
dotnet publish src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
|
||||||
|
--configuration Release --runtime win-x64 --self-contained true
|
||||||
|
-p:RestoreLockedMode=false
|
||||||
|
-p:DodoChannel=nightly
|
||||||
|
-p:MinVerVersionOverride="$VERSION"
|
||||||
|
--output "$RUNNER_TEMP/nightly-win-x64"
|
||||||
|
|
||||||
|
# ◆ A DIFFERENT PACK ID, A DIFFERENT CHANNEL, A DIFFERENT TITLE. ALL THREE, AND NONE IS COSMETIC.
|
||||||
|
#
|
||||||
|
# packId decides where Velopack installs and what an installed client matches an update against, so
|
||||||
|
# DodoSSH.Desktop.Nightly is what makes this install *beside* the release build rather than over it,
|
||||||
|
# and what stops either feed's package being applied to the other's install.
|
||||||
|
#
|
||||||
|
# channel decides the name of the index file on the feed — releases.win-nightly.json — and it is a
|
||||||
|
# contract with VelopackUpdateChannel.NightlyChannel. Disagree on this word and the channel answers
|
||||||
|
# nothing, forever, with no error anywhere.
|
||||||
|
#
|
||||||
|
# title is what a person reads in the Start menu and in Add/Remove Programs, and it is the only one
|
||||||
|
# of the three they will ever see. Two entries both called DodoSSH would be the whole benefit of
|
||||||
|
# installing side by side, thrown away at the last step.
|
||||||
|
#
|
||||||
|
# No `vpk download` and so no deltas: this channel deletes its previous release on every push, so
|
||||||
|
# there would be nothing on the feed for a delta to be applied against. A nightly update is a full
|
||||||
|
# download, which is the honest cost of a rolling channel that keeps exactly one build.
|
||||||
|
- name: package the nightly
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
dotnet tool restore
|
||||||
|
|
||||||
|
dotnet vpk '[win]' pack \
|
||||||
|
--skip-updates \
|
||||||
|
--packId DodoSSH.Desktop.Nightly \
|
||||||
|
--packVersion "$VERSION" \
|
||||||
|
--packDir "$RUNNER_TEMP/nightly-win-x64" \
|
||||||
|
--packTitle 'DodoSSH Nightly' \
|
||||||
|
--packAuthors DodoTech \
|
||||||
|
--mainExe DodoSSH.exe \
|
||||||
|
--icon src/DodoSSH.Client.App/Assets/dodossh.ico \
|
||||||
|
--runtime win-x64 \
|
||||||
|
--channel win-nightly \
|
||||||
|
--outputDir "$RUNNER_TEMP/nightly-releases"
|
||||||
|
|
||||||
|
ls -la "$RUNNER_TEMP/nightly-releases"
|
||||||
|
|
||||||
|
# ◆ PUBLISHING IT, AND WHAT THAT CAPABILITY IS.
|
||||||
|
#
|
||||||
|
# Whoever can write a release here can put a build on every nightly desktop, because Velopack fetches
|
||||||
|
# from this feed and applies what it finds without verifying a signature. That is the same capability
|
||||||
|
# as a signing key reached through a different door, and ADR 0013 rule 3 keeps it off runners.
|
||||||
|
#
|
||||||
|
# It is acceptable here for the reason the android nightly gives, and only for that reason: this is
|
||||||
|
# not that channel. A nightly is a separate application with its own pack id, its own install
|
||||||
|
# directory and its own profile, and it cannot update the build anybody is trusting with their
|
||||||
|
# credentials — the release channel reads a different index and refuses prereleases, so it cannot
|
||||||
|
# even see this one. Anybody installing a nightly is trusting everyone who can write to this
|
||||||
|
# repository, which is a thing to know rather than a thing to discover; the README says so.
|
||||||
|
#
|
||||||
|
# ◆ DELETED AND RECREATED RATHER THAN ADDED TO.
|
||||||
|
#
|
||||||
|
# A rolling channel has to keep exactly one build, and every asset here is version-named, so merging
|
||||||
|
# would grow the release by a hundred and twenty megabytes per push until the forge said no. There is
|
||||||
|
# no atomic form of this in the API, so the shape with the fewest states is to remove both the
|
||||||
|
# release and its tag and make them again. The window where no nightly exists is a few seconds, and
|
||||||
|
# the client's answer to it is the same as to an unreachable forge: the timer swallows it and tries
|
||||||
|
# later; a pressed CHECK NOW says so.
|
||||||
|
- name: publish the nightly release
|
||||||
|
env:
|
||||||
|
FORGE: https://git.dodotech.cloud
|
||||||
|
REPO: DodoTech-Public/DodoSSH
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
TAG: nightly-desktop
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${TOKEN:-}" ]; then
|
||||||
|
echo "No token, so the nightly was built and not published." >&2
|
||||||
|
echo "GITHUB_TOKEN is provided by the runner; an empty one means Actions is configured" >&2
|
||||||
|
echo "without it, and the job's contents: write permission is what asks for it." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
api="$FORGE/api/v1/repos/$REPO"
|
||||||
|
auth="Authorization: token $TOKEN"
|
||||||
|
|
||||||
|
# The first "id" with digits after it, for the reason the android job records at length: a
|
||||||
|
# greedy .* walks past the release's own id to the author's, which is -2, and yields nothing.
|
||||||
|
release_id() {
|
||||||
|
grep -oE '"id":[0-9]+' | head -1 | cut -d: -f2
|
||||||
|
}
|
||||||
|
|
||||||
|
existing="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG" 2>/dev/null || true)"
|
||||||
|
if [ -n "$existing" ]; then
|
||||||
|
id="$(printf '%s' "$existing" | release_id)"
|
||||||
|
if [ -n "$id" ]; then
|
||||||
|
echo "Removing the previous $TAG release $id"
|
||||||
|
curl -fsS -X DELETE -H "$auth" "$api/releases/$id" >/dev/null || true
|
||||||
|
else
|
||||||
|
echo "A $TAG release exists and its id could not be read:" >&2
|
||||||
|
printf '%s\n' "$existing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# vpk rather than curl, unlike the android job above, and the difference is what is being
|
||||||
|
# uploaded. An APK is one file the client is told about by a manifest this repository writes; a
|
||||||
|
# Velopack release is a set of files plus an index whose format is Velopack's own. Writing that
|
||||||
|
# index by hand would be reimplementing the tool that is already here.
|
||||||
|
#
|
||||||
|
# --pre is load-bearing twice over. It keeps this out of the release channel, which refuses
|
||||||
|
# prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
|
||||||
|
# channel reads: a desktop nightly published as a stable release would become the newest release
|
||||||
|
# in this repository and every phone on the release channel would start failing its update check
|
||||||
|
# against a release carrying no android manifest.
|
||||||
|
dotnet vpk upload gitea \
|
||||||
|
--skip-updates \
|
||||||
|
--repoUrl "$FORGE/$REPO" \
|
||||||
|
--token "$TOKEN" \
|
||||||
|
--outputDir "$RUNNER_TEMP/nightly-releases" \
|
||||||
|
--channel win-nightly \
|
||||||
|
--tag "$TAG" \
|
||||||
|
--releaseName "Nightly desktop $VERSION" \
|
||||||
|
--targetCommitish "$GITHUB_SHA" \
|
||||||
|
--pre \
|
||||||
|
--publish
|
||||||
|
|
||||||
|
# Asked for back rather than assumed, and the android job's history is why: it once created a
|
||||||
|
# release, uploaded nothing, and reported success for every upload it never made. A nightly
|
||||||
|
# desktop feed that exists and carries no index is a client that checks, finds nothing, and
|
||||||
|
# reports itself up to date forever.
|
||||||
|
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
|
||||||
|
|
||||||
|
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
|
||||||
|
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
||||||
|
echo "The release was created but $name is not on it:" >&2
|
||||||
|
printf '%s\n' "$published" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Published nightly desktop $VERSION as $TAG"
|
||||||
|
|
||||||
image:
|
image:
|
||||||
name: api image
|
name: api image
|
||||||
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
||||||
@@ -1067,9 +1339,10 @@ jobs:
|
|||||||
if: always() && github.event_name != 'pull_request'
|
if: always() && github.event_name != 'pull_request'
|
||||||
run: docker logout registry-docker.dodotech.cloud
|
run: docker logout registry-docker.dodotech.cloud
|
||||||
|
|
||||||
# There is no job here that publishes the desktop client, and there is not going to be one. It is built
|
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
|
||||||
# and packaged here — the two steps at the end of the build job — and what is withheld is only the
|
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
||||||
# upload.
|
# install directory and profile — and the distance between those two sentences is the whole design. What
|
||||||
|
# the build job does for the release channel is prove it still builds and packs; the upload is withheld.
|
||||||
#
|
#
|
||||||
# ◆ ONE REASON, WHERE THIS ONCE CLAIMED TWO, AND THE SECOND WAS NOT TRUE.
|
# ◆ ONE REASON, WHERE THIS ONCE CLAIMED TWO, AND THE SECOND WAS NOT TRUE.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -155,6 +155,23 @@ 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
|
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.
|
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
|
||||||
|
|
||||||
|
### The nightly desktop build
|
||||||
|
|
||||||
|
There is a second Windows build, published by CI from `main` on every push, on the `nightly-desktop`
|
||||||
|
release. It installs **beside** the release build rather than over it — its own entry called *DodoSSH
|
||||||
|
Nightly*, its own install directory, and its own profile at `%LOCALAPPDATA%\DodoSSH.Nightly` — so trying it
|
||||||
|
costs nothing you are relying on. It signs in separately and appears to your account as a new device,
|
||||||
|
because it genuinely is a second installation.
|
||||||
|
|
||||||
|
**Installing it means trusting everyone who can write a release on this repository, including CI.** The
|
||||||
|
updater applies what the feed serves without verifying a signature, so anyone who can change a workflow
|
||||||
|
file here can put a build on every nightly machine. That is an acceptable trade for a build you are trying
|
||||||
|
and not one for a build holding your infrastructure credentials, which is the entire reason the two
|
||||||
|
channels exist and cannot see each other. The release build refuses prereleases and reads a different feed,
|
||||||
|
so nothing published here can ever reach it. Same arrangement, and same reasoning, as the phone's nightly —
|
||||||
|
[ADR 0014](docs/adr/0014-android-updates.md), and [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md)
|
||||||
|
decision 9.
|
||||||
|
|
||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
Three commands, in order. The first is once per machine.
|
Three commands, in order. The first is once per machine.
|
||||||
|
|||||||
@@ -228,6 +228,56 @@ changes.
|
|||||||
token, and it puts a compellable third party in the signing path — which is ADR 0011 rule 3's shape one
|
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.
|
layer down, declined there for reasons that do not stop applying because the vendor changed.
|
||||||
|
|
||||||
|
### 9. There is a second desktop channel, published by CI, and it is a second application
|
||||||
|
|
||||||
|
[ADR 0014](0014-android-updates.md) gave the phone a nightly channel and rule 3 above gives the desktop
|
||||||
|
none, which left the two heads with different answers to the same question — *how does somebody try what
|
||||||
|
is on main?* — for no reason other than the order the work happened in. This is the desktop's answer, and
|
||||||
|
it is the phone's arrangement with one difference that changes how much of it has to be built.
|
||||||
|
|
||||||
|
**Android gets the separation from the platform. Windows does not.** Two Android channels cannot replace
|
||||||
|
one another because the installer refuses a package signed by a different key; a mistake there is loud.
|
||||||
|
Velopack applies what its feed serves and verifies no signature, so on this head the separation is entirely
|
||||||
|
construction:
|
||||||
|
|
||||||
|
| | release | nightly |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| pack id | `DodoSSH.Desktop` | `DodoSSH.Desktop.Nightly` |
|
||||||
|
| Velopack channel | `win` | `win-nightly` |
|
||||||
|
| feed | newest non-prerelease release | the rolling `nightly-desktop` prerelease |
|
||||||
|
| profile | `%LOCALAPPDATA%\DodoSSH` | `%LOCALAPPDATA%\DodoSSH.Nightly` |
|
||||||
|
| cut by | a person, from a `v*` tag | CI, on every push to main |
|
||||||
|
|
||||||
|
Four separations rather than one, because each closes a different door. The pack id decides the install
|
||||||
|
directory and what an installed client matches an update against, so it is what makes a nightly install
|
||||||
|
*beside* rather than *over*. The channel names the index file on the feed, so neither build ever reads the
|
||||||
|
other's — and the release channel additionally refuses prereleases, which is belt and braces in the one
|
||||||
|
direction that matters: an unsigned CI build must never reach a machine somebody is trusting with their
|
||||||
|
credentials. The profile is the one that is easy to skip and would hurt most: the cache schema is migrated
|
||||||
|
on every launch, before unlock, so a shared profile means a nightly quietly upgrading a database the
|
||||||
|
release build then opens.
|
||||||
|
|
||||||
|
**The prerelease flag is also how the two heads stay out of each other's way.** The phone's release channel
|
||||||
|
reads `releases/latest`, which skips prereleases. A desktop nightly published as a stable release would
|
||||||
|
become the newest release in this repository, and every phone on the release channel would start failing
|
||||||
|
its check against a release carrying no Android manifest. The nightly is published with `--pre` for its own
|
||||||
|
sake and for that one.
|
||||||
|
|
||||||
|
**What this costs, stated where somebody will read it before installing:** whoever can write a release on
|
||||||
|
this repository can put a build on every nightly desktop, because the client applies what the feed serves
|
||||||
|
without verifying a signature. That set includes CI and therefore everyone who can change a workflow file.
|
||||||
|
It is the same trade [ADR 0014](0014-android-updates.md) accepted for the phone's nightly, and it is
|
||||||
|
acceptable for the same reason and only that reason: this is not the channel anybody's real credentials are
|
||||||
|
on. Rule 3 is untouched — the release channel still has no job, no token and no runner.
|
||||||
|
|
||||||
|
**The version gets a floor, on this channel only.** MinVer answers `0.0.0-alpha.0.N` until the first `v*`
|
||||||
|
tag and `vpk` refuses to pack anything below `0.0.1`, so the nightly job lifts the patch digit and keeps
|
||||||
|
the prerelease height. It is applied through `MinVerVersionOverride`, which moves the assembly version too
|
||||||
|
— packing a number the assemblies disagree with would put one version in the installer and another on the
|
||||||
|
preferences screen, which is the screen somebody reads when asked which nightly they are running. Ordering
|
||||||
|
across the floor holds: heights rise within a floored version, and the first real tag moves past all of
|
||||||
|
them. The release script gets no floor and must not have one; there, `0.0.0` should be refused.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
|
**The desktop gets what ADR 0011 had to refuse the phone.** Discovery is still manual — somebody has to be
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ is the entire reason there are two.
|
|||||||
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
|
other is an uninstall and a fresh enrolment. There is no migration and there will not be one — the two
|
||||||
caches are encrypted under keys held by two package identities the platform keeps apart.
|
caches are encrypted under keys held by two package identities the platform keeps apart.
|
||||||
|
|
||||||
|
◆ **The desktop has since taken this arrangement, and it had to build what Android is given.**
|
||||||
|
[ADR 0013](0013-desktop-distribution-and-updates.md) decision 9 is this ADR applied to Windows: a nightly
|
||||||
|
CI publishes from main, installing beside the release build rather than over it. The difference worth
|
||||||
|
carrying back here is that every separation this ADR gets from the platform — different package identity,
|
||||||
|
signature-checked updates, a per-app data directory — is on Windows a thing somebody had to choose and can
|
||||||
|
therefore undo. The paragraph above about the attack surface applies there word for word, with one line
|
||||||
|
removed: on the desktop the packages are not signed at all, so the feed is the only thing standing between
|
||||||
|
a nightly install and an arbitrary build.
|
||||||
|
|
||||||
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
|
**ADR 0011's "no automatic update" consequence is now wrong for the release channel and remains true for
|
||||||
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
|
reach.** Discovery, MDM deployment and the sideloading permission are all unchanged. What changed is only
|
||||||
that an installed copy can now learn a newer one exists.
|
that an installed copy can now learn a newer one exists.
|
||||||
|
|||||||
@@ -2005,6 +2005,29 @@ is wrong. Reinstalling then asks for the passphrase rather than for a server.
|
|||||||
lost their offline unlock and any change that was still in the outbox. That is the failure ADR 0013
|
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.
|
decision 2 exists to prevent, and it is why 16.1 checks the same thing from the other end.
|
||||||
|
|
||||||
|
### 16.10 The nightly installs beside the release build and not over it · **the one CI cannot check**
|
||||||
|
|
||||||
|
Needs both: a release build installed by walking 16.1, and `DodoSSH.Desktop.Nightly-win-nightly-Setup.exe`
|
||||||
|
from the `nightly-desktop` release. Install the nightly second, and then start **both**.
|
||||||
|
|
||||||
|
**Pass:** two entries in the Start menu, one *DodoSSH* and one *DodoSSH Nightly*; two directories,
|
||||||
|
`%LOCALAPPDATA%\DodoSSH.Desktop` and `%LOCALAPPDATA%\DodoSSH.Desktop.Nightly`; two profiles,
|
||||||
|
`%LOCALAPPDATA%\DodoSSH` and `%LOCALAPPDATA%\DodoSSH.Nightly`, each with its own `cache.db` and
|
||||||
|
`device.key`. The nightly's titlebar says **DodoSSH Nightly** and the release build's says **DodoSSH** —
|
||||||
|
which is the only difference visible while somebody is typing a passphrase into one of them. Signing in to
|
||||||
|
the nightly leaves the release build signed in and untouched, and the account shows a second device.
|
||||||
|
|
||||||
|
**Failure means:** if there is one profile directory, the two builds are sharing a cache and a device key,
|
||||||
|
and the next nightly carrying a schema migration will upgrade the database the release build opens. If
|
||||||
|
there is one install directory, the pack ids collide and the nightly has replaced the release build
|
||||||
|
outright — which is the thing ADR 0013 decision 9 is constructed to make impossible, so it means one of the
|
||||||
|
four separations has been undone.
|
||||||
|
|
||||||
|
**And the direction that matters most:** on the release build, PREFERENCES → UPDATES → CHECK NOW must not
|
||||||
|
offer a nightly, ever, however many have been published since. It reads a different index and refuses
|
||||||
|
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
|
||||||
|
rather than as a bug in the nightly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 17 — Installing and updating the phone
|
## Phase 17 — Installing and updating the phone
|
||||||
|
|||||||
+17
-3
@@ -595,9 +595,23 @@ targeted, not which RID. Only signing needs Windows tooling, which is why `ci.ym
|
|||||||
without a target *evaluates* the project and runs nothing, while MinVer computes the version inside a
|
without a target *evaluates* the project and runs nothing, while MinVer computes the version inside a
|
||||||
target — so the read comes back as the SDK default on a full checkout with every tag present, which looks
|
target — so the read comes back as the SDK default on a full checkout with every tag present, which looks
|
||||||
exactly like a version that was never configured. `-t:MinVer` makes `-getProperty` report the value after
|
exactly like a version that was never configured. `-t:MinVer` makes `-getProperty` report the value after
|
||||||
that target has run, and both readers of it — the tag check in `ci.yml` and `scripts/release-windows.ps1`
|
that target has run, and every reader of it — the tag check in `ci.yml`, the desktop nightly job, and
|
||||||
— pass it. Neither had, and neither had ever run: the CI check is `if:` a tag ref and there are no tags
|
`scripts/release-windows.ps1` — passes it. Neither of the first two had, and neither had ever run: the CI
|
||||||
yet, so the first release would have been refused by its own guard, which would have blamed `fetch-depth`.
|
check is `if:` a tag ref and there are no tags yet, so the first release would have been refused by its own
|
||||||
|
guard, which would have blamed `fetch-depth`.
|
||||||
|
|
||||||
|
**And naming that target requires a restore first, which is a second failure wearing a very different
|
||||||
|
face.** MinVer arrives as a package, so its target is imported from `obj/*.nuget.g.targets` and does not
|
||||||
|
exist at all on a clean checkout:
|
||||||
|
|
||||||
|
```
|
||||||
|
error MSB4057: The target "MinVer" does not exist in the project.
|
||||||
|
```
|
||||||
|
|
||||||
|
That reads like a typo in the workflow rather than like a missing restore, and it does not reproduce on any
|
||||||
|
machine that has built the project before — which is every developer machine and no fresh runner. The
|
||||||
|
build job's tag check is safe because it runs after that job's own restore; the desktop nightly job and the
|
||||||
|
release script each restore before reading, deliberately and with a comment saying why.
|
||||||
|
|
||||||
**The Android head's lock file is outside the solution, so nothing checks it until the android job runs
|
**The Android head's lock file is outside the solution, so nothing checks it until the android job runs
|
||||||
— and the android job was broken for an unrelated reason for the whole of the release that went stale.**
|
— and the android job was broken for an unrelated reason for the whole of the release that went stale.**
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ $RepoUrl = 'https://git.dodotech.cloud/DodoTech-Public/DodoSSH'
|
|||||||
# A contract with VelopackUpdateChannel.ReleaseChannel. It is Velopack's Windows default, so leaving it
|
# 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
|
# 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.
|
# error at all: the client checks, finds nothing, and reports itself up to date forever.
|
||||||
|
#
|
||||||
|
# The nightly channel is the same three values with different contents — DodoSSH.Desktop.Nightly, DodoSSH
|
||||||
|
# Nightly, win-nightly — and they live in the `desktop nightly` job in .github/workflows/ci.yml rather than
|
||||||
|
# here, because that build is CI's and this script is a person's. Nothing shares them, deliberately: this
|
||||||
|
# file must keep working if that job is deleted. See ADR 0013 decision 9 for what the separation buys.
|
||||||
$Channel = 'win'
|
$Channel = 'win'
|
||||||
|
|
||||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
@@ -100,11 +105,20 @@ Push-Location $RepoRoot
|
|||||||
try {
|
try {
|
||||||
# ---- What is being released -----------------------------------------------------------------------
|
# ---- What is being released -----------------------------------------------------------------------
|
||||||
|
|
||||||
# -t:MinVer, and it is load-bearing. -getProperty on its own evaluates the project and runs no
|
# Restored before the version is read, and both halves of that sentence are load-bearing.
|
||||||
# targets, while MinVer sets Version from inside one — so this read answered the SDK's default
|
#
|
||||||
# 1.0.0 regardless of the tag, and the check below would then have refused to build anything not
|
# -t:MinVer, because -getProperty on its own evaluates the project and runs no targets, while MinVer
|
||||||
# tagged v1.0.0. CI's tag check had the same line and the same fault; both are fixed, and both
|
# sets Version from inside one — so this read answered the SDK's default 1.0.0 regardless of the tag,
|
||||||
# say so, because this is the version that ends up in the package a client compares against.
|
# and the check below would then have refused to build anything not tagged v1.0.0. CI's tag check had
|
||||||
|
# the same line and the same fault.
|
||||||
|
#
|
||||||
|
# And a restore first, because naming a target that arrives with a package fails MSB4057 on a clean
|
||||||
|
# clone, where obj/ has no MinVer targets to import yet. It costs seconds on a machine that has built
|
||||||
|
# before, which is every machine except the one this would otherwise fail on.
|
||||||
|
Write-Step 'Restoring the desktop head, so the version can be read'
|
||||||
|
& dotnet restore $Project --locked-mode
|
||||||
|
if ($LASTEXITCODE -ne 0) { Stop-With 'Restore failed.' }
|
||||||
|
|
||||||
$version = (& dotnet msbuild $Project -getProperty:Version -t:MinVer -nologo) -replace '\s', ''
|
$version = (& dotnet msbuild $Project -getProperty:Version -t:MinVer -nologo) -replace '\s', ''
|
||||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||||
Stop-With 'Could not read the version from MSBuild.'
|
Stop-With 'Could not read the version from MSBuild.'
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
|
|
||||||
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
var paths = ClientPaths.Default;
|
// ForChannel rather than Default, so a nightly keeps its cache, outbox and device key somewhere
|
||||||
|
// the release build never opens. See ClientPaths.ForChannel for why sharing them is the failure
|
||||||
|
// worth spending a directory on.
|
||||||
|
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||||
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
var caches = ClientCacheFactory.ForFile(paths.CacheFile);
|
||||||
|
|
||||||
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
|
// Known hosts live in the vault, so trust survives a restart and follows the user to every device.
|
||||||
|
|||||||
@@ -42,6 +42,30 @@
|
|||||||
-->
|
-->
|
||||||
<ApplicationIcon>Assets/dodossh.ico</ApplicationIcon>
|
<ApplicationIcon>Assets/dodossh.ico</ApplicationIcon>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============ WHICH CHANNEL THIS BUILD BELONGS TO ============
|
||||||
|
|
||||||
|
The same split the Android head has, for the same reason and with one difference worth stating up
|
||||||
|
front: Android gets separation for free, because the platform refuses an update signed by a
|
||||||
|
different key, so its two channels cannot replace one another whatever anybody does. Nothing
|
||||||
|
refuses anything here. Velopack applies what its feed serves, so the separation has to be built:
|
||||||
|
two pack ids, two Velopack channels, two feeds, and a profile directory each.
|
||||||
|
|
||||||
|
release — DodoSSH.Desktop, Velopack channel win, read from the newest non-prerelease release.
|
||||||
|
Cut from a v* tag by scripts/release-windows.ps1, by a person. See ADR 0013 rule 3.
|
||||||
|
nightly — DodoSSH.Desktop.Nightly, Velopack channel win-nightly, read from a prerelease release
|
||||||
|
CI replaces on every push to main.
|
||||||
|
|
||||||
|
Default release, so an unqualified `dotnet build` is the real application and the nightly is the
|
||||||
|
one you have to ask for.
|
||||||
|
|
||||||
|
What this property does *not* decide is the pack id or the title. Those are arguments to vpk and
|
||||||
|
live where the packaging happens — in ci.yml for the nightly and in the release script for the
|
||||||
|
release. Putting them here would suggest the build knows which package it will end up inside,
|
||||||
|
and it does not.
|
||||||
|
-->
|
||||||
|
<DodoChannel Condition="'$(DodoChannel)' == ''">release</DodoChannel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
False here, unlike every server project. The root Directory.Build.props sets it true because
|
False here, unlike every server project. The root Directory.Build.props sets it true because
|
||||||
the API is container-hosted, UTC-only and has no business formatting anything for a human.
|
the API is container-hosted, UTC-only and has no business formatting anything for a human.
|
||||||
@@ -60,6 +84,16 @@
|
|||||||
<AvaloniaResource Include="Assets/dodossh.ico" />
|
<AvaloniaResource Include="Assets/dodossh.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!--
|
||||||
|
Which channel this build belongs to, carried in the assembly the same way the Android head carries
|
||||||
|
it. Metadata rather than a compile-time constant for the reason stated there: the updater needs the
|
||||||
|
string rather than a branch, and a value baked into the assembly is one a crash report can be asked
|
||||||
|
for. See DesktopChannel, which is the only thing that reads it.
|
||||||
|
-->
|
||||||
|
<AssemblyMetadata Include="DodoChannel" Value="$(DodoChannel)" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Two native symbol files, and they are the reason a self-contained publish weighed 227 MB.
|
Two native symbol files, and they are the reason a self-contained publish weighed 227 MB.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which of the two desktop channels this build belongs to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The counterpart of the Android head's <c>DodoChannel</c> metadata, read the same way and for the same
|
||||||
|
/// reasons. Three things depend on the answer and they are listed here rather than discovered one at a
|
||||||
|
/// time: which Velopack channel the updater reads, whether that read considers prereleases, and which
|
||||||
|
/// profile directory this copy keeps its cache and device key in.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Android gets this separation from the platform and this head has to build it.</b> Two Android
|
||||||
|
/// channels cannot replace one another because the installer refuses a package signed by a different key.
|
||||||
|
/// Nothing refuses anything on Windows: Velopack applies what its feed serves, without verifying a
|
||||||
|
/// signature. So the two channels are kept apart by construction here — a pack id each, so they install
|
||||||
|
/// in different directories; a Velopack channel each, so neither ever reads the other's release index; and
|
||||||
|
/// a profile directory each, so a nightly cannot migrate the schema of a cache the release build is using.
|
||||||
|
/// See <c>docs/adr/0013-desktop-distribution-and-updates.md</c> rule 9.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Resolved once, at first use. The channel of a build does not change while it runs, and a value read
|
||||||
|
/// per call site is one that eventually gets read differently in two places.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class DesktopChannel
|
||||||
|
{
|
||||||
|
/// <summary>The build this channel is not: the one a person cuts from a tag.</summary>
|
||||||
|
internal const string Release = "release";
|
||||||
|
|
||||||
|
/// <summary>The build CI publishes from main, which installs beside the release one.</summary>
|
||||||
|
internal const string Nightly = "nightly";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What this build says it is, defaulting to the release channel.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The csproj always writes the metadata, so the fallback covers a build that reached here some other
|
||||||
|
/// way — an assembly loaded by a test host, a designer, a trimmed-down copy. <see cref="Release"/> is
|
||||||
|
/// the safe answer for all of them: it is what the value was before there were two channels, and every
|
||||||
|
/// consequence of being wrong about it is inert. A build that is not installed has no updater at all
|
||||||
|
/// (see <c>UpdateChannels.ForThisMachine</c>), and the profile directory it names is the one every
|
||||||
|
/// existing install already uses.
|
||||||
|
/// </remarks>
|
||||||
|
internal static string Name { get; } = Read();
|
||||||
|
|
||||||
|
/// <summary>Whether this build belongs to the nightly channel.</summary>
|
||||||
|
internal static bool IsNightly => string.Equals(Name, Nightly, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static string Read()
|
||||||
|
{
|
||||||
|
var declared = typeof(DesktopChannel).Assembly
|
||||||
|
.GetCustomAttributes<AssemblyMetadataAttribute>()
|
||||||
|
.FirstOrDefault(attribute => string.Equals(attribute.Key, "DodoChannel", StringComparison.Ordinal))
|
||||||
|
?.Value;
|
||||||
|
|
||||||
|
// Only the two the csproj declares are honoured. An unrecognised value is a build made by
|
||||||
|
// something nobody here wrote, and answering "release" to it is the same inert default as
|
||||||
|
// answering it to no value at all — rather than pointing an updater at a feed named by a string
|
||||||
|
// of unknown origin.
|
||||||
|
return string.Equals(declared, Nightly, StringComparison.Ordinal) ? Nightly : Release;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,25 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const string ReleaseChannel = "win";
|
private const string ReleaseChannel = "win";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The nightly channel, which is a different name rather than the same one on a different tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A contract with the <c>desktop nightly</c> job in <c>.github/workflows/ci.yml</c>, which passes
|
||||||
|
/// this word to both <c>vpk pack</c> and <c>vpk upload</c>. The name reaches the wire: Velopack
|
||||||
|
/// publishes its index as <c>releases.win-nightly.json</c> and looks for exactly that file, so a
|
||||||
|
/// disagreement between the two sides is a channel that answers nothing, forever, without an error.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Two names rather than one name on two tags, and that is the part doing the work.</b> Both
|
||||||
|
/// channels are published to the same repository, so a client that read the other's index could be
|
||||||
|
/// offered a package built under a different pack id. Velopack would refuse it, but at the far end of
|
||||||
|
/// a download somebody watched. A channel each means neither ever sees the other's releases at all.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private const string NightlyChannel = "win-nightly";
|
||||||
|
|
||||||
private readonly UpdateManager manager;
|
private readonly UpdateManager manager;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -138,10 +157,34 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string CurrentVersion => ClientVersion.Current;
|
public string CurrentVersion => ClientVersion.Current;
|
||||||
|
|
||||||
internal static UpdateManager CreateManager() =>
|
/// <summary>
|
||||||
new(
|
/// The updater for this build's channel.
|
||||||
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: false),
|
/// </summary>
|
||||||
new UpdateOptions { ExplicitChannel = ReleaseChannel });
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The prerelease flag is the half Velopack cannot work out for itself.</b> The channel could be
|
||||||
|
/// left to the installed layout — Velopack records what a package was built with — but whether to
|
||||||
|
/// consider prereleases is a property of the feed rather than of the install, and it decides more
|
||||||
|
/// than it looks. The nightly is published as a prerelease deliberately: the Android head's release
|
||||||
|
/// channel reads <c>releases/latest</c>, which skips prereleases, so a desktop nightly published as a
|
||||||
|
/// stable release would become the newest release in this repository and the phone's release channel
|
||||||
|
/// would start finding no Android manifest on it. One flag here keeps the two heads out of each
|
||||||
|
/// other's way.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The release channel takes <c>false</c>, so it cannot see the nightly at all — which is the property
|
||||||
|
/// that matters most, because that is the direction where a mistake would put an unsigned CI build on
|
||||||
|
/// a machine somebody trusts with their credentials.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static UpdateManager CreateManager()
|
||||||
|
{
|
||||||
|
var nightly = DesktopChannel.IsNightly;
|
||||||
|
|
||||||
|
return new UpdateManager(
|
||||||
|
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: nightly),
|
||||||
|
new UpdateOptions { ExplicitChannel = nightly ? NightlyChannel : ReleaseChannel });
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using DodoSSH.Client.App.Platform;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
using Velopack;
|
using Velopack;
|
||||||
|
|
||||||
@@ -69,7 +70,12 @@ internal static class Program
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var folder = Path.Combine(ClientPaths.Default.DataDirectory, "WebView2");
|
// The same profile directory the rest of the application resolves, channel and all — a nightly
|
||||||
|
// pointing WebView2 at the release build's profile would put two browser profiles in one folder
|
||||||
|
// and hand the pair of them whichever process opened first.
|
||||||
|
var folder = Path.Combine(
|
||||||
|
ClientPaths.ForChannel(DesktopChannel.Name).DataDirectory,
|
||||||
|
"WebView2");
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,7 +37,11 @@
|
|||||||
The product's name is the one string in this bar that is not machine-shaped, so v2 sets it in the
|
The product's name is the one string in this bar that is not machine-shaped, so v2 sets it in the
|
||||||
sans face while the address, the account and the fingerprint beside it stay monospaced.
|
sans face while the address, the account and the fingerprint beside it stay monospaced.
|
||||||
-->
|
-->
|
||||||
<TextBlock Text="DodoSSH" FontSize="14" FontWeight="SemiBold"
|
<!--
|
||||||
|
Named, because a nightly says so here. See the code-behind: the release build is what this
|
||||||
|
markup says and nothing changes for it.
|
||||||
|
-->
|
||||||
|
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="14" FontWeight="SemiBold"
|
||||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||||
<!--
|
<!--
|
||||||
The design puts an organisation here — "dodotech / platform". There are no organisations: the
|
The design puts an organisation here — "dodotech / platform". There are no organisations: the
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
|
using DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
@@ -21,7 +22,24 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class TitleBar : UserControl
|
internal sealed partial class TitleBar : UserControl
|
||||||
{
|
{
|
||||||
public TitleBar() => InitializeComponent();
|
public TitleBar()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
// ◆ A nightly says which one it is, in the one place a person is always looking at.
|
||||||
|
//
|
||||||
|
// The two channels install side by side on purpose, so both windows are on screen at once, both
|
||||||
|
// are called DodoSSH, and both ask for a passphrase the same way. Everything else that
|
||||||
|
// distinguishes them — the Start menu entry, the install directory, the version on the
|
||||||
|
// preferences screen — is somewhere nobody is looking while typing into one of them.
|
||||||
|
//
|
||||||
|
// Set here rather than in the markup so the release build is exactly what the XAML says, and so
|
||||||
|
// the layout harness, which hosts this control directly, measures the shipping string.
|
||||||
|
if (DesktopChannel.IsNightly)
|
||||||
|
{
|
||||||
|
ProductName.Text = "DodoSSH Nightly";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,60 @@ public sealed record ClientPaths(string DataDirectory)
|
|||||||
private const string WindowsFolderName = "DodoSSH";
|
private const string WindowsFolderName = "DodoSSH";
|
||||||
private const string UnixFolderName = "dodossh";
|
private const string UnixFolderName = "dodossh";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The channel whose profile is kept apart, named here because this type is the one that acts on it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The string itself is the desktop head's <c>DesktopChannel.Nightly</c>. It is repeated rather than
|
||||||
|
/// referenced because the dependency runs the wrong way — this project is shared with the Android
|
||||||
|
/// head, which must never acquire a desktop updater — and because a value that reaches an assembly as
|
||||||
|
/// build metadata is a string by the time anybody here sees it.
|
||||||
|
/// </remarks>
|
||||||
|
private const string NightlyChannelName = "nightly";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the nightly's directory is called: the release one, with this on the end.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A sibling rather than a subdirectory of the release profile, so that neither install's uninstaller
|
||||||
|
/// or reset can reach the other's, and so a person looking in <c>%LOCALAPPDATA%</c> sees two things
|
||||||
|
/// with two names rather than one thing with a surprise inside it.
|
||||||
|
/// </remarks>
|
||||||
|
private const string NightlySuffix = ".Nightly";
|
||||||
|
|
||||||
/// <summary>The conventional location for this platform.</summary>
|
/// <summary>The conventional location for this platform.</summary>
|
||||||
public static ClientPaths Default { get; } = new(ResolveDataDirectory());
|
public static ClientPaths Default { get; } = new(ResolveDataDirectory(suffix: null));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a build on the given channel keeps its profile.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A nightly may not share a profile with the release build, and the reason is the cache rather
|
||||||
|
/// than the secrets.</b> The schema is migrated on every launch, before unlock; a nightly carrying a
|
||||||
|
/// migration the release build has not shipped yet would upgrade a database the release build then
|
||||||
|
/// opens. Both are installed at once by design — that is the whole point of a channel that installs
|
||||||
|
/// beside rather than over — so this is an ordinary Tuesday rather than a corner case. Two of them
|
||||||
|
/// running at the same time on one SQLite file and one outbox is the second reason and would be
|
||||||
|
/// enough on its own.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It costs a nightly its sign-in and its known hosts, which is the honest trade: a nightly is a
|
||||||
|
/// second installation of the application, and treating it as one is what stops it damaging the first.
|
||||||
|
/// The device key is per install too, so the deployment sees a new device — which is exactly what
|
||||||
|
/// happened, and what the trust model expects to be told about.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Only the nightly channel is answered specially. Anything else, including the release channel and
|
||||||
|
/// anything unrecognised, gets <see cref="Default"/> — the directory every existing install already
|
||||||
|
/// uses, which must not move for any reason.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="channel">The build channel, as the head's own metadata reports it.</param>
|
||||||
|
public static ClientPaths ForChannel(string? channel) =>
|
||||||
|
string.Equals(channel, NightlyChannelName, StringComparison.Ordinal)
|
||||||
|
? new ClientPaths(ResolveDataDirectory(NightlySuffix))
|
||||||
|
: Default;
|
||||||
|
|
||||||
/// <summary>The encrypted local cache.</summary>
|
/// <summary>The encrypted local cache.</summary>
|
||||||
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
|
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
|
||||||
@@ -68,20 +120,25 @@ public sealed record ClientPaths(string DataDirectory)
|
|||||||
/// here is one line versus depending on whether the runtime happens to.
|
/// here is one line versus depending on whether the runtime happens to.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static string ResolveDataDirectory()
|
private static string ResolveDataDirectory(string? suffix)
|
||||||
{
|
{
|
||||||
|
// Appended to the folder name rather than added as a path segment, on every platform, so the two
|
||||||
|
// profiles are siblings everywhere. The Unix name is lower-cased with the rest of its folder.
|
||||||
|
var windows = WindowsFolderName + suffix;
|
||||||
|
var unix = UnixFolderName + suffix?.ToLowerInvariant();
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows())
|
if (OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
return Path.Combine(
|
return Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
WindowsFolderName);
|
windows);
|
||||||
}
|
}
|
||||||
|
|
||||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
|
||||||
if (OperatingSystem.IsMacOS())
|
if (OperatingSystem.IsMacOS())
|
||||||
{
|
{
|
||||||
return Path.Combine(home, "Library", "Application Support", WindowsFolderName);
|
return Path.Combine(home, "Library", "Application Support", windows);
|
||||||
}
|
}
|
||||||
|
|
||||||
var xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
|
var xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
|
||||||
@@ -90,6 +147,6 @@ public sealed record ClientPaths(string DataDirectory)
|
|||||||
? Path.Combine(home, ".local", "share")
|
? Path.Combine(home, ".local", "share")
|
||||||
: xdgDataHome;
|
: xdgDataHome;
|
||||||
|
|
||||||
return Path.Combine(root, UnixFolderName);
|
return Path.Combine(root, unix);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,63 @@ public sealed class ClientPathsTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The nightly channel keeps its own profile, and everything else keeps the existing one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both halves are load-bearing and they fail in opposite directions. A nightly sharing the release
|
||||||
|
/// build's directory would migrate the cache schema of a database the release build then opens — the
|
||||||
|
/// two are installed at once by design, so that is an ordinary Tuesday. And the release channel's
|
||||||
|
/// directory moving even slightly would orphan every existing install's cache, outbox and device key:
|
||||||
|
/// the application would start, find nothing, and ask for a server. See ADR 0013 decision 9.
|
||||||
|
/// </remarks>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("release")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("something nobody wrote")]
|
||||||
|
public void EveryChannelButTheNightlyKeepsTheExistingProfile(string? channel)
|
||||||
|
{
|
||||||
|
var resolved = ClientPaths.ForChannel(channel).DataDirectory;
|
||||||
|
|
||||||
|
// string.Equals with an explicit comparison rather than ShouldBe, here and below: these are paths
|
||||||
|
// built from the same constants, so the comparison that means anything is the exact one.
|
||||||
|
string.Equals(resolved, ClientPaths.Default.DataDirectory, StringComparison.Ordinal)
|
||||||
|
.ShouldBeTrue($"'{channel}' resolved to '{resolved}' rather than to the default profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheNightlyProfileIsASiblingOfTheReleaseOne()
|
||||||
|
{
|
||||||
|
var release = ClientPaths.Default.DataDirectory;
|
||||||
|
var nightly = ClientPaths.ForChannel("nightly").DataDirectory;
|
||||||
|
|
||||||
|
string.Equals(nightly, release, StringComparison.Ordinal)
|
||||||
|
.ShouldBeFalse($"the nightly and the release build would share '{release}'");
|
||||||
|
|
||||||
|
// A sibling rather than a child, so neither install's uninstaller or reset can reach the other's.
|
||||||
|
string.Equals(
|
||||||
|
Path.GetDirectoryName(nightly),
|
||||||
|
Path.GetDirectoryName(release),
|
||||||
|
StringComparison.Ordinal)
|
||||||
|
.ShouldBeTrue($"'{nightly}' should sit beside '{release}'");
|
||||||
|
|
||||||
|
// And still identifiably ours, on either platform's spelling — the same property the default is
|
||||||
|
// checked for above, for the same reason.
|
||||||
|
nightly.Contains("dodossh", StringComparison.OrdinalIgnoreCase)
|
||||||
|
.ShouldBeTrue($"'{nightly}' should be identifiable as ours");
|
||||||
|
|
||||||
|
// The files follow the directory. A nightly writing the release build's cache or device key is the
|
||||||
|
// whole failure this separation exists to prevent, so it is asserted rather than inferred.
|
||||||
|
var separated = ClientPaths.ForChannel("nightly");
|
||||||
|
|
||||||
|
string.Equals(separated.CacheFile, ClientPaths.Default.CacheFile, StringComparison.Ordinal)
|
||||||
|
.ShouldBeFalse("the two builds would write one cache.db");
|
||||||
|
|
||||||
|
string.Equals(separated.DeviceKeyFile, ClientPaths.Default.DeviceKeyFile, StringComparison.Ordinal)
|
||||||
|
.ShouldBeFalse("the two builds would share one device key");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OnWindowsItIsTheLocalProfileAndNotTheRoamingOne()
|
public void OnWindowsItIsTheLocalProfileAndNotTheRoamingOne()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user