ControlD Random Services Location

adds a "Random Location" option to the ControlD service redirect destinations picker

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         ControlD Random Services Location
// @namespace    https://spin.rip/
// @version      1.1.1
// @description  adds a "Random Location" option to the ControlD service redirect destinations picker
// @author       spin
// @match        https://controld.com/*
// @grant        none
// @run-at       document-idle
// @icon         https://www.google.com/s2/favicons?sz=64&domain=controld.com
// @license      GPL-3.0-only
// ==/UserScript==

(function () {
  'use strict';

  const RANDOM_VIA = '?';
  const RANDOM_LABEL = 'Random Location';
  const RANDOM_TEST_ID = 'services-country-RandomLocation';
  const RANDOM_DEFAULT_TEST_ID = 'default-redirect-country-RandomLocation';

  // controld's own svg for the random/redirect icon, scaled to 16x16 to match flag size
  const RANDOM_SVG = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style="display:block;">
    <path fill-rule="evenodd" clip-rule="evenodd" d="M23.834 10C22.882 4.325 17.946 0 12 0 5.373 0 0 5.373 0 12c0 3.073 1.155 5.877 3.056 8 .445.497.931.958 1.453 1.375A11.96 11.96 0 0 0 9 23.622V11l.02-.04c.044-1.157.162-2.25.337-3.252C10.207 7.9 11.092 8 12 8c.908 0 1.793-.1 2.643-.292.126.72.222 1.488.283 2.292h2.005a25.585 25.585 0 0 0-.365-2.899 11.99 11.99 0 0 0 2.925-1.726A9.969 9.969 0 0 1 21.8 10h2.034zm-16.4 6.9a11.991 11.991 0 0 0-2.925 1.725A9.96 9.96 0 0 1 2.049 13h4.968c.048 1.379.192 2.692.417 3.9zM6 20a9.997 9.997 0 0 1 1.903-1.124c.294 1.01.652 1.905 1.059 2.654A9.97 9.97 0 0 1 5.999 20zM12 6c.756 0 1.492-.084 2.2-.242a13.568 13.568 0 0 0-.51-1.474c-.393-.941-.809-1.572-1.169-1.937a1.533 1.533 0 0 0-.395-.308A.286.286 0 0 0 12 2c-.01 0-.048 0-.126.039-.086.042-.221.13-.395.308-.36.365-.776.996-1.168 1.937-.186.445-.357.938-.51 1.474C10.507 5.916 11.243 6 12 6zm3.039-3.53c.407.749.765 1.645 1.06 2.655A9.993 9.993 0 0 0 18 4a9.969 9.969 0 0 0-2.962-1.53zm-6.078 0c-.407.75-.765 1.645-1.06 2.655A9.994 9.994 0 0 1 6 4 9.97 9.97 0 0 1 8.96 2.47zM7.017 11c.048-1.379.192-2.692.417-3.899A11.992 11.992 0 0 1 4.51 5.375 9.96 9.96 0 0 0 2.049 11h4.968z" fill="currentColor"/>
    <path fill-rule="evenodd" clip-rule="evenodd" d="M13 12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-9zm7.942 1.058a.625.625 0 0 0-.884.884l.458.458c-1.338.14-2.56.85-3.342 1.964l-.384.548-.627-.892A3.865 3.865 0 0 0 13 14.375a.625.625 0 1 0 0 1.25c.852 0 1.65.415 2.14 1.113L16.026 18l-.886 1.262A2.615 2.615 0 0 1 13 20.375a.625.625 0 1 0 0 1.25c1.26 0 2.44-.614 3.163-1.645l.627-.892.384.548a4.675 4.675 0 0 0 3.342 1.964l-.458.458a.625.625 0 0 0 .884.884L22.884 21l-1.942-1.942a.625.625 0 0 0-.884.884l.388.388a3.425 3.425 0 0 1-2.249-1.412L17.553 18l.644-.918a3.425 3.425 0 0 1 2.249-1.412l-.388.388a.625.625 0 0 0 .884.884L22.884 15l-1.942-1.942z" fill="currentColor"/>
  </svg>`;

  // checkmark svg matching controld's style
  const CHECKMARK_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="color: rgb(29, 191, 115);">
    <path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
  </svg>`;

  let observer = null;

  // figure out which type of modal we're looking at
  function detectModalContext(scrollList) {
    if (scrollList.querySelector('button[data-testid^="services-country-"]')) {
      return 'services';
    }
    if (scrollList.querySelector('button[data-testid^="default-redirect-country-"]')) {
      return 'default';
    }
    return null;
  }

  function getCountryBtnSelector(context) {
    if (context === 'default') return 'button[data-testid^="default-redirect-country-"]';
    return 'button[data-testid^="services-country-"]';
  }

  function getRandomTestId(context) {
    if (context === 'default') return RANDOM_DEFAULT_TEST_ID;
    return RANDOM_TEST_ID;
  }

  // watches for the destinations modal to appear and injects the random option
  function startObserver() {
    if (observer) return;
    observer = new MutationObserver(() => {
      const modal = document.querySelector('.modal-dialog');
      if (!modal) return;

      const scrollList = modal.querySelector('.show-scrollbar');
      if (!scrollList) return;

      const context = detectModalContext(scrollList);
      if (!context) return;

      const selector = getCountryBtnSelector(context);
      const randomTestId = getRandomTestId(context);

      if (scrollList.querySelector(`[data-testid="${randomTestId}"]`)) return;

      const allCountryBtns = scrollList.querySelectorAll(selector);
      if (!allCountryBtns.length) return;

      // prefer cloning a non-selected button so we don't inherit active/expanded
      // styles (green text, missing chevron, etc). the selected button often has a
      // different css class than the rest.
      const firstBtn = allCountryBtns[0];
      let referenceBtn = firstBtn;
      if (allCountryBtns.length > 1) {
        const firstClass = firstBtn.className;
        const secondClass = allCountryBtns[1].className;
        if (firstClass !== secondClass) {
          referenceBtn = allCountryBtns[1];
        }
      }

      injectRandomOption(scrollList, referenceBtn, firstBtn, context);
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  function injectRandomOption(scrollList, referenceBtn, firstBtn, context) {
    const randomTestId = getRandomTestId(context);

    // clone a real button to inherit all css classes and structure
    const clone = referenceBtn.cloneNode(true);

    clone.setAttribute('data-testid', randomTestId);
    clone.setAttribute('aria-label', `select ${RANDOM_LABEL}`);

    // swap the flag <img> for the controld random svg icon
    const img = clone.querySelector('img');
    if (img) {
      const iconWrapper = document.createElement('span');
      iconWrapper.innerHTML = RANDOM_SVG;
      // match the native flag color
      iconWrapper.style.cssText = 'color: rgba(232, 239, 255, 0.6); display: flex; align-items: center;';
      img.replaceWith(iconWrapper);
    }

    // remove dropdown chevron (img on services page, svg on profile options page)
    clone.querySelectorAll('img').forEach(el => el.remove());
    const chevronSvg = clone.querySelector('[data-testid="proxy-country-close"]');
    if (chevronSvg) chevronSvg.remove();

    // replace the country name text
    const nameSpan = clone.querySelector('span[aria-label*="show tooltip"]');
    if (nameSpan) {
      nameSpan.textContent = RANDOM_LABEL;
      nameSpan.setAttribute('aria-label', `show tooltip: ${RANDOM_LABEL}`);
    }

    // update the proxy-country data-testid on the inner div
    const proxyDiv = clone.querySelector('[data-testid^="proxy-country-"]');
    if (proxyDiv) {
      proxyDiv.setAttribute('data-testid', 'proxy-country-RandomLocation');
    }

    // remove any existing checkmark
    const checkmark = clone.querySelector('.right-svg');
    if (checkmark) checkmark.remove();

    const container = firstBtn.parentElement;

    // deep clone to strip inherited react event handlers
    const freshClone = clone.cloneNode(true);

    freshClone.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      handleRandomSelect(freshClone, context);
    });

    container.insertBefore(freshClone, container.firstChild);

    // the native list uses border-top on every item except the first to create
    // separators. since we inserted above the first item, that item (now second)
    // needs a border-top to maintain the separator pattern.
    const firstBtnProxyDiv = firstBtn.querySelector('[data-testid^="proxy-country-"]');
    if (firstBtnProxyDiv) {
      firstBtnProxyDiv.style.borderTop = '1px solid var(--theme-ui-colors-white15, rgba(255, 255, 255, 0.15))';
    }

    // show checkmark if already set to random
    checkCurrentVia(freshClone, context);
  }

  function getApiUrl(context) {
    const profileId = getProfileId();
    if (!profileId) return null;

    if (context === 'default') {
      return `https://api.controld.com/profiles/${profileId}/default`;
    }
    const serviceName = getServiceName();
    if (!serviceName) return null;
    return `https://api.controld.com/profiles/${profileId}/services/${serviceName}`;
  }

  async function checkCurrentVia(btnElement, context) {
    const url = getApiUrl(context);
    if (!url) return;

    const token = getSessionToken();
    if (!token) return;

    try {
      const resp = await fetch(url, {
        headers: { 'Authorization': 'Bearer ' + token }
      });
      const data = await resp.json();

      let isRandom = false;

      if (context === 'default') {
        isRandom = data?.body?.default?.via === RANDOM_VIA;
      } else if (data?.body?.services) {
        const serviceName = getServiceName();
        isRandom = data.body.services.some(s => {
          const pk = s.PK || '';
          const name = (typeof s.name === 'string' ? s.name : '').toLowerCase();
          const via = s.via || s.action?.via || '';
          return (pk === serviceName || name === serviceName) && via === RANDOM_VIA;
        });
      }

      if (isRandom) {
        addCheckmark(btnElement);
      }
    } catch (err) {
      // silently fail
    }
  }

  function addCheckmark(btnElement) {
    // avoid duplicates
    if (btnElement.querySelector('.right-svg')) return;

    // find the inner row div (same level as the flag+name container)
    const proxyDiv = btnElement.querySelector('[data-testid^="proxy-country-"]');
    if (!proxyDiv) return;

    const check = document.createElement('div');
    check.className = 'right-svg';
    check.innerHTML = CHECKMARK_SVG;
    proxyDiv.appendChild(check);
  }

  async function handleRandomSelect(btnElement, context) {
    const url = getApiUrl(context);
    if (!url) {
      console.warn('[ControlD Random] could not determine api url from context');
      return;
    }

    const token = getSessionToken();
    if (!token) {
      console.warn('[ControlD Random] no session token found');
      return;
    }

    try {
      const resp = await fetch(url, {
        method: 'PUT',
        headers: {
          'Authorization': 'Bearer ' + token,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ status: 1, do: 3, via: RANDOM_VIA })
      });
      const data = await resp.json();

      if (data.success) {
        // remove checkmarks from all other items in the list
        const modal = document.querySelector('.modal-dialog');
        if (modal) {
          modal.querySelectorAll('.right-svg').forEach(el => el.remove());
        }

        // add checkmark to our button
        addCheckmark(btnElement);

        // close modal
        const closeBtn = document.querySelector(
          '.modal-dialog button[data-testid="dialog-close-button"], ' +
          '.modal-dialog button[data-testid="close-button"], ' +
          '.modal-dialog button[aria-label*="Close"], ' +
          '.modal-dialog button[aria-label*="close"]'
        );
        if (closeBtn) closeBtn.click();

        // reload to sync the ui state
        setTimeout(() => location.reload(), 300);
      } else {
        console.error('[ControlD Random] api error:', data.error?.message);
      }
    } catch (err) {
      console.error('[ControlD Random] request failed:', err);
    }
  }

  function getProfileId() {
    const urlMatch = window.location.pathname.match(/profiles\/([^/]+)/);
    return urlMatch?.[1] || null;
  }

  function getServiceName() {
    let serviceName = window.__controld_random_serviceName || null;
    if (!serviceName) {
      serviceName = findServiceFromReact();
    }
    return serviceName;
  }

  function findServiceFromReact() {
    const modal = document.querySelector('.modal-dialog');
    if (!modal) return null;

    const fiberKey = Object.keys(modal).find(k =>
      k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')
    );
    if (!fiberKey) return null;

    let fiber = modal[fiberKey];
    let depth = 0;
    while (fiber && depth < 50) {
      const props = fiber.memoizedProps || fiber.pendingProps;
      if (props) {
        if (props.servicePK || props.service?.PK || props.serviceId) {
          return props.servicePK || props.service?.PK || props.serviceId;
        }
        if (typeof props.serviceName === 'string') {
          return props.serviceName;
        }
      }
      let state = fiber.memoizedState;
      let stateDepth = 0;
      while (state && stateDepth < 10) {
        if (state.memoizedState && typeof state.memoizedState === 'object') {
          const s = state.memoizedState;
          if (s.servicePK || s.PK) return s.servicePK || s.PK;
        }
        state = state.next;
        stateDepth++;
      }
      fiber = fiber.return;
      depth++;
    }
    return null;
  }

  function getSessionToken() {
    try {
      const session = JSON.parse(localStorage.getItem('persist:session') || '{}');
      return JSON.parse(session.sessionToken || '""') || null;
    } catch {
      return null;
    }
  }

  // capture which service card was clicked before the modal opens
  function interceptGlobeClicks() {
    document.addEventListener('click', (e) => {
      const btn = e.target.closest(
        'button[data-testid^="proxy-list-button"], button[aria-label*="Open Proxy List"]'
      );
      if (btn) {
        const card = btn.closest('[data-testid^="service-list-item-"]');
        if (card) {
          const name = card.getAttribute('data-testid').replace('service-list-item-', '');
          if (name) window.__controld_random_serviceName = name;
        }
      }
    }, true);
  }

  // also capture service name from fetch calls the app makes when opening the modal
  function interceptServiceFetch() {
    const origFetch = window.fetch;
    window.fetch = function (...args) {
      const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
      if (url) {
        const match = url.match(/\/profiles\/[^/]+\/services\/([^/?]+)$/);
        if (match && match[1] !== 'categories') {
          window.__controld_random_serviceName = match[1];
        }
      }
      return origFetch.apply(this, args);
    };
  }

  // handle spa navigation by watching for url changes
  function watchNavigation() {
    // patch pushState and replaceState so we can react to route changes
    const origPush = history.pushState;
    const origReplace = history.replaceState;

    history.pushState = function () {
      origPush.apply(this, arguments);
      window.dispatchEvent(new Event('controld-nav'));
    };
    history.replaceState = function () {
      origReplace.apply(this, arguments);
      window.dispatchEvent(new Event('controld-nav'));
    };

    // also catch back/forward
    window.addEventListener('popstate', () => {
      window.dispatchEvent(new Event('controld-nav'));
    });

    // on any nav event, make sure the observer is running
    window.addEventListener('controld-nav', () => {
      // clear stale service name when navigating away
      if (!window.location.pathname.includes('/services')) {
        window.__controld_random_serviceName = null;
      }
      // (re-)start observer just in case
      startObserver();
    });
  }

  // init
  interceptServiceFetch();
  interceptGlobeClicks();
  watchNavigation();
  startObserver();
})();