Torn Script Loader

Loads gated scripts for authorized faction members

Verzia zo dňa 20.05.2026. Pozri najnovšiu verziu.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
  // @name         Torn Script Loader
  // @namespace    https://github.com/torn-script-loader
  // @version      3.1.2
  // @description  Loads gated scripts for authorized faction members
  // @match        https://www.torn.com/*
  // @grant        GM_getValue
  // @grant        GM_setValue
  // @grant        GM_xmlhttpRequest
  // @grant        GM_addElement
  // @connect      torn-script-loader-v2.theaaronlawrence.workers.dev
  // @connect      *
  // @run-at       document-idle
  // ==/UserScript==

  (function () {
    'use strict';

    // ── Configuration ──────────────────────────────────────────────────
    const VERSION = GM_info.script.version;
    const WORKER_BASE = 'https://torn-script-loader-v2.theaaronlawrence.workers.dev';
    const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
    const loadedScripts = new Set();
    const pendingScripts = new Set();

    // ── API Key Management ─────────────────────────────────────────────
    function getApiKey() {
      return GM_getValue('torn_api_key', null);
    }

    function setApiKey(key) {
      GM_setValue('torn_api_key', key);
    }

    function clearApiKey() {
      GM_setValue('torn_api_key', null);
      GM_setValue('cache_manifest', null);
    }

    function promptForApiKey() {
      const key = prompt(
        '[Script Loader] Enter your Torn API key.\n' +
        'This is used to verify your faction membership.\n' +
        'You can generate one at Settings > API Key.'
      );
      if (key && key.trim().length === 16) {
        setApiKey(key.trim());
        return key.trim();
      }
      if (key !== null) {
        alert('[Script Loader] Invalid API key. Must be 16 characters. Reload to try again.');
      }
      return null;
    }

    // ── Enable/Disable State ───────────────────────────────────────────
    function getEnabledScripts() {
      return GM_getValue('enabled_scripts', {});
    }

    function setScriptEnabled(name, enabled) {
      const state = getEnabledScripts();
      state[name] = enabled;
      GM_setValue('enabled_scripts', state);
    }

    function isScriptEnabled(name) {
      const state = getEnabledScripts();
      return state[name] === true;
    }

    // ── Cache ──────────────────────────────────────────────────────────
    function getCached(key) {
      const raw = GM_getValue(`cache_${key}`, null);
      if (!raw) return null;
      try {
        const cached = JSON.parse(raw);
        if (Date.now() - cached.ts < CACHE_TTL) {
          return cached.data;
        }
      } catch { /* cache miss */ }
      return null;
    }

    function setCache(key, data) {
      GM_setValue(`cache_${key}`, JSON.stringify({ ts: Date.now(), data }));
    }

    // ── Manifest Fetch ─────────────────────────────────────────────────
    function fetchManifest(apiKey) {
      return new Promise((resolve, reject) => {
        const cached = getCached('manifest');
        if (cached) {
          resolve(cached);
          return;
        }

        GM_xmlhttpRequest({
          method: 'POST',
          url: `${WORKER_BASE}/manifest`,
          headers: { 'Content-Type': 'application/json' },
          data: JSON.stringify({ apiKey }),
          onload(response) {
            try {
              const data = JSON.parse(response.responseText);
              if (data.ok && data.manifest) {
                setCache('manifest', data.manifest);
                resolve(data.manifest);
              } else {
                showError(data.error || 'Failed to load manifest');
                reject(new Error(data.error));
              }
            } catch (e) {
              showError('Failed to parse manifest response');
              reject(e);
            }
          },
          onerror(err) {
            showError('Network error fetching manifest');
            reject(err);
          },
        });
      });
    }

    // ── Script Loading ─────────────────────────────────────────────────
    function fetchScriptCode(scriptName, apiKey) {
      return new Promise((resolve, reject) => {
        const cached = getCached(scriptName);
        if (cached) {
          resolve(cached);
          return;
        }

        GM_xmlhttpRequest({
          method: 'POST',
          url: `${WORKER_BASE}/load`,
          headers: { 'Content-Type': 'application/json' },
          data: JSON.stringify({ apiKey, script: scriptName }),
          onload(response) {
            try {
              const data = JSON.parse(response.responseText);
              if (data.ok && data.code) {
                setCache(scriptName, data.code);
                resolve(data.code);
              } else {
                showError(data.error || 'Unknown error loading script');
                reject(new Error(data.error));
              }
            } catch (e) {
              showError('Failed to parse worker response');
              reject(e);
            }
          },
          onerror(err) {
            showError('Network error contacting script loader');
            reject(err);
          },
        });
      });
    }

    async function loadScript(scriptName, apiKey) {
      const code = await fetchScriptCode(scriptName, apiKey);
      scheduleScriptInjection(code, scriptName);
      return true;
    }

    async function prefetchScript(scriptName, apiKey) {
      await fetchScriptCode(scriptName, apiKey);
      return true;
    }

    // Polyfills injected into the page context (via GM_addElement).
    // GM_xmlhttpRequest uses postMessage to bridge back to the loader's
    // Tampermonkey context, which has the real GM_xmlhttpRequest that
    // bypasses CSP restrictions.
    const GM_POLYFILLS = `
      window.GM_addStyle = function(css) {
        var s = document.createElement('style');
        s.textContent = css;
        (document.head || document.documentElement || document.body).appendChild(s);
        return s;
      };
      window.GM_getValue = function(key, def) {
        var v = localStorage.getItem('_gm_' + key);
        return v === null ? def : JSON.parse(v);
      };
      window.GM_setValue = function(key, val) {
        localStorage.setItem('_gm_' + key, JSON.stringify(val));
      };
      window.GM_xmlhttpRequest = function(opts) {
        var id = 'sl_xhr_' + Math.random().toString(36).slice(2);
        function handler(event) {
          if (!event.data || event.data.type !== 'SL_XHR_RESPONSE' || event.data.id !== id) return;
          window.removeEventListener('message', handler);
          if (event.data.error) {
            if (opts.onerror) opts.onerror(event.data.error);
          } else {
            if (opts.onload) opts.onload(event.data.response);
          }
        }
        window.addEventListener('message', handler);
        window.postMessage({
          type: 'SL_XHR_REQUEST',
          id: id,
          opts: {
            method: opts.method,
            url: opts.url,
            headers: opts.headers,
            data: opts.data,
            responseType: opts.responseType
          }
        }, '*');
      };
    `;

    // ── XHR Bridge ─────────────────────────────────────────────────────
    function setupXhrBridge() {
      window.addEventListener('message', (event) => {
        if (!event.data || event.data.type !== 'SL_XHR_REQUEST') return;

        const { id, opts } = event.data;
        GM_xmlhttpRequest({
          method: opts.method || 'GET',
          url: opts.url,
          headers: opts.headers || {},
          data: opts.data || undefined,
          responseType: opts.responseType || '',
          onload(response) {
            window.postMessage({
              type: 'SL_XHR_RESPONSE',
              id,
              response: {
                status: response.status,
                statusText: response.statusText,
                responseText: response.responseText,
                response: response.response,
                finalUrl: response.finalUrl,
              },
            }, '*');
          },
          onerror(err) {
            window.postMessage({
              type: 'SL_XHR_RESPONSE',
              id,
              error: true,
            }, '*');
          },
        });
      });
    }

    function injectScript(code, name) {
      try {
        if (loadedScripts.has(name)) return;
        GM_addElement('script', { textContent: GM_POLYFILLS + '\n' + code });
        loadedScripts.add(name);
        pendingScripts.delete(name);
        console.log(`[Script Loader] Loaded: ${name}`);
      } catch (e) {
        pendingScripts.delete(name);
        console.error(`[Script Loader] Error executing ${name}:`, e);
        showError(`Error running ${name}`);
      }
    }

    function getRunAtFromCode(code) {
      const match = String(code || '').match(/^[ \t]*\/\/\s*@run-at\s+([^\s]+)/im);
      return match ? String(match[1]).trim().toLowerCase() : 'document-idle';
    }

    function scheduleScriptInjection(code, name) {
      if (loadedScripts.has(name) || pendingScripts.has(name)) return;

      const runAt = getRunAtFromCode(code);
      pendingScripts.add(name);

      const injectNow = () => {
        if (loadedScripts.has(name)) {
          pendingScripts.delete(name);
          return;
        }
        injectScript(code, name);
      };

      if (runAt === 'document-start') {
        injectNow();
        return;
      }

      if (runAt === 'document-end') {
        if (document.readyState === 'loading') {
          document.addEventListener('DOMContentLoaded', injectNow, { once: true });
        } else {
          injectNow();
        }
        return;
      }

      const injectIdle = () => {
        if (typeof requestIdleCallback === 'function') {
          requestIdleCallback(() => injectNow(), { timeout: 1000 });
        } else {
          setTimeout(() => injectNow(), 0);
        }
      };

      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', injectIdle, { once: true });
      } else {
        injectIdle();
      }
    }

    // ── UI: Error Toast ────────────────────────────────────────────────
    function showError(message) {
      console.warn(`[Script Loader] ${message}`);
      const el = document.createElement('div');
      el.textContent = `Script Loader: ${message}`;
      Object.assign(el.style, {
        position: 'fixed',
        bottom: '10px',
        right: '10px',
        background: '#c0392b',
        color: '#fff',
        padding: '8px 14px',
        borderRadius: '6px',
        fontSize: '13px',
        zIndex: '99999',
        opacity: '0.95',
        fontFamily: 'Arial, sans-serif',
      });
      document.body.appendChild(el);
      setTimeout(() => el.remove(), 6000);
    }

    // ── UI: Settings Panel ─────────────────────────────────────────────
    let panelOpen = false;
    let currentManifest = [];

    // Original Torn-style gear icon for the menu launcher.
    const GEAR_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="-6 -4 28 28" aria-hidden="true"><path d="M15.93,7.16l-1.41-.37a.46.46,0,0,1-.32-.32,6.16,6.16,0,0,0-.34-.83.45.45,0,0,1,0-.45l.74-1.25a.45.45,0,0,0-.07-.55l-.93-.93a.45.45,0,0,0-.55-.07l-1.25.74a.45.45,0,0,1-.45,0,6.16,6.16,0,0,0-.83-.34.46.46,0,0,1-.32-.32L9.84.07A.45.45,0,0,0,9.4-.27H7.6a.45.45,0,0,0-.44.34L6.79,1.48a.46.46,0,0,1-.32.32,6.16,6.16,0,0,0-.83.34.45.45,0,0,1-.45,0L3.94,1.39a.45.45,0,0,0-.55.07l-.93.93a.45.45,0,0,0-.07.55l.74,1.25a.45.45,0,0,1,0,.45,6.16,6.16,0,0,0-.34.83.46.46,0,0,1-.32.32L1.07,6.16A.45.45,0,0,0,.73,6.6V8.4a.45.45,0,0,0,.34.44l1.41.37a.46.46,0,0,1,.32.32,6.16,6.16,0,0,0,.34.83.45.45,0,0,1,0,.45L2.39,11.06a.45.45,0,0,0,.07.55l.93.93a.45.45,0,0,0,.55.07l1.25-.74a.45.45,0,0,1,.45,0,6.16,6.16,0,0,0,.83.34.46.46,0,0,1,.32.32l.37,1.41a.45.45,0,0,0,.44.34h1.8a.45.45,0,0,0,.44-.34l.37-1.41a.46.46,0,0,1,.32-.32,6.16,6.16,0,0,0,.83-.34.45.45,0,0,1,.45,0l1.25.74a.45.45,0,0,0,.55-.07l.93-.93a.45.45,0,0,0,.07-.55l-.74-1.25a.45.45,0,0,1,0-.45,6.16,6.16,0,0,0,.34-.83.46.46,0,0,1,.32-.32l1.41-.37A.45.45,0,0,0,16.27,8.4V6.6A.45.45,0,0,0,15.93,7.16ZM8.5,10.75A3.25,3.25,0,1,1,11.75,7.5,3.25,3.25,0,0,1,8.5,10.75Z"></path></svg>`;

    function createPanel() {
      if (document.getElementById('sl-panel')) return;
      if (!document.body) return;
      const panel = document.createElement('div');
      panel.id = 'sl-panel';
      Object.assign(panel.style, {
        position: 'fixed',
        top: '56px',
        right: '14px',
        width: '280px',
        maxHeight: '400px',
        overflowY: 'auto',
        background: '#1a1a2e',
        border: '1px solid #333',
        borderRadius: '8px',
        zIndex: '100000',
        fontFamily: 'Arial, sans-serif',
        fontSize: '13px',
        color: '#ccc',
        display: 'none',
        boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
      });
      document.body.appendChild(panel);

      // Click outside closes
      document.addEventListener('mousedown', (e) => {
        if (!panelOpen) return;
        if (panel.contains(e.target)) return;
        if (e.target.closest && e.target.closest('#sl-menu-item')) return;
        panelOpen = false;
        panel.style.display = 'none';
      });
    }

    function togglePanel() {
      const panel = document.getElementById('sl-panel');
      if (!panel) return;
      panelOpen = !panelOpen;
      panel.style.display = panelOpen ? 'block' : 'none';
      if (panelOpen) renderPanel();
    }

    function makeToggle(name) {
      const enabled = isScriptEnabled(name);

      const label = document.createElement('label');
      Object.assign(label.style, {
        position: 'relative',
        display: 'inline-block',
        width: '40px',
        height: '22px',
        marginLeft: '10px',
        flexShrink: '0',
        cursor: 'pointer',
      });

      const input = document.createElement('input');
      input.type = 'checkbox';
      input.checked = enabled;
      Object.assign(input.style, { opacity: '0', width: '0', height: '0', position: 'absolute' });

      const track = document.createElement('span');
      Object.assign(track.style, {
        position: 'absolute', top: '0', left: '0', right: '0', bottom: '0',
        background: enabled ? '#4CAF50' : '#555',
        borderRadius: '11px', transition: 'background 0.2s',
      });

      const thumb = document.createElement('span');
      Object.assign(thumb.style, {
        position: 'absolute', top: '2px', left: enabled ? '20px' : '2px',
        width: '18px', height: '18px', background: '#fff', borderRadius: '50%',
        transition: 'left 0.2s',
      });

      input.addEventListener('change', () => {
        const on = input.checked;
        setScriptEnabled(name, on);
        track.style.background = on ? '#4CAF50' : '#555';
        thumb.style.left = on ? '20px' : '2px';
      });

      label.append(input, track, thumb);
      return label;
    }

    function renderPanel() {
      const panel = document.getElementById('sl-panel');
      while (panel.firstChild) panel.removeChild(panel.firstChild);

      const header = document.createElement('div');
      Object.assign(header.style, {
        padding: '12px 14px 8px',
        borderBottom: '1px solid #333',
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
      });
      const title = document.createElement('span');
      title.textContent = 'Script Loader';
      Object.assign(title.style, { fontWeight: 'bold', fontSize: '14px', color: '#eee' });
      const version = document.createElement('span');
      version.textContent = `v${VERSION}`;
      Object.assign(version.style, { fontSize: '11px', color: '#666' });
      header.append(title, version);
      panel.appendChild(header);

      if (currentManifest.length === 0) {
        const empty = document.createElement('div');
        empty.textContent = 'No scripts in manifest';
        Object.assign(empty.style, { padding: '14px', color: '#666', textAlign: 'center' });
        panel.appendChild(empty);
      } else {
        for (const entry of currentManifest) {
          const row = document.createElement('div');
          Object.assign(row.style, {
            padding: '10px 14px',
            borderBottom: '1px solid #262640',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'space-between',
          });

          const info = document.createElement('div');
          Object.assign(info.style, { flex: '1', minWidth: '0' });

          const nameEl = document.createElement('div');
          nameEl.textContent = entry.name;
          Object.assign(nameEl.style, { fontWeight: 'bold', color: '#ddd', marginBottom: '2px' });
          info.appendChild(nameEl);

          if (entry.description) {
            const desc = document.createElement('div');
            desc.textContent = entry.description;
            Object.assign(desc.style, {
              fontSize: '11px', color: '#777',
              whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
            });
            info.appendChild(desc);
          }

          row.append(info, makeToggle(entry.name));
          panel.appendChild(row);
        }
      }

      const footer = document.createElement('div');
      Object.assign(footer.style, { padding: '10px 14px', borderTop: '1px solid #333' });

      const resetBtn = document.createElement('button');
      resetBtn.textContent = 'Reset API Key';
      Object.assign(resetBtn.style, {
        background: 'none', border: '1px solid #555', color: '#999',
        padding: '5px 10px', borderRadius: '4px', fontSize: '11px',
        cursor: 'pointer', width: '100%', transition: 'background 0.15s, color 0.15s',
      });
      resetBtn.addEventListener('mouseenter', () => {
        resetBtn.style.background = '#c0392b'; resetBtn.style.color = '#fff'; resetBtn.style.borderColor =
  '#c0392b';
      });
      resetBtn.addEventListener('mouseleave', () => {
        resetBtn.style.background = 'none'; resetBtn.style.color = '#999'; resetBtn.style.borderColor = '#555';
      });
      resetBtn.addEventListener('click', () => {
        if (confirm('Reset your API key? You will need to re-enter it on next page load.')) {
          clearApiKey();
          panelOpen = false;
          document.getElementById('sl-panel').style.display = 'none';
          showError('API key cleared. Reload the page to re-enter.');
        }
      });

      footer.appendChild(resetBtn);
      panel.appendChild(footer);
    }

    // ── Inject into Torn settings dropdown ─────────────────────────────
    function injectMenuItem() {
      const tryInject = () => {
        const menu = document.querySelector('ul.settings-menu');
        if (!menu) return false;
        if (menu.querySelector('#sl-menu-item')) return true;

        const item = document.createElement('li');
        item.id = 'sl-menu-item';
        item.className = 'link';

        const a = document.createElement('a');
        a.href = '#';
        a.style.cursor = 'pointer';

        const iconWrap = document.createElement('div');
        iconWrap.className = 'icon-wrapper';
        iconWrap.innerHTML = GEAR_SVG;

        const text = document.createElement('span');
        text.className = 'link-text';
        text.textContent = 'Scripts';

        a.append(iconWrap, text);
        item.appendChild(a);

        a.addEventListener('click', (e) => {
          e.preventDefault();
          e.stopPropagation();
          togglePanel();
        });

        // Insert after View Profile (first child)
        if (menu.firstChild && menu.firstChild.nextSibling) {
          menu.insertBefore(item, menu.firstChild.nextSibling);
        } else {
          menu.appendChild(item);
        }
        return true;
      };

      if (tryInject()) return;
      if (!document.body) return;
      const observer = new MutationObserver(() => { tryInject(); });
      observer.observe(document.body, { childList: true, subtree: true });
    }

    function initializeUiWhenReady() {
      const start = () => {
        if (!document.body) return;
        createPanel();
        injectMenuItem();
      };

      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', start, { once: true });
      } else {
        start();
      }
    }

    async function prefetchEnabledScripts(apiKey, manifest) {
      if (!Array.isArray(manifest) || manifest.length === 0) return;

      for (const entry of manifest) {
        if (!entry?.name) continue;
        if (!isScriptEnabled(entry.name)) continue;

        try {
          await prefetchScript(entry.name, apiKey);
        } catch (err) {
          console.error(`[Script Loader] Failed prefetching ${entry.name}:`, err);
        }
      }
    }

    function patternMatchesCurrentUrl(matchValue, currentUrl) {
      if (!matchValue) return false;
      const isUrlPattern = matchValue.includes('://');
      const pattern = isUrlPattern
        ? new RegExp(`^${matchValue.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`)
        : new RegExp(matchValue);

      return pattern.test(currentUrl);
    }

    function entryMatchesCurrentUrl(entry, currentUrl) {
      if (!entry.name || !entry.match) return false;
      return patternMatchesCurrentUrl(String(entry.match), currentUrl);
    }

    function codeMatchesCurrentUrl(code, currentUrl) {
      const text = String(code || '');
      const regex = /^[ \t]*\/\/\s*@(match|include)\s+(.+)$/gim;
      let match;

      while ((match = regex.exec(text))) {
        const pattern = String(match[2] || '').trim();
        if (!pattern) continue;
        if (patternMatchesCurrentUrl(pattern, currentUrl)) return true;
      }

      return false;
    }

    async function loadMatchingScripts(manifest, apiKey) {
      const currentUrl = window.location.href;

      for (const entry of manifest) {
        if (!isScriptEnabled(entry.name)) continue;
        if (loadedScripts.has(entry.name) || pendingScripts.has(entry.name)) continue;
        const cachedCode = getCached(entry.name);
        const matched = entryMatchesCurrentUrl(entry, currentUrl) || codeMatchesCurrentUrl(cachedCode, currentUrl);
        if (!matched) continue;

        loadScript(entry.name, apiKey);
      }
    }

    // ── Main ───────────────────────────────────────────────────────────
    async function main() {
      let apiKey = getApiKey();
      if (!apiKey) {
        apiKey = promptForApiKey();
        if (!apiKey) return;
      }

      setupXhrBridge();
      initializeUiWhenReady();

      const cachedManifest = getCached('manifest');
      if (Array.isArray(cachedManifest) && cachedManifest.length > 0) {
        currentManifest = cachedManifest;
        await loadMatchingScripts(cachedManifest, apiKey);
      }

      let manifest;
      try {
        manifest = await fetchManifest(apiKey);
      } catch {
        return;
      }

      currentManifest = manifest;
      await prefetchEnabledScripts(apiKey, manifest);
      await loadMatchingScripts(manifest, apiKey);
    }

    main();
  })();