#!/usr/bin/env bash

#
# steamtricks provides workarounds for problems with Steam on Linux
#

# build-time constants
STEAMTRICKS_VERSION=0.3.1
STEAMTRICKS_SCRIPT="$(cd "${0%/*}" && echo "$PWD")/${0##*/}"
STEAMTRICKS_DIR="$(dirname "$STEAMTRICKS_SCRIPT")"
STEAMTRICKS_DATA_DIR=/usr/share/steamtricks
STEAMTRICKS_DATA_REPO_DIR="$STEAMTRICKS_DATA_DIR/data"
CONFIG_DIR=~/.local/share/steamtricks
CONFIG_RC="$CONFIG_DIR/steamtricksrc"
CONFIG_CACHE_DIR="$CONFIG_DIR/cache"
STEAM_LIB_PREFIX=/usr/lib/steam
STEAM_OPENSSL_REPLACE=1
STEAM_DIR=~/.local/share/Steam
STEAM_RUNTIME=$STEAM_DIR/ubuntu12_32/steam-runtime

# config defaults
C_DATA_DIR=
C_NOTIFICATION=1
C_NOTIFICATION_CLEAN=1
C_NOTIFICATION_FIX_APPLY=1
C_NOTIFICATION_FIX_APPLY_NONE=1
C_NOTIFICATION_FIX_APPLY_PRE=0
C_NOTIFICATION_FIX_DEPS=1
C_NOTIFICATION_FIX_FETCH=0
C_STEAM_STARTUP_MAX=120

_C_VERSION=20161013


notify()
{
  echo "notify: $2"
  eval local toggle=\$$"C_NOTIFICATION_$1"
  if test "$C_NOTIFICATION" == 1 && test "$toggle" == 1 ; then
    notify-send "steamtricks" "$2" --icon=steam
  fi
}

strip_quotes()
{
  local clean="$1"
  clean="${clean%\"}"
  clean="${clean#\"}"
  # escape single quotes
  clean="${clean//\'/\'\"\'\"\'}"
  echo "$clean"
}

strip_name()
{
  local clean="$1"
  clean="${clean// /_}"
  clean="${clean//\$/}"
  clean="${clean//_&_/}"
  clean="${clean//&/}"
  clean="${clean//:/}"
  clean="${clean//;/}"
  echo "$clean"
}

keyvalue_parse()
{
  local file="$1"
  local match="$2"

  local section=()
  local previous
  local previous_consumed=0
  local IFS=$'\t\n'
  for line in $(< "$file") ; do
    line=$(strip_quotes "$line")
    case $line in
      \{) section+=("$previous") ;;
      \}) unset section[${#section[@]}-1] ;;
      *)
      if [[ "$previous" != "" && "$previous" != "{" && "$previous" != "}" ]] ; then
        if [ $previous_consumed -eq 1 ] ; then
          previous_consumed=0
        else
          IFS='_'; echo "local ${section[*]}_$previous='$line'"
          previous_consumed=1
        fi
      fi
      ;;
    esac
    previous=$line
  done
}

steam_library_directories()
{
  echo "parsing libraryfolders.vdf..." >&2
  echo "$STEAM_DIR/steamapps"
  (
    eval $(keyvalue_parse "$STEAM_DIR/steamapps/libraryfolders.vdf")
    for var in $(compgen -A variable | grep -E "LibraryFolders_[0-9]+$") ; do
      eval echo \$$var/steamapps
    done
  )
}

steam_app_manifest()
{
  local app_id=$1
  local manifest
  local IFS=$'\n' # allow for spaces in directories
  for dir in $(steam_library_directories) ; do
    manifest="$dir/appmanifest_$app_id.acf"
    if [ -f "$manifest" ] ; then
      echo "$manifest"
      break
    fi
  done
}

steamtricks_fix_fetch()
{
  notify FIX_FETCH "fetching fix for app $1"
}

