From 96f24a8b25a76e7ffbe4f696de0b599a6ff4d1fe Mon Sep 17 00:00:00 2001 From: arch_agent Date: Mon, 13 Jul 2026 10:24:16 +0200 Subject: [PATCH] =?UTF-8?q?Neon=20Mod=20Manager=20v0.1=20=E2=80=94=20Cyber?= =?UTF-8?q?punk=202077=20mod=20manager=20for=20Linux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Enable/disable mods (.archive <-> .archive.disabled) - Load order via filename prefixes (000_, 010_, 020_) - Conflict detection - Profile save/load - Nexus Mods nxm:// protocol handler - PyQt6 GUI, Wayland compatible - No REDmod, no FUSE, no Wine prefix --- README.md | 150 ++++++++ mod_manager.py | 1005 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1155 insertions(+) create mode 100644 README.md create mode 100644 mod_manager.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..1dd7c31 --- /dev/null +++ b/README.md @@ -0,0 +1,150 @@ +# 🎮 Neon Mod Manager + +A Cyberpunk 2077 mod manager for Linux. Built with PyQt6. + +## Why? + +Because existing mod managers (Mod Organizer 2 / Fluorine) have problems on Linux: +- **REDmod deployment fails** with permission errors on FUSE mounts +- **FUSE VFS** blocks file access and causes crashes +- **Wine prefix** management is fragile and complex + +Neon Mod Manager takes a simpler approach: **directly manage files in `archive/pc/mod/`**. No VFS, no staging, no REDmod, no FUSE. + +## Features + +| Feature | How it works | +|---|---| +| **Enable/Disable Mods** | Renames `.archive` ↔ `.archive.disabled` — no staging, no VFS | +| **Load Order** | Numeric filename prefixes (`000_`, `010_`, `020_`...) — higher number = loaded later = overrides earlier | +| **Add Mods** | Copy `.archive` files directly to mod directory | +| **Conflict Detection** | Scans for duplicate file patterns across enabled mods | +| **Profiles** | Save/load mod configurations (e.g. "NSFW", "Vanilla+", "Minimal") | +| **Nexus Mods Integration** | Paste `nxm://` URLs to download, or register as browser protocol handler | +| **Drag & Drop** | Drop `.archive` files onto the window to install | +| **No Wine needed** | Pure Python + Qt, runs natively on Linux | + +## Installation + +### Prerequisites + +```bash +# Arch Linux / EndeavourOS +sudo pacman -S python-pyqt6 + +# Ubuntu / Debian +sudo apt install python3-pyqt6 + +# Fedora +sudo dnf install python3-pyqt6 + +# Or via pip +pip install PyQt6 +``` + +### Download + +```bash +git clone https://gitea.die-heimatlosen.eu/arch_agent/neon-mod-manager.git +cd neon-mod-manager +``` + +### Run + +```bash +# Wayland (most common on modern Linux) +QT_QPA_PLATFORM=wayland python3 mod_manager.py + +# X11 +python3 mod_manager.py +``` + +## Usage + +### Adding Mods + +1. Click **➕ Add Mod** and select `.archive` files, **OR** +2. Drag & drop `.archive` files onto the window, **OR** +3. Download from Nexus Mods (see below) + +### Load Order + +- Mods are sorted by numeric prefix: `000_` loads first, `999_` loads last +- Later mods **override** earlier ones (same texture/file = last one wins) +- Click **🔢 Auto-Sort** to auto-assign prefixes based on current list order +- Right-click a mod → **Move Up/Down** to reorder + +### Enabling / Disabling + +- **Checkbox** in the mod list to toggle on/off +- **Enable All / Disable All** buttons for bulk actions +- Disabled mods get `.archive.disabled` extension (not deleted!) + +### Profiles + +1. Set up your mods the way you want them +2. Go to **💾 Profile** tab +3. Enter a name (e.g. "NSFW Full", "Vanilla+") and click **Save Profile** +4. Switch between profiles anytime from the dropdown at the top + +### Nexus Mods Integration + +1. Get your API key from [Nexus Mods](https://www.nexusmods.com/users/myaccount?tab=api%20keys) +2. Go to **🌐 Nexus Mods** tab and paste your key +3. **Option A:** Paste `nxm://` URLs manually and click Download +4. **Option B:** Click **🔗 Register nxm:// Protocol Handler** — then clicking "Download" on Nexus Mods will automatically send the mod to Neon Mod Manager + +## How It Works (Technical) + +### No VFS / No Staging + +Unlike MO2/Fluorine which use a virtual filesystem (FUSE on Linux), Neon Mod Manager copies files directly to the game's `archive/pc/mod/` directory. This avoids: +- FUSE mount permission issues +- REDmod deployment failures +- Wine prefix corruption + +### Load Order via Filename + +Cyberpunk 2077 loads `.archive` files in alphabetical order. Neon Mod Manager uses numeric prefixes to control this: +``` +000_MyBodyMod.archive → loads first +010_MyTextureMod.archive → loads second +020_MyAdMod.archive → loads third (overrides earlier) +``` + +### Enable/Disable + +Instead of moving files in/out of a staging directory, mods are renamed: +- **Enable:** `MyMod.archive.disabled` → `MyMod.archive` +- **Disable:** `MyMod.archive` → `MyMod.archive.disabled` + +The game ignores `.disabled` files completely. + +## Configuration + +Config is stored at `~/.config/neon-mod-manager/config.json`: +```json +{ + "game_path": "/mnt/Spiele/Heroic/Cyberpunk 2077", + "mod_dir": "archive/pc/mod", + "last_profile": "Default", + "nexus_api_key": "", + "auto_prefix": true +} +``` + +Profiles are stored at `~/.config/neon-mod-manager/profiles/*.json`. + +## License + +MIT — do whatever you want. + +## Roadmap + +- [ ] Archive file parsing (read internal file list for real conflict detection) +- [ ] Nexus Mods search (browse mods without leaving the app) +- [ ] Mod update detection (compare versions) +- [ ] REDmod script mod support (r6/tweaks) +- [ ] Auto-extract `.7z` / `.zip` downloads +- [ ] Mod categories and tags +- [ ] Dark/light theme switcher \ No newline at end of file diff --git a/mod_manager.py b/mod_manager.py new file mode 100644 index 0000000..701e4de --- /dev/null +++ b/mod_manager.py @@ -0,0 +1,1005 @@ +#!/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() \ No newline at end of file