#!/bin/bash
# DevGrail Desktop installer: adds a desktop (XFCE, Firefox, sound, RDP) to a
# running DevGrail workspace, reached through one of the workspace's app
# subdomains. Run it in the workspace's terminal, as the workspace user:
#
#   curl -fsSL https://<devgrail-web>/desktop/install.sh | bash
#   curl -fsSL https://<devgrail-web>/desktop/install.sh | bash -s -- --no-rdp
#
# Re-running it upgrades in place and keeps the choices made the first time
# (ports, display, RDP, Firefox, CJK fonts) unless a flag changes them. To
# remove the add-on:
#
#   devgrail-desktop uninstall [--purge-packages]
#
# which runs the copy of this script kept in /opt/devgrail-desktop.
#
# Flags:
#   --port=N          the app's port, which the subdomain points at (26080)
#   --vnc-port=N      Xvnc's loopback port (25901)
#   --rdp-port=N      xrdp's loopback port (23389)
#   --display=N       the X display number (8)
#   --cjk-fonts       also install fonts-noto-cjk (about 90 MB); --no-cjk-fonts
#   --no-firefox      skip Firefox and Mozilla's apt repo; --firefox
#   --no-rdp          skip xrdp; --rdp
#   --version=X       install the add-on from DevGrail release X, not the latest
#   --yes             accepted; there are no questions to answer
#   uninstall [--purge-packages]
#                     remove the add-on; --purge-packages also removes the
#                     packages this installer added
#
# Environment:
#   DEVGRAIL_WEB_URL          the DevGrail site to download from (default: the
#                             site this script was downloaded from)
#   DEVGRAIL_ALLOW_UNSIGNED   1: install downloads whose release signature
#                             cannot be checked
#   DEVGRAIL_RELEASE_PUBKEY_FILE  extra trusted release signing keys (PEM)
#
# It runs as the workspace user, not root, and uses `sudo -n` for what needs
# root, so a sudo that asks for a password fails the checks instead of hanging
# on a prompt nobody can answer. Everything happens inside main(), called on
# the last line: under `curl | bash` the script arrives on bash's stdin, and
# nothing may run before all of it has been read. For the same reason every
# command that could read stdin is given /dev/null.
#
# docs/plans/DESKTOP_ADDON_PLAN.md is the design.
set -Eeuo pipefail

# __DEVGRAIL_WEB_URL__ is replaced with the serving origin by devgrail-web, as
# for install.sh. An explicit DEVGRAIL_WEB_URL still wins.
DEVGRAIL_WEB_URL="${DEVGRAIL_WEB_URL:-__DEVGRAIL_WEB_URL__}"
WEB="${DEVGRAIL_WEB_URL%/}"

BIN=/usr/local/bin/devgrail-desktop
SHARE=/opt/devgrail-desktop
ETC=/etc/devgrail-desktop
CONFIG="$ETC/config.json"
RECORD=/etc/devgrail-desktop.json
# The files this installer wrote outside its own directories, one path a line.
# Each one's original, when there was one, is kept under $ETC/orig/<path>, and
# uninstall puts it back.
MANAGED="$ETC/managed"
# Packages this installer installed that were not installed before, for
# `uninstall --purge-packages`.
PACKAGES_ADDED="$ETC/packages-added"
# Present when this installer created the boot hook, which uninstall then
# removes again if nothing else is left in it.
CREATED_HOOK="$ETC/created-startup-hook"
STARTUP=/workspace/.devgrail/startup.sh
STATE_DIR="$HOME/.local/state/devgrail-desktop"
LOG="$STATE_DIR/install.log"

MOZILLA_FPR=35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3
# Ours by name, so a re-run and uninstall recognise them.
MOZILLA_KEYRING=/etc/apt/keyrings/devgrail-desktop-mozilla.gpg
MOZILLA_LIST=/etc/apt/sources.list.d/devgrail-desktop-mozilla.list
MOZILLA_PIN=/etc/apt/preferences.d/devgrail-desktop-mozilla
# xrdp 0.9 (Ubuntu 22.04) has sesman listen on TCP 3350; this moves it.
# xrdp 0.10 (26.04) uses a Unix socket and needs nothing.
SESMAN_PORT=23350
# The release the default workspace image is built on, the one this is tested
# on. Others get a warning; apt's dry run decides whether they can take it.
TESTED_RELEASE=26.04

BLOCK_BEGIN='# >>> devgrail-desktop >>>'
BLOCK_END='# <<< devgrail-desktop <<<'

# --- output ------------------------------------------------------------------
# What the user sees is a short report; everything the commands print goes to
# $LOG, which is where an error points.

ON_LINE=0
note() { [ -n "${LOG_FD:-}" ] && printf '%s\n' "$*" >&"$LOG_FD" || true; }
say() { printf '%s\n' "$*"; note "$*"; }
ok() { say "  ✓ $*"; }
caution() { say "  ! $*"; }
# begin/done print one "Doing this ... done" line around a long step.
begin() { printf '  %s ... ' "$*"; note "--- $*"; ON_LINE=1; STEP_START=$SECONDS; }
finish() {
  local took=$((SECONDS - STEP_START)) msg="${1:-done}"
  if [ "$took" -ge 5 ]; then msg="$msg ($(duration "$took"))"; fi
  printf '%s\n' "$msg"; note "$msg"; ON_LINE=0
}
duration() {
  if [ "$1" -ge 60 ]; then printf '%dm %ds' $(($1 / 60)) $(($1 % 60)); else printf '%ds' "$1"; fi
}
die() {
  [ "$ON_LINE" = 1 ] && printf 'failed\n' >&2
  ON_LINE=0
  printf '\n  error: %s\n' "$*" >&2
  note "error: $*"
  if [ -n "${LOG_FD:-}" ]; then printf '  The full log is %s\n' "$LOG" >&2; fi
  exit 1
}
# quietly runs a command with its output in the log and nothing on stdin.
quietly() { "$@" </dev/null >&"$LOG_FD" 2>&1; }
# log_tail prints the end of the log, indented, after a command failed.
log_tail() { tail -n "${1:-15}" "$LOG" | sed 's/^/    /' >&2; }

as_root() { sudo -n "$@"; }

# --- arguments ---------------------------------------------------------------

MODE=install
PURGE=0
F_PORT=""; F_VNC=""; F_RDP_PORT=""; F_DISPLAY=""
F_FIREFOX=""; F_RDP=""; F_CJK=""
RELEASE=current

usage() {
  sed -n '2,/^set -Eeuo/{/^set -Eeuo/d;s/^# \{0,1\}//;p}' "$SHARE/install.sh" 2>/dev/null ||
    printf 'usage: install.sh [--port=N] [--vnc-port=N] [--rdp-port=N] [--display=N]\n  [--cjk-fonts] [--no-firefox] [--no-rdp] [--version=X] | uninstall [--purge-packages]\n'
}

