Sync from development - prepare for v0.1.1

This commit is contained in:
Omni
2025-09-15 20:18:13 +01:00
parent 0b6e32beac
commit 70b18004e1
64 changed files with 5142 additions and 1164 deletions

View File

@@ -1,5 +1,44 @@
# Jackify Changelog
## v0.1.1 - Self-Updater Implementation
**Release Date:** September 17, 2025
### New Features
- **Self-Updater System**: Complete automatic update mechanism for Jackify AppImages
- **GitHub Integration**: Automatic detection of new releases from GitHub
- **GUI Update Dialog**: Professional update notification with Jackify theme styling
- **CLI Update Command**: `--update` flag for manual update checks and installation
- **Startup Checks**: Automatic update detection on application launch
- **User Control**: Skip version, remind later, and download & install options
### Technical Implementation
- **UpdateService**: Core service handling version detection, download, and installation
- **Full AppImage Replacement**: Reliable update mechanism using helper scripts
- **User-Writable Directories**: All update files stored in `~/Jackify/updates/` for consistency with existing directory structure
- **Progress Indication**: Download progress bars for both GUI and CLI
- **Error Handling**: Graceful fallbacks and comprehensive error messages
### Security Enhancements
- **AppImage Validation**: Prevents accidental updating of other AppImages when running from development environments
- **Path Verification**: Validates target AppImage contains "jackify" in filename before applying updates
### User Experience
- **Seamless Updates**: Users receive notifications when updates are available
- **Professional Interface**: Update dialog matches Jackify's visual theme
- **Flexible Options**: Users can choose when and how to update
- **No External Dependencies**: Works on all systems including SteamOS and immutable OSes
### Bug Fixes
- **Path Regression Fix**: Resolved regression where Configure New/Existing Modlist workflows were creating malformed paths
- Fixed duplicate steamapps/common path generation
- Corrected Steam library root path detection
- Removed broken duplicate PathHandler causing path duplication
- **Enhanced Download Error Messages**: Added Nexus mod URLs to failed download errors for easier troubleshooting
- Automatically appends direct Nexus mod page links
- Supports all major games (Skyrim, Fallout 4, FNV, Oblivion, Starfield)
---
## v0.1.0.1 - Engine Update and Stability Improvements
**Release Date:** September 14, 2025

View File

