diff --git a/website/public/install.sh b/website/public/install.sh new file mode 100644 index 0000000..1a5561a --- /dev/null +++ b/website/public/install.sh @@ -0,0 +1,92 @@ +#!/bin/sh +# Installs the latest soulseek-rs release. +# +# curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh +# +# Uses Homebrew when it is on the PATH (macOS and Linux), otherwise downloads +# the prebuilt binary for this platform from GitHub releases, verifies its +# sha256, and puts it in /usr/local/bin or ~/.local/bin. +# +# SOULSEEK_RS_INSTALL_DIR overrides where the binary lands. +set -eu + +REPO="michel/soulseek-rs" + +say() { printf '%s\n' "$*"; } +die() { + printf 'install.sh: %s\n' "$*" >&2 + exit 1 +} + +case "$(uname -s)" in + Darwin) os="apple-darwin" ;; + Linux) os="unknown-linux-musl" ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + die "no install script for Windows: take the pc-windows-msvc zip from https://github.com/$REPO/releases/latest, or run 'cargo install soulseek-rs'" ;; + *) die "unsupported OS '$(uname -s)': see https://github.com/$REPO/releases/latest" ;; +esac + +case "$(uname -m)" in + x86_64 | amd64) arch="x86_64" ;; + arm64 | aarch64) arch="aarch64" ;; + *) die "unsupported architecture '$(uname -m)': releases cover x86_64 and aarch64" ;; +esac +target="$arch-$os" + +if command -v brew >/dev/null 2>&1; then + say "Homebrew found: installing michel/tap/soulseek-rs (brew also handles upgrades)" + exec brew install michel/tap/soulseek-rs +fi + +if command -v curl >/dev/null 2>&1; then + fetch() { curl -fsSL "$1"; } +elif command -v wget >/dev/null 2>&1; then + fetch() { wget -qO- "$1"; } +else + die "need curl or wget" +fi + +tag=$(fetch "https://api.github.com/repos/$REPO/releases/latest" | + sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1) +[ -n "$tag" ] || die "could not resolve the latest release; downloads are at https://github.com/$REPO/releases/latest" + +archive="soulseek-rs-$tag-$target.tar.gz" +base_url="https://github.com/$REPO/releases/download/$tag" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +say "Downloading $archive" +fetch "$base_url/$archive" >"$tmp/$archive" +fetch "$base_url/soulseek-rs-$tag-$target.sha256" >"$tmp/expected.sha256" + +if command -v sha256sum >/dev/null 2>&1; then + got=$(sha256sum "$tmp/$archive") +else + got=$(shasum -a 256 "$tmp/$archive") +fi +got=${got%% *} +want=$(cut -d' ' -f1 <"$tmp/expected.sha256") +[ "$got" = "$want" ] || die "sha256 mismatch for $archive: expected $want, got $got" + +tar -xzf "$tmp/$archive" -C "$tmp" +[ -f "$tmp/soulseek-rs" ] || die "archive $archive did not contain a soulseek-rs binary" + +dir="${SOULSEEK_RS_INSTALL_DIR:-}" +if [ -z "$dir" ]; then + dir="/usr/local/bin" + { [ -d "$dir" ] && [ -w "$dir" ]; } || dir="$HOME/.local/bin" +fi +mkdir -p "$dir" + +# mv over a copy, not cp over the target: replacing a running binary in place +# fails with "text file busy" on Linux. +cp "$tmp/soulseek-rs" "$dir/.soulseek-rs.new" +chmod 755 "$dir/.soulseek-rs.new" +mv -f "$dir/.soulseek-rs.new" "$dir/soulseek-rs" + +say "Installed $("$dir/soulseek-rs" --version) to $dir/soulseek-rs" +case ":$PATH:" in + *:"$dir":*) ;; + *) say "note: $dir is not on your PATH; add it with: export PATH=\"$dir:\$PATH\"" ;; +esac diff --git a/website/scripts/test-install.sh b/website/scripts/test-install.sh new file mode 100644 index 0000000..91cae23 --- /dev/null +++ b/website/scripts/test-install.sh @@ -0,0 +1,155 @@ +#!/bin/sh +# Exercises public/install.sh: target detection, the brew fast path, checksum +# verification, and a real install into a throwaway prefix. Network tests hit +# GitHub; run from a machine that can reach it. +set -eu + +here=$(cd "$(dirname "$0")" && pwd) +script="$here/../public/install.sh" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +failures=0 +report() { + if [ "$1" = 0 ]; then + printf 'ok %s\n' "$2" + else + printf 'FAIL %s\n' "$2" + failures=$((failures + 1)) + fi +} +contains() { case "$1" in *"$2"*) true ;; *) false ;; esac; } + +# Stub uname reads STUB_OS / STUB_ARCH; stub curl logs every URL it is asked +# for, answers the release-API call with a canned tag, and refuses the rest so +# a detection test stops before downloading anything. +mkdir -p "$tmp/stub" +cat >"$tmp/stub/uname" <<'EOF' +#!/bin/sh +case "${1:-}" in + -m) echo "$STUB_ARCH" ;; + *) echo "$STUB_OS" ;; +esac +EOF +cat >"$tmp/stub/curl" <>"$tmp/curl.log" +case "\$url" in + *api.github.com*) echo '{"tag_name": "v12.0.0"}' ;; + *) exit 22 ;; +esac +EOF +chmod +x "$tmp/stub/uname" "$tmp/stub/curl" +base_path="/usr/bin:/bin" + +resolved_target() { + : >"$tmp/curl.log" + STUB_OS="$1" STUB_ARCH="$2" PATH="$tmp/stub:$base_path" sh "$script" >/dev/null 2>&1 || true + grep -o 'soulseek-rs-v12\.0\.0-[a-z0-9_-]*\.tar\.gz' "$tmp/curl.log" | head -n1 +} + +for spec in \ + "Darwin arm64 aarch64-apple-darwin" \ + "Darwin x86_64 x86_64-apple-darwin" \ + "Linux aarch64 aarch64-unknown-linux-musl" \ + "Linux amd64 x86_64-unknown-linux-musl"; do + # spec is three space-separated fields, so the splitting is the point + # shellcheck disable=SC2086 + set -- $spec + res=1 + if [ "$(resolved_target "$1" "$2")" = "soulseek-rs-v12.0.0-$3.tar.gz" ]; then res=0; fi + report $res "$1/$2 resolves to $3" +done + +out=$(STUB_OS="MINGW64_NT-10.0" STUB_ARCH=x86_64 PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" != 0 ] && contains "$out" "pc-windows-msvc"; then res=0; fi +report $res "Windows refuses and points at the msvc zip" + +out=$(STUB_OS=Linux STUB_ARCH=armv7l PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" != 0 ] && contains "$out" "unsupported architecture"; then res=0; fi +report $res "armv7l refuses as unsupported" + +# Brew fast path: a stub brew must be exec'd with the tap formula, and no URL +# may be fetched. +cat >"$tmp/stub/brew" <"$tmp/brew.log" +EOF +chmod +x "$tmp/stub/brew" +: >"$tmp/curl.log" +STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/stub:$base_path" sh "$script" >/dev/null 2>&1 +res=1 +if [ "$(cat "$tmp/brew.log")" = "install michel/tap/soulseek-rs" ] && [ ! -s "$tmp/curl.log" ]; then res=0; fi +report $res "brew on PATH wins and skips the download" +rm -f "$tmp/stub/brew" + +# Offline happy path: stub curl serves a locally built archive whose sha256 +# matches, and the installed fake binary must answer --version. +mkdir -p "$tmp/payload" "$tmp/bin" +printf '#!/bin/sh\necho "soulseek-rs 99.0.0-test"\n' >"$tmp/payload/soulseek-rs" +chmod +x "$tmp/payload/soulseek-rs" +tar -czf "$tmp/payload.tar.gz" -C "$tmp/payload" soulseek-rs +sum=$(shasum -a 256 "$tmp/payload.tar.gz" | cut -d' ' -f1) +cat >"$tmp/stub/curl" <>"$tmp/curl.log" +case "\$url" in + *api.github.com*) echo '{"tag_name": "v12.0.0"}' ;; + *.tar.gz) cat "$tmp/payload.tar.gz" ;; + *.sha256) echo "$sum *payload" ;; + *) exit 22 ;; +esac +EOF +out=$(STUB_OS=Darwin STUB_ARCH=arm64 SOULSEEK_RS_INSTALL_DIR="$tmp/bin" \ + PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" = 0 ] && [ -x "$tmp/bin/soulseek-rs" ] && + contains "$out" "Installed soulseek-rs 99.0.0-test"; then res=0; fi +report $res "verified archive installs and reports the version" +res=1 +if contains "$out" "not on your PATH"; then res=0; fi +report $res "install dir off the PATH gets the PATH note" + +# Tampered archive: same stub but a wrong checksum must abort before install. +rm -f "$tmp/bin/soulseek-rs" +sed "s/$sum/0000000000000000000000000000000000000000000000000000000000000000/" \ + "$tmp/stub/curl" >"$tmp/stub/curl.bad" && mv "$tmp/stub/curl.bad" "$tmp/stub/curl" +chmod +x "$tmp/stub/curl" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 SOULSEEK_RS_INSTALL_DIR="$tmp/bin" \ + PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" != 0 ] && [ ! -e "$tmp/bin/soulseek-rs" ] && + contains "$out" "sha256 mismatch"; then res=0; fi +report $res "sha256 mismatch aborts without installing" + +# Real network installs: once over curl, once over wget (a symlink farm of +# /usr/bin and /bin minus curl and brew forces the wget code path). +out=$(SOULSEEK_RS_INSTALL_DIR="$tmp/real-curl" PATH="$base_path" sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" = 0 ] && "$tmp/real-curl/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi +report $res "real release installs via curl and runs" + +if wget=$(command -v wget); then + mkdir -p "$tmp/farm" + for d in /usr/bin /bin; do + for f in "$d"/*; do ln -sf "$f" "$tmp/farm/${f##*/}"; done + done + rm -f "$tmp/farm/curl" "$tmp/farm/brew" + ln -sf "$wget" "$tmp/farm/wget" + out=$(SOULSEEK_RS_INSTALL_DIR="$tmp/real-wget" PATH="$tmp/farm" sh "$script" 2>&1) && rc=0 || rc=$? + res=1 + if [ "$rc" = 0 ] && "$tmp/real-wget/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi + report $res "real release installs via wget and runs" +else + echo "skip real release installs via wget (no wget on this machine)" +fi + +[ "$failures" = 0 ] || { + echo "$failures test(s) failed" + exit 1 +} +echo "all tests passed" diff --git a/website/src/lib/links.ts b/website/src/lib/links.ts index 33654ac..226d1f3 100644 --- a/website/src/lib/links.ts +++ b/website/src/lib/links.ts @@ -18,3 +18,5 @@ export const LINKS = { export const VERSION = __APP_VERSION__ export const SITE_URL = 'https://re-invention.nl/soulseek-rs/' + +export const INSTALL_CMD = `curl -fsSL ${SITE_URL}install.sh | sh` diff --git a/website/src/pages/home.tsx b/website/src/pages/home.tsx index 0af6bbc..bb0d781 100644 --- a/website/src/pages/home.tsx +++ b/website/src/pages/home.tsx @@ -10,12 +10,12 @@ import { Cols, Eyebrow, Prose, Section, SectionHead, Wrap } from '@/components/u import { OsIcon, type OsName } from '@/components/ui/os-icon' import { Callout } from '@/components/ui/panel' import { Terminal } from '@/components/ui/terminal' -import { LINKS } from '@/lib/links' +import { INSTALL_CMD, LINKS } from '@/lib/links' import { cn } from '@/lib/utils' const PLATFORMS: readonly { name: OsName; note: string; anchor: string }[] = [ - { name: 'macOS', note: 'brew or cargo', anchor: 'macos' }, - { name: 'Linux', note: 'cargo install', anchor: 'linux' }, + { name: 'macOS', note: 'script or brew', anchor: 'macos' }, + { name: 'Linux', note: 'script or cargo', anchor: 'linux' }, { name: 'Windows', note: 'cargo or .exe', anchor: 'windows' }, ] @@ -154,7 +154,7 @@ const Hero = () => ( - + @@ -327,9 +327,10 @@ const GetStarted = () => ( diff --git a/website/src/pages/install.tsx b/website/src/pages/install.tsx index b4b2df2..651f01f 100644 --- a/website/src/pages/install.tsx +++ b/website/src/pages/install.tsx @@ -6,7 +6,7 @@ import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/lay import { OsIcon, type OsName } from '@/components/ui/os-icon' import { Callout } from '@/components/ui/panel' import { Terminal, type TermLine } from '@/components/ui/terminal' -import { LINKS } from '@/lib/links' +import { INSTALL_CMD, LINKS, SITE_URL } from '@/lib/links' const LIB_SRC = `use soulseek_rs::Client; use std::time::Duration; @@ -266,7 +266,18 @@ const Steps = () => (

