mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 01:37:44 +02:00
Sync from development - prepare for v0.5.0
This commit is contained in:
@@ -45,7 +45,7 @@ def get_appimage_path() -> Optional[Path]:
|
||||
if 'jackify' in path.name.lower():
|
||||
return path
|
||||
else:
|
||||
# Running from different AppImage (e.g., development in Cursor.AppImage)
|
||||
# Running from a different AppImage context
|
||||
return None
|
||||
|
||||
return None
|
||||
@@ -94,4 +94,4 @@ def get_appimage_info() -> dict:
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
return info
|
||||
return info
|
||||
|
||||
@@ -75,12 +75,24 @@ def _looks_sensitive_key(key: str) -> bool:
|
||||
def _scrub_sensitive_text(text: str) -> str:
|
||||
"""Best-effort redaction for key=value style sensitive fragments."""
|
||||
scrubbed = text
|
||||
patterns = [
|
||||
# Authorization header forms: "authorization: Bearer <token>"
|
||||
scrubbed = re.sub(
|
||||
r"(?i)\bauthorization\b\s*[:=]\s*bearer\s+[A-Za-z0-9\-._~+/]+=*",
|
||||
"authorization=[REDACTED]",
|
||||
scrubbed,
|
||||
)
|
||||
# Standalone bearer form: "Bearer <token>"
|
||||
scrubbed = re.sub(
|
||||
r"(?i)\b(bearer)\s+[A-Za-z0-9\-._~+/]+=*",
|
||||
r"\1=[REDACTED]",
|
||||
scrubbed,
|
||||
)
|
||||
# Generic sensitive key/value forms.
|
||||
scrubbed = re.sub(
|
||||
r"(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|token|authorization|password|secret)\b\s*[:=]\s*([^\s,;]+)",
|
||||
r"(?i)\b(bearer)\s+([A-Za-z0-9\-._~+/]+=*)",
|
||||
]
|
||||
for pattern in patterns:
|
||||
scrubbed = re.sub(pattern, r"\1=[REDACTED]", scrubbed)
|
||||
r"\1=[REDACTED]",
|
||||
scrubbed,
|
||||
)
|
||||
return scrubbed
|
||||
|
||||
|
||||
@@ -226,7 +238,24 @@ def modlist_not_found(path: str) -> ModlistError:
|
||||
)
|
||||
|
||||
|
||||
def configuration_failed(detail: str) -> ConfigError:
|
||||
def game_not_found_for_modlist(game_name: str, detail: Optional[str] = None) -> InstallError:
|
||||
game = (game_name or "Unknown game").strip()
|
||||
return InstallError(
|
||||
title="Required Game Not Found",
|
||||
message=f"Jackify could not find the required base game: {game}",
|
||||
suggestion="Install the base game in Steam, launch it once, then retry.",
|
||||
solutions=[
|
||||
"Confirm the game is installed in Steam and fully updated.",
|
||||
"Launch the vanilla game once from Steam to complete first-run setup.",
|
||||
"If you have multiple Steam installs, ensure Jackify is pointed at the install that contains this game.",
|
||||
"Restart Steam and retry the install workflow.",
|
||||
f"If detection still fails, check Jackify logs ({_logs_dir_display()}) for game-detection details.",
|
||||
],
|
||||
technical=format_technical_context(detail=detail, context={"required_game": game}),
|
||||
)
|
||||
|
||||
|
||||
def configuration_failed(detail: str, context: Optional[dict] = None) -> ConfigError:
|
||||
return ConfigError(
|
||||
title="Post-Install Configuration Failed",
|
||||
message="Jackify could not complete the post-installation configuration for this modlist.",
|
||||
@@ -239,7 +268,7 @@ def configuration_failed(detail: str) -> ConfigError:
|
||||
"If the error mentions registry or prefix, ensure sufficient disk space.",
|
||||
f"If this still fails, check Jackify logs ({_logs_dir_display()}) and open a GitHub issue with modlist name.",
|
||||
],
|
||||
technical=format_technical_context(detail=detail),
|
||||
technical=format_technical_context(detail=detail, context=context),
|
||||
)
|
||||
|
||||
|
||||
@@ -261,7 +290,7 @@ def ttw_install_failed(detail: str) -> TTWError:
|
||||
)
|
||||
|
||||
|
||||
def wabbajack_install_failed(detail: str) -> InstallError:
|
||||
def wabbajack_install_failed(detail: str, context: Optional[dict] = None) -> InstallError:
|
||||
return InstallError(
|
||||
title="Wabbajack Installation Failed",
|
||||
message="The modlist installation did not complete successfully.",
|
||||
@@ -275,7 +304,7 @@ def wabbajack_install_failed(detail: str) -> InstallError:
|
||||
"Check Modlist_Install_workflow.log for the specific file that failed.",
|
||||
"If the same failure repeats with no clear workaround, open a GitHub issue with logs.",
|
||||
],
|
||||
technical=format_technical_context(detail=detail),
|
||||
technical=format_technical_context(detail=detail, context=context),
|
||||
)
|
||||
|
||||
|
||||
@@ -324,6 +353,27 @@ def manual_steps_incomplete() -> ConfigError:
|
||||
)
|
||||
|
||||
|
||||
def cc_content_missing(filename: str = "") -> InstallError:
|
||||
detail = f"Missing file: {filename}" if filename else ""
|
||||
return InstallError(
|
||||
title="Anniversary Edition Content Missing",
|
||||
message=(
|
||||
"One or more Skyrim Anniversary Edition Creation Club files were not found "
|
||||
"in your game installation."
|
||||
+ (f" ({filename})" if filename else "")
|
||||
),
|
||||
suggestion="Open Vanilla Skyrim and allow it to download the required Anniversary Edition content.",
|
||||
solutions=[
|
||||
"Open Vanilla Skyrim SE/AE and let it run until all Creation Club content has downloaded.",
|
||||
"From the Skyrim main menu, go into Creations and select 'Download All'.",
|
||||
"If specific files are still missing, search for and download them individually from the Creations menu.",
|
||||
"If problems persist, uninstall and reinstall Skyrim, then launch once to trigger the AE download.",
|
||||
"Note: Skyrim AE via Steam Family Sharing does not transfer DLC content — you must own AE directly.",
|
||||
],
|
||||
technical=format_technical_context(detail=detail) if detail else None,
|
||||
)
|
||||
|
||||
|
||||
def mo2_setup_failed(detail: str) -> InstallError:
|
||||
return InstallError(
|
||||
title="Mod Organizer 2 Setup Failed",
|
||||
|
||||
@@ -5,12 +5,41 @@ Centralized Steam installation type detection to avoid redundant subprocess call
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import shutil
|
||||
from typing import Tuple
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NATIVE_STEAM_ROOTS = [
|
||||
Path.home() / ".steam" / "steam",
|
||||
Path.home() / ".local" / "share" / "Steam",
|
||||
Path.home() / ".steam" / "root",
|
||||
]
|
||||
|
||||
FLATPAK_STEAM_ROOTS = [
|
||||
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam",
|
||||
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / ".local" / "share" / "Steam",
|
||||
Path.home() / ".var" / "app" / "com.valvesoftware.Steam" / "home" / ".local" / "share" / "Steam",
|
||||
]
|
||||
|
||||
STEAM_PREFERENCE_AUTO = "auto"
|
||||
STEAM_PREFERENCE_NATIVE = "native"
|
||||
STEAM_PREFERENCE_FLATPAK = "flatpak"
|
||||
|
||||
# Common Jackify-supported game AppIDs used to infer which Steam install is actually in use.
|
||||
_STEAM_USAGE_APPIDS = {
|
||||
"489830", # Skyrim Special Edition
|
||||
"377160", # Fallout 4
|
||||
"22380", # Fallout New Vegas
|
||||
"22330", # Oblivion
|
||||
"22370", # Fallout 3
|
||||
"1716740", # Starfield
|
||||
}
|
||||
|
||||
|
||||
def detect_steam_installation_types() -> Tuple[bool, bool]:
|
||||
"""
|
||||
@@ -21,14 +50,187 @@ def detect_steam_installation_types() -> Tuple[bool, bool]:
|
||||
Returns:
|
||||
Tuple[bool, bool]: (is_flatpak_steam, is_native_steam)
|
||||
"""
|
||||
is_flatpak = _detect_flatpak_steam()
|
||||
is_native = _detect_native_steam()
|
||||
raw_flatpak = _detect_flatpak_steam()
|
||||
raw_native = _detect_native_steam()
|
||||
|
||||
logger.info(f"Steam installation detection: Flatpak={is_flatpak}, Native={is_native}")
|
||||
is_flatpak = raw_flatpak
|
||||
is_native = raw_native
|
||||
preferred_type, preferred_root = resolve_preferred_steam_installation()
|
||||
|
||||
# Deterministic dual-install behavior: expose one active Steam type.
|
||||
if raw_flatpak and raw_native:
|
||||
if preferred_type == STEAM_PREFERENCE_FLATPAK:
|
||||
is_flatpak, is_native = True, False
|
||||
else:
|
||||
is_flatpak, is_native = False, True
|
||||
|
||||
logger.info(
|
||||
"Steam installation detection: Flatpak=%s, Native=%s, Preferred=%s (%s), RawFlatpak=%s, RawNative=%s",
|
||||
is_flatpak,
|
||||
is_native,
|
||||
preferred_type or "none",
|
||||
preferred_root or "n/a",
|
||||
raw_flatpak,
|
||||
raw_native,
|
||||
)
|
||||
|
||||
return is_flatpak, is_native
|
||||
|
||||
|
||||
def get_steam_install_roots(install_type: Optional[str] = None) -> List[Path]:
|
||||
"""Return known Steam roots for a specific install type or both."""
|
||||
if install_type == STEAM_PREFERENCE_FLATPAK:
|
||||
return list(FLATPAK_STEAM_ROOTS)
|
||||
if install_type == STEAM_PREFERENCE_NATIVE:
|
||||
return list(NATIVE_STEAM_ROOTS)
|
||||
return list(NATIVE_STEAM_ROOTS) + list(FLATPAK_STEAM_ROOTS)
|
||||
|
||||
|
||||
def is_flatpak_steam_root(path: Path) -> bool:
|
||||
"""Return True if a Steam root path belongs to Flatpak Steam."""
|
||||
path_str = str(path)
|
||||
return ".var/app/com.valvesoftware.Steam" in path_str
|
||||
|
||||
|
||||
def get_available_steam_roots() -> Dict[str, List[Path]]:
|
||||
"""Return discovered Steam roots grouped by install type."""
|
||||
roots = {
|
||||
STEAM_PREFERENCE_NATIVE: [],
|
||||
STEAM_PREFERENCE_FLATPAK: [],
|
||||
}
|
||||
for root in NATIVE_STEAM_ROOTS:
|
||||
if root.exists():
|
||||
roots[STEAM_PREFERENCE_NATIVE].append(root)
|
||||
for root in FLATPAK_STEAM_ROOTS:
|
||||
if root.exists():
|
||||
roots[STEAM_PREFERENCE_FLATPAK].append(root)
|
||||
return roots
|
||||
|
||||
|
||||
def get_ordered_steam_roots(preference: str = STEAM_PREFERENCE_AUTO) -> List[Path]:
|
||||
"""
|
||||
Return Steam roots in deterministic priority order.
|
||||
|
||||
If both native and flatpak are installed, preference controls order.
|
||||
AUTO uses the most recently active install (loginusers.vdf timestamp/mtime).
|
||||
"""
|
||||
available = get_available_steam_roots()
|
||||
native_roots = available[STEAM_PREFERENCE_NATIVE]
|
||||
flatpak_roots = available[STEAM_PREFERENCE_FLATPAK]
|
||||
|
||||
if preference not in {
|
||||
STEAM_PREFERENCE_AUTO,
|
||||
STEAM_PREFERENCE_NATIVE,
|
||||
STEAM_PREFERENCE_FLATPAK,
|
||||
}:
|
||||
preference = STEAM_PREFERENCE_AUTO
|
||||
|
||||
if preference == STEAM_PREFERENCE_NATIVE:
|
||||
return native_roots + flatpak_roots
|
||||
if preference == STEAM_PREFERENCE_FLATPAK:
|
||||
return flatpak_roots + native_roots
|
||||
|
||||
preferred_type, _ = resolve_preferred_steam_installation(STEAM_PREFERENCE_AUTO)
|
||||
if preferred_type == STEAM_PREFERENCE_FLATPAK:
|
||||
return flatpak_roots + native_roots
|
||||
return native_roots + flatpak_roots
|
||||
|
||||
|
||||
def resolve_preferred_steam_installation(
|
||||
preference: str = STEAM_PREFERENCE_AUTO,
|
||||
) -> Tuple[Optional[str], Optional[Path]]:
|
||||
"""
|
||||
Resolve the preferred Steam install type/root deterministically.
|
||||
|
||||
Priority:
|
||||
1) Explicit preference (`native` or `flatpak`) if installed
|
||||
2) AUTO mode: whichever install has more relevant installed-game manifests
|
||||
3) AUTO tie-break: newest loginusers activity marker
|
||||
4) Deterministic fallback: native first, then flatpak
|
||||
"""
|
||||
available = get_available_steam_roots()
|
||||
native_roots = available[STEAM_PREFERENCE_NATIVE]
|
||||
flatpak_roots = available[STEAM_PREFERENCE_FLATPAK]
|
||||
|
||||
if preference == STEAM_PREFERENCE_NATIVE and native_roots:
|
||||
return STEAM_PREFERENCE_NATIVE, native_roots[0]
|
||||
if preference == STEAM_PREFERENCE_FLATPAK and flatpak_roots:
|
||||
return STEAM_PREFERENCE_FLATPAK, flatpak_roots[0]
|
||||
|
||||
if native_roots and flatpak_roots:
|
||||
native_game_score = _steam_root_game_presence_score(native_roots[0])
|
||||
flatpak_game_score = _steam_root_game_presence_score(flatpak_roots[0])
|
||||
if flatpak_game_score > native_game_score:
|
||||
return STEAM_PREFERENCE_FLATPAK, flatpak_roots[0]
|
||||
if native_game_score > flatpak_game_score:
|
||||
return STEAM_PREFERENCE_NATIVE, native_roots[0]
|
||||
|
||||
native_score = _steam_root_activity_score(native_roots[0])
|
||||
flatpak_score = _steam_root_activity_score(flatpak_roots[0])
|
||||
if flatpak_score > native_score:
|
||||
return STEAM_PREFERENCE_FLATPAK, flatpak_roots[0]
|
||||
return STEAM_PREFERENCE_NATIVE, native_roots[0]
|
||||
|
||||
if native_roots:
|
||||
return STEAM_PREFERENCE_NATIVE, native_roots[0]
|
||||
if flatpak_roots:
|
||||
return STEAM_PREFERENCE_FLATPAK, flatpak_roots[0]
|
||||
return None, None
|
||||
|
||||
|
||||
def _steam_root_activity_score(steam_root: Path) -> float:
|
||||
"""
|
||||
Return a comparable activity score for Steam root.
|
||||
Uses loginusers.vdf mtime as a robust cross-layout signal.
|
||||
"""
|
||||
try:
|
||||
loginusers = steam_root / "config" / "loginusers.vdf"
|
||||
if loginusers.exists():
|
||||
return os.path.getmtime(loginusers)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read Steam activity marker for %s: %s", steam_root, exc)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _steam_root_game_presence_score(steam_root: Path) -> int:
|
||||
"""
|
||||
Score a Steam root by presence of relevant installed game appmanifests.
|
||||
Higher score means that Steam install is more likely the one user is actively using.
|
||||
"""
|
||||
score = 0
|
||||
for library_root in _get_library_roots_for_steam_root(steam_root):
|
||||
steamapps = library_root / "steamapps"
|
||||
if not steamapps.is_dir():
|
||||
continue
|
||||
for app_id in _STEAM_USAGE_APPIDS:
|
||||
manifest = steamapps / f"appmanifest_{app_id}.acf"
|
||||
if manifest.is_file():
|
||||
score += 1
|
||||
return score
|
||||
|
||||
|
||||
def _get_library_roots_for_steam_root(steam_root: Path) -> List[Path]:
|
||||
"""
|
||||
Return Steam library roots for a given Steam root using libraryfolders.vdf.
|
||||
Includes the primary Steam root as a fallback.
|
||||
"""
|
||||
roots: List[Path] = [steam_root]
|
||||
vdf_path = steam_root / "config" / "libraryfolders.vdf"
|
||||
if not vdf_path.is_file():
|
||||
return roots
|
||||
|
||||
try:
|
||||
text = vdf_path.read_text(encoding="utf-8", errors="ignore")
|
||||
for match in re.finditer(r'"path"\s*"([^"]+)"', text):
|
||||
raw_path = match.group(1).replace("\\\\", "\\")
|
||||
lib_root = Path(raw_path).expanduser()
|
||||
if lib_root not in roots:
|
||||
roots.append(lib_root)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed reading %s: %s", vdf_path, exc)
|
||||
return roots
|
||||
|
||||
|
||||
def _detect_flatpak_steam() -> bool:
|
||||
"""Detect if Steam is installed as a Flatpak."""
|
||||
try:
|
||||
@@ -58,16 +260,8 @@ def _detect_flatpak_steam() -> bool:
|
||||
def _detect_native_steam() -> bool:
|
||||
"""Detect if native Steam installation exists."""
|
||||
try:
|
||||
# Check for common Steam paths
|
||||
import os
|
||||
steam_paths = [
|
||||
os.path.expanduser("~/.steam/steam"),
|
||||
os.path.expanduser("~/.local/share/Steam"),
|
||||
os.path.expanduser("~/.steam/root")
|
||||
]
|
||||
|
||||
for path in steam_paths:
|
||||
if os.path.exists(path):
|
||||
for path in NATIVE_STEAM_ROOTS:
|
||||
if path.exists():
|
||||
logger.debug(f"Native Steam detected at: {path}")
|
||||
return True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user