parse_args() {
  local arg
  for arg in "$@"; do
    case "$arg" in
      uninstall) MODE=uninstall ;;
      --purge-packages) PURGE=1 ;;
      --port=*) F_PORT="${arg#*=}" ;;
      --vnc-port=*) F_VNC="${arg#*=}" ;;
      --rdp-port=*) F_RDP_PORT="${arg#*=}" ;;
      --display=*) F_DISPLAY="${arg#*=}" ;;
      --cjk-fonts) F_CJK=1 ;;
      --no-cjk-fonts) F_CJK=0 ;;
      --firefox) F_FIREFOX=1 ;;
      --no-firefox) F_FIREFOX=0 ;;
      --rdp) F_RDP=1 ;;
      --no-rdp) F_RDP=0 ;;
      --version=*) RELEASE="${arg#*=}" ;;
      --yes | -y) ;;
      --help | -h) usage; exit 0 ;;
      *) printf 'install.sh: unknown option %s (see --help)\n' "$arg" >&2; exit 2 ;;
    esac
  done
  if [ "$PURGE" = 1 ] && [ "$MODE" != uninstall ]; then
    printf 'install.sh: --purge-packages goes with uninstall\n' >&2
    exit 2
  fi
}

# --- the log and the lock ----------------------------------------------------

open_log() {
  mkdir -p "$STATE_DIR"
  chmod 0700 "$STATE_DIR"
  # The lock first, so a second run does not truncate the first one's log.
  exec {LOCK_FD}>"$STATE_DIR/install.lock"
  flock -n "$LOCK_FD" || die "another run of this installer is still going."
  exec {LOG_FD}>"$LOG"
  note "devgrail-desktop $MODE, $(date -u +%Y-%m-%dT%H:%M:%SZ), args: $*"
}

# --- cleanup -----------------------------------------------------------------
# Nothing a run downloads stays in the workspace once it is no longer needed,
# whether the run succeeded or not: the temporary directory goes, apt's .deb
# files go, and so do apt's package lists when there were none before the run
# (the default image ships without them). On a failure before any package was
# installed, the Mozilla repo files this run added go too, so a failed check
# leaves the system as it was.

WORK=""
APT_TOUCHED=0
LISTS_BEFORE=1
MOZILLA_ADDED_NOW=""
TEMP_POLICY_RC=0
FAILED_LINE=""

cleanup() {
  local status=$?
  if [ "$status" -ne 0 ] && [ -n "$MOZILLA_ADDED_NOW" ]; then
    # shellcheck disable=SC2086 # a list of paths
    as_root rm -f $MOZILLA_ADDED_NOW || true
    note "removed the Mozilla repo files this run added"
  fi
  if [ "$TEMP_POLICY_RC" = 1 ]; then
    as_root rm -f /usr/sbin/policy-rc.d || true
    TEMP_POLICY_RC=0
  fi
  if [ "$APT_TOUCHED" = 1 ]; then
    as_root apt-get clean </dev/null >/dev/null 2>&1 || true
    if [ "$LISTS_BEFORE" = 0 ]; then
      as_root find /var/lib/apt/lists -type f ! -name lock -delete 2>/dev/null || true
    fi
    APT_TOUCHED=0
  fi
  if [ -n "$WORK" ]; then
    rm -rf "$WORK" || true
    WORK=""
  fi
  if [ "$status" -ne 0 ] && [ -n "$FAILED_LINE" ]; then
    [ "$ON_LINE" = 1 ] && printf 'failed\n' >&2
    printf '\n  error: the installer stopped at line %s.\n  The full log is %s\n' "$FAILED_LINE" "$LOG" >&2
  fi
}

# apt_lists_present says whether apt has any package lists.
apt_lists_present() {
  find /var/lib/apt/lists -maxdepth 1 -type f -name '*_Packages*' 2>/dev/null | grep -q .
}

# --- small helpers -----------------------------------------------------------

is_port() { case "$1" in '' | *[!0-9]*) return 1 ;; esac; [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; }

# port_listening says whether something listens on TCP port $1, on any
# address, from /proc: ss and lsof are not in the image.
port_listening() {
  local hex
  hex="$(printf '%04X' "$1")"
  awk -v h="$hex" 'FNR > 1 { n = split($2, a, ":"); if (a[n] == h && $4 == "0A") found = 1 }
    END { exit !found }' /proc/net/tcp /proc/net/tcp6 2>/dev/null
}

# display_taken says whether a running X server holds display $1.
display_taken() {
  local pid comm
  [ -f "/tmp/.X$1-lock" ] || return 1
  pid="$(tr -dc '0-9' <"/tmp/.X$1-lock")"
  [ -n "$pid" ] && [ -r "/proc/$pid/comm" ] || return 1
  comm="$(cat "/proc/$pid/comm")"
  case "$comm" in X*) return 0 ;; esac
  return 1
}

# json_value reads one key from a JSON file this installer wrote: one key a
# line, so no JSON parser is needed (jq is not on every Ubuntu).
json_value() {
  [ -r "$2" ] || return 0
  sed -n "s/^ *\"$1\": *\"\{0,1\}\([^\",]*\)\"\{0,1\},\{0,1\} *\$/\1/p" "$2" | head -n 1
}
json_str() { printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"; }
bool() { if [ "$1" = 1 ]; then printf true; else printf false; fi; }
from_bool() { case "$1" in true) printf 1 ;; false) printf 0 ;; *) printf '%s' "$2" ;; esac; }

sha256_of() { sha256sum "$1" | awk '{print $1}'; }

installed_version() { dpkg-query -W -f='${Version}' "$1" 2>/dev/null || true; }

installed_packages() {
  dpkg-query -W -f='${binary:Package} ${db:Status-Status}\n' 2>/dev/null | awk '$2 == "installed" {print $1}' | LC_ALL=C sort -u
}

# root_install <mode> <src> <dest> puts a file in place as root.
root_install() { as_root install -D -m "$1" "$2" "$3"; }

# take_over <path> registers a file this installer is about to write outside
# its own directories, keeping the original the first time. A file named
# devgrail-desktop* is ours by name, so there is no original to keep.
take_over() {
  local path="$1"
  if [ -f "$MANAGED" ] && grep -qxF "$path" "$MANAGED"; then
    return 0
  fi
  case "${path##*/}" in
    devgrail-desktop*) ;;
    *)
      if as_root test -e "$path"; then
        as_root mkdir -p "$ETC/orig${path%/*}"
        as_root cp -a "$path" "$ETC/orig$path"
      fi
      ;;
  esac
  printf '%s\n' "$path" | as_root tee -a "$MANAGED" >/dev/null
}

# give_back <path> puts the original back, or removes the file when there was
# none, and forgets it.
give_back() {
  local path="$1"
  if as_root test -e "$ETC/orig$path"; then
    as_root cp -a "$ETC/orig$path" "$path"
    as_root rm -f "$ETC/orig$path"
  else
    as_root rm -f "$path"
  fi
  if [ -f "$MANAGED" ]; then
    grep -vxF "$path" "$MANAGED" >"$WORK/managed" || true
    root_install 0644 "$WORK/managed" "$MANAGED"
  fi
}

managed() { [ -f "$MANAGED" ] && grep -qxF "$1" "$MANAGED"; }

