#!/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 `TS_CHANNEL=dev`. Through a pipe the options go # after a `--`: curl -fsSL | 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 TS_SERVER and TS_CHANNEL. Through a pipe, pass them after `--`: curl -fsSL | 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"; 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/` 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 # 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" </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