#!/usr/bin/env sh # # Collect every client this image should hand out, into one directory. # # fetch-clients.sh # # The server serves clients from `DATA_DIR/client/` or from the copy baked into the # image (`client_dist.py`). This is what fills the second one. It runs in CI, right # before `docker build`, and writes the FIXED filenames that module looks for. # # THE CHANNEL IS A PROPERTY OF THE IMAGE. A `:dev` image serves dev clients; # `:latest` serves stable ones. Passed in rather than derived here, because the # caller is the thing that knows which image it is building. # # NEVER FAILS. A platform with nothing published means the server advertises # nothing for it and the UI hides that download — a supported state, and the only # one available before a platform's first build has ever published. Turning eight # fetches into eight ways to redden an otherwise fine lane would be strictly worse # than shipping an image that offers four clients instead of five. # # WHY THE VERSION IS FETCHED AND NOT DERIVED. The obvious shortcut is to run # `version.sh display desktop` here — this job has the checkout, after all. It is # wrong: this commit may not be the commit the channel is serving. A push touching # only `src/` does not rebuild the desktop, so the channel still holds an older # build, and a locally-derived version would describe those bytes with this # commit's number. The size check in `client_dist.py` would not catch it, because # the size IS measured from the real file — it would sail through and lie about the # version only. So the version comes from the channel, beside the bytes it # describes, and only `size`/`sha256` are measured here. set -eu channel="${1:?usage: fetch-clients.sh }" dest="${2:?usage: fetch-clients.sh }" case "$channel" in dev|stable) : ;; *) echo "fetch-clients.sh: unknown channel '$channel'" >&2; exit 2 ;; esac SERVER="${GITHUB_SERVER_URL:-https://git.fabledsword.com}" REPO="${GITHUB_REPOSITORY:-bvandeusen/thoughtsync}" BASE="$SERVER/$REPO/releases/download/$channel" mkdir -p "$dest" # Authenticated when we have a token — these releases are private (issue 2091), so # on this instance we always do. Anonymous still works against a public fork. fetch() { if [ -n "${GITHUB_TOKEN:-}" ]; then curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "$2" "$1" else curl -fsSL -o "$2" "$1" fi } # One field out of a small flat JSON object. `grep`/`sed` rather than a parser # because this runs in the CI image's busybox sh and adding a jq dependency to buy # one string is not a trade worth making. The sidecars are written by us and are # one level deep. field() { grep -oE "\"$2\"[[:space:]]*:[[:space:]]*\"?[^,\"}]+\"?" "$1" 2>/dev/null \ | head -1 | sed -E 's/.*:[[:space:]]*"?([^"]*)"?[[:space:]]*$/\1/' } bytes() { wc -c < "$1" | tr -d ' '; } digest() { sha256sum "$1" | cut -d' ' -f1; } # The sidecar shape `client_dist.py` reads. `size` and `sha256` are measured from # the file that actually landed, so a truncated download cannot be described as a # whole one. sidecar() { _file="$1"; _out="$2"; _name="$3"; _code="$4" # `version_code` is QUOTED here, and that is not a slip. This function only ever # writes DESKTOP sidecars, whose ordering key is Tauri's `1.0.` — which # unquoted is not valid JSON at all, so every sidecar this wrote would fail to # parse and the server would advertise nothing. Android's sidecar is a different # file, copied verbatim from its lane, and keeps its integer. printf '{\n "version_name": "%s",\n "version_code": "%s",\n "size": %s,\n "sha256": "%s"\n}\n' \ "$_name" "$_code" "$(bytes "$_file")" "$(digest "$_file")" > "$_out" } echo "==> Collecting the $channel clients" # --- Android ----------------------------------------------------------------- # # Its sidecar is published whole by the Android lane — an APK keeps its version in # a binary AXML manifest, so the values are recorded where they were already known. # Copied verbatim rather than rebuilt here. if fetch "$BASE/thoughtsync.apk" "$dest/thoughtsync.apk" && fetch "$BASE/thoughtsync-android.json" "$dest/thoughtsync-android.json"; then echo " android $(field "$dest/thoughtsync-android.json" version_name)" else # Both or neither. Half a pair is worse than none: the server would read a # sidecar describing an APK that is not there, or an APK it cannot state a # version for. echo "::warning::No Android client on the $channel channel — this image ships without one." rm -f "$dest/thoughtsync.apk" "$dest/thoughtsync-android.json" fi # --- desktop ----------------------------------------------------------------- # # One sidecar on the channel carries the version PAIR for all four bundles, because # they are one build: `version_name` is what a person reads, `version_code` is the # ordering key, and the key is also what the bundle filenames are stamped with. # Written by `write-manifest.sh`, which is the step that speaks for what the channel # serves. bake_desktop() { desk="$dest/.desktop-release.json" if ! fetch "$BASE/thoughtsync-desktop.json" "$desk"; then echo "::warning::No desktop release on the $channel channel — this image ships without desktop clients." rm -f "$desk" return 0 fi name="$(field "$desk" version_name)" key="$(field "$desk" version_code)" rm -f "$desk" if [ -z "$name" ] || [ -z "$key" ]; then echo "::warning::The $channel desktop sidecar named no version — skipping desktop clients." return 0 fi echo " desktop $name (key $key)" # Bundle filenames are stamped with the ORDERING KEY — what Tauri puts in them, # and what `write-manifest.sh` already selects on. Constructed rather than # discovered from the release's asset list: one shape, no JSON walk, and a name # that does not resolve is caught by the fetch failing rather than by matching # the wrong file. # # `||` for row in \ "linux-deb|ThoughtSync_${key}_amd64.deb|thoughtsync.deb" \ "linux-pacman|thoughtsync-${key}-1-x86_64.pkg.tar.zst|thoughtsync.pkg.tar.zst" \ "linux-appimage|ThoughtSync_${key}_amd64.AppImage|thoughtsync.AppImage" \ "windows|ThoughtSync_${key}_x64-setup.exe|thoughtsync-setup.exe" do id="${row%%|*}"; rest="${row#*|}" remote="${rest%%|*}"; local_name="${rest#*|}" if ! fetch "$BASE/$remote" "$dest/$local_name"; then echo "::warning::$channel has no $remote — this image ships without the $id client." rm -f "$dest/$local_name" continue fi # The AppImage is the only bundle that replaces itself in place, so the updater # verifies a signature before it does. Without one it is not servable as an # update, and `client_dist.py` treats it as absent rather than offering it # unverifiable — so drop the bundle too rather than baking 95 MB nothing can use. if [ "$id" = "linux-appimage" ]; then if ! fetch "$BASE/$remote.sig" "$dest/$local_name.sig"; then echo "::warning::$remote has no signature on $channel — dropping the AppImage." rm -f "$dest/$local_name" "$dest/$local_name.sig" continue fi fi sidecar "$dest/$local_name" "$dest/thoughtsync-$id.json" "$name" "$key" echo " $id $(bytes "$dest/$local_name") bytes" done } bake_desktop echo "==> Baked in:" ls -l "$dest"