# app_running says whether the add-on's app is up, by its own account.
app_running() { [ -x "$BIN" ] && "$BIN" status </dev/null >/dev/null 2>&1; }

# --- choices -----------------------------------------------------------------
# Flags over what the last run chose over the defaults, so the upgrade command
# the viewer shows (which has no flags) keeps a moved port where it is.

PREV_PORT=""; PREV_VNC=""; PREV_RDP_PORT=""; PREV_DISPLAY=""

settle_choices() {
  PREV_PORT="$(json_value port "$CONFIG")"
  PREV_VNC="$(json_value vnc_port "$CONFIG")"
  PREV_RDP_PORT="$(json_value rdp_port "$CONFIG")"
  PREV_DISPLAY="$(json_value display "$CONFIG")"
  PORT="${F_PORT:-${PREV_PORT:-26080}}"
  VNC_PORT="${F_VNC:-${PREV_VNC:-25901}}"
  RDP_PORT="${F_RDP_PORT:-${PREV_RDP_PORT:-23389}}"
  DISPLAY_NO="${F_DISPLAY:-${PREV_DISPLAY:-8}}"
  RDP="${F_RDP:-$(from_bool "$(json_value rdp "$CONFIG")" 1)}"
  FIREFOX="${F_FIREFOX:-$(from_bool "$(json_value firefox "$CONFIG")" 1)}"
  CJK="${F_CJK:-$(from_bool "$(json_value cjk_fonts "$CONFIG")" 0)}"

  local name value
  for name in port:"$PORT" vnc-port:"$VNC_PORT" rdp-port:"$RDP_PORT"; do
    value="${name#*:}"
    is_port "$value" || die "--${name%%:*}=$value is not a TCP port."
  done
  if [ "$PORT" = "$VNC_PORT" ] || [ "$PORT" = "$RDP_PORT" ] || [ "$VNC_PORT" = "$RDP_PORT" ] ||
    [ "$SESMAN_PORT" = "$PORT" ] || [ "$SESMAN_PORT" = "$VNC_PORT" ] || [ "$SESMAN_PORT" = "$RDP_PORT" ]; then
    die "--port, --vnc-port and --rdp-port must differ from each other and from $SESMAN_PORT."
  fi
  case "$DISPLAY_NO" in '' | *[!0-9]*) die "--display=$DISPLAY_NO is not a display number." ;; esac
  [ "$DISPLAY_NO" -le 999 ] || die "--display=$DISPLAY_NO is out of range."
}

# ours <port> says whether a port is held by this add-on's running app: on a
# re-run, the ports it already listens on are not taken by somebody else.
OURS_RUNNING=0
ours() {
  [ "$OURS_RUNNING" = 1 ] || return 1
  case "$1" in "$PREV_PORT" | "$PREV_VNC" | "$PREV_RDP_PORT") return 0 ;; esac
  return 1
}

# --- preflight ---------------------------------------------------------------
# Everything here fails before anything changes.

ARCH=""
PASSWORD_OK=0

preflight() {
  say ""
  say "  Checks"

  [ -r /etc/os-release ] || die "this is not Ubuntu (no /etc/os-release)."
  local id version_id
  # shellcheck source=/dev/null # the system's, not this repo's
  id="$(. /etc/os-release && printf '%s' "${ID:-}")"
  # shellcheck source=/dev/null
  version_id="$(. /etc/os-release && printf '%s' "${VERSION_ID:-}")"
  if [ "$id" != ubuntu ] || ! command -v apt-get >/dev/null; then
    die "the desktop add-on installs on Ubuntu, and this is ${id:-unknown}."
  fi
  ARCH="$(dpkg --print-architecture)"
  case "$ARCH" in amd64 | arm64) ;; *) die "the desktop add-on is built for amd64 and arm64, not $ARCH." ;; esac
  if [ "$version_id" = "$TESTED_RELEASE" ]; then
    ok "Ubuntu $version_id, $ARCH"
  else
    caution "Ubuntu $version_id, $ARCH: tested on $TESTED_RELEASE; the package check below decides"
  fi

  [ "$(id -u)" -ne 0 ] || die "run this as the workspace user, not as root: the desktop belongs to that user.
  In the workspace's terminal: curl -fsSL $WEB/desktop/install.sh | bash"
  sudo -n true 2>/dev/null </dev/null || die "sudo asks for a password here. The installer needs sudo without one, as workspaces have it."
  ok "sudo works without a password"

  local tool
  for tool in curl tar sha256sum flock; do
    command -v "$tool" >/dev/null || die "$tool is not installed."
  done
  # Workspaces have both; a release cannot be checked without them.
  for tool in jq openssl; do
    command -v "$tool" >/dev/null ||
      die "$tool is needed to check the release this installs (jq reads it, openssl checks its signature). Install it with: sudo apt-get install $tool"
  done
  if [ "$FIREFOX" = 1 ] && ! command -v gpg >/dev/null; then
    die "gpg is needed to check the key of Mozilla's apt repo. Install gnupg, or pass --no-firefox."
  fi

  if [ -e /desktop-entrypoint.sh ] || [ -e /etc/supervisor/conf.d/devgrail-desktop.conf ]; then
    die "this workspace was created from DevGrail's desktop image, which has a desktop already.
  The add-on is for default workspaces: its xrdp and sound would compete with the image's."
  fi

  # About 1 GB lands under /usr on a first install; an upgrade needs little.
  local need_mb=1200 free_kb
  [ "$CJK" = 1 ] && need_mb=1300
  [ -n "$(installed_version xfce4)" ] && need_mb=300
  free_kb="$(df -Pk / | awk 'NR == 2 {print $4}')"
  if [ "$((free_kb / 1024))" -lt "$need_mb" ]; then
    die "$((free_kb / 1024)) MB free under /, and the desktop needs about $need_mb MB."
  fi
  ok "$(awk -v k="$free_kb" 'BEGIN { printf "%.1f GB", k / 1048576 }') free (needs about $((need_mb / 100 / 10)).$((need_mb / 100 % 10)) GB)"

  if app_running; then
    OURS_RUNNING=1
  fi
  local p flag
  for p in "port:$PORT" "vnc-port:$VNC_PORT" "rdp-port:$RDP_PORT"; do
    flag="${p%%:*}"
    p="${p#*:}"
    [ "$flag" = rdp-port ] && [ "$RDP" != 1 ] && continue
    if ! ours "$p" && port_listening "$p"; then
      die "port $p is taken by another program. Choose another with --$flag=N."
    fi
  done
  # sesman's port matters only on xrdp 0.9, which configure_xrdp checks for.
  if ! { [ "$OURS_RUNNING" = 1 ] && [ "$DISPLAY_NO" = "$PREV_DISPLAY" ]; } && display_taken "$DISPLAY_NO"; then
    die "display :$DISPLAY_NO is taken by another X server. Choose another with --display=N."
  fi
  if [ "$RDP" = 1 ]; then
    ok "ports $PORT, $VNC_PORT and $RDP_PORT and display :$DISPLAY_NO are free"
  else
    ok "ports $PORT and $VNC_PORT and display :$DISPLAY_NO are free"
  fi

  local pw
  pw="$(sudo -n passwd -S "$(id -un)" 2>/dev/null </dev/null | awk '{print $2}')" || pw=""
  if [ "$pw" = P ]; then
    PASSWORD_OK=1
    ok "user $(id -un) has a password (it is your workspace's SSH password)"
  else
    caution "user $(id -un) has no password yet: set the workspace's SSH password in DevGrail to sign in"
  fi
}

