Files
inkwell/desktop/packaging/install.sh
T
bvandeusenandClaude Opus 5.5 fae03c7eca install.sh reads INKWELL_SERVER, INKWELL_CHANNEL and INKWELL_TOKEN (#5372)
The installer's environment variables kept the ThoughtSync-era TS_ prefix
after the rename to Inkwell. They are now INKWELL_*, as is the
INKWELL_SERVER_DEFAULT line the server fills in when it serves the script.
The old names are not read any more; the operator approved the clean cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:01:13 -04:00

430 lines
19 KiB
Bash
Executable File

#!/bin/sh
#
# 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`.
# Both are fixed-tag releases: the tag never moves and the assets are pruned to the
# current build, so the tag alone names the newest one. `stable` only became one in
# M314 step 3, when `main` started publishing — before that it was a manifest-only
# pointer at whatever `v*` tag somebody had last cut, and this script carried a
# fallback that chased the `v*` release its manifest named. That came out once
# `main` had published to `stable` for real (`b6673c6`); the two channels are the
# same shape now and nothing here should special-case one of them again.
# Pick one with `--channel dev` or `INKWELL_CHANNEL=dev`. Through a pipe the options go
# after a `--`: curl -fsSL <url> | sh -s -- --channel dev
#
# Served from `dev` rather than `main`: `main` exists but trails day-to-day work by
# a long way, so the copy there would install an older script. Move the documented
# URL to `main` after a dev→main merge lands, not before.
#
# Fetches the LATEST published release on the chosen channel for this machine's
# architecture and installs it, ending with a working app + menu entry. Native-first:
# * Arch/CachyOS (pacman) -> the native .pkg.tar.zst (system libs; needs sudo).
# * Debian/Ubuntu (dpkg+apt) -> the native .deb (system libs; needs sudo).
# * everything else (Fedora/openSUSE/…) -> the de-bundled AppImage,
# installed user-locally (no sudo). The AppImage's graphics libs are
# stripped in CI (see debundle-graphics.sh), so it uses the host GPU stack
# and renders where a stock Tauri AppImage would black-window (issue 2021).
#
# POSIX sh (dash-safe) so `curl … | sh` works everywhere. Dependency-light and
# auditable on purpose — read it before you pipe it.
set -eu
INSTANCE="https://git.fabledsword.com"
REPO="bvandeusen/inkwell"
API="$INSTANCE/api/v1/repos/$REPO"
say() { printf '==> %s\n' "$1"; }
die() { printf 'error: %s\n' "$1" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
usage() {
cat <<'USAGE'
Inkwell desktop installer.
install.sh [--server URL] [--channel stable|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 options can also come from INKWELL_SERVER and INKWELL_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. INKWELL_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.
INKWELL_SERVER_DEFAULT=""
# --- channel ----------------------------------------------------------------
channel="${INKWELL_CHANNEL:-stable}"
channel_asked="${INKWELL_CHANNEL:-}"
server="${INKWELL_SERVER:-$INKWELL_SERVER_DEFAULT}"
while [ $# -gt 0 ]; do
case "$1" in
--channel)
[ $# -ge 2 ] || die "--channel needs a value (stable or dev)."
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
done
case "$channel" in
stable | dev) : ;;
*) die "unknown channel '$channel' — expected stable or dev." ;;
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
# match by file extension below and just guard the arch here.
arch="$(uname -m)"
case "$arch" in
x86_64 | amd64) : ;;
*) die "Inkwell ships x86_64 Linux builds only right now (this machine: $arch)." ;;
esac
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="${INKWELL_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 INKWELL_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
# are pruned to the current build, so the tag alone names the newest build on that
# channel — which is exactly what an installer wants and what the in-app updater
# already reads.
# The channel's RELEASE TAG, which is not always its name: `dev` publishes on the
# tag `dev-rolling`, because a tag called `dev` shadowed the branch of the same name
# and broke `git push origin dev` (Scribe #2184). Same mapping as
# packaging/channel-tag.sh — inlined because this script is fetched alone through a
# pipe and has nothing to source.
case "$channel" in
dev) tag=dev-rolling ;;
*) tag="$channel" ;;
esac
json="$(curl -fsSL "$API/releases/tags/$tag" 2>/dev/null)" ||
die "the $channel channel has nothing published yet."
# Pull asset URLs straight out of the release JSON (no jq). Anchored on the closing
# quote so a `…AppImage.sig` URL can't be truncated into a match of its own.
asset_url() {
printf '%s' "$json" | grep -oE "https?://[^\"]+$1\"" | head -1 | tr -d '"'
}
appimage_url="$(asset_url '\.AppImage')"
deb_url="$(asset_url '\.deb')"
pkg_url="$(asset_url '\.pkg\.tar\.[a-z]+')"
version="$(printf '%s' "$json" | grep -oE '"tag_name":"[^"]+"' | head -1 | sed -E 's/.*:"([^"]+)".*/\1/')"
[ -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
# 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
# checked the stable feed — which advertises an OLDER version — reporting "up to date"
# forever (issue 2183).
#
# A plain file rather than a write into the app's SQLite store: shell has no business
# knowing that schema, and a file it can't misread is the narrowest possible contract.
# The app reads it at startup (src-tauri/src/update.rs, INSTALL_MARKER) and only acts
# when the value CHANGED, so switching channel in the app isn't undone on next launch.
#
# 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 || 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
# rather than duplicated per branch; the AppImage path below never calls this.
need_root() {
if [ "$(id -u)" -eq 0 ]; then sudo=""; else
have sudo || die "a native install needs root; re-run as root or install sudo."
sudo="sudo"
fi
say "Installing (you may be prompted for your password)…"
}
# Both native paths are package-manager-owned, so the app cannot replace itself
# in place (update.rs refuses, by design). Say so at the end of those paths rather
# 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 [ -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'
fi
}
# --- native pacman path (Arch/CachyOS/Manjaro) ------------------------------
# Preferred over the AppImage on Arch: pacman pulls webkit2gtk-4.1 itself and the
# app then runs against the host graphics stack, which is what keeps the
# EGL_BAD_PARAMETER black window (issue 2021) from coming back. It also means the
# app is tracked by the package manager and uninstalls cleanly.
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.
# 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
say "Done. Launch Inkwell from your application menu, or run inkwell."
native_update_note
exit 0
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"
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
# unconfigured, so `apt-get -f install` is what actually completes that path.
$sudo apt-get install -y "$tmp/inkwell.deb" ||
{ $sudo dpkg -i "$tmp/inkwell.deb" || true; $sudo apt-get -f install -y; }
record_channel
say "Done. Launch Inkwell from your application menu."
native_update_note
exit 0
fi
# --- universal AppImage path (user-local, no sudo) --------------------------
# Install into ~/Applications/Inkwell.AppImage — the SAME location the app's
# own self-integration uses (src/integration.rs) — so the running app sees
# itself already installed and never makes a second copy or menu entry.
say "Installing the de-bundled AppImage (user-local, no sudo)"
[ -n "$appimage_url" ] || die "the $channel release (${version:-unknown}) has no AppImage asset."
apps_dir="$HOME/Applications"
dest="$apps_dir/Inkwell.AppImage"
mkdir -p "$apps_dir"
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"
# Menu entry — written to match integration.rs verbatim (same paths + fields),
# so the app reports is_integrated=true and won't duplicate it.
apps_menu="$HOME/.local/share/applications"
icons_dir="$HOME/.local/share/icons"
mkdir -p "$apps_menu" "$icons_dir"
# Best-effort: pull the real icon out of the AppImage (.DirIcon) so the menu
# entry looks right immediately. Extraction is a non-GUI unsquash (no FUSE, no
# black-window risk); if it fails we fall back to the themed name and the app
# writes its embedded icon on first launch anyway.
icon_ref="inkwell"
if ( cd "$tmp" && "$dest" --appimage-extract .DirIcon >/dev/null 2>&1 ) \
&& cp -L "$tmp/squashfs-root/.DirIcon" "$icons_dir/inkwell.png" 2>/dev/null; then
icon_ref="$icons_dir/inkwell.png"
fi
rm -rf "$tmp/squashfs-root" 2>/dev/null || true
# StartupWMClass is the BINARY name, not the product name and not the AppImage
# filename: the AppImage's AppRun execs usr/bin/inkwell, and GTK derives
# WM_CLASS from whatever it ends up running. Anything else here means the window
# never associates with this entry and the taskbar shows a second, generic icon.
cat > "$apps_menu/inkwell.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Inkwell
Comment=Capture a fleeting thought in a second
Exec=$dest %U
Icon=$icon_ref
Terminal=false
Categories=Utility;Office;
StartupWMClass=inkwell
EOF
have update-desktop-database && update-desktop-database "$apps_menu" >/dev/null 2>&1 || true
# Convenience CLI launcher.
mkdir -p "$HOME/.local/bin"
ln -sf "$dest" "$HOME/.local/bin/inkwell"
record_channel
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 [ -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