#!/bin/sh # Zoodl installer (draft for zoodl-ai/marketplace#15). # # Final home: https://zoodl.ai/install.sh — the URL the /install page renders. # Approved binaries are published separately from the private application source # in zoodl-ai/zoodl-releases. set -eu REPO="zoodl-ai/zoodl-releases" LATEST_URL="https://github.com/$REPO/releases/latest" say() { printf '%s\n' "$*"; } die() { printf 'zoodl install: %s\n' "$*" >&2 exit 1 } LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" # Only these two directories are ever written by this installer. A developer # build living anywhere else must never be touched. macos_previous_installs() { for dir in /Applications "$HOME/Applications"; do [ -d "$dir" ] || continue for candidate in "$dir"/*.app; do [ -d "$candidate" ] || continue found=$(defaults read "$candidate/Contents/Info.plist" CFBundleIdentifier 2>/dev/null) || continue if [ "$found" = "$BUNDLE_ID" ]; then printf '%s\n' "$candidate" fi done done } macos_hint() { say "Launch it from Spotlight or Launchpad. Re-run this command any time to upgrade." } macos_launch() { # A piped install also runs from provisioning scripts and CI, where opening # a GUI app is unwanted. stdin is the script itself, so the terminal check # belongs on stdout. if [ -n "${ZOODL_NO_LAUNCH:-}" ] || [ ! -t 1 ]; then macos_hint return 0 fi if open -a "$DEST/$APPNAME" >/dev/null 2>&1; then say "Zoodl is starting. Re-run this command any time to upgrade." else # The app is installed by this point, so a launch failure is not an # install failure. say "Installed, but Zoodl could not be started automatically." macos_hint fi } OS=$(uname -s) ARCH=$(uname -m) TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT INT TERM # ── Pick the asset for this platform ───────────────────────────────────── case "$OS" in Darwin) [ "$ARCH" = "arm64" ] || die "there is no Intel build yet: Zoodl currently ships for Apple Silicon (M1 or later) only." PATTERN='darwin-arm64.*\.zip' ;; Linux) [ "$ARCH" = "x86_64" ] || die "unsupported architecture $ARCH: Linux builds are x86_64 only for now." if command -v apt-get >/dev/null 2>&1; then PATTERN='amd64\.deb' PKG=deb elif command -v dnf >/dev/null 2>&1 || command -v rpm >/dev/null 2>&1; then PATTERN='x86_64\.rpm' PKG=rpm else die "no supported package manager found (need apt, or dnf/rpm)." fi ;; *) die "unsupported platform $OS. On Windows, use the PowerShell command from https://zoodl.ai/install." ;; esac # ── Resolve the latest release ─────────────────────────────────────────── RELEASE_URL=$(curl -fsSL -o /dev/null -w '%{url_effective}' "$LATEST_URL") || die "cannot reach the public Zoodl release feed." VERSION=${RELEASE_URL##*/} [ -n "$VERSION" ] || die "the release feed did not include a version." DOWNLOAD_BASE="https://github.com/$REPO/releases/download/$VERSION" CHECKSUMS="$TMP/SHA256SUMS" curl -fsSL "$DOWNLOAD_BASE/SHA256SUMS" -o "$CHECKSUMS" || die "the latest release ($VERSION) has no SHA256SUMS manifest." FILE=$(awk -v pat="$PATTERN" '$2 ~ pat { print $2; exit }' "$CHECKSUMS") [ -n "$FILE" ] || die "the latest release ($VERSION) has no asset for this platform." DIGEST=$(awk -v file="$FILE" '$2 == file { print $1; exit }' "$CHECKSUMS") [ -n "$DIGEST" ] || die "the checksum manifest has no SHA-256 digest for $FILE." # ── Download and verify ────────────────────────────────────────────────── say "Downloading Zoodl $VERSION ($FILE)..." curl -fSL --progress-bar \ "$DOWNLOAD_BASE/$FILE" -o "$TMP/$FILE" if command -v sha256sum >/dev/null 2>&1; then SUM=$(sha256sum "$TMP/$FILE" | awk '{print $1}') elif command -v shasum >/dev/null 2>&1; then SUM=$(shasum -a 256 "$TMP/$FILE" | awk '{print $1}') else die "no SHA-256 tool found (need sha256sum or shasum)." fi [ "$SUM" = "$DIGEST" ] || die "checksum mismatch for $FILE (got $SUM, expected $DIGEST). Aborting without installing." say "Checksum verified." # ── Install ────────────────────────────────────────────────────────────── case "$OS" in Darwin) ditto -xk "$TMP/$FILE" "$TMP/extract" APP=$(find "$TMP/extract" -maxdepth 3 -name "*.app" | head -1) [ -n "$APP" ] || die "no .app bundle inside $FILE." BUNDLE_ID=$(defaults read "$APP/Contents/Info.plist" CFBundleIdentifier 2>/dev/null) || die "could not read the bundle identifier from $FILE." DEST="/Applications" [ -w "$DEST" ] || { DEST="$HOME/Applications" mkdir -p "$DEST" } APPNAME=$(basename "$APP") # Previous installs are found by bundle identifier rather than by name. # v1.0.0 shipped the bundle as sourgrapes.app and later releases renamed it # to Zoodl.app, so matching the name being written leaves the predecessor # behind. The $HOME/Applications fallback above can also leave a copy in the # other directory. Every one of them claims this same bundle ID, so # LaunchServices is free to start a stale one. macos_previous_installs >"$TMP/previous" # Zoodl takes a single-instance lock. A copy left running would have its # bundle replaced underneath it, and would make the newly installed copy # quit immediately on launch. RUNNING=0 while IFS= read -r old; do if pkill -f "^$old/Contents/MacOS/" 2>/dev/null; then say "Quit the running copy at $old." RUNNING=1 fi done <"$TMP/previous" [ "$RUNNING" -eq 0 ] || sleep 2 # A copy can sit somewhere this user cannot write, which is the very reason # DEST may have fallen back to $HOME/Applications. Removing it is best # effort: failing here would abort an install that is otherwise fine, and # would leave the user with the running copy already quit. while IFS= read -r old; do if rm -rf "$old" 2>/dev/null; then # The registration outlives the files, leaving a phantom entry in # Spotlight and Launchpad. if [ -x "$LSREGISTER" ]; then "$LSREGISTER" -u "$old" >/dev/null 2>&1 || true fi say "Removed the previous install at $old." else say "Could not remove the previous install at $old; delete it by hand," say "otherwise it may still be launched instead of this one." fi done <"$TMP/previous" ditto "$APP" "$DEST/$APPNAME" say "Installed $DEST/$APPNAME." macos_launch ;; Linux) SUDO="" [ "$(id -u)" -eq 0 ] || SUDO="sudo" say "Installing $FILE (your package manager may ask for your password)..." if [ "$PKG" = deb ]; then $SUDO apt-get install -y "$TMP/$FILE" elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y "$TMP/$FILE" else $SUDO rpm -Uvh "$TMP/$FILE" fi # No auto-launch here yet. The package managers above replace the binary # while an old copy keeps running and holding the single-instance lock, so # a launch would start a process that quits immediately and report success # while the stale copy is still the one on screen. macOS quits the running # copy first; Linux needs the same before this can be switched on. say "Installed. Launch Zoodl from your app menu, or run: sourgrapes" say "Re-run this command any time to upgrade." ;; esac