Nexus No Wait ++

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

As of 2025-09-04. See the latest version.

// ==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 (keeps several page strategies)
    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");
        });
    }
})();
长期地址
遇到问题?请前往 GitHub 提 Issues。