steamtricks_fix_apply()
{
  local app_id=$1
  local manifest=$(steam_app_manifest $app_id)
  if [[ -z "$manifest" ]] ; then
    echo "no manifest found for app $app_id"
    return 1
  fi

  eval $(keyvalue_parse "$manifest")
  local app_name="$AppState_name"
  local app_dir="$(dirname "$manifest")/common/$AppState_installdir"
  local build_id="$AppState_buildid"

  notify FIX_APPLY_PRE "Handling <b>$app_name</b> update"

  local failed=0
  local removed=$(remove_incompatible_files "$app_dir")
  local applied=0

  cd "$app_dir"
  if [ ! $? -eq 0 ] ; then
    echo "unable to enter app_dir"
    return 1
  fi

  # apply root directory
  # apply revision directory
  local data_dir="$STEAMTRICKS_DATA_REPO_DIR/$app_id-*"
  data_dir=$(echo $data_dir) # evaluate the glob
  if [ -d "$data_dir" ] ; then
    echo "fix available"

    steamtricks_fix_apply_data "$data_dir"
    if [ $? -eq 1 ] ; then
      failed=1
    fi

    # ensure build_id is a positive integer
    if [ -z "${build_id##*[!0-9]*}" ] ; then
      echo "invalid build ID ($build_id), ignoring revision specific fixes"
    else
      echo "valid build ID ($build_id), checking for revision specific fixes"
      # look through revision directories and apply first applicable directory.
      # directories will be sorted from largest to smallest so apply the first
      # one that matches criteria. `cd` ensures returned paths are not absolute
      # so after stripping ./ the bare directory name is available.
      local first
      for directory in $(cd "$data_dir" && find . -mindepth 1 -maxdepth 1 -type d | sed "s|\./||g") ; do
        # check that directory is a positive integer is less than build_id
        if [ ! -z "${directory##*[!0-9]*}" ] && [ $directory -le $build_id ] ; then
          first="$directory"
          break
        fi
      done

      if [ ! -z "$first" ] ; then
        steamtricks_fix_apply_data "$data_dir/$first"
        if [ $? -eq 1 ] ; then
          failed=1
        fi
      fi
    fi
  fi

  # seems to help to let steam settle
  sleep 1

  # check deps last since it may take a while
  steamtricks_fix_deps "$app_id" "$app_name"
  local deps=$?

  # prints directory to which it changes
  cd - > /dev/null

  if [ $removed -gt 0 ] || [ $applied -gt 0 ] || [ $deps -eq 1 ] ; then
    local status
    if [ $failed -eq 0 ] ; then
      status=success
    else
      status=failure
    fi

    local deps_status
    if [ $deps -eq 1 ] ; then
      deps_status="installed"
    else
      deps_status="unchanged"
    fi

    local message="<h3>$app_name</h3><ul> \
      <li>result: $status</li> \
      <li>removed: $removed</li> \
      <li>applied: $applied</li> \
      <li>dependencies: $deps_status</li> \
      </ul>"

    notify FIX_APPLY "$message"
  else
    notify FIX_APPLY_NONE "No changes needed by <b>$app_name</b>"
  fi
}

# apply fixes in the following order
# - 00-pre: executable script
# - 00-remove: list of files or glob patterns to remove
# - *.patch: -p1 formated patches to apply relative to app or steamruntime
# - 99-post: executable script
steamtricks_fix_apply_data()
{
  local data_dir="$1"
  local failed=0

  echo "applying $data_dir..."

  steamtricks_fix_script "$data_dir/00-pre"
  if [ $? -eq 1 ] ; then
    failed=1
  fi

  if [ -f "$data_dir/00-remove" ] ; then
    ((removed+=$(rm -v $(cat "$data_dir/00-remove") | tee /dev/shm/steamtricks | wc -l)))
    1>&2 cat /dev/shm/steamtricks # ugly, but cannot reference /dev/stderr in systemd
    failed=$?
  fi

  # could pipe the list of patches, but lose access to variable state
  local IFS=$'\n' # allow for spaces in directories
  for patch in $(find "$data_dir" -type f -name "*.patch") ; do
    echo "patch $patch..."
    patch -p1 --dry-run --unified --forward -i "$patch"
    if [ $? -eq 0 ] ; then
      patch -p1 --unified --forward -i "$patch"
    else
      patch -t -p1 --dry-run --unified --reverse -i "$patch"
    fi

    if [ $? -eq 0 ] ; then
      ((applied++))
    else
      failed=1
    fi
  done

  steamtricks_fix_script "$data_dir/99-post"
  if [ $? -eq 1 ] ; then
    failed=1
  fi

  return $failed
}

