Nexus No Wait ++ (Fork)

Skip countdowns, auto-start downloads, archived file support, and Nexus SPA fixes. Fork of Nexus No Wait ++ with UI + safety improvements.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name        Nexus No Wait ++ (Fork)
// @description Skip countdowns, auto-start downloads, archived file support, and Nexus SPA fixes. Fork of Nexus No Wait ++ with UI + safety improvements.
// @version     2.1.2
// @namespace   NexusNoWaitPlusPlusFork
// @author      StrangeT (Orig) + Torkelicious + ShredGman
// @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
// @license      GPL-3.0-or-later
// @match        https://*.nexusmods.com/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @grant        GM_info
// @connect      nexusmods.com
// ==/UserScript==

(function () {
  'use strict'

  const SCRIPT_NAME = 'NexusNoWait++'
  const VERSION = GM_info?.script?.version || 'dev'

  // ────────────────────────────────────────────────
  // GM API fallbacks
  // ────────────────────────────────────────────────
  const GMX = {
    get:    (k, d) => typeof GM_getValue === 'function' ? GM_getValue(k, d) : d,
    set:    (k, v) => typeof GM_setValue === 'function' && GM_setValue(k, v),
    del:    (k) => typeof GM_deleteValue === 'function' && GM_deleteValue(k),
    style:  (css) => typeof GM_addStyle === 'function' && GM_addStyle(css),
  }

  const xhr = GM_xmlhttpRequest || (window.GM?.xmlHttpRequest) || null
  if (!xhr) {
    console.error(`[${SCRIPT_NAME}] GM_xmlhttpRequest missing`)
    return
  }

  // ────────────────────────────────────────────────
  // Config
  // ────────────────────────────────────────────────
  const CFG_PREFIX = 'nnwpp_'
  const DEFAULTS = {
    AutoStartDownload:   true,
    AutoCloseTab:        true,
    SkipRequirements:    true,
    ShowAlertsOnError:   true,
    HidePremiumUpsells:  true,
    HandleArchivedFiles: true,
    CloseTabDelay:       1800,
    RequestTimeout:      25000,
  }

  function loadConfig() {
    const cfg = { ...DEFAULTS }
    for (const k of Object.keys(DEFAULTS)) {
      const v = GMX.get(CFG_PREFIX + k, null)
      if (v !== null) cfg[k] = v === 'true' ? true : v === 'false' ? false : Number(v) || v
    }
    return cfg
  }

  let cfg = loadConfig()

  function save(key, value) {
    GMX.set(CFG_PREFIX + key, String(value))
    cfg[key] = value
  }

  // ────────────────────────────────────────────────
  // Logger
  // ────────────────────────────────────────────────
  const log = {
    debug: (...a) => console.debug(`[${SCRIPT_NAME} ${VERSION}]`, ...a),
    info:  (...a) => console.info (`[${SCRIPT_NAME} ${VERSION}]`, ...a),
    warn:  (...a) => console.warn (`[${SCRIPT_NAME} ${VERSION}]`, ...a),
    error: (...a) => console.error(`[${SCRIPT_NAME} ${VERSION}]`, ...a),
  }

  // ────────────────────────────────────────────────
  // Notifications (color-coded toasts)
  // ────────────────────────────────────────────────
  GMX.style(`
    .nnw-toast {
      position: fixed;
      top: 16px;
      right: 16px;
      z-index: 9999999;
      padding: 10px 16px;
      border-radius: 6px;
      color: white;
      font-weight: 500;
      box-shadow: 0 4px 12px rgba(0,0,0,0.4);
      min-width: 180px;
      opacity: 0;
      transform: translateY(-10px);
      transition: all 0.3s ease;
    }
    .nnw-toast.show  { opacity: 1; transform: translateY(0); }
    .nnw-success     { background: #28a745; }
    .nnw-warning     { background: #ffc107; color: #212529; }
    .nnw-error       { background: #dc3545; }
  `)

  function notify(message, type = 'info') { // type: 'success' | 'warning' | 'error'
    const div = document.createElement('div')
    div.className = `nnw-toast nnw-${type}`
    div.textContent = message
    document.body.appendChild(div)

    setTimeout(() => div.classList.add('show'), 50)
    setTimeout(() => {
      div.classList.remove('show')
      setTimeout(() => div.remove(), 400)
    }, 2200)
  }

  // ────────────────────────────────────────────────
  // Hide Premium Upsells (CSS)
  // ────────────────────────────────────────────────
  function injectPremiumHider() {
    if (!cfg.HidePremiumUpsells) return

    GMX.style(`
      /* Common premium banners, upsells, go-premium prompts */
      [class*="premium-banner"],
      [class*="GetPremium"],
      [class*="premium-upsell"],
      [class*="upgrade-premium"],
      .premium-upsell,
      .premium-promo,
      .go-premium,
      [href*="premium"],
      [href*="/premium"],
      .premium-cta,
      .nexus-premium-ad,
      .premium-download-notice,
      .slow-download-upsell,
      .premium-speed-limit,
      .premium-teaser,
      .premium-feature-lock,
      /* Vortex-like in-page banners */
      .get-more-mods-bar,
      .premium-feature,
      /* Generic hiding for text containing "premium" in links/buttons */
      a[href*="premium"]:not([href*="account"]):not([href*="billing"]),
      button:contains("Premium"),
      div:contains("Go Premium"),
      div:contains("Unlock with Premium"),
      div:contains("Get Premium"),
      /* Sidebars, footers, headers with premium links */
      .sidebar-premium,
      footer .premium,
      header .premium {
        display: none !important;
      }
    `)

    // Also hide via JS if needed (some are dynamically added)
    const observer = new MutationObserver(() => {
      document.querySelectorAll(
        '[class*="premium"], [id*="premium"], [class*="upsell"], [class*="GetPremium"]'
      ).forEach(el => {
        if (el.textContent.toLowerCase().includes('premium') ||
            el.href?.includes('premium')) {
          el.style.display = 'none'
        }
      })
    })
    observer.observe(document.body, { childList: true, subtree: true })
  }

  // ────────────────────────────────────────────────
  // Network / Download logic (unchanged except notify calls)
  // ────────────────────────────────────────────────
  function request(opts) {
    return new Promise(resolve => {
      xhr({
        method:  'GET',
        timeout: cfg.RequestTimeout,
        ...opts,
        onload:  r => resolve(r),
        onerror: () => resolve(null),
        ontimeout: () => resolve(null),
      })
    })
  }

  async function getDownloadUrl({ fileId, gameId = '', isNMM = false, fullHref = '' }) {
    if (!fileId) return { error: 'No file_id' }

    if (isNMM && fullHref) {
      const html = (await request({ url: fullHref }))?.responseText || ''
      const nxm = html.match(/nxm:\/\/[^\s"']+/i)?.[0]
      if (nxm) return { url: nxm }
    }

    const postData = `fid=${encodeURIComponent(fileId)}&game_id=${encodeURIComponent(gameId)}`

    const res = await request({
      method: 'POST',
      url: '/Core/Libs/Common/Managers/Downloads?GenerateDownloadUrl',
      data: postData,
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
        'X-Requested-With': 'XMLHttpRequest',
      },
    })

    if (!res?.responseText) return { error: 'Request failed' }

    let url = null
    try {
      url = JSON.parse(res.responseText)?.url?.replace(/&/g, '&')
    } catch {}

    if (!url) {
      const m = res.responseText.match(/id=["']dl_link["'][^>]*value=["']([^"']+)/i)
      if (m) url = m[1].replace(/&/g, '&')
    }

    return url ? { url } : { error: 'Could not extract download link (login?)' }
  }

  // ────────────────────────────────────────────────
  // Click handler with notifications
  // ────────────────────────────────────────────────
  document.addEventListener('click', async e => {
    const a = e.target.closest('a[href], button')
    if (!a) return

    let href = a.href || a.getAttribute('href')
    if (!href) return

    const url = new URL(href, location.href)
    const fileId = url.searchParams.get('file_id') || url.searchParams.get('id')
    if (!fileId) return

    if (url.searchParams.has('requirements') && !cfg.SkipRequirements) return

    e.preventDefault()
    e.stopImmediatePropagation()

    const gameId = document.querySelector('[data-game-id]')?.dataset?.gameId || ''

    notify('Preparing download...', 'warning')

    const { url: dlUrl, error } = await getDownloadUrl({
      fileId,
      gameId,
      isNMM: url.searchParams.has('nmm'),
      fullHref: href,
    })

    if (dlUrl) {
      notify('Download started!', 'success')
      location.assign(dlUrl)
    } else if (error) {
      notify(`Error: ${error}`, 'error')
      if (cfg.ShowAlertsOnError) alert(`${SCRIPT_NAME}\n\n${error}`)
      log.error(error)
    }
  }, true)

  // ────────────────────────────────────────────────
  // Auto-start with notifications
  // ────────────────────────────────────────────────
  async function tryAutoStart() {
    if (!cfg.AutoStartDownload) return

    const p = new URLSearchParams(location.search)
    const fileId = p.get('file_id')
    if (!fileId) return

    const gameId = document.querySelector('[data-game-id]')?.dataset?.gameId || ''

    notify('Auto-starting download...', 'warning')

    const { url: dl, error } = await getDownloadUrl({
      fileId,
      gameId,
      isNMM: p.has('nmm'),
      fullHref: location.href,
    })

    if (dl) {
      notify('Download started!', 'success')
      location.assign(dl)
      if (cfg.AutoCloseTab) {
        setTimeout(() => window.close(), cfg.CloseTabDelay)
      }
    } else if (error) {
      notify(`Auto-start failed: ${error}`, 'error')
      if (cfg.ShowAlertsOnError) alert(error)
    }
  }

  // ────────────────────────────────────────────────
  // Archived files (unchanged)
  // ────────────────────────────────────────────────
  function patchArchivedFiles() {
    if (!cfg.HandleArchivedFiles) return
    if (!location.search.includes('archived')) return

    document.querySelectorAll('[data-file-id]:not([data-nnw-done])').forEach(el => {
      el.dataset.nnwDone = '1'
      const id = el.dataset.fileId
      const base = location.origin + location.pathname

      const div = document.createElement('div')
      div.className = 'nnw-archived-links'
      div.innerHTML = `
        <a href="${base}?file_id=${id}&nmm=1" class="btn small">Mod Manager</a>
        <a href="${base}?file_id=${id}"       class="btn small">Manual</a>
      `
      el.appendChild(div)
    })
  }

  // ────────────────────────────────────────────────
  // Settings panel (added new option)
  // ────────────────────────────────────────────────
  GMX.style(`
    #nnw-settings {
      position: fixed;
      bottom: 16px;
      right: 16px;
      z-index: 999999;
      background: #1e1e1e;
      color: #e0e0e0;
      border: 1px solid #444;
      border-radius: 8px;
      padding: 10px 14px;
      font-size: 13px;
      box-shadow: 0 6px 24px rgba(0,0,0,0.5);
      max-width: 260px;
    }
    #nnw-settings summary {
      cursor: pointer;
      font-weight: 600;
      user-select: none;
    }
    #nnw-settings label {
      display: flex;
      align-items: center;
      gap: 8px;
      margin: 6px 0 0;
      font-size: 12.5px;
    }
    #nnw-settings input[type="checkbox"] {
      accent-color: #ff6a00;
    }
  `)

  function injectSettings() {
    if (document.getElementById('nnw-settings')) return

    const box = document.createElement('details')
    box.id = 'nnw-settings'
    box.open = false

    let html = `<summary>Nexus No Wait ++ ${VERSION}</summary>`

    for (const [k, defVal] of Object.entries(DEFAULTS)) {
      if (typeof defVal !== 'boolean') continue
      html += `
        <label>
          <input type="checkbox" data-key="${k}" ${cfg[k] ? 'checked' : ''}>
          ${k.replace(/([A-Z])/g, ' $1')}
        </label>`
    }

    box.innerHTML = html

    box.addEventListener('change', e => {
      const el = e.target
      if (!el.dataset?.key) return
      save(el.dataset.key, el.checked)
      // Re-apply premium hider if toggled
      if (el.dataset.key === 'HidePremiumUpsells') {
        if (el.checked) injectPremiumHider()
        else location.reload() // simplest way to unhide
      }
    })

    document.body.appendChild(box)
  }

  // ────────────────────────────────────────────────
  // SPA navigation
  // ────────────────────────────────────────────────
  let lastLocation = location.href

  function onNavigate() {
    if (location.href === lastLocation) return
    lastLocation = location.href
    run()
  }

  const originalPush = history.pushState
  const originalReplace = history.replaceState

  history.pushState = function (...args) { originalPush.apply(this, args); onNavigate() }
  history.replaceState = function (...args) { originalReplace.apply(this, args); onNavigate() }

  window.addEventListener('popstate', onNavigate)
  window.addEventListener('hashchange', onNavigate)
  new MutationObserver(onNavigate).observe(document, { subtree: true, childList: true })

  // ────────────────────────────────────────────────
  // Main run
  // ────────────────────────────────────────────────
  function run() {
    tryAutoStart()
    patchArchivedFiles()
    injectSettings()
    injectPremiumHider() // apply on every page/nav
    log.debug('Running on', location.pathname)
  }

  run()
})();