Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 217 additions & 27 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
import errno
import shutil
import time
import tempfile
import vdf
import platform
import argparse
import json
from functools import lru_cache
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLineEdit,
QPushButton, QLabel, QFileDialog, QTextEdit, QTabWidget, QSizePolicy,
Expand All @@ -24,6 +26,15 @@
from utils import write_log, apply_launch_options, find_steam_user_id, steam_userdata_path, app_id, launch_game_via_steam


NUITKA_ENVIRONMENT_KEYS = (
"NUITKA_ONEFILE_PARENT",
"NUITKA_EXE_PATH",
"NUITKA_PACKAGE_HOME",
)

_NUITKA_DETECTION_KEYS = NUITKA_ENVIRONMENT_KEYS + ("NUITKA_ONEFILE_TEMP",)


def _normalize_dir(path):
"""Return a valid directory path derived from the given value."""
if not path:
Expand All @@ -45,27 +56,135 @@ def _normalize_dir(path):
return None


def _frozen_base_directory():
"""Resolve the persistent location of a frozen executable."""
if not getattr(sys, "frozen", False):
return None
def _is_frozen_environment():
"""Determine if the application is running from a frozen executable."""
if getattr(sys, "frozen", False):
return True

env_keys = (
"NUITKA_ONEFILE_PARENT",
"NUITKA_EXE_PATH",
"NUITKA_PACKAGE_HOME",
)
if getattr(sys, "_MEIPASS", None):
return True

for key in _NUITKA_DETECTION_KEYS:
if os.environ.get(key):
return True

return False


def _get_true_executable_path():
"""Get the absolute path to the running executable using OS-specific methods.

This function uses platform-specific APIs to reliably determine the actual
location of the executable, bypassing limitations of sys.executable and
sys.argv which may point to temporary extraction directories in frozen builds.

Returns:
str: Absolute directory path of the executable, or None if detection fails
"""
system = platform.system()

if system == "Windows":
try:
import ctypes
# Use kernel32.GetModuleFileNameW for reliable path on Windows
buffer = ctypes.create_unicode_buffer(32768)
get_module_filename = ctypes.windll.kernel32.GetModuleFileNameW
get_module_filename(None, buffer, len(buffer))
exe_path = buffer.value
if exe_path and os.path.exists(exe_path):
write_log(f"Resolved Windows executable path: {exe_path}", "Info", None)
return os.path.dirname(exe_path)
except Exception as e:
write_log(f"Windows path resolution failed: {e}", "Warning", None)

elif system == "Linux":
try:
# Read /proc/self/exe symlink for true executable path
exe_path = os.path.realpath('/proc/self/exe')
if exe_path and os.path.exists(exe_path):
write_log(f"Resolved Linux executable path: {exe_path}", "Info", None)
return os.path.dirname(exe_path)
except Exception as e:
write_log(f"Linux path resolution failed: {e}", "Warning", None)

elif system == "Darwin":
# macOS support (optional - only if needed in future)
try:
import ctypes
from ctypes.util import find_library

libc = ctypes.CDLL(find_library('c'))
buffer = ctypes.create_string_buffer(1024)
size = ctypes.c_uint32(len(buffer))
if libc._NSGetExecutablePath(buffer, ctypes.byref(size)) == 0:
exe_path = os.path.realpath(buffer.value.decode())
if exe_path and os.path.exists(exe_path):
write_log(f"Resolved macOS executable path: {exe_path}", "Info", None)
return os.path.dirname(exe_path)
except Exception as e:
write_log(f"macOS path resolution failed: {e}", "Warning", None)

for key in env_keys:
base_dir = _normalize_dir(os.environ.get(key))
if base_dir:
return base_dir
write_log(f"Platform-specific path resolution not available for {system}", "Warning", None)
return None

argv_dir = _normalize_dir(sys.argv[0])
if argv_dir:
return argv_dir

return _normalize_dir(sys.executable)
@lru_cache(maxsize=1)
def _frozen_base_directory():
"""Resolve the persistent location of a frozen executable.

Priority order:
1. Platform-specific OS API (most reliable)
2. Nuitka environment variables
3. sys.argv[0] (if not in temp directory)
4. sys.executable (if not in temp directory)

Returns:
str: Absolute directory path, or None if unable to resolve
"""
if not _is_frozen_environment():
return None

temp_dir = os.path.normcase(os.path.abspath(tempfile.gettempdir()))

# Priority 1: Use platform-specific API
true_path = _get_true_executable_path()
if true_path:
normalized_true = os.path.normcase(true_path)
if not normalized_true.startswith(temp_dir):
write_log(f"Using platform-specific path: {true_path}", "Info", None)
return true_path

# Priority 2: Check Nuitka environment variables
for key in NUITKA_ENVIRONMENT_KEYS:
env_path = os.environ.get(key)
if env_path:
normalized = _normalize_dir(env_path)
if normalized:
normalized_case = os.path.normcase(normalized)
if not normalized_case.startswith(temp_dir):
write_log(f"Using Nuitka environment variable {key}: {normalized}", "Info", None)
return normalized

