Sync from development - prepare for v0.5.0

This commit is contained in:
Omni
2026-03-13 14:43:25 +00:00
parent 411addeea2
commit 3556914560
179 changed files with 7126 additions and 1806 deletions

View File

@@ -17,6 +17,10 @@ from typing import Optional
from .config_handler_encryption import ConfigEncryptionMixin
from .config_handler_directories import ConfigDirectoriesMixin
from .config_handler_proton import ConfigProtonMixin
from jackify.shared.steam_utils import (
STEAM_PREFERENCE_AUTO,
resolve_preferred_steam_installation,
)
logger = logging.getLogger(__name__)
@@ -50,6 +54,7 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
"resolution": None,
"protontricks_path": None,
"steam_path": None,
"steam_install_preference": STEAM_PREFERENCE_AUTO, # auto|flatpak|native
"nexus_api_key": None, # Base64 encoded API key
"default_install_parent_dir": None, # Parent directory for modlist installations
"default_download_parent_dir": None, # Parent directory for downloads
@@ -62,6 +67,8 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
"proton_path": None, # Install Proton path (for jackify-engine) - None means auto-detect
"proton_version": None, # Install Proton version name - None means auto-detect
"steam_restart_strategy": "jackify", # "jackify" (default) or "simple"
"manual_download_concurrent_limit": 2, # Shared GUI/CLI default for manual download browser tabs
"manual_download_watch_directory": None, # Optional override for manual-download watcher folder
"window_width": None, # Saved window width (None = use dynamic sizing)
"window_height": None # Saved window height (None = use dynamic sizing)
}
@@ -72,14 +79,13 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
# Perform version migrations
self._migrate_config()
# Normalize/repair Proton selections on every startup so stale deleted versions
# cannot break workflows.
self.normalize_proton_paths_on_boot()
# If steam_path is not set, detect it
if not self.settings["steam_path"]:
self.settings["steam_path"] = self._detect_steam_path()
# Auto-detect and set Proton version ONLY on first run (config file doesn't exist)
# Do NOT overwrite user's saved settings!
if not os.path.exists(self.config_file) and not self.settings.get("proton_path"):
self._auto_detect_proton()
# If jackify_data_dir is not set, initialize it to default
if not self.settings.get("jackify_data_dir"):
@@ -95,35 +101,16 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM
str: Path to the Steam installation or None if not found
"""
logger.info("Detecting Steam installation path...")
# Common Steam installation paths
steam_paths = [
os.path.expanduser("~/.steam/steam"),
os.path.expanduser("~/.local/share/Steam"),
os.path.expanduser("~/.steam/root")
]
# Check each path
for path in steam_paths:
if os.path.exists(path):
logger.info(f"Found Steam installation at: {path}")
return path
# If not found in common locations, try to find using libraryfolders.vdf
libraryfolders_vdf_paths = [
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.steam/root/config/libraryfolders.vdf"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf") # Flatpak
]
for vdf_path in libraryfolders_vdf_paths:
if os.path.exists(vdf_path):
# Extract the Steam path from the libraryfolders.vdf path
steam_path = os.path.dirname(os.path.dirname(vdf_path))
logger.info(f"Found Steam installation at: {steam_path}")
return steam_path
preference = self.settings.get("steam_install_preference", STEAM_PREFERENCE_AUTO)
install_type, install_root = resolve_preferred_steam_installation(preference=preference)
if install_root:
logger.info(
"Selected Steam installation: %s (%s)",
install_type,
install_root,
)
return str(install_root)
logger.error("Steam installation not found")
return None
@@ -376,4 +363,4 @@ class ConfigHandler(ConfigEncryptionMixin, ConfigDirectoriesMixin, ConfigProtonM

View File

@@ -3,6 +3,8 @@ Config handler Proton path and version getters and auto-detect.
"""
import logging
from pathlib import Path
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
@@ -10,6 +12,105 @@ logger = logging.getLogger(__name__)
class ConfigProtonMixin:
"""Mixin providing Proton path/version and auto-detect for ConfigHandler."""
@staticmethod
def _is_usable_proton_path(proton_path: Optional[str]) -> bool:
"""Return True when path looks like a valid Proton install directory."""
if not proton_path:
return False
try:
p = Path(str(proton_path)).expanduser()
if not p.is_dir():
return False
# Valve Proton structure
if (p / "dist" / "bin" / "wine").exists():
return True
# GE-Proton structure
if (p / "files" / "bin" / "wine").exists():
return True
return False
except Exception:
return False
@staticmethod
def _best_proton_entry() -> Optional[Dict[str, Any]]:
"""Get best detected Proton entry or None."""
try:
from .wine_utils import WineUtils
return WineUtils.select_best_proton()
except Exception:
return None
def normalize_proton_paths_on_boot(self) -> bool:
"""
Ensure stored Proton paths are valid at startup, repairing stale selections.
Rules:
- If install proton path is missing/invalid, auto-detect next best and persist it.
- If no compatible Proton exists, persist install path/version as null.
- If game proton path is set and invalid, reset it to install proton (or null).
Returns:
True if config values were changed and saved, False otherwise.
"""
changed = False
install_path = self.settings.get("proton_path")
if install_path == "auto":
install_path = None
install_valid = self._is_usable_proton_path(install_path)
if not install_valid:
best = self._best_proton_entry()
if best:
best_path = str(best["path"])
best_name = str(best.get("name") or Path(best_path).name)
if self.settings.get("proton_path") != best_path:
self.settings["proton_path"] = best_path
changed = True
if self.settings.get("proton_version") != best_name:
self.settings["proton_version"] = best_name
changed = True
logger.warning(
"Install Proton path was missing/invalid; auto-selected %s (%s)",
best_name,
best_path,
)
else:
if self.settings.get("proton_path") is not None:
self.settings["proton_path"] = None
changed = True
if self.settings.get("proton_version") is not None:
self.settings["proton_version"] = None
changed = True
logger.warning(
"Install Proton path was missing/invalid and no compatible Proton was found"
)
else:
# Keep proton_version in sync with existing valid path when missing/legacy.
if not self.settings.get("proton_version"):
self.settings["proton_version"] = Path(str(install_path)).name
changed = True
effective_install = self.settings.get("proton_path")
game_path = self.settings.get("game_proton_path")
# Legacy/placeholder values should not persist for runtime resolution.
if game_path in ("same_as_install", "auto"):
target = effective_install
if self.settings.get("game_proton_path") != target:
self.settings["game_proton_path"] = target
changed = True
elif game_path and not self._is_usable_proton_path(game_path):
self.settings["game_proton_path"] = effective_install
changed = True
logger.warning(
"Game Proton path was missing/invalid; reset to install Proton path"
)
if changed:
self.save_config()
return changed
def get_proton_path(self):
"""Retrieve the saved Install Proton path. Always reads fresh from disk."""
try:

View File

@@ -279,46 +279,56 @@ class ModlistMenuHandler:
timestamp = f"[{hours:02d}:{minutes:02d}:{seconds:02d}]"
print(f"{COLOR_INFO}{timestamp} {message}{COLOR_RESET}")
# Run the automated workflow
result = prefix_service.run_working_workflow(
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
)
# Handle the result
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
# Handle conflict - ask user what to do
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" 1. Use existing shortcut (recommended)")
print(" 2. Create new shortcut anyway")
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
if choice == "1":
# Use existing shortcut
existing_appid = conflicts[0].get('appid')
if existing_appid:
context = {
"name": modlist_name,
"appid": str(existing_appid),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
return self.run_modlist_configuration_phase(context)
elif choice == "2":
# Create new shortcut - would need to handle this, but for now just fail
print(f"{COLOR_ERROR}Creating new shortcut with same name not supported in this flow.{COLOR_RESET}")
return True
else:
while True:
result = prefix_service.run_working_workflow(
modlist_name, install_dir, mo2_path, progress_callback, steamdeck=self.steamdeck
)
if isinstance(result, tuple) and len(result) == 4:
if result[0] == "CONFLICT":
conflicts = result[1]
print(f"\n{COLOR_WARNING}Found existing Steam shortcut(s) with the same name and path:{COLOR_RESET}")
for i, conflict in enumerate(conflicts, 1):
print(f" {i}. Name: {conflict['name']}")
print(f" Executable: {conflict['exe']}")
print(f" Start Directory: {conflict['startdir']}")
print(f"\n{COLOR_PROMPT}Options:{COLOR_RESET}")
print(" 1. Use existing shortcut (recommended)")
print(" 2. Choose a different shortcut name")
choice = input(f"{COLOR_PROMPT}Enter choice (1-2): {COLOR_RESET}").strip()
if choice == "1":
existing_appid = conflicts[0].get('appid')
if existing_appid:
context = {
"name": modlist_name,
"appid": str(existing_appid),
"path": mo2_dir,
"manual_steps_completed": True,
"resolution": None
}
return self.run_modlist_configuration_phase(context)
print(f"{COLOR_ERROR}Could not determine existing shortcut AppID.{COLOR_RESET}")
return True
if choice == "2":
print("")
print(f"{COLOR_PROMPT}Enter a different shortcut name for this modlist.{COLOR_RESET}")
print(f"{COLOR_INFO}(Current conflicting name: {modlist_name}){COLOR_RESET}")
new_name = input(f"{COLOR_PROMPT}New shortcut name (or 'q' to cancel): {COLOR_RESET}").strip()
if new_name.lower() == 'q':
print(f"{COLOR_INFO}Configuration cancelled by user.{COLOR_RESET}")
return True
if not new_name:
print(f"{COLOR_ERROR}Name cannot be empty.{COLOR_RESET}")
continue
if new_name == modlist_name:
print(f"{COLOR_ERROR}Please enter a different name to resolve the conflict.{COLOR_RESET}")
continue
modlist_name = new_name
print(f"{COLOR_INFO}Retrying Steam setup with shortcut name: {modlist_name}{COLOR_RESET}")
continue
print(f"{COLOR_ERROR}Invalid choice.{COLOR_RESET}")
return True
else:
# Success - get the results
success, prefix_path, appid_int, last_timestamp = result
if success and appid_int:
context = {
@@ -330,10 +340,9 @@ class ModlistMenuHandler:
}
self.logger.debug(f"[DEBUG] New Modlist Context (automated workflow): {context}")
return self.run_modlist_configuration_phase(context)
else:
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
return True
else:
print(f"{COLOR_ERROR}Automated workflow completed but no AppID was returned.{COLOR_RESET}")
return True
# Unexpected result format
print(f"{COLOR_ERROR}Automated workflow returned unexpected format.{COLOR_RESET}")
self.logger.error(f"Unexpected result format from automated workflow: {result}")
@@ -566,8 +575,18 @@ class ModlistMenuHandler:
# Run modlist-specific post-install automation (e.g., VNV) before showing completion
# Only in CLI mode - GUI handles this in install_modlist.py
if not gui_mode:
from jackify.backend.services.vnv_integration_helper import run_vnv_automation_if_applicable
from jackify.backend.services.vnv_integration_helper import (
run_vnv_automation_if_applicable,
should_offer_vnv_automation,
)
from jackify.backend.services.automated_prefix_service import AutomatedPrefixService
from jackify.backend.services.vnv_post_install_service import VNVPostInstallService
from jackify.backend.handlers.path_handler import PathHandler
from jackify.frontends.cli.commands.vnv_manual_downloads import (
build_vnv_cli_manual_file_callback,
create_vnv_cli_progress_callback,
ensure_vnv_cli_manual_downloads,
)
from pathlib import Path
modlist_name = context.get('name', '')
@@ -581,33 +600,46 @@ class ModlistMenuHandler:
except (EOFError, KeyboardInterrupt):
return False
return user_input in ("", "y", "yes")
def _manual_vnv_file(title: str, instructions: str):
print(f"\n{COLOR_WARNING}{title}{COLOR_RESET}")
print(instructions)
try:
file_input = input(f"{COLOR_PROMPT}Path to downloaded file: {COLOR_RESET}").strip()
except (EOFError, KeyboardInterrupt):
return None
if not file_input:
return None
selected = Path(file_input).expanduser().resolve()
return selected if selected.exists() else None
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=modlist_path,
game_root=None, # Will be auto-detected
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=lambda msg: print(msg),
manual_file_callback=_manual_vnv_file,
confirmation_callback=_confirm_vnv
)
if automation_ran and not error:
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
if error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
if should_offer_vnv_automation(modlist_name, modlist_path):
game_paths = PathHandler().find_vanilla_game_paths()
resolved_game_root = game_paths.get('Fallout New Vegas')
vnv_service = VNVPostInstallService(
modlist_install_location=modlist_path,
game_root=resolved_game_root or modlist_path,
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
)
completed = vnv_service.check_already_completed()
all_vnv_steps_done = (
completed['root_mods']
and completed['4gb_patch']
and completed['bsa_decompressed']
)
if all_vnv_steps_done:
print(f"{COLOR_INFO}VNV post-install steps are already complete.{COLOR_RESET}")
elif _confirm_vnv(vnv_service.get_automation_description()):
if not ensure_vnv_cli_manual_downloads(vnv_service, output_callback=print):
print(f"{COLOR_WARNING}VNV manual downloads were not completed. Skipping VNV automation.{COLOR_RESET}")
else:
progress_callback, close_progress = create_vnv_cli_progress_callback(print)
try:
automation_ran, error = run_vnv_automation_if_applicable(
modlist_name=modlist_name,
modlist_install_location=modlist_path,
game_root=None, # Will be auto-detected
ttw_installer_path=AutomatedPrefixService.get_ttw_installer_path(),
progress_callback=progress_callback,
manual_file_callback=build_vnv_cli_manual_file_callback(vnv_service, output_callback=print),
confirmation_callback=lambda _description: True,
)
finally:
close_progress()
if automation_ran and not error:
print(f"{COLOR_INFO}VNV post-install automation completed.{COLOR_RESET}")
if error:
print(f"{COLOR_WARNING}VNV automation encountered an error: {error}{COLOR_RESET}")
print(f"{COLOR_INFO}You can complete these steps manually by following: https://vivanewvegas.moddinglinked.com/wabbajack.html{COLOR_RESET}")
else:
print(f"{COLOR_INFO}VNV automation skipped by user.{COLOR_RESET}")
except Exception as e:
self.logger.debug(f"VNV automation check skipped: {e}")
# Not an error - just means VNV automation wasn't applicable

