#!/usr/bin/env python3 """ Neon Mod Manager — Cyberpunk 2077 Mod Manager ============================================== Simple, no-nonsense mod manager that: - Copies mods directly to archive/pc/mod/ (no VFS, no FUSE, no REDmod) - Manages load order via filename prefixes (000_, 010_, 020_) - Enable/disable mods by renaming .archive <-> .archive.disabled - Parses .archive files to detect real file conflicts - Supports REDmod script mods (r6/tweaks/red4ext) - Auto-extracts .7z / .zip downloads and installs contents - Catches nxm:// downloads from Nexus Mods - Supports profiles (save/load mod sets) Usage: python3 mod_manager.py → GUI opens """ import os import sys import json import shutil import struct import hashlib import subprocess import urllib.request import urllib.parse import zipfile import tarfile import io from pathlib import Path from datetime import datetime from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QListWidget, QListWidgetItem, QLabel, QPushButton, QFileDialog, QTabWidget, QTreeWidget, QTreeWidgetItem, QComboBox, QLineEdit, QMessageBox, QProgressBar, QGroupBox, QCheckBox, QSpinBox, QSlider, QMenu, QStatusBar, QSplitter, QHeaderView, QTextEdit ) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer from PyQt6.QtGui import QIcon, QAction, QColor, QFont, QDragEnterEvent, QDropEvent # --- Config --- APP_NAME = "Neon Mod Manager" APP_VERSION = "0.2.0" CONFIG_DIR = Path.home() / ".config" / "neon-mod-manager" CONFIG_FILE = CONFIG_DIR / "config.json" PROFILES_DIR = CONFIG_DIR / "profiles" # Default game path — can be overridden in settings DEFAULT_GAME_PATH = "/mnt/Spiele/Heroic/Cyberpunk 2077" DEFAULT_MOD_DIR = "archive/pc/mod" # Nexus Mods NEXUS_API_KEY_FILE = Path.home() / ".nexus_api_key" NEXUS_GAME_ID = "cyberpunk2077" NEXUS_API_BASE = "https://api.nexusmods.com/v1" # =========================================================================== # ARCHIVE PARSING — Read internal file list from .archive files # =========================================================================== def read_archive_info(path): """Read info from a .archive file including internal file paths. REDengine .archive format (RDAR): Offset 0: magic "RDAR" (4 bytes) Offset 4: version (4 bytes, uint32 LE) Offset 8: file table offset (8 bytes, uint64 LE) Offset 16: file count (4 bytes, uint32 LE) The file table contains entries with: - hash (8 bytes) - offset (8 bytes) - size (8 bytes) - path string (variable, null-terminated) The path strings are at the end of the file table, encoded as UTF-8. """ try: with open(path, "rb") as f: magic = f.read(4) if magic != b"RDAR": return {"valid": False, "error": "Not a RDAR archive", "files": [], "size": os.path.getsize(path)} version = struct.unpack(" 0 and file_table_offset < file_size and file_count > 0 and file_count < 100000: try: f.seek(file_table_offset) # Each table entry: hash(8) + offset(8) + size(8) = 24 bytes + path # The path strings follow the fixed-size entries # Try to read entries entry_size = 24 # hash + offset + size entries_data = f.read(entry_size * file_count) if len(entries_data) == entry_size * file_count: # Try to read path strings after the entries # Paths are null-terminated UTF-8 strings paths_data = f.read(min(1024 * 1024, file_size - f.tell())) # Read up to 1MB of paths # Parse null-terminated strings all_paths = paths_data.split(b'\x00') for p in all_paths: try: decoded = p.decode('utf-8', errors='ignore').strip() if decoded and len(decoded) > 3 and ('/' in decoded or '\\' in decoded): files.append(decoded) except: continue # Limit to file_count files = files[:file_count] except Exception: pass return { "valid": True, "version": version, "file_count": file_count, "table_offset": file_table_offset, "files": files, "size": file_size, } except Exception as e: return {"valid": False, "error": str(e), "files": [], "size": 0} def get_file_hash(path, chunk_size=65536): """Get SHA256 hash of a file for duplicate detection.""" h = hashlib.sha256() with open(path, "rb") as f: while True: chunk = f.read(chunk_size) if not chunk: break h.update(chunk) return h.hexdigest() # =========================================================================== # ARCHIVE CONFLICT DETECTION — Compare internal file paths # =========================================================================== def scan_archive_conflicts(mods, progress_callback=None): """Scan all enabled .archive mods and find files that appear in multiple mods. Returns a list of conflicts: [{file, mods: [mod1, mod2, ...]}, ...] """ file_map = {} # file_path -> [mod_name, ...] for i, mod in enumerate(mods): if not mod.enabled: continue if progress_callback: progress_callback(i, len(mods), f"Scanning {mod.name}...") info = mod.info if mod.info else read_archive_info(mod.path) mod.info = info for internal_file in info.get("files", []): # Normalize path norm = internal_file.lower().replace("\\", "/") if norm not in file_map: file_map[norm] = [] file_map[norm].append(mod.name) # Find conflicts (files in more than one mod) conflicts = [] for file_path, mod_names in file_map.items(): if len(mod_names) > 1: conflicts.append({ "file": file_path, "mods": list(set(mod_names)), "count": len(set(mod_names)), }) return conflicts # =========================================================================== # ARCHIVE EXTRACTION — Auto-extract .7z / .zip / .rar downloads # =========================================================================== # Mod installation structure: # .archive files → archive/pc/mod/ # .reds files → r6/scripts/ # .yaml files (TweakXL) → r6/tweaks/ # .dll files (red4ext plugins) → red4ext/plugins// # .ini files (engine config) → engine/config/ # .xl files (ArchiveXL) → same dir as .archive ARCHIVE_EXTS = {".archive"} REDS_DIRS = {"r6", "scripts", "red4ext", "engine", "bin"} TWEAK_EXTS = {".yaml", ".yml"} DLL_EXTS = {".dll"} def extract_archive(filepath, extract_to): """Extract a .7z/.zip/.rar file to a temporary directory. Returns the path to the extracted directory, or None on failure. """ filepath = Path(filepath) extract_dir = extract_to / filepath.stem extract_dir.mkdir(parents=True, exist_ok=True) ext = filepath.suffix.lower() if ext == ".zip": try: with zipfile.ZipFile(filepath, 'r') as zf: zf.extractall(extract_dir) return extract_dir except Exception as e: print(f"ZIP extraction failed: {e}") return None elif ext == ".7z": # Try 7z command try: result = subprocess.run( ["7z", "x", str(filepath), f"-o{extract_dir}", "-y"], capture_output=True, text=True, timeout=120 ) if result.returncode == 0: return extract_dir print(f"7z failed: {result.stderr}") except FileNotFoundError: pass # Try 7za (p7zip-full) try: result = subprocess.run( ["7za", "x", str(filepath), f"-o{extract_dir}", "-y"], capture_output=True, text=True, timeout=120 ) if result.returncode == 0: return extract_dir print(f"7za failed: {result.stderr}") except FileNotFoundError: pass print("7z not found. Install p7zip: sudo pacman -S p7zip") return None elif ext in (".rar", ".r00"): try: result = subprocess.run( ["unrar", "x", str(filepath), str(extract_dir) + "/", "-y"], capture_output=True, text=True, timeout=120 ) if result.returncode == 0: return extract_dir print(f"unrar failed: {result.stderr}") except FileNotFoundError: pass print("unrar not found. Install unrar: sudo pacman -S unrar") return None elif ext in (".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz"): try: with tarfile.open(filepath, 'r:*') as tf: tf.extractall(extract_dir) return extract_dir except Exception as e: print(f"tar extraction failed: {e}") return None return None def install_extracted_mod(extract_dir, game_path): """Install files from an extracted mod directory to the correct game paths. Returns a list of installed files. """ game_path = Path(game_path) mod_dir = game_path / "archive/pc/mod" installed = [] # Walk the extracted directory for root, dirs, files in os.walk(extract_dir): root_path = Path(root) for fname in files: src = root_path / fname ext = Path(fname).suffix.lower() # .archive files → archive/pc/mod/ if ext == ".archive": dst = mod_dir / fname mod_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) installed.append(f"archive/pc/mod/{fname}") # .xl files (ArchiveXL) → same dir as .archive elif ext == ".xl": dst = mod_dir / fname mod_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) installed.append(f"archive/pc/mod/{fname}") # .yaml/.yml (TweakXL) → r6/tweaks/ elif ext in (".yaml", ".yml"): # Check if it's in a tweaks folder already rel = root_path.relative_to(extract_dir) parts = rel.parts if "tweaks" in parts or "r6" in parts: # Preserve relative path under r6/ if "r6" in parts: idx = parts.index("r6") rel_under_r6 = Path(*parts[idx+1:]) dst = game_path / "r6" / rel_under_r6 / fname else: dst = game_path / "r6" / "tweaks" / fname else: dst = game_path / "r6" / "tweaks" / fname dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) installed.append(str(dst.relative_to(game_path))) # .reds (redscript) → r6/scripts/ elif ext == ".reds": rel = root_path.relative_to(extract_dir) parts = rel.parts if "r6" in parts: idx = parts.index("r6") rel_under_r6 = Path(*parts[idx+1:]) dst = game_path / "r6" / rel_under_r6 / fname elif "scripts" in parts: dst = game_path / "r6" / "scripts" / fname else: dst = game_path / "r6" / "scripts" / fname dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) installed.append(str(dst.relative_to(game_path))) # .dll (red4ext plugins) → red4ext/plugins// elif ext == ".dll": rel = root_path.relative_to(extract_dir) parts = rel.parts if "red4ext" in parts and "plugins" in parts: # Preserve structure dst = game_path / rel / fname elif "plugins" in parts: idx = parts.index("plugins") plugin_name = parts[idx + 1] if idx + 1 < len(parts) else extract_dir.name dst = game_path / "red4ext" / "plugins" / plugin_name / fname else: plugin_name = extract_dir.name dst = game_path / "red4ext" / "plugins" / plugin_name / fname dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) installed.append(str(dst.relative_to(game_path))) # .ini (engine config) → engine/config/ elif ext == ".ini": rel = root_path.relative_to(extract_dir) parts = rel.parts if "engine" in parts: dst = game_path / rel / fname else: dst = game_path / "engine" / "config" / fname dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) installed.append(str(dst.relative_to(game_path))) # CET mods (cyber_engine_tweaks) → bin/x64/plugins/cyber_engine_tweaks/mods/ elif fname.endswith(".lua") or "cyber_engine_tweaks" in str(root_path).lower(): rel = root_path.relative_to(extract_dir) parts = rel.parts if "cyber_engine_tweaks" in str(rel).lower() and "mods" in parts: idx = parts.index("mods") if idx + 1 < len(parts): mod_name = parts[idx + 1] dst = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" / mod_name / fname else: dst = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" / fname elif ext == ".lua": dst = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" / extract_dir.name / fname else: continue # Skip non-lua files in CET context dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) installed.append(str(dst.relative_to(game_path))) return installed def install_mod_file(filepath, game_path, status_callback=None): """Install a mod file — auto-detect type and handle accordingly. Supports: .archive, .zip, .7z, .rar, .tar.gz """ filepath = Path(filepath) game_path = Path(game_path) ext = filepath.suffix.lower() if ext == ".archive" or ext == ".xl": # Direct copy to mod directory mod_dir = game_path / "archive/pc/mod" mod_dir.mkdir(parents=True, exist_ok=True) dst = mod_dir / filepath.name if status_callback: status_callback(f"Installing {filepath.name}...") shutil.copy2(filepath, dst) return [f"archive/pc/mod/{filepath.name}"] elif ext in (".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", ".bz2", ".tbz", ".r00"): # Extract and install if status_callback: status_callback(f"Extracting {filepath.name}...") extract_dir = extract_archive(filepath, Path("/tmp/neon-mod-extract")) if not extract_dir: return None if status_callback: status_callback(f"Installing files from {filepath.name}...") installed = install_extracted_mod(extract_dir, game_path) # Cleanup shutil.rmtree(extract_dir, ignore_errors=True) return installed elif ext in (".yaml", ".yml"): # TweakXL — copy to r6/tweaks/ dst = game_path / "r6/tweaks" / filepath.name dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(filepath, dst) return [f"r6/tweaks/{filepath.name}"] elif ext == ".reds": # redscript — copy to r6/scripts/ dst = game_path / "r6/scripts" / filepath.name dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(filepath, dst) return [f"r6/scripts/{filepath.name}"] else: return None # =========================================================================== # REDMOD SCRIPT SUPPORT — Manage r6/tweaks, r6/scripts, red4ext # =========================================================================== def scan_script_mods(game_path): """Scan for script-based mods (redscript, TweakXL, CET, red4ext). Returns dict with categories and their files. """ game_path = Path(game_path) script_mods = { "redscript": [], # r6/scripts/*.reds "tweakxl": [], # r6/tweaks/*.yaml "cet": [], # bin/x64/plugins/cyber_engine_tweaks/mods/* "red4ext": [], # red4ext/plugins/* "archivexl": [], # *.xl files in archive/pc/mod/ } # redscript rs_dir = game_path / "r6/scripts" if rs_dir.exists(): for f in rs_dir.rglob("*.reds"): script_mods["redscript"].append(str(f.relative_to(game_path))) # TweakXL tw_dir = game_path / "r6/tweaks" if tw_dir.exists(): for f in tw_dir.rglob("*.yaml"): script_mods["tweakxl"].append(str(f.relative_to(game_path))) for f in tw_dir.rglob("*.yml"): script_mods["tweakxl"].append(str(f.relative_to(game_path))) # CET cet_dir = game_path / "bin/x64/plugins/cyber_engine_tweaks/mods" if cet_dir.exists(): for d in cet_dir.iterdir(): if d.is_dir(): files = list(d.rglob("*")) if files: script_mods["cet"].append(str(d.relative_to(game_path))) # red4ext r4e_dir = game_path / "red4ext/plugins" if r4e_dir.exists(): for d in r4e_dir.iterdir(): if d.is_dir(): files = list(d.glob("*.dll")) if files: script_mods["red4ext"].append(str(d.relative_to(game_path))) # ArchiveXL mod_dir = game_path / "archive/pc/mod" if mod_dir.exists(): for f in mod_dir.glob("*.xl"): script_mods["archivexl"].append(str(f.relative_to(game_path))) return script_mods # =========================================================================== # CONFIG MANAGEMENT # =========================================================================== def load_config(): """Load configuration from file.""" if CONFIG_FILE.exists(): with open(CONFIG_FILE, "r") as f: return json.load(f) return { "game_path": DEFAULT_GAME_PATH, "mod_dir": DEFAULT_MOD_DIR, "last_profile": "Default", "nexus_api_key": "", "auto_prefix": True, } def save_config(config): """Save configuration to file.""" CONFIG_DIR.mkdir(parents=True, exist_ok=True) with open(CONFIG_FILE, "w") as f: json.dump(config, f, indent=2) # =========================================================================== # MOD CLASS # =========================================================================== class Mod: """Represents a single mod file.""" def __init__(self, path, enabled=True, prefix=""): self.path = Path(path) self.enabled = enabled self.prefix = prefix self.info = {} self.conflicts = [] @property def name(self): name = self.path.name if name.endswith(".disabled"): name = name[:-9] if name.endswith(".archive"): name = name[:-8] if len(name) > 4 and name[:4].isdigit() and name[3] == "_": name = name[4:] return name @property def filename(self): return self.path.name @property def size_mb(self): return self.path.stat().st_size / (1024 * 1024) @property def sort_key(self): name = self.path.name if name[:3].isdigit(): return name[:3] return "999" def scan_mods(mod_dir): """Scan the mod directory and return list of Mod objects.""" mods = [] mod_path = Path(mod_dir) if not mod_path.exists(): return mods for f in mod_path.iterdir(): if f.is_file() and (f.name.endswith(".archive") or f.name.endswith(".archive.disabled")): enabled = f.name.endswith(".archive") mod = Mod(f, enabled=enabled) mods.append(mod) mods.sort(key=lambda m: m.sort_key) return mods def toggle_mod(mod): """Enable or disable a mod by renaming .archive <-> .archive.disabled.""" if mod.enabled: new_path = mod.path.with_suffix(mod.path.suffix + ".disabled") mod.path.rename(new_path) mod.path = new_path mod.enabled = False else: new_path = mod.path.with_suffix("") mod.path.rename(new_path) mod.path = new_path mod.enabled = True def set_load_order(mods, order_list): """Rename mods to set load order via numeric prefixes.""" for i, mod_name in enumerate(order_list): prefix = f"{i * 10:03d}_" for mod in mods: if mod.name == mod_name: old_name = mod.path.name clean_name = old_name if clean_name[:4].isdigit() and clean_name[3] == "_": clean_name = clean_name[4:] new_name = prefix + clean_name if old_name != new_name: new_path = mod.path.parent / new_name mod.path.rename(new_path) mod.path = new_path break # =========================================================================== # NEXUS MODS INTEGRATION # =========================================================================== def parse_nxm_url(url): """Parse an nxm:// URL to extract mod info.""" parsed = urllib.parse.urlparse(url) parts = parsed.path.strip("/").split("/") if len(parts) >= 4 and parts[0] == "mods" and parts[2] == "files": return { "game": parsed.netloc, "mod_id": parts[1], "file_id": parts[3], "params": dict(urllib.parse.parse_qsl(parsed.query)), } return None def download_nxm_mod(nxm_url, api_key, download_dir): """Download a mod from Nexus Mods using an nxm:// URL.""" info = parse_nxm_url(nxm_url) if not info: return None, "Invalid NXM URL" headers = {"apikey": api_key} api_url = f"{NEXUS_API_BASE}/games/{info['game']}/mods/{info['mod_id']}/files/{info['file_id']}/download_link.json" try: req = urllib.request.Request(api_url, headers=headers) with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read()) download_url = data[0]["URI"] filename = f"mod_{info['mod_id']}_{info['file_id']}.7z" filepath = Path(download_dir) / filename urllib.request.urlretrieve(download_url, filepath) return filepath, None except Exception as e: return None, str(e) # =========================================================================== # GUI # =========================================================================== class ModManagerWindow(QMainWindow): def __init__(self): super().__init__() self.config = load_config() self.mods = [] self.current_profile = self.config.get("last_profile", "Default") self.script_mods = {} self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}") self.setMinimumSize(900, 600) self.setStyleSheet(self._get_style()) self.setAcceptDrops(True) # Central widget central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) # Top bar top_bar = self._create_top_bar() layout.addLayout(top_bar) # Tabs self.tabs = QTabWidget() self.tabs.addTab(self._create_mods_tab(), "📦 Mods") self.tabs.addTab(self._create_scripts_tab(), "📜 Script Mods") self.tabs.addTab(self._create_conflicts_tab(), "⚠️ Konflikte") self.tabs.addTab(self._create_profiles_tab(), "💾 Profile") self.tabs.addTab(self._create_nexus_tab(), "🌐 Nexus Mods") self.tabs.addTab(self._create_settings_tab(), "⚙️ Settings") layout.addWidget(self.tabs) # Status bar self.status = QStatusBar() self.setStatusBar(self.status) self.status.showMessage("Ready") # Load on startup QTimer.singleShot(100, self.refresh_all) def _get_style(self): return """ QMainWindow { background: #0a0a0f; } QWidget { background: #12121a; color: #e0e0e0; font-size: 14px; } QTabWidget::pane { border: 1px solid #2a2a3a; border-radius: 8px; } QTabBar::tab { background: #12121a; border: 1px solid #2a2a3a; padding: 8px 20px; border-radius: 8px 8px 0 0; color: #a0a0b0; } QTabBar::tab:selected { background: #1a1a2a; border-bottom: 2px solid #00ff9f; color: #00ff9f; } QPushButton { background: #1a1a2a; border: 1px solid #2a2a3a; border-radius: 6px; padding: 8px 16px; color: #e0e0e0; } QPushButton:hover { background: #2a2a3a; border-color: #00ff9f; } QPushButton:pressed { background: #00ff9f; color: #0a0a0f; } QListWidget { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 6px; } QListWidgetItem { padding: 4px; } QTreeWidget { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 6px; } QLineEdit, QComboBox { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 4px; padding: 6px; } QGroupBox { border: 1px solid #2a2a3a; border-radius: 8px; margin-top: 12px; padding-top: 12px; } QGroupBox::title { color: #00ff9f; subcontrol-origin: margin; left: 12px; padding: 0 4px; } QProgressBar { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 4px; text-align: center; } QProgressBar::chunk { background: #00ff9f; border-radius: 4px; } QCheckBox { color: #e0e0e0; } QLabel { color: #e0e0e0; } QStatusBar { background: #12121a; border-top: 1px solid #2a2a3a; } QTextEdit { background: #0a0a0f; border: 1px solid #2a2a3a; border-radius: 6px; } """ # --- Drag & Drop --- def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): event.acceptProposedAction() def dropEvent(self, event: QDropEvent): for url in event.mimeData().urls(): filepath = url.toLocalFile() if filepath: self.install_mod(filepath) # --- Top bar --- def _create_top_bar(self): layout = QHBoxLayout() layout.addWidget(QLabel("Game:")) self.game_path_label = QLabel(self.config.get("game_path", DEFAULT_GAME_PATH)) self.game_path_label.setStyleSheet("color: #a0a0b0; padding: 0 10px;") layout.addWidget(self.game_path_label, 1) browse_btn = QPushButton("📂 Browse") browse_btn.clicked.connect(self.browse_game_path) layout.addWidget(browse_btn) layout.addWidget(QLabel(" | ")) layout.addWidget(QLabel("Profile:")) self.profile_combo = QComboBox() self.profile_combo.setMinimumWidth(150) self._refresh_profiles() self.profile_combo.currentTextChanged.connect(self.load_profile) layout.addWidget(self.profile_combo) refresh_btn = QPushButton("🔄 Refresh") refresh_btn.clicked.connect(self.refresh_all) layout.addWidget(refresh_btn) return layout # --- Mods tab --- def _create_mods_tab(self): widget = QWidget() layout = QVBoxLayout(widget) splitter = QSplitter(Qt.Orientation.Horizontal) self.mod_list = QTreeWidget() self.mod_list.setHeaderLabels(["", "Mod Name", "Size", "Order", "Status", "Files"]) self.mod_list.setRootIsDecorated(False) self.mod_list.setAlternatingRowColors(True) self.mod_list.setColumnWidth(0, 40) self.mod_list.setColumnWidth(1, 250) self.mod_list.setColumnWidth(2, 80) self.mod_list.setColumnWidth(3, 60) self.mod_list.setColumnWidth(5, 60) self.mod_list.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) self.mod_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.mod_list.customContextMenuRequested.connect(self._mod_context_menu) self.mod_list.itemChanged.connect(self._on_mod_checkbox_changed) splitter.addWidget(self.mod_list) # Details panel details_group = QGroupBox("Details") details_layout = QVBoxLayout(details_group) self.details_label = QTextEdit() self.details_label.setReadOnly(True) self.details_label.setMaximumHeight(300) details_layout.addWidget(self.details_label) splitter.addWidget(details_group) splitter.setSizes([600, 300]) layout.addWidget(splitter, 1) # Buttons btn_layout = QHBoxLayout() add_btn = QPushButton("➕ Add Mod (.archive, .zip, .7z)") add_btn.clicked.connect(self.add_mod) btn_layout.addWidget(add_btn) enable_all_btn = QPushButton("✅ Enable All") enable_all_btn.clicked.connect(self.enable_all_mods) btn_layout.addWidget(enable_all_btn) disable_all_btn = QPushButton("❌ Disable All") disable_all_btn.clicked.connect(self.disable_all_mods) btn_layout.addWidget(disable_all_btn) reorder_btn = QPushButton("🔢 Auto-Sort (Load Order)") reorder_btn.clicked.connect(self.auto_sort_mods) btn_layout.addWidget(reorder_btn) btn_layout.addStretch() layout.addLayout(btn_layout) return widget # --- Script Mods tab --- def _create_scripts_tab(self): widget = QWidget() layout = QVBoxLayout(widget) layout.addWidget(QLabel("📜 Script-based mods (redscript, TweakXL, CET, red4ext, ArchiveXL):")) self.script_tree = QTreeWidget() self.script_tree.setHeaderLabels(["Type", "Mod Name", "Path", "Files", "Status"]) self.script_tree.setAlternatingRowColors(True) header = self.script_tree.header() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) self.script_tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.script_tree.customContextMenuRequested.connect(self._script_context_menu) layout.addWidget(self.script_tree, 1) # Info label self.script_info_label = QLabel("Ready. Click Refresh to scan.") self.script_info_label.setStyleSheet("color: #888; font-size: 11px;") layout.addWidget(self.script_info_label) # Buttons btn_layout = QHBoxLayout() add_script_btn = QPushButton("➕ Add Script Mod (.yaml, .reds, .zip, .7z)") add_script_btn.clicked.connect(self.add_script_mod) btn_layout.addWidget(add_script_btn) refresh_script_btn = QPushButton("🔄 Refresh") refresh_script_btn.clicked.connect(self.refresh_script_mods) btn_layout.addWidget(refresh_script_btn) toggle_script_btn = QPushButton("🔄 Toggle Selected") toggle_script_btn.clicked.connect(self._toggle_script_mod) btn_layout.addWidget(toggle_script_btn) btn_layout.addStretch() layout.addLayout(btn_layout) return widget # --- Conflicts tab --- def _create_conflicts_tab(self): widget = QWidget() layout = QVBoxLayout(widget) layout.addWidget(QLabel("⚠️ Real file conflicts — files that appear in multiple .archive mods:")) self.conflict_tree = QTreeWidget() self.conflict_tree.setHeaderLabels(["Internal File Path", "Mod 1", "Mod 2", "Mod 3", "Count"]) self.conflict_tree.setAlternatingRowColors(True) self.conflict_tree.setColumnWidth(0, 350) layout.addWidget(self.conflict_tree, 1) # Progress bar for scanning self.conflict_progress = QProgressBar() self.conflict_progress.setVisible(False) layout.addWidget(self.conflict_progress) btn_layout = QHBoxLayout() scan_btn = QPushButton("🔍 Deep Scan (Parse All Archives)") scan_btn.clicked.connect(self.scan_conflicts_deep) btn_layout.addWidget(scan_btn) quick_btn = QPushButton("⚡ Quick Scan (Names Only)") quick_btn.clicked.connect(self.scan_conflicts_quick) btn_layout.addWidget(quick_btn) btn_layout.addStretch() layout.addLayout(btn_layout) return widget # --- Profiles tab --- def _create_profiles_tab(self): widget = QWidget() layout = QVBoxLayout(widget) layout.addWidget(QLabel("Save and load mod configurations as profiles:")) name_layout = QHBoxLayout() name_layout.addWidget(QLabel("Name:")) self.profile_name_input = QLineEdit() self.profile_name_input.setPlaceholderText("Profile name (e.g. NSFW, Vanilla+, Minimal)") name_layout.addWidget(self.profile_name_input, 1) save_btn = QPushButton("💾 Save Profile") save_btn.clicked.connect(self.save_profile) name_layout.addWidget(save_btn) layout.addLayout(name_layout) self.profile_list = QListWidget() self.profile_list.itemDoubleClicked.connect(lambda item: self.load_profile(item.text())) layout.addWidget(self.profile_list, 1) btn_layout = QHBoxLayout() load_btn = QPushButton("📂 Load") load_btn.clicked.connect(lambda: self.load_profile(self.profile_list.currentItem().text() if self.profile_list.currentItem() else "")) btn_layout.addWidget(load_btn) delete_btn = QPushButton("🗑️ Delete") delete_btn.clicked.connect(self.delete_profile) btn_layout.addWidget(delete_btn) btn_layout.addStretch() layout.addLayout(btn_layout) return widget # --- Nexus tab --- def _create_nexus_tab(self): widget = QWidget() layout = QVBoxLayout(widget) # API key key_group = QGroupBox("Nexus Mods API Key") key_layout = QVBoxLayout(key_group) key_layout.addWidget(QLabel("Get your API key from: https://www.nexusmods.com/users/myaccount?tab=api%20keys")) key_input_layout = QHBoxLayout() self.nexus_key_input = QLineEdit() self.nexus_key_input.setEchoMode(QLineEdit.EchoMode.Password) self.nexus_key_input.setPlaceholderText("Paste your Nexus API key here...") self.nexus_key_input.setText(self.config.get("nexus_api_key", "")) key_input_layout.addWidget(self.nexus_key_input, 1) save_key_btn = QPushButton("Save") save_key_btn.clicked.connect(self.save_nexus_key) key_input_layout.addWidget(save_key_btn) key_layout.addLayout(key_input_layout) layout.addWidget(key_group) # NXM download dl_group = QGroupBox("Manual NXM Download") dl_layout = QVBoxLayout(dl_group) dl_layout.addWidget(QLabel("Paste an nxm:// URL from Nexus Mods:")) url_layout = QHBoxLayout() self.nxm_url_input = QLineEdit() self.nxm_url_input.setPlaceholderText("nxm://cyberpunk2077/mods/123/files/456?key=...") url_layout.addWidget(self.nxm_url_input, 1) dl_btn = QPushButton("⬇️ Download & Install") dl_btn.clicked.connect(self.download_nxm) url_layout.addWidget(dl_btn) dl_layout.addLayout(url_layout) self.nexus_progress = QProgressBar() self.nexus_progress.setVisible(False) dl_layout.addWidget(self.nexus_progress) self.nexus_status = QLabel("") dl_layout.addWidget(self.nexus_status) layout.addWidget(dl_group) # Browser integration info_group = QGroupBox("Browser Integration") info_layout = QVBoxLayout(info_group) info_layout.addWidget(QLabel("To auto-catch nxm:// links from your browser:")) register_btn = QPushButton("🔗 Register nxm:// Protocol Handler") register_btn.clicked.connect(self.register_nxm_protocol) info_layout.addWidget(register_btn) info_layout.addWidget(QLabel("This lets clicking 'Download' on Nexus Mods\nautomatically download the mod here.")) layout.addWidget(info_group) layout.addStretch() return widget # --- Settings tab --- def _create_settings_tab(self): widget = QWidget() layout = QVBoxLayout(widget) path_group = QGroupBox("Game Path") path_layout = QVBoxLayout(path_group) path_layout.addWidget(QLabel("Cyberpunk 2077 installation directory:")) path_input_layout = QHBoxLayout() self.game_path_input = QLineEdit() self.game_path_input.setText(self.config.get("game_path", DEFAULT_GAME_PATH)) path_input_layout.addWidget(self.game_path_input, 1) path_browse_btn = QPushButton("📂") path_browse_btn.clicked.connect(self.browse_game_path_settings) path_input_layout.addWidget(path_browse_btn) path_layout.addLayout(path_input_layout) save_path_btn = QPushButton("Save") save_path_btn.clicked.connect(self.save_game_path) path_layout.addWidget(save_path_btn) layout.addWidget(path_group) order_group = QGroupBox("Load Order") order_layout = QVBoxLayout(order_group) self.auto_prefix_check = QCheckBox("Auto-assign numeric prefixes (000_, 010_, 020_)") self.auto_prefix_check.setChecked(self.config.get("auto_prefix", True)) order_layout.addWidget(self.auto_prefix_check) order_layout.addWidget(QLabel("Higher numbers = loaded later = overrides earlier mods")) layout.addWidget(order_group) # Tools check tools_group = QGroupBox("Extraction Tools") tools_layout = QVBoxLayout(tools_group) tools_layout.addWidget(QLabel("Required for auto-extracting .7z / .zip / .rar downloads:")) for tool, pkg in [("7z", "p7zip"), ("unrar", "unrar"), ("zipinfo", "zip")]: found = shutil.which(tool) is not None label = QLabel(f" {'✅' if found else '❌'} {tool} ({pkg})") label.setStyleSheet(f"color: {'#00ff9f' if found else '#ff0080'};") tools_layout.addWidget(label) layout.addWidget(tools_group) layout.addStretch() return widget # =========================================================================== # ACTIONS # =========================================================================== def get_mod_dir(self): return Path(self.config["game_path"]) / self.config["mod_dir"] def refresh_all(self): """Refresh mods, script mods, and profiles.""" self.refresh_mods() self.refresh_script_mods() def refresh_mods(self): """Scan mod directory and update the list.""" mod_dir = self.get_mod_dir() if not mod_dir.exists(): self.status.showMessage(f"Mod directory not found: {mod_dir}") return self.mods = scan_mods(mod_dir) self._update_mod_list() self.status.showMessage(f"Found {len(self.mods)} mods in {mod_dir}") def refresh_script_mods(self): """Scan for script-based mods.""" self.script_mods = scan_script_mods(self.config["game_path"]) self._update_script_list() def _update_mod_list(self): self.mod_list.blockSignals(True) self.mod_list.clear() for mod in self.mods: item = QTreeWidgetItem() item.setCheckState(0, Qt.CheckState.Checked if mod.enabled else Qt.CheckState.Unchecked) item.setText(1, mod.name) item.setText(2, f"{mod.size_mb:.1f} MB") item.setText(3, mod.sort_key) item.setText(4, "✅ Enabled" if mod.enabled else "❌ Disabled") # Parse archive for file count (lazy — only if not already parsed) if not mod.info: mod.info = read_archive_info(mod.path) file_count = mod.info.get("file_count", 0) item.setText(5, str(file_count) if file_count else "?") if not mod.enabled: item.setForeground(4, QColor("#ff0080")) else: item.setForeground(4, QColor("#00ff9f")) item.setData(0, Qt.ItemDataRole.UserRole, mod) self.mod_list.addTopLevelItem(item) self.mod_list.blockSignals(False) def _update_script_list(self): """Update the script mods tree.""" self.script_tree.clear() icons = { "redscript": "📜", "tweakxl": "⚙️", "cet": "🔧", "red4ext": "📦", "archivexl": "🗂️", } total_mods = 0 total_files = 0 for category, files in self.script_mods.items(): if not files: continue # Group by mod name (top-level folder or filename) mod_groups = {} for f in files: # Extract mod name from path path_parts = f.split("/") if category == "cet" and len(path_parts) >= 7: # bin/x64/plugins/cyber_engine_tweaks/mods//... mod_name = path_parts[6] elif category == "red4ext" and len(path_parts) >= 3: # red4ext/plugins//... mod_name = path_parts[2] elif category == "redscript" and len(path_parts) >= 3: # r6/scripts//... mod_name = path_parts[2] if path_parts[2] != "r6" else path_parts[-1] elif category == "tweakxl" and len(path_parts) >= 3: # r6/tweaks//... mod_name = path_parts[2] if path_parts[2] != "r6" else path_parts[-1] else: mod_name = path_parts[-1] if mod_name not in mod_groups: mod_groups[mod_name] = [] mod_groups[mod_name].append(f) parent = QTreeWidgetItem() parent.setText(0, f"{icons.get(category, '📋')} {category.upper()}") parent.setText(1, f"{len(mod_groups)} mods") parent.setText(3, f"{len(files)} files") parent.setText(4, "✅ Active") parent.setForeground(0, QColor("#00ff9f")) parent.setForeground(4, QColor("#00ff9f")) parent.setExpanded(True) for mod_name, mod_files in sorted(mod_groups.items()): child = QTreeWidgetItem() child.setText(1, mod_name) child.setText(3, str(len(mod_files))) # Check if disabled (.disabled suffix on folder or file) is_disabled = any(".disabled" in f for f in mod_files) child.setText(4, "❌ Disabled" if is_disabled else "✅ Active") if is_disabled: child.setForeground(4, QColor("#ff0080")) else: child.setForeground(4, QColor("#00ff9f")) child.setData(0, Qt.ItemDataRole.UserRole, { "category": category, "mod_name": mod_name, "files": mod_files, "disabled": is_disabled }) # Show first file as path hint child.setText(2, mod_files[0] if mod_files else "") parent.addChild(child) total_mods += 1 total_files += len(mod_files) self.script_tree.addTopLevelItem(parent) parent.setExpanded(True) self.script_info_label.setText(f"Total: {total_mods} mods, {total_files} files") def _on_mod_checkbox_changed(self, item, column): if column != 0: return mod = item.data(0, Qt.ItemDataRole.UserRole) if not mod: return new_state = item.checkState(0) == Qt.CheckState.Checked if new_state != mod.enabled: toggle_mod(mod) item.setText(4, "✅ Enabled" if mod.enabled else "❌ Disabled") if mod.enabled: item.setForeground(4, QColor("#00ff9f")) else: item.setForeground(4, QColor("#ff0080")) self.status.showMessage(f"{'Enabled' if mod.enabled else 'Disabled'}: {mod.name}") def _mod_context_menu(self, pos): item = self.mod_list.itemAt(pos) if not item: return mod = item.data(0, Qt.ItemDataRole.UserRole) menu = QMenu(self) toggle_action = QAction("Disable" if mod.enabled else "Enable", self) toggle_action.triggered.connect(lambda: self._on_mod_checkbox_changed(item, 0)) menu.addAction(toggle_action) menu.addSeparator() # Show internal files show_files = QAction("📋 Show Internal Files", self) show_files.triggered.connect(lambda: self._show_internal_files(mod)) menu.addAction(show_files) menu.addSeparator() move_up = QAction("⬆️ Move Up (earlier)", self) move_up.triggered.connect(lambda: self._move_mod(item, up=True)) menu.addAction(move_up) move_down = QAction("⬇️ Move Down (later)", self) move_down.triggered.connect(lambda: self._move_mod(item, up=False)) menu.addAction(move_down) menu.addSeparator() delete_action = QAction("🗑️ Delete Mod", self) delete_action.triggered.connect(lambda: self._delete_mod(item)) menu.addAction(delete_action) menu.exec(self.mod_list.mapToGlobal(pos)) def _show_internal_files(self, mod): """Show internal file list of a mod in the details panel.""" if not mod.info: mod.info = read_archive_info(mod.path) info = mod.info text = f"{mod.name}
" text += f"Size: {mod.size_mb:.1f} MB
" text += f"Version: {info.get('version', '?')}
" text += f"Files: {info.get('file_count', '?')}