# --- download and verify -----------------------------------------------------
# The app binary and its support files come from the DevGrail site, through the
# release manifest, and are checked against the release's signature: the same
# signed payload, and the same keys, as DevGrail's own install.sh. The site
# serves this script, the manifest and the bytes alike, so the digests it serves
# catch a corrupted download but not a compromised site; only the signature,
# made with a key that never touches the site, tells those apart. See
# docs/SECURITY.md.

# Public keys this installer trusts to sign a release: install.sh's, kept
# identical by test/installer/desktop_addon_keys_test.sh. Rotation follows
# install.sh's.
release_public_keys() {
  # key id f1f1eaf642454d65 — first embedded in v0.4.0
  cat <<'PUBKEYS'
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEK1NbYrxBM8Ia1iM4BcR0m/8DelN5
ZDvOwKmEOJA24dci3ftqHWwsxzbw8bACRps0rIOTqa45wblm6260fQVdIw==
-----END PUBLIC KEY-----
PUBKEYS
  # An operator running their own registry (or the tests) can add keys. This
  # grants trust rather than removing it.
  if [ -n "${DEVGRAIL_RELEASE_PUBKEY_FILE:-}" ] && [ -f "$DEVGRAIL_RELEASE_PUBKEY_FILE" ]; then
    cat "$DEVGRAIL_RELEASE_PUBKEY_FILE"
  fi
}

RELEASE_MANIFEST=""
RELEASE_VERSION=""
# The verified payload, once there is one; empty when nothing was verified.
SIGNED_PAYLOAD=""
ALLOW_UNSIGNED=0

unverified() {
  if [ "$ALLOW_UNSIGNED" != 1 ]; then
    die "refusing to install downloads whose signature could not be checked:
  $1
  Set DEVGRAIL_ALLOW_UNSIGNED=1 to install anyway, trusting $WEB and nothing else."
  fi
  note "unverified: $1"
}

resolve_release() {
  local body
  body="$(curl -fsSL "$WEB/api/registry/releases/$RELEASE" 2>&"$LOG_FD")" || body=""
  if [ -z "$body" ]; then
    # --version must never quietly install something else.
    [ "$RELEASE" = current ] || die "release $RELEASE is not published at $WEB."
    unverified "$WEB publishes no release manifest"
    return 0
  fi
  RELEASE_MANIFEST="$body"
  RELEASE_VERSION="$(printf '%s' "$body" | jq -r '.release_version // empty' 2>/dev/null)" || RELEASE_VERSION=""
  [ -n "$RELEASE_VERSION" ] || die "the release manifest from $WEB names no release."
  note "release $RELEASE_VERSION"
}

# verify_signature checks the manifest's signature against the keys above, and
# that the signed payload names this release; then SIGNED_PAYLOAD is the
# payload, whose lines each download must match.
verify_signature() {
  local sig alg kid pem verified=0
  sig="$(printf '%s' "$RELEASE_MANIFEST" | jq -c '.signature // empty' 2>/dev/null)" || sig=""
  if [ -z "$sig" ] || [ "$sig" = null ]; then
    unverified "release $RELEASE_VERSION was published without a signature"
    return 0
  fi
  alg="$(printf '%s' "$sig" | jq -r '.algorithm // empty')"
  kid="$(printf '%s' "$sig" | jq -r '.key_id // empty')"
  # A scheme this script does not know is not the same as no signature: treating
  # it as unsigned would let anyone downgrade the check by naming one.
  [ "$alg" = ecdsa-p256-sha256 ] ||
    die "release $RELEASE_VERSION is signed with '$alg', which this installer cannot check."

  mkdir "$WORK/sig"
  # -j, not -r: the signature is over exactly these bytes, and -r would add a
  # newline the signer never saw.
  printf '%s' "$sig" | jq -j '.payload' >"$WORK/sig/payload"
  printf '%s' "$sig" | jq -r '.signature' | base64 -d >"$WORK/sig/sig" 2>/dev/null ||
    die "the signature on release $RELEASE_VERSION is not valid base64."
  release_public_keys >"$WORK/sig/keys.pem"
  # One file per key; any one verifying is enough, which is what lets a key be
  # rotated by shipping two for a while.
  awk -v d="$WORK/sig" '/-----BEGIN PUBLIC KEY-----/ { n++ } n > 0 { print > (d "/key" n ".pem") }' "$WORK/sig/keys.pem"
  for pem in "$WORK"/sig/key*.pem; do
    [ -f "$pem" ] || continue
    if openssl dgst -sha256 -verify "$pem" -signature "$WORK/sig/sig" "$WORK/sig/payload" >/dev/null 2>&1; then
      verified=1
      break
    fi
  done
  [ "$verified" = 1 ] || die "the signature on release $RELEASE_VERSION does not verify against any key this
  installer trusts (signing key id: ${kid:-unknown}). That is a corrupted download or a
  tampered release, and there is no telling which from here. Nothing has been changed."
  # A genuine signature over another release proves nothing about this one: an
  # old signed release must not be replayable as the current one.
  grep -qxF devgrail-release-signature-v1 "$WORK/sig/payload" ||
    die "the signature on release $RELEASE_VERSION verifies, but its payload is in a format this installer does not know."
  grep -qxF "release $RELEASE_VERSION" "$WORK/sig/payload" ||
    die "the signature verifies, but it was made for another release than $RELEASE_VERSION."
  SIGNED_PAYLOAD="$WORK/sig/payload"
  note "release $RELEASE_VERSION signature verified (key ${kid:-unknown})"
}

# release_asset <slug> <field> is one field of one asset in the manifest.
release_asset() {
  printf '%s' "$RELEASE_MANIFEST" | jq -r --arg s "$1" --arg f "$2" '.assets[]? | select(.slug == $s) | .[$f] // empty' 2>/dev/null || true
}