View File

@@ -401,6 +401,18 @@ class ModlistConfigurationMixin:
else:
self.logger.warning("Could not set download_directory in ModOrganizer.ini")
# Step 8.5: Align /home vs /var/home basis for Z: paths to match modlist install directory.
# This is intentionally separate from broad binary-path rewriting so it still runs when
# engine-installed workflows skip edit_binary_working_paths.
if not self.path_handler.align_home_path_basis(
modlist_ini_path=modlist_ini_path_obj,
modlist_dir_path=modlist_dir_path_obj,
modlist_sdcard=self.modlist_sdcard,
):
self.logger.error("Failed to align home-path basis in ModOrganizer.ini. Configuration aborted.")
self.logger.error("Failed to align /home path basis in ModOrganizer.ini.")
return False
self.logger.info("Step 8: Updating ModOrganizer.ini paths... Done")
# Step 9: Update Resolution Settings (if applicable)
@@ -539,6 +551,9 @@ class ModlistConfigurationMixin:
else:
self.logger.debug("Step 13: No special launch options needed for this modlist type")
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Finalizing post-install configuration")
# Do not call status_callback here, the final message is handled in menu_handler
# if status_callback:
# status_callback("Configuration completed successfully!")
@@ -546,6 +561,8 @@ class ModlistConfigurationMixin:
self.logger.info("Configuration steps completed successfully.")
# Step 14: Re-enforce Windows 10 mode after modlist-specific configurations (matches legacy script line 1333)
if status_callback:
status_callback(f"{self._get_progress_timestamp()} Re-applying final Windows compatibility settings")
self._re_enforce_windows_10_mode()
return True # Return True on success
@@ -581,4 +598,3 @@ class ModlistConfigurationMixin:
else:
self.selected_resolution = None
self.logger.info("Resolution setup skipped by user.")

