Torn Script Loader

Loads gated scripts for authorized faction members

2026-05-01 기준 버전입니다. 최신 버전을 확인하세요.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
  // @name         Torn Script Loader
  // @namespace    https://github.com/torn-script-loader
  // @version      3.1.0
  // @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

    // ── 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 loadScript(scriptName, apiKey) {
      return new Promise((resolve, reject) => {
        const cached = getCached(scriptName);
        if (cached) {
          injectScript(cached, scriptName);
          resolve(true);
          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);
                injectScript(data.code, scriptName);
                resolve(true);
              } 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);
          },
        });
      });
    }

    // 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.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 {
        GM_addElement('script', { textContent: GM_POLYFILLS + '\n' + code });
        console.log(`[Script Loader] Loaded: ${name}`);
      } catch (e) {
        console.error(`[Script Loader] Error executing ${name}:`, e);
        showError(`Error running ${name}`);
      }
    }

    // ── 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 = [];

    // Gear SVG matching Torn's settings-menu icon style (28x28 viewBox)
    const GEAR_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="-6 -4 28 28"><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.0
  7A.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,.4
  5,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;
      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;
      const observer = new MutationObserver(() => { tryInject(); });
      observer.observe(document.body, { childList: true, subtree: true });
    }

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

      setupXhrBridge();
      createPanel();
      injectMenuItem();

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

      currentManifest = manifest;
      const currentUrl = window.location.href;

      for (const entry of manifest) {
        if (!entry.name || !entry.match) continue;

        const pattern = new RegExp(entry.match);
        if (!pattern.test(currentUrl)) continue;
        if (!isScriptEnabled(entry.name)) continue;

        loadScript(entry.name, apiKey);
      }
    }

    main();
  })();