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

@@ -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

View File

@@ -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",

View File

@@ -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