steamtricks_fix_script()
{
  local script="$1"
  if [ -f "$script" ] ; then
    echo "executing $script"
    PATH="$PATH:$STEAMTRICKS_DATA_REPO_DIR/00-common" "$script"
    if [ ! $? -eq 0 ] ; then
      echo "-> script failed"
      return 1
    fi
    ((applied++))
  fi
  return 0
}

# openSUSE specific, once other distro provide equivilent this can be abstracted
steamtricks_fix_deps()
{
  echo "checking dependencies..."
  if ! which zypper > /dev/null ; then
    echo "-> zypper unavailable, skipping"
    return 0
  fi

  # `find -maxdepth 1` is nice, but games like dota have nothing in their root
  # directory. However this makes the check much more expensive. Until it is
  # clear this is common enough to warrant the cost, leave with maxdepth.
  # Alternatively, include in -data repo or package builds would remove cost.
  local app_id="$1"
  local app_name="$2"
  local prefix=/tmp/steamtricks
  local cache="$CONFIG_CACHE_DIR/$app_id"
  local cache_files cache_needs
  if [ -f "$cache" ] ; then
    read -d "\n" cache_files cache_needs <<< $(cat "$cache")
    echo "cache loaded"
    echo "-> files: $cache_files"
    echo "-> needs: $cache_needs"
  fi

  # limit dep check to .so/.bin or files with no . at all (presumed executable)
  find . -type f -regextype posix-extended -regex \
    ".*/((.+(\.so(\.[0-9]+)*|\.bin(\..*)?))|([^.]+))$" > $prefix-files
  local sha1_files=$(sha1 $prefix-files)
  if [ "$sha1_files" == "$cache_files" ] ; then
    echo "file list identical"
    return 0
  fi

  notify FIX_DEPS "Checking dependencies for <b>$app_name</b>, which may take a bit"
  echo "evaluating binaries..."
  local provides=$(/usr/lib/rpm/find-provides < $prefix-files | sort | tee $prefix-provides | wc -l)
  local requires=$(/usr/lib/rpm/find-requires < $prefix-files | sort | tee $prefix-requires | wc -l)
  # look for data package for the app in question
  echo "steamtricks-data-$app_id" >> $prefix-requires
  echo "steamtricks-data-$app_id-$(strip_name "$app_name")" >> $prefix-requires
  local needs=$(comm -23 $prefix-requires $prefix-provides | tee $prefix-needs | wc -l)
  local sha1_needs=$(sha1 $prefix-needs)
  echo "-> provides: $provides, requires: $requires, needs: $needs"

  # check if previous deps differ from current
  local return=0
  if [ "$sha1_needs" != "$cache_needs" ] ; then
    # check if new packages to install before prompting the user
    output=$(zypper --root / --no-refresh --non-interactive --ignore-unknown \
      in --dry-run --capability $(cat $prefix-needs) 2>&1)
    # the least language specific way to determine if new packages to install
    if [ $? -eq 0 ] && echo "$output" | tail -n 1 | grep "(y): y" > /dev/null ; then
      echo "-> new packages to install, prompt user"
      xdg-terminal '/usr/bin/env bash -c " \
        echo \"steamtricks is attempting to install dependencies for '"$app_name"'.\"; \
        sudo zypper in -C $(cat '$prefix-needs') || sleep 5"'
      return=1
    else
      echo "-> change in needs, but no new matching packages"
    fi
  else
    echo "-> nothing new"
  fi

  # at minimum sha1_files changed
  echo "$sha1_files"$'\n'"$sha1_needs" | tee "$cache"

  rm $prefix-*
  return $return
}

