diff --git a/constants.py b/constants.py index 7551bab..a4fac82 100644 --- a/constants.py +++ b/constants.py @@ -1,6 +1,11 @@ import os from PyQt6.QtGui import QFontDatabase +# Launcher version — must match the FileVersion tuple in version.txt +# (currently filevers=(1, 0, 8, 0) at version.txt:5). Bumping requires +# updating both this constant and version.txt together. +LAUNCHER_VERSION = "1.0.8" + # File paths ASSET_DIR = os.path.join("data", "assets") MECH_ASSET_DIR = os.path.join(ASSET_DIR, "exosuit") diff --git a/discord_rpc.py b/discord_rpc.py index 10d3981..04bbfa2 100644 --- a/discord_rpc.py +++ b/discord_rpc.py @@ -1,7 +1,10 @@ +import logging import time from pypresence import Presence from PyQt6.QtCore import QThread +log = logging.getLogger(__name__) + class DiscordRPCManager(QThread): def __init__(self, client_id): super().__init__() @@ -25,8 +28,8 @@ def run(self): break time.sleep(1) - except Exception as e: - print(f"[RPC] Discord connection failed: {e}") + except Exception: + log.exception("Discord RPC connection failed") def stop(self): self.is_running = False @@ -34,4 +37,4 @@ def stop(self): try: self.rpc.close() except Exception: - pass \ No newline at end of file + log.debug("rpc.close() raised on shutdown", exc_info=True) \ No newline at end of file diff --git a/functions.py b/functions.py index 7ddb42c..dfaab10 100644 --- a/functions.py +++ b/functions.py @@ -1,3 +1,4 @@ +import logging import os import re import shutil @@ -13,6 +14,8 @@ except ImportError: winreg = None +log = logging.getLogger(__name__) + CONFIG_FILE = os.path.join(os.getcwd(), 'data', 'launcher_config.json') def load_config(): @@ -20,8 +23,8 @@ def load_config(): if os.path.isfile(CONFIG_FILE): with open(CONFIG_FILE, 'r', encoding='utf-8') as f: return json.load(f) - except Exception as e: - print(f"Failed to load launcher config: {e}") + except Exception: + log.exception("Failed to load launcher config") return {} @@ -31,8 +34,8 @@ def save_config(cfg): with open(CONFIG_FILE, 'w', encoding='utf-8') as f: json.dump(cfg, f, indent=4) return True - except Exception as e: - print(f"Failed to save launcher config: {e}") + except Exception: + log.exception("Failed to save launcher config") return False @@ -89,20 +92,39 @@ def error_count_since_reset(): # Show a modal error dialog. Falls back to console output if Tk is unavailable. -# Every call increments the error counter so the launcher knows an error was -# surfaced to the user during the current operation. +# Increments the error counter ONLY after the dialog is successfully shown, +# so a logging or Tk failure can never bump the counter without the user +# seeing the dialog (InjectionThread in run.py relies on this invariant). def show_error_box(title, message): global _error_count - _error_count += 1 - print(f"[{title}] {message}") + + try: + log.error("[%s] %s", title, message) + except Exception: + pass + + try: + from logger import get_log_dir + log_dir = get_log_dir() + except Exception: + log_dir = None + + full_message = message + if log_dir: + full_message = f"{message}\n\nDiagnostic logs folder:\n{log_dir}" + try: root = tk.Tk() root.withdraw() root.attributes('-topmost', True) - messagebox.showerror(title, message, parent=root) + messagebox.showerror(title, full_message, parent=root) root.destroy() - except Exception as e: - print(f"(Could not display message box: {e})") + _error_count += 1 + except Exception: + try: + log.exception("Could not display message box") + except Exception: + pass # Standardized warning for the "files missing from BOTH locations" scenario. @@ -231,8 +253,8 @@ def get_steam_library_folders(): normalized = os.path.normpath(raw) if os.path.isdir(normalized) and normalized.lower() not in (p.lower() for p in libraries): libraries.append(normalized) - except Exception as e: - print(f"Failed to parse libraryfolders.vdf: {e}") + except Exception: + log.exception("Failed to parse libraryfolders.vdf at %r", vdf_path) return libraries @@ -243,10 +265,10 @@ def modify_mounts_json(left_path, right_path): json_file = os.path.join(os.getcwd(), 'data', 'mounts.json') if not os.path.exists(json_file): - print(f"Warning: Could not find {json_file}. Skipping JSON modification.") + log.warning("Could not find %r. Skipping JSON modification.", json_file) return False - print(f"Updating {json_file}...") + log.info("Updating %r...", json_file) try: # Read the current data with open(json_file, 'r') as file: @@ -263,17 +285,17 @@ def modify_mounts_json(left_path, right_path): with open(json_file, 'w') as file: json.dump(data, file, indent=4) - print(" -> mounts.json updated successfully.") + log.info(" -> mounts.json updated successfully.") return True else: - print(f" -> Error: '{target_mech}' not found in JSON, or array is missing items.") + log.error(" -> '%s' not found in JSON, or array is missing items.", target_mech) return False - except json.JSONDecodeError as e: - print(f" -> JSON Formatting Error (Check for trailing commas!): {e}") + except json.JSONDecodeError: + log.exception("JSON formatting error in %r (check for trailing commas)", json_file) return False - except Exception as e: - print(f" -> Error modifying mounts.json: {e}") + except Exception: + log.exception("Error modifying mounts.json at %r", json_file) return False # Auto-detection of the Helldivers 2 bin directory (no manual override). @@ -339,12 +361,11 @@ def get_helldivers_bin_dir(): # and persisted via set_saved_bin_dir so the next CONNECT click uses it. def prompt_for_new_game_folder(bin_dir, missing_files): global _error_count - _error_count += 1 files_list = "\n - ".join(missing_files) info_msg = ( f"helldivers2.exe was not found in the Helldivers 2 'bin' folder:\n" - f"{bin_dir}\n\n" + f"{bin_dir!r}\n\n" f"Missing file(s):\n - {files_list}\n\n" "The folder currently in use does not look like a valid Helldivers 2 " "install. Would you like to select the correct Helldivers 2 folder " @@ -352,7 +373,7 @@ def prompt_for_new_game_folder(bin_dir, missing_files): "The launcher will reset to its starting state either way; the game " "will not be launched on this attempt." ) - print(f"[Helldivers 2 Executable Missing] {info_msg}") + log.error("[Helldivers 2 Executable Missing] %s", info_msg) try: root = tk.Tk() @@ -365,6 +386,10 @@ def prompt_for_new_game_folder(bin_dir, missing_files): parent=root, icon='warning', ) + # Counter is incremented only after the dialog successfully appears, + # matching the invariant established by show_error_box. If Tk fails + # (headless / display unavailable) we fall through to the except. + _error_count += 1 if not wants_to_reselect: root.destroy() @@ -417,8 +442,8 @@ def prompt_for_new_game_folder(bin_dir, missing_files): root.destroy() return True - except Exception as e: - print(f"(Could not display folder picker: {e})") + except Exception: + log.exception("Could not display folder picker") return False @@ -485,7 +510,7 @@ def move_files_to_helldivers(): return [] moved_files = [] - print("Moving files to Helldivers 2...") + log.info("Moving files to Helldivers 2...") for filename in os.listdir(source_dir): source_path = os.path.join(source_dir, filename) @@ -499,7 +524,7 @@ def move_files_to_helldivers(): # Special conditional logic for dxgi.dll due to reshade if filename.lower() == "dxgi.dll": if not os.path.exists(target_path): - print(f" -> Skipped: {filename} (No existing dxgi.dll found in target directory)") + log.debug(" -> Skipped: %r (no existing dxgi.dll in target dir)", filename) continue try: @@ -511,8 +536,9 @@ def move_files_to_helldivers(): if filename.lower() != "dxgi.dll": moved_files.append(filename) - print(f" -> Injected: {filename}") + log.info(" -> Injected: %r", filename) except Exception as e: + log.exception("Failed to move %r to %r", filename, target_dir) show_error_box( "Failed to Move File to Helldivers 2", f"Could not move '{filename}' to:\n{target_dir}\n\n" @@ -559,12 +585,12 @@ def ensure_required_files_in_data(): if os.path.exists(source_path): try: shutil.move(source_path, target_path) - print(f" -> Recovered: {filename}") + log.info(" -> Recovered: %r", filename) except Exception as e: - print(f" -> Error recovering '{filename}': {e}") + log.exception("Error recovering %r", filename) failed_recoveries.append((filename, str(e))) else: - print(f" -> Missing in bin folder too: {filename}") + log.warning(" -> Missing in bin folder too: %r", filename) truly_missing.append(filename) if truly_missing: @@ -596,7 +622,7 @@ def move_files_back_to_data(files_to_retrieve): ) return - print("\nReturning files to local data folder...") + log.info("Returning files to local data folder...") moved_count = 0 missing_files = [] failed_moves = [] @@ -610,10 +636,10 @@ def move_files_back_to_data(files_to_retrieve): if os.path.exists(target_path): os.remove(target_path) shutil.move(source_path, target_path) - print(f" -> Retrieved: {filename}") + log.info(" -> Retrieved: %r", filename) moved_count += 1 except Exception as e: - print(f" -> Error returning '{filename}': {e}") + log.exception("Error returning %r", filename) failed_moves.append((filename, str(e))) elif not os.path.exists(target_path): # File is missing from BOTH the bin folder and the data folder. @@ -633,14 +659,14 @@ def move_files_back_to_data(files_to_retrieve): "and ensuring the launcher is on the same drive as HD2." ) - print(f"Successfully retrieved {moved_count}/{len(files_to_retrieve)} files.") + log.info("Successfully retrieved %d/%d files.", moved_count, len(files_to_retrieve)) # Moves game files, launches the game, and returns files. # If any step shows an error message box, execution stops immediately and any # files that were already moved into the game's bin folder are restored. def launch_and_restore(): - print("Starting CGW") + log.info("Starting CGW") errors_before_inject = error_count_since_reset() @@ -653,7 +679,7 @@ def launch_and_restore(): # An error box was shown. Roll back anything that did get moved and # do not proceed to launch the game. if moved_files: - print("Errors during injection. Restoring files to 'data'...") + log.warning("Errors during injection. Restoring files to 'data'...") move_files_back_to_data(moved_files) return @@ -663,7 +689,7 @@ def launch_and_restore(): for f in os.listdir(data_dir) ) if os.path.isdir(data_dir) else False if not moved_files and not has_dxgi_override: - print("No files were moved. Aborting sequence.") + log.warning("No files were moved. Aborting sequence.") return game_dir = get_helldivers_bin_dir() @@ -678,7 +704,7 @@ def launch_and_restore(): move_files_back_to_data(moved_files) return - print("\nLaunching helldivers2.exe...") + log.info("Launching helldivers2.exe...") steam_path = find_steam_exe() if not steam_path: @@ -725,6 +751,7 @@ def launch_and_restore(): try: subprocess.Popen([steam_path, "-applaunch", game_app_id], cwd=game_dir) except Exception as e: + log.exception("Failed to start Helldivers 2 via Steam") show_error_box( "Failed to Launch Helldivers 2", f"Could not start the game via Steam.\n\nError: {e}\n\n" @@ -733,8 +760,8 @@ def launch_and_restore(): move_files_back_to_data(moved_files) return - print("Waiting 45 seconds for game initialization...") + log.info("Waiting 45 seconds for game initialization...") time.sleep(45) move_files_back_to_data(moved_files) - print("Successfully launched game and moved files back.") \ No newline at end of file + log.info("Successfully launched game and moved files back.") \ No newline at end of file diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..72bb258 --- /dev/null +++ b/logger.py @@ -0,0 +1,274 @@ +import atexit +import faulthandler +import getpass +import logging +import logging.handlers +import os +import platform +import sys +import tempfile +import threading +import traceback +from datetime import datetime +from typing import Optional + +import constants + +_LOG_FILE_NAME = "launcher.log" +_FAULT_FILE_NAME = "faulthandler.log" +_LOG_DIR_REL = os.path.join("data", "logs") +_LOG_MAX_BYTES = 1 * 1024 * 1024 # 1 MB +_LOG_BACKUP_COUNT = 5 + +_setup_lock = threading.RLock() +_log_dir_resolved: Optional[str] = None +_log_file_resolved: Optional[str] = None +_faulthandler_file = None # kept open for the life of the process + + +def _redact_username(s: str) -> str: + # Replace the current Windows username with in arbitrary strings. + try: + user = getpass.getuser() + except Exception: + return s + if not user: + return s + return s.replace(user, "") + + +def _try_probe_write(directory: str) -> bool: + """Return True iff `directory` is usable for our log file. + + Two checks, because on Windows the directory may be writable (NTFS ACL) + while a pre-existing `launcher.log` file is read-only (file attribute). + RotatingFileHandler would then crash trying to open the existing file + for append. + + 1. Create+delete a temp file in `directory` (tests directory ACL). + 2. If `launcher.log` already exists, open it for append (tests file ACL).""" + try: + os.makedirs(directory, exist_ok=True) + with tempfile.NamedTemporaryFile( + dir=directory, prefix=".probe-", delete=True + ): + pass + except OSError: + return False + + existing = os.path.join(directory, _LOG_FILE_NAME) + if os.path.exists(existing): + try: + with open(existing, "a", encoding="utf-8"): + pass + except OSError: + return False + return True + + +def _resolve_log_dir() -> str: + """Pick a writable log directory. + 1. /data/logs/ (next to the exe; same place launcher_config.json lives) + 2. %LOCALAPPDATA%\\E-710 Launcher\\logs (if 1 isn't writable) + 3. /E-710 Launcher logs (last resort — always writable) + + Best-effort: returns a path on every realistic system. Can raise + OSError only if even %TEMP% is unwritable (truly broken host) — that + propagates to setup_logging and is caught by the run.py:__main__ + breadcrumb wrapper.""" + primary = os.path.join(os.getcwd(), _LOG_DIR_REL) + if _try_probe_write(primary): + return primary + + fallback_root = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") + fallback = os.path.join(fallback_root, "E-710 Launcher", "logs") + if _try_probe_write(fallback): + return fallback + + last_resort = os.path.join(tempfile.gettempdir(), "E-710 Launcher logs") + os.makedirs(last_resort, exist_ok=True) + return last_resort + + +def get_log_file_path() -> Optional[str]: + """Return the active log file path, or None if setup hasn't run / failed. + Callers (e.g. dialog footer, UI button) must handle the None case.""" + return _log_file_resolved + + +def get_log_dir() -> Optional[str]: + return _log_dir_resolved + + +def is_initialized() -> bool: + return _log_file_resolved is not None + + +def _format_preamble() -> str: + return ( + "=" * 72 + "\n" + f"E-710 Launcher session start: {datetime.now().isoformat(timespec='seconds')}\n" + f" Version : {getattr(constants, 'LAUNCHER_VERSION', 'unknown')}\n" + f" Python : {sys.version.splitlines()[0]}\n" + f" Platform : {platform.platform()}\n" + f" Frozen (exe) : {getattr(sys, 'frozen', False)}\n" + f" CWD : {_redact_username(os.getcwd())}\n" + f" argv : {_redact_username(repr(sys.argv))}\n" + + "=" * 72 + ) + + +class _UsernameRedactionFilter(logging.Filter): + """Apply _redact_username to every formatted log record. Catches the + case where an OSError/PermissionError exception message embeds a + home-directory path (e.g. C:\\Users\\\\...) that the + plain preamble redactor cannot reach. Operates on the post-format + string so it covers exception tracebacks too.""" + + def filter(self, record: logging.LogRecord) -> bool: + try: + user = getpass.getuser() + except Exception: + return True + if not user: + return True + if isinstance(record.msg, str) and user in record.msg: + record.msg = record.msg.replace(user, "") + if record.args: + record.args = tuple( + a.replace(user, "") if isinstance(a, str) and user in a else a + for a in (record.args if isinstance(record.args, tuple) else (record.args,)) + ) + if record.exc_info: + # Materialize the traceback now so we can redact it; otherwise + # the formatter would expand it after this filter has run. + if record.exc_text is None: + record.exc_text = logging.Formatter().formatException(record.exc_info) + record.exc_text = record.exc_text.replace(user, "") + record.exc_info = None # already materialized into exc_text + return True + + +def _safe_isatty(stream) -> bool: + """isatty() can raise on wrapped/dead streams under PyInstaller --windowed.""" + if stream is None: + return False + try: + return stream.isatty() + except (OSError, ValueError): + return False + + +def _close_faulthandler_file(): + """Registered with atexit. Closes the persistent faulthandler file so the + last buffer is flushed and we don't leak a file descriptor across + re-entrant setup paths.""" + global _faulthandler_file + if _faulthandler_file is not None: + try: + faulthandler.disable() + except Exception: + pass + try: + _faulthandler_file.close() + except OSError: + pass + _faulthandler_file = None + + +def setup_logging() -> Optional[str]: + """Configure root logger, install hooks, open faulthandler. + Idempotent and thread-safe — safe to call more than once. + Returns the resolved log file path, or None on catastrophic failure.""" + global _log_dir_resolved, _log_file_resolved, _faulthandler_file + + with _setup_lock: + if _log_file_resolved is not None: + return _log_file_resolved + + _log_dir_resolved = _resolve_log_dir() + _log_file_resolved = os.path.join(_log_dir_resolved, _LOG_FILE_NAME) + + root = logging.getLogger() + root.setLevel(logging.DEBUG) + # Wipe any handlers a library may have attached (pypresence is known to). + root.handlers.clear() + + fmt = logging.Formatter( + fmt="%(asctime)s %(levelname)-7s [%(threadName)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + file_handler = logging.handlers.RotatingFileHandler( + _log_file_resolved, + maxBytes=_LOG_MAX_BYTES, + backupCount=_LOG_BACKUP_COUNT, + encoding="utf-8", + delay=False, + ) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(fmt) + file_handler.addFilter(_UsernameRedactionFilter()) + root.addHandler(file_handler) + + if _safe_isatty(sys.stdout): + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setLevel(logging.INFO) + stream_handler.setFormatter(fmt) + root.addHandler(stream_handler) + + logging.getLogger("e710").info("\n%s", _format_preamble()) + + # Python-level uncaught exceptions (main thread). + def _excepthook(exc_type, exc, tb): + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc, tb) + return + logging.getLogger("uncaught").critical( + "Uncaught exception:\n%s", + "".join(traceback.format_exception(exc_type, exc, tb)), + ) + + sys.excepthook = _excepthook + + # Worker-thread uncaught exceptions. QThread.run runs in a Python + # thread, so this fires for InjectionThread / DiscordRPCManager + # bodies that don't already catch. Requires Python 3.8+. + def _thread_excepthook(args): + if issubclass(args.exc_type, SystemExit): + return + thread_name = args.thread.name if args.thread else "" + logging.getLogger("thread").critical( + "Uncaught exception in thread %r:\n%s", + thread_name, + "".join( + traceback.format_exception( + args.exc_type, args.exc_value, args.exc_traceback + ) + ), + ) + + threading.excepthook = _thread_excepthook + + # Native crashes (segfault in PyQt6, discord_game_sdk.dll, pypresence). + # Separate file so faulthandler can write even when the logging + # subsystem is wedged. atexit closes the FD on clean shutdown so + # a future re-entrant setup_logging would not leak it. + if _faulthandler_file is None: + fault_path = os.path.join(_log_dir_resolved, _FAULT_FILE_NAME) + try: + _faulthandler_file = open( + fault_path, "a", encoding="utf-8", buffering=1 + ) + _faulthandler_file.write( + f"\n--- faulthandler armed at " + f"{datetime.now().isoformat(timespec='seconds')} ---\n" + ) + faulthandler.enable(file=_faulthandler_file, all_threads=True) + atexit.register(_close_faulthandler_file) + except OSError: + logging.getLogger("e710").warning( + "Could not open faulthandler file at %s", fault_path + ) + + return _log_file_resolved diff --git a/mech_editor.py b/mech_editor.py index 8b32a79..ec9b570 100644 --- a/mech_editor.py +++ b/mech_editor.py @@ -1,3 +1,4 @@ +import logging import os import json from PyQt6.QtWidgets import ( @@ -11,6 +12,8 @@ import functions from button_cta import InteractiveCTA +log = logging.getLogger(__name__) + # Added some comments to make this a bit less of a fucking mess @@ -81,6 +84,7 @@ def read_json_safely(filepath, default_return): with open(filepath, 'r') as f: return json.load(f) except Exception: + log.exception("Failed to read JSON: %r", filepath) return default_return @@ -362,8 +366,8 @@ def cycle_chassis(self, direction): cfg = functions.load_config() cfg["active_chassis"] = self.active_chassis functions.save_config(cfg) - except Exception as e: - print(f"Failed to persist active chassis: {e}") + except Exception: + log.exception("Failed to persist active chassis") self.refresh_schematic() @@ -371,6 +375,6 @@ def commit_loadout(self): try: self._capture_current() save_all_loadouts(self.loadouts) - print("Exosuit Edit Successful") - except Exception as e: - print(f"Exosuit Edit Failed: {e}") + log.info("Exosuit edit successful") + except Exception: + log.exception("Exosuit Edit Failed") diff --git a/run.py b/run.py index c52f59f..b60979c 100644 --- a/run.py +++ b/run.py @@ -1,3 +1,4 @@ +import logging import sys import os import time @@ -14,6 +15,8 @@ from custom_widgets import LinkButtonWidget, LauncherProgressBar, IconButton, InstructionsOverlay from discord_rpc import DiscordRPCManager +log = logging.getLogger(__name__) + class InjectionThread(QThread): progress_update = pyqtSignal(float, str) sequence_complete = pyqtSignal(bool) # True == clean run, no error popups @@ -134,9 +137,47 @@ def setup_ui(self): self.info_button.raise_() self.info_button.clicked.connect(self.show_instructions) + # "Open Log File" button — Notepad, not Explorer (the launcher runs + # --uac-admin; spawning Explorer from an elevated process is a + # documented UAC escalation surface). + log_icon_path = os.path.join(constants.ASSET_DIR, "log.png") + self.log_button = IconButton( + icon_path=log_icon_path, + fallback_letter="L", + tooltip="Open diagnostic log", + size=35, + parent=self.central_widget, + ) + self.log_button.move(10, 95) + self.log_button.raise_() + self.log_button.clicked.connect(self._open_log_file) + # Auto-show instructions the first time the launcher is opened. QTimer.singleShot(0, self.maybe_auto_show_instructions) + def _open_log_file(self): + import subprocess + try: + from logger import get_log_file_path, is_initialized + log_path = get_log_file_path() if is_initialized() else None + except Exception: + log_path = None + + if not log_path or not os.path.isfile(log_path): + QMessageBox.information( + self, + "Log Not Available", + "The diagnostic log has not been created yet, or the launcher " + "failed to initialize logging. Check %TEMP%\\e710-launcher-boot.log " + "for clues if this is unexpected." + ) + return + + try: + subprocess.Popen(["notepad.exe", log_path]) + except Exception: + log.exception("Failed to open log in Notepad") + def paintEvent(self, event): super().paintEvent(event) if os.path.exists(constants.BG_PATH): @@ -160,6 +201,7 @@ def load_instructions_text(self): with open(path, "r", encoding="utf-8", errors="replace") as f: return f.read() except Exception as e: + log.exception("Failed to open instructions file: %r", path) return ( f"# Could not read instructions\n\n" f"Failed to open {path}:\n\n{e}" @@ -304,10 +346,29 @@ def closeEvent(self, event): self.rpc_manager.stop() self.rpc_manager.wait() functions.ensure_required_files_in_data() - self.rpc_manager.stop() + logging.getLogger("e710").info("Session end (close).") super().closeEvent(event) if __name__ == "__main__": + try: + from logger import setup_logging + setup_logging() + except Exception: + # Logging is best-effort, BUT a silent failure here is exactly the + # bug class we're trying to fix. Drop a one-line breadcrumb in TEMP + # so future-us can find out logging never started. + try: + import tempfile + import traceback as _tb + breadcrumb = os.path.join( + tempfile.gettempdir(), "e710-launcher-boot.log" + ) + with open(breadcrumb, "a", encoding="utf-8") as f: + f.write(f"\n--- setup_logging failed at {time.time()} ---\n") + _tb.print_exc(file=f) + except Exception: + pass # truly nothing we can do + app = QApplication(sys.argv) app_icon = QtGui.QIcon() app_icon.addFile('data/assets/icon.png', QSize(256, 256))