bzdno / AmneziaVPN Device Renamer

// ==UserScript==
// @name         AmneziaVPN Device Renamer
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Adds local device renaming to the AmneziaVPN control panel
// @match        https://cp.amnezia.org/*
// @grant        none
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  const STORAGE_KEY = 'amnezia_device_names';

  function getNames() {
    try {
      return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
    } catch {
      return {};
    }
  }

  function saveName(uuid, name) {
    const names = getNames();
    if (name.trim()) {
      names[uuid] = name.trim();
    } else {
      delete names[uuid];
    }
    localStorage.setItem(STORAGE_KEY, JSON.stringify(names));
  }

  function extractUUID(tagText) {
    const match = tagText.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
    return match ? match[1] : null;
  }

  function exportNames() {
    const names = getNames();
    const json = JSON.stringify(names, null, 2);
    const blob = new Blob([json], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'amnezia_device_names.json';
    a.click();
    URL.revokeObjectURL(url);
  }

  function importNames() {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'application/json';
    input.addEventListener('change', () => {
      const file = input.files[0];
      if (!file) return;
      const reader = new FileReader();
      reader.onload = (e) => {
        try {
          const data = JSON.parse(e.target.result);
          if (typeof data !== 'object' || Array.isArray(data)) throw new Error();
          // Merge with existing names, imported values take precedence
          const merged = { ...getNames(), ...data };
          localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
          location.reload();
        } catch {
          alert('Invalid backup file.');
        }
      };
      reader.readAsText(file);
    });
    input.click();
  }

  function injectToolbar() {
    if (document.getElementById('amnezia-renamer-toolbar')) return;

    const header = document.querySelector('[class*="expandHeader"]');
    if (!header) return;

    const toolbar = document.createElement('div');
    toolbar.id = 'amnezia-renamer-toolbar';

    const exportBtn = document.createElement('button');
    exportBtn.className = 'amnezia-toolbar-btn';
    exportBtn.textContent = '⬇️ Export names';
    exportBtn.title = 'Download device names as a JSON backup file';
    exportBtn.addEventListener('click', (e) => { e.stopPropagation(); exportNames(); });

    const importBtn = document.createElement('button');
    importBtn.className = 'amnezia-toolbar-btn';
    importBtn.textContent = '⬆️ Import names';
    importBtn.title = 'Restore device names from a JSON backup file';
    importBtn.addEventListener('click', (e) => { e.stopPropagation(); importNames(); });

    toolbar.appendChild(exportBtn);
    toolbar.appendChild(importBtn);

    // Insert between text section and close button
    const expandBtn = header.querySelector('[class*="ExpandButton"]');
    header.insertBefore(toolbar, expandBtn || null);
  }

  function injectStyles() {
    if (document.getElementById('amnezia-renamer-styles')) return;
    const style = document.createElement('style');
    style.id = 'amnezia-renamer-styles';
    style.textContent = `
      .amnezia-custom-name {
        font-weight: 600;
        color: #a78bfa;
        font-size: 13px;
        margin-bottom: 2px;
      }
      .amnezia-rename-btn {
        background: none;
        border: none;
        cursor: pointer;
        padding: 2px 6px;
        font-size: 11px;
        color: #9ca3af;
        border-radius: 4px;
        transition: color 0.15s, background 0.15s;
        vertical-align: middle;
      }
      .amnezia-rename-btn:hover {
        color: #a78bfa;
        background: rgba(167,139,250,0.1);
      }
      .amnezia-rename-input {
        font-size: 13px;
        padding: 2px 6px;
        border: 1px solid #a78bfa;
        border-radius: 4px;
        background: transparent;
        color: inherit;
        outline: none;
        width: 160px;
      }
      .amnezia-rename-save {
        background: #a78bfa;
        border: none;
        color: #fff;
        font-size: 11px;
        padding: 2px 8px;
        border-radius: 4px;
        cursor: pointer;
        margin-left: 4px;
      }
      .amnezia-rename-save:hover {
        background: #7c3aed;
      }
      .amnezia-rename-cancel {
        background: none;
        border: none;
        color: #9ca3af;
        font-size: 11px;
        padding: 2px 6px;
        border-radius: 4px;
        cursor: pointer;
        margin-left: 2px;
      }
      #amnezia-renamer-toolbar {
        display: flex;
        gap: 8px;
        align-items: center;
        margin: 0 12px;
      }
      .amnezia-toolbar-btn {
        background: transparent;
        border: 1px solid #a78bfa;
        color: #a78bfa;
        font-size: 11px;
        padding: 4px 10px;
        border-radius: 6px;
        cursor: pointer;
        transition: background 0.15s, color 0.15s;
        white-space: nowrap;
      }
      .amnezia-toolbar-btn:hover {
        background: #a78bfa;
        color: #fff;
      }
    `;
    document.head.appendChild(style);
  }

  function processDevice(container) {
    if (container.dataset.amneziaPatched) return;
    container.dataset.amneziaPatched = '1';

    const uuidEl = container.querySelector('[class*="uuidText"]');
    if (!uuidEl) return;

    const uuid = extractUUID(uuidEl.textContent);
    if (!uuid) return;

    const labelEl = container.querySelector('[class*="LabelText"]');
    if (!labelEl) return;

    const names = getNames();
    const customName = names[uuid];

    // Insert row with custom name and rename button
    const nameRow = document.createElement('div');
    nameRow.style.display = 'flex';
    nameRow.style.alignItems = 'center';
    nameRow.style.gap = '4px';
    nameRow.style.marginBottom = '2px';

    const customNameEl = document.createElement('span');
    customNameEl.className = 'amnezia-custom-name';
    customNameEl.textContent = customName || '';
    customNameEl.style.display = customName ? 'inline' : 'none';

    const renameBtn = document.createElement('button');
    renameBtn.className = 'amnezia-rename-btn';
    renameBtn.textContent = customName ? '✏️ rename' : '✏️ set name';
    renameBtn.title = 'Rename this device (stored locally in your browser)';

    nameRow.appendChild(customNameEl);
    nameRow.appendChild(renameBtn);

    // Insert before the platform label
    labelEl.parentNode.insertBefore(nameRow, labelEl);

    renameBtn.addEventListener('click', () => {
      const currentName = getNames()[uuid] || '';

      // Show inline edit form
      const form = document.createElement('span');
      form.style.display = 'inline-flex';
      form.style.alignItems = 'center';

      const input = document.createElement('input');
      input.className = 'amnezia-rename-input';
      input.value = currentName;
      input.placeholder = 'Enter device name';

      const saveBtn = document.createElement('button');
      saveBtn.className = 'amnezia-rename-save';
      saveBtn.textContent = 'Save';

      const cancelBtn = document.createElement('button');
      cancelBtn.className = 'amnezia-rename-cancel';
      cancelBtn.textContent = 'Cancel';

      form.appendChild(input);
      form.appendChild(saveBtn);
      form.appendChild(cancelBtn);

      nameRow.replaceWith(form);
      input.focus();
      input.select();

      function applyRename() {
        const newName = input.value.trim();
        saveName(uuid, newName);
        customNameEl.textContent = newName;
        customNameEl.style.display = newName ? 'inline' : 'none';
        renameBtn.textContent = newName ? '✏️ rename' : '✏️ set name';
        nameRow.replaceChildren(customNameEl, renameBtn);
        form.replaceWith(nameRow);
      }

      function cancelRename() {
        form.replaceWith(nameRow);
      }

      saveBtn.addEventListener('click', applyRename);
      cancelBtn.addEventListener('click', cancelRename);
      input.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') applyRename();
        if (e.key === 'Escape') cancelRename();
      });
    });
  }

  function processAll() {
    const containers = document.querySelectorAll('[class*="deviceContainer"]');
    containers.forEach(processDevice);
  }

  function init() {
    injectStyles();
    injectToolbar();
    processAll();

    // Watch for DOM changes — device list may render asynchronously (React)
    const observer = new MutationObserver(() => {
      injectToolbar();
      processAll();
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  // Wait for the React app to be ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();