Public Access
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174ef7c420 | ||
|
|
af0e29a98b |
+276
-3
@@ -787,6 +787,278 @@ jobs:
|
||||
|
||||
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:
|
||||
name: api image
|
||||
# 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'
|
||||
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
|
||||
# and packaged here — the two steps at the end of the build job — and what is withheld is only the
|
||||
# upload.
|
||||
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
|
||||
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
||||
# 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.
|
||||
#
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
**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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
+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
|
||||
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
|
||||
that target has run, and both readers of it — the tag check in `ci.yml` and `scripts/release-windows.ps1`
|
||||
— pass it. Neither had, and neither had ever run: the CI 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`.
|
||||
that target has run, and every reader of it — the tag check in `ci.yml`, the desktop nightly job, and
|
||||
`scripts/release-windows.ps1` — passes it. Neither of the first two had, and neither had ever run: the CI
|
||||
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
|
||||
— 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
|
||||
# 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.
|
||||
#
|
||||
# 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'
|
||||
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
@@ -100,11 +105,20 @@ Push-Location $RepoRoot
|
||||
try {
|
||||
# ---- What is being released -----------------------------------------------------------------------
|
||||
|
||||
# -t:MinVer, and it is load-bearing. -getProperty on its own evaluates the project and runs no
|
||||
# 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
|
||||
# tagged v1.0.0. CI's tag check had the same line and the same fault; both are fixed, and both
|
||||
# say so, because this is the version that ends up in the package a client compares against.
|
||||
# Restored before the version is read, and both halves of that sentence are load-bearing.
|
||||
#
|
||||
# -t:MinVer, because -getProperty on its own evaluates the project and runs no 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 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', ''
|
||||
if ([string]::IsNullOrWhiteSpace($version)) {
|
||||
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)
|
||||
{
|
||||
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);
|
||||
|
||||
// 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>
|
||||
|
||||
<!--
|
||||
============ 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
|
||||
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" />
|
||||
</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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
@@ -138,10 +157,34 @@ internal sealed class VelopackUpdateChannel : IUpdateChannel
|
||||
/// </remarks>
|
||||
public string CurrentVersion => ClientVersion.Current;
|
||||
|
||||
internal static UpdateManager CreateManager() =>
|
||||
new(
|
||||
new GiteaSource(RepositoryUrl, accessToken: null, prerelease: false),
|
||||
new UpdateOptions { ExplicitChannel = ReleaseChannel });
|
||||
/// <summary>
|
||||
/// The updater for this build's channel.
|
||||
/// </summary>
|
||||
/// <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 />
|
||||
public async Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
using Velopack;
|
||||
|
||||
@@ -69,7 +70,12 @@ internal static class Program
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
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" />
|
||||
<!--
|
||||
The design puts an organisation here — "dodotech / platform". There are no organisations: the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
@@ -21,7 +22,24 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
|
||||
@@ -23,8 +23,60 @@ public sealed record ClientPaths(string DataDirectory)
|
||||
private const string WindowsFolderName = "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>
|
||||
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>
|
||||
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.
|
||||
/// </para>
|
||||
/// </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())
|
||||
{
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
WindowsFolderName);
|
||||
windows);
|
||||
}
|
||||
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
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");
|
||||
@@ -90,6 +147,6 @@ public sealed record ClientPaths(string DataDirectory)
|
||||
? Path.Combine(home, ".local", "share")
|
||||
: 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]
|
||||
public void OnWindowsItIsTheLocalProfileAndNotTheRoamingOne()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user