- One command per platform. Cargo needs a Rust toolchain from{' '} + One command on macOS and Linux. The script installs through Homebrew when you + have it; otherwise it downloads the latest release binary for your platform, + checks its sha256, and puts it on your PATH. +

+ +

+ No curl? {`wget -qO- ${SITE_URL}install.sh | sh`} does the same. + Piping to a shell asks for trust; read the script first at{' '} + {`${SITE_URL}install.sh`}. +

+

+ Or pick a route per platform. Cargo needs a Rust toolchain from{' '} rustup.rs; the other routes don’t.

@@ -520,6 +531,8 @@ const Uninstall = () => ( { t: 'cm', text: '# whichever way you installed it' }, { t: 'cmd', text: 'brew uninstall soulseek-rs' }, { t: 'cmd', text: 'cargo uninstall soulseek-rs' }, + { t: 'cm', text: '# script install without brew: the binary itself' }, + { t: 'cmd', text: 'rm -f /usr/local/bin/soulseek-rs ~/.local/bin/soulseek-rs' }, { t: 'cm', text: '# brew only: drop the tap it came from' }, { t: 'cmd', text: 'brew untap michel/tap' }, { t: 'cm', text: '# config and state: searches, downloads, rooms,' }, @@ -560,8 +573,8 @@ const Uninstall = () => ( export const Install = () => ( <> - One command with Homebrew or cargo. Build from source if you’d rather. Or depend - on the library and write your own client. + One command with the install script, Homebrew or cargo. Build from source if + you’d rather. Or depend on the library and write your own client.