mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 01:47:45 +02:00
Sync from development - prepare for v0.4.0
This commit is contained in:
382
jackify/shared/errors.py
Normal file
382
jackify/shared/errors.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""Structured error types for Jackify.
|
||||
|
||||
All user-facing failures should raise a JackifyError subclass so callers
|
||||
can display a consistent, plain-English error dialog with actionable advice
|
||||
and a numbered list of things to try.
|
||||
"""
|
||||
import re
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class JackifyError(Exception):
|
||||
"""Base class for all user-facing Jackify errors."""
|
||||
|
||||
def __init__(self, title: str, message: str,
|
||||
suggestion: Optional[str] = None,
|
||||
solutions: Optional[List[str]] = None,
|
||||
technical: Optional[str] = None):
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.suggestion = suggestion
|
||||
self.solutions = solutions or []
|
||||
self.technical = technical
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class SteamError(JackifyError):
|
||||
pass
|
||||
|
||||
|
||||
class PrefixCreationError(JackifyError):
|
||||
pass
|
||||
|
||||
|
||||
class ProtonNotFoundError(JackifyError):
|
||||
pass
|
||||
|
||||
|
||||
class ModlistError(JackifyError):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigError(JackifyError):
|
||||
pass
|
||||
|
||||
|
||||
class InstallError(JackifyError):
|
||||
pass
|
||||
|
||||
|
||||
class TTWError(JackifyError):
|
||||
pass
|
||||
|
||||
|
||||
class OAuthError(JackifyError):
|
||||
pass
|
||||
|
||||
|
||||
_SENSITIVE_KEYWORDS = (
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"secret",
|
||||
"authorization",
|
||||
"oauth",
|
||||
"bearer",
|
||||
"password",
|
||||
)
|
||||
|
||||
|
||||
def _looks_sensitive_key(key: str) -> bool:
|
||||
lowered = key.lower()
|
||||
return any(word in lowered for word in _SENSITIVE_KEYWORDS)
|
||||
|
||||
|
||||
def _scrub_sensitive_text(text: str) -> str:
|
||||
"""Best-effort redaction for key=value style sensitive fragments."""
|
||||
scrubbed = text
|
||||
patterns = [
|
||||
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)
|
||||
return scrubbed
|
||||
|
||||
|
||||
def format_technical_context(detail: Optional[str] = None, context: Optional[dict] = None) -> Optional[str]:
|
||||
"""Format technical context into a readable block with secret redaction."""
|
||||
lines: List[str] = []
|
||||
|
||||
if detail:
|
||||
safe_detail = _scrub_sensitive_text(str(detail).strip())
|
||||
if safe_detail:
|
||||
lines.append("Detail:")
|
||||
lines.append(safe_detail)
|
||||
|
||||
if context:
|
||||
ctx_lines: List[str] = []
|
||||
for key, value in context.items():
|
||||
if _looks_sensitive_key(str(key)):
|
||||
safe_value = "[REDACTED]"
|
||||
else:
|
||||
safe_value = _scrub_sensitive_text(str(value))
|
||||
ctx_lines.append(f"- {key}: {safe_value}")
|
||||
if ctx_lines:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.append("Context:")
|
||||
lines.extend(ctx_lines)
|
||||
|
||||
if not lines:
|
||||
return None
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _logs_dir_display() -> str:
|
||||
"""Return the active Jackify logs directory for user-facing guidance."""
|
||||
try:
|
||||
from jackify.shared.paths import get_jackify_logs_dir
|
||||
return str(get_jackify_logs_dir())
|
||||
except Exception:
|
||||
return "~/Jackify/logs"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory functions for known failure modes.
|
||||
# No GUI imports allowed here — backend code raises these directly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def steam_still_running() -> SteamError:
|
||||
return SteamError(
|
||||
title="Steam Could Not Be Shut Down",
|
||||
message="Jackify attempted to close Steam automatically but it did not respond in time.",
|
||||
suggestion="Close Steam fully, then continue from the correct Jackify workflow.",
|
||||
solutions=[
|
||||
"Exit Steam from the Steam UI or system tray icon.",
|
||||
"Wait 10-15 seconds before continuing.",
|
||||
"If the install phase completed successfully and there is a shortcut in Steam for your modlist after restarting Steam, run 'Configure Existing Modlist' in Jackify.",
|
||||
"If the Steam shortcut is not present after restarting Steam, run 'Configure New Modlist' in Jackify.",
|
||||
f"Check Jackify logs ({_logs_dir_display()}) for the specific shutdown failure.",
|
||||
"If this repeats, open a GitHub issue and include your Jackify logs.",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def proton_not_found() -> ProtonNotFoundError:
|
||||
return ProtonNotFoundError(
|
||||
title="No Proton Version Found",
|
||||
message="Jackify could not find a Proton installation to create the game prefix.",
|
||||
suggestion="Make sure Steam has registered at least one Proton version, then select it in Jackify.",
|
||||
solutions=[
|
||||
"In Steam, open Settings > Compatibility and enable Steam Play for supported/all titles.",
|
||||
"Launch any Windows game once in Steam to let Steam finish Proton setup and registration.",
|
||||
"In Jackify Settings, select your installed Proton under 'Proton Version'.",
|
||||
"If you want GE-Proton, install it with ProtonPlus or ProtonUp-Qt.",
|
||||
f"If detection still fails, check Jackify logs ({_logs_dir_display()}) and open a GitHub issue.",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def shortcut_write_failed(detail: str) -> SteamError:
|
||||
return SteamError(
|
||||
title="Steam Shortcut Could Not Be Created",
|
||||
message="Jackify was unable to write the Steam shortcut for this modlist.",
|
||||
suggestion="Close Steam fully, verify userdata permissions, then continue with the correct configure flow.",
|
||||
solutions=[
|
||||
"Close Steam completely (check system tray) and retry.",
|
||||
"Check that your home directory has write permissions: ls -la ~/.steam/steam/userdata/",
|
||||
"If running Steam as Flatpak, confirm Jackify has access to the Flatpak data directory.",
|
||||
"Check available disk space: df -h ~",
|
||||
"If modlist install files are already complete, relaunch Steam manually and use 'Configure New Modlist' in Jackify.",
|
||||
f"Check Jackify logs ({_logs_dir_display()}) for the specific write error.",
|
||||
"If this keeps failing, open a GitHub issue and include your Jackify logs.",
|
||||
],
|
||||
technical=format_technical_context(detail=detail),
|
||||
)
|
||||
|
||||
|
||||
def prefix_creation_failed(detail: str) -> PrefixCreationError:
|
||||
return PrefixCreationError(
|
||||
title="Proton Prefix Creation Failed",
|
||||
message="Jackify could not create the Proton compatibility prefix for this modlist.",
|
||||
suggestion="Check Proton is installed and the modlist directory is accessible.",
|
||||
solutions=[
|
||||
"Confirm Steam Play is enabled in Steam > Settings > Compatibility.",
|
||||
"Launch a Windows game once in Steam so Proton is fully initialized.",
|
||||
"Confirm a Proton version is selected in Jackify Settings.",
|
||||
"Check available disk space on the modlist drive: df -h",
|
||||
"Ensure the modlist directory exists and is readable.",
|
||||
"Try closing all other Steam/Proton processes before retrying.",
|
||||
f"Check Jackify logs ({_logs_dir_display()}) for the specific failure point.",
|
||||
"If this fails consistently, open a GitHub issue and include your Jackify logs.",
|
||||
],
|
||||
technical=format_technical_context(detail=detail),
|
||||
)
|
||||
|
||||
|
||||
def steam_restart_failed(detail: str) -> SteamError:
|
||||
return SteamError(
|
||||
title="Steam Did Not Restart",
|
||||
message="Jackify could not confirm Steam came back after the install/configuration step.",
|
||||
suggestion="Start Steam manually, then continue with the appropriate Jackify configure flow.",
|
||||
solutions=[
|
||||
"Launch Steam manually and wait until the library is fully loaded.",
|
||||
"If Steam is showing an update prompt, complete or cancel it first.",
|
||||
"If the modlist shortcut is visible in Steam, use 'Configure Existing Modlist' in Jackify.",
|
||||
"If the shortcut is missing but install files are present, use 'Configure New Modlist' in Jackify.",
|
||||
"Do not rerun the full download/install unless you are missing modlist files.",
|
||||
f"If recovery still fails, check Jackify logs ({_logs_dir_display()}) and open a GitHub issue.",
|
||||
],
|
||||
technical=format_technical_context(detail=detail),
|
||||
)
|
||||
|
||||
|
||||
def modlist_not_found(path: str) -> ModlistError:
|
||||
return ModlistError(
|
||||
title="Modlist Directory Not Found",
|
||||
message=f"The modlist directory does not exist: {path}",
|
||||
suggestion="Check the path is correct and the drive is mounted.",
|
||||
solutions=[
|
||||
"Verify the path is correct and has not been moved or deleted.",
|
||||
"If the modlist is on an external drive or SD card, ensure it is mounted.",
|
||||
"On Steam Deck, SD card paths are typically under /run/media/",
|
||||
"Re-select the modlist directory in Jackify.",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def configuration_failed(detail: str) -> ConfigError:
|
||||
return ConfigError(
|
||||
title="Post-Install Configuration Failed",
|
||||
message="Jackify could not complete the post-installation configuration for this modlist.",
|
||||
suggestion=f"Check Jackify logs ({_logs_dir_display()}) for the specific step that failed.",
|
||||
solutions=[
|
||||
"Confirm Steam is running and fully loaded before retrying.",
|
||||
"Check that the modlist AppID appears in your Steam library (look for the shortcut).",
|
||||
"Try 'Configure Existing Modlist' from the main menu to re-run configuration.",
|
||||
"Verify Proton is set correctly in Jackify Settings.",
|
||||
"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),
|
||||
)
|
||||
|
||||
|
||||
def ttw_install_failed(detail: str) -> TTWError:
|
||||
return TTWError(
|
||||
title="TTW Installation Failed",
|
||||
message="Tale of Two Wastelands could not be installed.",
|
||||
suggestion="Check that your vanilla Fallout 3 and Fallout New Vegas installs are clean and accessible.",
|
||||
solutions=[
|
||||
"Confirm vanilla Fallout 3 and Fallout New Vegas are both installed and launch correctly.",
|
||||
"If either game was previously modded, restore a clean vanilla install before retrying TTW.",
|
||||
"Ensure TTW_Linux_Installer is installed — use 'Install TTW Installer' in Additional Tasks.",
|
||||
"Check available disk space — TTW requires ~15GB free.",
|
||||
"Verify the TTW .mpi file is not corrupted (try re-downloading it).",
|
||||
f"Check Jackify logs ({_logs_dir_display()}) and TTW_Install_workflow.log for the specific failure.",
|
||||
f"If this still fails, open a GitHub issue and include logs from {_logs_dir_display()}.",
|
||||
],
|
||||
technical=format_technical_context(detail=detail),
|
||||
)
|
||||
|
||||
|
||||
def wabbajack_install_failed(detail: str) -> InstallError:
|
||||
return InstallError(
|
||||
title="Wabbajack Installation Failed",
|
||||
message="The modlist installation did not complete successfully.",
|
||||
suggestion=f"Check the console output and Jackify logs ({_logs_dir_display()}) for the failure reason.",
|
||||
solutions=[
|
||||
"Ensure you are logged in to Nexus Mods — check Settings > OAuth.",
|
||||
"Confirm your Nexus account has Premium access for automated downloads.",
|
||||
"Check available disk space on both the install and download drives.",
|
||||
"Re-run the install — Wabbajack resumes from where it stopped.",
|
||||
"If a specific file failed repeatedly, try downloading it manually from Nexus.",
|
||||
"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),
|
||||
)
|
||||
|
||||
|
||||
def oauth_expired() -> OAuthError:
|
||||
return OAuthError(
|
||||
title="Nexus Authentication Expired",
|
||||
message="Your Nexus Mods authorisation has expired or is no longer valid.",
|
||||
suggestion="In Settings, revoke the current Nexus authorisation first, then authorise again.",
|
||||
solutions=[
|
||||
"Open Jackify Settings and click 'Revoke Nexus Authorisation' first.",
|
||||
"Then click 'Authorise with Nexus Mods'.",
|
||||
"Complete the browser authorisation flow and return to Jackify.",
|
||||
"If the browser does not open automatically, copy the URL from the console and open it manually.",
|
||||
"After re-authorising, retry the failed operation.",
|
||||
f"If this keeps failing, check Jackify logs ({_logs_dir_display()}) and open a GitHub issue.",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def install_dir_create_failed(path: str, detail: str) -> InstallError:
|
||||
return InstallError(
|
||||
title="Could Not Create Install Directory",
|
||||
message=f"Jackify could not create the installation directory: {path}",
|
||||
suggestion="Check you have write permission to the target drive.",
|
||||
solutions=[
|
||||
"Confirm the target drive is mounted and writable.",
|
||||
"Check available disk space: df -h",
|
||||
"Try creating the folder manually first, then retry.",
|
||||
"On Steam Deck, avoid paths under /usr or /var — use /home/deck or an SD card.",
|
||||
],
|
||||
technical=format_technical_context(detail=detail, context={"path": path}),
|
||||
)
|
||||
|
||||
|
||||
def manual_steps_incomplete() -> ConfigError:
|
||||
return ConfigError(
|
||||
title="Unexpected Internal Setup State",
|
||||
message="Jackify reached a setup state that should not occur in normal workflows.",
|
||||
suggestion="Restart Steam and retry once. If this appears again, treat it as a Jackify bug and report it.",
|
||||
solutions=[
|
||||
"Restart Steam and verify your modlist shortcut is visible.",
|
||||
"Retry your last Jackify action once after Steam restarts.",
|
||||
"Do not perform manual Steam shortcut or prefix setup steps.",
|
||||
f"If this state appears again, check Jackify logs ({_logs_dir_display()}) and open a GitHub issue.",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def mo2_setup_failed(detail: str) -> InstallError:
|
||||
return InstallError(
|
||||
title="Mod Organizer 2 Setup Failed",
|
||||
message="Jackify could not complete the Mod Organizer 2 setup.",
|
||||
suggestion=f"Check Jackify logs ({_logs_dir_display()}) for the specific failure.",
|
||||
solutions=[
|
||||
"Ensure you have an active internet connection — MO2 is downloaded from GitHub.",
|
||||
"Check available disk space in the install directory.",
|
||||
"Try selecting a different install directory with full write permissions.",
|
||||
"If the download failed, check GitHub is accessible (try opening it in a browser).",
|
||||
],
|
||||
technical=format_technical_context(detail=detail),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception classifier.
|
||||
# Maps known error substrings to specific JackifyError factory functions.
|
||||
# Called by callers that catch a bare Exception and want to produce a
|
||||
# structured error rather than wrapping with prefix_creation_failed().
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PATTERNS: List[tuple] = [
|
||||
# Steam / prefix / Proton
|
||||
("no space left on device", lambda d: InstallError("Disk Full", "There is no space left on the target drive.", suggestion="Free up disk space and retry.", solutions=["Run: df -h to see available space.", "Delete old modlist downloads or backups.", "Move the install to a larger drive."], technical=format_technical_context(detail=d))),
|
||||
("permission denied", lambda d: SteamError("Permission Denied", "Jackify was refused access to a required file or directory.", suggestion="Check file permissions on the target path.", solutions=["Run: ls -la <path> to inspect permissions.", "Ensure Steam and Jackify are run as the same user.", "Avoid install paths under /usr, /var or /opt."], technical=format_technical_context(detail=d))),
|
||||
("steamwebhelper", lambda d: steam_still_running()),
|
||||
("no such file or directory.*compatdata", lambda d: proton_not_found()),
|
||||
("proton.*not found|no proton", lambda d: proton_not_found()),
|
||||
("vdf.*error|binary_vdf|invalid vdf", lambda d: SteamError("Steam VDF File Error", "A Steam configuration file (VDF) could not be read or written.", suggestion="Ensure Steam is closed and try again.", solutions=["Close Steam completely before retrying.", "Restart Steam and retry the same action.", f"Check Jackify logs ({_logs_dir_display()}) for the specific VDF path.", f"If this still fails, open a GitHub issue and include logs from {_logs_dir_display()}."], technical=format_technical_context(detail=d))),
|
||||
("connection.*refused|connection.*timed out|network.*unreachable", lambda d: InstallError("Network Error", "Jackify could not reach a required network resource.", suggestion="Check your internet connection and retry.", solutions=["Verify your internet connection is active.", "Check if Nexus Mods is reachable at nexusmods.com.", "Disable VPN or proxy if active.", "Retry — transient network errors often resolve on the second attempt."], technical=format_technical_context(detail=d))),
|
||||
("401|unauthorized|forbidden.*nexus", lambda d: oauth_expired()),
|
||||
("7z.*error|bad archive|cannot open.*archive", lambda d: InstallError("Archive Error", "A downloaded archive file is corrupted or unreadable.", suggestion="Delete the corrupted file and re-run the install to re-download it.", solutions=["Re-run the install — Wabbajack will re-download files that fail verification.", "Check available disk space (partial downloads look corrupt).", "Check Modlist_Install_workflow.log for the specific file name."], technical=format_technical_context(detail=d))),
|
||||
("timeout", lambda d: SteamError("Operation Timed Out", "An operation took longer than expected and was stopped.", suggestion="Retry — timeouts are often transient.", solutions=["Retry the operation.", "If Steam is slow to start, give it more time before retrying.", "Check system load: close other applications.", f"Check Jackify logs ({_logs_dir_display()}) for which step timed out."], technical=format_technical_context(detail=d))),
|
||||
]
|
||||
|
||||
|
||||
def classify_exception(exc_str: str, fallback_factory=None) -> JackifyError:
|
||||
"""Return a structured JackifyError for a raw exception string.
|
||||
|
||||
Checks known error patterns in order. Returns the first match.
|
||||
Falls back to fallback_factory(exc_str) if provided, otherwise
|
||||
returns a generic prefix_creation_failed error.
|
||||
"""
|
||||
import re
|
||||
lowered = exc_str.lower()
|
||||
for pattern, factory in _PATTERNS:
|
||||
if re.search(pattern, lowered):
|
||||
result = factory(exc_str)
|
||||
if not result.technical:
|
||||
result.technical = exc_str
|
||||
return result
|
||||
|
||||
if fallback_factory is not None:
|
||||
return fallback_factory(exc_str)
|
||||
return prefix_creation_failed(exc_str)
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
from typing import Optional, Dict, List
|
||||
from datetime import datetime
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
class LoggingHandler:
|
||||
"""
|
||||
@@ -35,7 +36,7 @@ class LoggingHandler:
|
||||
try:
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
print(f"Failed to create log directory: {e}")
|
||||
sys.stderr.write(f"Failed to create log directory: {e}\n")
|
||||
|
||||
def rotate_log_file_per_run(self, log_file_path: Path, backup_count: int = 5):
|
||||
"""Rotate the log file on every run, keeping up to backup_count backups."""
|
||||
@@ -131,7 +132,7 @@ class LoggingHandler:
|
||||
for old_backup in backups[:-backup_count]:
|
||||
old_backup.unlink()
|
||||
except Exception as e:
|
||||
print(f"Failed to rotate log file {log_file}: {e}")
|
||||
sys.stderr.write(f"Failed to rotate log file {log_file}: {e}\n")
|
||||
|
||||
def cleanup_old_logs(self, days: int = 30) -> None:
|
||||
"""Clean up log files older than specified days."""
|
||||
@@ -141,7 +142,7 @@ class LoggingHandler:
|
||||
if log_file.stat().st_mtime < cutoff:
|
||||
log_file.unlink()
|
||||
except Exception as e:
|
||||
print(f"Failed to clean up log file {log_file}: {e}")
|
||||
sys.stderr.write(f"Failed to clean up log file {log_file}: {e}\n")
|
||||
|
||||
def get_log_files(self) -> List[Path]:
|
||||
"""Get a list of all log files."""
|
||||
@@ -153,7 +154,7 @@ class LoggingHandler:
|
||||
with open(log_file, 'r') as f:
|
||||
return f.readlines()[-lines:]
|
||||
except Exception as e:
|
||||
print(f"Failed to read log file {log_file}: {e}")
|
||||
sys.stderr.write(f"Failed to read log file {log_file}: {e}\n")
|
||||
return []
|
||||
|
||||
def search_logs(self, pattern: str) -> Dict[Path, List[str]]:
|
||||
@@ -166,7 +167,7 @@ class LoggingHandler:
|
||||
if matches:
|
||||
results[log_file] = matches
|
||||
except Exception as e:
|
||||
print(f"Failed to search log file {log_file}: {e}")
|
||||
sys.stderr.write(f"Failed to search log file {log_file}: {e}\n")
|
||||
return results
|
||||
|
||||
def export_logs(self, output_dir: Path) -> bool:
|
||||
@@ -177,7 +178,7 @@ class LoggingHandler:
|
||||
shutil.copy2(log_file, output_dir / log_file.name)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to export logs: {e}")
|
||||
sys.stderr.write(f"Failed to export logs: {e}\n")
|
||||
return False
|
||||
|
||||
def set_log_level(self, level: int) -> None:
|
||||
@@ -207,7 +208,7 @@ class LoggingHandler:
|
||||
stats['newest_file'] = max(log_files, key=lambda x: x.stat().st_mtime)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to get log stats: {e}")
|
||||
sys.stderr.write(f"Failed to get log stats: {e}\n")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ class InstallationProgress:
|
||||
@property
|
||||
def phase_progress_text(self) -> str:
|
||||
"""Get phase progress text like '[12/14]'."""
|
||||
if self.phase_max_steps > 0:
|
||||
if self.phase_max_steps > 0 and self.phase_step > 0:
|
||||
return f"[{self.phase_step}/{self.phase_max_steps}]"
|
||||
elif self.phase_step > 0:
|
||||
return f"[{self.phase_step}]"
|
||||
@@ -273,15 +273,9 @@ class InstallationProgress:
|
||||
secs = int(seconds % 60)
|
||||
|
||||
if hours > 0:
|
||||
if minutes > 0:
|
||||
return f"{hours}h {minutes}m"
|
||||
else:
|
||||
return f"{hours}h"
|
||||
return f"{hours}h {minutes:02d}m"
|
||||
elif minutes > 0:
|
||||
if secs > 0:
|
||||
return f"{minutes}m {secs}s"
|
||||
else:
|
||||
return f"{minutes}m"
|
||||
return f"{minutes}m {secs:02d}s"
|
||||
else:
|
||||
return f"{secs}s"
|
||||
|
||||
@@ -348,6 +342,14 @@ class InstallationProgress:
|
||||
|
||||
def get_phase_label(self) -> str:
|
||||
"""Return a short, stable label for the current phase."""
|
||||
# During install+texture conversion, keep a stable combined label to avoid
|
||||
# rapid banner flipping between install and conversion text.
|
||||
if self.phase == InstallationPhase.INSTALL and self.texture_conversion_total > 0:
|
||||
has_install_counter = self.phase_max_steps > 0 and self.phase_max_steps != self.texture_conversion_total
|
||||
if has_install_counter:
|
||||
return "Installing + Converting Textures"
|
||||
return "Converting Textures"
|
||||
|
||||
# Check for specific operations first (more specific than generic phase labels)
|
||||
if self.phase_name:
|
||||
phase_lower = self.phase_name.lower()
|
||||
@@ -358,9 +360,12 @@ class InstallationProgress:
|
||||
if 'bsa' in phase_lower or ('building' in phase_lower and self.phase == InstallationPhase.INSTALL):
|
||||
return "Building BSAs"
|
||||
|
||||
# For FINALIZE phase, always prefer phase_name over generic "Finalising" label
|
||||
# Post-install steps can show specific labels
|
||||
if self.phase == InstallationPhase.FINALIZE and self.phase_name:
|
||||
# For FINALIZE and INITIALIZATION phases, prefer phase_name over the generic label.
|
||||
# INITIALIZATION cycles through many short sections ("Configuring Installer",
|
||||
# "Looking for unmodified files", etc.) that are more informative than "Preparing".
|
||||
if self.phase in (InstallationPhase.FINALIZE, InstallationPhase.INITIALIZATION) and self.phase_name:
|
||||
return self.phase_name
|
||||
if self.phase == InstallationPhase.DOWNLOAD and self.phase_name:
|
||||
return self.phase_name
|
||||
|
||||
phase_labels = {
|
||||
@@ -397,11 +402,26 @@ class InstallationProgress:
|
||||
else:
|
||||
# Normal phase - show phase progress
|
||||
phase_prog = self.phase_progress_text
|
||||
# For download phase, hide zero-step counters (e.g. [0/2]) because
|
||||
# they are confusing when byte/speed progress is already active.
|
||||
if self.phase == InstallationPhase.DOWNLOAD and self.phase_step <= 0:
|
||||
phase_prog = ""
|
||||
# If current step reflects texture counters, don't duplicate that as
|
||||
# the primary install counter.
|
||||
if self.phase == InstallationPhase.INSTALL and self.texture_conversion_total > 0:
|
||||
if self.phase_max_steps > 0 and self.phase_max_steps == self.texture_conversion_total:
|
||||
phase_prog = ""
|
||||
if phase_prog:
|
||||
parts.append(phase_prog)
|
||||
|
||||
# Data progress (but not during BSA building)
|
||||
data_prog = self.data_progress_text
|
||||
if data_prog:
|
||||
# Some engine versions report a changing remaining total while keeping
|
||||
# processed bytes at 0. Avoid showing misleading "(0B/YYY)" pairs.
|
||||
if self.phase == InstallationPhase.DOWNLOAD and self.data_total > 0 and self.data_processed <= 0:
|
||||
data_prog = ""
|
||||
|
||||
if data_prog:
|
||||
# Don't show if it's 100% complete (adds no value)
|
||||
if self.data_total > 0 and self.data_processed < self.data_total:
|
||||
@@ -409,6 +429,10 @@ class InstallationProgress:
|
||||
elif self.data_total == 0 and self.data_processed > 0:
|
||||
# Show partial progress even without total
|
||||
parts.append(f"({data_prog})")
|
||||
if self.phase == InstallationPhase.INSTALL and self.texture_conversion_total > 0:
|
||||
tex_total = self.texture_conversion_total
|
||||
tex_current = max(0, min(self.texture_conversion_current, tex_total))
|
||||
parts.append(f"Converting textures: {tex_current}/{tex_total}")
|
||||
|
||||
# Overall speed (if available, but not during BSA building)
|
||||
if self.bsa_building_total == 0:
|
||||
@@ -500,4 +524,3 @@ class InstallationProgress:
|
||||
# Update speed history for ETA smoothing
|
||||
if speed > 0:
|
||||
self._update_speed_history(op_key, speed)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user