# Priority 3: Try sys.argv[0]
if sys.argv and sys.argv[0]:
argv_path = os.path.abspath(sys.argv[0])
if os.path.isfile(argv_path):
argv_case = os.path.normcase(argv_path)
if not argv_case.startswith(temp_dir):
argv_dir = os.path.dirname(argv_path)
write_log(f"Using sys.argv[0]: {argv_dir}", "Info", None)
return argv_dir

# Priority 4: Last resort - sys.executable (but avoid temp directories)
exe_path = os.path.abspath(sys.executable)
exe_case = os.path.normcase(exe_path)
if not exe_case.startswith(temp_dir):
exe_dir = os.path.dirname(exe_path)
write_log(f"Using sys.executable: {exe_dir}", "Warning", None)
return exe_dir

write_log("Could not resolve frozen executable path outside temp directory", "Error", None)
return None


def resource_path(relative_path):
Expand All @@ -76,11 +195,12 @@ def resource_path(relative_path):
if meipass:
candidates.append(os.path.join(meipass, relative_path))

if getattr(sys, "frozen", False):
if _is_frozen_environment():
frozen_base = _frozen_base_directory()
if frozen_base:
candidates.append(os.path.join(frozen_base, relative_path))
candidates.append(os.path.join(os.path.dirname(os.path.abspath(sys.executable)), relative_path))
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
candidates.append(os.path.join(exe_dir, relative_path))
nuitka_temp = os.environ.get("NUITKA_ONEFILE_TEMP")
if nuitka_temp:
candidates.append(os.path.join(nuitka_temp, relative_path))
Expand All @@ -98,20 +218,44 @@ def resource_path(relative_path):

return os.path.join(os.path.abspath("."), relative_path)

@lru_cache(maxsize=1)
def get_application_path():
"""Get the real application path for both script and frozen executables."""
if getattr(sys, "frozen", False):
base_dir = _frozen_base_directory()
if base_dir:
return base_dir
return os.path.dirname(os.path.abspath(sys.executable))
return os.path.dirname(os.path.abspath(__file__))
"""Get the real application path for both script and frozen executables.

This function determines where the application is actually installed,
ensuring mod files and settings are stored in the correct location.

Returns:
str: Absolute path to the application directory

Raises:
SystemExit: If unable to determine a valid application path
"""
base_dir = _frozen_base_directory()
if base_dir and os.path.exists(base_dir):
write_log(f"Application path (frozen): {base_dir}", "Info", None)
return base_dir

# Development mode or fallback
base_dir = os.path.dirname(os.path.abspath(__file__))

if _is_frozen_environment():
error_msg = (
"Could not determine application installation directory. "
"Please ensure the executable is in a writable location (not Program Files or temp directories). "
f"Attempted path: {base_dir or 'unknown'}"
)
_show_install_location_error(error_msg)

write_log(f"Application path (development): {base_dir}", "Info", None)
return base_dir

GAME_EXECUTABLE_NAMES = ("BlackOpsIII.exe", "BlackOps3.exe")


def _settings_file_path():
return os.path.join(get_application_path(), "PatchOpsIII_settings.json")
base_path = APPLICATION_PATH if 'APPLICATION_PATH' in globals() and APPLICATION_PATH else get_application_path()
return os.path.join(base_path, "PatchOpsIII_settings.json")


def _has_game_executable(directory):
Expand Down Expand Up @@ -162,6 +306,48 @@ def save_game_directory(directory):
return False


def _migrate_settings_if_needed(current_path):
"""Migrate settings from old location if they exist.

This handles cases where the application previously detected the wrong path
(e.g., in AppData temp directories) and needs to migrate settings to the
correct location.

Args:
current_path: The newly detected correct application path
"""
current_settings = os.path.join(current_path, "PatchOpsIII_settings.json")

# If settings already exist in current location, no migration needed
if os.path.exists(current_settings):
return

# Check potential old locations
temp_dir = tempfile.gettempdir()

# Search for settings files in temp directory tree
old_settings_found = []
try:
for root, dirs, files in os.walk(temp_dir):
if "PatchOpsIII_settings.json" in files:
old_path = os.path.join(root, "PatchOpsIII_settings.json")
# Only consider files modified in last 30 days
if os.path.getmtime(old_path) > time.time() - (30 * 24 * 60 * 60):
old_settings_found.append(old_path)
except (OSError, PermissionError):
pass

if old_settings_found:
# Use most recently modified settings file
old_settings = max(old_settings_found, key=os.path.getmtime)
try:
import shutil
shutil.copy2(old_settings, current_settings)
write_log(f"Migrated settings from {old_settings} to {current_settings}", "Success", None)
except Exception as e:
write_log(f"Failed to migrate settings: {e}", "Warning", None)


def find_game_executable(directory):
for executable in GAME_EXECUTABLE_NAMES:
candidate = os.path.join(directory, executable)
Expand Down Expand Up @@ -242,6 +428,10 @@ def _ensure_install_location_writable(app_dir, mod_files_dir):
DEFAULT_GAME_DIR = get_game_directory()

MOD_FILES_DIR = os.path.join(APPLICATION_PATH, "BO3 Mod Files")

# Migrate settings from old location if needed
_migrate_settings_if_needed(APPLICATION_PATH)

_ensure_install_location_writable(APPLICATION_PATH, MOD_FILES_DIR)

class ApplyLaunchOptionsWorker(QThread):
Expand Down