" files = info.get("files", []) if files: text += "Internal files:
" for f in files[:50]: text += f" {f}
" if len(files) > 50: text += f" ... and {len(files) - 50} more
" else: text += "No file list could be parsed
" self.details_label.setHtml(text) def _move_mod(self, item, up=True): idx = self.mod_list.indexOfTopLevelItem(item) if up and idx > 0: self.mod_list.takeTopLevelItem(idx) self.mod_list.insertTopLevelItem(idx - 1, item) self.mod_list.setCurrentItem(item) elif not up and idx < self.mod_list.topLevelItemCount() - 1: self.mod_list.takeTopLevelItem(idx) self.mod_list.insertTopLevelItem(idx + 1, item) self.mod_list.setCurrentItem(item) self._apply_order_from_list() def _apply_order_from_list(self): order = [] for i in range(self.mod_list.topLevelItemCount()): item = self.mod_list.topLevelItem(i) mod = item.data(0, Qt.ItemDataRole.UserRole) if mod: order.append(mod.name) set_load_order(self.mods, order) self.refresh_mods() def _delete_mod(self, item): mod = item.data(0, Qt.ItemDataRole.UserRole) reply = QMessageBox.question(self, "Delete Mod", f"Delete '{mod.name}' ({mod.size_mb:.1f} MB)?\nThis cannot be undone!", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) if reply == QMessageBox.StandardButton.Yes: mod.path.unlink() self.refresh_mods() self.status.showMessage(f"Deleted: {mod.name}") def install_mod(self, filepath): """Install a mod file — auto-detect and handle .archive, .zip, .7z, .yaml, .reds""" game_path = self.config["game_path"] installed = install_mod_file(filepath, game_path, status_callback=lambda msg: self.status.showMessage(msg)) if installed: msg = f"Installed {len(installed)} files:\n" + "\n".join(installed[:20]) if len(installed) > 20: msg += f"\n... and {len(installed) - 20} more" self.status.showMessage(f"Installed {len(installed)} files from {Path(filepath).name}") QMessageBox.information(self, "Mod Installed", msg) self.refresh_all() else: QMessageBox.warning(self, "Install Failed", f"Could not install {filepath}.\nUnsupported file type or extraction failed.") def add_mod(self): """Add mod files from file dialog — supports .archive, .zip, .7z, .rar, .yaml, .reds""" files, _ = QFileDialog.getOpenFileNames(self, "Select mod files", "", "Mod files (*.archive *.zip *.7z *.rar *.yaml *.yml *.reds *.xl);;All files (*.*)") for f in files: self.install_mod(f) def add_script_mod(self): """Add script mod files — .yaml, .reds, .zip, .7z""" files, _ = QFileDialog.getOpenFileNames(self, "Select script mod files", "", "Script mods (*.yaml *.yml *.reds *.zip *.7z);;All files (*.*)") for f in files: self.install_mod(f) def enable_all_mods(self): for mod in self.mods: if not mod.enabled: toggle_mod(mod) self.refresh_mods() self.status.showMessage("All mods enabled") def disable_all_mods(self): for mod in self.mods: if mod.enabled: toggle_mod(mod) self.refresh_mods() self.status.showMessage("All mods disabled") def auto_sort_mods(self): self._apply_order_from_list() self.status.showMessage("Load order applied") def browse_game_path(self): path = QFileDialog.getExistingDirectory(self, "Select Cyberpunk 2077 directory", self.config.get("game_path", DEFAULT_GAME_PATH)) if path: self.config["game_path"] = path save_config(self.config) self.game_path_label.setText(path) self.refresh_all() def browse_game_path_settings(self): path = QFileDialog.getExistingDirectory(self, "Select Cyberpunk 2077 directory", self.game_path_input.text()) if path: self.game_path_input.setText(path) def save_game_path(self): self.config["game_path"] = self.game_path_input.text() self.config["auto_prefix"] = self.auto_prefix_check.isChecked() save_config(self.config) self.game_path_label.setText(self.config["game_path"]) self.status.showMessage("Settings saved") # --- Conflict scanning --- def scan_conflicts_quick(self): """Quick conflict scan based on mod names only.""" self.conflict_tree.clear() name_map = {} for mod in self.mods: if not mod.enabled: continue base = mod.name.lower() if base not in name_map: name_map[base] = [] name_map[base].append(mod) for base, mod_list in name_map.items(): if len(mod_list) > 1: for i in range(len(mod_list)): for j in range(i + 1, len(mod_list)): item = QTreeWidgetItem() item.setText(0, base) item.setText(1, mod_list[i].filename) item.setText(2, mod_list[j].filename) item.setText(4, "2") item.setForeground(3, QColor("#ff9500")) self.conflict_tree.addTopLevelItem(item) if self.conflict_tree.topLevelItemCount() == 0: item = QTreeWidgetItem() item.setText(0, "No conflicts detected ✅") self.conflict_tree.addTopLevelItem(item) self.status.showMessage(f"Quick scan: {self.conflict_tree.topLevelItemCount()} items") def scan_conflicts_deep(self): """Deep conflict scan — parse all .archive files and compare internal paths.""" self.conflict_tree.clear() self.conflict_progress.setVisible(True) self.conflict_progress.setRange(0, len(self.mods)) def progress(current, total, msg): self.conflict_progress.setValue(current) self.status.showMessage(msg) QApplication.processEvents() conflicts = scan_archive_conflicts(self.mods, progress_callback=progress) self.conflict_progress.setVisible(False) if not conflicts: item = QTreeWidgetItem() item.setText(0, "No conflicts detected ✅") self.conflict_tree.addTopLevelItem(item) else: for c in sorted(conflicts, key=lambda x: -x["count"]): item = QTreeWidgetItem() item.setText(0, c["file"]) for i, mod_name in enumerate(c["mods"][:3]): item.setText(i + 1, mod_name) item.setText(4, str(c["count"])) if c["count"] > 2: item.setForeground(0, QColor("#ff0080")) else: item.setForeground(0, QColor("#ff9500")) self.conflict_tree.addTopLevelItem(item) self.status.showMessage(f"Deep scan: {len(conflicts)} conflicting files found") # --- Profiles --- def _refresh_profiles(self): PROFILES_DIR.mkdir(parents=True, exist_ok=True) profiles = [f.stem for f in PROFILES_DIR.glob("*.json")] if not profiles: profiles = ["Default"] self._save_profile_data("Default") self.profile_combo.blockSignals(True) self.profile_combo.clear() self.profile_combo.addItems(profiles) if self.current_profile in profiles: self.profile_combo.setCurrentText(self.current_profile) self.profile_combo.blockSignals(False) if hasattr(self, "profile_list"): self.profile_list.clear() self.profile_list.addItems(profiles) def _save_profile_data(self, name): PROFILES_DIR.mkdir(parents=True, exist_ok=True) data = { "mods": [{"name": m.name, "enabled": m.enabled, "order": m.sort_key} for m in self.mods], "saved_at": datetime.now().isoformat(), } with open(PROFILES_DIR / f"{name}.json", "w") as f: json.dump(data, f, indent=2) def save_profile(self): name = self.profile_name_input.text().strip() if not name: QMessageBox.warning(self, "No name", "Please enter a profile name.") return self._save_profile_data(name) self._refresh_profiles() self.status.showMessage(f"Profile saved: {name}") def load_profile(self, name): if not name: return path = PROFILES_DIR / f"{name}.json" if not path.exists(): return with open(path, "r") as f: data = json.load(f) desired = {m["name"]: m["enabled"] for m in data.get("mods", [])} desired_order = [m["name"] for m in data.get("mods", [])] for mod in self.mods: should_enable = desired.get(mod.name, None) if should_enable is True and not mod.enabled: toggle_mod(mod) elif should_enable is False and mod.enabled: toggle_mod(mod) if desired_order: set_load_order(self.mods, desired_order) self.current_profile = name self.config["last_profile"] = name save_config(self.config) self.refresh_mods() self.status.showMessage(f"Profile loaded: {name}") def delete_profile(self): item = self.profile_list.currentItem() if not item: return name = item.text() if name == "Default": QMessageBox.warning(self, "Cannot delete", "Cannot delete Default profile.") return reply = QMessageBox.question(self, "Delete Profile", f"Delete profile '{name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) if reply == QMessageBox.StandardButton.Yes: (PROFILES_DIR / f"{name}.json").unlink() self._refresh_profiles() self.status.showMessage(f"Profile deleted: {name}") # --- Nexus --- def save_nexus_key(self): key = self.nexus_key_input.text().strip() self.config["nexus_api_key"] = key save_config(self.config) NEXUS_API_KEY_FILE.write_text(key) NEXUS_API_KEY_FILE.chmod(0o600) self.status.showMessage("Nexus API key saved") def download_nxm(self): url = self.nxm_url_input.text().strip() if not url: return api_key = self.config.get("nexus_api_key", "") if not api_key: QMessageBox.warning(self, "No API key", "Please set your Nexus Mods API key first.") self.tabs.setCurrentIndex(4) return self.nexus_progress.setVisible(True) self.nexus_progress.setRange(0, 0) self.nexus_status.setText("Downloading...") import threading def do_download(): download_dir = Path.home() / "Downloads" / "nexus-mods" download_dir.mkdir(parents=True, exist_ok=True) filepath, error = download_nxm_mod(url, api_key, download_dir) def on_done(): self.nexus_progress.setVisible(False) if error: self.nexus_status.setText(f"❌ {error}") elif filepath: self.nexus_status.setText(f"✅ Downloaded: {filepath.name}") # Auto-install installed = install_mod_file(filepath, self.config["game_path"], status_callback=lambda msg: self.status.showMessage(msg)) if installed: msg = f"Downloaded and installed {len(installed)} files:\n" + "\n".join(installed[:20]) if len(installed) > 20: msg += f"\n... and {len(installed) - 20} more" QMessageBox.information(self, "Mod Installed", msg) self.refresh_all() else: QMessageBox.warning(self, "Install Failed", f"Downloaded {filepath.name} but could not auto-install.\n" f"File saved at: {filepath}") QTimer.singleShot(0, on_done) threading.Thread(target=do_download, daemon=True).start() def register_nxm_protocol(self): desktop_dir = Path.home() / ".local" / "share" / "applications" desktop_dir.mkdir(parents=True, exist_ok=True) script_path = Path(__file__).resolve() desktop_file = desktop_dir / "neon-mod-manager-nxm.desktop" desktop_content = f"""[Desktop Entry] Type=Application Name=Neon Mod Manager (NXM Handler) Exec=python3 {script_path} --nxm %u Terminal=false MimeType=x-scheme-handler/nxm; NoDisplay=true """ desktop_file.write_text(desktop_content) subprocess.run(["update-desktop-database", str(desktop_dir)], capture_output=True) self.status.showMessage("nxm:// protocol registered!") QMessageBox.information(self, "Protocol Registered", "nxm:// protocol handler registered!\n\n" "When you click 'Download' on Nexus Mods,\n" "the mod will be sent to Neon Mod Manager.") def main(): app = QApplication(sys.argv) # Check for --nxm argument (protocol handler invocation) if len(sys.argv) > 2 and sys.argv[1] == "--nxm": nxm_url = sys.argv[2] config = load_config() api_key = config.get("nexus_api_key", "") if not api_key and NEXUS_API_KEY_FILE.exists(): api_key = NEXUS_API_KEY_FILE.read_text().strip() if not api_key: print("No API key set. Run Neon Mod Manager GUI to set your key.") sys.exit(1) download_dir = Path.home() / "Downloads" / "nexus-mods" download_dir.mkdir(parents=True, exist_ok=True) filepath, error = download_nxm_mod(nxm_url, api_key, download_dir) if error: print(f"Error: {error}") sys.exit(1) print(f"Downloaded: {filepath}") # Auto-install installed = install_mod_file(filepath, config["game_path"]) if installed: print(f"Installed {len(installed)} files to {config['game_path']}") else: print(f"File saved at: {filepath} (manual install needed)") sys.exit(0) # Normal GUI mode window = ModManagerWindow() window.show() sys.exit(app.exec()) if __name__ == "__main__": main()