View File

@@ -7,6 +7,7 @@ import shutil
from pathlib import Path
from .ui_colors import COLOR_PROMPT, COLOR_INFO, COLOR_ERROR, COLOR_RESET, COLOR_WARNING
from jackify.shared.paths import get_jackify_logs_dir
logger = logging.getLogger(__name__)
@@ -352,10 +353,16 @@ class ModlistInstallCLITTWMixin:
print(f"\nTTW has been installed to: {ttw_output_path}")
print(f"TTW has been integrated into '{modlist_name}' (modlist.txt + plugins.txt updated).")
print(f"The modlist '{modlist_name}' is now ready to use with TTW.")
print(f"Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}TTW installation failed. Check the logs for details.{COLOR_RESET}")
print(f"{COLOR_ERROR}Error: {message}{COLOR_RESET}")
print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
except Exception as e:
self.logger.error(f"Error during TTW installation: {e}", exc_info=True)
print(f"{COLOR_ERROR}Error during TTW installation: {e}{COLOR_RESET}")
print(f"{COLOR_INFO}Detailed log available at: {get_jackify_logs_dir() / 'TTW_Install_workflow.log'}{COLOR_RESET}")
input(f"\n{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")

View File

@@ -28,6 +28,95 @@ SDCARD_PREFIX = '/run/media/mmcblk0p1/'
class PathHandlerMO2Mixin:
"""Mixin providing ModOrganizer.ini path updates and formatting."""
@staticmethod
def _desired_home_basis_from_modlist_dir(modlist_dir_path: Path) -> Optional[str]:
"""
Determine desired Linux home-path basis from modlist install directory.
Returns:
"/var/home" when modlist dir is under /var/home,
"/home" when modlist dir is under /home,
None otherwise.
"""
try:
posix = modlist_dir_path.as_posix()
except Exception:
posix = str(modlist_dir_path).replace("\\", "/")
if posix.startswith("/var/home/"):
return "/var/home"
if posix.startswith("/home/"):
return "/home"
return None
@staticmethod
def _rewrite_z_home_basis_in_line(line: str, desired_home_basis: str) -> str:
"""
Rewrite only Z:-drive /home -> /var/home path basis in a single INI line.
Preserves slash style (forward or backslash), and leaves D: paths untouched.
"""
if desired_home_basis == "/var/home":
# Z:/home/... -> Z:/var/home/...
# Z:\\home\\... -> Z:\\var\\home\\...
return re.sub(r'([Zz]:[/\\]+)home([/\\]+)', r'\1var\2home\2', line)
return line
def align_home_path_basis(self, modlist_ini_path: Path, modlist_dir_path: Path, modlist_sdcard: bool) -> bool:
"""
Align gamePath/binary/workingDirectory home-path basis to modlist_dir_path.
This is a targeted post-processing step for Z: paths only:
- If install path is /var/home/... then rewrite Z:/home/... to Z:/var/home/...
- Otherwise do nothing.
"""
if modlist_sdcard:
return True
desired_home_basis = self._desired_home_basis_from_modlist_dir(modlist_dir_path)
# This alignment pass is intentionally one-way:
# only promote Z:/home -> Z:/var/home when install dir uses /var/home.
if desired_home_basis != "/var/home":
return True
if not modlist_ini_path.is_file():
logger.error(f"INI file {modlist_ini_path} does not exist for home-basis alignment")
return False
try:
with open(modlist_ini_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
changed = 0
for i, line in enumerate(lines):
stripped = line.strip()
if not (
re.match(r'^\s*gamepath\s*=.*$', stripped, re.IGNORECASE)
or re.match(r'^(\d+)(\\+)\s*binary\s*=.*$', stripped, re.IGNORECASE)
or re.match(r'^(\d+)(\\+)\s*workingDirectory\s*=.*$', stripped, re.IGNORECASE)
):
continue
rewritten = self._rewrite_z_home_basis_in_line(line, desired_home_basis)
if rewritten != line:
lines[i] = rewritten
changed += 1
if changed > 0:
with open(modlist_ini_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(
"Aligned ModOrganizer.ini home-path basis to %s for %d line(s): %s",
desired_home_basis,
changed,
modlist_ini_path,
)
else:
logger.debug(
"No home-path basis alignment needed for %s (target %s)",
modlist_ini_path,
desired_home_basis,
)
return True
except Exception as e:
logger.error(f"Error aligning home path basis in {modlist_ini_path}: {e}")
return False
@staticmethod
def _strip_sdcard_path_prefix(path_obj: Path) -> str:
"""Removes SD card mount prefix. Returns path as POSIX-style string."""

View File

@@ -12,6 +12,10 @@ from pathlib import Path
from typing import Optional, List
from datetime import datetime
import vdf
from jackify.shared.steam_utils import (
get_ordered_steam_roots,
STEAM_PREFERENCE_AUTO,
)
logger = logging.getLogger(__name__)
@@ -23,11 +27,7 @@ class PathHandlerSteamMixin:
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"
]
possible_steam_paths = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
for steam_path in possible_steam_paths:
potential_path = steam_path / "config/config.vdf"
if potential_path.is_file():
@@ -40,10 +40,9 @@ class PathHandlerSteamMixin:
def find_steam_library() -> Optional[Path]:
"""Find the primary Steam library common directory containing games."""
logger.debug("Attempting to find Steam library...")
ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
libraryfolders_vdf_paths = [
os.path.expanduser("~/.steam/steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.local/share/Steam/config/libraryfolders.vdf"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/.local/share/Steam/config/libraryfolders.vdf"),
str(root / "config" / "libraryfolders.vdf") for root in ordered_roots
]
for path in libraryfolders_vdf_paths:
if os.path.exists(path):
@@ -92,14 +91,11 @@ class PathHandlerSteamMixin:
logger.info(f"Using Steam library common path: {library_paths[0]}")
return library_paths[0]
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
for root in ordered_roots:
default_common_path = root / "steamapps" / "common"
if default_common_path.is_dir():
logger.info(f"Using default Steam library common path: {default_common_path}")
return default_common_path
logger.error("No valid Steam library common path found in VDF or default locations.")
return None
except Exception as e:
@@ -181,12 +177,8 @@ class PathHandlerSteamMixin:
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",
]
ordered_roots = get_ordered_steam_roots(STEAM_PREFERENCE_AUTO)
vdf_paths = [root / "config" / "libraryfolders.vdf" for root in ordered_roots]
library_paths = set()
for vdf_path in vdf_paths:
if vdf_path.is_file():

View File

@@ -6,6 +6,7 @@ import resource
import sys
import shutil
import logging
import threading
def get_safe_python_executable():
"""
@@ -154,7 +155,7 @@ class ProcessManager:
"""
Shared process manager for robust subprocess launching, tracking, and cancellation.
"""
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False):
def __init__(self, cmd, env=None, cwd=None, text=False, bufsize=0, separate_stderr=False, enable_stdin=False):
self.cmd = cmd
# Default to cleaned environment if None to prevent AppImage variable inheritance
if env is None:
@@ -165,14 +166,18 @@ class ProcessManager:
self.text = text
self.bufsize = bufsize
self.separate_stderr = separate_stderr
self.enable_stdin = enable_stdin
self.proc = None
self.process_group_pid = None
self._stdin_lock = threading.Lock()
self._start_process()
def _start_process(self):
stderr_arg = subprocess.PIPE if self.separate_stderr else subprocess.STDOUT
stdin_arg = subprocess.PIPE if self.enable_stdin else None
self.proc = subprocess.Popen(
self.cmd,
stdin=stdin_arg,
stdout=subprocess.PIPE,
stderr=stderr_arg,
env=self.env,
@@ -190,31 +195,45 @@ class ProcessManager:
cleanup_attempts = 0
try:
if self.proc:
# Terminate process group first so child tools don't survive parent exit.
if self.process_group_pid:
try:
os.killpg(self.process_group_pid, signal.SIGTERM)
except Exception:
pass
try:
self.proc.terminate()
try:
self.proc.wait(timeout=timeout_terminate)
return
except subprocess.TimeoutExpired:
pass
except Exception:
pass
try:
self.proc.kill()
try:
self.proc.wait(timeout=timeout_kill)
return
except subprocess.TimeoutExpired:
pass
self.proc.wait(timeout=timeout_terminate)
except subprocess.TimeoutExpired:
pass
except Exception:
pass
# Kill entire process group (catches 7zz and other child processes)
# Escalate to SIGKILL for stubborn children/process group.
if self.process_group_pid:
try:
os.killpg(self.process_group_pid, signal.SIGKILL)
except Exception:
pass
# Last resort: pkill by command name
try:
self.proc.kill()
except Exception:
pass
try:
self.proc.wait(timeout=timeout_kill)
except subprocess.TimeoutExpired:
pass
except Exception:
pass
# Last resort: pkill by command name (kept bounded).
while cleanup_attempts < max_cleanup_attempts:
try:
subprocess.run(['pkill', '-f', os.path.basename(self.cmd[0])], timeout=5, capture_output=True)
@@ -224,7 +243,7 @@ class ProcessManager:
finally:
# Always close pipes — unblocks threads blocked on read(1) or iterating stderr
if self.proc:
for pipe in (self.proc.stdout, self.proc.stderr):
for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr):
if pipe:
try:
pipe.close()
@@ -250,4 +269,20 @@ class ProcessManager:
return self.proc.stdout.read(1)
except (ValueError, OSError):
return None
return None
return None
def write_stdin(self, line: str) -> bool:
"""
Write a line to the process stdin. Thread-safe.
Returns True on success, False if stdin is not available or process is gone.
"""
if not self.enable_stdin or not self.proc or not self.proc.stdin:
return False
with self._stdin_lock:
try:
payload = line if line.endswith('\n') else line + '\n'
self.proc.stdin.write(payload.encode())
self.proc.stdin.flush()
return True
except (OSError, BrokenPipeError):
return False

View File

@@ -64,17 +64,29 @@ class TTWInstallerBackendMixin:
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, universal_newlines=True
)
error_context: list = []
capturing_explanation = False
if process.stdout:
for line in process.stdout:
line = line.rstrip()
if line:
self.logger.info("TTW_Linux_Installer: %s", line)
lower = line.lower()
if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower:
error_context.append(line.strip())
capturing_explanation = True
elif capturing_explanation and line.startswith(' '):
error_context.append(line.strip())
else:
capturing_explanation = False
process.wait()
ret = process.returncode
if ret == 0:
self.logger.info("TTW installation completed successfully.")
return True, "TTW installation completed successfully!"
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
if error_context:
return False, "TTW installation failed:\n" + "\n".join(error_context)
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)
@@ -210,6 +222,8 @@ class TTWInstallerBackendMixin:
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, universal_newlines=True
)
error_context: list = []
capturing_explanation = False
if process.stdout:
for line in process.stdout:
line = line.rstrip()
@@ -217,12 +231,22 @@ class TTWInstallerBackendMixin:
self.logger.info("TTW_Linux_Installer: %s", line)
if output_callback:
output_callback(line)
lower = line.lower()
if 'failed' in lower or 'cannot continue' in lower or 'error:' in lower:
error_context.append(line.strip())
capturing_explanation = True
elif capturing_explanation and line.startswith(' '):
error_context.append(line.strip())
else:
capturing_explanation = False
process.wait()
ret = process.returncode
if ret == 0:
self.logger.info("TTW installation completed successfully.")
return True, "TTW installation completed successfully!"
self.logger.error("TTW installation process returned non-zero exit code: %s", ret)
if error_context:
return False, "TTW installation failed:\n" + "\n".join(error_context)
return False, f"TTW installation failed with exit code {ret}"
except Exception as e:
self.logger.error("Error executing TTW_Linux_Installer: %s", e, exc_info=True)