# fetch <slug> <dest> downloads one of the release's assets and checks it
# against the digest the manifest gives, then against the signed payload.
# Without a manifest (allowed only unsigned, for development) it comes from
# $WEB/install/<slug>, unchecked.
fetch() {
  local slug="$1" dest="$2" url="" ver="" sha="" got
  if [ -n "$RELEASE_MANIFEST" ]; then
    url="$(release_asset "$slug" download_url)"
    ver="$(release_asset "$slug" asset_version)"
    sha="$(release_asset "$slug" sha256)"
    [ -n "$url" ] && [ -n "$ver" ] && [ -n "$sha" ] ||
      die "release $RELEASE_VERSION at $WEB does not include $slug: either it predates
  the desktop add-on, or it was published without this architecture."
  else
    url="$WEB/install/$slug"
  fi
  note "fetch $slug from $url"
  curl -fsSL --retry 3 "$url" -o "$dest" 2>&"$LOG_FD" || die "cannot download $slug from $url."
  got="$(sha256_of "$dest")"
  if [ -n "$sha" ] && [ "$got" != "$sha" ]; then
    die "$slug does not match its published SHA-256 (expected $sha, got $got)."
  fi
  # The check that survives a compromised site: bytes that match the digest it
  # served are not enough unless the signed release names that digest.
  if [ -n "$SIGNED_PAYLOAD" ] && ! grep -qxF "asset $slug $ver $got" "$SIGNED_PAYLOAD"; then
    die "$slug is not what release $RELEASE_VERSION was signed for: its digest
  $got is not in the signed payload. Nothing has been changed."
  fi
}

ADDON_VERSION=""

download() {
  case "$WEB" in
    *__DEVGRAIL_WEB""_URL__* | "")
      die "set DEVGRAIL_WEB_URL to the DevGrail site to download the desktop from." ;;
  esac
  case "${DEVGRAIL_ALLOW_UNSIGNED:-0}" in 1 | yes | true) ALLOW_UNSIGNED=1 ;; esac

  say ""
  begin "Downloading the desktop app"
  WORK="$(mktemp -d "${TMPDIR:-/tmp}/devgrail-desktop.XXXXXX")"
  resolve_release
  [ -z "$RELEASE_MANIFEST" ] || verify_signature
  fetch "devgrail-desktop-linux-$ARCH" "$WORK/devgrail-desktop"
  fetch devgrail-desktop-files.tar.gz "$WORK/files.tar.gz"
  chmod 0755 "$WORK/devgrail-desktop"
  ADDON_VERSION="$("$WORK/devgrail-desktop" version </dev/null 2>/dev/null)" ||
    die "the downloaded app does not run on this $ARCH system."
  mkdir "$WORK/files"
  tar -xzf "$WORK/files.tar.gz" -C "$WORK/files" 2>&"$LOG_FD" || die "the support files did not unpack."
  local f
  for f in install.sh libexec/session libexec/audio share/desktop-audio.pa share/home; do
    [ -e "$WORK/files/$f" ] || die "the support files have no $f."
  done
  rm -f "$WORK/files.tar.gz"
  if [ -n "$SIGNED_PAYLOAD" ]; then
    finish "version $ADDON_VERSION, verified (release signature + SHA-256)"
  else
    finish "version $ADDON_VERSION, not verified (DEVGRAIL_ALLOW_UNSIGNED=1)"
  fi
}

# --- Mozilla repo and the dry run ---------------------------------------------

PACKAGES=()

package_list() {
  # The default image's desktop list, without websockify and supervisor (the
  # app does their jobs), without the libraries XFCE and Firefox pull in
  # anyway (whose names changed with Ubuntu's 64-bit time_t move, so leaving
  # them out keeps one list for every release), and without libnss3 and
  # libgbm1, which nothing here needs.
  PACKAGES=(
    xfce4 xfce4-terminal thunar yaru-theme-gtk yaru-theme-icon librsvg2-common
    fonts-ubuntu fonts-jetbrains-mono fonts-noto-color-emoji
    dbus-x11 tigervnc-standalone-server tigervnc-common
    xclip x11-xserver-utils xdg-utils tzdata
    pulseaudio pulseaudio-utils libasound2-plugins
    gstreamer1.0-plugins-base gstreamer1.0-pulseaudio python3-gi gir1.2-gstreamer-1.0
  )
  [ "$RDP" = 1 ] && PACKAGES+=(xrdp)
  [ "$CJK" = 1 ] && PACKAGES+=(fonts-noto-cjk)
  [ "$FIREFOX" = 1 ] && PACKAGES+=(firefox)
  return 0
}

# other_mozilla_source says whether apt already has Mozilla's repo from
# somewhere other than this installer. A second entry for it with another key
# would make apt refuse both.
other_mozilla_source() {
  grep -rlsF packages.mozilla.org /etc/apt/sources.list /etc/apt/sources.list.d 2>/dev/null |
    grep -vxF "$MOZILLA_LIST" | grep -q .
}

add_mozilla_repo() {
  # Ubuntu's own firefox package only installs the snap, and snaps do not run
  # in a container. The pin keeps it away; Mozilla's repo has the real one.
  local pin="$WORK/mozilla.pin"
  printf 'Package: firefox\nPin: release o=Ubuntu\nPin-Priority: -1\n\nPackage: *\nPin: origin packages.mozilla.org\nPin-Priority: 1000\n' >"$pin"
  if ! as_root test -e "$MOZILLA_PIN"; then
    MOZILLA_ADDED_NOW="$MOZILLA_ADDED_NOW $MOZILLA_PIN"
  fi
  root_install 0644 "$pin" "$MOZILLA_PIN"
  if other_mozilla_source; then
    note "Mozilla's repo is configured already; using it"
    return 0
  fi
  # apt trusts the downloaded key only if it is exactly Mozilla's published
  # one: one key, with that fingerprint. Whoever could change the download
  # could otherwise add a key of their own and sign packages apt then trusts.
  # --show-keys and --dearmor need no keyring, so no gpg-agent is started:
  # in a container nothing would reap it when it exits.
  local keys fpr
  curl -fsSL --retry 3 https://packages.mozilla.org/apt/repo-signing-key.gpg -o "$WORK/mozilla-key.asc" 2>&"$LOG_FD" ||
    die "cannot download the signing key of Mozilla's apt repo."
  keys="$(GNUPGHOME="$WORK" gpg --batch --show-keys --with-colons "$WORK/mozilla-key.asc" </dev/null 2>&"$LOG_FD")" ||
    die "the signing key of Mozilla's apt repo could not be read."
  fpr="$(printf '%s\n' "$keys" | awk -F: '$1 == "fpr" { print $10; exit }')"
  if [ "$(printf '%s\n' "$keys" | grep -c '^pub:')" != 1 ] || [ "$fpr" != "$MOZILLA_FPR" ]; then
    die "Mozilla's apt repo key is not the one this installer expects ($MOZILLA_FPR, alone); it has ${fpr:-none}."
  fi
  GNUPGHOME="$WORK" gpg --batch --yes --dearmor -o "$WORK/mozilla.gpg" "$WORK/mozilla-key.asc" </dev/null 2>&"$LOG_FD" ||
    die "the signing key of Mozilla's apt repo could not be converted for apt."
  rm -f "$WORK/mozilla-key.asc"
  as_root test -e "$MOZILLA_KEYRING" || MOZILLA_ADDED_NOW="$MOZILLA_ADDED_NOW $MOZILLA_KEYRING"
  as_root test -e "$MOZILLA_LIST" || MOZILLA_ADDED_NOW="$MOZILLA_ADDED_NOW $MOZILLA_LIST"
  root_install 0644 "$WORK/mozilla.gpg" "$MOZILLA_KEYRING"
  printf 'deb [signed-by=%s] https://packages.mozilla.org/apt mozilla main\n' "$MOZILLA_KEYRING" >"$WORK/mozilla.list"
  root_install 0644 "$WORK/mozilla.list" "$MOZILLA_LIST"
}

