Files
inkwell/packaging/fetch-clients.sh
T
bvandeusenandClaude Opus 5.5 fe6f0746b2 rename: the desktop is Inkwell — crates, Tauri identity, data move, packaging
Step 3 of milestone 481 (Scribe note 5071: a full rename).

- crates thoughtsync-{core,desktop,ffi,uniffi-bindgen} → inkwell-*, the
  Cargo.lock entries moved to match (checked with `cargo metadata --locked`)
- Tauri: productName "Inkwell", identifier com.fabledsword.inkwell, binary
  `inkwell`, updater feed on bvandeusen/inkwell, store file inkwell.db
- client agent inkwell-desktop, headers X-Inkwell-Client/-Protocol (the server
  reads neither), capture event inkwell://captured, display-version env
- .deb: conflicts + replaces thought-sync, so the updater's install retires the
  old package instead of colliding on it. kebab-case("Inkwell") is `inkwell`, so
  the package name finally matches the command and verify.sh now asserts it
- pacman: inkwell, conflicting with and replacing thoughtsync and
  thoughtsync-desktop
- AppImage ~/Applications/Inkwell.AppImage, menu entry inkwell.desktop,
  installer, release titles, desktop asset names in fetch-clients.sh

The one shim, chosen by the operator because it is the only copy of a
local-first user's notes: crossover.rs moves the old
com.fabledsword.thoughtsync app-data dir's contents into the new one on startup,
before the store opens, renaming thoughtsync.db and its -wal/-shm with it. It
skips when the new dir already has a store, and anything already in the new dir
wins (the installer writes its channel marker there first). Tested.

Android's Kotlin side (package, applicationId, uniffi class) is step 4. Its
release asset names stay thoughtsync.* until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:27:26 -04:00

170 lines
7.4 KiB
Bash
Executable File

#!/usr/bin/env sh
#
# Collect every client this image should hand out, into one directory.
#
# fetch-clients.sh <dev|stable> <destdir>
#
# 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 <dev|stable> <destdir>}"
dest="${2:?usage: fetch-clients.sh <dev|stable> <destdir>}"
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/inkwell}"
# The channel's release tag, not its name — `dev` lives on `dev-rolling`
# (packaging/channel-tag.sh, Scribe #2184).
tag="$(sh "$(dirname "$0")/channel-tag.sh" "$channel")"
BASE="$SERVER/$REPO/releases/download/$tag"
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.<minutes>` — 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/inkwell.apk" &&
fetch "$BASE/thoughtsync-android.json" "$dest/inkwell-android.json"; then
echo " android $(field "$dest/inkwell-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/inkwell.apk" "$dest/inkwell-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/inkwell-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.
#
# `<platform id>|<published name>|<name on disk>`
for row in \
"linux-deb|Inkwell_${key}_amd64.deb|inkwell.deb" \
"linux-pacman|inkwell-${key}-1-x86_64.pkg.tar.zst|inkwell.pkg.tar.zst" \
"linux-appimage|Inkwell_${key}_amd64.AppImage|inkwell.AppImage" \
"windows|Inkwell_${key}_x64-setup.exe|inkwell-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/inkwell-$id.json" "$name" "$key"
echo " $id $(bytes "$dest/$local_name") bytes"
done
}
bake_desktop
echo "==> Baked in:"
ls -l "$dest"