#!/bin/sh
# Installs the Factory desktop app for the current user from the portable
# release tarball. Produces the layout the in-app updater manages
# (apps/desktop/src/main/linuxInstall.ts):
#
#   $XDG_DATA_HOME/<executable>/versions/<version>/
#   $XDG_DATA_HOME/<executable>/current -> versions/<version>
#
# plus a ~/.local/bin launcher and a desktop entry pointing through `current`.
# A version whose executable is already installed is reused, never replaced:
# rerunning for it switches `current` to it and refreshes the launcher and
# desktop entry; `--uninstall` first to replace it. The in-app installer knows
# which version it runs and prunes the rest; this script cannot know what is
# running, so it never replaces a launchable version directory.
set -eu

DOWNLOADS_URL="${FACTORY_DOWNLOADS_URL:-https://downloads.factory.ai}"
# Signs every release checksum file; FACTORY_LINUX_UPDATE_PUBLIC_KEY in
# apps/desktop/src/main/constants.ts embeds the same key.
RELEASE_PUBLIC_KEY='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEA+gXLwGYSht7swpkAHLqbZkicX/6QnEeVS9dG2MkCIzE=
-----END PUBLIC KEY-----'

usage() {
  cat <<'EOF'
Usage: install.sh [--version <version>] [--uninstall]

Installs the Factory desktop app into ~/.local/share and keeps it updated
from inside the app.

Options:
  --version <ver>    Install a specific version instead of the latest.
  --uninstall        Remove the installed app, launcher, and desktop entry.
  -h, --help         Show this help.
EOF
}

fail() {
  echo "error: $*" >&2
  exit 1
}

EXECUTABLE=factory-desktop
VERSION=
UNINSTALL=false
while [ $# -gt 0 ]; do
  case "$1" in
    --version) VERSION="${2:?--version requires a value}"; shift 2 ;;
    --version=*) VERSION="${1#*=}"; shift ;;
    --uninstall) UNINSTALL=true; shift ;;
    -h|--help) usage; exit 0 ;;
    *) echo "error: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
  esac
done

DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
ROOT="$DATA_HOME/$EXECUTABLE"
BIN_LINK="$HOME/.local/bin/$EXECUTABLE"
APPLICATIONS_DIR="$DATA_HOME/applications"
DESKTOP_ENTRY="$APPLICATIONS_DIR/$EXECUTABLE.desktop"

# Sign-in credentials need a Secret Service provider on the session bus,
# running or D-Bus activatable. Without dbus-send or a reachable session bus
# (an SSH install) there is nothing to judge, so no warning.
secret_service_missing() {
  command -v dbus-send >/dev/null 2>&1 || return 1
  set -- --session --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus
  names=$(dbus-send "$@" org.freedesktop.DBus.ListNames 2>/dev/null) || return 1
  activatable=$(dbus-send "$@" org.freedesktop.DBus.ListActivatableNames 2>/dev/null) || true
  case "$names$activatable" in
    *'"org.freedesktop.secrets"'*) return 1 ;;
  esac
}

refresh_desktop_database() {
  if command -v update-desktop-database >/dev/null 2>&1; then
    update-desktop-database "$APPLICATIONS_DIR" >/dev/null 2>&1 || true
  fi
}

# The in-app updater (apps/desktop/src/main/linuxInstall.ts) takes the same
# lock before touching the install root; a contender is refused, never queued.
lock_install_root() {
  command -v flock >/dev/null 2>&1 || fail "'flock' is required"
  exec 9>"$ROOT/.install.lock"
  flock -n 9 || fail "another $EXECUTABLE install is running; wait for it to finish and try again"
  # A holder can only unlink the lock while it owns it, so a different inode
  # at the path means an uninstall replaced the root between open and lock.
  # -ef is not in POSIX but dash, bash, busybox sh, and ksh all implement it.
  # shellcheck disable=SC3013
  [ "$ROOT/.install.lock" -ef /dev/fd/9 ] ||
    fail "the $EXECUTABLE install changed while locking it; try again"
}