check_packages() {
  package_list
  begin "Checking the packages"
  apt_lists_present && LISTS_BEFORE=1 || LISTS_BEFORE=0
  APT_TOUCHED=1
  if [ "$FIREFOX" = 1 ]; then
    add_mozilla_repo
  fi
  if ! quietly as_root apt-get update; then
    die "apt-get update failed:
$(log_tail 2>&1)"
  fi
  # The compatibility check: apt answers for every package on this release,
  # and names the one it cannot install.
  local out
  if ! out="$(as_root apt-get install -s --no-install-recommends "${PACKAGES[@]}" </dev/null 2>&1)"; then
    note "$out"
    die "this system cannot take the desktop; apt says:
$(printf '%s\n' "$out" | grep -E '^(E:| )' | head -n 20 | sed 's/^/    /')"
  fi
  note "$out"
  local count
  count="$(printf '%s\n' "$out" | grep -c '^Inst ' || true)"
  if [ "$count" -gt 0 ]; then
    finish "all available ($count to install or upgrade)"
  else
    finish "all available, all installed"
  fi
}

# --- apt ---------------------------------------------------------------------

install_packages() {
  local before after
  begin "Installing XFCE, sound$([ "$FIREFOX" = 1 ] && printf ', Firefox')$([ "$RDP" = 1 ] && printf ' and RDP')"
  # No service is started from a package's install script: xrdp's would
  # listen on every address. The images block that already; this makes sure.
  if ! [ -e /usr/sbin/policy-rc.d ]; then
    printf '#!/bin/sh\nexit 101\n' >"$WORK/policy-rc.d"
    root_install 0755 "$WORK/policy-rc.d" /usr/sbin/policy-rc.d
    TEMP_POLICY_RC=1
  fi
  before="$WORK/packages.before"
  after="$WORK/packages.after"
  installed_packages >"$before"
  # From here on the repo stays, whatever happens: it is what Firefox, once
  # installed, updates from. It is recorded first, so uninstall finds it even
  # after a failed install.
  MOZILLA_ADDED_NOW=""
  as_root install -d -m 0755 "$ETC"
  if [ "$FIREFOX" = 1 ]; then
    take_over "$MOZILLA_PIN"
    if as_root test -e "$MOZILLA_LIST"; then
      take_over "$MOZILLA_KEYRING"
      take_over "$MOZILLA_LIST"
    fi
  fi
  # confold: a re-run keeps the xrdp config this installer edited instead of
  # stopping at dpkg's question about it.
  local status=0
  quietly as_root env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
    -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold "${PACKAGES[@]}" || status=$?
  # What did get installed is recorded either way, for --purge-packages.
  installed_packages >"$after"
  {
    if [ -f "$PACKAGES_ADDED" ]; then cat "$PACKAGES_ADDED"; fi
    LC_ALL=C comm -13 "$before" "$after"
  } | LC_ALL=C sort -u >"$WORK/packages-added"
  root_install 0644 "$WORK/packages-added" "$PACKAGES_ADDED"
  if [ "$status" -ne 0 ]; then
    die "apt-get install failed:
$(log_tail 2>&1)"
  fi
  finish
}

# --- configure ---------------------------------------------------------------

configure_xrdp() {
  local ini=/etc/xrdp/xrdp.ini sesman=/etc/xrdp/sesman.ini src
  take_over "$ini"
  src="$ETC/orig$ini"
  as_root test -e "$src" || src="$ini"
  # The image's edits, with the add-on's ports: xrdp on loopback only (the SSH
  # tunnel reaches it there), straight into the one session, and every other
  # session type gone.
  as_root cat "$src" | sed -e "s|^port=3389\$|port=tcp://127.0.0.1:$RDP_PORT|" \
    -e 's/^autorun=$/autorun=devgrail/' -e '/^\[Xorg\]/,$d' >"$WORK/xrdp.ini"
  if ! grep -qxF "port=tcp://127.0.0.1:$RDP_PORT" "$WORK/xrdp.ini" || ! grep -qx 'autorun=devgrail' "$WORK/xrdp.ini"; then
    die "$ini does not look like the xrdp config this installer knows how to change (no port=3389 or autorun= line).
  Install with --no-rdp, or put the package's own $ini back and run this again."
  fi
  printf '\n[devgrail]\nname=DevGrail shared desktop\nlib=libvnc.so\nip=127.0.0.1\nport=%s\nusername=na\npassword=na\npamusername=ask\npampassword=ask\npamsessionmng=127.0.0.1\n' \
    "$VNC_PORT" >>"$WORK/xrdp.ini"
  root_install 0644 "$WORK/xrdp.ini" "$ini"

  # xrdp 0.9's sesman listens on TCP 3350, on every address; xrdp finds it
  # through the same file.
  src="$ETC/orig$sesman"
  as_root test -e "$src" || src="$sesman"
  if as_root grep -qx 'ListenPort=3350' "$src"; then
    if ! { [ "$OURS_RUNNING" = 1 ] && managed "$sesman"; } && port_listening "$SESMAN_PORT"; then
      die "port $SESMAN_PORT, which xrdp's session manager needs here, is taken by another program. Install with --no-rdp."
    fi
    take_over "$sesman"
    as_root cat "$ETC/orig$sesman" | sed -e "s/^ListenPort=3350\$/ListenPort=$SESMAN_PORT/" \
      -e 's/^ListenAddress=.*$/ListenAddress=127.0.0.1/' >"$WORK/sesman.ini"
    root_install 0644 "$WORK/sesman.ini" "$sesman"
  fi
}

