#!/bin/sh # Transit — Linux installer. # # curl -fsSL https://downloads.transitai.app/install.sh | sh # # Installs Transit using the system's own package manager, so the result is # an ordinary tracked package: `pacman -Q` / `dpkg -l` / `rpm -q` see it, and # the normal uninstall works. Nothing lands outside the package. # # Re-running this script UPDATES an existing install — every path below # upgrades in place, so install and update are the same command. # # Two deliberate properties, because this is piped into a shell: # * it prints exactly what it will run and waits for confirmation (-y to # skip); with no terminal available it refuses rather than assuming yes. # * it never runs itself as root. Only the package-manager step is sudo'd, # so `curl … | sudo sh` is never the documented form. # # The published sha256 is checked, which catches a truncated or mismatched # download. It is NOT independent provenance: the checksum and the package # come from the same origin over the same TLS. Signed repositories are what # would make it provenance; that is a separate piece of work. set -eu BASE="${TRANSIT_DOWNLOADS_BASE:-https://downloads.transitai.app}" ASSUME_YES=0 PIN_VERSION="" DRY_RUN=0 # ---------------------------------------------------------------- helpers say() { printf '%s\n' "$*"; } bold() { printf '\033[1m%s\033[0m\n' "$*"; } die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } usage() { cat </dev/null && printf '%s' "${PRETTY_NAME:-Linux}") || PRETTY=Linux # ------------------------------------------------------------ which machine # # Transit publishes four Linux packages per release — {deb,rpm} × {x86_64, # aarch64} — so the arch has to be part of picking a file. It is matched on # the FILENAME token below rather than trusted from any single source, # because the two formats spell the same machine differently: a .deb says # amd64/arm64 where an .rpm says x86_64/aarch64. # # `uname -m` is the kernel's answer and is what both package managers agree # with in practice. Anything not in this list is refused outright: Transit # publishes nothing for armv7/i686/riscv64, and downloading a package the # system cannot install is a worse failure than saying so. case "$(uname -m)" in x86_64|amd64) ARCH=x86_64; DEB_ARCH=amd64; RPM_ARCH=x86_64; PKG_ARCH=x86_64 ;; aarch64|arm64) ARCH=arm64; DEB_ARCH=arm64; RPM_ARCH=aarch64; PKG_ARCH=aarch64 ;; *) die "unsupported architecture: $(uname -m). Transit publishes x86_64 and aarch64 Linux packages only — see ${BASE}." ;; esac # ------------------------------------------------------------- what to fetch # The newest version comes from the always-latest redirect rather than by # parsing versions.json — the redirect target carries both the version # directory and the exact filename, and needs no JSON parser present. resolve_latest() { _u=$(curl -fsSL -o /dev/null -w '%{url_effective}' "${BASE}/download/$1") \ || die "could not reach ${BASE}. Check the network and try again." # .../v6.5.1/Transit_6.5.1_amd64.deb -> 6.5.1 _v=$(printf '%s' "$_u" | sed -n 's|.*/v\([0-9][0-9.]*\)/.*|\1|p') [ -n "$_v" ] || die "could not determine the newest version from: $_u" printf '%s' "$_v" } # MATCH is the regex that selects this machine's package out of # SHA256SUMS.txt. It pins BOTH the format and the arch: matching on the # extension alone silently picked whichever package happened to be listed # first, which was harmless while each release published one .deb, and became # a coin flip the moment aarch64 was added (2026-09-03). case "$PM" in apt) PLATFORM=linux-deb; EXT=deb; MATCH="_${DEB_ARCH}\.deb\$" ;; dnf) PLATFORM=linux-rpm; EXT=rpm; MATCH="\.${RPM_ARCH}\.rpm\$" ;; pacman) PLATFORM=linux-deb; EXT=deb; MATCH="_${DEB_ARCH}\.deb\$" ;; # repackaged into a pacman package esac # Only the VERSION comes from the always-latest redirect — never the file. # That keeps this arch-agnostic: both arches publish the same version, and the # actual filename is resolved from SHA256SUMS.txt below. VERSION="${PIN_VERSION:-$(resolve_latest "$PLATFORM")}" # Filename AND checksum both come from the published SHA256SUMS.txt, so the # script never has to guess a naming convention (the .rpm carries a release # field, the .deb does not). SUMS=$(curl -fsSL "${BASE}/v${VERSION}/SHA256SUMS.txt") \ || die "no published build for version ${VERSION}." MATCHES=$(printf '%s\n' "$SUMS" | grep -iE "$MATCH" || true) COUNT=$(printf '%s' "$MATCHES" | grep -c . || true) if [ "$COUNT" -eq 0 ]; then # Distinguish "this release has no build for your machine" (an arm64 user on # a release published before aarch64 support) from "no .deb/.rpm at all". if printf '%s\n' "$SUMS" | grep -qiE "\.${EXT}\$"; then die "version ${VERSION} publishes no ${ARCH} .${EXT} (it predates ${ARCH} support, or that build failed). Try the newest version, or see ${BASE}." fi die "version ${VERSION} publishes no .${EXT}." fi if [ "$COUNT" -ne 1 ]; then # Two files claiming one arch means the release is malformed; picking either # is a guess, so refuse rather than install something arbitrary. die "version ${VERSION} lists ${COUNT} ${ARCH} .${EXT} packages — refusing to guess. Please report this." fi LINE="$MATCHES" SHA=$(printf '%s' "$LINE" | awk '{print $1}' | tr 'A-F' 'a-f') FILE=$(printf '%s' "$LINE" | awk '{print $2}') URL="${BASE}/v${VERSION}/${FILE}" # ------------------------------------------------------------ already here? installed_version() { case "$PM" in pacman) pacman -Q transit-bin 2>/dev/null | awk '{print $2}' | cut -d- -f1 ;; apt) dpkg-query -W -f='${Version}' transit 2>/dev/null ;; dnf) rpm -q --qf '%{VERSION}' transit 2>/dev/null ;; esac } CURRENT=$(installed_version 2>/dev/null || true) APT_EXTRA="" if [ -z "$CURRENT" ]; then ACTION="install" elif [ "$CURRENT" = "$VERSION" ]; then ACTION="reinstall" elif [ "$(printf '%s\n%s\n' "$CURRENT" "$VERSION" | sort -V | tail -n 1)" = "$CURRENT" ]; then # Installed is NEWER than what we are about to put down. Refusing unless # the version was named explicitly: walking a working install backwards is # never what "just install it" meant, and it is what someone running a # pre-release build would otherwise get from the plain one-liner. [ -n "$PIN_VERSION" ] || die "Transit ${CURRENT} is already installed, which is newer than the published ${VERSION}. Nothing to do. To go back deliberately, re-run with --version ${VERSION}." ACTION="downgrade" APT_EXTRA="--allow-downgrades" else ACTION="update" fi # ------------------------------------------------------------------- plan WORK=$(mktemp -d "${TMPDIR:-/tmp}/transit-install.XXXXXX") cleanup() { rm -rf "$WORK"; } trap cleanup EXIT INT TERM case "$PM" in apt) METHOD="download the official .deb and install it with apt" STEPS=" sudo apt install -y ${APT_EXTRA:+$APT_EXTRA }${WORK}/${FILE}" ;; dnf) METHOD="download the official .rpm and install it with dnf" STEPS=" sudo dnf install -y ${WORK}/${FILE}" ;; pacman) METHOD="build a pacman package from the official .deb, then install it" STEPS=" makepkg --syncdeps --install (in ${WORK})" ;; esac echo bold "Transit ${VERSION} — Linux installer" echo printf ' %-10s %s\n' "Detected" "$PRETTY ($ARCH)" printf ' %-10s %s\n' "Action" "$ACTION${CURRENT:+ (currently ${CURRENT})}" printf ' %-10s %s\n' "Method" "$METHOD" printf ' %-10s %s\n' "Source" "$URL" printf ' %-10s %s\n' "sha256" "$SHA" echo say "Will run:" say "$STEPS" echo if [ "$PM" = dnf ] && grep -qiE 'platform:el|Red Hat|AlmaLinux|Rocky' /etc/os-release 2>/dev/null; then say "Note: on Enterprise Linux 10 this needs EPEL for webkit2gtk4.1:" say " sudo dnf install epel-release" echo fi if [ "$PM" = pacman ]; then say "Note: the PCaps surface needs tcpdump, which some Arch-based systems" say " (Omarchy included) do not ship: sudo pacman -S tcpdump" echo fi if [ "$ASSUME_YES" -eq 0 ]; then # stdin is the curl pipe, so the prompt has to come from the terminal. # Probed by actually writing to it: `[ -r /dev/tty ]` succeeds in contexts # where the open then fails, which leaked a raw error and reported the run # as cancelled instead of telling the user about -y. The write is wrapped # in a group rather than `exec`, because a failed `exec` redirection exits # a non-interactive shell outright. if ! { printf 'Continue? [y/N] ' >/dev/tty; } 2>/dev/null; then die "no terminal available to confirm. Re-run with -y to proceed unattended." fi reply="" read -r reply "${WORK}/PKGBUILD" < "${WORK}/sum" sha256sum -c "${WORK}/sum" >/dev/null 2>&1 \ || die "checksum mismatch — the download does not match the published sha256. Not installing." say "Checksum OK." if [ "$DRY_RUN" -eq 1 ]; then say "--dry-run: verified ${FILE}, stopping before install." exit 0 fi case "$PM" in # A path with a slash is what makes apt treat this as a local file rather # than a repository package name. apt) sudo apt install -y $APT_EXTRA "${WORK}/${FILE}" ;; dnf) sudo dnf install -y "${WORK}/${FILE}" ;; esac fi echo bold "Transit ${VERSION} installed." say "Launch it from your application menu, or run: transit_app" say "Update later by re-running this installer."