if [ "$UNINSTALL" = true ]; then
  # Always lock, even when nothing is installed, so a root appearing right
  # now cannot lose its launcher and entry to an unlocked removal.
  mkdir -p "$ROOT"
  lock_install_root
  # .prepared holds the in-app updater's next release.
  rm -rf "$ROOT/versions" "$ROOT/current" "$ROOT"/.staging-* "$ROOT/.prepared"
  rm -f "$BIN_LINK" "$DESKTOP_ENTRY"
  # The lock goes last so no contender can start before everything else is
  # gone; a root that is non-empty afterwards belongs to a newer install.
  rm -f "$ROOT/.install.lock"
  rmdir "$ROOT" 2>/dev/null || true
  refresh_desktop_database
  echo "Uninstalled $EXECUTABLE. Settings in ~/.config remain in place."
  exit 0
fi

[ "$(uname -s)" = Linux ] || fail "this installer supports Linux only"
case "$(uname -m)" in
  x86_64|amd64) ARCH=x64 ;;
  aarch64|arm64) ARCH=arm64 ;;
  *) fail "unsupported architecture '$(uname -m)'" ;;
esac
for tool in curl tar sha256sum flock openssl; do
  command -v "$tool" >/dev/null 2>&1 || fail "'$tool' is required"
done
# A per-user install cannot ship the setuid sandbox helper, so Chromium's
# sandbox depends on unprivileged user namespaces.
if [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns 2>/dev/null)" = 1 ]; then
  echo "warning: AppArmor restricts unprivileged user namespaces here, so the app may fail to start. Install the .deb instead." >&2
fi

RELEASES_URL="$DOWNLOADS_URL/factory-desktop"
if [ -z "$VERSION" ]; then
  VERSION=$(curl -fsSL "$RELEASES_URL/LATEST" | tr -d '[:space:]')
  [ -n "$VERSION" ] || fail "could not resolve the latest version"
fi
# VERSION names a directory under versions/, so it must be a release version
# and never a path such as ../current.
case "$VERSION" in
  *[!0-9A-Za-z.+-]* | [!0-9]*) fail "'$VERSION' is not a release version" ;;
esac

TARBALL="$EXECUTABLE-$VERSION-linux-$ARCH.tar.gz"
ARTIFACT_URL="$RELEASES_URL/releases/$VERSION/linux/$ARCH/$TARBALL"

# versions/ is created under the lock so an install that lost the race to an
# uninstall recreates it rather than finding it removed.
mkdir -p "$ROOT"
lock_install_root
mkdir -p "$ROOT/versions"
# The .staging- prefix matches the in-app updater, which prunes leftovers
# from interrupted installs.
STAGING=$(mktemp -d "$ROOT/.staging-XXXXXX")
trap 'rm -rf "$STAGING"' EXIT
# A signal ends the install (releasing the lock); it never resumes.
trap 'exit 130' INT TERM

RELEASE_DIR="$ROOT/versions/$VERSION"
if [ -x "$RELEASE_DIR/$EXECUTABLE" ]; then
  # A launchable version may be running; it is reused, never replaced.
  INSTALLED=false
  if [ -L "$ROOT/current" ] &&
    [ "$(readlink "$ROOT/current")" = "versions/$VERSION" ]; then
    echo "$EXECUTABLE $VERSION is already installed in $ROOT"
  else
    ln -s "versions/$VERSION" "$STAGING/current"
    mv -T "$STAGING/current" "$ROOT/current"
    echo "Switched $EXECUTABLE to installed version $VERSION"
  fi
