#!/usr/bin/env bash set -euo pipefail SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SEMVER_PATTERN='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(beta|rc)\.(0|[1-9][0-9]*))?' SOURCE_VERSION="$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' "${SOURCE_ROOT}/ptlinux/__init__.py")" VERSION="${PTLINUX_VERSION:-$SOURCE_VERSION}" if [[ ! "$VERSION" =~ ^${SEMVER_PATTERN}$ ]] then printf 'Invalid launcher version: %s (expected X.Y.Z, X.Y.Z-beta.N, or X.Y.Z-rc.N)\n' "$VERSION" >&2 exit 2 fi if [[ "$VERSION" != "$SOURCE_VERSION" ]] then printf 'Launcher version %s does not match source version %s.\n' "$VERSION" "$SOURCE_VERSION" >&2 exit 2 fi case "$VERSION" in *-beta.*|*-rc.*) RELEASE_CHANNEL="prerelease" ;; *) RELEASE_CHANNEL="stable" ;; esac DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}" BIN_HOME="${HOME}/.local/bin" APPLICATION_HOME="${DATA_HOME}/applications" PIXMAP_HOME="${DATA_HOME}/pixmaps" ICON_PATH="${PIXMAP_HOME}/eu.pristontale.PTLinux.png" DEFAULT_INSTALL_DIRECTORY="${HOME}/Games/PristontaleEU" LEGACY_INSTALL_DIRECTORY="${DATA_HOME}/pristontale-eu" INSTALL_DIRECTORY_EXPLICIT=0 if [[ -n "${PTLINUX_INSTALL_DIR:-}" ]] then INSTALL_DIRECTORY="$PTLINUX_INSTALL_DIR" INSTALL_DIRECTORY_EXPLICIT=1 else INSTALL_DIRECTORY="$DEFAULT_INSTALL_DIRECTORY" fi PRESERVED_LEGACY_INSTALL=0 PYTHON_BIN="${PTLINUX_PYTHON:-python3}" DRY_RUN=0 RUN_DOCTOR=1 INSTALL_DEPENDENCIES=1 RUN_RUNTIME_BOOTSTRAP=1 usage() { printf '%s\n' \ "Usage: ./install.sh [--install-dir PATH] [--dry-run] [--skip-deps] [--skip-doctor] [--skip-runtime-bootstrap]" \ "" \ " --install-dir PATH Store the app, managed game, and runtime data at PATH." \ " Default: ~/Games/PristontaleEU" \ " --skip-deps Do not install packages or repair host AppArmor profiles." \ " --skip-runtime-bootstrap" \ " Do not prepare the verified UMU helper during install." } while (( $# > 0 )) do case "$1" in --install-dir) if (( $# < 2 )) then printf '%s\n' "--install-dir requires a path." >&2 exit 2 fi INSTALL_DIRECTORY="$2" INSTALL_DIRECTORY_EXPLICIT=1 shift 2 ;; --install-dir=*) INSTALL_DIRECTORY="${1#*=}" INSTALL_DIRECTORY_EXPLICIT=1 shift ;; --dry-run) DRY_RUN=1 shift ;; --skip-doctor) RUN_DOCTOR=0 shift ;; --skip-deps) INSTALL_DEPENDENCIES=0 shift ;; --skip-runtime-bootstrap) RUN_RUNTIME_BOOTSTRAP=0 shift ;; -h|--help) usage exit 0 ;; *) printf 'Unknown option: %s\n' "$1" >&2 usage >&2 exit 2 ;; esac done if (( INSTALL_DIRECTORY_EXPLICIT == 0 )) \ && [[ ! -d "${DEFAULT_INSTALL_DIRECTORY}/app/ptlinux" \ && ! -d "${DEFAULT_INSTALL_DIRECTORY}/app/current/ptlinux" ]] \ && [[ -d "${LEGACY_INSTALL_DIRECTORY}/app/ptlinux" \ || -d "${LEGACY_INSTALL_DIRECTORY}/app/current/ptlinux" \ || -f "${LEGACY_INSTALL_DIRECTORY}/launcher-prefix/drive_c/Program Files (x86)/Pristontale EU/Game.exe" ]] then INSTALL_DIRECTORY="$LEGACY_INSTALL_DIRECTORY" PRESERVED_LEGACY_INSTALL=1 fi if [[ -z "$INSTALL_DIRECTORY" \ || "$INSTALL_DIRECTORY" == *$'\n'* \ || "$INSTALL_DIRECTORY" == *$'\r'* ]] then printf '%s\n' "Install directory is empty or contains invalid characters." >&2 exit 2 fi case "$INSTALL_DIRECTORY" in "~") INSTALL_DIRECTORY="$HOME" ;; "~/"*) INSTALL_DIRECTORY="${HOME}/${INSTALL_DIRECTORY:2}" ;; "~"*) printf '%s\n' "Use an absolute path instead of another user's ~ directory." >&2 exit 2 ;; esac if [[ "$INSTALL_DIRECTORY" != /* ]] then INSTALL_DIRECTORY="${PWD}/${INSTALL_DIRECTORY}" fi if command -v realpath >/dev/null 2>&1 then INSTALL_DIRECTORY="$(realpath -m -- "$INSTALL_DIRECTORY")" fi if [[ "$INSTALL_DIRECTORY" == "/" ]] then printf '%s\n' "Refusing to use the filesystem root as the install directory." >&2 exit 2 fi APP_ROOT="${INSTALL_DIRECTORY}/app" RELEASES_ROOT="${APP_ROOT}/releases" RELEASE_ROOT="${RELEASES_ROOT}/${VERSION}" if [[ ! -d "${SOURCE_ROOT}/ptlinux" \ || ! -f "${SOURCE_ROOT}/ptdiag/assbin.py" \ || ! -f "${SOURCE_ROOT}/ptdiag/__init__.py" \ || ! -f "${SOURCE_ROOT}/ptdiag/logs.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/self_update.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/ui/__init__.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/ui/dashboard.py" \ || ! -f "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.README" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.copyright" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-lazy-output-images.patch" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-live-resize.patch" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-letterbox-backing.patch" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" \ || ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-keep-nested-windows-visible.patch" \ || ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \ || ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ || ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \ || ! -f "${SOURCE_ROOT}/packaging/install-dependencies.sh" ]] then printf '%s\n' "Installer source is incomplete." >&2 exit 1 fi printf '%s\n' "Installing PristonTale EU Linux launcher for the current user" if (( PRESERVED_LEGACY_INSTALL == 1 )) then printf '%s\n' " Existing installation found; preserving its previous data location." fi printf ' Install data: %s\n' "$INSTALL_DIRECTORY" printf ' Application: %s\n' "$APP_ROOT" printf ' Command: %s\n' "${BIN_HOME}/pristontale-eu" printf ' Desktop: %s\n' "${APPLICATION_HOME}/eu.pristontale.PTLinux.desktop" if (( INSTALL_DEPENDENCIES == 1 )) then # shellcheck source=packaging/install-dependencies.sh source "${SOURCE_ROOT}/packaging/install-dependencies.sh" ptlinux_install_host_dependencies "$DRY_RUN" else printf '%s\n' "Skipping host dependency installation." fi if (( DRY_RUN == 1 )) then if (( INSTALL_DEPENDENCIES == 1 )) then printf '%s\n' \ "Dry run: installation would probe Bubblewrap and offer only the guarded Ubuntu AppArmor repair." fi exit 0 fi if ! command -v "$PYTHON_BIN" >/dev/null 2>&1 then printf 'Python was not found after dependency installation: %s\n' "$PYTHON_BIN" >&2 exit 1 fi if ! "$PYTHON_BIN" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' then printf '%s\n' "Python 3.11 or newer is required." >&2 exit 1 fi if ! "$PYTHON_BIN" -I -B -c \ 'import sys; sys.path.insert(0, sys.argv[1]); from ptlinux.update_resolver import validate_channel_version; validate_channel_version(sys.argv[2], sys.argv[3])' \ "$SOURCE_ROOT" "$RELEASE_CHANNEL" "$VERSION" then printf '%s\n' "Launcher version and release channel validation failed." >&2 exit 1 fi install -d -m 0755 "$APP_ROOT" "$RELEASES_ROOT" "$BIN_HOME" "$APPLICATION_HOME" "$PIXMAP_HOME" STAGING_RELEASE="${RELEASES_ROOT}/.${VERSION}.$$.staging" if [[ -e "$STAGING_RELEASE" || -L "$STAGING_RELEASE" ]] then printf 'Launcher staging path already exists: %s\n' "$STAGING_RELEASE" >&2 exit 1 fi install -d -m 0755 "$STAGING_RELEASE" install -d -m 0755 \ "${STAGING_RELEASE}/ptlinux" \ "${STAGING_RELEASE}/ptlinux/native" \ "${STAGING_RELEASE}/ptdiag" shopt -s globstar nullglob for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"**/*.py do RELATIVE_MODULE="${SOURCE_MODULE#"${SOURCE_ROOT}/ptlinux/"}" case "$RELATIVE_MODULE" in desktop_entry.py|launcher_wrapper.py) continue ;; esac install -D -m 0644 \ "$SOURCE_MODULE" \ "${STAGING_RELEASE}/ptlinux/${RELATIVE_MODULE}" done shopt -u globstar nullglob install -m 0644 \ "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ "${STAGING_RELEASE}/ptlinux/launcher-update-public.pem" install -m 0644 \ "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \ "${STAGING_RELEASE}/ptlinux/native/pt-identity-probe.exe" install -m 0755 \ "${SOURCE_ROOT}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" \ "${STAGING_RELEASE}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" install -m 0644 \ "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.README" \ "${SOURCE_ROOT}/ptlinux/native/gamescope-compat.copyright" \ "${SOURCE_ROOT}/ptlinux/native/gamescope-lazy-output-images.patch" \ "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-live-resize.patch" \ "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-letterbox-backing.patch" \ "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" \ "${SOURCE_ROOT}/ptlinux/native/gamescope-keep-nested-windows-visible.patch" \ "${STAGING_RELEASE}/ptlinux/native/" install -m 0644 "${SOURCE_ROOT}/ptdiag/__init__.py" "${STAGING_RELEASE}/ptdiag/__init__.py" install -m 0644 "${SOURCE_ROOT}/ptdiag/assbin.py" "${STAGING_RELEASE}/ptdiag/assbin.py" install -m 0644 "${SOURCE_ROOT}/ptdiag/logs.py" "${STAGING_RELEASE}/ptdiag/logs.py" printf '{\n "channel": "%s",\n "config_schema": 1,\n "minimum_config_schema": 1,\n "schema": 1,\n "tag": "v%s",\n "version": "%s"\n}\n' \ "$RELEASE_CHANNEL" "$VERSION" "$VERSION" > "${STAGING_RELEASE}/release.json" chmod 0644 "${STAGING_RELEASE}/release.json" if ! "$PYTHON_BIN" -I -B -c \ 'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli, ptlinux.self_update; raise SystemExit(0 if ptlinux.__version__ == sys.argv[2] else 1)' \ "$STAGING_RELEASE" "$VERSION" then printf '%s\n' "The staged launcher failed its import/version check." >&2 rm -r -- "$STAGING_RELEASE" exit 1 fi REPLACED_RELEASE="" if [[ -e "$RELEASE_ROOT" || -L "$RELEASE_ROOT" ]] then if [[ ! -d "$RELEASE_ROOT" || -L "$RELEASE_ROOT" ]] then printf 'Existing launcher release is not a directory: %s\n' "$RELEASE_ROOT" >&2 rm -r -- "$STAGING_RELEASE" exit 1 fi REPLACED_RELEASE="${RELEASES_ROOT}/.${VERSION}.$$.replaced" mv -- "$RELEASE_ROOT" "$REPLACED_RELEASE" fi if ! mv -- "$STAGING_RELEASE" "$RELEASE_ROOT" then if [[ -n "$REPLACED_RELEASE" && -d "$REPLACED_RELEASE" ]] then mv -- "$REPLACED_RELEASE" "$RELEASE_ROOT" fi exit 1 fi if [[ -n "$REPLACED_RELEASE" && -d "$REPLACED_RELEASE" ]] then rm -r -- "$REPLACED_RELEASE" fi CURRENT_TARGET="" if [[ -L "${APP_ROOT}/current" ]] then CURRENT_TARGET="$(readlink -- "${APP_ROOT}/current")" if [[ ! "$CURRENT_TARGET" =~ ^releases/${SEMVER_PATTERN}$ ]] then printf 'Existing launcher current link is unsafe: %s\n' "$CURRENT_TARGET" >&2 exit 1 fi if [[ ! -d "${APP_ROOT}/${CURRENT_TARGET}" ]] then printf 'Existing launcher current link is broken: %s\n' "$CURRENT_TARGET" >&2 exit 1 fi elif [[ -e "${APP_ROOT}/current" ]] then printf 'Launcher current path is not a symbolic link: %s\n' "${APP_ROOT}/current" >&2 exit 1 fi if [[ -L "${APP_ROOT}/previous" ]] then PREVIOUS_TARGET="$(readlink -- "${APP_ROOT}/previous")" if [[ ! "$PREVIOUS_TARGET" =~ ^releases/${SEMVER_PATTERN}$ \ || ! -d "${APP_ROOT}/${PREVIOUS_TARGET}" ]] then printf 'Existing launcher previous link is unsafe or broken: %s\n' "$PREVIOUS_TARGET" >&2 exit 1 fi elif [[ -e "${APP_ROOT}/previous" ]] then printf 'Launcher previous path is not a symbolic link: %s\n' "${APP_ROOT}/previous" >&2 exit 1 fi if [[ -n "$CURRENT_TARGET" && "$CURRENT_TARGET" != "releases/${VERSION}" ]] then PREVIOUS_LINK="${APP_ROOT}/.previous.$$.tmp" ln -s -- "$CURRENT_TARGET" "$PREVIOUS_LINK" mv -Tf -- "$PREVIOUS_LINK" "${APP_ROOT}/previous" fi CURRENT_LINK="${APP_ROOT}/.current.$$.tmp" ln -s -- "releases/${VERSION}" "$CURRENT_LINK" mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current" "$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \ "${SOURCE_ROOT}/packaging/pristontale-eu" \ "$APP_ROOT" \ "$INSTALL_DIRECTORY" \ "${BIN_HOME}/pristontale-eu" install -m 0644 \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ "$ICON_PATH" "$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \ "${BIN_HOME}/pristontale-eu" \ "$ICON_PATH" \ "${APPLICATION_HOME}/eu.pristontale.PTLinux.desktop" if command -v desktop-file-validate >/dev/null 2>&1 then desktop-file-validate "${APPLICATION_HOME}/eu.pristontale.PTLinux.desktop" fi if command -v update-desktop-database >/dev/null 2>&1 then update-desktop-database "$APPLICATION_HOME" >/dev/null 2>&1 || true fi printf '%s\n' "Launcher files installed." CONTAINER_HOST_READY=1 if (( INSTALL_DEPENDENCIES == 1 )) then printf '%s\n' "Checking Steam Runtime sandbox permissions." if ! "${BIN_HOME}/pristontale-eu" container-host --repair then CONTAINER_HOST_READY=0 fi else printf '%s\n' "Steam Runtime sandbox repair skipped with host dependencies." fi RUNTIME_BOOTSTRAP_READY=1 if (( RUN_RUNTIME_BOOTSTRAP == 1 )) then if ! "${BIN_HOME}/pristontale-eu" runtime-bootstrap then RUNTIME_BOOTSTRAP_READY=0 fi else printf '%s\n' "Steam Runtime helper preparation skipped." fi DOCTOR_READY=1 if (( RUN_DOCTOR == 1 )) then if ! "${BIN_HOME}/pristontale-eu" doctor then DOCTOR_READY=0 fi else printf '%s\n' "Host readiness check skipped." fi printf '%s\n' "Start PristonTale EU with:" printf ' %q gui\n' "${BIN_HOME}/pristontale-eu" case ":${PATH:-}:" in *":${BIN_HOME}:"*) ;; *) printf '%s\n' \ "Note: ${BIN_HOME} is not in PATH for this shell." \ "The application-menu shortcut already uses the full path." if [[ "${SHELL##*/}" == "fish" ]] then printf '%s\n' "Enable the short command in Fish with:" printf ' fish_add_path %q\n' "$BIN_HOME" else printf '%s\n' "Enable the short command in this Bash/Zsh session with:" printf ' export PATH=%q:"$PATH"\n' "$BIN_HOME" fi ;; esac if (( CONTAINER_HOST_READY == 0 )) then printf '%s\n' \ "Launcher files were installed, but Bubblewrap cannot create the Steam Runtime sandbox." \ "Retry with: ${BIN_HOME}/pristontale-eu container-host --repair" >&2 exit 1 fi if (( RUNTIME_BOOTSTRAP_READY == 0 )) then printf '%s\n' \ "Launcher files were installed, but the Steam Runtime helper could not be prepared." \ "Retry with: ${BIN_HOME}/pristontale-eu runtime-bootstrap" >&2 exit 1 fi if (( DOCTOR_READY == 0 )) then printf '%s\n' \ "Launcher files were installed, but this host is not ready to run the game." \ "Resolve the diagnostics above, then run the launcher command again." >&2 exit 1 fi printf '%s\n' "Installation ready."