sha1()
{
  # use < to avoid filename in output and then simply strip
  sha1sum < "$1" | tr -d ' -'
}

# Many apps also ship a libstdc that causes issues in the same way the steam
# runtime version of the lib does. As such parse the config to get a list of
# library folders to search and remove such files. An example of a game that
# will not launch without this fix is Portal 2 (app 620). Unfortunately, there
# does not appear to be a method for running the script after a game has
# downloaded or before it launches so at best a steam restart will work.
remove_incompatible_files()
{
  echo "removing troublesome files..." >&2
  local IFS=$'\n' # allow for spaces in directories
  local count=0
  for dir in $*; do
    echo "-> checking $dir" >&2
    ((count+=$(find "$dir" -type f -name "libstdc*" -print -delete | tee /dev/shm/steamtricks | wc -l)))
    1>&2 cat /dev/shm/steamtricks # ugly, but cannot reference /dev/stderr in systemd
  done
  echo $count
}

# TODO replace with bootstrap phase watching
remove_incompatible_files_runtime()
{
  local count=$(remove_incompatible_files $STEAM_RUNTIME)
  if [ $count -gt 0 ] ; then
    notify CLEAN "$count troublesome file(s) removed from steam-runtime"
  fi
}

steam_app_running_wait()
{
  # Allow for multiple apps to be launched simultaneously which will result in
  # duplicate wait calls once all apps are closed and logs are processed. All
  # apps launched from steam are parented by a SteamChildMonit process. Once
  # there are no more processes there are no Steam apps running.
  if [ "$(pgrep -cx SteamChildMonit)" != "0" ] ; then
    echo "suspending event processing while apps are running"
  else
    return
  fi

  until [ "$(pgrep -cx SteamChildMonit)" == "0" ] ; do
    # avoid resource usage while app is running
    sleep 10
  done
  echo "resuming event processing"
}

steam_content_log_watch()
{
  echo -n "waiting for steam to start..."
  local tries=0
  until pgrep -xo steam > /dev/null ; do
    if [ $((tries++)) -eq $C_STEAM_STARTUP_MAX ] ; then
     echo "failed after $C_STEAM_STARTUP_MAX seconds"
     exit 1
    fi
    sleep 1
  done
  echo "done"

  local line
  local parts
  local validating=0
  tail --pid $(pgrep -xo steam) -Fn0 "$STEAM_DIR/logs/content_log.txt" | \
  while read line ; do
    # steam prints CRLF into log files (issue #4646)
    # for some reason this does not work when chained above
    line=$(echo "$line" | tr -d '\r')
    parts=($line)

    # detect app validation, steam only allows one app to be validated at a time
    # so a single boolean toggle is enough to track it
    case $line in
      *"Start validating appID"*)
        echo "validate app ${parts[5]} start, ignore state changes"
        validating=1 ;;
      *"File validation finished"*)
        echo "validate app stop, process state changes"
        validating=0 ;;
      *) ;;
    esac

    # ignore any misleading events during validation
    if [ $validating -eq 1 ] ; then
      continue
    fi

    # handle app state change events
    case $line in
      *"state changed : Update Required,")
        steamtricks_fix_fetch ${parts[3]} ;;
      *"state changed : Fully Installed,Update Running,")
        steamtricks_fix_apply ${parts[3]} ;;
      *"state changed : Fully Installed,App Running,")
        steam_app_running_wait ;;

      # commands
      "steamtricks_fix_apply"*)
        steamtricks_fix_apply ${parts[1]} ;;
    esac
  done

  echo "steam quit, exit"
}

steam_lib_replace()
{
  local replacement="$STEAM_LIB_PREFIX/$1"
  local original="$2"

  if [ ! -L "$original" ] && [ -f "$replacement" ] ; then
    rm "$original"
    ln -s "$replacement" "$original"
    echo "-> replace $original with $replacement"
  fi
}