else
  INSTALLED=true
  echo "Downloading $EXECUTABLE $VERSION ($ARCH)..."
  curl -fL --progress-bar -o "$STAGING/$TARBALL" "$ARTIFACT_URL"
  curl -fsSL -o "$STAGING/$TARBALL.sha256" "$ARTIFACT_URL.sha256"
  curl -fsSL -o "$STAGING/$TARBALL.sha256.sig" "$ARTIFACT_URL.sha256.sig"
  printf '%s\n' "$RELEASE_PUBLIC_KEY" >"$STAGING/release-key.pem"
  # Ed25519 verification through pkeyutl needs OpenSSL 3.
  openssl pkeyutl -verify -pubin -inkey "$STAGING/release-key.pem" -rawin \
    -in "$STAGING/$TARBALL.sha256" -sigfile "$STAGING/$TARBALL.sha256.sig" >/dev/null 2>&1 ||
    fail "signature verification failed for $TARBALL.sha256 (OpenSSL 3 is required)"
  (cd "$STAGING" && sha256sum -c --status "$TARBALL.sha256") ||
    fail "checksum verification failed for $TARBALL"

  mkdir "$STAGING/release"
  tar -xzf "$STAGING/$TARBALL" -C "$STAGING/release"
  [ -x "$STAGING/release/$EXECUTABLE" ] ||
    fail "$TARBALL does not contain the $EXECUTABLE executable"

  # Only an incomplete directory (no executable, checked above) reaches here;
  # it moves into staging for cleanup instead of being deleted in place.
  if [ -e "$RELEASE_DIR" ]; then
    mv -T "$RELEASE_DIR" "$STAGING/previous"
  fi
  mv -T "$STAGING/release" "$RELEASE_DIR"
  ln -s "versions/$VERSION" "$STAGING/current"
  mv -T "$STAGING/current" "$ROOT/current"
fi

LAUNCHER="$ROOT/current/$EXECUTABLE"
mkdir -p "$(dirname "$BIN_LINK")" "$APPLICATIONS_DIR"
ln -sfn "$LAUNCHER" "$BIN_LINK"

[ -f "$RELEASE_DIR/resources/$EXECUTABLE.desktop" ] ||
  fail "$RELEASE_DIR is missing resources/$EXECUTABLE.desktop; run this installer with --uninstall and install again"
# Desktop sessions often lack ~/.local/bin on PATH, so the entry launches
# and draws its icon through absolute paths that survive in-app updates.
# Desktop-entry values unescape string escapes before Exec unquotes its
# arguments, so a quoted argument escapes twice; awk reads the values from
# ENVIRON because -v would apply awk's own escapes too.
EXEC_ARG=$(printf '%s' "$LAUNCHER" | sed -e 's/[\\"`$]/\\&/g' -e 's/\\/\\\\/g' -e 's/%/%%/g')
ICON_PATH=$(printf '%s' "$ROOT/current/resources/icon.png" | sed -e 's/\\/\\\\/g')
EXEC_LINE="Exec=\"$EXEC_ARG\" %U" ICON_LINE="Icon=$ICON_PATH" awk '
  /^Exec=/ { print ENVIRON["EXEC_LINE"]; next }
  /^Icon=/ { print ENVIRON["ICON_LINE"]; next }
  { print }
' "$RELEASE_DIR/resources/$EXECUTABLE.desktop" >"$STAGING/$EXECUTABLE.desktop"
mv "$STAGING/$EXECUTABLE.desktop" "$DESKTOP_ENTRY"
refresh_desktop_database

if [ "$INSTALLED" = true ]; then
  echo "Installed $EXECUTABLE $VERSION to $ROOT"
fi
if secret_service_missing; then
  echo "warning: no Secret Service (org.freedesktop.secrets) is available, so sign-in cannot store credentials. Install and start gnome-keyring, kwallet, or KeePassXC with Secret Service integration." >&2
fi
case ":$PATH:" in
  *":$HOME/.local/bin:"*) ;;
  *) echo "Add ~/.local/bin to your PATH to launch $EXECUTABLE from a terminal." ;;
esac
