Greasy Fork

Greasy Fork is available in English.

Nexus No Wait ++

Download from nexusmods.com without wait (Manual/Vortex/MO2/NMM), Tweaked with extra features.

当前为 2025-09-04 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Nexus No Wait ++
// @description Download from nexusmods.com without wait (Manual/Vortex/MO2/NMM), Tweaked with extra features.
// @namespace   NexusNoWaitPlusPlus
// @author      Torkelicious
// @version     1.1.13
// @include     https://*.nexusmods.com/*
// @run-at      document-idle
// @iconURL     https://raw.githubusercontent.com/torkelicious/nexus-no-wait-pp/refs/heads/main/icon.png
// @icon        https://raw.githubusercontent.com/torkelicious/nexus-no-wait-pp/refs/heads/main/icon.png
// @grant       GM_xmlhttpRequest
// @grant       GM.xmlHttpRequest
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_deleteValue
// @grant       GM_info
// @connect     nexusmods.com
// @connect     *.nexusmods.com
// @connect     raw.githubusercontent.com
// @license     GPL-3.0-or-later
// ==/UserScript==

/* global GM_getValue, GM_setValue, GM_deleteValue, GM_xmlhttpRequest, GM.xmlHttpRequest, GM_info GM */

(function () {
    const DEFAULT_CONFIG = {
        autoCloseTab: true,
        skipRequirements: true,
        showAlerts: true,
        refreshOnError: false,
        requestTimeout: 30000,
        closeTabTime: 1000,
        debug: false,
        playErrorSound: true,
    };

    const RECENT_HANDLE_MS = 600;

    // logging helpers
    function debugLog(...args) {
        try {
            const prefix = "[Nexus No Wait ++]";
            (console.debug || console.log).call(
                console,
                prefix,
                ...args,
                "Page:",
                window.location.href,
            );
        } catch (e) {}
    }
    function infoLog(...args) {
        try {
            (console.info || console.log).call(
                console,
                "[Nexus No Wait ++]",
                ...args,
                "Page:",
                window.location.href,
            );
        } catch (e) {}
    }
    function errorLog(...args) {
        try {
            (console.error || console.log).call(
                console,
                "[Nexus No Wait ++]",
                ...args,
                "Page:",
                window.location.href,
            );
        } catch (e) {}
    }

    // === Settings management ===
    function validateSettings(settings) {
        if (!settings || typeof settings !== "object")
            return { ...DEFAULT_CONFIG };
        const validated = { ...settings };
        for (const [key, defaultValue] of Object.entries(DEFAULT_CONFIG)) {
            if (typeof validated[key] !== typeof defaultValue) {
                validated[key] = defaultValue;
            }
        }
        return validated;
    }
    function loadSettings() {
        try {
            const saved = GM_getValue("nexusNoWaitConfig", null);
            let parsed;
            if (!saved) parsed = DEFAULT_CONFIG;
            else if (typeof saved === "string") {
                try {
                    parsed = JSON.parse(saved);
                } catch (e) {
                    parsed = DEFAULT_CONFIG;
                }
            } else parsed = saved;
            const validated = validateSettings(parsed);
            debugLog("Loaded settings", validated);
            return validated;
        } catch (e) {
            debugLog("Failed loading settings:", e);
            return { ...DEFAULT_CONFIG };
        }
    }
    function saveSettings(settings) {
        try {
            try {
                GM_setValue("nexusNoWaitConfig", settings);
            } catch (_) {
                GM_setValue("nexusNoWaitConfig", JSON.stringify(settings));
            }
            debugLog("Saved settings");
        } catch (e) {
            console.error("Failed to save settings:", e);
        }
    }
    const config = Object.assign({}, DEFAULT_CONFIG, loadSettings());

    // Error sound
    const errorSound = new Audio(
        "https://github.com/torkelicious/nexus-no-wait-pp/raw/refs/heads/main/errorsound.mp3",
    );
    try {
        errorSound.load();
    } catch (e) {
        debugLog("Could not preload sound", e);
    }
    function playErrorSound() {
        if (!config.playErrorSound) return;
        errorSound.play().catch((e) => debugLog("Error playing sound:", e));
    }

    // Error/log helpers used by UI
    function logMessage(message, showAlert = false, isDebug = false) {
        if (isDebug) {
            debugLog(message);
            if (config.debug) alert("[Nexus No Wait ++] (Debug):\n" + message);
            return;
        }
        playErrorSound();
        errorLog(message);
        if (showAlert && config.showAlerts)
            alert("[Nexus No Wait ++]\n" + message);
        if (config.refreshOnError) location.reload();
    }

    // Skip requirements tab
    if (
        window.location.href.includes("tab=requirements") &&
        config.skipRequirements
    ) {
        const newUrl = window.location.href.replace(
            "tab=requirements",
            "tab=files",
        );
        infoLog("Skipping requirements tab -> files", {
            from: window.location.href,
            to: newUrl,
        });
        window.location.replace(newUrl);
        return;
    }

    // === AJAX wrapper ===
    // Use Greasemonkey GM.xmlHttpRequest when present,
    // otherwise GM_xmlhttpRequest fallback
    let ajaxRequestRaw;
    if (typeof GM !== "undefined" && typeof GM.xmlHttpRequest === "function") {
        ajaxRequestRaw = GM.xmlHttpRequest;
    } else if (typeof GM_xmlhttpRequest !== "undefined") {
        ajaxRequestRaw = GM_xmlhttpRequest;
    }

    function ajaxRequest(obj) {
        if (!ajaxRequestRaw) {
            logMessage(
                "AJAX not available in this environment (your userscript manager may not support this!)",
                true,
            );
            return;
        }
        debugLog("ajaxRequest", {
            method: obj.type,
            url: obj.url,
            dataPreview:
                typeof obj.data === "string"
                    ? obj.data.slice(0, 200)
                    : obj.data,
        });
        ajaxRequestRaw({
            method: obj.type,
            url: obj.url,
            data: obj.data,
            headers: obj.headers,
            timeout: config.requestTimeout,
            onload(response) {
                const body =
                    typeof response.response !== "undefined"
                        ? response.response
                        : response.responseText;
                debugLog("ajax response", {
                    status: response.status,
                    length: body ? body.length || 0 : 0,
                    preview: body ? String(body).slice(0, 500) : "",
                });
                if (response.status >= 200 && response.status < 300)
                    obj.success(body);
                else obj.error(response);
            },
            onerror(response) {
                obj.error(response);
            },
            ontimeout(response) {
                obj.error(response);
            },
        });
    }

    // === Button UI helpers ===
    function btnError(button, error) {
        try {
            if (button && button.style) button.style.color = "red";
            let message = "Download failed: ";
            if (error) {
                if (typeof error === "string") message += error;
                else if (error.message) message += error.message;
                else if (error.status)
                    message += `HTTP ${error.status} ${error.statusText || ""}`;
                else if (typeof error.responseText === "string")
                    message += error.responseText.slice(0, 300);
                else message += JSON.stringify(error);
            } else message += "Unknown error";
            if (button && "innerText" in button)
                button.innerText = "ERROR: " + message;
            errorLog(message);
            logMessage(message, true);
        } catch (e) {
            logMessage(
                "Unknown error while handling button error: " + e.message,
                true,
            );
        }
    }
    function btnSuccess(button) {
        if (button && button.style) button.style.color = "green";
        if (button && "innerText" in button) button.innerText = "Downloading!";
        infoLog("Download started (UI updated).", { button });
    }
    function btnWait(button) {
        if (button && button.style) button.style.color = "yellow";
        if (button && "innerText" in button) button.innerText = "Wait...";
        debugLog("Set button to wait", { button });
    }

    function closeOnDL() {
        if (config.autoCloseTab) {
            debugLog("Scheduling close", { delay: config.closeTabTime });
            setTimeout(() => {
                debugLog("Closing window");
                window.close();
            }, config.closeTabTime);
        }
    }

    // Primary file id extractor
    function getPrimaryFileId() {
        try {
            // action-nmm link (vortex)
            const vortexAction = document.querySelector(
                '#action-nmm a[href*="file_id="]',
            );
            if (vortexAction) {
                const fid = new URL(
                    vortexAction.href,
                    location.href,
                ).searchParams.get("file_id");
                if (fid) {
                    debugLog("getPrimaryFileId found via action-nmm", fid);
                    return fid;
                }
            }

            // any file link with file_id
            const anyFileLink = document.querySelector('a[href*="file_id="]');
            if (anyFileLink) {
                const fid = new URL(
                    anyFileLink.href,
                    location.href,
                ).searchParams.get("file_id");
                if (fid) {
                    debugLog("getPrimaryFileId found via any file link", fid);
                    return fid;
                }
            }

            // file-expander-header[data-id]
            const header = document.querySelector(
                ".file-expander-header[data-id]",
            );
            if (header) {
                const fid = header.getAttribute("data-id");
                if (fid) {
                    debugLog("getPrimaryFileId found via header", fid);
                    return fid;
                }
            }

            // fallback data-fileid / data-id attributes
            const dataFile = document.querySelector("[data-fileid], [data-id]");
            if (dataFile) {
                const fid =
                    dataFile.getAttribute("data-fileid") ||
                    dataFile.getAttribute("data-id") ||
                    (dataFile.dataset && dataFile.dataset.fileid);
                if (fid) {
                    debugLog(
                        "getPrimaryFileId found via data-fileid/data-id",
                        fid,
                    );
                    return fid;
                }
            }
        } catch (e) {
            debugLog("getPrimaryFileId error", e);
        }
        debugLog("getPrimaryFileId: none found");
        return null;
    }

    // === MAIN DOWNLOAD HANDLER ===
    function clickListener(event) {
        console.groupCollapsed("[NNW++] clickListener");

        // duplicate-handling guard
        try {
            if (this && this.dataset && this.dataset.nnwHandled === "1") {
                debugLog("Element recently handled, skipping duplicate");
                console.groupEnd();
                return;
            }
            try {
                if (this && this.dataset) this.dataset.nnwHandled = "1";
            } catch (_) {}
            try {
                if (this)
                    setTimeout(() => {
                        try {
                            if (this && this.dataset)
                                delete this.dataset.nnwHandled;
                        } catch (_) {}
                    }, RECENT_HANDLE_MS);
            } catch (_) {}
            if (event) {
                try {
                    event.__nnw_nofollow = true;
                } catch (_) {}
            }
        } catch (e) {
            debugLog("Guard error", e);
        }

        try {
            debugLog("clickListener start", {
                target: this,
                href: (this && this.href) || window.location.href,
            });

            const selfIsElement = this && this.tagName;
            const href = (selfIsElement && this.href) || window.location.href;
            const params = new URL(href, location.href).searchParams;

            if (params.get("file_id")) {
                infoLog("file link clicked", { href });
                let button = event;
                if (selfIsElement && this.href) {
                    button = this;
                    try {
                        if (event && typeof event.preventDefault === "function")
                            event.preventDefault();
                    } catch (_) {}
                }
                btnWait(button);

                const section = document.getElementById("section");
                const gameId = section
                    ? section.dataset.gameId
                    : this.current_game_id;
                let fileId = params.get("file_id") || params.get("id");

                // NMM
                if (params.get("nmm")) {
                    infoLog(
                        "nmm parameter present -> performing NMM GET extraction",
                        {
                            href,
                        },
                    );
                    ajaxRequest({
                        type: "GET",
                        url: href,
                        headers: {
                            Origin: "https://www.nexusmods.com",
                            Referer: document.location.href,
                            "Sec-Fetch-Site": "same-origin",
                            "X-Requested-With": "XMLHttpRequest",
                        },
                        success(data) {
                            debugLog(
                                "NMM GET response preview:",
                                String(data).slice(0, 1200),
                            );
                            if (!data) {
                                btnError(button, {
                                    message: "Empty response from server",
                                });
                                console.groupEnd();
                                return;
                            }
                            try {
                                const doc = new DOMParser().parseFromString(
                                    String(data),
                                    "text/html",
                                );
                                const slow =
                                    doc.getElementById("slowDownloadButton") ||
                                    doc.querySelector("[data-download-url]");
                                if (slow) {
                                    const downloadUrl =
                                        slow.getAttribute(
                                            "data-download-url",
                                        ) ||
                                        (slow.dataset &&
                                            slow.dataset.downloadUrl) ||
                                        slow.href;
                                    if (downloadUrl) {
                                        infoLog(
                                            "Found data-download-url (NMM)",
                                            downloadUrl,
                                        );
                                        btnSuccess(button);
                                        try {
                                            document.location.href =
                                                downloadUrl;
                                        } catch (_) {
                                            window.location = downloadUrl;
                                        }
                                        console.groupEnd();
                                        return;
                                    } else {
                                        btnError(button, {
                                            message:
                                                "NMM page contained slowDownloadButton but no data-download-url attribute",
                                        });
                                        console.groupEnd();
                                        return;
                                    }
                                }
                                let parsed = null;
                                try {
                                    parsed =
                                        typeof data === "string"
                                            ? JSON.parse(data)
                                            : data;
                                } catch (e) {
                                    parsed = null;
                                }
                                if (parsed && parsed.url) {
                                    infoLog(
                                        "Found parsed.url in NMM GET response",
                                        parsed.url,
                                    );
                                    btnSuccess(button);
                                    try {
                                        document.location.href = parsed.url;
                                    } catch (_) {
                                        window.location = parsed.url;
                                    }

                                    console.groupEnd();
                                    return;
                                }
                                btnError(button, {
                                    message:
                                        "Could not find NMM download URL in response\n\n(Are you logged in?)",
                                });
                                console.groupEnd();
                            } catch (e) {
                                btnError(button, e);
                                console.groupEnd();
                            }
                        },
                        error(xhr) {
                            btnError(button, xhr);
                            console.groupEnd();
                        },
                    });
                    return;
                }

                // POST ---> GenerateDownloadUrl
                const postOptions = {
                    type: "POST",
                    url: "/Core/Libs/Common/Managers/Downloads?GenerateDownloadUrl",
                    data: "fid=" + fileId + "&game_id=" + gameId,
                    headers: {
                        Origin: "https://www.nexusmods.com",
                        Referer: href,
                        "Sec-Fetch-Site": "same-origin",
                        "X-Requested-With": "XMLHttpRequest",
                        "Content-Type":
                            "application/x-www-form-urlencoded; charset=UTF-8",
                    },
                    success(data) {
                        debugLog(
                            "file link POST response preview:",
                            String(data).slice(0, 1200),
                        );
                        if (!data) {
                            btnError(button, {
                                message: "Empty response from server",
                            });
                            console.groupEnd();
                            return;
                        }
                        let parsed = null;
                        try {
                            parsed =
                                typeof data === "string"
                                    ? JSON.parse(data)
                                    : data;
                        } catch (e) {
                            btnError(button, {
                                message: "Server response was not JSON",
                            });
                            console.groupEnd();
                            return;
                        }
                        if (parsed && parsed.url) {
                            infoLog("Using parsed.url from POST", parsed.url);
                            btnSuccess(button);
                            try {
                                document.location.href = parsed.url;
                            } catch (_) {
                                window.location = parsed.url;
                            }
                            console.groupEnd();
                            return;
                        }
                        btnError(button, {
                            message:
                                "No download URL returned from server\n\n(Are you logged in?)",
                        });
                        console.groupEnd();
                    },
                    error(xhr) {
                        btnError(button, xhr);
                        console.groupEnd();
                    },
                };
                ajaxRequest(postOptions);

                const popup = selfIsElement ? this.parentNode : null;
                if (popup && popup.classList.contains("popup")) {
                    popup.getElementsByTagName("button")[0]?.click();
                    const popupButton = document.getElementById(
                        "popup" + fileId,
                    );
                    if (popupButton) {
                        btnSuccess(popupButton);
                    }
                }
                return;
            }

            // mirror ModRequirementsPopUp id for element for later lookup
            if (/ModRequirementsPopUp/.test(href)) {
                const fileId = new URL(href, location.href).searchParams.get(
                    "id",
                );
                if (fileId && selfIsElement) {
                    this.setAttribute("id", "popup" + fileId);
                }
            }
        } catch (err) {
            errorLog("Unhandled error in clickListener", err);
        } finally {
            try {
                if (this && this.dataset) delete this.dataset.nnwProcessing;
            } catch (_) {}
            console.groupEnd();
        }
    }

    // === Event delegation ===
    function delegatedClickHandler(event) {
        try {
            const selector = [
                "#slowDownloadButton",
                "#action-manual a",
                "#action-nmm a",
                'a[href*="file_id="]',
                "a.btn",
            ].join(",");
            const el =
                event.target && event.target.closest
                    ? event.target.closest(selector)
                    : null;
            if (!el) return;

            if (event && event.__nnw_nofollow) {
                debugLog(
                    "delegatedClickHandler: event already handled, skipping",
                );
                return;
            }
            clickListener.call(el, event);
        } catch (e) {
            debugLog("delegatedClickHandler error", e);
        }
    }

    // Autostart when file_id present in URL
    function autoStartFileLink() {
        if (/file_id=/.test(window.location.href)) {
            debugLog("autoStartFileLink detected file_id in URL");
            try {
                const slowButton =
                    document.getElementById("slowDownloadButton");
                if (slowButton) clickListener.call(slowButton, null);
                closeOnDL();
            } catch (e) {
                debugLog("autoStartFileLink error", e);
            }
        }
    }

    function autoClickRequiredFileDownload() {
        let popupClicked = false;
        const observer = new MutationObserver(() => {
            const popup = document.querySelector(".popup-mod-requirements");
            if (popup) {
                if (!popupClicked) {
                    const downloadButton = popup.querySelector("a.btn");
                    const exitPopupBtn = popup.querySelector(".mfp-close");
                    if (downloadButton) {
                        infoLog(
                            "Requirements popup detected, auto-clicking download.",
                        );
                        popupClicked = true;
                        downloadButton.click();
                        exitPopupBtn?.click();
                    }
                }
            } else {
                if (popupClicked) {
                    debugLog(
                        "Requirements popup closed, resetting click flag.",
                    );
                    popupClicked = false;
                }
            }
        });

        observer.observe(document.body, { childList: true, subtree: true });
    }

    // Archived files: inject nmm=1 and Manual buttons
    const ICON_PATHS = {
        nmm: "https://www.nexusmods.com/assets/images/icons/icons.svg#icon-nmm",
        manual: "https://www.nexusmods.com/assets/images/icons/icons.svg#icon-manual",
    };

    function createArchiveButtonsFor(fileId) {
        const path = `${location.protocol}//${location.host}${location.pathname}`;
        const fragment = document.createDocumentFragment();

        const makeBtn = (href, label, isNmm) => {
            const li = document.createElement("li");
            const a = document.createElement("a");
            a.className = "btn inline-flex";
            a.href = href;
            a.dataset.fileid = fileId;
            a.tabIndex = 0;
            try {
                const svg = document.createElementNS(
                    "http://www.w3.org/2000/svg",
                    "svg",
                );
                svg.setAttribute(
                    "class",
                    "icon " + (isNmm ? "icon-nmm" : "icon-manual"),
                );
                const use = document.createElementNS(
                    "http://www.w3.org/2000/svg",
                    "use",
                );
                use.setAttributeNS(
                    "http://www.w3.org/1999/xlink",
                    "xlink:href",
                    isNmm ? ICON_PATHS.nmm : ICON_PATHS.manual,
                );
                svg.appendChild(use);
                a.appendChild(svg);
            } catch (_) {
                const spanIcon = document.createElement("span");
                spanIcon.className =
                    "icon " + (isNmm ? "icon-nmm" : "icon-manual");
                a.appendChild(spanIcon);
            }

            const labelSpan = document.createElement("span");
            labelSpan.className = "flex-label";
            labelSpan.textContent = label;
            a.appendChild(labelSpan);

            li.appendChild(a);
            return li;
        };

        const nmmHref = `${path}?tab=files&file_id=${encodeURIComponent(
            fileId,
        )}&nmm=1`;
        const manualHref = `${path}?tab=files&file_id=${encodeURIComponent(
            fileId,
        )}`;

        fragment.appendChild(makeBtn(nmmHref, "Vortex", true));
        fragment.appendChild(makeBtn(manualHref, "Manual", false));
        return fragment;
    }

    function archivedFile() {
        try {
            if (!window.location.href.includes("category=archived")) return;

            const downloadSections = Array.from(
                document.querySelectorAll(".accordion-downloads"),
            );
            const fileHeaders = Array.from(
                document.querySelectorAll(".file-expander-header"),
            );

            for (let idx = 0; idx < downloadSections.length; idx++) {
                const section = downloadSections[idx];
                const fileId = fileHeaders[idx]?.getAttribute("data-id");
                if (!fileId) continue;
                try {
                    if (
                        section.dataset &&
                        section.dataset.nnwInjected === fileId
                    ) {
                        continue;
                    }
                } catch (_) {}

                infoLog("archivedFile: injecting buttons (safe DOM creation)", {
                    fileId,
                });
                while (section.firstChild)
                    section.removeChild(section.firstChild);
                section.appendChild(createArchiveButtonsFor(fileId));

                try {
                    if (section.dataset) section.dataset.nnwInjected = fileId;
                } catch (_) {}
            }
        } catch (e) {
            errorLog("archivedFile error", e);
        }
    }

    // -------------------------------- UI --------------------------------
    const SETTING_UI = {
        autoCloseTab: {
            name: "Auto-Close tab on download",
            description: "Automatically close tab after download starts",
        },
        skipRequirements: {
            name: "Skip Requirements Popup/Tab",
            description: "Skip requirements page and go straight to download",
        },
        showAlerts: {
            name: "Show Error Alert messages",
            description: "Show error messages as browser alerts",
        },
        refreshOnError: {
            name: "Refresh page on error",
            description:
                "Refresh the page when errors occur (may lead to infinite refresh loop!)",
        },
        requestTimeout: {
            name: "Request Timeout",
            description: "Time to wait for server response before timeout",
        },
        closeTabTime: {
            name: "Auto-Close tab Delay",
            description: "Delay before closing tab after download starts",
        },
        debug: {
            name: "⚠️ Debug Alerts",
            description: "Show all console logs as alerts",
        },
        playErrorSound: {
            name: "Play Error Sound",
            description: "Play a sound when errors occur",
        },
    };

    const STYLES = {
        button: `position:fixed;bottom:20px;right:20px;background:#2f2f2f;color:#fff;padding:10px 15px;border-radius:4px;cursor:pointer;z-index:9999;font-family:'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:14px;border:none;`,
        modal: `position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2f2f2f;color:#dadada;padding:25px;border-radius:4px;z-index:10000;min-width:300px;max-width:90%;max-height:90vh;overflow-y:auto;font-family:'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;`,
        section: `background:#363636;padding:15px;border-radius:4px;margin-bottom:15px;`,
        sectionHeader: `color:#da8e35;margin:0 0 10px 0;font-size:16px;font-weight:500;`,
        input: `background:#2f2f2f;border:1px solid #444;color:#dadada;border-radius:3px;padding:5px;`,
        btn: {
            primary: `padding:8px 15px;border:none;background:#da8e35;color:white;border-radius:3px;cursor:pointer;`,
            secondary: `padding:8px 15px;border:1px solid #da8e35;background:transparent;color:#da8e35;border-radius:3px;cursor:pointer;`,
            advanced: `padding:4px 8px;background:transparent;color:#666;border:none;cursor:pointer;`,
        },
    };

    function createSettingsUI() {
        const btn = document.createElement("div");
        btn.innerHTML = "NexusNoWait++ ⚙️";
        btn.style.cssText = STYLES.button;
        btn.onmouseover = () => (btn.style.transform = "translateY(-2px)");
        btn.onmouseout = () => (btn.style.transform = "translateY(0)");
        btn.onclick = () => {
            if (activeModal) {
                activeModal.remove();
                activeModal = null;
                if (settingsChanged) location.reload();
            } else showSettingsModal();
        };
        document.body.appendChild(btn);
    }

    function generateSettingsHTML() {
        const normalBooleanSettings = Object.entries(SETTING_UI)
            .filter(([k]) => typeof config[k] === "boolean" && k !== "debug")
            .map(
                ([key, { name, description }]) => `
        <div style="margin-bottom:10px;">
          <label title="${description}" style="display:flex;align-items:center;gap:8px;">
            <input type="checkbox" ${
                config[key] ? "checked" : ""
            } data-setting="${key}">
            <span>${name}</span>
          </label>
        </div>`,
            )
            .join("");
        const numberSettings = Object.entries(SETTING_UI)
            .filter(([key]) => typeof config[key] === "number")
            .map(
                ([key, { name, description }]) => `
        <div style="margin-bottom:10px;">
          <label title="${description}" style="display:flex;align-items:center;justify-content:space-between;">
            <span>${name}:</span>
            <input type="number" value="${config[key]}" min="0" step="100" data-setting="${key}" style="${STYLES.input};width:120px;">
          </label>
        </div>`,
            )
            .join("");
        const advancedSection = `
      <div id="advancedSection" style="display:none;">
        <div style="${STYLES.section}">
          <h4 style="${STYLES.sectionHeader}">Advanced Settings</h4>
          <div style="margin-bottom:10px;">
            <label title="${
                SETTING_UI.debug.description
            }" style="display:flex;align-items:center;gap:8px;">
              <input type="checkbox" ${
                  config.debug ? "checked" : ""
              } data-setting="debug"><span>${SETTING_UI.debug.name}</span>
            </label>
          </div>
        </div>
      </div>`;
        return `
      <h3 style="${STYLES.sectionHeader}">NexusNoWait++ Settings</h3>
      <div style="${STYLES.section}"><h4 style="${STYLES.sectionHeader}">Features</h4>${normalBooleanSettings}</div>
      <div style="${STYLES.section}"><h4 style="${STYLES.sectionHeader}">Timing</h4>${numberSettings}</div>
      ${advancedSection}
      <div style="display:flex;justify-content:center;gap:10px;margin-top:20px;">
        <button id="resetSettings" style="${STYLES.btn.secondary}">Reset</button>
        <button id="closeSettings" style="${STYLES.btn.primary}">Save & Close</button>
      </div>
      <div style="text-align:center;margin-top:12px;"><button id="toggleAdvanced" style="${STYLES.btn.advanced}">⚙️ Advanced</button></div>
      <div style="text-align:center;margin-top:12px;color:#666;font-size:12px;">Version ${GM_info.script.version} by Torkelicious</div>
    `;
    }

    let activeModal = null;
    let settingsChanged = false;

    function showSettingsModal() {
        if (activeModal) activeModal.remove();
        settingsChanged = false;
        const modal = document.createElement("div");
        modal.style.cssText = STYLES.modal;
        modal.innerHTML = generateSettingsHTML();

        function updateSetting(element) {
            const setting = element.getAttribute("data-setting");
            const value =
                element.type === "checkbox"
                    ? element.checked
                    : parseInt(element.value, 10);
            if (typeof value === "number" && isNaN(value)) {
                element.value = config[setting];
                return;
            }
            if (config[setting] !== value) {
                settingsChanged = true;
                window.nexusConfig.setFeature(setting, value);
            }
        }

        modal.addEventListener("change", (e) => {
            if (e.target.hasAttribute("data-setting")) updateSetting(e.target);
        });
        modal.addEventListener("input", (e) => {
            if (
                e.target.type === "number" &&
                e.target.hasAttribute("data-setting")
            )
                updateSetting(e.target);
        });

        modal.querySelector("#closeSettings").onclick = () => {
            modal.remove();
            activeModal = null;
            if (settingsChanged) location.reload();
        };
        modal.querySelector("#resetSettings").onclick = () => {
            settingsChanged = true;
            window.nexusConfig.reset();
            saveSettings(config);
            modal.remove();
            activeModal = null;
            location.reload();
        };
        modal.querySelector("#toggleAdvanced").onclick = (e) => {
            const section = modal.querySelector("#advancedSection");
            const isHidden = section.style.display === "none";
            section.style.display = isHidden ? "block" : "none";
            e.target.textContent = `Advanced ${isHidden ? "▲" : "▼"}`;
        };

        document.body.appendChild(modal);
        activeModal = modal;
    }

    function setupDebugMode() {
        if (config.debug) {
            const originalConsole = {
                log: console.log,
                warn: console.warn,
                error: console.error,
            };
            console.log = function () {
                originalConsole.log.apply(console, arguments);
                alert("[Debug Log]\n" + Array.from(arguments).join(" "));
            };
            console.warn = function () {
                originalConsole.warn.apply(console, arguments);
                alert("[Debug Warn]\n" + Array.from(arguments).join(" "));
            };
            console.error = function () {
                originalConsole.error.apply(console, arguments);
                alert("[Debug Error]\n" + Array.from(arguments).join(" "));
            };
            infoLog("Debug mode enabled");
        }
    }

    function scrollToMainFiles() {
        try {
            if (!/\btab=files\b/.test(window.location.href)) return;
            const header = document.querySelector(".file-category-header");
            if (header) header.scrollIntoView();
        } catch (e) {
            /* ignore */
        }
    }

    window.nexusConfig = {
        setFeature(name, value) {
            const old = config[name];
            config[name] = value;
            saveSettings(config);
            if (name !== "debug") applySettings();
            if (old !== value) {
                settingsChanged = true;
                debugLog("Feature changed", name, old, value);
            }
        },
        reset() {
            GM_deleteValue("nexusNoWaitConfig");
            Object.assign(config, DEFAULT_CONFIG);
            saveSettings(config);
            applySettings();
        },
        getConfig() {
            return config;
        },
    };
    function applySettings() {
        setupDebugMode();
    }

    // Initialization
    function isModPage() {
        return /nexusmods\.com\/.*\/mods\//.test(window.location.href);
    }
    function initializeUI() {
        applySettings();
        createSettingsUI();
    }

    function initMainFunctions() {
        if (!isModPage()) {
            debugLog("Not a mod page - skipping");
            return;
        }
        infoLog("Initializing main functions");
        archivedFile();
        document.body.addEventListener("click", delegatedClickHandler, true);
        try {
            getPrimaryFileId();
        } catch (e) {
            debugLog("initMainFunctions: getPrimaryFileId failed", e);
        }
        autoStartFileLink();
        if (config.skipRequirements) autoClickRequiredFileDownload();
        setTimeout(() => {
            try {
                scrollToMainFiles();
            } catch (e) {
                /* ignore */
            }
        }, 200);
    }

    // URL Watcher
    (() => {
        let lastHref = location.href;
        const CHECK_MS = 300;

        setInterval(() => {
            try {
                if (location.href === lastHref) return;
                lastHref = location.href;
                debugLog(
                    "URL changed ---> running light init for changed tab",
                    {
                        href: lastHref,
                    },
                );
                // only run lightweight operations needed on navigation:
                if (isModPage()) {
                    try {
                        archivedFile();
                    } catch (e) {
                        debugLog("archivedFile error on URL change", e);
                    }
                    setTimeout(() => {
                        try {
                            scrollToMainFiles();
                        } catch (e) {
                            /* ignore */
                        }
                    }, 150);
                }
            } catch (e) {
                debugLog("URL watcher error", e);
            }
        }, CHECK_MS);
    })();

    let archivedDebounceTimer = null;
    const ARCHIVE_DEBOUNCE_MS = 200;

    const mainObserver = new MutationObserver((mutations) => {
        if (!isModPage()) return;
        try {
            let touched = false;
            mutations.forEach((mutation) => {
                if (!mutation.addedNodes) return;
                mutation.addedNodes.forEach((node) => {
                    if (node.nodeType !== 1) return;
                    touched = true;
                });
            });
            if (!touched) return;
            clearTimeout(archivedDebounceTimer);
            archivedDebounceTimer = setTimeout(() => {
                try {
                    archivedFile();
                } finally {
                    archivedDebounceTimer = null;
                }
            }, ARCHIVE_DEBOUNCE_MS);
        } catch (e) {
            errorLog("MutationObserver error", e);
        }
    });

    initializeUI();
    initMainFunctions();

    if (isModPage()) {
        mainObserver.observe(document.body, { childList: true, subtree: true });
        debugLog("Started mutation observer");
        window.addEventListener("unload", () => {
            mainObserver.disconnect();
            debugLog("Unload: disconnected observer");
        });
    }
})();