#!/bin/bash set -euo pipefail # Install Autodarts on Linux or macOS, from the release this repo publishes: the Desktop # app, or the headless board daemon. # # curl -fsSL https://autodarts.sh/sh/install.sh | bash # curl -fsSL https://autodarts.sh/sh/install.sh | bash -s -- --beta # curl -fsSL https://autodarts.sh/sh/install.sh | bash -s -- --headless # curl -fsSL https://autodarts.sh/sh/install.sh | bash -s -- --uninstall # # THE FULL PATH, not the bare domain, until the CDN has a rule for the root: autodarts.sh # fronts the releases bucket, so /sh/install.sh is this object and / is nothing. Every URL # printed below is one that works today rather than the shorter one we want — a wrong # uninstall command is worse than a long one. Swap them when the root redirects here. # # THE ONLY SCRIPT HERE THAT RUNS ON SOMEONE ELSE'S MACHINE. Everything else in this # directory builds or publishes the release; this one is served to users and consumes what # the others wrote, so it assumes nothing about the repo being present. It lives beside them # anyway because it parses the artifact names and the index that package-{linux,macos}.sh and # index-downloads.sh define — one change, one directory, reviewed in the same diff as the # packaging it has to agree with. `task desktop:publish-installer` uploads it to the releases # bucket as sh/install.sh; it is expected to move to its own repo eventually, the way the old # desktop's installer lives in get.git as sh/install_autodarts_desktop.sh (which installs 1.x, # is a .deb, and does need sudo) — but until there is one, the copy under review is here. # # THREE TARGETS, ONE SCRIPT, because only the last step differs. *Deciding what to download* # is a property of the bucket rather than of the OS: one index, one filename grammar, one rule # mapping a version to a track. What differs is what a release *is* for each target, and # where it belongs: # # Desktop, Linux ~/.local/bin/autodarts-desktop.AppImage one file, plus a .desktop # entry we write # Desktop, macOS /Applications/Autodarts Desktop.app a bundle inside a disk image; # Spotlight indexes it, so # there is nothing to write # headless, Linux ~/.local/share/autodarts/ a tarball of the binary and # its libraries, plus a symlink # at ~/.local/bin/autodarts # # Which of the two a Linux machine gets is decided from the machine when the caller does not # say — see pick_mode, and the reason it does not ask $DISPLAY. # # Windows is deliberately absent and stays absent: its artifact is a Setup.exe, and driving # that wants PowerShell rather than another branch here. There is no headless build for # macOS either: the Desktop hosts the same board core in-process. # # NO SUDO, ANYWHERE, and that is a requirement rather than a courtesy: the app updates itself # in place through Velopack, which replaces the very AppImage or .app it was launched from. A # root-owned copy is precisely what a normal user cannot replace, so an install that asked for # a password would be an install that could never update itself again. # # That costs nothing on either platform. /Applications is drwxrwxr-x root:admin, so an admin # account writes to it exactly as Finder does when you drag an app out of a DMG — no prompt. # A standard account cannot, and falls back to ~/Applications, which is not a lesser location # but the only one where self-update can work for that user. Spotlight indexes both. # # WHAT IT WRITES # Desktop, Linux ~/.local/bin/autodarts-desktop.AppImage the app # ~/.local/share/autodarts-desktop/appicon.png its icon # ~/.local/share/applications/…desktop the menu/search entry # Desktop, macOS /Applications/Autodarts Desktop.app the app, and only that # headless ~/.local/share/autodarts/ the bundle # ~/.local/bin/autodarts a relative symlink # ~/.local/bin/ad the same, shorthand — # only when nothing else # already provides `ad` # # and for the Desktop it clears one thing it did not write: Velopack's staged-release cache # (see clear_velopack_cache below for why that has to happen here). Headless has none. # # NEITHER writes config. A predecessor's config.toml may be the only copy of a credential its # owner has never seen, and deciding what happens to it belongs to the daemon's first run, # where it can be reasoned about — not to an installer. # # THE LINUX FILENAME IS UNVERSIONED ON PURPOSE. Velopack replaces the AppImage at its own # path, keeping the name, so a file installed as ...autodarts-desktop_2.0.0_linux-arm64.AppImage # would still be called 2.0.0 after updating itself to 2.1.0. One stable name also means the # .desktop entry keeps pointing at the app across every update. On macOS the bundle name is # already version-free, so the same rule needs no special handling. # # NOTHING HERE UPDATES ANYTHING. This script installs; the Desktop takes over from the moment # it first runs, checking the same bucket on its own (apps/desktop/updater.h). Re-running it is # a supported way to reinstall or to switch tracks, not the way to get a new version. # # A headless board does NOT update itself, and the installer must not claim it does: a # supervised daemon swapping its own binary out from under systemd is the thing that shape of # deployment exists to avoid. Its updates come from its own CLI. # # HOW IT FINDS THE DOWNLOAD. Not by composing a URL from a version: the bucket publishes an # index of what is actually in it, and this reads that. # # /downloads/latest..json newest version per os/arch # /downloads/downloads..json the archive, for --version # # Both list entries as {"name","kind","size","url"}, and the artifact names follow one # grammar — __-[_Setup]., where is autodarts-desktop or # autodarts — which is what lets this pick its own file out with grep instead of a JSON # parser. jq is not assumed to be present. # Both are written by index-downloads.sh next door, which is the authority on the grammar # and on the index shape. APP_ID="autodarts-desktop" MAC_BUNDLE_ID="com.autodarts.desktop" MAC_APP_NAME="Autodarts Desktop.app" # THE THIRD CASE, on the same argument the header makes for the first two: only the last # step differs. A headless board is downloaded exactly like a Desktop — same index, same # filename grammar, same track rule — and then installed differently, because what a # release *is* differs. A tarball of a binary and its libraries, not an AppImage. # # headless ~/.local/share/autodarts/ the bundle, flat ($ORIGIN) # ~/.local/bin/autodarts a relative symlink into it # # No menu entry, no icon, no FUSE, no Velopack cache: none of those exist for something # with no window that updates through its own CLI. HEADLESS_APP_ID="autodarts" # The unit keeps the legacy name. It can: this is a *user* unit, so it and a legacy # system autodarts.service are in different namespaces and cannot collide — what collides # is /dev/video*, which the screen's service install checks for instead. UNIT_NAME="autodarts.service" SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" # The macOS half of the same idea. A board is a user agent on both platforms — systemd --user # there, launchd LaunchAgent here — and the label matches what # apps/headless/cli/platform/launchd/service.cpp writes, because this script has to find a # service that file installed. The two must not drift. LAUNCHD_LABEL="com.autodarts.board" LAUNCHD_PLIST="${HOME}/Library/LaunchAgents/${LAUNCHD_LABEL}.plist" # Which startup file a login shell of theirs would read, so the advice names a real path # instead of "your shell profile". From $SHELL rather than $0: this script is running under # bash because that is what the curl pipe invoked, which says nothing about what they use. # # ~/.profile and not ~/.bashrc for bash, because a *login* shell reads the former and that is # what a new terminal or an ssh session gets; ~/.bashrc alone would work interactively on many # distributions and not for the systemd user session that starts the board. # shellcheck disable=SC2088 # the tilde is deliberate: this is text to paste into a shell, # where it does expand, not a path this script ever opens. shell_rc() { case "${SHELL##*/}" in zsh) printf '~/.zshrc' ;; fish) printf '~/.config/fish/config.fish' ;; *) printf '~/.profile' ;; esac } # And the line to put in it, in that shell's own syntax. fish is the reason this is a # function rather than one string reused twice: it has no `export`, and handing a fish user a # bash line is advice that fails in a way they then have to debug. fish_add_path is idempotent # and is what fish's own documentation tells you to use. path_line() { case "${SHELL##*/}" in fish) printf 'fish_add_path %s' "$BIN_DIR" ;; *) printf 'export PATH="%s:$PATH"' "$BIN_DIR" ;; esac } # The three verbs this script needs from a service manager, behind one name each, so the # install and uninstall paths below read the same on both platforms and neither grows an # `if macos` at every call. launchctl's bootout/bootstrap take a domain and a plist where # systemctl takes a unit name — no vocabulary is shared, which is exactly why this is a pair # of implementations rather than a shim. service_stop() { if [ "$OS" = "macos" ]; then launchctl bootout "gui/$(id -u)/${LAUNCHD_LABEL}" > /dev/null 2>&1 || true else systemctl --user stop "$UNIT_NAME" > /dev/null 2>&1 || true fi } service_start() { if [ "$OS" = "macos" ]; then launchctl bootstrap "gui/$(id -u)" "$LAUNCHD_PLIST" > /dev/null 2>&1 else systemctl --user start "$UNIT_NAME" > /dev/null 2>&1 fi } service_remove() { if [ "$OS" = "macos" ]; then launchctl bootout "gui/$(id -u)/${LAUNCHD_LABEL}" > /dev/null 2>&1 || true rm -f "$LAUNCHD_PLIST" elif command -v systemctl > /dev/null 2>&1; then systemctl --user disable --now "$UNIT_NAME" > /dev/null 2>&1 || true rm -f "${SYSTEMD_USER_DIR}/${UNIT_NAME}" systemctl --user daemon-reload > /dev/null 2>&1 || true fi } # Where its output goes. launchd writes nothing anywhere by default, so the plist names files # and this points at them — see apps/headless/cli/platform/launchd/service.cpp, which is what # creates them and therefore decides the path. service_log_hint() { if [ "$OS" = "macos" ]; then printf 'tail -f ~/Library/Logs/autodarts/board.log' else printf 'journalctl --user -u %s -f' "$UNIT_NAME" fi } # What to type to look at it, which differs enough to be worth naming rather than guessing. service_status_hint() { if [ "$OS" = "macos" ]; then printf 'launchctl print gui/%s/%s' "$(id -u)" "$LAUNCHD_LABEL" else printf 'systemctl --user status %s' "$UNIT_NAME" fi } service_start_hint() { if [ "$OS" = "macos" ]; then printf 'launchctl bootstrap gui/%s %s' "$(id -u)" "$LAUNCHD_PLIST" else printf 'systemctl --user start %s' "$UNIT_NAME" fi } # Which one to install. Empty means decide from the machine (pick_mode below). MODE="" # WHERE THE APP COMES FROM, and the one thing in this script that nothing can change: no flag, # no environment variable, no argument. A script piped into a shell is already asking for # trust; letting a caller redirect *where the binary comes from* would turn that into a way to # install something else entirely under our name, and there is no user who needs it. The same # host the app itself polls for updates (src/common/env.h's UpdateUrl) — one bucket, one # origin, stated once. # # TO TEST AGAINST ANOTHER BUCKET, edit a copy rather than teaching this one a switch: # sed 's|^RELEASES_BASE=.*|RELEASES_BASE="http://127.0.0.1:8080/desktop"|' install.sh > /tmp/i.sh # which is how the local-minio rehearsal and every end-to-end test of this file is run. RELEASES_BASE="https://releases.autodarts.com/desktop" TRACK="" REQ_VERSION="" INSTALL_DIR="" DIR_EXPLICIT="false" WRITE_ENTRY="true" ACTION="install" DRY_RUN="false" QUIET="false" SHOW_HELP="false" # There is no --service flag, and the reason is the one that always applied: installing a # unit is a decision about a machine's boot behaviour, and not one to make for someone who # is still finding out whether the thing works. `ad` runs a board itself when there is no # service, so a fresh install is usable immediately, and the screen's Service section offers # the unit at the moment that decision makes sense. # # It used to exist and delegate to `autodarts service install`, which is gone — the screen # installs the unit now, and a second way in was a second thing to keep in step with it. # Whether a unit was already installed before this run, decided before anything moves: # it is what says the running board has to be stopped before its files do. SERVICE_PRESENT="false" usage() { cat < install a specific version; implies its track -d, --dir where to install -n, --dry-run resolve and report what would be installed, download nothing -u, --uninstall remove the app, its menu entry and the update cache -p, --purge --uninstall, and also delete settings, calibration and sign-in -q, --quiet errors and warnings only --no-desktop-entry Linux only: skip the menu entry --color[=when] auto (default), always or never --no-color same as --color=never; so is NO_COLOR=1, or piping stdout -h, --help this ${C_BOLD}DEFAULTS${C_RESET} Desktop, Linux ~/.local/bin/autodarts-desktop.AppImage, plus a menu entry Desktop, macOS /Applications, or ~/Applications when that is not writable headless ~/.local/share/autodarts, plus ~/.local/bin/autodarts ${C_BOLD}EXAMPLES${C_RESET} ... | bash -s -- -b latest beta ... | bash -s -- --headless the board daemon, whatever this machine boots to ... | bash -s -- -V 2.1.0 a specific release (a leading v is fine) ... | bash -s -- -n -b what would the beta track give me? ... | bash -s -- -up remove it and delete its data Short flags cluster: -up is -u -p. A bare argument is taken as a version, matching the older installers — or as the track, if it is beta or stable. Everything after -- is a positional. USAGE } # ── output ───────────────────────────────────────────────────────────────────────────── # # Colour when it is going to a terminal and nowhere else. Piping this script into bash does # NOT make stdout a pipe — only stdin — so the usual `curl … | bash` still gets colour, while # `… | bash > install.log` correctly gets none. NO_COLOR is honoured because it is the one # convention every tool agrees on (no-color.org), and TERM=dumb because that is what a terminal # says when it cannot render this at all. COLOR="auto" color_setup() { # An explicit --color=always wins over everything, including NO_COLOR: the flag is this # run's instruction and the variable is the machine's default, which is the precedence git # and ls use. It is what makes `… | less -R` and a captured transcript keep their colour. if [ "$COLOR" = "always" ]; then : elif [ "$COLOR" = "never" ] || [ -n "${NO_COLOR:-}" ] || [ ! -t 1 ] \ || [ "${TERM:-}" = "dumb" ]; then C_RESET="" C_BOLD="" C_DIM="" C_RED="" C_GREEN="" C_YELLOW="" C_CYAN="" C_BLUE="" return 0 fi C_RESET=$'\033[0m' C_BOLD=$'\033[1m' C_DIM=$'\033[2m' C_RED=$'\033[31m' C_GREEN=$'\033[32m' C_YELLOW=$'\033[33m' C_CYAN=$'\033[36m' C_BLUE=$'\033[34m' } color_setup # Four levels, so a reader can tell at a glance what is a step, what worked, what needs their # attention and what stopped. Steps and detail go to stdout; anything the user has to act on # goes to stderr, which is also what keeps `… | bash > log` useful. step() { [ "$QUIET" = "true" ] && return 0 printf '%s==>%s %s%s%s\n' "$C_BLUE$C_BOLD" "$C_RESET" "$C_BOLD" "$*" "$C_RESET" } info() { [ "$QUIET" = "true" ] || printf ' %s\n' "$*"; } ok() { [ "$QUIET" = "true" ] && return 0 printf ' %s✓%s %s\n' "$C_GREEN" "$C_RESET" "$*" } note() { [ "$QUIET" = "true" ] && return 0 printf ' %s%s%s\n' "$C_DIM" "$*" "$C_RESET" } warn() { printf '%s!%s %s\n' "$C_YELLOW$C_BOLD" "$C_RESET" "$*" >&2; } say() { [ "$QUIET" = "true" ] || printf '%s\n' "$*"; } die() { printf '%sERROR:%s %s\n' "$C_RED$C_BOLD" "$C_RESET" "$*" >&2; exit 1; } # Exit 2 for "you typed it wrong", 1 for "it did not work" — the distinction every shell tool # makes, and the one a wrapper script wants when it has to tell a typo from a failed download. usage_die() { printf '%sERROR:%s %s\n' "$C_RED$C_BOLD" "$C_RESET" "$*" >&2 printf ' run with --help for the options\n' >&2 exit 2 } # The brand lockup — the three-blade mark beside the wordmark — rendered from the artwork # rather than drawn by hand. Each character is a braille cell, a 2x4 grid of dots, so these 69 # columns carry 138x20 dots. The mark is drawn in blue; the wordmark is just bold. # # Rendered from apps/desktop/ui/assets/logo.svg, from the vectors straight at the final size # so nothing is resampled. The mark (the first three paths) is 5 cells high; the letters (the # rest) are split apart and rendered one by one, 3 cells high on rows 1-3, so each starts on # a whole dot and none straddles a cell row. The mark and the symmetric letters (A U T O) are # their left half mirrored, because the artwork is symmetric and a raster of it is not. The # mark counts a dot only when it is mostly covered, or the blades' points become lone dots. # Regenerate it the same way if the artwork changes, and never hand-edit a cell. # # Printed only when there is a terminal wide enough to hold it. Wrapped, the lockup is # unreadable rubbish, and the steps below say everything it says. banner() { [ "$QUIET" = "true" ] && return 0 # No colour means no terminal, in practice — a log file or a pipe, where rows of dot # patterns are noise rather than a logo. --color=always brings it back. [ -z "$C_RESET" ] && return 0 local cols cols="$(tput cols 2>/dev/null || echo 80)" [ "$cols" -lt 76 ] && return 0 printf '\n' printf '%s⠀⠀⠀⢠⣆⠀⠀⣰⡄⠀⠀⠀⠀⠀%s' "$C_BLUE" "$C_RESET" printf '%s%s\n' "$C_BOLD" "$C_RESET" printf '%s⠀⠀⠀⠈⢿⡄⢠⡿⠁⠀⠀⠀⠀⠀%s' "$C_BLUE" "$C_RESET" printf '%s⠀⢰⡿⢿⡆⠀⠀⣿⡆⠀⢰⣿⠀⠿⢿⣿⠿⠇⢀⣴⠿⠛⠿⣦⡀⠀⣿⠿⠿⣶⣄⠀⠀⣾⠿⣷⠀⠀⠀⣿⡟⠻⣶⡄⠸⠿⣿⡿⠿⠀⣰⡟⠻⣷%s\n' "$C_BOLD" "$C_RESET" printf '%s⠀⣀⣠⣤⡼⠃⠘⢧⣤⣄⣀⠀⠀⠀%s' "$C_BLUE" "$C_RESET" printf '%s⢀⣿⣃⣘⣿⡀⠀⣿⡇⠀⢸⣿⠀⠀⢸⣿⠀⠀⢸⣿⠀⠀⠀⣿⡇⠀⣿⠀⠀⢸⣿⠀⣸⣟⣀⣻⣇⠀⠀⣿⣧⣤⣾⠇⠀⠀⣿⡇⠀⠀⠘⠷⠶⣦⡀%s\n' "$C_BOLD" "$C_RESET" printf '%s⠈⠉⠉⠁⣤⡶⢶⣤⠈⠉⠉⠁⠀⠀%s' "$C_BLUE" "$C_RESET" printf '%s⣼⠏⠉⠉⠹⣧⠀⠹⢷⣤⡾⠏⠀⠀⢸⣿⠀⠀⠀⠻⣷⣤⣾⠟⠀⠀⣿⣶⣶⠿⠃⢠⡿⠉⠉⠉⢿⡄⠀⣿⠇⠈⢿⣆⠀⠀⣿⡇⠀⠀⠻⣦⣤⡾⠃%s\n' "$C_BOLD" "$C_RESET" printf '%s⠀⠀⣠⡾⠋⠀⠀⠙⢷⣄⠀⠀⠀⠀%s' "$C_BLUE" "$C_RESET" printf '%s%s\n' "$C_BOLD" "$C_RESET" printf '\n' } # Bytes as a person reads them, for the one number this script shows: how big the download is. human_size() { local b="${1:-0}" if [ "$b" -ge 1048576 ]; then printf '%s.%s MB' "$((b / 1048576))" "$(( (b % 1048576) * 10 / 1048576 ))" elif [ "$b" -ge 1024 ]; then printf '%s KB' "$((b / 1024))" else printf '%s bytes' "$b" fi } # ── arguments ────────────────────────────────────────────────────────────────────────── # # Every spelling a person might reasonably type, normalised in one pre-pass so the case # statement below stays a plain list rather than four entries per option: # # -b -u separate short flags # -bu clustered, the way `ls -la` clusters # -V 2.1.0 -V2.1.0 --version 2.1.0 --version=2.1.0 all the same # -- ends option parsing, so a path beginning with a dash is still reachable # # The value-taking flags (-d, -V) end a cluster: what follows them inside the same word is # their value, which is why `-d/opt/apps` works and `-du` would not silently become -d -u. NORMALISED=() END_OF_OPTS="false" while [ $# -gt 0 ]; do if [ "$END_OF_OPTS" = "true" ]; then NORMALISED+=("$1"); shift; continue; fi case "$1" in --) END_OF_OPTS="true" ;; --*) NORMALISED+=("$1") ;; -[!-]*) rest="${1#-}" while [ -n "$rest" ]; do c="${rest:0:1}"; rest="${rest:1}" NORMALISED+=("-$c") case "$c" in d|V) [ -n "$rest" ] && { NORMALISED+=("$rest"); rest=""; } ;; esac done ;; *) NORMALISED+=("$1") ;; esac shift done set -- ${NORMALISED[@]+"${NORMALISED[@]}"} while [ $# -gt 0 ]; do case "$1" in -b|--beta) TRACK="beta" ;; -s|--stable) TRACK="stable" ;; --headless) MODE="headless" ;; --desktop) MODE="desktop" ;; -V|--version) REQ_VERSION="${2:-}" [ -n "$REQ_VERSION" ] || usage_die "--version needs a version" shift ;; --version=*) REQ_VERSION="${1#*=}" ;; -d|--dir) INSTALL_DIR="${2:-}" [ -n "$INSTALL_DIR" ] || usage_die "--dir needs a path" DIR_EXPLICIT="true"; shift ;; --dir=*) INSTALL_DIR="${1#*=}"; DIR_EXPLICIT="true" ;; -n|--dry-run) DRY_RUN="true" ;; -q|--quiet) QUIET="true" ;; -u|--uninstall) ACTION="uninstall" ;; -p|--purge) ACTION="purge" ;; --no-desktop-entry) WRITE_ENTRY="false" ;; --color) COLOR="always"; color_setup ;; --color=*) COLOR="${1#*=}" case "$COLOR" in auto|always|never) ;; *) usage_die "--color takes auto, always or never" ;; esac color_setup ;; --no-color) COLOR="never"; color_setup ;; -h|--help) SHOW_HELP="true" ;; -*) usage_die "unknown option '$1'" ;; # A bare track name is the track, not a version. `-- beta` and `-s -- beta` are # what a person types when they half-remember the flag, and taking it as a version # sent them to the *stable* index (no dash, so no prerelease) looking for a file # called autodarts-desktop_beta_ — an error about a missing download for # a release that exists. There is no version named beta or stable to shadow. beta|stable) TRACK="$1" ;; *) REQ_VERSION="${1#v}" ;; esac shift done # Help is answered after the loop rather than inside it, so every other flag has been seen by # the time it prints: --help --color=always is a reasonable thing to type when piping this into # a pager, and handled where the flag sits it would have exited before reading the second one. if [ "$SHOW_HELP" = "true" ]; then banner usage exit 0 fi # One place, so every spelling agrees: --version v2.1.0, --version=v2.1.0 and a bare v2.1.0 all # mean 2.1.0. Versions on the wire carry no v (CLAUDE.md's version rule); accepting one and # then searching for a name that contains it would fail with "no such version". REQ_VERSION="${REQ_VERSION#v}" # Anything else bare is a typo, and saying so here is the only place it can be said clearly: # further down it has already become a filename, and the failure reads as a missing download # rather than as a mistyped argument. case "$REQ_VERSION" in ''|[0-9]*.[0-9]*.[0-9]*) ;; *) usage_die "'${REQ_VERSION}' is not a version — versions look like 2.1.0 or 2.1.0-beta.3" ;; esac banner # --- platform ----------------------------------------------------------------------------- # # First, because every path below depends on it — including the ones --uninstall removes. case "$(uname -s)" in Linux) OS="linux" ;; Darwin) OS="macos" ;; *) die "$(uname -s) is not supported; on Windows use the installer from autodarts.com" ;; esac case "$(uname -m)" in x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; # 32-bit ARM is the board's alone — there is no Desktop for it, because Velopack ships no # armv7 library and an app that cannot ever update itself is not one we publish. The # refusal for the other product is below, once the mode is known; it cannot be here, # because here nothing has decided yet which product is being installed. armv7l|armv7*|armhf) ARCH="armv7l" ;; *) die "architecture '$(uname -m)' is not supported — Autodarts ships for" \ "x86_64, arm64 and (board only) armv7l" ;; esac # Rosetta reports the architecture it emulates, not the one it runs on: a bash started under # translation says x86_64 on an Apple Silicon Mac, and taking that at face value installs the # Intel build on an arm64 machine — slower, and updating itself within the wrong channel # forever. The kernel will say so if asked directly. if [ "$OS" = "macos" ] \ && [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)" = "1" ]; then ARCH="arm64" fi # Desktop or headless, when the caller did not say. # # NOT from $DISPLAY or $WAYLAND_DISPLAY, and this is the whole difficulty: neither is set in # an ssh session, so a machine with a perfectly good desktop would be handed the headless # build for the entirely incidental reason that it was installed over ssh — which is how # most boards are installed. The question is about the *machine*, not this session. # # systemd's default target is the machine's own answer to it: graphical.target means a # display manager is what boots. Where systemd cannot be asked, the presence of any session # desktop file is the same question one layer down — a machine with an X or Wayland session # to offer has one of these, whether or not anyone is logged into it right now. # # When nothing is conclusive the honest answer is that we do not know, so `unknown` is a # third outcome rather than a guess wearing one of the two names. Guessing is what this used # to do — and in both directions at once: the comment here promised the Desktop while the code # returned headless, so whichever a reader trusted, one of them was lying. Installing the # wrong one is not a small miss either. A person at a machine gets no window and concludes the # install failed; a board gets an AppImage it cannot run. pick_mode() { [ "$OS" = "macos" ] && { printf 'desktop'; return 0; } if command -v systemctl > /dev/null 2>&1; then case "$(systemctl get-default 2>/dev/null)" in graphical.target) printf 'desktop'; return 0 ;; multi-user.target) printf 'headless'; return 0 ;; esac fi for dir in /usr/share/xsessions /usr/share/wayland-sessions; do if [ -d "$dir" ] && [ -n "$(ls -A "$dir" 2>/dev/null)" ]; then printf 'desktop' return 0 fi done printf 'unknown' } # Ask, when the machine could not answer for itself. # # From /dev/tty, never from stdin: the documented way to run this is `curl … | bash`, where # stdin is the script still being read. A `read` there eats the rest of this file and the # install dies somewhere strange, which is the classic way an interactive curl-to-bash # installer breaks. # # Fails when there is no terminal at all — a CI job, a provisioning script — and the caller # decides what to do about it rather than this hanging forever on a prompt nobody will see. ask_mode() { # Openable, not merely present. /dev/tty is a device node on every machine, including one # with no controlling terminal, where it fails at open() — and the printf below would then # fail under `set -e` and take the install down instead of falling back to a default. { : < /dev/tty; } 2> /dev/null || return 1 printf '\n%sThis machine could not be identified as a desktop or a board.%s\n' \ "$C_BOLD" "$C_RESET" > /dev/tty printf ' 1) Desktop — the app with a window, for a machine you sit at\n' > /dev/tty printf ' 2) Board — the headless daemon, for a machine that boots to a console\n' \ > /dev/tty local reply="" printf '\nWhich one? [1/2] ' > /dev/tty read -r reply < /dev/tty || return 1 case "$reply" in 1 | d | D | desktop) printf 'desktop' ;; 2 | b | B | board | h | H | headless) printf 'headless' ;; *) return 1 ;; esac } # 32-bit ARM publishes one product, so there is nothing to detect: either the caller asked # for the one that exists, or for one that does not. Settled BEFORE pick_mode rather than # after, which is where it was first written and where it reads wrong — a 32-bit Pi with a # desktop session makes pick_mode answer "desktop" perfectly correctly, so the user watched # it announce the Desktop, and sometimes warn that it was assuming the Desktop, before being # quietly overruled a line later. # # Forcing is also not the "assumed" case below. That one picks between two real options and # can be wrong; this one names the only option there is. if [ "$ARCH" = "armv7l" ]; then if [ -n "$MODE" ] && [ "$MODE" != "headless" ]; then die "Autodarts Desktop does not ship for 32-bit ARM (armv7l) — Velopack has no" \ "armv7 library, so it could never update itself. The board does: re-run with --headless" fi [ -n "$MODE" ] && MODE_CHOSEN="requested" || MODE_CHOSEN="the only build for armv7l" MODE="headless" fi if [ -z "$MODE" ]; then MODE="$(pick_mode)" MODE_CHOSEN="detected" if [ "$MODE" = "unknown" ]; then if MODE="$(ask_mode)"; then MODE_CHOSEN="chosen" else # Nobody to ask, or an answer that was not one of the two. The Desktop is the # safer default of the pair: it is what someone sitting at a machine expects, and # it carries the same board core — a board installed by mistake is a window # nobody wanted, while a Desktop missed is a machine with no UI and a person # concluding the installer is broken. --headless is one flag away either way. MODE="desktop" MODE_CHOSEN="assumed" warn "could not tell whether this is a desktop or a board — installing the Desktop" warn "re-run with --headless for the board daemon" fi fi elif [ -z "${MODE_CHOSEN:-}" ]; then MODE_CHOSEN="requested" fi # The board is a different product in the same bucket, so it reads a different prefix: two # version lines, two feeds, two download indexes (common/env.h says the same on the client # side, where HeadlessUpdateUrl is the Desktop's URL with the product swapped). Until now this # was not done at all — RELEASES_BASE was fixed at /desktop, so --headless went looking for a # board in the Desktop's index and found whatever the Desktop had published. # # Derived from RELEASES_BASE rather than spelled out again, so the documented local-rehearsal # sed at the top of this file — which rewrites that one line — still moves both products. [ "$MODE" = "headless" ] && RELEASES_BASE="${RELEASES_BASE%/desktop}/headless" # osx, not darwin: the release artifacts are named for the Velopack channel, and that is the # spelling packaging uses (third_party/use/velopack.cmake, and the dist/out/desktop/osx/ tree). if [ "$MODE" = "headless" ]; then APP_ID="$HEADLESS_APP_ID" # A board ships the same tarball on both platforms — it is a binary and its libraries in a # directory, unpacked by this script, with no bundle and no installer on either. Only the # OS in the name differs. It used to be hardcoded to linux, alongside a hard refusal above # for macOS, which was true until there was a macOS board to publish. case "$OS" in linux) PLATFORM="linux-${ARCH}" ;; macos) PLATFORM="osx-${ARCH}" ;; esac EXT=".tar.gz" else case "$OS" in linux) PLATFORM="linux-${ARCH}"; EXT=".AppImage" ;; macos) PLATFORM="osx-${ARCH}"; EXT=".dmg" ;; esac fi # --- paths -------------------------------------------------------------------------------- if [ -z "$INSTALL_DIR" ]; then if [ "$MODE" = "headless" ]; then # The bundle's home, not the symlink's: --dir names where the payload goes, and the # link in ~/.local/bin follows it. INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}" elif [ "$OS" = "linux" ]; then INSTALL_DIR="${HOME}/.local/bin" elif [ -w /Applications ]; then INSTALL_DIR="/Applications" else # A standard (non-admin) account, or a managed Mac. Not a downgrade: it is the only # place this user can install something that is able to replace itself. INSTALL_DIR="${HOME}/Applications" fi fi # Absolute, whatever --dir was given. On Linux a desktop entry's Exec is looked up in PATH # when it has no slash and resolved against the launcher's own directory when it is merely # relative, so a relative --dir writes an entry that works from a terminal and fails from the # menu — the same trap the app itself had on the other side of this file (executable_path() # in apps/desktop/platform/linux/deep_link.cpp). macOS has no equivalent lookup, but a # relative install path is no more meaningful there. case "$INSTALL_DIR" in /*) : ;; *) INSTALL_DIR="$(pwd)/${INSTALL_DIR#./}" ;; esac if [ "$MODE" = "headless" ]; then # TARGET is the bundle directory, not a file — the tarball unpacks into it and the binary # lives inside beside its libraries, because it carries RUNPATH=$ORIGIN. TARGET="${INSTALL_DIR}/${APP_ID}" BIN_DIR="${HOME}/.local/bin" LINK="${BIN_DIR}/${APP_ID}" # Two letters, and free: no package on Debian or macOS ships an `ad`. Verified per # machine before it is created, never assumed. SHORT_LINK="${BIN_DIR}/ad" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/${APP_ID}" # Nothing to clear: no Velopack on this path. Set so the shared cleanup is a no-op rather # than an unset-variable error under `set -u`. CACHE_BASE="" # Whether a board is running as a service here, so the upgrade below stops it first and # starts it after. Asked of whichever service manager this platform has. if [ "$OS" = "macos" ]; then if launchctl list "$LAUNCHD_LABEL" > /dev/null 2>&1; then SERVICE_PRESENT="true" fi elif command -v systemctl > /dev/null 2>&1 \ && systemctl --user is-active "$UNIT_NAME" > /dev/null 2>&1; then SERVICE_PRESENT="true" fi elif [ "$OS" = "linux" ]; then # The last three are not this script's choice: the app writes the same paths itself on # every start (apps/desktop/platform/linux/deep_link.cpp), so an installer that put them # anywhere else would be shadowed by the app's own copy. TARGET="${INSTALL_DIR}/${APP_ID}${EXT}" DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}" APPS_DIR="${DATA_DIR}/applications" DESKTOP_FILE="${APPS_DIR}/${APP_ID}.desktop" ICON_DIR="${DATA_DIR}/${APP_ID}" ICON_FILE="${ICON_DIR}/appicon.png" CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/${APP_ID}" # Velopack hardcodes this base on Linux — /var/tmp rather than ~/.cache, because an # AppImage has no install directory to keep packages beside, and /var/tmp survives a # reboot where /tmp need not. CACHE_BASE="/var/tmp/velopack" else TARGET="${INSTALL_DIR}/${MAC_APP_NAME}" # The bundle id, because that is what the app itself resolves (user_config_dir's # AppDirName in src/common/user_dirs.h picks the bundle id on Apple platforms). CONFIG_DIR="${HOME}/Library/Application Support/${MAC_BUNDLE_ID}" CACHE_BASE="${HOME}/Library/Caches/velopack" fi # --- helpers ------------------------------------------------------------------------------ # Silent on failure as well as on success — no -S, no wget progress: every fetch here is # followed by a check that reports the failure in this script's own words, and curl's # "curl: (22) The requested URL returned error: 404" ahead of it only says the same thing # twice, in a vocabulary the reader did not ask for. if command -v curl >/dev/null 2>&1; then fetch() { curl -fsL "$1"; } fetch_file() { curl -fsL -o "$2" "$1"; } elif command -v wget >/dev/null 2>&1; then fetch() { wget -qO- "$1"; } fetch_file() { wget -qO "$2" "$1"; } else die "neither curl nor wget found — install one and re-run" fi # One retry, after a pause, for anything fetched over the network. # # MEASURED, not defensive: the CDN in front of the bucket answers 404 from one edge for an # object another edge serves happily — a negative cache entry, and a second attempt normally # lands past it. Both hostnames are one pull zone, so this is not something a different URL # avoids. # # Only the transfer is retried. A download that arrives and fails its size or format check is # reported, never re-attempted: that is a wrong file rather than a missed one. retry_fetch() { fetch "$1" || { sleep 2; fetch "$1"; }; } retry_fetch_file() { fetch_file "$1" "$2" || { sleep 2; fetch_file "$1" "$2"; }; } # Velopack's package cache — the one thing this app keeps outside the places an installer # would think to look. Linux puts it in /var/tmp/velopack/, macOS in # ~/Library/Caches/velopack/, and in both the staged release lives there rather than # beside the app. It matters at both ends: # # uninstall nothing else removes it, and it holds a full copy of a release (tens of MB) # install a staged release is applied on the next start whether or not it is newer than # what was just installed, so installing 2.0.9 over a cache holding a staged # 2.1.0 silently puts 2.1.0 back. A --version pin or a downgrade that does not # survive the first launch is worse than one that fails outright. # # Cleared unconditionally rather than only for the downgrade that made it visible: on a first # install there is nothing there, and every other install replaces the app the cache was built # around — including a track switch, where the staged release is a prerelease the newly # installed stable build would never have asked for. The cost is one full download instead of # a delta the next time the app updates itself. clear_velopack_cache() { local cleared="" # Headless has no Velopack and therefore no cache base. Guarded here rather than at each # call site, so the install and uninstall paths stay one shape. [ -n "$CACHE_BASE" ] || return 0 for dir in "$CACHE_BASE/$APP_ID" "$CACHE_BASE/$APP_ID"*; do [ -d "$dir" ] || continue # On Linux that path is under /var/tmp, which is shared between users, so ownership is # checked rather than assumed: someone else's cache is reported and left alone. if [ ! -O "$dir" ]; then note "Leaving ${dir} alone — it belongs to another user." continue fi rm -rf "$dir" && cleared="${cleared} ${dir}" done [ -n "$cleared" ] && ok "Cleared the update cache:${cleared}" return 0 } # The database is what a launcher and a browser actually consult; the file alone changes # nothing. Same two-step the app does: xdg-mime is the fallback for systems shipping no # update-desktop-database, and it also makes us the *default* handler for the sign-in # scheme rather than merely a candidate. kbuildsycoca is KDE's own menu cache — modern # Plasma watches the directory, but rebuilding costs nothing and covers the ones that do not. reindex_applications() { if ! update-desktop-database "$APPS_DIR" >/dev/null 2>&1; then xdg-mime default "${APP_ID}.desktop" "x-scheme-handler/${APP_ID}" >/dev/null 2>&1 || true fi for kb in kbuildsycoca6 kbuildsycoca5; do command -v "$kb" >/dev/null 2>&1 && "$kb" >/dev/null 2>&1 && break done return 0 } # The macOS sibling of reindex_applications: tell the OS about what was just written, rather # than waiting for it to notice. # # Finder does this implicitly when you drag an app out of a DMG, which is why a DMG install # appears in Spotlight and Launchpad at once while a copy made by a script appears *eventually* # — measured here between four and twenty-plus seconds for the same bundle, same directory. # lsregister is what closes that gap for Launch Services. # # It is not only cosmetic. Launch Services is also what routes autodarts-desktop:// to the app, # and the app is signed in through exactly that handoff (the scheme is declared in the bundle's # Info.plist — apps/desktop/platform/macos/deep_link.mm). An install where the first sign-in # lands before Launch Services has caught up is a sign-in that fails for no visible reason. # # NOT mdimport, deliberately. It is the documented way to push an item into the Spotlight # index, but on an application bundle it did not help in any measurement here, and in one run # the entry that had been indexed was gone immediately after it. A command that may un-index # what it was called to index has no place in an installer. # # Best-effort throughout: lsregister is an internal tool at a path Apple does not promise, so a # machine without it simply waits the extra few seconds instead of failing an install. LS_SUPPORT="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework" LSREGISTER="${LS_SUPPORT}/Support/lsregister" mac_register_app() { [ -x "$LSREGISTER" ] || return 0 "$LSREGISTER" -f "$TARGET" >/dev/null 2>&1 || true return 0 } # The reverse, before the bundle goes away: an entry left claiming autodarts-desktop:// for a # path that no longer exists is worse than no entry at all, because the scheme still resolves — # to nothing. mac_unregister_app() { [ -x "$LSREGISTER" ] || return 0 [ -d "$TARGET" ] || return 0 "$LSREGISTER" -u "$TARGET" >/dev/null 2>&1 || true return 0 } # macOS only, and needed for both installing and removing: replacing or deleting a bundle # whose process is still running leaves a half-app behind and a running program with no files. # Asked by bundle id rather than by name, which is the one identifier that cannot be ambiguous, # and matched by the path of the running executable so this cannot mistake its own arguments # for the app. mac_quit_if_running() { pgrep -f "${TARGET}/Contents/MacOS/" >/dev/null 2>&1 || return 0 info "Autodarts Desktop is running — asking it to quit" osascript -e "tell application id \"${MAC_BUNDLE_ID}\" to quit" >/dev/null 2>&1 || true local i=0 while [ "$i" -lt 15 ]; do pgrep -f "${TARGET}/Contents/MacOS/" >/dev/null 2>&1 || return 0 sleep 1 i=$((i + 1)) done die "Autodarts Desktop is still running — quit it and run this again" } # --- uninstall ---------------------------------------------------------------------------- if [ "$ACTION" != "install" ]; then if [ "$MODE" = "headless" ]; then step "Removing the Autodarts headless board" else step "Removing Autodarts Desktop" fi # An install chooses between /Applications and ~/Applications by what is writable, so an # uninstall that only looks in one of them can report success while leaving the app exactly # where it is. Skipped when --dir named a directory: then the caller has said where to # look, and quietly deleting an app somewhere else is not what they asked for. if [ "$OS" = "macos" ] && [ ! -d "$TARGET" ] && [ "$DIR_EXPLICIT" = "false" ]; then for alt in "/Applications/${MAC_APP_NAME}" "${HOME}/Applications/${MAC_APP_NAME}"; do [ -d "$alt" ] || continue TARGET="$alt" INSTALL_DIR="$(dirname "$alt")" break done fi FOUND="false" [ -e "$TARGET" ] && FOUND="true" if [ "$MODE" = "headless" ]; then # The service first: stopping it before the binary goes away is the difference # between a clean removal and a unit that respawns onto a missing ExecStart until # the restart limit trips. service_remove ok "Removed the service, if there was one" rm -f "$LINK" # Only if it is ours. An `ad` this install did not create is not this uninstall's # to delete. case "$(readlink "$SHORT_LINK" 2>/dev/null)" in */"${APP_ID}") rm -f "$SHORT_LINK" ;; esac rm -rf "$TARGET" elif [ "$OS" = "linux" ]; then rm -f "$TARGET" "$DESKTOP_FILE" "$ICON_FILE" rmdir "$ICON_DIR" 2>/dev/null || true reindex_applications else mac_quit_if_running mac_unregister_app rm -rf "$TARGET" fi clear_velopack_cache if [ "$FOUND" = "true" ]; then ok "Removed ${TARGET}" else # Not an error: the caches and the menu entry above are cleaned either way, and a # second --uninstall should be as harmless as the first. But it must not claim to have # removed something it never found. note "No app at ${TARGET} — nothing to remove there." fi if [ "$ACTION" = "purge" ]; then ok "Deleted settings, calibration and sign-in in ${CONFIG_DIR}" rm -rf "$CONFIG_DIR" else note "Kept ${CONFIG_DIR} — pass --purge to delete it too." fi say "" exit 0 fi # --- resolve ------------------------------------------------------------------------------ # The track comes from the version whenever there is one, because semver already says which # it is — a prerelease tag is what makes 2.2.0-beta.3 a beta. Asking for a version and a # track that disagree is a mistake worth naming rather than resolving. if [ -n "$REQ_VERSION" ]; then case "$REQ_VERSION" in *-*) V_TRACK="beta" ;; *) V_TRACK="stable" ;; esac if [ -n "$TRACK" ] && [ "$TRACK" != "$V_TRACK" ]; then die "version ${REQ_VERSION} is on the ${V_TRACK} track, but --${TRACK} was passed" fi TRACK="$V_TRACK" INDEX="${RELEASES_BASE}/downloads/downloads.${TRACK}.json" WANT="${APP_ID}_${REQ_VERSION}_${PLATFORM}${EXT}" else TRACK="${TRACK:-stable}" INDEX="${RELEASES_BASE}/downloads/latest.${TRACK}.json" WANT="_${PLATFORM}${EXT}" fi PRODUCT="Autodarts Desktop" [ "$MODE" = "headless" ] && PRODUCT="Autodarts headless board" step "${PRODUCT} for ${PLATFORM}, ${TRACK} track (${MODE_CHOSEN})" INDEX_JSON="$(retry_fetch "$INDEX")" || INDEX_JSON="" [ -n "$INDEX_JSON" ] || die "could not read the release index at ${INDEX}" # One file entry per line, then pick ours by name. Whitespace goes first so this works whether # the index is pretty-printed or compact — no name or URL in the grammar contains a space, so # removing all of it is safe. # # SPLIT AT EVERY '{', which is what puts each file object alone on a line, so the name matched # below and the url read out of it cannot come from different objects. # # The obvious split — on '},', the boundary *between* objects — is wrong, and wrong in a way # that quietly installs the wrong thing. The archive index nests platform → versions → files, # so where one platform's list ends and the next begins the text reads `}]}],"win-amd64":[{`: # that is ']' before the comma, not '}', so no split happens there. The last entry of one # platform and the first of the next share a line, and since `.*"url"` is greedy it takes the # *last* url on it. Asking for the oldest listed version of a platform therefore returned the # newest artifact of the next one — on macOS, `--version ` downloaded a Windows # Setup.exe. Reproduced, then fixed here. ENTRY="$(printf '%s' "$INDEX_JSON" | tr -d ' \t\n\r' | sed 's/{/\n{/g' \ | grep -F "$WANT" | head -1)" || true if [ -z "$ENTRY" ]; then if [ -n "$REQ_VERSION" ]; then die "version ${REQ_VERSION} has no ${PLATFORM} download in ${INDEX}" fi printf 'ERROR: no %s release for %s yet.\n' "$TRACK" "$PLATFORM" >&2 [ "$TRACK" = "stable" ] && printf ' The beta track may have one: re-run with --beta\n' >&2 exit 1 fi # One field per line before reading them, so these are the matched object's own url and size # even if some future index shape ever puts two objects on one line again. Belt and braces # against the bug described above, which cost a wrong download rather than an error. URL="$(printf '%s' "$ENTRY" | tr ',' '\n' | sed -n 's/.*"url":"\([^"]*\)".*/\1/p' | head -1)" SIZE="$(printf '%s' "$ENTRY" | tr ',' '\n' | sed -n 's/.*"size":\([0-9]*\).*/\1/p' | head -1)" if [ -z "$URL" ]; then die "the index entry for ${PLATFORM} carries no url — ${INDEX} may have changed shape" fi # Grammar again: autodarts-desktop__-.. Read from the name we are # about to download rather than from the index's own version field, so what is reported is # what actually arrives. NAME="${URL##*/}" VERSION="${NAME#"${APP_ID}"_}" VERSION="${VERSION%%_*}" # --dry-run stops here, which is the whole point of it: resolution is the part that can be # wrong in an interesting way — the wrong track, the wrong architecture, a version that is not # in the index — and it is the part that costs nothing to check. Nothing has been written or # downloaded at this point. if [ "$DRY_RUN" = "true" ]; then step "Would install v${VERSION}" info "artifact ${NAME} ($(human_size "${SIZE:-0}"))" info "from ${URL}" info "to ${TARGET}" if [ "$MODE" = "headless" ]; then info "command ${LINK}" elif [ "$OS" = "linux" ] && [ "$WRITE_ENTRY" = "true" ]; then info "entry ${DESKTOP_FILE}" fi note "nothing was downloaded (--dry-run)" say "" exit 0 fi step "Installing v${VERSION}" info "to ${TARGET}" # --- download ----------------------------------------------------------------------------- mkdir -p "$INSTALL_DIR" # Downloaded beside its destination so a Linux install can move it into place atomically, # within one filesystem: an interrupted download must not leave a half-written AppImage where # a working one was. macOS unpacks rather than moves, but the same directory is as good a # place as any, and one cleanup path covers both. TMP_DOWNLOAD="${INSTALL_DIR}/.${APP_ID}.download.$$" TMP_DIR="" MOUNTPOINT="" MOUNT_DEV="" # Detached by DEVICE, never by mountpoint, and the failure is not swallowed. # # Measured, and it is worth the words: a detach naming the mountpoint can fail while the image # stays attached, and DiskArbitration then re-mounts that volume at its default # /Volumes/ as soon as the temporary mountpoint is removed. The result is a # successful install that leaves a disk image mounted on the user's desktop, which nothing # afterwards knows to clean up. The device node is the handle that does not depend on where # the volume currently is, and it is only cleared once the detach has actually worked, so the # exit trap gets another attempt. detach_image() { [ -n "$MOUNT_DEV" ] || return 0 hdiutil detach -quiet "$MOUNT_DEV" >/dev/null 2>&1 \ || hdiutil detach -quiet -force "$MOUNT_DEV" >/dev/null 2>&1 \ || return 1 MOUNT_DEV="" return 0 } cleanup() { detach_image || true [ -n "$MOUNTPOINT" ] && rmdir "$MOUNTPOINT" 2>/dev/null rm -f "$TMP_DOWNLOAD" rm -rf "${INSTALL_DIR}/.${APP_ID}.new.app" [ -n "$TMP_DIR" ] && rm -rf "$TMP_DIR" return 0 } trap cleanup EXIT info "$(printf 'downloading %s%s%s (%s)' "$C_DIM" "$NAME" "$C_RESET" "$(human_size "${SIZE:-0}")")" retry_fetch_file "$URL" "$TMP_DOWNLOAD" || die "download failed: ${URL}" GOT="$(wc -c < "$TMP_DOWNLOAD" | tr -d ' ')" if [ -n "$SIZE" ] && [ "$GOT" != "$SIZE" ]; then die "downloaded ${GOT} bytes, index says ${SIZE} — refusing to install a truncated app" fi # --- install ------------------------------------------------------------------------------ if [ "$MODE" = "headless" ]; then # Unpacked into a sibling and then swapped. Not atomic — no rename replaces a populated # directory — but it keeps the incomplete window to two operations rather than the length # of an extraction, and a failed download never touches the working install. STAGE="${TARGET}.new.$$" rm -rf "$STAGE" mkdir -p "$STAGE" # --strip-components=1: the tarball carries a top-level autodarts/ directory, and its # contents are what belong in TARGET. tar's own exit status is the format check here — # an error page is not a gzip stream. tar xzf "$TMP_DOWNLOAD" -C "$STAGE" --strip-components=1 \ || die "what downloaded is not a readable archive — check ${URL}" [ -x "$STAGE/$APP_ID" ] || die "no ${APP_ID} executable inside ${NAME}" # Stop the running board before its files move, not after. Two reasons, and the second is # the one that bit: a daemon whose binary is swapped underneath it keeps running from the # deleted inode, so an install over a running service would leave the *old* version # serving with the new one merely on disk. And with Restart=always, a crash inside the # swap window would restart onto an ExecStart that does not exist yet. if [ "$SERVICE_PRESENT" = "true" ]; then service_stop fi mkdir -p "$INSTALL_DIR" rm -rf "$TARGET" mv "$STAGE" "$TARGET" rm -f "$TMP_DOWNLOAD" ok "Bundle at ${TARGET}" # Relative when it can be, so the tree survives a moved, renamed or rsynced home. Absolute # only when --dir put the bundle somewhere a relative path cannot reach from ~/.local/bin. mkdir -p "$BIN_DIR" case "$TARGET" in "$HOME/.local/share/$APP_ID") LINK_TARGET="../share/${APP_ID}/${APP_ID}" ;; *) LINK_TARGET="${TARGET}/${APP_ID}" ;; esac ln -sfn "$LINK_TARGET" "$LINK" ok "Command at ${LINK}" # Start it again, if it was running when we arrived. The stop above is deliberate — a # daemon whose binary is swapped underneath it keeps serving from the deleted inode — but # stopping without starting is not an install, it is an outage: the board went quiet and # stayed quiet, and nothing on screen said the service was now off. # # After the symlink, not after the move: the unit's ExecStart goes through it. if [ "$SERVICE_PRESENT" = "true" ]; then if service_start; then ok "Service restarted" else warn "Could not restart the board service — start it with:" warn " $(service_start_hint)" fi fi # `ad` as a shorthand. Checked to be free on Debian and macOS, but checked again here # per machine, because ~/.local/bin usually precedes /usr/bin: a blind symlink would # SHADOW whatever else provided it, and silently breaking an unrelated command is far # worse than not having a two-letter alias. # # Replaced only when it already points into our own bundle — that is a reinstall. A # foreign `ad`, of any kind, is reported and left exactly where it is. if [ -e "$SHORT_LINK" ] || [ -L "$SHORT_LINK" ]; then case "$(readlink "$SHORT_LINK" 2>/dev/null)" in "$LINK_TARGET"|"$TARGET/$APP_ID") ln -sfn "$LINK_TARGET" "$SHORT_LINK" ok "Shorthand at ${SHORT_LINK}" ;; *) note "Left ${SHORT_LINK} alone — something else owns it." ;; esac else ln -sfn "$LINK_TARGET" "$SHORT_LINK" ok "Shorthand at ${SHORT_LINK} (\`ad\`)" fi # The installed binary answering is the only proof that matters, and it is also the check # that $ORIGIN resolved: it cannot start without its OpenCV libraries. # --version, not a `version` subcommand: the subcommand printed the same thing and was # removed, and this check failing is indistinguishable from a broken install. "$TARGET/$APP_ID" --version > /dev/null 2>&1 \ || die "the installed binary does not run — its libraries may be missing" elif [ "$OS" = "linux" ]; then # An AppImage is an ELF executable with the payload appended, so this is the cheapest # proof that what arrived is a program and not an error page a proxy served with a 200. case "$(dd if="$TMP_DOWNLOAD" bs=4 count=1 2>/dev/null | tr -d '\000')" in *ELF) : ;; *) die "what downloaded is not an executable — check ${URL}" ;; esac chmod 755 "$TMP_DOWNLOAD" mv -f "$TMP_DOWNLOAD" "$TARGET" ok "AppImage at ${TARGET}" else # No magic-byte check here, because mounting is a stronger one: hdiutil verifies the # image's own checksum, so an error page or a truncated body cannot attach at all. # # -readonly, so nothing can write back into the image; -nobrowse, so it does not appear in # Finder mid-install; -mountpoint, so a stale /Volumes entry left by an interrupted run # cannot shadow the image we just downloaded. MOUNTPOINT="$(mktemp -d)" # Not -quiet: that suppresses the device table too, and the device is what detach_image # needs. The checksum progress hdiutil prints along with it is captured here rather than # shown — the verification is the point, not its running commentary. ATTACH_OUT="$(hdiutil attach -nobrowse -readonly -mountpoint "$MOUNTPOINT" \ "$TMP_DOWNLOAD" 2>/dev/null)" \ || die "could not mount ${NAME} — the download may be damaged" MOUNT_DEV="$(printf '%s\n' "$ATTACH_OUT" | awk '/^\/dev\// {print $1; exit}')" SRC_APP="$(find "$MOUNTPOINT" -maxdepth 1 -name '*.app' -print 2>/dev/null | head -1)" [ -n "$SRC_APP" ] || die "no application bundle inside ${NAME}" # ditto rather than cp -R: it is the tool that copies a bundle with its extended # attributes and code signature intact, which is what keeps the copy notarized and # therefore launchable without a Gatekeeper prompt. # # Staged next to the destination and then swapped, so a failure part-way through leaves # the installed app untouched rather than half-replaced. NEW_APP="${INSTALL_DIR}/.${APP_ID}.new.app" rm -rf "$NEW_APP" ditto "$SRC_APP" "$NEW_APP" || die "could not copy the app out of ${NAME}" if detach_image; then rmdir "$MOUNTPOINT" 2>/dev/null || true MOUNTPOINT="" else # Reported rather than ignored: the install still completes, but the user has a mounted # image to eject, and saying so beats leaving them to notice it. warn "Could not eject ${NAME} — eject \"$(basename "$SRC_APP" .app)\" in Finder." fi mac_quit_if_running rm -rf "$TARGET" mv "$NEW_APP" "$TARGET" || die "could not move the app into ${INSTALL_DIR}" rm -f "$TMP_DOWNLOAD" ok "App bundle in ${INSTALL_DIR}" mac_register_app fi # Only now, with the new app in place: a download that failed must not cost a working install # the cache it would have updated from. clear_velopack_cache # --- desktop integration (Linux only) ----------------------------------------------------- # # macOS needs none: Spotlight and Launchpad index /Applications and ~/Applications, the icon # is inside the bundle, and the sign-in scheme is declared in its Info.plist rather than # registered by hand (apps/desktop/platform/macos/deep_link.mm). # # And headless needs none either, for a stronger reason: there is no window to put in a menu, # no icon to draw and no AppImage to mount. if [ "$OS" = "linux" ] && [ "$MODE" != "headless" ]; then # The icon, so the menu entry has something to draw before the app has ever run. The app # copies it to this same path itself on first start; this is only about the window between # installing and launching. Best-effort throughout: --appimage-extract needs no FUSE (the # runtime unpacks itself), but if anything about it fails the entry is simply written # without an Icon= line, which is exactly what the app does in the same situation. # # FULL PATHS, NOT A GLOB, and that is the whole fix: the runtime matches with # fnmatch(pattern, path, FNM_FILE_NAME | FNM_LEADING_DIR) (AppImageKit src/runtime.c), and # FNM_FILE_NAME is FNM_PATHNAME — a '*' does not cross a '/'. The obvious '*appicon.png' # therefore matched *nothing* against usr/bin/appicon.png, silently, and every Linux # install had a menu entry with no icon until the app wrote one at first launch. # # Two patterns because one extraction is one pattern. usr/bin/appicon.png is the file the # app itself copies out (install_icon() in apps/desktop/platform/linux/deep_link.cpp), so # installing it here produces the identical bytes at the identical path — the entry the # app compares against on start already matches, and it rewrites nothing. .DirIcon is the # AppImage format's own top-level icon, the same image, and survives vpk ever staging the # payload somewhere other than usr/bin. extract_icon() { TMP_DIR="$(mktemp -d)" || return 1 local pattern found for pattern in usr/bin/appicon.png .DirIcon; do # A pattern that matches nothing is not an error to the runtime — it extracts # nothing and exits 0 — so the file has to be looked for rather than assumed. ( cd "$TMP_DIR" && "$TARGET" --appimage-extract "$pattern" >/dev/null 2>&1 ) \ || continue found="$(find "$TMP_DIR" -name "${pattern##*/}" -type f -size +0 2>/dev/null | head -1)" [ -n "$found" ] || continue mkdir -p "$ICON_DIR" && cp -f "$found" "$ICON_FILE" && return 0 done return 1 } if extract_icon; then ok "Icon at ${ICON_FILE}" else ICON_FILE="" note "No icon yet — the app installs its own on first start." fi # The menu and search entry. # # BYTE-IDENTICAL TO WHAT THE APP WRITES, and that is a hard constraint rather than # tidiness: the app rewrites this file on every start unless its content already matches # exactly (desktop_entry() in apps/desktop/platform/linux/deep_link.cpp). A field added # only here therefore lasts until the first launch and then vanishes — so anything worth # having in the entry goes into that function first, and is mirrored here. # # Keywords is what makes the app findable by typing "darts": a launcher matches word # prefixes, so Name=Autodarts Desktop answers to "autodarts" and nothing else. # StartupWMClass is what ties the running window back to this entry, which is what gives # it an icon in the switcher and lets "pin to favourites" work. write_desktop_entry() { mkdir -p "$APPS_DIR" { printf '[Desktop Entry]\n' printf 'Type=Application\n' printf 'Name=Autodarts Desktop\n' printf 'Comment=Autodarts board client\n' printf 'Exec=%s %%u\n' "$TARGET" printf 'Terminal=false\n' printf 'Categories=Game;\n' printf 'Keywords=darts;dart;autodarts;board;scoreboard;\n' printf 'StartupWMClass=%s\n' "$APP_ID" [ -n "$ICON_FILE" ] && printf 'Icon=%s\n' "$ICON_FILE" printf 'MimeType=x-scheme-handler/%s;\n' "$APP_ID" } > "$DESKTOP_FILE" chmod 644 "$DESKTOP_FILE" reindex_applications } if [ "$WRITE_ENTRY" = "true" ]; then write_desktop_entry # The one thing a launcher needs and cannot report usefully: that Exec names a file it # can run. Checked here, where the path is still in hand, rather than discovered as # "could not find the program" the first time someone clicks the icon. if [ ! -x "$TARGET" ]; then die "wrote ${DESKTOP_FILE} pointing at ${TARGET}, which is not executable" fi ok "Menu entry at ${DESKTOP_FILE}" fi # An AppImage is a filesystem image that mounts itself, and FUSE is what mounts it. # Checked rather than assumed, because the failure without it is a bare "Cannot mount # AppImage" at first launch, long after this script has said it succeeded. A warning, not # an error: some systems autoload the module on first use, so a missing /dev/fuse now may # still work later. fuse_hint() { if command -v apt-get >/dev/null 2>&1; then echo "sudo apt install libfuse2" elif command -v dnf >/dev/null 2>&1; then echo "sudo dnf install fuse-libs" elif command -v pacman >/dev/null 2>&1; then echo "sudo pacman -S fuse2" elif command -v zypper >/dev/null 2>&1; then echo "sudo zypper install fuse" else echo "install your distribution's FUSE package" fi } if [ ! -e /dev/fuse ] && ! command -v fusermount3 >/dev/null 2>&1 \ && ! command -v fusermount >/dev/null 2>&1; then warn "No FUSE on this system, which is what mounts an AppImage." warn "If the app does not start: $(fuse_hint)" fi fi # --- report ------------------------------------------------------------------------------- say "" step "$(printf 'Installed %s %sv%s%s' "$PRODUCT" "$C_GREEN$C_BOLD" "$VERSION" "$C_RESET")" if [ "$MODE" = "headless" ]; then if [ "$SERVICE_PRESENT" = "true" ]; then # A unit was already here and has been restarted onto the new binary. Saying "now # run it" would invite a second instance fighting the first for :3180 and the # cameras. info "The service is running the new version:" info " $(service_status_hint)" info " $(service_log_hint)" else info "Run the board with:" info " ${APP_ID}" fi # The one thing that leaves a correct install looking broken: everything worked, and the # command cannot be found. Two different reasons, and the advice differs, so it is worth # more than one line. # # On Debian and Ubuntu ~/.profile puts ~/.local/bin on PATH only if the directory already # existed *at login*, so an install that created it is invisible until the next one — and # there, logging out really is the fix. On macOS nothing puts it there at all, so waiting # achieves nothing and saying "log out and back in" sends someone to reboot for no reason. # # The export is named as what it is — this shell only — because the previous wording # offered it as though it were the fix, and the next terminal disagreed. case ":$PATH:" in *":$BIN_DIR:"*) ;; *) warn "${BIN_DIR} is not on your PATH, so \`${APP_ID}\` will not be found yet." say "" info "For this shell only:" info " $(path_line)" say "" info "To keep it, add that line to $(shell_rc):" info " echo '$(path_line)' >> $(shell_rc)" if [ "$OS" = "linux" ]; then note "On Debian and Ubuntu, logging out and back in also does it: ~/.profile" note "adds ~/.local/bin, but only if it existed when you logged in." fi ;; esac elif [ "$OS" = "macos" ]; then info "Search for \"Autodarts\" in Spotlight, or:" info " open -a \"${TARGET}\"" elif [ "$WRITE_ENTRY" = "true" ]; then info "Search your applications for \"Autodarts\" or \"darts\", or run:" info " ${TARGET}" else info "Run it with:" info " ${TARGET}" fi if [ "$MODE" = "headless" ] && [ "$SERVICE_PRESENT" != "true" ]; then note "No service installed — the board runs while ${APP_ID} is open." note "Install one from its Service section, which also starts it at boot." fi # The three commands someone needs, and where the rest is. `remote` in particular is not # guessable: it is how a board without a screen is managed from a laptop on the same network. if [ "$MODE" = "headless" ]; then say "" info "Commands:" info " ${APP_ID} manage the board on this machine" info " ${APP_ID} remote manage a board elsewhere on your network, from any machine" info " ${APP_ID} update install the latest release" info " ${APP_ID} --help everything else" fi # Only the Desktop does. A headless board is updated by its own CLI, deliberately — a # supervised daemon must not swap its binary out from under systemd. Saying otherwise here # would be the installer promising something no code does. if [ "$MODE" != "headless" ]; then note "It updates itself from here on." fi if [ "$MODE" = "headless" ]; then note "Remove it with: curl -fsSL https://autodarts.sh/sh/install.sh | bash -s -- -u --headless" else note "Remove it with: curl -fsSL https://autodarts.sh/sh/install.sh | bash -s -- -u" fi say ""