From 0e33ca5ec1ff36a1e3a1b063d42ce985c2e8b909 Mon Sep 17 00:00:00 2001 From: arch_agent Date: Mon, 13 Jul 2026 10:30:07 +0200 Subject: [PATCH] v0.2: Archive parsing, script mod support, auto-extract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New features: - Archive file parsing: reads internal file paths from .archive files - Deep conflict scan: compares internal file paths across all enabled mods - Script mod support: manages r6/scripts (.reds), r6/tweaks (.yaml), red4ext (.dll), CET (.lua), ArchiveXL (.xl) - Auto-extract: .7z/.zip/.rar downloads are extracted and files installed to correct paths - Drag & drop: drop mod files onto the window to install - Internal file viewer: right-click mod → Show Internal Files - Script Mods tab: shows all script-based mods grouped by type - Settings: shows extraction tool availability (7z, unrar, zip) --- mod_manager.py | 901 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 740 insertions(+), 161 deletions(-) diff --git a/mod_manager.py b/mod_manager.py index 701e4de..1196d4e 100644 --- a/mod_manager.py +++ b/mod_manager.py @@ -6,7 +6,9 @@ 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 -- Detects file conflicts between .archive files +- 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) @@ -24,6 +26,9 @@ import hashlib import subprocess import urllib.request import urllib.parse +import zipfile +import tarfile +import io from pathlib import Path from datetime import datetime @@ -32,14 +37,14 @@ from PyQt6.QtWidgets import ( QListWidget, QListWidgetItem, QLabel, QPushButton, QFileDialog, QTabWidget, QTreeWidget, QTreeWidgetItem, QComboBox, QLineEdit, QMessageBox, QProgressBar, QGroupBox, QCheckBox, QSpinBox, QSlider, - QMenu, QStatusBar, QSplitter, QHeaderView + 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.1.0" +APP_VERSION = "0.2.0" CONFIG_DIR = Path.home() / ".config" / "neon-mod-manager" CONFIG_FILE = CONFIG_DIR / "config.json" PROFILES_DIR = CONFIG_DIR / "profiles" @@ -53,34 +58,81 @@ NEXUS_API_KEY_FILE = Path.home() / ".nexus_api_key" NEXUS_GAME_ID = "cyberpunk2077" NEXUS_API_BASE = "https://api.nexusmods.com/v1" -# --- Archive parsing --- -# CP2077 .archive files are REDengine RAD archives -# Header: "RDAR" magic, version, file count, file table offset + +# =========================================================================== +# ARCHIVE PARSING — Read internal file list from .archive files +# =========================================================================== def read_archive_info(path): - """Read basic info from a .archive file: file count, size, list of internal file paths.""" + """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": []} + 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": [], # Would need full parser for file list - "size": os.path.getsize(path), + "files": files, + "size": file_size, } except Exception as e: - return {"valid": False, "error": str(e), "files": []} + return {"valid": False, "error": str(e), "files": [], "size": 0} def get_file_hash(path, chunk_size=65536): @@ -95,7 +147,384 @@ def get_file_hash(path, chunk_size=65536): return h.hexdigest() -# --- Config management --- +# =========================================================================== +# 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.""" @@ -107,7 +536,7 @@ def load_config(): "mod_dir": DEFAULT_MOD_DIR, "last_profile": "Default", "nexus_api_key": "", - "auto_prefix": True, # Auto-assign load order prefixes + "auto_prefix": True, } @@ -118,7 +547,9 @@ def save_config(config): json.dump(config, f, indent=2) -# --- Mod management --- +# =========================================================================== +# MOD CLASS +# =========================================================================== class Mod: """Represents a single mod file.""" @@ -131,13 +562,11 @@ class Mod: @property def name(self): - """Display name without prefix and extension.""" name = self.path.name if name.endswith(".disabled"): name = name[:-9] if name.endswith(".archive"): name = name[:-8] - # Remove numeric prefix like "000_" if len(name) > 4 and name[:4].isdigit() and name[3] == "_": name = name[4:] return name @@ -152,11 +581,10 @@ class Mod: @property def sort_key(self): - """Sort key based on prefix.""" name = self.path.name if name[:3].isdigit(): return name[:3] - return "999" # Unprefixed mods go last + return "999" def scan_mods(mod_dir): @@ -170,7 +598,6 @@ def scan_mods(mod_dir): 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) - mod.info = read_archive_info(f) mods.append(mod) mods.sort(key=lambda m: m.sort_key) @@ -180,13 +607,11 @@ def scan_mods(mod_dir): def toggle_mod(mod): """Enable or disable a mod by renaming .archive <-> .archive.disabled.""" if mod.enabled: - # Disable new_path = mod.path.with_suffix(mod.path.suffix + ".disabled") mod.path.rename(new_path) mod.path = new_path mod.enabled = False else: - # Enable new_path = mod.path.with_suffix("") mod.path.rename(new_path) mod.path = new_path @@ -194,15 +619,12 @@ def toggle_mod(mod): def set_load_order(mods, order_list): - """Rename mods to set load order via numeric prefixes. - order_list is a list of mod names in the desired order. - """ + """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 - # Remove existing prefix clean_name = old_name if clean_name[:4].isdigit() and clean_name[3] == "_": clean_name = clean_name[4:] @@ -214,12 +636,12 @@ def set_load_order(mods, order_list): break -# --- Nexus Mods integration --- +# =========================================================================== +# NEXUS MODS INTEGRATION +# =========================================================================== def parse_nxm_url(url): - """Parse an nxm:// URL to extract mod info. - Format: nxm://game/mods/mod_id/files/file_id?key=...&expires=... - """ + """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": @@ -238,7 +660,6 @@ def download_nxm_mod(nxm_url, api_key, download_dir): if not info: return None, "Invalid NXM URL" - # Generate download URL via Nexus API headers = {"apikey": api_key} api_url = f"{NEXUS_API_BASE}/games/{info['game']}/mods/{info['mod_id']}/files/{info['file_id']}/download_link.json" @@ -248,7 +669,6 @@ def download_nxm_mod(nxm_url, api_key, download_dir): data = json.loads(resp.read()) download_url = data[0]["URI"] - # Download the file filename = f"mod_{info['mod_id']}_{info['file_id']}.7z" filepath = Path(download_dir) / filename @@ -258,7 +678,9 @@ def download_nxm_mod(nxm_url, api_key, download_dir): return None, str(e) -# --- GUI --- +# =========================================================================== +# GUI +# =========================================================================== class ModManagerWindow(QMainWindow): def __init__(self): @@ -266,23 +688,26 @@ class ModManagerWindow(QMainWindow): 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) - # Create central widget with tabs + # Central widget central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) - # Top bar: game path + profile selector + # Top bar top_bar = self._create_top_bar() layout.addLayout(top_bar) - # Tab widget + # 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") @@ -294,8 +719,8 @@ class ModManagerWindow(QMainWindow): self.setStatusBar(self.status) self.status.showMessage("Ready") - # Load mods on startup - QTimer.singleShot(100, self.refresh_mods) + # Load on startup + QTimer.singleShot(100, self.refresh_all) def _get_style(self): return """ @@ -318,12 +743,26 @@ class ModManagerWindow(QMainWindow): 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() - # Game path 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;") @@ -333,10 +772,8 @@ class ModManagerWindow(QMainWindow): browse_btn.clicked.connect(self.browse_game_path) layout.addWidget(browse_btn) - # Separator layout.addWidget(QLabel(" | ")) - # Profile selector layout.addWidget(QLabel("Profile:")) self.profile_combo = QComboBox() self.profile_combo.setMinimumWidth(150) @@ -344,29 +781,29 @@ class ModManagerWindow(QMainWindow): self.profile_combo.currentTextChanged.connect(self.load_profile) layout.addWidget(self.profile_combo) - # Refresh button refresh_btn = QPushButton("🔄 Refresh") - refresh_btn.clicked.connect(self.refresh_mods) + 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) - # Mod list splitter = QSplitter(Qt.Orientation.Horizontal) - # Left: mod list self.mod_list = QTreeWidget() - self.mod_list.setHeaderLabels(["", "Mod Name", "Size", "Order", "Status"]) + 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) # Checkbox - self.mod_list.setColumnWidth(1, 300) # Name - self.mod_list.setColumnWidth(2, 80) # Size - self.mod_list.setColumnWidth(3, 60) # Order + 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) @@ -374,22 +811,22 @@ class ModManagerWindow(QMainWindow): splitter.addWidget(self.mod_list) - # Right: mod details + # Details panel details_group = QGroupBox("Details") details_layout = QVBoxLayout(details_group) - self.details_label = QLabel("Select a mod to see details.") - self.details_label.setWordWrap(True) - self.details_label.setAlignment(Qt.AlignmentFlag.AlignTop) + 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) - # Bottom buttons + # Buttons btn_layout = QHBoxLayout() - add_btn = QPushButton("➕ Add Mod") + add_btn = QPushButton("➕ Add Mod (.archive, .zip, .7z)") add_btn.clicked.connect(self.add_mod) btn_layout.addWidget(add_btn) @@ -410,30 +847,75 @@ class ModManagerWindow(QMainWindow): 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", "Path", "Status"]) + self.script_tree.setAlternatingRowColors(True) + layout.addWidget(self.script_tree, 1) + + # Add script mod button + 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) + + btn_layout.addStretch() + layout.addLayout(btn_layout) + + return widget + + # --- Conflicts tab --- + def _create_conflicts_tab(self): widget = QWidget() layout = QVBoxLayout(widget) - layout.addWidget(QLabel("⚠️ Detected file conflicts between mods:")) + layout.addWidget(QLabel("⚠️ Real file conflicts — files that appear in multiple .archive mods:")) self.conflict_tree = QTreeWidget() - self.conflict_tree.setHeaderLabels(["File", "Mod 1", "Mod 2", "Resolution"]) + 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) - scan_btn = QPushButton("🔍 Scan for Conflicts") - scan_btn.clicked.connect(self.scan_conflicts) - layout.addWidget(scan_btn) + # 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:")) - # Profile name input name_layout = QHBoxLayout() name_layout.addWidget(QLabel("Name:")) self.profile_name_input = QLineEdit() @@ -444,12 +926,10 @@ class ModManagerWindow(QMainWindow): name_layout.addWidget(save_btn) layout.addLayout(name_layout) - # Profile list self.profile_list = QListWidget() self.profile_list.itemDoubleClicked.connect(lambda item: self.load_profile(item.text())) layout.addWidget(self.profile_list, 1) - # Buttons 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 "")) @@ -463,6 +943,8 @@ class ModManagerWindow(QMainWindow): return widget + # --- Nexus tab --- + def _create_nexus_tab(self): widget = QWidget() layout = QVBoxLayout(widget) @@ -483,7 +965,7 @@ class ModManagerWindow(QMainWindow): key_layout.addLayout(key_input_layout) layout.addWidget(key_group) - # NXM URL download + # NXM download dl_group = QGroupBox("Manual NXM Download") dl_layout = QVBoxLayout(dl_group) dl_layout.addWidget(QLabel("Paste an nxm:// URL from Nexus Mods:")) @@ -491,7 +973,7 @@ class ModManagerWindow(QMainWindow): 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") + dl_btn = QPushButton("⬇️ Download & Install") dl_btn.clicked.connect(self.download_nxm) url_layout.addWidget(dl_btn) dl_layout.addLayout(url_layout) @@ -504,7 +986,7 @@ class ModManagerWindow(QMainWindow): dl_layout.addWidget(self.nexus_status) layout.addWidget(dl_group) - # nxm:// protocol handler info + # Browser integration info_group = QGroupBox("Browser Integration") info_layout = QVBoxLayout(info_group) info_layout.addWidget(QLabel("To auto-catch nxm:// links from your browser:")) @@ -517,11 +999,12 @@ class ModManagerWindow(QMainWindow): return widget + # --- Settings tab --- + def _create_settings_tab(self): widget = QWidget() layout = QVBoxLayout(widget) - # Game path path_group = QGroupBox("Game Path") path_layout = QVBoxLayout(path_group) path_layout.addWidget(QLabel("Cyberpunk 2077 installation directory:")) @@ -539,7 +1022,6 @@ class ModManagerWindow(QMainWindow): path_layout.addWidget(save_path_btn) layout.addWidget(path_group) - # Load order settings order_group = QGroupBox("Load Order") order_layout = QVBoxLayout(order_group) self.auto_prefix_check = QCheckBox("Auto-assign numeric prefixes (000_, 010_, 020_)") @@ -548,14 +1030,33 @@ class ModManagerWindow(QMainWindow): 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 --- + # =========================================================================== + # 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() @@ -567,8 +1068,12 @@ class ModManagerWindow(QMainWindow): 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): - """Update the mod list tree widget.""" self.mod_list.blockSignals(True) self.mod_list.clear() @@ -580,19 +1085,53 @@ class ModManagerWindow(QMainWindow): 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")) - # Store mod reference 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": "🗂️", + } + + for category, files in self.script_mods.items(): + if not files: + continue + + parent = QTreeWidgetItem() + parent.setText(0, f"{icons.get(category, '📋')} {category.upper()}") + parent.setText(2, f"{len(files)} items") + parent.setForeground(0, QColor("#00ff9f")) + + for f in files: + child = QTreeWidgetItem() + child.setText(1, f) + child.setText(2, "✅ Active") + parent.addChild(child) + + self.script_tree.addTopLevelItem(parent) + parent.setExpanded(True) + def _on_mod_checkbox_changed(self, item, column): - """Handle checkbox toggle on mod list.""" if column != 0: return mod = item.data(0, Qt.ItemDataRole.UserRole) @@ -610,7 +1149,6 @@ class ModManagerWindow(QMainWindow): self.status.showMessage(f"{'Enabled' if mod.enabled else 'Disabled'}: {mod.name}") def _mod_context_menu(self, pos): - """Right-click context menu on mod list.""" item = self.mod_list.itemAt(pos) if not item: return @@ -624,6 +1162,13 @@ class ModManagerWindow(QMainWindow): 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) @@ -640,8 +1185,30 @@ class ModManagerWindow(QMainWindow): 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): - """Move a mod up or down in load order.""" idx = self.mod_list.indexOfTopLevelItem(item) if up and idx > 0: self.mod_list.takeTopLevelItem(idx) @@ -651,12 +1218,9 @@ class ModManagerWindow(QMainWindow): self.mod_list.takeTopLevelItem(idx) self.mod_list.insertTopLevelItem(idx + 1, item) self.mod_list.setCurrentItem(item) - - # Re-apply prefixes self._apply_order_from_list() def _apply_order_from_list(self): - """Rename mods based on current list order.""" order = [] for i in range(self.mod_list.topLevelItemCount()): item = self.mod_list.topLevelItem(i) @@ -667,7 +1231,6 @@ class ModManagerWindow(QMainWindow): self.refresh_mods() def _delete_mod(self, item): - """Delete a mod file.""" 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!", @@ -677,32 +1240,39 @@ class ModManagerWindow(QMainWindow): 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 a mod file from file dialog.""" + """Add mod files from file dialog — supports .archive, .zip, .7z, .rar, .yaml, .reds""" files, _ = QFileDialog.getOpenFileNames(self, "Select mod files", - "", "Archive files (*.archive *.7z *.zip);;All files (*.*)") - if not files: - return - - mod_dir = self.get_mod_dir() - mod_dir.mkdir(parents=True, exist_ok=True) - + "", "Mod files (*.archive *.zip *.7z *.rar *.yaml *.yml *.reds *.xl);;All files (*.*)") for f in files: - src = Path(f) - dst = mod_dir / src.name - if dst.exists(): - reply = QMessageBox.question(self, "Overwrite?", - f"'{src.name}' already exists. Overwrite?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) - if reply != QMessageBox.StandardButton.Yes: - continue - shutil.copy2(src, dst) - self.status.showMessage(f"Added: {src.name}") - - self.refresh_mods() + 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): - """Enable all disabled mods.""" for mod in self.mods: if not mod.enabled: toggle_mod(mod) @@ -710,7 +1280,6 @@ class ModManagerWindow(QMainWindow): self.status.showMessage("All mods enabled") def disable_all_mods(self): - """Disable all enabled mods.""" for mod in self.mods: if mod.enabled: toggle_mod(mod) @@ -718,19 +1287,17 @@ class ModManagerWindow(QMainWindow): self.status.showMessage("All mods disabled") def auto_sort_mods(self): - """Auto-sort mods by current list order and apply numeric prefixes.""" self._apply_order_from_list() self.status.showMessage("Load order applied") def browse_game_path(self): - """Browse for game directory.""" 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_mods() + self.refresh_all() def browse_game_path_settings(self): path = QFileDialog.getExistingDirectory(self, "Select Cyberpunk 2077 directory", @@ -745,17 +1312,11 @@ class ModManagerWindow(QMainWindow): self.game_path_label.setText(self.config["game_path"]) self.status.showMessage("Settings saved") - # --- Conflicts --- + # --- Conflict scanning --- - def scan_conflicts(self): - """Scan for file conflicts between mods.""" + def scan_conflicts_quick(self): + """Quick conflict scan based on mod names only.""" self.conflict_tree.clear() - - # Group mods by name pattern (similar names might conflict) - # For now, just check if multiple mods have similar file patterns - # A real implementation would parse .archive files and compare internal file paths - - # Simple check: mods with same base name but different prefix name_map = {} for mod in self.mods: if not mod.enabled: @@ -765,31 +1326,61 @@ class ModManagerWindow(QMainWindow): name_map[base] = [] name_map[base].append(mod) - conflicts_found = False for base, mod_list in name_map.items(): if len(mod_list) > 1: - conflicts_found = True 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(3, f"{mod_list[i].sort_key} > {mod_list[j].sort_key}") + item.setText(4, "2") item.setForeground(3, QColor("#ff9500")) self.conflict_tree.addTopLevelItem(item) - if not conflicts_found: + 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)) - self.status.showMessage(f"Conflict scan complete: {self.conflict_tree.topLevelItemCount()} items") + 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): - """Refresh the profile combo box and list.""" PROFILES_DIR.mkdir(parents=True, exist_ok=True) profiles = [f.stem for f in PROFILES_DIR.glob("*.json")] if not profiles: @@ -803,13 +1394,11 @@ class ModManagerWindow(QMainWindow): self.profile_combo.setCurrentText(self.current_profile) self.profile_combo.blockSignals(False) - # Also update profile list if hasattr(self, "profile_list"): self.profile_list.clear() self.profile_list.addItems(profiles) def _save_profile_data(self, name): - """Save current mod state as a profile.""" 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], @@ -819,7 +1408,6 @@ class ModManagerWindow(QMainWindow): json.dump(data, f, indent=2) def save_profile(self): - """Save current configuration as a profile.""" name = self.profile_name_input.text().strip() if not name: QMessageBox.warning(self, "No name", "Please enter a profile name.") @@ -829,7 +1417,6 @@ class ModManagerWindow(QMainWindow): self.status.showMessage(f"Profile saved: {name}") def load_profile(self, name): - """Load a profile — enable/disable mods accordingly.""" if not name: return path = PROFILES_DIR / f"{name}.json" @@ -839,11 +1426,9 @@ class ModManagerWindow(QMainWindow): with open(path, "r") as f: data = json.load(f) - # Build a map of desired states desired = {m["name"]: m["enabled"] for m in data.get("mods", [])} desired_order = [m["name"] for m in data.get("mods", [])] - # Apply enable/disable for mod in self.mods: should_enable = desired.get(mod.name, None) if should_enable is True and not mod.enabled: @@ -851,7 +1436,6 @@ class ModManagerWindow(QMainWindow): elif should_enable is False and mod.enabled: toggle_mod(mod) - # Apply load order if desired_order: set_load_order(self.mods, desired_order) @@ -876,19 +1460,17 @@ class ModManagerWindow(QMainWindow): self._refresh_profiles() self.status.showMessage(f"Profile deleted: {name}") - # --- Nexus Mods --- + # --- Nexus --- def save_nexus_key(self): key = self.nexus_key_input.text().strip() self.config["nexus_api_key"] = key save_config(self.config) - # Also save to file for protocol handler NEXUS_API_KEY_FILE.write_text(key) NEXUS_API_KEY_FILE.chmod(0o600) self.status.showMessage("Nexus API key saved") def download_nxm(self): - """Download a mod from an nxm:// URL.""" url = self.nxm_url_input.text().strip() if not url: return @@ -896,14 +1478,13 @@ class ModManagerWindow(QMainWindow): 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(3) # Switch to Nexus tab + self.tabs.setCurrentIndex(4) return self.nexus_progress.setVisible(True) - self.nexus_progress.setRange(0, 0) # Indeterminate + self.nexus_progress.setRange(0, 0) self.nexus_status.setText("Downloading...") - # Download in a separate thread import threading def do_download(): download_dir = Path.home() / "Downloads" / "nexus-mods" @@ -911,30 +1492,31 @@ class ModManagerWindow(QMainWindow): filepath, error = download_nxm_mod(url, api_key, download_dir) - # Update UI from main thread def on_done(): self.nexus_progress.setVisible(False) if error: self.nexus_status.setText(f"❌ {error}") - else: - self.nexus_status.setText(f"✅ Downloaded: {filepath}") - # Offer to install - reply = QMessageBox.question(self, "Install Mod?", - f"Downloaded {filepath.name}\n\nInstall to mod directory?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) - if reply == QMessageBox.StandardButton.Yes: - mod_dir = self.get_mod_dir() - shutil.copy2(filepath, mod_dir / filepath.name) - self.refresh_mods() - self.status.showMessage(f"Installed: {filepath.name}") + 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): - """Register nxm:// protocol handler for this app.""" - # Create .desktop file for nxm:// protocol desktop_dir = Path.home() / ".local" / "share" / "applications" desktop_dir.mkdir(parents=True, exist_ok=True) @@ -950,16 +1532,13 @@ MimeType=x-scheme-handler/nxm; NoDisplay=true """ desktop_file.write_text(desktop_content) - - # Update MIME database subprocess.run(["update-desktop-database", str(desktop_dir)], capture_output=True) - self.status.showMessage("nxm:// protocol registered! Browser will now redirect downloads here.") + 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.\n\n" - "You may need to set it as default in your browser settings.") + "the mod will be sent to Neon Mod Manager.") def main(): @@ -968,7 +1547,6 @@ def main(): # Check for --nxm argument (protocol handler invocation) if len(sys.argv) > 2 and sys.argv[1] == "--nxm": nxm_url = sys.argv[2] - # Quick download mode — no GUI config = load_config() api_key = config.get("nexus_api_key", "") if not api_key and NEXUS_API_KEY_FILE.exists(): @@ -985,15 +1563,16 @@ def main(): 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"Downloaded: {filepath}") - - # Auto-install to mod directory - mod_dir = Path(config["game_path"]) / config["mod_dir"] - if mod_dir.exists(): - shutil.copy2(filepath, mod_dir / filepath.name) - print(f"Installed to: {mod_dir}") - sys.exit(0) + print(f"File saved at: {filepath} (manual install needed)") + sys.exit(0) # Normal GUI mode window = ModManagerWindow()