steam_openssl_replace()
{
  echo "replacing steam-runtime openssl libraries..."
  for lib in libcrypto.so.1.0.0 libssl.so.1.0.0 ; do
    if [ -d "$STEAM_RUNTIME/i386" ] ; then
      find "$STEAM_RUNTIME/i386" -name $lib | while read found
      do
        steam_lib_replace "lib/$lib" "$found"
      done
    fi

    if [ -d "$STEAM_RUNTIME/amd64" ] ; then
      find "$STEAM_RUNTIME/amd64" -name $lib | while read found
      do
        steam_lib_replace "lib64/$lib" "$found"
      done
    fi
  done
}

steam_openssl_replace_check()
{
  # check if nvidia is active using glxinfo and fallback to lsmod
  if (which glxinfo && glxinfo | grep "server glx vendor string: NVIDIA") > /dev/null || \
    [ $(lsmod | grep "nvidia" | wc -l) -ne 0 ] ; then
    echo "detected nvidia binary driver, disabling openssl replacement"
    return 1
  fi
  return 0
}

steamtricks_data_dir()
{
  # check for config override, but allow git to win
  if [ ! -z "$C_DATA_DIR" ] ; then
    STEAMTRICKS_DATA_REPO_DIR="$C_DATA_DIR"
  fi

  # automatically use development copy if running out of git tree
  if [ -d "$STEAMTRICKS_DIR/../.git" ] && [ -d "$STEAMTRICKS_DIR/../data" ] ; then
    STEAMTRICKS_DATA_REPO_DIR="$STEAMTRICKS_DIR/../data"
  fi
}

steamtricks_config()
{
  if [ ! -d "$CONFIG_DIR" ] ; then
    mkdir -p "$CONFIG_DIR"
  fi

  if [ ! -d "$CONFIG_CACHE_DIR" ] ; then
    mkdir -p "$CONFIG_CACHE_DIR"
  fi

  if [ -e "$CONFIG_RC" ] ; then
    source "$CONFIG_RC"
    if [ -z "$C_VERSION" ] || [ $C_VERSION -lt $_C_VERSION ] ; then
      C_VERSION=$_C_VERSION
      # config is already loaded, remove config and re-write
      rm "$CONFIG_RC"
    fi
  fi

  if [ ! -e "$CONFIG_RC" ] ; then
    steamtricks_config_print > "$CONFIG_RC"
  fi
}

steamtricks_config_print()
{
  echo "#!/usr/bin/env bash"
  echo "# $(date)"
  echo

  ( set -o posix ; set ) | grep ^C_
}

steamtricks_usage()
{
  cat <<_EOF_
Usage: $0 [options] command

Options:
    --version         Print version string and exit
    --watch           Watch Steam logs for relevant activity
-h, --help            Display this message and exit

Commands:
apply [APP_ID]        Force apply fixes to an app
clean                 Clean steam-runtime and steamapps directories (default)
_EOF_
}

steamtricks_command_apply()
{
  local app_id="$1"
  rm -f "$CONFIG_CACHE_DIR/$app_id"
  echo "steamtricks_fix_apply $app_id" >> "$STEAM_DIR/logs/content_log.txt"
}

until [ $# -eq 0 ] ; do
  case "$1" in
    --version) echo "$STEAMTRICKS_VERSION"; exit 0; ;;
    --watch) STEAMTRICKS_WATCH=1 ;;
    -h|--help) steamtricks_usage ; exit 0 ;;
    apply)
      if [ $# -lt 2 ] ; then
        echo "an app ID is required"
      else
        steamtricks_command_apply "$2"
      fi
      exit ;;
    clean) ;;
    -*) echo "unknown option $1" ; exit 1 ;;
    *) echo "unknown command $1" ; exit 1 ;;
  esac
  shift
done

# initial boot
steamtricks_config
remove_incompatible_files_runtime
if test "$STEAM_OPENSSL_REPLACE" == 1 && steam_openssl_replace_check ; then
  steam_openssl_replace
fi

# watch phase
steamtricks_data_dir
if test "$STEAMTRICKS_WATCH" == 1 ; then
  steam_content_log_watch
fi