configure() {
  begin "Configuring the app, $([ "$RDP" = 1 ] && printf 'xrdp, ')$([ "$FIREFOX" = 1 ] && printf 'Firefox, ')the sound and the XFCE defaults"
  local new="$SHARE.new" f rel

  # The binary is replaced by a rename: the running app keeps its own file.
  root_install 0755 "$WORK/devgrail-desktop" "$BIN.new"
  as_root mv -f "$BIN.new" "$BIN"

  as_root rm -rf "$new"
  as_root mkdir -p "$new"
  as_root cp -r "$WORK/files/." "$new/"
  as_root chown -R root:root "$new"
  as_root find "$new" -type d -exec chmod 0755 {} +
  as_root find "$new" -type f -exec chmod 0644 {} +
  as_root find "$new/libexec" -type f -exec chmod 0755 {} +
  as_root chmod 0755 "$new/install.sh"
  as_root rm -rf "$SHARE.old"
  if as_root test -d "$SHARE"; then as_root mv "$SHARE" "$SHARE.old"; fi
  as_root mv "$new" "$SHARE"
  as_root rm -rf "$SHARE.old"

  if [ "$RDP" = 1 ]; then
    configure_xrdp
  else
    for f in /etc/xrdp/xrdp.ini /etc/xrdp/sesman.ini; do
      if managed "$f"; then give_back "$f"; fi
    done
  fi

  if [ "$FIREFOX" = 1 ]; then
    # No first-run pages, and no smooth scrolling: every frame of a scroll
    # animation is a screen update sent to the viewer.
    take_over /etc/firefox/policies/policies.json
    root_install 0644 "$SHARE/share/firefox-policies.json" /etc/firefox/policies/policies.json
  elif managed /etc/firefox/policies/policies.json; then
    give_back /etc/firefox/policies/policies.json
  fi

  take_over /etc/pulse/client.conf.d/99-devgrail.conf
  root_install 0644 "$SHARE/share/pulse-client.conf" /etc/pulse/client.conf.d/99-devgrail.conf

  # A browser that an agent opens from the terminal lands on the desktop. An
  # SSH session with X forwarding keeps its own DISPLAY.
  # shellcheck disable=SC2016 # expanded by the shell that reads the file
  printf '# The DevGrail desktop add-on'"'"'s display, for programs started from a shell.\n[ -n "${DISPLAY:-}" ] || export DISPLAY=:%s\n' "$DISPLAY_NO" >"$WORK/profile.sh"
  take_over /etc/profile.d/devgrail-desktop.sh
  root_install 0644 "$WORK/profile.sh" /etc/profile.d/devgrail-desktop.sh

  # The XFCE defaults are the user's own settings: copied only where the user
  # has none yet, so a re-run never undoes a change.
  (cd "$SHARE/share/home" && find . -type f) | while read -r rel; do
    rel="${rel#./}"
    if [ ! -e "$HOME/$rel" ]; then
      install -D -m 0644 "$SHARE/share/home/$rel" "$HOME/$rel"
    fi
  done

  {
    printf '{\n'
    printf '  "port": %s,\n' "$PORT"
    printf '  "vnc_port": %s,\n' "$VNC_PORT"
    printf '  "rdp_port": %s,\n' "$RDP_PORT"
    printf '  "display": %s,\n' "$DISPLAY_NO"
    printf '  "rdp": %s,\n' "$(bool "$RDP")"
    printf '  "firefox": %s,\n' "$(bool "$FIREFOX")"
    printf '  "cjk_fonts": %s,\n' "$(bool "$CJK")"
    printf '  "web_url": %s\n' "$(json_str "$WEB")"
    printf '}\n'
  } >"$WORK/config.json"
  root_install 0644 "$WORK/config.json" "$CONFIG"

  # What the viewer's About shows, and what a support question starts from.
  {
    printf '{\n'
    printf '  "version": %s,\n' "$(json_str "$ADDON_VERSION")"
    printf '  "arch": %s,\n' "$(json_str "$ARCH")"
    printf '  "installed_at": %s' "$(json_str "$(date -u +%Y-%m-%dT%H:%M:%SZ)")"
    local key pkg v
    for key in xfce4:xfce4 firefox:firefox tigervnc:tigervnc-standalone-server xrdp:xrdp; do
      pkg="${key#*:}"
      v="$(installed_version "$pkg")"
      [ -n "$v" ] && printf ',\n  "%s": %s' "${key%%:*}" "$(json_str "$v")"
    done
    printf '\n}\n'
  } >"$WORK/record.json"
  root_install 0644 "$WORK/record.json" "$RECORD"
  finish
}

# --- boot block --------------------------------------------------------------
# The workspace runs /workspace/.devgrail/startup.sh on every container start,
# before its terminal comes up. The block starts the app there, in the
# background; the app decides whether to start the desktop.

strip_block() {
  awk -v b="$BLOCK_BEGIN" -v e="$BLOCK_END" '$0 == b { skip = 1 } !skip { print } $0 == e { skip = 0 }' "$1"
}

BOOT_NOTE=""

install_boot_block() {
  if [ ! -d /workspace ]; then
    BOOT_NOTE="There is no /workspace here, so the desktop does not start by itself when the container does: run devgrail-desktop start."
    return 0
  fi
  mkdir -p "${STARTUP%/*}"
  if [ -e "$STARTUP" ]; then
    strip_block "$STARTUP" >"$WORK/startup.sh"
    # An empty line between the user's part and the block, but not one more
    # on every re-run.
    [ -n "$(tail -n 1 "$WORK/startup.sh")" ] && printf '\n' >>"$WORK/startup.sh"
  else
    printf '#!/bin/bash\n\n' >"$WORK/startup.sh"
    as_root touch "$CREATED_HOOK"
  fi
  cat >>"$WORK/startup.sh" <<EOF
$BLOCK_BEGIN
# The DevGrail desktop add-on starts with the workspace. Its installer wrote
# this block, and \`devgrail-desktop uninstall\` removes it.
if [ -x $BIN ]; then
    $BIN start --background || true
else
    echo "devgrail-desktop: the desktop add-on is not installed in this container any more (re-adding or restoring the workspace removes it). Install it again with: curl -fsSL $WEB/desktop/install.sh | bash"
fi
$BLOCK_END
EOF
  if [ -e "$STARTUP" ]; then
    cat "$WORK/startup.sh" >"$STARTUP" || {
      BOOT_NOTE="$STARTUP could not be written, so the desktop does not start by itself when the container does."
      return 0
    }
  else
    install -m 0755 "$WORK/startup.sh" "$STARTUP"
  fi
  if [ ! -x "$STARTUP" ]; then
    BOOT_NOTE="$STARTUP is not executable, so the workspace does not run it and the desktop does not start by itself. chmod +x it if everything in it should run."
  fi
}

# --- start -------------------------------------------------------------------

TURNED_OFF=0

start_app() {
  local out verb=Starting
  app_running && verb=Restarting
  begin "$verb the desktop"
  # The installer's own settings are no business of the desktop's programs,
  # which get the workspace's DEVGRAIL_* variables.
  # Nor are its lock and log: the app outlives this run, and would otherwise
  # hold the lock against every later one.
  local -a env_clean=(env -u DEVGRAIL_WEB_URL -u DEVGRAIL_ALLOW_UNSIGNED -u DEVGRAIL_RELEASE_PUBKEY_FILE)
  if [ "$verb" = Restarting ]; then
    out="$("${env_clean[@]}" "$BIN" restart </dev/null 2>&1 {LOCK_FD}>&- {LOG_FD}>&-)" || die "the app did not start: $out"
  else
    out="$("${env_clean[@]}" "$BIN" start </dev/null 2>&1 {LOCK_FD}>&- {LOG_FD}>&-)" || die "the app did not start: $out"
  fi
  note "$out"
  if grep -q '"off": *true' "$STATE_DIR/state.json" 2>/dev/null; then
    TURNED_OFF=1
    finish "running on port $PORT; the desktop is turned off"
  else
    finish "ready on port $PORT"
  fi
}

