install.sh installs from your own server, not just from the forge
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m5s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m15s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 2m57s
Desktop (Tauri) / Update manifest (push) Successful in 6s

Milestone 325 step 5 (Scribe #3253).

    curl -fsSL https://notes.example.com/install.sh | sh

The server serves the installer at /install.sh with its own address written
into it (installer.py). Settings → Public address when set, the request's own
address otherwise. The substitution is one variable, given a value that has
passed a strict shape check, and the script checks it again; an address that
cannot pass makes the route refuse rather than serve a script pointed
elsewhere. `public_url` joins the live settings cache so the route needs no
database.

From a server, the script:
- resolves each Linux bundle from the public /api/client/<platform>, and
  builds the download URL from the platform id rather than reading it from
  the reply;
- asks for a device token (from the terminal, since stdin is the script),
  or takes TS_TOKEN, and sends it from a file rather than the command line;
- checks the sha256 the server published before anything installs;
- revokes a prompted token once the download is done;
- records `install-server` for the updater (step 6) instead of the channel.

The forge path is unchanged, and stays the default for the copy the forge
serves. The Account page's downloads card shows the one-line command
whenever the server holds a Linux client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 06:44:39 -04:00
co-authored by Claude Opus 5.5
parent 802eab4ef9
commit 871878de41
7 changed files with 442 additions and 18 deletions
+184 -18
View File
@@ -2,8 +2,16 @@
#
# Inkwell desktop — one-command Linux installer.
#
# curl -fsSL https://notes.example.com/install.sh | sh (from your server)
# curl -fsSL https://git.fabledsword.com/bvandeusen/inkwell/raw/branch/dev/desktop/packaging/install.sh | sh
#
# FROM A SERVER, the script installs the build that server holds — the same one its
# Account page offers — and asks for a device token to download it with, because the
# bytes are not for anyone who can reach the port. The server writes its own address
# into the copy it serves (src/inkwell/installer.py), so nothing needs editing.
# Everything below about channels is the FORGE path; a server serves one build, and
# which channel that is was decided when its image was built.
#
# Two channels, the SAME two the app's own updater offers (src-tauri/src/update.rs):
# stable (default) — the rolling build from every merge to `main`.
# dev — the rolling build from every green push to `dev`.
@@ -46,25 +54,43 @@ usage() {
cat <<'USAGE'
Inkwell desktop installer.
install.sh [--channel stable|dev]
install.sh [--server URL] [--channel stable|dev]
--channel stable newest build from main (default)
--channel dev rolling build from the latest green push to `dev`
--server URL install from this Inkwell server (set already when the
script came from one)
--channel stable from the forge: newest build from main (default)
--channel dev from the forge: rolling build from the latest green push to `dev`
-h, --help this text
The channel can also come from TS_CHANNEL. Through a pipe, pass options after
`--`: curl -fsSL <url> | sh -s -- --channel dev
The options can also come from TS_SERVER and TS_CHANNEL. Through a pipe, pass
them after `--`: curl -fsSL <url> | sh -s -- --channel dev
From a server, the download needs a device token: make one in the web app under
Account → Linked devices and paste it when asked. A token typed at the prompt is
revoked again once the download is done. TS_TOKEN supplies one without a prompt,
and is left alone, since whoever set it is managing it.
USAGE
}
# The address of the server that served this script. Written by that server
# (src/inkwell/installer.py rewrites exactly this line) and empty in the copy the
# forge serves, which is what keeps the forge path the default there.
TS_SERVER_DEFAULT=""
# --- channel ----------------------------------------------------------------
channel="${TS_CHANNEL:-stable}"
channel_asked="${TS_CHANNEL:-}"
server="${TS_SERVER:-$TS_SERVER_DEFAULT}"
while [ $# -gt 0 ]; do
case "$1" in
--channel)
[ $# -ge 2 ] || die "--channel needs a value (stable or dev)."
channel="$2"; shift 2 ;;
--channel=*) channel="${1#*=}"; shift ;;
channel="$2"; channel_asked="$2"; shift 2 ;;
--channel=*) channel="${1#*=}"; channel_asked="$channel"; shift ;;
--server)
[ $# -ge 2 ] || die "--server needs an address (https://…)."
server="$2"; shift 2 ;;
--server=*) server="${1#*=}"; shift ;;
-h | --help) usage; exit 0 ;;
*) die "unknown option: $1 (try --help)" ;;
esac
@@ -76,6 +102,25 @@ esac
have curl || die "curl is required."
# --- server address ---------------------------------------------------------
# Checked HERE as well as by the server that wrote it: this value goes into every
# URL below, and a script should not trust a string because of where it came from.
# The same shape the server enforces — a scheme, then only characters that cannot
# mean anything to a shell or a URL parser beyond what they say.
server="${server%/}"
if [ -n "$server" ]; then
case "$server" in
http://?* | https://?*) : ;;
*) die "the server address must start with http:// or https:// (got: $server)." ;;
esac
case "$server" in
*[!A-Za-z0-9:/._~-]*) die "the server address has characters an address cannot have (got: $server)." ;;
esac
if [ -n "$channel_asked" ]; then
say "Note: a server serves the one build it holds; --channel only applies to the forge."
fi
fi
# --- architecture gate ------------------------------------------------------
# Only x86_64 is built today; arm64 will be added when the CI matrix grows. The
# release-asset naming carries the arch, but since only one arch ships now we
@@ -86,7 +131,99 @@ case "$arch" in
*) die "Inkwell ships x86_64 Linux builds only right now (this machine: $arch)." ;;
esac
# --- resolve the release for this channel -----------------------------------
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM
# Every download goes through here, so the server path's token is sent on all of them
# and on nothing else. From a FILE, not the command line, where any user on the
# machine could read it out of `ps` while curl runs.
fetch() {
if [ -n "$server" ]; then
curl -fSL -H @"$tmp/auth" -o "$2" "$1"
else
curl -fSL -o "$2" "$1"
fi
}
# The download against the digest its server published, BEFORE it reaches pacman,
# dpkg or a menu entry: a truncated file installs as something broken rather than
# failing. The forge path publishes no digest per bundle, so it passes an empty one
# and this checks nothing there; that is the forge path exactly as it always was.
verify() {
[ -n "$2" ] || return 0
if have sha256sum; then
got="$(sha256sum "$1" | cut -d' ' -f1)"
elif have shasum; then
got="$(shasum -a 256 "$1" | cut -d' ' -f1)"
else
die "can't check the download: neither sha256sum nor shasum is installed."
fi
[ "$got" = "$2" ] || die "the download doesn't match what the server published (sha256 $got, expected $2). Nothing was installed."
}
pkg_sha=""; deb_sha=""; appimage_sha=""
if [ -n "$server" ]; then
# --- resolve from a server ----------------------------------------------------
# `/api/client/<platform>` is public, so what the server holds is known before any
# token is asked for. The download URL is BUILT here from the platform id, never read
# from the reply — the same rule the apps follow (core/src/sync/client.rs): a reply
# that named some other host would otherwise be fetched, with the token attached.
say "Finding the Inkwell build $server holds…"
# One string field out of the flat JSON object the server returns. sed rather than
# a parser, for the same reason as the forge path's grep: no jq on most machines.
jfield() {
printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1
}
meta() { curl -fsS "$server/api/client/$1" 2>/dev/null || true; }
pkg_meta="$(meta linux-pacman)"
deb_meta="$(meta linux-deb)"
appimage_meta="$(meta linux-appimage)"
[ -n "$pkg_meta" ] || [ -n "$deb_meta" ] || [ -n "$appimage_meta" ] ||
die "$server has no Linux client to install (or isn't an Inkwell server — is the address right?)."
pkg_url=""; deb_url=""; appimage_url=""
if [ -n "$pkg_meta" ]; then
pkg_url="$server/api/client/linux-pacman/download"; pkg_sha="$(jfield "$pkg_meta" sha256)"
fi
if [ -n "$deb_meta" ]; then
deb_url="$server/api/client/linux-deb/download"; deb_sha="$(jfield "$deb_meta" sha256)"
fi
if [ -n "$appimage_meta" ]; then
appimage_url="$server/api/client/linux-appimage/download"; appimage_sha="$(jfield "$appimage_meta" sha256)"
fi
# One build, four bundles: any of them names the version.
version="$(jfield "$pkg_meta$deb_meta$appimage_meta" version)"
say "Installing ${version:-unknown} from $server"
# The token, from the environment or from the person at the keyboard. Read from the
# TERMINAL, not stdin: through `curl | sh`, stdin is this script.
token="${TS_TOKEN:-}"
prompted=""
if [ -z "$token" ]; then
{ [ -r /dev/tty ] && [ -w /dev/tty ]; } ||
die "the download needs a device token and there's no terminal to ask on; set TS_TOKEN."
printf 'A device token is needed to download from %s.\n' "$server" > /dev/tty
printf 'Make one in the web app under Account → Linked devices (it is revoked once the download is done).\n' > /dev/tty
printf 'Token: ' > /dev/tty
stty -echo < /dev/tty 2>/dev/null || true
IFS= read -r token < /dev/tty || true
stty echo < /dev/tty 2>/dev/null || true
printf '\n' > /dev/tty
prompted=1
fi
[ -n "$token" ] || die "no token given."
# The server's tokens are URL-safe base64. Anything else is a paste gone wrong, and a
# newline in it would be a second header.
case "$token" in
*[!A-Za-z0-9_-]*) die "that doesn't look like a device token (they're letters, digits, - and _)." ;;
esac
( umask 077; printf 'Authorization: Bearer %s\n' "$token" > "$tmp/auth" )
else
# --- resolve from the forge, for this channel --------------------------------
say "Finding the latest Inkwell build on the $channel channel…"
# ONE lookup, both channels. Each is a release whose tag never moves and whose assets
@@ -117,9 +254,19 @@ version="$(printf '%s' "$json" | grep -oE '"tag_name":"[^"]+"' | head -1 | sed -
[ -n "$appimage_url" ] || [ -n "$deb_url" ] || [ -n "$pkg_url" ] ||
die "the $channel release (${version:-unknown}) has no installable Linux asset."
say "Installing ${version:-unknown} from the $channel channel"
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT INT TERM
# A token typed at the prompt was made for this one install; once the bytes are here
# it has nothing left to do, so it goes. Best-effort: the install does not depend on
# it, and the person is told if it is still live.
revoke_token() {
[ -n "$server" ] && [ -n "$prompted" ] || return 0
if curl -fsS -o /dev/null -X DELETE -H @"$tmp/auth" "$server/api/auth/devices/self" 2>/dev/null; then
say "Revoked the download token."
else
say "Couldn't revoke the download token; remove it under Account → Linked devices."
fi
}
# Tell the app which channel it was installed from. The installer is the only thing
# that knows, and without this the app kept its own `stable` default and a dev install
@@ -133,11 +280,21 @@ trap 'rm -rf "$tmp"' EXIT INT TERM
#
# The directory is Tauri's app-data dir for identifier com.fabledsword.inkwell;
# both sides hardcode it, so a change to the identifier has to change both.
#
# From a server, the sibling `install-server` is written instead: which server the app
# came from, for the updater to follow (milestone 325, step 6). The channel marker is
# left alone on that path, because a server's channel is whatever its image was built
# with and nothing here can know it.
record_channel() {
marker_dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.fabledsword.inkwell"
# Best-effort: a failure here costs the channel setting, not the install, and a
# native install run as root would only be writing into root's home anyway.
mkdir -p "$marker_dir" 2>/dev/null && printf '%s\n' "$channel" > "$marker_dir/install-channel" 2>/dev/null || true
mkdir -p "$marker_dir" 2>/dev/null || return 0
if [ -n "$server" ]; then
printf '%s\n' "$server" > "$marker_dir/install-server" 2>/dev/null || true
else
printf '%s\n' "$channel" > "$marker_dir/install-channel" 2>/dev/null || true
fi
}
# Both native paths install system-wide, so they need root. Resolved once here
@@ -155,7 +312,9 @@ need_root() {
# than letting someone discover it from a greyed-out button.
native_update_note() {
printf ' A package-manager install can'\''t update itself in-app.\n'
if [ "$channel" = "dev" ]; then
if [ -n "$server" ]; then
printf ' Re-run this script from %s to move to the build it holds.\n' "$server"
elif [ "$channel" = "dev" ]; then
printf ' Re-run this script with --channel dev to move to a newer dev build.\n'
else
printf ' Re-run this script to move to a newer release.\n'
@@ -171,8 +330,11 @@ if have pacman && [ -n "$pkg_url" ]; then
say "Arch-family system detected — installing the native pacman package"
# Keep the published filename: pacman -U expects a *.pkg.tar.* name and refuses
# a file that doesn't look like a package, whatever its actual contents.
pkg_file="$tmp/$(basename "$pkg_url")"
curl -fSL -o "$pkg_file" "$pkg_url"
# From a server the URL ends in `/download`, so the name comes from the platform.
if [ -n "$server" ]; then pkg_file="$tmp/inkwell.pkg.tar.zst"; else pkg_file="$tmp/$(basename "$pkg_url")"; fi
fetch "$pkg_url" "$pkg_file"
verify "$pkg_file" "$pkg_sha"
revoke_token
need_root
$sudo pacman -U --noconfirm "$pkg_file"
record_channel
@@ -184,7 +346,9 @@ fi
# --- native .deb path (Debian/Ubuntu) ---------------------------------------
if have dpkg && have apt-get && [ -n "$deb_url" ]; then
say "Debian-family system detected — installing the native .deb"
curl -fSL -o "$tmp/inkwell.deb" "$deb_url"
fetch "$deb_url" "$tmp/inkwell.deb"
verify "$tmp/inkwell.deb" "$deb_sha"
revoke_token
need_root
# apt-get resolves the .deb's dependencies (webkit2gtk etc.). dpkg is the
# fallback if this apt is too old for local-file installs — it leaves the deps
@@ -207,8 +371,10 @@ say "Installing the de-bundled AppImage (user-local, no sudo)"
apps_dir="$HOME/Applications"
dest="$apps_dir/Inkwell.AppImage"
mkdir -p "$apps_dir"
say "Downloading $(basename "$appimage_url")…"
curl -fSL -o "$tmp/Inkwell.AppImage" "$appimage_url"
say "Downloading the AppImage…"
fetch "$appimage_url" "$tmp/Inkwell.AppImage"
verify "$tmp/Inkwell.AppImage" "$appimage_sha"
revoke_token
chmod +x "$tmp/Inkwell.AppImage"
mv -f "$tmp/Inkwell.AppImage" "$dest"
@@ -257,7 +423,7 @@ say "Installed to $dest"
printf ' Launch it from your application menu, or run \033[1minkwell\033[0m'
printf ' (if ~/.local/bin is on your PATH).\n'
# This is the one path where the app can update itself, so say what it will follow.
if [ "$channel" = "dev" ]; then
if [ -z "$server" ] && [ "$channel" = "dev" ]; then
printf ' In-app updates will follow the \033[1mdev\033[0m channel.'
printf ' Change it in Sync → App updates.\n'
fi