@@ -817,7 +817,7 @@ configuration_phase() {
display "For example, if you wanted to install a modlist to /home/user/Games/Skyrim/Modlistname, you would need to run:" "$YELLOW"
display "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW"
fi
echo -e "\n${YELLOW}⚠️ IMPORTANT: For best compatibility, add the following line to the Launch Options of your Wabbajack Steam entry:${RESET}"
echo -e "\n${YELLOW}IMPORTANT: For best compatibility, add the following line to the Launch Options of your Wabbajack Steam entry:${RESET}"
echo -e "\n${GREEN}PROTON_USE_WINED3D=1 %command%${RESET}\n"
echo -e "This can help resolve certain graphics issues with Wabbajack running under Proton."
exit 0

View File

@@ -858,7 +858,7 @@ configuration_phase() {
display "For example, if you wanted to install a modlist to /home/user/Games/Skyrim/Modlistname, you would need to run:" "$YELLOW"
display "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW"
fi
echo -e "\n${YELLOW}⚠️ IMPORTANT: For best compatibility, add the following line to the Launch Options of your Wabbajack Steam entry:${RESET}"
echo -e "\n${YELLOW}IMPORTANT: For best compatibility, add the following line to the Launch Options of your Wabbajack Steam entry:${RESET}"
echo -e "\n${GREEN}PROTON_USE_WINED3D=1 %command%${RESET}\n"
echo -e "This can help resolve certain graphics issues with Wabbajack running under Proton."
exit 0

880
binaries/WabbajackProton.sh Normal file
View File

@@ -0,0 +1,880 @@
#!/usr/bin/env bash
#
##################################################################
# #
# Attempt to automate installing Wabbajack on Linux Steam/Proton #
# #
# v0.20 - Refactored #1
# #
##################################################################
# Set up logging
LOGFILE="$HOME/wabbajack-via-proton-sh.log"
echo "" > "$LOGFILE"
# Script configuration
SCRIPT_VERSION="0.20"
STEAM_IS_FLATPAK=0
VERBOSE=0
CURRENT_TASK=""
TOTAL_TASKS=10
CURRENT_TASK_NUM=0
IN_MODIFICATION_PHASE=0 # Add this flag
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-v|--verbose)
VERBOSE=1
shift
;;
*)
display "Unknown option: $1" "$RED"
display "Usage: $0 [-v|--verbose]" "$YELLOW"
exit 1
;;
esac
done
# URLs for resources
WABBALIST_URL="https://raw.githubusercontent.com/wabbajack-tools/mod-lists/master/README.md"
WEBVIEW_INSTALLER_URL="https://files.omnigaming.org/MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe"
SYSTEM_REG_URL="https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/system.reg.github"
USER_REG_URL="https://github.com/Omni-guides/Wabbajack-Modlist-Linux/raw/refs/heads/main/files/user.reg.github"
# Color codes for pretty output
GREEN="\e[32m"
YELLOW="\e[33m"
RED="\e[31m"
RESET="\e[0m"
# Logging function
log() {
local message="$1"
local log_level="${2:-INFO}"
local timestamp=$(date +'%Y-%m-%d %H:%M:%S')
echo "[$timestamp] [$log_level] $message" >> "$LOGFILE"
# If verbose mode is enabled, also print to console
if [[ $VERBOSE -eq 1 ]]; then
echo "[$timestamp] [$log_level] $message"
fi
}
# Display and logging function
display() {
local message="$1"
local color="${2:-$RESET}"
# Only log to file if it's not a user prompt or selection
if [[ ! "$message" =~ "Please select" ]] && [[ ! "$message" =~ "Enter the number" ]]; then
log "$message"
fi
echo -e "${color}${message}${RESET}"
}
# Verbose logging function
verbose_log() {
if [[ $VERBOSE -eq 1 ]]; then
log "$1" "VERBOSE"
fi
}
# Section header function
log_section() {
local message="$1"
local separator="============================================"
log "$separator"
log "$message"
log "$separator"
if [[ $VERBOSE -eq 1 ]]; then
echo -e "${YELLOW}$separator${RESET}"
echo -e "${YELLOW}$message${RESET}"
echo -e "${YELLOW}$separator${RESET}"
fi
}
# Error handling function
error_exit() {
display "$1" "$RED"
log "$1" "ERROR"
cleanup_wine_procs
exit 1
}
# Progress bar function
update_progress() {
# Only show progress bar during modification phase
if [[ $IN_MODIFICATION_PHASE -eq 0 ]]; then
return
fi
local percent=$1
local bar_length=50
local filled_length=$((percent * bar_length / 100))
local bar=""
# Create the bar string with = for filled portions
for ((i = 0; i < bar_length; i++)); do
if [ $i -lt $filled_length ]; then
bar+="="
else
bar+=" "
fi
done
# Use \r to return to start of line and overwrite previous progress
printf "\r[%-${bar_length}s] %d%% - %s" "$bar" "$percent" "$CURRENT_TASK"
}
# Set current task function
set_current_task() {
CURRENT_TASK="$1"
# Only increment and show progress during modification phase
if [[ $IN_MODIFICATION_PHASE -eq 1 ]]; then
# Calculate percentage based on modification phase tasks
local total_mod_tasks=11 # Updated to account for split configure_prefix tasks
local percent=0
# Only show 100% when we're actually complete
if [[ "$CURRENT_TASK" == "Complete" ]]; then
percent=100
else
# Calculate percentage based on current task number
percent=$((CURRENT_TASK_NUM * 100 / total_mod_tasks))
# Ensure we don't hit 100% before completion
if [[ $percent -ge 100 ]]; then
percent=99
fi
fi
# Clear the current line before updating progress
printf "\r%-100s\r" ""
update_progress "$percent"
# Increment task counter after displaying progress
CURRENT_TASK_NUM=$((CURRENT_TASK_NUM + 1))
fi
}
# Download function that uses either wget or curl
download_file() {
local url="$1"
local output_path="$2"
local description="${3:-file}"
# Only log to file, don't display to user
log "Downloading $description..."
if command -v wget &>/dev/null; then
if wget "$url" -O "$output_path" >>"$LOGFILE" 2>&1; then
log "Downloaded $description successfully using wget"
return 0
else
error_exit "Failed to download $description with wget"
fi
elif command -v curl &>/dev/null; then
if curl -sLo "$output_path" "$url" >>"$LOGFILE" 2>&1; then
log "Downloaded $description successfully using curl"
return 0
else
error_exit "Failed to download $description with curl"
fi
else
error_exit "Neither wget nor curl is available. Cannot download $description"
fi
}
display_banner() {
echo "╔══════════════════════════════════════════════════════════════════╗"
echo "║ Wabbajack Proton Setup v$SCRIPT_VERSION"
echo "║ ║"
echo "║ A tool for running Wabbajack on Linux via Proton ║"
echo "╚══════════════════════════════════════════════════════════════════╝"
echo ""
display "This script automates setting up Wabbajack to run on Linux via Steam's Proton compatibility layer." "$YELLOW"
echo "───────────────────────────────────────────────────────────────────"
display "Please be aware that this is experimental software and is *NOT* supported by the Wabbajack team." "$YELLOW"
display "If you encounter issues, please report them on GitHub or the #unofficial-linux-support channel on Discord." "$YELLOW"
echo "───────────────────────────────────────────────────────────────────"
display "⚠ IMPORTANT: Use this script at your own risk." "$RED"
echo ""
echo -e "\e[33mPress any key to continue...\e[0m"
read -n 1 -s -r -p ""
echo ""
}
detect_steamdeck() {
if [ -f "/etc/os-release" ] && grep -q "steamdeck" "/etc/os-release"; then
STEAMDECK=1
log "Running on Steam Deck"
else
STEAMDECK=0
log "NOT running on Steam Deck"
fi
}
detect_protontricks() {
# Only log to file, don't display to user
log "Detecting protontricks installation..."
if command -v protontricks >/dev/null 2>&1; then
PROTONTRICKS_PATH=$(command -v protontricks)
# Check if the detected binary is actually a Flatpak wrapper
if [[ -f "$PROTONTRICKS_PATH" ]] && grep -q "flatpak run" "$PROTONTRICKS_PATH"; then
log "Detected Protontricks is a Flatpak wrapper at $PROTONTRICKS_PATH"
WHICH_PROTONTRICKS="flatpak"
return 0
else
log "Native Protontricks found at $PROTONTRICKS_PATH"
WHICH_PROTONTRICKS="native"
return 0
fi
elif flatpak list | grep -iq protontricks; then
log "Flatpak Protontricks is installed"
WHICH_PROTONTRICKS="flatpak"
return 0
else
log "Protontricks not found. Do you wish to install it? (y/n): "
display "Protontricks not found. Do you wish to install it? (y/n): " "$RED"
read -p " " answer
if [[ $answer =~ ^[Yy]$ ]]; then
if [[ $STEAMDECK -eq 1 ]]; then
if flatpak install -u -y --noninteractive flathub com.github.Matoking.protontricks; then
WHICH_PROTONTRICKS="flatpak"
return 0
else
display "\n\e[31mFailed to install Protontricks via Flatpak. Please install it manually and rerun this script.\e[0m" "$RED"
exit 1
fi
else
read -p "Choose installation method: 1) Flatpak (preferred) 2) Native: " choice
if [[ $choice =~ 1 ]]; then
if flatpak install -u -y --noninteractive flathub com.github.Matoking.protontricks; then
WHICH_PROTONTRICKS="flatpak"
return 0
else
display "\n\e[31mFailed to install Protontricks via Flatpak. Please install it manually and rerun this script.\e[0m" "$RED"
exit 1
fi
else
display "Sorry, there are too many distros to automate this!"
display "Please check how to install Protontricks using your OS package manager (yum, dnf, apt, pacman, etc.)"
display "\e[31mProtontricks is required for this script to function. Exiting.\e[0m" "$RED"
exit 1
fi
fi
else
display "\e[31mProtontricks is required for this script to function. Exiting.\e[0m" "$RED"
exit 1
fi
fi
}
setup_protontricks_alias() {
set_current_task "Setting up Protontricks aliases"
if [[ "$WHICH_PROTONTRICKS" = "flatpak" ]]; then
local protontricks_alias_exists=$(grep "^alias protontricks=" ~/.bashrc)
local launch_alias_exists=$(grep "^alias protontricks-launch" ~/.bashrc)
if [[ -z "$protontricks_alias_exists" ]]; then
display "Adding protontricks alias to ~/.bashrc" "$YELLOW"
echo "alias protontricks='flatpak run com.github.Matoking.protontricks'" >> ~/.bashrc
source ~/.bashrc
else
log "protontricks alias already exists in ~/.bashrc"
fi
if [[ -z "$launch_alias_exists" ]]; then
display "Adding protontricks-launch alias to ~/.bashrc" "$YELLOW"
echo "alias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'" >> ~/.bashrc
source ~/.bashrc
else
log "protontricks-launch alias already exists in ~/.bashrc"
fi
else
log "Protontricks is not installed via flatpak, skipping alias creation"
fi
}
run_protontricks() {
# Determine the protontricks binary path
verbose_log "Running protontricks with arguments: $*"
if [ "$WHICH_PROTONTRICKS" = "flatpak" ]; then
verbose_log "Using Flatpak protontricks"
# Redirect Wine output to /dev/null but keep protontricks output
if [[ "$*" == *"-c"* ]]; then
# For Wine commands, suppress output but check exit code
if flatpak run com.github.Matoking.protontricks "$@" >/dev/null 2>&1; then
return 0
else
return 1
fi
else
# For non-Wine commands, show output but redirect stderr to /dev/null
flatpak run com.github.Matoking.protontricks "$@" 2>/dev/null
fi
else
verbose_log "Using native protontricks"
# Redirect Wine output to /dev/null but keep protontricks output
if [[ "$*" == *"-c"* ]]; then
# For Wine commands, suppress output but check exit code
if protontricks "$@" >/dev/null 2>&1; then
return 0
else
return 1
fi
else
# For non-Wine commands, show output but redirect stderr to /dev/null
protontricks "$@" 2>/dev/null
fi
fi
}
check_protontricks_version() {
set_current_task "Checking Protontricks version"
# Get the current version of protontricks
local protontricks_version=$(run_protontricks -V | cut -d ' ' -f 2 | sed 's/[()]//g' | sed 's/\.[0-9]$//')
local protontricks_version_cleaned=$(echo "$protontricks_version" | sed 's/[^0-9.]//g')
log "Protontricks Version: $protontricks_version_cleaned"
# Compare version strings
if [[ "$protontricks_version_cleaned" < "1.12" ]]; then
error_exit "Your protontricks version is too old! Update to version 1.12 or newer and rerun this script."
fi
}
get_wabbajack_path() {
set_current_task "Detecting Wabbajack path"
local wabbajack_path=""
local wabbajack_entries=()
local app_ids=()
local app_names=()
local all_app_ids=()
local all_app_names=()
local selection=""
local use_all_shortcuts=0
log "Detecting Wabbajack Install Directory..."
verbose_log "Attempting to find Wabbajack entries using protontricks -l"
# First, try to find Wabbajack entries using protontricks
local protontricks_entries=$(run_protontricks -l | grep -i 'Non-Steam shortcut' | grep -i wabbajack)
verbose_log "Protontricks output: $protontricks_entries"
if [[ -n "$protontricks_entries" ]]; then
log "Found Wabbajack entries via protontricks (name match)"
while IFS= read -r line; do
local app_id=$(echo "$line" | awk '{print $NF}' | sed 's:^.\(.*\).$:\1:')
local app_name=$(echo "$line" | sed 's/^Non-Steam shortcut: //i' | sed 's: ([0-9]*)$::')
if [[ -n "$app_id" ]]; then
app_ids+=("$app_id")
app_names+=("$app_name")
log "Found App ID: $app_id, Name: $app_name"
fi
done <<< "$protontricks_entries"
echo ""
display "Wabbajack-related Steam entries found. Please select which one you wish to configure:" "$RED"
for i in "${!app_ids[@]}"; do
echo "$((i + 1)). ${app_names[i]} (App ID: ${app_ids[i]})"
done
local extra_option=$(( ${#app_ids[@]} + 1 ))
echo "$extra_option. List all Steam shortcuts"
echo "Please select the entry you want to use (1-$extra_option):"
read -r selection
if [[ "$selection" == "$extra_option" ]]; then
use_all_shortcuts=1
elif [[ "$selection" =~ ^[0-9]+$ ]] && ((selection >= 1 && selection <= ${#app_ids[@]})); then
APPID="${app_ids[$((selection - 1))]}"
log "Selected App ID: $APPID, Name: ${app_names[$((selection - 1))]}"
else
use_all_shortcuts=1
fi
else
use_all_shortcuts=1
fi
# If requested, list all Non-Steam shortcuts
if [[ $use_all_shortcuts -eq 1 ]]; then
local all_entries=$(run_protontricks -l | grep -i 'Non-Steam shortcut')
if [[ -z "$all_entries" ]]; then
error_exit "No Non-Steam shortcuts found via protontricks. Please ensure you've added your entry as a non-Steam game and run it once via Steam."
fi
while IFS= read -r line; do
local app_id=$(echo "$line" | awk '{print $NF}' | sed 's:^.\(.*\).$:\1:')
local app_name=$(echo "$line" | sed 's/^Non-Steam shortcut: //i' | sed 's: ([0-9]*)$::')
if [[ -n "$app_id" ]]; then
all_app_ids+=("$app_id")
all_app_names+=("$app_name")
log "Found App ID: $app_id, Name: $app_name (all shortcuts)"
fi
done <<< "$all_entries"
echo ""
display "All Steam shortcuts detected. Please select which one you wish to configure:" "$RED"
for i in "${!all_app_ids[@]}"; do
echo "$((i + 1)). ${all_app_names[i]} (App ID: ${all_app_ids[i]})"
done
echo "Please select the entry you want to use (1-${#all_app_ids[@]}):"
read -r selection
if ! [[ "$selection" =~ ^[0-9]+$ ]] || ((selection < 1 || selection > ${#all_app_ids[@]})); then
error_exit "Invalid selection"
fi
APPID="${all_app_ids[$((selection - 1))]}"
log "Selected App ID: $APPID, Name: ${all_app_names[$((selection - 1))]}"
echo ""
echo "If you don't see your Wabbajack entry in the list, please make sure you have added it to Steam, set the Proton version, and run it once (then closed Wabbajack)."
fi
# Now that we have the App ID, try to find the executable path in shortcuts.vdf
verbose_log "Attempting to find executable path for App ID: $APPID"
local steam_userdata_paths=(
"$HOME/.steam/steam/userdata"
"$HOME/.local/share/Steam/userdata"
"$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/userdata"
)
local wabbajack_entries=()
for path in "${steam_userdata_paths[@]}"; do
if [[ -d "$path" ]]; then
verbose_log "Checking directory: $path"
local vdf_files=$(find "$path" -name "shortcuts.vdf" 2>/dev/null)
for vdf_file in $vdf_files; do
if [[ "$vdf_file" == *"12345678"* ]]; then
verbose_log "Skipping test directory shortcuts.vdf: $vdf_file"
continue
fi
verbose_log "Checking shortcuts.vdf: $vdf_file"
while IFS= read -r line; do
if [[ "$line" == */Wabbajack.exe* ]]; then
local path=$(echo "$line" | sed -E 's/.*"([^"*Wabbajack\\.exe[^"]*)".*$/\1/')
if [[ -n "$path" ]]; then
if [[ "$path" != *".wabbajack_test"* ]]; then
verbose_log "Found Wabbajack.exe path: $path"
wabbajack_entries+=("$path")
else
verbose_log "Skipping test directory entry: $path"
fi
fi
fi
done < <(strings "$vdf_file" | grep -i "/Wabbajack.exe")
done
fi
done
readarray -t unique_entries < <(printf '%s\n' "${wabbajack_entries[@]}" | sort -u)
wabbajack_entries=("${unique_entries[@]}")
# Sort alphabetically for user-friendly order
IFS=$'\n' wabbajack_entries=($(sort <<<"${wabbajack_entries[*]}"))
unset IFS
# Build a mapping from App ID to Wabbajack.exe path
declare -A appid_to_path
for path in "${wabbajack_entries[@]}"; do
# Try to extract the App ID from the path's parent directory name (assumes unique per shortcut)
for i in "${!all_app_ids[@]}"; do
if [[ "$path" == *"${all_app_names[$i]}"* ]]; then
appid_to_path["${all_app_ids[$i]}"]="$path"
fi
done
done
# If we have a sorted app_ids array, display the paths in that order
ordered_paths=()
for id in "${app_ids[@]}"; do
if [[ -n "${appid_to_path[$id]}" ]]; then
ordered_paths+=("${appid_to_path[$id]}")
fi
done
# Only use ordered_paths if it matches the number of app_ids
if [[ ${#ordered_paths[@]} -eq ${#app_ids[@]} ]]; then
wabbajack_entries=("${ordered_paths[@]}")
fi
local entry_count=${#wabbajack_entries[@]}
verbose_log "Found $entry_count unique Wabbajack.exe entries (ordered by shortcut name)"
if [[ "$entry_count" -eq 0 ]]; then
error_exit "No Wabbajack.exe entries found in shortcuts.vdf. Please ensure you've added Wabbajack.exe as a non-Steam game and run it once via Steam."
elif [[ "$entry_count" -gt 1 ]]; then
echo ""
display "Multiple Wabbajack.exe paths found, please select which one you wish to configure:" "$RED"
local i=1
for path in "${wabbajack_entries[@]}"; do
echo "$i) $path"
((i++))
done
local selected_entry=""
while [[ ! "$selected_entry" =~ ^[0-9]+$ || "$selected_entry" -lt 1 || "$selected_entry" -gt "$entry_count" ]]; do
read -p "Enter the number of the desired entry (1-$entry_count): " selected_entry
if [[ ! "$selected_entry" =~ ^[0-9]+$ || "$selected_entry" -lt 1 || "$selected_entry" -gt "$entry_count" ]]; then
display "Invalid selection. Please enter a number between 1 and $entry_count" "$RED"
fi
done
wabbajack_path="${wabbajack_entries[$((selected_entry - 1))]}"
log "Selected Wabbajack path: $wabbajack_path"
echo ""
else
wabbajack_path="${wabbajack_entries[0]}"
log "Single Wabbajack path found: $wabbajack_path"
fi
if [[ -n "$wabbajack_path" ]]; then
log "Using Wabbajack path: $wabbajack_path"
APPLICATION_DIRECTORY=$(dirname "$wabbajack_path")
# Sanitize: remove any stray quotes and trim whitespace/newlines
APPLICATION_DIRECTORY=$(echo "$APPLICATION_DIRECTORY" | tr -d '"' | xargs)
log "Application Directory: $APPLICATION_DIRECTORY"
return 0
else
error_exit "Failed to determine Wabbajack path"
fi
}
detect_compatdata_path() {
set_current_task "Detecting compatdata path"
# Check common Steam library locations first
local steam_paths=(
"$HOME/.local/share/Steam/steamapps/compatdata"
"$HOME/.steam/steam/steamapps/compatdata"
"$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/compatdata"
)
for path in "${steam_paths[@]}"; do
if [[ -d "$path/$APPID" ]]; then
COMPAT_DATA_PATH="$path/$APPID"
log "compatdata Path detected: $COMPAT_DATA_PATH"
return 0
fi
done
# If not found in common locations, use find command with specific paths
local found=0
for base_path in "${steam_paths[@]}"; do
if [[ -d "$base_path" ]]; then
if [[ -d "$base_path/$APPID" ]]; then
COMPAT_DATA_PATH="$base_path/$APPID"
log "compatdata Path detected: $COMPAT_DATA_PATH"
found=1
break
fi
fi
done
if [[ $found -eq 0 ]]; then
error_exit "Directory named '$APPID' not found in any compatdata directories. Please ensure you have started the Steam entry for Wabbajack at least once."
fi
}
set_protontricks_perms() {
set_current_task "Setting Protontricks permissions"
if [ "$WHICH_PROTONTRICKS" = "flatpak" ]; then
# Only log to file, don't display to user
log "Setting Protontricks permissions..."
# Always set Flatpak override for the application directory, suppressing error output
flatpak override --user com.github.Matoking.protontricks --filesystem="$APPLICATION_DIRECTORY" 2>>"$LOGFILE"
if [[ "$STEAMDECK" = 1 ]]; then
log "Checking for SDCard and setting permissions appropriately..."
# Set protontricks SDCard permissions early to suppress warning
sdcard_path=$(df -h | grep "/run/media" | awk '{print $NF}')
log "SD Card path: $sdcard_path"
if [[ -n "$sdcard_path" ]]; then
flatpak override --user --filesystem="$sdcard_path" com.github.Matoking.protontricks 2>>"$LOGFILE"
log "SD Card permission set"
fi
fi
else
log "Using Native protontricks, skip setting permissions"
fi
}
webview_installer() {
set_current_task "Downloading WebView installer"
log "Setting up WebView..."
local installer_path="$APPLICATION_DIRECTORY/MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe"
# Download if not present
if [ ! -f "$installer_path" ]; then
download_file "$WEBVIEW_INSTALLER_URL" "$installer_path" "WebView Installer"
else
log "WebView Installer already exists, skipping download"
fi
# Check installer exists before running
if [ ! -f "$installer_path" ]; then
log "ERROR: WebView installer not found at $installer_path"
error_exit "WebView installer missing"
fi
# Always run the installer in the correct prefix using run_protontricks, capturing output
set_current_task "Installing WebView runtime (this may take a while)..."
log "Installing WebView..."
local webview_tmp_log
webview_tmp_log="$(mktemp)"
if ! run_protontricks -c "wine \"$installer_path\" /silent /install" "$APPID" > "$webview_tmp_log" 2>&1; then
log "ERROR: Failed to install WebView. See $LOGFILE for details."
echo "--- WebView Installer Output ---" >> "$LOGFILE"
cat "$webview_tmp_log" >> "$LOGFILE"
echo "--- End WebView Installer Output ---" >> "$LOGFILE"
rm -f "$webview_tmp_log"
display "Failed to install WebView. See $LOGFILE for details.\nYou may need to install the WebView2 runtime manually inside the Proton prefix." "$RED"
error_exit "Failed to install WebView. See $LOGFILE for details."
fi
rm -f "$webview_tmp_log"
}
detect_link_steam_library() {
local steam_library_paths=()
local libraryfolders_vdf=""
# Only log to file, don't display to user
log "Discovering Steam libraries..."
# Find libraryfolders.vdf and extract library paths
local vdf_paths=(
"$HOME/.steam/steam/steamapps/libraryfolders.vdf"
"$HOME/.local/share/Steam/steamapps/libraryfolders.vdf"
"$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/libraryfolders.vdf"
)
for vdf_path in "${vdf_paths[@]}"; do
if [[ -f "$vdf_path" ]]; then
if [[ ! -r "$vdf_path" ]]; then
log "Found libraryfolders.vdf at $vdf_path but it's not readable"
continue
fi
libraryfolders_vdf="$vdf_path"
log "Found readable libraryfolders.vdf at $vdf_path"
break
fi
done
if [[ -z "$libraryfolders_vdf" ]]; then
display "Steam libraryfolders.vdf not found. Manual input required." "$RED"
read -e -p "Enter the path to your main Steam directory: " steam_library_path
while true; do
if [[ ! -d "$steam_library_path" ]]; then
display "Invalid path. Please enter a valid directory." "$RED"
elif [[ ! -f "$steam_library_path/steamapps/libraryfolders.vdf" ]]; then
display "The specified path does not appear to be a Steam directory. Do not enter a secondary Steam Library path, only the main Steam install path." "$RED"
elif [[ ! -r "$steam_library_path/steamapps/libraryfolders.vdf" ]]; then
display "The libraryfolders.vdf file exists but is not readable. Please check permissions." "$RED"
else
read -p "Confirm using '$steam_library_path' as the Steam directory path? (y/n): " -r choice
if [[ "$choice" =~ ^[Yy]$ ]]; then
libraryfolders_vdf="$steam_library_path/steamapps/libraryfolders.vdf"
CHOSEN_LIBRARY="$steam_library_path"
break
fi
fi
read -e -p "Enter the path to your Steam library: " steam_library_path
done
fi
if [[ -n "$libraryfolders_vdf" ]]; then
# Parse libraryfolders.vdf
while IFS= read -r line; do
if [[ "$line" =~ \"path\" ]]; then
local path=$(echo "$line" | sed 's/.*"\(.*\)".*/\1/')
if [[ -d "$path" && -r "$path" ]]; then
steam_library_paths+=("$path")
log "Found valid Steam library at: $path"
else
log "Found Steam library path but it's not accessible: $path"
fi
fi
done < <(grep "\"path\"" "$libraryfolders_vdf")
if [[ ${#steam_library_paths[@]} -gt 0 ]]; then
# Use the first library path found as the chosen library
CHOSEN_LIBRARY="${steam_library_paths[0]}"
log "Selected Steam library: $CHOSEN_LIBRARY"
else
error_exit "No accessible Steam library paths found in libraryfolders.vdf"
fi
else
error_exit "Steam library not found"
fi
}
configure_steam_libraries() {
set_current_task "Configuring Steam libraries"
# Only log to file, don't display to user
log "Configuring Steam libraries..."
# Make directories
local steam_config_directory="$CHOSEN_LIBRARY/steamapps/compatdata/$APPID/pfx/drive_c/Program Files (x86)/Steam/config"
log "Creating directory $steam_config_directory"
mkdir -p "$steam_config_directory" || error_exit "Failed to create directory $steam_config_directory"
# Copy or symlink libraryfolders.vdf to config directory
if [[ "$CHOSEN_LIBRARY" == "$HOME/.var/app/com.valvesoftware.Steam/.local/share/Steam" ]]; then
STEAM_IS_FLATPAK=1
# For Flatpak Steam, adjust the paths accordingly
log "Symlinking libraryfolders.vdf to config directory for Flatpak Steam"
ln -sf "$CHOSEN_LIBRARY/config/libraryfolders.vdf" "$steam_config_directory/libraryfolders.vdf" ||
log "Failed to symlink libraryfolders.vdf (Flatpak Steam)"
else
log "Symlinking libraryfolders.vdf to config directory"
ln -sf "$CHOSEN_LIBRARY/config/libraryfolders.vdf" "$steam_config_directory/libraryfolders.vdf" ||
log "Failed to symlink libraryfolders.vdf"
fi
# Backup existing libraryfolders.vdf if it exists
local pfx_libraryfolders="$CHOSEN_LIBRARY/steamapps/compatdata/$APPID/pfx/drive_c/Program Files (x86)/Steam/steamapps/libraryfolders.vdf"
if [[ -f "$pfx_libraryfolders" ]]; then
mv "$pfx_libraryfolders" "${pfx_libraryfolders}.bak" || log "Failed to backup libraryfolders.vdf"
fi
}
create_dotnet_cache_dir() {
set_current_task "Setting up .NET cache directory"
# Only log to file, don't display to user
log "Setting up .NET cache directory..."
local user_name=$(whoami)
local cache_dir="$APPLICATION_DIRECTORY/home/$user_name/.cache/dotnet_bundle_extract"
# Check if the directory already exists
if [ -d "$cache_dir" ]; then
log "Directory already exists: $cache_dir, skipping..."
return 0
fi
# Create the directory
mkdir -p "$cache_dir" || error_exit "Failed to create directory: $cache_dir"
log "Directory successfully created: $cache_dir"
}
cleanup_wine_procs() {
# Only log to file, don't display to user
log "Cleaning up any hanging Wine processes..."
# Find and kill processes
local processes=$(pgrep -f "WabbajackProton.exe|renderer=vulkan|win7|win10|ShowDotFiles|MicrosoftEdgeWebView2RuntimeInstallerX64-WabbajackProton.exe")
if [[ -n "$processes" ]]; then
echo "$processes" | xargs -r kill -9
log "Processes killed successfully"
else
log "No matching wine processes found"
fi
}
# Show detection summary and ask for confirmation
show_detection_summary() {
echo ""
echo "───────────────────────────────────────────────────────────────────"
echo -e "\e[1mDetection Summary:\e[0m" | tee -a "$LOGFILE"
echo -e "===================" | tee -a "$LOGFILE"
echo -e "Wabbajack Path: \e[32m\"$APPLICATION_DIRECTORY\"\e[0m" | tee -a "$LOGFILE"
echo -e "Steam App ID: \e[32m$APPID\e[0m" | tee -a "$LOGFILE"
echo -e "Compatdata Path: \e[32m$COMPAT_DATA_PATH\e[0m" | tee -a "$LOGFILE"
echo -e "Steam Library: \e[32m$CHOSEN_LIBRARY\e[0m" | tee -a "$LOGFILE"
echo -e "Protontricks: \e[32m$WHICH_PROTONTRICKS\e[0m" | tee -a "$LOGFILE"
# Show Steam Deck status if detected
if [[ $STEAMDECK -eq 1 ]]; then
echo -e "Running on: \e[32mSteam Deck\e[0m" | tee -a "$LOGFILE"
fi
# Show SD Card status if detected
if [[ "$CHOSEN_LIBRARY" == "/run/media"* ]] || [[ "$APPLICATION_DIRECTORY" == "/run/media"* ]]; then
echo -e "SD Card: \e[32mDetected\e[0m" | tee -a "$LOGFILE"
fi
echo "───────────────────────────────────────────────────────────────────"
# Show confirmation with retry loop
while true; do
read -rp $'\e[32mDo you want to proceed with the installation? (y/N)\e[0m ' proceed
if [[ $proceed =~ ^[Yy]$ ]]; then
break
elif [[ $proceed =~ ^[Nn]$ ]] || [[ -z $proceed ]]; then
log "Installation cancelled by user"
display "Installation cancelled." "$YELLOW"
cleanup_wine_procs
exit 0
fi
display "Please enter 'y' for yes or 'n' for no." "$YELLOW"
done
# Add padding after user confirmation
echo ""
}
# --- Discovery Phase ---
discovery_phase() {
# All detection, user input, and variable gathering
display_banner
log_section "Initial Setup"
cleanup_wine_procs
CURRENT_TASK_NUM=0
IN_MODIFICATION_PHASE=0
log_section "Environment Detection"
detect_steamdeck
detect_protontricks
setup_protontricks_alias
check_protontricks_version
log_section "Path Detection"
get_wabbajack_path
detect_compatdata_path
detect_link_steam_library
show_detection_summary
}
# --- Configuration Phase ---
configuration_phase() {
# All actions that change the system, using only variables set above
IN_MODIFICATION_PHASE=1
CURRENT_TASK_NUM=0
log_section "Environment Configuration"
set_protontricks_perms
set_current_task "Applying initial system.reg (phase 1)"
download_file "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/main/files/system.reg.wj.win7" "$COMPAT_DATA_PATH/pfx/system.reg" "Phase 1 system.reg"
webview_installer
set_current_task "Applying final system.reg and user.reg"
download_file "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/main/files/system.reg.wj" "$COMPAT_DATA_PATH/pfx/system.reg" "Final system.reg"
download_file "https://raw.githubusercontent.com/Omni-guides/Wabbajack-Modlist-Linux/main/files/user.reg.wj" "$COMPAT_DATA_PATH/pfx/user.reg" "Final user.reg"
configure_steam_libraries
create_dotnet_cache_dir
log_section "Final Cleanup"
cleanup_wine_procs
set_current_task "Complete"
echo -e "\n"
echo "───────────────────────────────────────────────────────────────────"
log_section "Setup Complete"
display "✓ Installation completed successfully!" "$GREEN"
echo -e "\n📝 Next Steps:"
echo " • Launch Wabbajack through Steam"
echo " • When Wabbajack opens, verify you can log in to Nexus from the Settings option"
echo " • Begin downloading and installing your modlist"
echo -e "\n💡 If you encounter any issues:"
echo " • Check the log file at: $LOGFILE"
echo " • Join the #unofficial-linux-support channel on the Wabbajack Discord"
echo " • Ensure you've followed all modlist-specific requirements"
echo "───────────────────────────────────────────────────────────────────"
echo -e "\n"
if [[ $STEAM_IS_FLATPAK -eq 1 ]]; then
display "Flatpak Steam is in use. You may need to add a permissions override so that Wabbajack can access the directories." "$YELLOW"
display "For example, if you wanted to install a modlist to /home/user/Games/Skyrim/Modlistname, you would need to run:" "$YELLOW"
display "flatpak override --user com.valvesoftware.Steam --filesystem=\"/home/user/Games\"" "$YELLOW"
fi
echo -e "\n${YELLOW}IMPORTANT: For best compatibility, add the following line to the Launch Options of your Wabbajack Steam entry:${RESET}"
echo -e "\n${GREEN}PROTON_USE_WINED3D=1 %command%${RESET}\n"
echo -e "This can help resolve certain graphics issues with Wabbajack running under Proton."
exit 0
}
# --- Main Execution ---
main() {
log_section "Script version $SCRIPT_VERSION started at: $(date +'%Y-%m-%d %H:%M:%S')"
if [[ $VERBOSE -eq 1 ]]; then
display "Verbose mode enabled" "$YELLOW"
fi
# Discovery Phase
discovery_phase
# Configuration Phase
configuration_phase
}
# Run the main function
main

494
binaries/WabbajackWine.sh Normal file
View File

@@ -0,0 +1,494 @@
#!/usr/bin/env bash
#
##############################################################
# #
# Attempt to automate installing Wabbajack on Linux via Wine #
# #
# Alpha v0.13 - Omni, from 08/02/25 #
# #
##############################################################
# - v0.01 - Initial script structure.
# - v0.02 - Added function for detecting the wine version.
# - v0.02 - Added function for setting Wabbajack directry path.
# - v0.02 - Added function for setting wine prefix path.
# - v0.02 - Added function to create the Wabbajack directory and wine prefix.
# - v0.03 - Added function to download required .exe files.
# - v0.03 - Added function to install and configure WebView and set up Wabbajack Application entry.
# - v0.04 - Added function to try to detect the Steam library.
# - v0.04 - Added function to create a Desktop item.
# - v0.04 - Added function to ask if Wabbajack should be started now.
# - v0.05 - Tweak to wine version comparison removing the requirement for 'bc'.
# - v0.06 - Remove references to $HOME for downloading and installing WebView.
# - v0.07 - Added capture of spaces in provided directory name - unsupported.
# - v0.08 - Added colouring to the text output to better distinguish questions, warnings and informationals.
# - v0.09 - Reworked the steam library detection to include confirmation if library detected, user defined path as desired.
# - v0.10 - Completely replace Steam Library symlink with modified copy of libraryfolders.vdf - this should handle all Steam Libraries, and not just the default library
# - v0.11 - create a dotnet_bundle_extract directory which seems required on some distros (harmless on others)
# - v0.12 - Fixed incorrect path in Desktop Shortcut creation (thanks valkari)
# - v0.13 - Modified Wine Version detection so that Wine 10 as well as future versions should be handled correctly.
# Current Script Version (alpha)
script_ver=0.13
# Today's date
date=$(date +"%d%m%y")
# Set up and blank logs
LOGFILE=$HOME/wabbajack-via-wine-sh.log
echo "" >$HOME/wabbajack-via-wine-sh.log
#set -x
######################
# Fancy banner thing #
######################
if [ -f "/usr/bin/toilet" ]; then
toilet -t -f smmono12 -F border:metal "Omni-Guides (alpha)"
else
echo "=================================================================================================="
echo "| ####### ## ## ## ## #### ###### ## ## #### ######## ######## ###### |"
echo "| ## ## ### ### ### ## ## ## ## ## ## ## ## ## ## ## ## |"
echo "| ## ## #### #### #### ## ## ## ## ## ## ## ## ## ## |"
echo "| ## ## ## ### ## ## ## ## ## ####### ## #### ## ## ## ## ## ###### ###### |"
echo "| ## ## ## ## ## #### ## ## ## ## ## ## ## ## ## ## |"
echo "| ## ## ## ## ## ### ## ## ## ## ## ## ## ## ## ## ## |"
echo "| ####### ## ## ## ## #### ###### ####### #### ######## ######## ###### |"
echo "============================================================================~~--(alpha)--~~======="
fi
#########
# Intro #
#########
echo ""
echo -e "This is an experimental script - an attempt to automate as much as possible of the process of getting"
echo -e "Wabbajack running on Linux. Please be aware that stability of the Wabbajack application is not guaranteed."
echo -e "Please use at your own risk and accept that in the worst case, you may have to re-run this script to "
echo -e "create a new prefix for WabbaJack. You can report back to me via GitHub or the Official Wabbajack Discord"
echo -e "if you discover an issue with this script. Any other feedback, positive or negative, is also most welcome."
echo -e "\e[32m\nPress any key to continue...\e[0m"
echo
read -n 1 -s -r -p ""
#############
# Functions #
#############
######################################
# Detect Wine and winetricks version #
######################################
detect_wine_version() {
# Which version of wine is installed?
wine_binary=$(which wine)
echo -e "Wine Binary Path: $wine_binary" >>$LOGFILE 2>&1
# Extract the Wine version numbers
wine_version=$(wine --version | grep -oE '[0-9]+\.[0-9]+')
echo -e "Wine Version: $wine_version" >>$LOGFILE 2>&1
# Split major and minor version
major_version=$(echo "$wine_version" | cut -d. -f1)
minor_version=$(echo "$wine_version" | cut -d. -f2)
# Convert to integers for proper numerical comparison
if (( major_version < 9 )) || (( major_version == 9 && minor_version < 15 )); then
echo -e "Wabbajack requires Wine newer than 9.15. Please arrange this on your system and rerun this script."
exit 0
else
echo -e "Wine version $wine_version, should be fine" >>$LOGFILE 2>&1
fi
# Is winetricks installed?
if [[ $(which winetricks) ]]; then
echo -e "Winetricks found at: $(which winetricks)" >>$LOGFILE 2>&1
else
echo -e "Winetricks not detected. Please arrange this on your system and rerun this script."
exit 0
fi
}
###########################
# Get Wabbajack Directory #
###########################
get_wineprefix_and_application_directory() {
local application_directory_prompt="Enter the path where you want to store your application directory: "
while true; do
# Prompt for the application directory
read -e -p "$application_directory_prompt" application_directory
echo
# Check for spaces in the directory path
if [[ $application_directory =~ " " ]]; then
# Suggest an alternative path without spaces
local suggested_path="${application_directory// /_}"
echo -e "\e[31m\nWARNING:\e[0m Spaces in directory paths can cause compatibility issues with some applications."
echo -e "\e[32m\nWould you like to use the following path instead: $suggested_path? (y/n)\e[0m"
read -r confirm
echo
if [[ $confirm == "y" || $confirm == "Y" ]]; then
application_directory="$suggested_path"
break # Break out of the outer loop
elif [[ $confirm == "n" || $confirm == "N" ]]; then
continue # Loop back to the beginning
else
echo -e "\e[31m\nInvalid input.\e[0m Please enter 'y' or 'n'."
fi
fi
# Confirm the application directory
while true; do
echo -e "\e[32m\nAre you sure you want to store the application directory in \"$application_directory\"? (y/n): \e[0m"
read -r confirm
echo
if [[ $confirm == "y" || $confirm == "Y" ]]; then
# Check for existing application directory and warn
break 2 # Break out of both loops
elif [[ $confirm == "n" || $confirm == "N" ]]; then
break # Break out of the inner loop, continue the outer loop
else
echo -e "\e[31m\nInvalid input.\e[0m Please enter 'y' or 'n'."
fi
done
done
local wineprefix_prompt="Do you want to create the Wine prefix in the default location (\"$application_directory/.wine\")? (y/n): "
# Ask about the default Wine prefix location
read -e -p "$wineprefix_prompt" confirm
if [[ $confirm == "y" || $confirm == "Y" ]]; then
# Set the Wine prefix in the default location
export wineprefix="$application_directory/.wine"
else
# Call the get_wineprefix function to get the custom Wine prefix
set_wineprefix
fi
echo "Application Directory Path: $application_directory." >>$LOGFILE 2>&1
echo "Wine Prefix Path: $wineprefix" >>$LOGFILE 2>&1
}
###################
# Set Wine Prefix #
###################
set_wineprefix() {
local wineprefix_prompt="Enter the path where you want to store your Wine prefix: "
while true; do
# Prompt for the path, allowing tab completion
read -e -p "$wineprefix_prompt" wineprefix
echo
# Confirm the path
while true; do
echo -e "\e[32m\nAre you sure you want to store the Wine prefix in \"$wineprefix\"? (y/n): \e[0m"
read -r confirm
echo
if [[ $confirm == "y" || $confirm == "Y" ]]; then
break
elif [[ $confirm == "n" || $confirm == "N" ]]; then
read -e -p "$wineprefix_prompt" wineprefix
else
echo -e "\e[31m\nInvalid input.\e[0m Please enter 'y' or 'n'."
fi
done
# Check for existing .wine directory
if [[ -d "$wineprefix/.wine" ]]; then
echo -e "\e[31m\nWARNING:\e[0m This will overwrite any existing directory in \"$wineprefix/.wine\"."
while true; do
echo "Continue? (y/n): "
read -r confirm
echo
if [[ $confirm == "y" || $confirm == "Y" ]]; then
break
elif [[ $confirm == "n" || $confirm == "N" ]]; then
read -e -p "$wineprefix_prompt" wineprefix
else
echo -e "\e[31m\nInvalid input.\e[0m Please enter 'y' or 'n'."
fi
done
else
break
fi
done
echo
# Set the wineprefix variable
export wineprefix
}
#########################################
# Create Wabbajack Directory and prefix #
#########################################
create_wine_environment() {
# Create the application directory if it doesn't exist
mkdir -p "$application_directory"
# Check if the Wine prefix exists and delete it if necessary
if [[ -d "$wineprefix" ]]; then
rm -rf "$wineprefix"
fi
# Create the Wine prefix directory
mkdir -p "$wineprefix"
# Set the WINEPREFIX variable and run wineboot
export WINEPREFIX="$wineprefix"
#export WINEDLLOVERRIDES="mscoree=d;mshtml=d"
wineboot >>$LOGFILE 2>&1
}
########################################################
# Download Webview Installer and Wabbajack Application #
########################################################
download_apps() {
echo -e "\e[33m\nDownloading Wabbajack Application...\e[0m"
# Check if Wabbajack.exe exists and skip download if so
if ! [ -f "$application_directory/Wabbajack.exe" ]; then
wget https://github.com/wabbajack-tools/wabbajack/releases/latest/download/Wabbajack.exe -O "$application_directory/Wabbajack.exe"
# Set as executable
chmod +x "$application_directory/Wabbajack.exe"
else
echo "Wabbajack.exe already exists, skipping download."
fi
echo -e "\e[33m\nDownloading WebView Installer...\e[0m"
# Check if MicrosoftEdgeWebView2RuntimeInstallerX64.exe exists and skip download if so
if ! [ -f "$application_directory/MicrosoftEdgeWebView2RuntimeInstallerX64.exe" ]; then
wget https://msedge.sf.dl.delivery.mp.microsoft.com/filestreamingservice/files/6d376ab4-4a07-4679-8918-e0dc3c0735c8/MicrosoftEdgeWebView2RuntimeInstallerX64.exe -O "$application_directory/MicrosoftEdgeWebView2RuntimeInstallerX64.exe"
else
echo "MicrosoftEdgeWebView2RuntimeInstallerX64.exe already exists, skipping download."
fi
}
############################################
# Install WebView, configure Wine settings #
############################################
install_and_configure() {
# set based on distro? harware?...
echo -e "\e[33m\nChanging the default renderer used..\e[0m" >>$LOGFILE 2>&1
WINEPREFIX=$wineprefix winetricks renderer=vulkan >>$LOGFILE 2>&1
# Install WebView
echo -e "\e[33m\nInstalling Webview, this can take a while, please be patient..\e[0m" >>$LOGFILE 2>&1
WINEPREFIX=$wineprefix wine $application_directory/MicrosoftEdgeWebView2RuntimeInstallerX64.exe >>$LOGFILE 2>&1
# Change prefix version
echo -e "\e[33m\nChange the default prefix version to win7..\e[0m" >>$LOGFILE 2>&1
WINEPREFIX=$wineprefix winecfg -v win7 >>$LOGFILE 2>&1
# Add Wabbajack as an application
echo -e "\e[33m\nAdding Wabbajack Application to customise settings..\e[0m" >>$LOGFILE 2>&1
cat <<EOF >$application_directory/WJApplication.reg
Windows Registry Editor Version 5.00
[HKEY_CURRENT_USER\Software\Wine\AppDefaults\Wabbajack.exe]
"Version"="win10"
EOF
WINEPREFIX=$wineprefix wine regedit $application_directory/WJApplication.reg >>$LOGFILE 2>&1
echo
}
#################################
# Detect and Link Steam Library #
#################################
detect_link_steam_library() {
# Possible Steam library locations
steam_library_locations=(
"$HOME/.local/share/Steam"
#"$HOME/.steam/steam/steamapps"
"$HOME/Library/Application Support/Steam"
"/opt/steam"
"/usr/share/Steam"
"/usr/local/share/Steam"
)
# Function to check if a directory is a Steam library
is_steam_library() {
local location="$1"
if [[ -d "$location" ]]; then
if find "$location/steamapps" -type f -name "libraryfolders.vdf" -print | grep -q "$location/steamapps/libraryfolders.vdf"; then
return 0
fi
fi
return 1
}
echo -e "\e[33mDiscovering Steam libraries..\e[0m"
# Find the first valid Steam library location
for location in "${steam_library_locations[@]}"; do
if is_steam_library "$location"; then
read -p "Found Steam install at '$location' Is this path correct for your Steam install? (y/n): " -r choice
if [[ "$choice" =~ ^[Yy]$ ]]; then
chosen_library="$location"
break
fi
fi
done
# If no library was found or the user declined, ask for a custom path
if [[ -z "$chosen_library" ]]; then
read -e -p "Enter the path to your main Steam directory: " steam_library_path
while true; do
if [[ ! -d "$steam_library_path" ]]; then
echo -e "\e[31m\nInvalid path.\e[0m Please enter a valid directory."
elif ! is_steam_library "$steam_library_path"; then
echo -e "\e[31m\nThe specified path does not appear to be a Steam directory. Please check the path and try again. Do not enter the path for a secondary Steam Library, only the path for your actual Steam install.\e[0m"
else
read -p "Confirm using '$steam_library_path' as the Steam directory path? (y/n): " -r choice
if [[ "$choice" =~ ^[Yy]$ ]]; then
chosen_library="$steam_library_path"
break
fi
fi
read -e -p "Enter the path to your Steam library: " steam_library_path
done
fi
# If a valid library was found, print its location and create the symlink
if [[ -n "$chosen_library" ]]; then
echo "Steam library found at: $chosen_library" >>$LOGFILE 2>&1
configure_steam_libraries
else
echo -e "\e[31m\nSteam library not found. Please check the installation.\e[0m"
fi
}
configure_steam_libraries() {
# Make directories
#wineprefix=/home/deck/WJTest
steam_config_directory="$wineprefix/drive_c/Program Files (x86)/Steam/config"
echo -e "Creating directory $steam_config_directory" >>$LOGFILE 2>&1
mkdir -p "$steam_config_directory"
# copy real libraryfolders.vdf to config directory
echo -e "Copying libraryfolders.vdf to config directory" >>$LOGFILE 2>&1
cp "$chosen_library/config/libraryfolders.vdf" "$steam_config_directory/."
# Edit this new libraryfolders.vdf file to convert linux path to Z:\ path with double backslashes
sed -E 's|("path"[[:space:]]+)"(/)|\1"Z:\\\\|; s|/|\\\\|g' "$steam_config_directory/libraryfolders.vdf" > "$steam_config_directory/libraryfolders2.vdf"
cp "$steam_config_directory/libraryfolders2.vdf" "$steam_config_directory/libraryfolders.vdf"
rm "$steam_config_directory/libraryfolders2.vdf"
}
##########################################
# Create dotnet_bundle_extract directory #
##########################################
create_dotnet_cache_dir() {
local user_name=$(whoami)
local cache_dir="$application_directory/home/$user_name/.cache/dotnet_bundle_extract"
mkdir -p "$cache_dir"
}
############################
# Create Desktop Shortcut? #
############################
create_desktop_shortcut() {
echo -e "\e[32m\nDo you want to create a desktop shortcut for Wabbajack? (y/n):\e[0m"
read -r create_shortcut
if [[ $create_shortcut == "y" || $create_shortcut == "Y" ]]; then
desktop_file="$HOME/Desktop/Wabbajack.desktop"
cat >"$desktop_file" <<EOF
[Desktop Entry]
Name=Wabbajack
Exec=env HOME="$HOME" WINEPREFIX=$wineprefix wine $application_directory/Wabbajack.exe
Type=Application
StartupNotify=true
Path=$application_directory
Icon=$application_directory/Wabbajack.ico
EOF
chmod +x "$desktop_file"
echo -e "\e[33m\nDesktop shortcut created at $desktop_file\e[0m"
#Grab an icon for it
wget -q -O $application_directory/Wabbajack.ico https://raw.githubusercontent.com/wabbajack-tools/wabbajack/main/Wabbajack.Launcher/Assets/wabbajack.ico
fi
}
####################
# Start Wabbajack? #
####################
start_wabbajack() {
echo -e "\e[32m\nDo you want to start Wabbajack now? (y/n):\e[0m"
read -r start_wabbajack
if [[ $start_wabbajack == "y" || $start_wabbajack == "Y" ]]; then
# Run Wabbajack
echo -e "\e[33m\nStarting Wabbajack...\e[0m"
cd $application_directory
WINEPREFIX=$wineprefix WINEDEBUG=-all wine $application_directory/Wabbajack.exe >>$LOGFILE 2>&1
fi
}
#####################
# Run the Functions #
#####################
# Detect Wine and winetricks version
detect_wine_version
# Get Wabbajack Directory
get_wineprefix_and_application_directory
# Create Wabbajack Directory
create_wine_environment
# Download Webview Installer and Wabbajack Application
download_apps
# Install WebView, configure Wine settings
install_and_configure
# Detect and Link Steam Library
detect_link_steam_library
# Create dotnet_bundle_extract directory
create_dotnet_cache_dir
# Create Desktop Shortcut?
create_desktop_shortcut
# Start Wabbajack?
start_wabbajack
echo -e "\e[32m\nSet up complete.\e[0m"
exit

File diff suppressed because it is too large Load Diff

1711
binaries/omni-guides.sh Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,4 +5,4 @@ This package provides both CLI and GUI interfaces for managing
Wabbajack modlists natively on Linux systems.
"""
__version__ = "0.1.2"
__version__ = "0.1.1"

View File

@@ -168,7 +168,7 @@ def main():
print(f"Error: {diagnosis['error']}")
return
print(f"\n📊 Diagnosis Results:")
print(f"\nDiagnosis Results:")
print(f" Average CPU: {diagnosis['avg_cpu']:.1f}% (Range: {diagnosis['min_cpu']:.1f}% - {diagnosis['max_cpu']:.1f}%)")
print(f" Memory usage: {diagnosis['avg_memory_mb']:.1f}MB (Peak: {diagnosis['max_memory_mb']:.1f}MB)")
print(f" Low CPU samples: {diagnosis['low_cpu_samples']}/{diagnosis['samples']} "

View File

@@ -761,7 +761,14 @@ class ModlistHandler:
# Conditionally update binary and working directory paths
# Skip for jackify-engine workflows since paths are already correct
if not getattr(self, 'engine_installed', False):
steam_libraries = [self.steam_library] if self.steam_library else None
# Convert steamapps/common path to library root path
steam_libraries = None
if self.steam_library:
# self.steam_library is steamapps/common, need to go up 2 levels to get library root
steam_library_root = Path(self.steam_library).parent.parent
steam_libraries = [steam_library_root]
self.logger.debug(f"Using Steam library root: {steam_library_root}")
if not self.path_handler.edit_binary_working_paths(
modlist_ini_path=modlist_ini_path_obj,
modlist_dir_path=modlist_dir_path_obj,

View File

@@ -723,13 +723,17 @@ class ModlistInstallCLI:
if chunk == b'\n':
# Complete line - decode and print
line = buffer.decode('utf-8', errors='replace')
print(line, end='')
# Enhance Nexus download errors with modlist context
enhanced_line = self._enhance_nexus_error(line)
print(enhanced_line, end='')
buffer = b''
last_progress_time = time.time()
elif chunk == b'\r':
# Carriage return - decode and print without newline
line = buffer.decode('utf-8', errors='replace')
print(line, end='')
# Enhance Nexus download errors with modlist context
enhanced_line = self._enhance_nexus_error(line)
print(enhanced_line, end='')
sys.stdout.flush()
buffer = b''
last_progress_time = time.time()
@@ -1098,4 +1102,36 @@ class ModlistInstallCLI:
print(f"Nexus API Key: [SET]")
else:
print(f"Nexus API Key: [NOT SET - WILL LIKELY FAIL]")
print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}")
print(f"{COLOR_INFO}----------------------------------------{COLOR_RESET}")
def _enhance_nexus_error(self, line: str) -> str:
"""
Enhance Nexus download error messages by adding the mod URL for easier troubleshooting.
"""
import re
# Pattern to match Nexus download errors with ModID and FileID
nexus_error_pattern = r"Failed to download '[^']+' from Nexus \(Game: ([^,]+), ModID: (\d+), FileID: \d+\):"
match = re.search(nexus_error_pattern, line)
if match:
game_name = match.group(1)
mod_id = match.group(2)
# Map game names to Nexus URL segments
game_url_map = {
'SkyrimSpecialEdition': 'skyrimspecialedition',
'Skyrim': 'skyrim',
'Fallout4': 'fallout4',
'FalloutNewVegas': 'newvegas',
'Oblivion': 'oblivion',
'Starfield': 'starfield'
}
game_url = game_url_map.get(game_name, game_name.lower())
mod_url = f"https://www.nexusmods.com/{game_url}/mods/{mod_id}"
# Add URL on next line for easier debugging
return f"{line}\n Nexus URL: {mod_url}"
return line

View File

@@ -815,11 +815,12 @@ class PathHandler:
subpath = value_part[idx:].lstrip('/')
correct_steam_lib = None
for lib in steam_libraries:
if (lib / subpath.split('/')[2]).exists():
correct_steam_lib = lib.parent
# Check if the actual game folder exists in this library
if len(subpath.split('/')) > 3 and (lib / subpath.split('/')[2] / subpath.split('/')[3]).exists():
correct_steam_lib = lib
break
if not correct_steam_lib and steam_libraries:
correct_steam_lib = steam_libraries[0].parent
correct_steam_lib = steam_libraries[0]
if correct_steam_lib:
new_binary_path = f"{drive_prefix}/{correct_steam_lib}/{subpath}".replace('\\', '/').replace('//', '/')
else:

View File

@@ -1,141 +0,0 @@
import os
import sys
import json
import requests
import shutil
import tempfile
import time
from pathlib import Path
GITHUB_OWNER = "Omni-guides"
GITHUB_REPO = "Jackify"
ASSET_NAME = "jackify"
CONFIG_DIR = os.path.expanduser("~/.config/jackify")
TOKEN_PATH = os.path.join(CONFIG_DIR, "github_token")
LAST_CHECK_PATH = os.path.join(CONFIG_DIR, "last_update_check.json")
THROTTLE_HOURS = 6
def get_github_token():
if os.path.exists(TOKEN_PATH):
with open(TOKEN_PATH, "r") as f:
return f.read().strip()
return None
def get_latest_release_info():
url = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest"
headers = {}
token = get_github_token()
if token:
headers["Authorization"] = f"token {token}"
resp = requests.get(url, headers=headers, verify=True)
if resp.status_code == 200:
return resp.json()
else:
raise RuntimeError(f"Failed to fetch release info: {resp.status_code} {resp.text}")
def get_current_version():
# This should match however Jackify stores its version
try:
from jackify import __version__
return __version__
except ImportError:
return None
def should_check_for_update():
try:
if os.path.exists(LAST_CHECK_PATH):
with open(LAST_CHECK_PATH, "r") as f:
data = json.load(f)
last_check = data.get("last_check", 0)
now = int(time.time())
if now - last_check < THROTTLE_HOURS * 3600:
return False
return True
except Exception as e:
print(f"[WARN] Could not read last update check timestamp: {e}")
return True
def record_update_check():
try:
with open(LAST_CHECK_PATH, "w") as f:
json.dump({"last_check": int(time.time())}, f)
except Exception as e:
print(f"[WARN] Could not write last update check timestamp: {e}")
def check_for_update():
if not should_check_for_update():
return False, None, None
try:
release = get_latest_release_info()
latest_version = release["tag_name"].lstrip("v")
current_version = get_current_version()
if current_version is None:
print("[WARN] Could not determine current version.")
record_update_check()
return False, None, None
if latest_version > current_version:
record_update_check()
return True, latest_version, release
record_update_check()
return False, latest_version, release
except Exception as e:
print(f"[ERROR] Update check failed: {e}")
record_update_check()
return False, None, None
def download_latest_asset(release):
token = get_github_token()
headers = {"Accept": "application/octet-stream"}
if token:
headers["Authorization"] = f"token {token}"
for asset in release["assets"]:
if asset["name"] == ASSET_NAME:
download_url = asset["url"]
resp = requests.get(download_url, headers=headers, stream=True, verify=True)
if resp.status_code == 200:
return resp.content
else:
raise RuntimeError(f"Failed to download asset: {resp.status_code} {resp.text}")
raise RuntimeError(f"Asset '{ASSET_NAME}' not found in release.")
def replace_current_binary(new_binary_bytes):
current_exe = os.path.realpath(sys.argv[0])
backup_path = current_exe + ".bak"
try:
# Write to a temp file first
with tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(current_exe)) as tmpf:
tmpf.write(new_binary_bytes)
tmp_path = tmpf.name
# Backup current binary
shutil.copy2(current_exe, backup_path)
# Replace atomically
os.replace(tmp_path, current_exe)
os.chmod(current_exe, 0o755)
print(f"[INFO] Updated binary written to {current_exe}. Backup at {backup_path}.")
return True
except Exception as e:
print(f"[ERROR] Failed to replace binary: {e}")
return False
def main():
if '--update' in sys.argv:
print("Checking for updates...")
update_available, latest_version, release = check_for_update()
if update_available:
print(f"A new version (v{latest_version}) is available. Downloading...")
try:
new_bin = download_latest_asset(release)
if replace_current_binary(new_bin):
print("Update complete! Please restart Jackify.")
else:
print("Update failed during binary replacement.")
except Exception as e:
print(f"[ERROR] Update failed: {e}")
else:
print("You are already running the latest version.")
sys.exit(0)
# For direct CLI testing
if __name__ == "__main__":
main()

View File

@@ -101,7 +101,7 @@ class AutomatedPrefixService:
logger.info(f" Native Steam service created shortcut successfully with AppID: {app_id}")
return True, app_id
else:
logger.error("Native Steam service failed to create shortcut")
logger.error("Native Steam service failed to create shortcut")
return False, None
except Exception as e:
@@ -471,7 +471,7 @@ exit"""
logger.warning(f"Error running protontricks -l on attempt {i+1}: {e}")
time.sleep(1)
logger.error(f"Shortcut '{shortcut_name}' not found in protontricks after 30 seconds")
logger.error(f"Shortcut '{shortcut_name}' not found in protontricks after 30 seconds")
return None
except Exception as e:
@@ -939,7 +939,7 @@ echo Prefix creation complete.
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.TimeoutExpired):
continue
logger.info(" No more processes to kill")
logger.info("No more processes to kill")
return True
except Exception as e:
@@ -1296,7 +1296,7 @@ echo Prefix creation complete.
time.sleep(1)
logger.warning(f"Timeout waiting for prefix completion after {timeout} seconds")
logger.warning(f"Timeout waiting for prefix completion after {timeout} seconds")
return False
except Exception as e:
@@ -1356,7 +1356,7 @@ echo Prefix creation complete.
if killed_count > 0:
logger.info(f" Killed {killed_count} ModOrganizer processes")
else:
logger.warning("No ModOrganizer processes found to kill")
logger.warning("No ModOrganizer processes found to kill")
return killed_count
@@ -1624,11 +1624,11 @@ echo Prefix creation complete.
return True
logger.error(f"Shortcut '{shortcut_name}' not found for CompatTool setting")
logger.error(f"Shortcut '{shortcut_name}' not found for CompatTool setting")
return False
except Exception as e:
logger.error(f"Error setting CompatTool on shortcut: {e}")
logger.error(f"Error setting CompatTool on shortcut: {e}")
return False
def _set_proton_on_shortcut(self, shortcut_name: str) -> bool:
@@ -2633,7 +2633,7 @@ echo Prefix creation complete.
logger.info(f" Proton prefix created at: {pfx}")
return True
else:
logger.warning(f"⚠️ Proton prefix not found at: {pfx}")
logger.warning(f"Proton prefix not found at: {pfx}")
return False
except subprocess.TimeoutExpired:
@@ -2735,7 +2735,7 @@ echo Prefix creation complete.
logger.info(" Compatibility tool persists")
return True
else:
logger.warning("⚠️ Compatibility tool not found")
logger.warning("Compatibility tool not found")
return False
except Exception as e:

View File

@@ -228,14 +228,14 @@ class NativeSteamService:
# Write back to file
if self.write_shortcuts_vdf(data):
logger.info(f"Shortcut created successfully at index {next_index}")
logger.info(f"Shortcut created successfully at index {next_index}")
return True, unsigned_app_id
else:
logger.error("Failed to write shortcut to VDF")
logger.error("Failed to write shortcut to VDF")
return False, None
except Exception as e:
logger.error(f"Error creating shortcut: {e}")
logger.error(f"Error creating shortcut: {e}")
return False, None
def set_proton_version(self, app_id: int, proton_version: str = "proton_experimental") -> bool:
@@ -320,11 +320,11 @@ class NativeSteamService:
with open(config_path, 'w', encoding='utf-8') as f:
f.write(new_config_text)
logger.info(f"Successfully set Proton version '{proton_version}' for AppID {app_id} using config.vdf only (steam-conductor method)")
logger.info(f"Successfully set Proton version '{proton_version}' for AppID {app_id} using config.vdf only (steam-conductor method)")
return True
except Exception as e:
logger.error(f"Error setting Proton version: {e}")
logger.error(f"Error setting Proton version: {e}")
return False
def create_shortcut_with_proton(self, app_name: str, exe_path: str, start_dir: str = None,
@@ -351,7 +351,7 @@ class NativeSteamService:
logger.error("Failed to set Proton version (shortcut still created)")
return False, app_id # Shortcut exists but Proton setting failed
logger.info(f"Complete workflow successful: '{app_name}' with '{proton_version}'")
logger.info(f"Complete workflow successful: '{app_name}' with '{proton_version}'")
return True, app_id
def list_shortcuts(self) -> Dict[str, str]:
@@ -388,12 +388,12 @@ class NativeSteamService:
# Write back
if self.write_shortcuts_vdf(data):
logger.info(f"Removed shortcut '{app_name}'")
logger.info(f"Removed shortcut '{app_name}'")
return True
else:
logger.error("Failed to write updated shortcuts")
logger.error("Failed to write updated shortcuts")
return False
except Exception as e:
logger.error(f"Error removing shortcut: {e}")
logger.error(f"Error removing shortcut: {e}")
return False

View File

@@ -33,6 +33,7 @@ class UpdateInfo:
download_url: str
file_size: Optional[int] = None
is_critical: bool = False
is_delta_update: bool = False
class UpdateService:
@@ -72,24 +73,44 @@ class UpdateService:
latest_version = release_data['tag_name'].lstrip('v')
if self._is_newer_version(latest_version):
# Find AppImage asset
# Check if this version was skipped
if self._is_version_skipped(latest_version):
logger.debug(f"Version {latest_version} was skipped by user")
return None
# Find AppImage asset (prefer delta update if available)
download_url = None
file_size = None
# Look for delta update first (smaller download)
for asset in release_data.get('assets', []):
if asset['name'].endswith('.AppImage'):
if asset['name'].endswith('.AppImage.delta') or 'delta' in asset['name'].lower():
download_url = asset['browser_download_url']
file_size = asset['size']
logger.debug(f"Found delta update: {asset['name']} ({file_size} bytes)")
break
# Fallback to full AppImage if no delta available
if not download_url:
for asset in release_data.get('assets', []):
if asset['name'].endswith('.AppImage'):
download_url = asset['browser_download_url']
file_size = asset['size']
logger.debug(f"Found full AppImage: {asset['name']} ({file_size} bytes)")
break
if download_url:
# Determine if this is a delta update
is_delta = '.delta' in download_url or 'delta' in download_url.lower()
return UpdateInfo(
version=latest_version,
tag_name=release_data['tag_name'],
release_date=release_data['published_at'],
changelog=release_data.get('body', ''),
download_url=download_url,
file_size=file_size
file_size=file_size,
is_delta_update=is_delta
)
else:
logger.warning(f"No AppImage found in release {latest_version}")
@@ -123,6 +144,25 @@ class UpdateService:
logger.warning(f"Could not parse version: {version}")
return False
def _is_version_skipped(self, version: str) -> bool:
"""
Check if a version was skipped by the user.
Args:
version: Version to check
Returns:
bool: True if version was skipped, False otherwise
"""
try:
from ...backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
skipped_versions = config_handler.get('skipped_versions', [])
return version in skipped_versions
except Exception as e:
logger.warning(f"Error checking skipped versions: {e}")
return False
def check_for_updates_async(self, callback: Callable[[Optional[UpdateInfo]], None]) -> None:
"""
Check for updates in background thread.
@@ -152,16 +192,25 @@ class UpdateService:
logger.debug("Not running as AppImage - updates not supported")
return False
appimage_path = get_appimage_path()
if not appimage_path:
logger.debug("AppImage path validation failed - updates not supported")
return False
if not can_self_update():
logger.debug("Cannot write to AppImage - updates not possible")
return False
logger.debug(f"Self-updating enabled for AppImage: {appimage_path}")
return True
def download_update(self, update_info: UpdateInfo,
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
"""
Download update to temporary location.
Download update using full AppImage replacement.
Since we can't rely on external tools being available, we use a reliable
full replacement approach that works on all systems without dependencies.
Args:
update_info: Information about the update to download
@@ -171,7 +220,27 @@ class UpdateService:
Path to downloaded file, or None if download failed
"""
try:
logger.info(f"Downloading update {update_info.version} from {update_info.download_url}")
logger.info(f"Downloading update {update_info.version} (full replacement)")
return self._download_update_manual(update_info, progress_callback)
except Exception as e:
logger.error(f"Failed to download update: {e}")
return None
def _download_update_manual(self, update_info: UpdateInfo,
progress_callback: Optional[Callable[[int, int], None]] = None) -> Optional[Path]:
"""
Fallback manual download method.
Args:
update_info: Information about the update to download
progress_callback: Optional callback for download progress
Returns:
Path to downloaded file, or None if download failed
"""
try:
logger.info(f"Manual download of update {update_info.version} from {update_info.download_url}")
response = requests.get(update_info.download_url, stream=True)
response.raise_for_status()
@@ -179,11 +248,12 @@ class UpdateService:
total_size = int(response.headers.get('content-length', 0))
downloaded_size = 0
# Create temporary file
temp_dir = Path(tempfile.gettempdir()) / "jackify_updates"
temp_dir.mkdir(exist_ok=True)
# Create update directory in user's home directory
home_dir = Path.home()
update_dir = home_dir / "Jackify" / "updates"
update_dir.mkdir(parents=True, exist_ok=True)
temp_file = temp_dir / f"Jackify-{update_info.version}.AppImage"
temp_file = update_dir / f"Jackify-{update_info.version}.AppImage"
with open(temp_file, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
@@ -197,11 +267,11 @@ class UpdateService:
# Make executable
temp_file.chmod(0o755)
logger.info(f"Update downloaded successfully to {temp_file}")
logger.info(f"Manual update downloaded successfully to {temp_file}")
return temp_file
except Exception as e:
logger.error(f"Failed to download update: {e}")
logger.error(f"Failed to download update manually: {e}")
return None
def apply_update(self, new_appimage_path: Path) -> bool:
@@ -252,10 +322,12 @@ class UpdateService:
Path to helper script, or None if creation failed
"""
try:
temp_dir = Path(tempfile.gettempdir()) / "jackify_updates"
temp_dir.mkdir(exist_ok=True)
# Create update directory in user's home directory
home_dir = Path.home()
update_dir = home_dir / "Jackify" / "updates"
update_dir.mkdir(parents=True, exist_ok=True)
helper_script = temp_dir / "update_helper.sh"
helper_script = update_dir / "update_helper.sh"
script_content = f'''#!/bin/bash
# Jackify Update Helper Script

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -100,7 +100,7 @@ class UlimitGuidanceDialog(QDialog):
status_text = "✓ Optimal"
status_color = "#4caf50" # Green
elif self.status['can_increase']:
status_text = "Can Improve"
status_text = "Can Improve"
status_color = "#ff9800" # Orange
else:
status_text = "✗ Needs Manual Fix"
@@ -222,7 +222,7 @@ class UlimitGuidanceDialog(QDialog):
# Warning
warning_label = QLabel(
"⚠️ WARNING: These commands require root/sudo privileges and modify system files. "
"WARNING: These commands require root/sudo privileges and modify system files. "
"Make sure you understand what each command does before running it."
)
warning_label.setWordWrap(True)
@@ -478,7 +478,7 @@ class UlimitGuidanceDialog(QDialog):
status_text = "✓ Optimal"
status_color = "#4caf50" # Green
elif self.status['can_increase']:
status_text = "Can Improve"
status_text = "Can Improve"
status_color = "#ff9800" # Orange
else:
status_text = "✗ Needs Manual Fix"

View File

@@ -78,8 +78,8 @@ class UpdateDialog(QDialog):
# Update icon (if available)
icon_label = QLabel()
icon_label.setText("🔄") # Simple emoji for now
icon_label.setStyleSheet("font-size: 32px;")
icon_label.setText("^") # Update arrow symbol
icon_label.setStyleSheet("font-size: 24px; color: #3fd0ea; font-weight: bold;")
header_layout.addWidget(icon_label)
# Update title
@@ -89,6 +89,7 @@ class UpdateDialog(QDialog):
title_font.setPointSize(14)
title_font.setBold(True)
title_label.setFont(title_font)
title_label.setStyleSheet("color: #3fd0ea;")
title_layout.addWidget(title_label)
subtitle_label = QLabel(f"Current version: v{self.update_service.current_version}")
@@ -103,7 +104,8 @@ class UpdateDialog(QDialog):
# File size info
if self.update_info.file_size:
size_mb = self.update_info.file_size / (1024 * 1024)
size_label = QLabel(f"Download size: {size_mb:.1f} MB")
update_type = "Delta update" if self.update_info.is_delta_update else "Full update"
size_label = QLabel(f"{update_type} - Download size: {size_mb:.1f} MB")
size_label.setStyleSheet("color: #666; margin-bottom: 10px;")
layout.addWidget(size_label)
@@ -157,29 +159,53 @@ class UpdateDialog(QDialog):
button_layout.addStretch()
self.download_button = QPushButton("Download & Install Update")
self.download_button = QPushButton("Download && Install Update")
self.download_button.setDefault(True)
self.download_button.clicked.connect(self.start_download)
button_layout.addWidget(self.download_button)
self.install_button = QPushButton("Install & Restart")
self.install_button = QPushButton("Install && Restart")
self.install_button.setVisible(False)
self.install_button.clicked.connect(self.install_update)
self.install_button.setStyleSheet("""
QPushButton {
background-color: #23272e;
color: #3fd0ea;
font-weight: bold;
padding: 8px 16px;
border-radius: 4px;
border: 2px solid #3fd0ea;
}
QPushButton:hover {
background-color: #3fd0ea;
color: #23272e;
}
QPushButton:pressed {
background-color: #2bb8d6;
color: #23272e;
}
""")
button_layout.addWidget(self.install_button)
layout.addLayout(button_layout)
# Style the download button
# Style the download button to match Jackify theme (dark with blue text)
self.download_button.setStyleSheet("""
QPushButton {
background-color: #0d7377;
color: white;
background-color: #23272e;
color: #3fd0ea;
font-weight: bold;
padding: 8px 16px;
border-radius: 4px;
border: 2px solid #3fd0ea;
}
QPushButton:hover {
background-color: #14a085;
background-color: #3fd0ea;
color: #23272e;
}
QPushButton:pressed {
background-color: #2bb8d6;
color: #23272e;
}
""")
@@ -274,8 +300,26 @@ class UpdateDialog(QDialog):
self.reject()
def skip_version(self):
"""Skip this version (could save preference)."""
# TODO: Save preference to skip this version
"""Skip this version and save preference."""
try:
# Save the skipped version to config
from jackify.backend.handlers.config_handler import ConfigHandler
config_handler = ConfigHandler()
# Get current skipped versions
skipped_versions = config_handler.get('skipped_versions', [])
# Add this version to skipped list
if self.update_info.version not in skipped_versions:
skipped_versions.append(self.update_info.version)
config_handler.set('skipped_versions', skipped_versions)
config_handler.save()
logger.info(f"Skipped version {self.update_info.version}")
except Exception as e:
logger.error(f"Error saving skip preference: {e}")
self.reject()
def show_error(self, title: str, message: str):

View File

@@ -18,7 +18,7 @@ if '--env-diagnostic' in sys.argv:
import json
from datetime import datetime
print("🔍 PyInstaller Environment Diagnostic")
print("PyInstaller Environment Diagnostic")
print("=" * 50)
# Check if we're in PyInstaller
@@ -65,7 +65,7 @@ if '--env-diagnostic' in sys.argv:
env_data['engine_paths_found'] = engine_paths
# Output the results
print("\n📊 Environment Data:")
print("\nEnvironment Data:")
print(json.dumps(env_data, indent=2))
# Save to file
@@ -73,9 +73,9 @@ if '--env-diagnostic' in sys.argv:
output_file = Path.cwd() / "pyinstaller_env_capture.json"
with open(output_file, 'w') as f:
json.dump(env_data, f, indent=2)
print(f"\n💾 Data saved to: {output_file}")
print(f"\nData saved to: {output_file}")
except Exception as e:
print(f"\nCould not save data: {e}")
print(f"\nCould not save data: {e}")
sys.exit(0)

View File

@@ -1074,7 +1074,7 @@ class InstallModlistScreen(QWidget):
self.save_api_key_checkbox.setChecked(False)
debug_print("DEBUG: Failed to save API key immediately")
else:
self._show_api_key_feedback("Enter an API key first", is_success=False)
self._show_api_key_feedback("Enter an API key first", is_success=False)
# Uncheck the checkbox since no key to save
self.save_api_key_checkbox.setChecked(False)
else:

View File

@@ -688,7 +688,7 @@ class TuxbornInstallerScreen(QWidget):
self.save_api_key_checkbox.setChecked(False)
print("DEBUG: Failed to save API key immediately")
else:
self._show_api_key_feedback("Enter an API key first", is_success=False)
self._show_api_key_feedback("Enter an API key first", is_success=False)
# Uncheck the checkbox since no key to save
self.save_api_key_checkbox.setChecked(False)
else:

View File

@@ -28,15 +28,25 @@ def get_appimage_path() -> Optional[Path]:
This uses the APPIMAGE environment variable set by the AppImage runtime.
This is the standard, reliable method for AppImage path detection.
For security, this validates that the AppImage is actually Jackify to prevent
accidentally updating other AppImages when running from development environments.
Returns:
Optional[Path]: Path to the AppImage file if running as AppImage, None otherwise
Optional[Path]: Path to the AppImage file if running as Jackify AppImage, None otherwise
"""
if not is_appimage():
return None
appimage_path = os.environ.get('APPIMAGE')
if appimage_path and os.path.exists(appimage_path):
return Path(appimage_path)
path = Path(appimage_path)
# Validate this is actually a Jackify AppImage to prevent updating wrong apps
if 'jackify' in path.name.lower():
return path
else:
# Running from different AppImage (e.g., development in Cursor.AppImage)
return None
return None

View File

@@ -1,958 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Path Handler Module
Handles path-related operations for ModOrganizer.ini and other configuration files
"""
import os
import re
import logging
import shutil
from pathlib import Path
from typing import Optional, Union, Dict, Any, List, Tuple
from datetime import datetime
# Initialize logger
logger = logging.getLogger(__name__)
# --- Configuration (Adapted from Proposal) ---
# Define known script extender executables (lowercase for comparisons)
TARGET_EXECUTABLES_LOWER = ["skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "falloutnv.exe"]
# Define known stock game folder names (case-sensitive, as they appear on disk)
STOCK_GAME_FOLDERS = ["Stock Game", "Game Root", "Stock Folder", "Skyrim Stock"]
# Define the SD card path prefix on Steam Deck/Linux
SDCARD_PREFIX = '/run/media/mmcblk0p1/'
class PathHandler:
"""
Handles path-related operations for ModOrganizer.ini and other configuration files
"""
@staticmethod
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
"""
Removes the '/run/media/mmcblk0p1/' prefix if present.
Returns the path as a POSIX-style string (using /).
"""
path_str = path_obj.as_posix() # Work with consistent forward slashes
if path_str.lower().startswith(SDCARD_PREFIX.lower()):
# Return the part *after* the prefix, ensuring no leading slash remains unless root
relative_part = path_str[len(SDCARD_PREFIX):]
return relative_part if relative_part else "." # Return '.' if it was exactly the prefix
return path_str
@staticmethod
def update_mo2_ini_paths(
modlist_ini_path: Path,
modlist_dir_path: Path,
modlist_sdcard: bool,
steam_library_common_path: Optional[Path] = None,
basegame_dir_name: Optional[str] = None,
basegame_sdcard: bool = False # Default to False if not provided
) -> bool:
logger.info(f"[DEBUG] update_mo2_ini_paths called with: modlist_ini_path={modlist_ini_path}, modlist_dir_path={modlist_dir_path}, modlist_sdcard={modlist_sdcard}, steam_library_common_path={steam_library_common_path}, basegame_dir_name={basegame_dir_name}, basegame_sdcard={basegame_sdcard}")
if not modlist_ini_path.is_file():
logger.error(f"ModOrganizer.ini not found at specified path: {modlist_ini_path}")
# Attempt to create a minimal INI
try:
logger.warning("Creating minimal ModOrganizer.ini with [General] section.")
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.write('[General]\n')
# Continue as if file existed
except Exception as e:
logger.critical(f"Failed to create minimal ModOrganizer.ini: {e}")
return False
if not modlist_dir_path.is_dir():
logger.error(f"Modlist directory not found or not a directory: {modlist_dir_path}")
# Warn but continue
# --- Bulletproof game directory detection ---
# 1. Get all Steam libraries and log them
all_steam_libraries = PathHandler.get_all_steam_library_paths()
logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}")
import sys
if hasattr(sys, 'argv') and any(arg in ('--debug', '-d') for arg in sys.argv):
# Debug logging for Steam libraries detection - use logger if available
if hasattr(globals(), 'logger') and logger:
logger.debug(f"Detected Steam libraries: {all_steam_libraries}")
# If no logger available, this debug info is not critical for user operation
# 2. For each library, check for the canonical vanilla game directory
GAME_DIR_NAMES = {
"Skyrim Special Edition": "Skyrim Special Edition",
"Fallout 4": "Fallout 4",
"Fallout New Vegas": "Fallout New Vegas",
"Oblivion": "Oblivion"
}
canonical_name = None
if basegame_dir_name and basegame_dir_name in GAME_DIR_NAMES:
canonical_name = GAME_DIR_NAMES[basegame_dir_name]
elif basegame_dir_name:
canonical_name = basegame_dir_name # fallback, but should match above
gamepath_target_dir = None
gamepath_target_is_sdcard = modlist_sdcard
checked_candidates = []
if canonical_name:
for lib in all_steam_libraries:
candidate = lib / "steamapps" / "common" / canonical_name
checked_candidates.append(str(candidate))
logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}")
if candidate.is_dir():
gamepath_target_dir = candidate
logger.info(f"Found vanilla game directory: {candidate}")
break
if not gamepath_target_dir:
logger.error(f"Could not find vanilla game directory '{canonical_name}' in any Steam library. Checked: {checked_candidates}")
# 4. Prompt the user for the path
print("\nCould not automatically detect a Stock Game or vanilla game directory.")
print("Please enter the full path to your vanilla game directory (e.g., /path/to/Skyrim Special Edition):")
while True:
user_input = input("Game directory path: ").strip()
user_path = Path(user_input)
logger.info(f"[DEBUG] User entered: {user_input}")
if user_path.is_dir():
exe_candidates = list(user_path.glob('*.exe'))
logger.info(f"[DEBUG] .exe files in user path: {exe_candidates}")
if exe_candidates:
gamepath_target_dir = user_path
logger.info(f"User provided valid vanilla game directory: {gamepath_target_dir}")
break
else:
print("Directory exists but does not appear to contain the game executable. Please check and try again.")
logger.warning("User path exists but no .exe files found.")
else:
print("Directory not found. Please enter a valid path.")
logger.warning("User path does not exist.")
if not gamepath_target_dir:
logger.critical("[FATAL] Could not determine a valid target directory for gamePath. Check configuration and paths. Aborting update.")
return False
# 3. Update gamePath, binary, and workingDirectory entries in the INI
logger.debug(f"Determined gamePath target directory: {gamepath_target_dir}")
logger.debug(f"gamePath target is on SD card: {gamepath_target_is_sdcard}")
try:
logger.debug(f"Reading original INI file: {modlist_ini_path}")
with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f:
original_lines = f.readlines()
# --- Find and robustly update gamePath line ---
gamepath_line_num = -1
general_section_line = -1
for i, line in enumerate(original_lines):
if re.match(r'^\s*\[General\]\s*$', line, re.IGNORECASE):
general_section_line = i
if re.match(r'^\s*gamepath\s*=\s*', line, re.IGNORECASE):
gamepath_line_num = i
break
processed_str = PathHandler._strip_sdcard_path_prefix(gamepath_target_dir)
windows_style_single = processed_str.replace('/', '\\')
gamepath_drive_letter = "D:" if gamepath_target_is_sdcard else "Z:"
# Use robust formatter
formatted_gamepath = PathHandler._format_gamepath_for_mo2(f'{gamepath_drive_letter}{windows_style_single}')
new_gamepath_line = f'gamePath = @ByteArray({formatted_gamepath})\n'
if gamepath_line_num != -1:
logger.info(f"Updating existing gamePath line: {original_lines[gamepath_line_num].strip()} -> {new_gamepath_line.strip()}")
original_lines[gamepath_line_num] = new_gamepath_line
else:
insert_at = general_section_line + 1 if general_section_line != -1 else 0
logger.info(f"Adding missing gamePath line at line {insert_at+1}: {new_gamepath_line.strip()}")
original_lines.insert(insert_at, new_gamepath_line)
# --- Update customExecutables binaries and workingDirectories ---
TARGET_EXECUTABLES_LOWER = [
"skse64_loader.exe", "f4se_loader.exe", "nvse_loader.exe", "obse_loader.exe", "falloutnv.exe"
]
in_custom_exec = False
for i, line in enumerate(original_lines):
if re.match(r'^\s*\[customExecutables\]\s*$', line, re.IGNORECASE):
in_custom_exec = True
continue
if in_custom_exec and re.match(r'^\s*\[.*\]\s*$', line):
in_custom_exec = False
if in_custom_exec:
m = re.match(r'^(\d+)\\binary\s*=\s*(.*)$', line.strip(), re.IGNORECASE)
if m:
idx, old_path = m.group(1), m.group(2)
exe_name = os.path.basename(old_path).lower()
if exe_name in TARGET_EXECUTABLES_LOWER:
new_path = f'{gamepath_drive_letter}/{PathHandler._strip_sdcard_path_prefix(gamepath_target_dir)}/{exe_name}'
# Use robust formatter
new_path = PathHandler._format_binary_for_mo2(new_path)
logger.info(f"Updating binary for entry {idx}: {old_path} -> {new_path}")
original_lines[i] = f'{idx}\\binary = {new_path}\n'
m_wd = re.match(r'^(\d+)\\workingDirectory\s*=\s*(.*)$', line.strip(), re.IGNORECASE)
if m_wd:
idx, old_wd = m_wd.group(1), m_wd.group(2)
new_wd = f'{gamepath_drive_letter}{windows_style_single}'
# Use robust formatter
new_wd = PathHandler._format_workingdir_for_mo2(new_wd)
logger.info(f"Updating workingDirectory for entry {idx}: {old_wd} -> {new_wd}")
original_lines[i] = f'{idx}\\workingDirectory = {new_wd}\n'
# --- Backup and Write New INI ---
backup_path = modlist_ini_path.with_suffix(f".{datetime.now().strftime('%Y%m%d_%H%M%S')}.bak")
try:
shutil.copy2(modlist_ini_path, backup_path)
logger.info(f"Backed up original INI to: {backup_path}")
except Exception as bak_err:
logger.error(f"Failed to backup original INI file: {bak_err}")
return False
try:
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(original_lines)
logger.info(f"Successfully wrote updated paths to {modlist_ini_path}")
return True
except Exception as write_err:
logger.error(f"Failed to write updated INI file {modlist_ini_path}: {write_err}", exc_info=True)
logger.error("Attempting to restore from backup...")
try:
shutil.move(backup_path, modlist_ini_path)
logger.info("Successfully restored original INI from backup.")
except Exception as restore_err:
logger.critical(f"CRITICAL FAILURE: Could not write new INI and failed to restore backup {backup_path}. Manual intervention required at {modlist_ini_path}! Error: {restore_err}")
return False
except Exception as e:
logger.error(f"An unexpected error occurred during INI path update: {e}", exc_info=True)
return False
@staticmethod
def edit_resolution(modlist_ini, resolution):
"""
Edit resolution settings in ModOrganizer.ini
Args:
modlist_ini (str): Path to ModOrganizer.ini
resolution (str): Resolution in the format "1920x1080"
Returns:
bool: True on success, False on failure
"""
try:
logger.info(f"Editing resolution settings to {resolution}...")
# Parse resolution
width, height = resolution.split('x')
# Read the current ModOrganizer.ini
with open(modlist_ini, 'r') as f:
content = f.read()
# Replace width and height settings
content = re.sub(r'^width\s*=\s*\d+$', f'width = {width}', content, flags=re.MULTILINE)
content = re.sub(r'^height\s*=\s*\d+$', f'height = {height}', content, flags=re.MULTILINE)
# Write the updated content back to the file
with open(modlist_ini, 'w') as f:
f.write(content)
logger.info("Resolution settings edited successfully")
return True
except Exception as e:
logger.error(f"Error editing resolution settings: {e}")
return False
@staticmethod
def create_dxvk_conf(modlist_dir, modlist_sdcard, steam_library, basegame_sdcard, game_var_full):
"""
Create dxvk.conf file in the appropriate location
Args:
modlist_dir (str): Path to the modlist directory
modlist_sdcard (bool): Whether the modlist is on an SD card
steam_library (str): Path to the Steam library
basegame_sdcard (bool): Whether the base game is on an SD card
game_var_full (str): Full name of the game (e.g., "Skyrim Special Edition")
Returns:
bool: True on success, False on failure
"""
try:
logger.info("Creating dxvk.conf file...")
# Determine the location for dxvk.conf
dxvk_conf_path = None
# Check for common stock game directories
stock_game_paths = [
os.path.join(modlist_dir, "Stock Game"),
os.path.join(modlist_dir, "STOCK GAME"),
os.path.join(modlist_dir, "Game Root"),
os.path.join(modlist_dir, "Stock Folder"),
os.path.join(modlist_dir, "Skyrim Stock"),
os.path.join(modlist_dir, "root", "Skyrim Special Edition"),
os.path.join(steam_library, game_var_full)
]
for path in stock_game_paths:
if os.path.exists(path):
dxvk_conf_path = os.path.join(path, "dxvk.conf")
break
if not dxvk_conf_path:
logger.error("Could not determine location for dxvk.conf")
return False
# Create dxvk.conf content
dxvk_conf_content = "dxvk.enableGraphicsPipelineLibrary = False\n"
# Write dxvk.conf to the appropriate location
with open(dxvk_conf_path, 'w') as f:
f.write(dxvk_conf_content)
logger.info(f"dxvk.conf created successfully at {dxvk_conf_path}")
return True
except Exception as e:
logger.error(f"Error creating dxvk.conf: {e}")
return False
@staticmethod
def find_steam_config_vdf() -> Optional[Path]:
"""Finds the active Steam config.vdf file."""
logger.debug("Searching for Steam config.vdf...")
possible_steam_paths = [
Path.home() / ".steam/steam",
Path.home() / ".local/share/Steam",
Path.home() / ".steam/root"
]
for steam_path in possible_steam_paths:
potential_path = steam_path / "config/config.vdf"
if potential_path.is_file():
logger.info(f"Found config.vdf at: {potential_path}")
return potential_path # Return Path object
logger.warning("Could not locate Steam's config.vdf file in standard locations.")
return None
@staticmethod
def find_steam_library() -> Optional[Path]:
"""Find the primary Steam library common directory containing games."""
logger.debug("Attempting to find Steam library...")
# Potential locations for libraryfolders.vdf
libraryfolders_vdf_paths = [
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
# Add other potential standard locations if necessary
]
# Simple backup mechanism (optional but good practice)
for path in libraryfolders_vdf_paths:
if os.path.exists(path):
backup_dir = os.path.join(os.path.dirname(path), "backups")
if not os.path.exists(backup_dir):
try:
os.makedirs(backup_dir)
except OSError as e:
logger.warning(f"Could not create backup directory {backup_dir}: {e}")
# Create timestamped backup if it doesn't exist for today
timestamp = datetime.now().strftime("%Y%m%d")
backup_filename = f"libraryfolders_{timestamp}.vdf.bak"
backup_path = os.path.join(backup_dir, backup_filename)
if not os.path.exists(backup_path):
try:
import shutil
shutil.copy2(path, backup_path)
logger.debug(f"Created backup of libraryfolders.vdf at {backup_path}")
except Exception as e:
logger.error(f"Failed to create backup of libraryfolders.vdf: {e}")
# Continue anyway, as we're only reading the file
pass
libraryfolders_vdf_path_obj = None # Will hold the Path object
found_path_str = None
for path_str in libraryfolders_vdf_paths:
if os.path.exists(path_str):
found_path_str = path_str # Keep the string path for logging/opening
libraryfolders_vdf_path_obj = Path(path_str) # Convert to Path object here
logger.debug(f"Found libraryfolders.vdf at: {path_str}")
break
# Check using the Path object's is_file() method
if not libraryfolders_vdf_path_obj or not libraryfolders_vdf_path_obj.is_file():
logger.warning("libraryfolders.vdf not found or is not a file. Cannot automatically detect Steam Library.")
return None
# Parse the VDF file to extract library paths
library_paths = []
try:
# Open using the original string path is fine, or use the Path object
with open(found_path_str, 'r') as f: # Or use libraryfolders_vdf_path_obj
content = f.read()
# Use regex to find all path entries
path_matches = re.finditer(r'"path"\s*"([^"]+)"', content)
for match in path_matches:
library_path_str = match.group(1).replace('\\\\', '\\') # Fix potential double escapes
common_path = os.path.join(library_path_str, "steamapps", "common")
if os.path.isdir(common_path): # Verify the common path exists
library_paths.append(Path(common_path))
logger.debug(f"Found potential common path: {common_path}")
else:
logger.debug(f"Skipping non-existent common path derived from VDF: {common_path}")
logger.debug(f"Found {len(library_paths)} valid library common paths from VDF.")
# Return the first valid path found
if library_paths:
logger.info(f"Using Steam library common path: {library_paths[0]}")
return library_paths[0]
# If no valid paths found in VDF, try the default structure
logger.debug("No valid common paths found in VDF, checking default location...")
default_common_path = Path.home() / ".steam/steam/steamapps/common"
if default_common_path.is_dir():
logger.info(f"Using default Steam library common path: {default_common_path}")
return default_common_path
default_common_path_local = Path.home() / ".local/share/Steam/steamapps/common"
if default_common_path_local.is_dir():
logger.info(f"Using default local Steam library common path: {default_common_path_local}")
return default_common_path_local
logger.error("No valid Steam library common path found in VDF or default locations.")
return None
except Exception as e:
logger.error(f"Error parsing libraryfolders.vdf or finding Steam library: {e}", exc_info=True)
return None
@staticmethod
def find_compat_data(appid: str) -> Optional[Path]:
"""Find the compatdata directory for a given AppID."""
if not appid or not appid.isdigit():
logger.error(f"Invalid AppID provided for compatdata search: {appid}")
return None
logger.debug(f"Searching for compatdata directory for AppID: {appid}")
# Prefer standard Steam locations
possible_bases = [
Path.home() / ".steam/steam/steamapps/compatdata",
Path.home() / ".local/share/Steam/steamapps/compatdata",
# Add likely SD card mount points if applicable
# Path("/run/media/mmcblk0p1/steamapps/compatdata")
]
# Check user's Steam Library path if available (more reliable)
# Assuming PathHandler might store or be passed the library path
# steam_lib_path = self.find_steam_library() # Or get from instance var if stored
# if steam_lib_path and (steam_lib_path / "steamapps/compatdata").is_dir():
# possible_bases.insert(0, steam_lib_path / "steamapps/compatdata") # Prioritize
for base_path in possible_bases:
if not base_path.is_dir():
logger.debug(f"Compatdata base path does not exist or is not a directory: {base_path}")
continue
potential_path = base_path / appid
if potential_path.is_dir():
logger.info(f"Found compatdata directory: {potential_path}")
return potential_path # Return Path object
else:
logger.debug(f"Compatdata for {appid} not found in {base_path}")
# Fallback: Broad search (can be slow, consider if needed)
# try:
# logger.debug(f"Compatdata not found in standard locations, attempting wider search...")
# # This can be very slow and resource-intensive
# # find_output = subprocess.check_output(['find', '/', '-type', 'd', '-name', appid, '-path', '*/compatdata/*', '-print', '-quit', '2>/dev/null'], text=True).strip()
# # if find_output:
# # logger.info(f"Found compatdata via find command: {find_output}")
# # return Path(find_output)
# except Exception as e:
# logger.warning(f"Error during 'find' command for compatdata: {e}")
logger.warning(f"Compatdata directory for AppID {appid} not found.")
return None
@staticmethod
def detect_stock_game_path(game_type: str, steam_library: Path) -> Optional[Path]:
"""
Detect the stock game path for a given game type and Steam library
Returns the path if found, None otherwise
"""
try:
# Map of game types to their Steam App IDs
game_app_ids = {
'skyrim': '489830', # Skyrim Special Edition
'fallout4': '377160', # Fallout 4
'fnv': '22380', # Fallout: New Vegas
'oblivion': '22330' # The Elder Scrolls IV: Oblivion
}
if game_type not in game_app_ids:
return None
app_id = game_app_ids[game_type]
game_path = steam_library / 'steamapps' / 'common'
# List of possible game directory names
possible_names = {
'skyrim': ['Skyrim Special Edition', 'Skyrim'],
'fallout4': ['Fallout 4'],
'fnv': ['Fallout New Vegas', 'FalloutNV'],
'oblivion': ['Oblivion']
}
if game_type not in possible_names:
return None
# Check each possible directory name
for name in possible_names[game_type]:
potential_path = game_path / name
if potential_path.exists():
return potential_path
return None
except Exception as e:
logging.error(f"Error detecting stock game path: {e}")
return None
@staticmethod
def get_steam_library_path(steam_path: str) -> Optional[str]:
"""Get the Steam library path from libraryfolders.vdf."""
try:
libraryfolders_path = os.path.join(steam_path, 'steamapps', 'libraryfolders.vdf')
if not os.path.exists(libraryfolders_path):
return None
with open(libraryfolders_path, 'r', encoding='utf-8') as f:
content = f.read()
# Parse the VDF content
libraries = {}
current_library = None
for line in content.split('\n'):
line = line.strip()
if line.startswith('"path"'):
current_library = line.split('"')[3].replace('\\\\', '\\')
elif line.startswith('"apps"') and current_library:
libraries[current_library] = True
# Return the first library path that exists
for library_path in libraries:
if os.path.exists(library_path):
return library_path
return None
except Exception as e:
logger.error(f"Error getting Steam library path: {str(e)}")
return None
@staticmethod
def get_all_steam_library_paths() -> List[Path]:
"""Finds all Steam library paths listed in all known libraryfolders.vdf files (including Flatpak)."""
logger.info("[DEBUG] Searching for all Steam libraryfolders.vdf files...")
vdf_paths = [
Path.home() / ".steam/steam/config/libraryfolders.vdf",
Path.home() / ".local/share/Steam/config/libraryfolders.vdf",
Path.home() / ".steam/root/config/libraryfolders.vdf",
Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf", # Flatpak
]
library_paths = set()
for vdf_path in vdf_paths:
if vdf_path.is_file():
logger.info(f"[DEBUG] Parsing libraryfolders.vdf: {vdf_path}")
try:
with open(vdf_path) as f:
for line in f:
m = re.search(r'"path"\s*"([^"]+)"', line)
if m:
lib_path = Path(m.group(1))
library_paths.add(lib_path)
except Exception as e:
logger.error(f"[DEBUG] Failed to parse {vdf_path}: {e}")
logger.info(f"[DEBUG] All detected Steam libraries: {library_paths}")
return list(library_paths)
# Moved _find_shortcuts_vdf here from ShortcutHandler
def _find_shortcuts_vdf(self) -> Optional[str]:
"""Helper to find the active shortcuts.vdf file for a user.
Iterates through userdata directories and returns the path to the
first found shortcuts.vdf file.
Returns:
Optional[str]: The full path to the shortcuts.vdf file, or None if not found.
"""
# This implementation was moved from ShortcutHandler
userdata_base_paths = [
os.path.expanduser("~/.steam/steam/userdata"),
os.path.expanduser("~/.local/share/Steam/userdata"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/userdata")
]
found_vdf_path = None
for base_path in userdata_base_paths:
if not os.path.isdir(base_path):
logger.debug(f"Userdata base path not found or not a directory: {base_path}")
continue
logger.debug(f"Searching for user IDs in: {base_path}")
try:
for item in os.listdir(base_path):
user_path = os.path.join(base_path, item)
if os.path.isdir(user_path) and item.isdigit():
logger.debug(f"Checking user directory: {user_path}")
config_path = os.path.join(user_path, "config")
shortcuts_file = os.path.join(config_path, "shortcuts.vdf")
if os.path.isfile(shortcuts_file):
logger.info(f"Found shortcuts.vdf at: {shortcuts_file}")
found_vdf_path = shortcuts_file
break # Found it for this base path
else:
logger.debug(f"shortcuts.vdf not found in {config_path}")
except OSError as e:
logger.warning(f"Could not access directory {base_path}: {e}")
continue # Try next base path
if found_vdf_path:
break # Found it in this base path
if not found_vdf_path:
logger.error("Could not find any shortcuts.vdf file in common Steam locations.")
return found_vdf_path
@staticmethod
def find_game_install_paths(target_appids: Dict[str, str]) -> Dict[str, Path]:
"""
Find installation paths for multiple specified games using Steam app IDs.
Args:
target_appids: Dictionary mapping game names to app IDs
Returns:
Dictionary mapping game names to their installation paths
"""
# Get all Steam library paths
library_paths = PathHandler.get_all_steam_library_paths()
if not library_paths:
logger.warning("Failed to find any Steam library paths")
return {}
results = {}
# For each library path, look for each target game
for library_path in library_paths:
# Check if the common directory exists
common_dir = library_path / "common"
if not common_dir.is_dir():
logger.debug(f"No 'common' directory in library: {library_path}")
continue
# Get subdirectories in common dir
try:
game_dirs = [d for d in common_dir.iterdir() if d.is_dir()]
except (PermissionError, OSError) as e:
logger.warning(f"Cannot access directory {common_dir}: {e}")
continue
# For each app ID, check if we find its directory
for game_name, app_id in target_appids.items():
if game_name in results:
continue # Already found this game
# Try to find by appmanifest
appmanifest_path = library_path / f"appmanifest_{app_id}.acf"
if appmanifest_path.is_file():
# Find the installdir value
try:
with open(appmanifest_path, 'r', encoding='utf-8') as f:
content = f.read()
match = re.search(r'"installdir"\s+"([^"]+)"', content)
if match:
install_dir_name = match.group(1)
install_path = common_dir / install_dir_name
if install_path.is_dir():
results[game_name] = install_path
logger.info(f"Found {game_name} at {install_path}")
continue
except Exception as e:
logger.warning(f"Error reading appmanifest for {game_name}: {e}")
return results
def replace_gamepath(self, modlist_ini_path: Path, new_game_path: Path, modlist_sdcard: bool = False) -> bool:
"""
Updates the gamePath value in ModOrganizer.ini to the specified path.
Strictly matches the bash script: only replaces an existing gamePath line.
If the file or line does not exist, logs error and aborts.
"""
logger.info(f"Replacing gamePath in {modlist_ini_path} with {new_game_path}")
if not modlist_ini_path.is_file():
logger.error(f"ModOrganizer.ini not found at: {modlist_ini_path}")
return False
try:
with open(modlist_ini_path, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
drive_letter = "D:" if modlist_sdcard else "Z:"
processed_path = self._strip_sdcard_path_prefix(new_game_path)
windows_style = processed_path.replace('/', '\\')
windows_style_double = windows_style.replace('\\', '\\\\')
new_gamepath_line = f'gamePath=@ByteArray({drive_letter}{windows_style_double})\n'
gamepath_found = False
for i, line in enumerate(lines):
# Make the check case-insensitive and robust to whitespace
if re.match(r'^\s*gamepath\s*=.*$', line, re.IGNORECASE):
lines[i] = new_gamepath_line
gamepath_found = True
break
if not gamepath_found:
logger.error("No gamePath line found in ModOrganizer.ini")
return False
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"Successfully updated gamePath to {new_game_path}")
return True
except Exception as e:
logger.error(f"Error replacing gamePath: {e}", exc_info=True)
return False
# =====================================================================================
# CRITICAL: DO NOT CHANGE THIS FUNCTION WITHOUT UPDATING TESTS AND CONSULTING PROJECT LEAD
# This function implements the exact path rewriting logic required for ModOrganizer.ini
# to match the original, robust bash script. Any change here risks breaking modlist
# configuration for users. If you must change this, update all relevant tests and
# consult the Project Lead for Jackify. See also omni-guides.sh for reference logic.
# =====================================================================================
def edit_binary_working_paths(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool, steam_libraries: Optional[List[Path]] = None) -> bool:
"""
Update all binary paths and working directories in a ModOrganizer.ini file.
Handles various ModOrganizer.ini formats (single or double backslashes in keys).
When updating gamePath, binary, and workingDirectory, retain the original stock folder (Stock Game, Game Root, etc) if present in the current value.
steam_libraries: Optional[List[Path]] - already-discovered Steam library paths to use for vanilla detection.
# DO NOT CHANGE THIS LOGIC WITHOUT UPDATING TESTS AND CONSULTING THE PROJECT LEAD
# This is a critical, regression-prone area. See omni-guides.sh for reference.
"""
try:
logger.debug(f"Updating binary paths and working directories in {modlist_ini_path} to use root: {modlist_dir_path}")
if not modlist_ini_path.is_file():
logger.error(f"INI file {modlist_ini_path} does not exist")
return False
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
game_path_updated = False
binary_paths_updated = 0
working_dirs_updated = 0
binary_lines = []
working_dir_lines = []
for i, line in enumerate(lines):
stripped = line.strip()
binary_match = re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE)
if binary_match:
index = binary_match.group(1)
backslash_style = binary_match.group(2)
binary_lines.append((i, stripped, index, backslash_style))
wd_match = re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE)
if wd_match:
index = wd_match.group(1)
backslash_style = wd_match.group(2)
working_dir_lines.append((i, stripped, index, backslash_style))
binary_paths_by_index = {}
# Use provided steam_libraries if available, else detect
if steam_libraries is None or not steam_libraries:
steam_libraries = PathHandler.get_all_steam_library_paths()
for i, line, index, backslash_style in binary_lines:
parts = line.split('=', 1)
if len(parts) != 2:
logger.error(f"Malformed binary line: {line}")
continue
key_part, value_part = parts
exe_name = os.path.basename(value_part)
drive_prefix = "D:" if modlist_sdcard else "Z:"
rel_path = None
# --- BEGIN: FULL PARITY LOGIC ---
if 'steamapps' in value_part:
idx = value_part.index('steamapps')
subpath = value_part[idx:].lstrip('/')
correct_steam_lib = None
for lib in steam_libraries:
if (lib / subpath.split('/')[2]).exists():
correct_steam_lib = lib.parent
break
if not correct_steam_lib and steam_libraries:
correct_steam_lib = steam_libraries[0].parent
if correct_steam_lib:
new_binary_path = f"{drive_prefix}/{correct_steam_lib}/{subpath}".replace('\\', '/').replace('//', '/')
else:
logger.error("Could not determine correct Steam library for vanilla game path.")
continue
else:
found_stock = None
for folder in STOCK_GAME_FOLDERS:
folder_pattern = f"/{folder.replace(' ', '')}".lower()
value_part_lower = value_part.replace(' ', '').lower()
if folder_pattern in value_part_lower:
idx = value_part_lower.index(folder_pattern)
rel_path = value_part[idx:].lstrip('/')
found_stock = folder
break
if not rel_path:
mods_pattern = "/mods/"
if mods_pattern in value_part:
idx = value_part.index(mods_pattern)
rel_path = value_part[idx:].lstrip('/')
else:
rel_path = exe_name
new_binary_path = f"{drive_prefix}/{modlist_dir_path}/{rel_path}".replace('\\', '/').replace('//', '/')
formatted_binary_path = PathHandler._format_binary_for_mo2(new_binary_path)
new_binary_line = f"{index}{backslash_style}binary={formatted_binary_path}"
logger.debug(f"Updating binary path: {line.strip()} -> {new_binary_line}")
lines[i] = new_binary_line + "\n"
binary_paths_updated += 1
binary_paths_by_index[index] = formatted_binary_path
for j, wd_line, index, backslash_style in working_dir_lines:
if index in binary_paths_by_index:
binary_path = binary_paths_by_index[index]
wd_path = os.path.dirname(binary_path)
drive_prefix = "D:" if modlist_sdcard else "Z:"
if wd_path.startswith("D:") or wd_path.startswith("Z:"):
wd_path = wd_path[2:]
wd_path = drive_prefix + wd_path
formatted_wd_path = PathHandler._format_workingdir_for_mo2(wd_path)
key_part = f"{index}{backslash_style}workingDirectory"
new_wd_line = f"{key_part}={formatted_wd_path}"
logger.debug(f"Updating working directory: {wd_line.strip()} -> {new_wd_line}")
lines[j] = new_wd_line + "\n"
working_dirs_updated += 1
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"edit_binary_working_paths completed: Game path updated: {game_path_updated}, Binary paths updated: {binary_paths_updated}, Working directories updated: {working_dirs_updated}")
return True
except Exception as e:
logger.error(f"Error updating binary paths in {modlist_ini_path}: {str(e)}")
return False
def _format_path_for_mo2(self, path: str) -> str:
"""Format a path for MO2's ModOrganizer.ini file (working directories)."""
# Replace forward slashes with double backslashes
formatted = path.replace('/', '\\')
# Ensure we have a Windows drive letter format
if not re.match(r'^[A-Za-z]:', formatted):
formatted = 'D:' + formatted
# Double the backslashes for the INI file format
formatted = formatted.replace('\\', '\\\\')
return formatted
def _format_binary_path_for_mo2(self, path_str):
"""Format a binary path for MO2 config file.
Binary paths need forward slashes (/) in the path portion.
"""
# Replace backslashes with forward slashes
return path_str.replace('\\', '/')
def _format_working_dir_for_mo2(self, path_str):
"""
Format a working directory path for MO2 config file.
Ensures double backslashes throughout, as required by ModOrganizer.ini.
"""
import re
path = path_str.replace('/', '\\')
path = path.replace('\\', '\\\\') # Double all backslashes
# Ensure only one double backslash after drive letter
path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path)
return path
@staticmethod
def find_vanilla_game_paths(game_names=None) -> Dict[str, Path]:
"""
For each known game, iterate all Steam libraries and look for the canonical game directory name in steamapps/common.
Returns a dict of found games and their paths.
Args:
game_names: Optional list of game names to check. If None, uses default supported games.
Returns:
Dict[str, Path]: Mapping of game name to found install Path.
"""
# Canonical game directory names (allow list for Fallout 3)
GAME_DIR_NAMES = {
"Skyrim Special Edition": ["Skyrim Special Edition"],
"Fallout 4": ["Fallout 4"],
"Fallout New Vegas": ["Fallout New Vegas"],
"Oblivion": ["Oblivion"],
"Fallout 3": ["Fallout 3", "Fallout 3 goty"]
}
if game_names is None:
game_names = list(GAME_DIR_NAMES.keys())
all_steam_libraries = PathHandler.get_all_steam_library_paths()
logger.info(f"[DEBUG] Detected Steam libraries: {all_steam_libraries}")
found_games = {}
for game in game_names:
possible_names = GAME_DIR_NAMES.get(game, [game])
for lib in all_steam_libraries:
for name in possible_names:
candidate = lib / "steamapps" / "common" / name
logger.info(f"[DEBUG] Checking for vanilla game directory: {candidate}")
if candidate.is_dir():
found_games[game] = candidate
logger.info(f"Found vanilla game directory for {game}: {candidate}")
break # Stop after first found location
if game in found_games:
break
return found_games
def _detect_stock_game_path(self):
"""Detects common 'Stock Game' or 'Game Root' directories within the modlist path."""
self.logger.info("Step 7a: Detecting Stock Game/Game Root directory...")
if not self.modlist_dir:
self.logger.error("Modlist directory not set, cannot detect stock game path.")
return False
modlist_path = Path(self.modlist_dir)
# Always prefer 'Stock Game' if it exists, then fallback to others
preferred_order = [
"Stock Game",
"STOCK GAME",
"Skyrim Stock",
"Stock Game Folder",
"Stock Folder",
Path("root/Skyrim Special Edition"),
"Game Root" # 'Game Root' is now last
]
found_path = None
for name in preferred_order:
potential_path = modlist_path / name
if potential_path.is_dir():
found_path = str(potential_path)
self.logger.info(f"Found potential stock game directory: {found_path}")
break # Found the first match
if found_path:
self.stock_game_path = found_path
return True
else:
self.stock_game_path = None
self.logger.info("No common Stock Game/Game Root directory found. Will assume vanilla game path is needed for some operations.")
return True
# --- Add robust path formatters for INI fields ---
@staticmethod
def _format_gamepath_for_mo2(path: str) -> str:
import re
path = path.replace('/', '\\')
path = re.sub(r'\\+', r'\\', path) # Collapse multiple backslashes
# Ensure only one double backslash after drive letter
path = re.sub(r'^([A-Z]:)\\+', r'\1\\', path)
return path
@staticmethod
def _format_binary_for_mo2(path: str) -> str:
import re
path = path.replace('\\', '/')
# Collapse multiple forward slashes after drive letter
path = re.sub(r'^([A-Z]:)//+', r'\1/', path)
return path
@staticmethod
def _format_workingdir_for_mo2(path: str) -> str:
import re
path = path.replace('/', '\\')
path = path.replace('\\', '\\\\') # Double all backslashes
# Ensure only one double backslash after drive letter
path = re.sub(r'^([A-Z]:)\\\\+', r'\1\\\\', path)
return path
# --- End of PathHandler ---