#!/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 - Detects file conflicts between .archive files - 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 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 ) 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" 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 --- # CP2077 .archive files are REDengine RAD archives # Header: "RDAR" magic, version, file count, file table offset def read_archive_info(path): """Read basic info from a .archive file: file count, size, list of internal file paths.""" try: with open(path, "rb") as f: magic = f.read(4) if magic != b"RDAR": return {"valid": False, "error": "Not a RDAR archive", "files": []} version = struct.unpack(" 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): """Sort key based on prefix.""" name = self.path.name if name[:3].isdigit(): return name[:3] return "999" # Unprefixed mods go last 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) mod.info = read_archive_info(f) 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: # 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 mod.enabled = True 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. """ 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:] 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. Format: nxm://game/mods/mod_id/files/file_id?key=...&expires=... """ 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" # 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" 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"] # Download the file 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.setWindowTitle(f"{APP_NAME} v{APP_VERSION}") self.setMinimumSize(900, 600) self.setStyleSheet(self._get_style()) # Create central widget with tabs central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) # Top bar: game path + profile selector top_bar = self._create_top_bar() layout.addLayout(top_bar) # Tab widget self.tabs = QTabWidget() self.tabs.addTab(self._create_mods_tab(), "📦 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 mods on startup QTimer.singleShot(100, self.refresh_mods) 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; } """ 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;") layout.addWidget(self.game_path_label, 1) browse_btn = QPushButton("📂 Browse") 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) self._refresh_profiles() 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) layout.addWidget(refresh_btn) return layout 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.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.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) # Right: mod details 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) details_layout.addWidget(self.details_label) splitter.addWidget(details_group) splitter.setSizes([600, 300]) layout.addWidget(splitter, 1) # Bottom buttons btn_layout = QHBoxLayout() add_btn = QPushButton("➕ Add Mod") 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 def _create_conflicts_tab(self): widget = QWidget() layout = QVBoxLayout(widget) layout.addWidget(QLabel("⚠️ Detected file conflicts between mods:")) self.conflict_tree = QTreeWidget() self.conflict_tree.setHeaderLabels(["File", "Mod 1", "Mod 2", "Resolution"]) self.conflict_tree.setAlternatingRowColors(True) layout.addWidget(self.conflict_tree, 1) scan_btn = QPushButton("🔍 Scan for Conflicts") scan_btn.clicked.connect(self.scan_conflicts) layout.addWidget(scan_btn) return widget 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() 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) # 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 "")) 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 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 URL 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") 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) # nxm:// protocol handler info 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 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:")) 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) # 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_)") 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) layout.addStretch() return widget # --- Actions --- def get_mod_dir(self): return Path(self.config["game_path"]) / self.config["mod_dir"] 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 _update_mod_list(self): """Update the mod list tree widget.""" 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") 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 _on_mod_checkbox_changed(self, item, column): """Handle checkbox toggle on mod list.""" 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): """Right-click context menu on mod list.""" 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() 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 _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) 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) # 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) 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): """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!", 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 add_mod(self): """Add a mod file from file dialog.""" 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) 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() def enable_all_mods(self): """Enable all disabled mods.""" 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): """Disable all enabled mods.""" 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): """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() 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") # --- Conflicts --- def scan_conflicts(self): """Scan for file conflicts between mods.""" 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: continue base = mod.name.lower() if base not in name_map: 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.setForeground(3, QColor("#ff9500")) self.conflict_tree.addTopLevelItem(item) if not conflicts_found: item = QTreeWidgetItem() item.setText(0, "No conflicts detected ✅") self.conflict_tree.addTopLevelItem(item) self.status.showMessage(f"Conflict scan complete: {self.conflict_tree.topLevelItemCount()} items") # --- 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: 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) # 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], "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): """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.") return self._save_profile_data(name) self._refresh_profiles() 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" if not path.exists(): return 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: toggle_mod(mod) elif should_enable is False and mod.enabled: toggle_mod(mod) # Apply load order 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 Mods --- 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 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 return self.nexus_progress.setVisible(True) self.nexus_progress.setRange(0, 0) # Indeterminate self.nexus_status.setText("Downloading...") # Download in a separate thread 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) # 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}") 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) 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) # 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.") 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.") 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] # 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(): 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) 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) # Normal GUI mode window = ModManagerWindow() window.show() sys.exit(app.exec()) if __name__ == "__main__": main()