next_steps() {
  say ""
  say "  Next:"
  say "   1. In DevGrail, open this workspace → Subdomains → add one for port $PORT."
  say "   2. Open https://<that subdomain>/ and sign in with the workspace's SSH password."
  if [ "$PASSWORD_OK" != 1 ]; then
    say "      This workspace has no SSH password yet: set one in DevGrail first."
  fi
  if [ "$TURNED_OFF" = 1 ]; then
    say "   The desktop was turned off in the viewer and stays off: sign in and choose Turn on."
  fi
  if [ "$RDP" = 1 ]; then
    say "   RDP: ssh -L $RDP_PORT:localhost:$RDP_PORT -p <ssh-port> $(id -un)@<host>, then connect to localhost:$RDP_PORT."
  fi
  if [ -n "$BOOT_NOTE" ]; then
    say ""
    say "  Note: $BOOT_NOTE"
  fi
  say ""
  say "  devgrail-desktop status | start | stop | restart | uninstall"
}

install_main() {
  open_log "$@"
  trap cleanup EXIT
  trap 'FAILED_LINE=$LINENO' ERR
  say "DevGrail Desktop installer"
  settle_choices
  preflight
  download
  check_packages
  install_packages
  configure
  install_boot_block
  start_app
  cleanup
  next_steps
}

# --- uninstall ---------------------------------------------------------------

# purge_list is the recorded packages that are still installed.
purge_list() {
  [ -f "$PACKAGES_ADDED" ] || return 0
  LC_ALL=C comm -12 <(LC_ALL=C sort -u "$PACKAGES_ADDED") <(installed_packages)
}

# check_purge refuses, before anything changes, a purge that would take
# packages this installer did not add with it: something installed since
# depends on them.
check_purge() {
  local out extra
  [ -n "$(purge_list)" ] || return 0
  # shellcheck disable=SC2046 # one package a word
  out="$(as_root apt-get -s purge $(purge_list) </dev/null 2>&1)" || die "apt cannot remove the packages:
$(printf '%s\n' "$out" | grep '^E:' | sed 's/^/    /')"
  extra="$(LC_ALL=C comm -13 <(purge_list | sed 's/:.*//' | LC_ALL=C sort -u) \
    <(printf '%s\n' "$out" | awk '$1 == "Purg" || $1 == "Remv" {print $2}' | sed 's/:.*//' | LC_ALL=C sort -u))"
  if [ -n "$extra" ]; then
    die "removing the desktop's packages would also remove these, which the installer did not add:
$(printf '%s\n' "$extra" | sed 's/^/    /')
  Remove them first, or uninstall without --purge-packages. Nothing has been changed."
  fi
}

uninstall_main() {
  open_log "$@"
  trap cleanup EXIT
  trap 'FAILED_LINE=$LINENO' ERR
  WORK="$(mktemp -d "${TMPDIR:-/tmp}/devgrail-desktop.XXXXXX")"
  say "DevGrail Desktop: uninstall"
  [ "$(id -u)" -ne 0 ] || die "run this as the workspace user, not as root."
  sudo -n true 2>/dev/null </dev/null || die "sudo asks for a password here."
  # The copy kept in $SHARE was never served by the site, so it does not know
  # the site's address; the config does.
  case "$WEB" in *__DEVGRAIL_WEB""_URL__*) WEB="$(json_value web_url "$CONFIG")" ;; esac
  [ "$PURGE" = 1 ] && check_purge

  if [ -x "$BIN" ]; then
    begin "Stopping the desktop"
    quietly "$BIN" stop || die "the app did not stop."
    finish
  fi

  if [ -f "$STARTUP" ] && grep -qxF "$BLOCK_BEGIN" "$STARTUP"; then
    strip_block "$STARTUP" >"$WORK/startup.sh"
    if [ -e "$CREATED_HOOK" ] && ! grep -qv -e '^#!/bin/bash$' -e '^[[:space:]]*$' "$WORK/startup.sh"; then
      rm -f "$STARTUP"
    else
      # Without the empty lines the installer left at the end.
      awk '{ line[NR] = $0; if ($0 != "") last = NR } END { for (i = 1; i <= last; i++) print line[i] }' \
        "$WORK/startup.sh" >"$WORK/startup.trim"
      cat "$WORK/startup.trim" >"$STARTUP"
    fi
    ok "removed the boot block from $STARTUP"
  fi

  # Firefox without --purge-packages stays installed, so the repo it updates
  # from stays too.
  local keep_mozilla=0 path
  if [ "$PURGE" != 1 ] && [ -n "$(installed_version firefox)" ]; then
    keep_mozilla=1
  fi
  if [ -f "$MANAGED" ]; then
    # A copy: give_back rewrites the list as it goes.
    cp "$MANAGED" "$WORK/managed.all"
    while read -r path; do
      [ -n "$path" ] || continue
      case "$path" in
        "$MOZILLA_KEYRING" | "$MOZILLA_LIST" | "$MOZILLA_PIN") [ "$keep_mozilla" = 1 ] && continue ;;
      esac
      give_back "$path"
    done <"$WORK/managed.all"
    ok "put back the configuration files it changed"
  fi

  if [ "$PURGE" = 1 ] && [ -n "$(purge_list)" ]; then
    begin "Removing the packages it installed"
    APT_TOUCHED=1
    # shellcheck disable=SC2046 # one package a word
    quietly as_root env DEBIAN_FRONTEND=noninteractive apt-get purge -y $(purge_list) ||
      die "apt-get purge failed:
$(log_tail 2>&1)"
    finish
  fi
  if [ "$PURGE" = 1 ]; then
    # Ours by name, and no longer listed when an earlier uninstall kept them
    # for the Firefox it left.
    as_root rm -f "$MOZILLA_KEYRING" "$MOZILLA_LIST" "$MOZILLA_PIN"
  fi

  # Packages that stay keep their record, alone in $ETC: a reinstall adds to
  # it, and a later --purge-packages (from the install command, once the app
  # is gone) still knows what to remove.
  local keep_record=0
  if [ "$PURGE" != 1 ] && [ -n "$(purge_list)" ]; then
    keep_record=1
    cp "$PACKAGES_ADDED" "$WORK/packages-added"
  fi
  as_root rm -rf "$BIN" "$SHARE" "$SHARE.new" "$SHARE.old" "$ETC" "$RECORD"
  if [ "$keep_record" = 1 ]; then
    root_install 0644 "$WORK/packages-added" "$PACKAGES_ADDED"
  fi
  ok "removed the app and its files"
  say ""
  if [ "$keep_record" = 1 ]; then
    say "  The packages it installed are still here$([ "$keep_mozilla" = 1 ] && printf "; Firefox keeps updating from Mozilla's repo").
  To remove them too: curl -fsSL ${WEB:-https://<devgrail-web>}/desktop/install.sh | bash -s -- uninstall --purge-packages
  ($PACKAGES_ADDED lists them.)"
  fi
  say "  Your XFCE settings in ~/.config are yours and stay. The DevGrail desktop add-on is uninstalled."
  # The log and the lock go last, with the state directory: nothing is left.
  rm -rf "$STATE_DIR"
}

main() {
  parse_args "$@"
  if [ "$MODE" = uninstall ]; then
    uninstall_main "$@"
  else
    install_main "$@"
  fi
}

main "$@"
