Greasy Fork

密码显示助手

通过鼠标悬浮/双击/始终显示来显示密码框内容 可通过脚本菜单或快捷键 Ctrl/Meta+Alt+P 选择触发方式

// ==UserScript==
// @name               Password Revealer
// @name:zh-CN         密码显示助手
// @name:zh-TW         密碼顯示助手
// @description        Reveal Passwords By Hovering/DoubleClicking/Always Show Select Mode Via The Tampermonkey Menu Or Shortcut Ctrl/Meta+Alt+P.
// @description:zh-CN  通过鼠标悬浮/双击/始终显示来显示密码框内容 可通过脚本菜单或快捷键 Ctrl/Meta+Alt+P 选择触发方式
// @description:zh-TW  透過滑鼠懸浮/雙擊/始終顯示來顯示密碼框內容 可透過腳本選單或快捷鍵 Ctrl/Meta+Alt+P 選擇觸發方式
// @version            1.3.0
// @icon               https://raw.githubusercontent.com/MiPoNianYou/UserScripts/refs/heads/main/Icons/PasswordRevealerIcon.svg
// @author             念柚
// @namespace          https://github.com/MiPoNianYou/UserScripts
// @supportURL         https://github.com/MiPoNianYou/UserScripts/issues
// @license            GPL-3.0
// @match              *://*/*
// @grant              GM_getValue
// @grant              GM_setValue
// @grant              GM_registerMenuCommand
// @grant              GM_unregisterMenuCommand
// @grant              GM_addStyle
// @run-at             document-idle
// ==/UserScript==

(function () {
  "use strict";

  const MODE_KEY = "PasswordDisplayMode";
  const MODE_HOVER = "Hover";
  const MODE_DBLCLICK = "DoubleClick";
  const MODE_ALWAYS_SHOW = "AlwaysShow";
  const NOTIFICATION_ID = "PasswordRevealerNotification";
  const NOTIFICATION_TIMEOUT = 2000;
  const ANIMATION_DURATION = 300;
  const SCRIPT_ICON_URL =
    "https://raw.githubusercontent.com/MiPoNianYou/UserScripts/refs/heads/main/Icons/PasswordRevealerIcon.svg";
  const PROCESSED_ATTRIBUTE = "data-password-revealer-processed";

  const VALID_MODES = [MODE_HOVER, MODE_DBLCLICK, MODE_ALWAYS_SHOW];

  const LOCALIZATION = {
    "en-US": {
      ScriptTitle: "Password Revealer",
      MenuCmdSetHover: "「Hover」Mode",
      MenuCmdSetDBClick: "「Double Click」Mode",
      MenuCmdSetAlwaysShow: "「Always Show」Mode",
      AlertMessages: {
        [MODE_HOVER]: "Mode Switched To 「Hover」",
        [MODE_DBLCLICK]: "Mode Switched To 「Double Click」",
        [MODE_ALWAYS_SHOW]: "Mode Switched To 「Always Show」",
      },
    },
    "zh-CN": {
      ScriptTitle: "密码显示助手",
      MenuCmdSetHover: "「悬浮显示」模式",
      MenuCmdSetDBClick: "「双击切换」模式",
      MenuCmdSetAlwaysShow: "「始终显示」模式",
      AlertMessages: {
        [MODE_HOVER]: "模式已切换为「悬浮显示」",
        [MODE_DBLCLICK]: "模式已切换为「双击切换」",
        [MODE_ALWAYS_SHOW]: "模式已切换为「始终显示」",
      },
    },
    "zh-TW": {
      ScriptTitle: "密碼顯示助手",
      MenuCmdSetHover: "「懸浮顯示」模式",
      MenuCmdSetDBClick: "「雙擊切換」模式",
      MenuCmdSetAlwaysShow: "「始終顯示」模式",
      AlertMessages: {
        [MODE_HOVER]: "模式已切換為「懸浮顯示」",
        [MODE_DBLCLICK]: "模式已切換為「雙擊切換」",
        [MODE_ALWAYS_SHOW]: "模式已切換為「始終顯示」",
      },
    },
  };

  const MODE_MENU_TEXT_KEYS = {
    [MODE_HOVER]: "MenuCmdSetHover",
    [MODE_DBLCLICK]: "MenuCmdSetDBClick",
    [MODE_ALWAYS_SHOW]: "MenuCmdSetAlwaysShow",
  };

  let registeredMenuCommandIds = [];
  let notificationTimer = null;
  let removalTimer = null;
  let currentMode = GM_getValue(MODE_KEY, MODE_HOVER);

  function getLanguageKey() {
    const lang = navigator.language;
    if (lang.startsWith("zh")) {
      return lang === "zh-TW" || lang === "zh-HK" || lang === "zh-Hant"
        ? "zh-TW"
        : "zh-CN";
    }
    return "en-US";
  }

  function getLocalizedText(key, subKey = null, fallbackLang = "en-US") {
    const langKey = getLanguageKey();
    const primaryLangData = LOCALIZATION[langKey] || LOCALIZATION[fallbackLang];
    const fallbackLangData = LOCALIZATION[fallbackLang];

    let value;
    if (subKey && key === "AlertMessages") {
      value = primaryLangData[key]?.[subKey] ?? fallbackLangData[key]?.[subKey];
    } else {
      value = primaryLangData[key] ?? fallbackLangData[key];
    }
    return value ?? (subKey ? `${key}.${subKey}?` : `${key}?`);
  }

  function injectNotificationStyles() {
    GM_addStyle(`
      #${NOTIFICATION_ID} {
        position: fixed;
        top: 20px;
        right: -400px;
        width: 300px;
        background-color: rgba(240, 240, 240, 0.9);
        color: #333;
        padding: 10px;
        border-radius: 10px;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        z-index: 99999;
        box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
        display: flex;
        align-items: flex-start;
        opacity: 0;
        transition: right ${ANIMATION_DURATION}ms ease-out, opacity ${
      ANIMATION_DURATION * 0.8
    }ms ease-out;
        box-sizing: border-box;
        backdrop-filter: blur(8px);
        -webkit-backdrop-filter: blur(8px);
      }

      #${NOTIFICATION_ID}.visible {
        right: 20px;
        opacity: 1;
      }

      #${NOTIFICATION_ID} .pr-icon {
        width: 32px;
        height: 32px;
        margin-right: 10px;
        flex-shrink: 0;
      }

      #${NOTIFICATION_ID} .pr-content {
        display: flex;
        flex-direction: column;
        flex-grow: 1;
        min-width: 0;
      }

      #${NOTIFICATION_ID} .pr-title {
        font-size: 13px;
        font-weight: 600;
        margin-bottom: 2px;
        color: #111;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }

      #${NOTIFICATION_ID} .pr-message {
        font-size: 12px;
        line-height: 1.3;
        color: #444;
        word-wrap: break-word;
        overflow-wrap: break-word;
      }

      @media (prefers-color-scheme: dark) {
        #${NOTIFICATION_ID} {
          background-color: rgba(50, 50, 50, 0.85);
          color: #eee;
          box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
        }
        #${NOTIFICATION_ID} .pr-title {
          color: #f0f0f0;
        }
        #${NOTIFICATION_ID} .pr-message {
          color: #ccc;
        }
      }
    `);
  }

  function showNotification(message) {
    if (notificationTimer) clearTimeout(notificationTimer);
    if (removalTimer) clearTimeout(removalTimer);

    const existingNotification = document.getElementById(NOTIFICATION_ID);
    if (existingNotification) {
      existingNotification.remove();
    }

    const notificationElement = document.createElement("div");
    notificationElement.id = NOTIFICATION_ID;
    notificationElement.innerHTML = `
      <img src="${SCRIPT_ICON_URL}" alt="" class="pr-icon">
      <div class="pr-content">
        <div class="pr-title">${getLocalizedText("ScriptTitle")}</div>
        <div class="pr-message">${message}</div>
      </div>
    `;

    document.body.appendChild(notificationElement);

    requestAnimationFrame(() => {
      notificationElement.classList.add("visible");
    });

    notificationTimer = setTimeout(() => {
      notificationElement.classList.remove("visible");
      removalTimer = setTimeout(() => {
        if (notificationElement.parentNode) {
          notificationElement.remove();
        }
        notificationTimer = null;
        removalTimer = null;
      }, ANIMATION_DURATION);
    }, NOTIFICATION_TIMEOUT);
  }

  function processPasswordInput(input, mode) {
    if (
      !(input instanceof HTMLInputElement) ||
      input.type === "hidden" ||
      input.hasAttribute(PROCESSED_ATTRIBUTE)
    ) {
      return;
    }

    if (mode === MODE_ALWAYS_SHOW) {
      input.type = "text";
    } else {
      if (input.type !== "password") {
        input.type = "password";
      }
    }
    input.setAttribute(PROCESSED_ATTRIBUTE, mode);
  }

  function findAllPasswordInputs(rootNode) {
    const results = [];
    try {
      rootNode
        .querySelectorAll(
          `input[type="password"]:not([${PROCESSED_ATTRIBUTE}])`
        )
        .forEach((input) => results.push(input));

      rootNode.querySelectorAll("*").forEach((el) => {
        if (el.shadowRoot) {
          results.push(...findAllPasswordInputs(el.shadowRoot));
        }
      });
    } catch (e) {}
    return results;
  }

  function setMode(newMode) {
    if (currentMode === newMode || !VALID_MODES.includes(newMode)) {
      return;
    }

    const oldMode = currentMode;
    currentMode = newMode;
    GM_setValue(MODE_KEY, currentMode);

    const alertMessage = getLocalizedText("AlertMessages", currentMode);
    showNotification(alertMessage);

    const processedInputs = document.querySelectorAll(
      `input[${PROCESSED_ATTRIBUTE}]`
    );
    processedInputs.forEach((input) => {
      input.setAttribute(PROCESSED_ATTRIBUTE, currentMode);
      if (currentMode === MODE_ALWAYS_SHOW) {
        input.type = "text";
      } else if (oldMode === MODE_ALWAYS_SHOW) {
        input.type = "password";
      }
    });

    const untrackedPasswordInputs = findAllPasswordInputs(document.body);
    untrackedPasswordInputs.forEach((input) =>
      processPasswordInput(input, currentMode)
    );

    registerModeMenuCommands();
  }

  function registerModeMenuCommands() {
    registeredMenuCommandIds.forEach((id) => {
      try {
        GM_unregisterMenuCommand(id);
      } catch (e) {}
    });
    registeredMenuCommandIds = [];

    VALID_MODES.forEach((mode) => {
      const menuKey = MODE_MENU_TEXT_KEYS[mode];
      const baseText = getLocalizedText(menuKey);
      const commandText = baseText + (mode === currentMode ? " ✅" : "");

      const commandId = GM_registerMenuCommand(commandText, () =>
        setMode(mode)
      );
      registeredMenuCommandIds.push(commandId);
    });
  }

  function showPasswordOnHover(event) {
    const input = event.target;
    if (
      currentMode === MODE_HOVER &&
      input.matches(
        `input[type="password"][${PROCESSED_ATTRIBUTE}="${MODE_HOVER}"]`
      )
    ) {
      input.type = "text";
    }
  }

  function hidePasswordOnLeave(event) {
    const input = event.target;
    if (
      currentMode === MODE_HOVER &&
      input.matches(
        `input[type="text"][${PROCESSED_ATTRIBUTE}="${MODE_HOVER}"]`
      )
    ) {
      input.type = "password";
    }
  }

  function togglePasswordOnDoubleClick(event) {
    const input = event.target;
    if (
      currentMode === MODE_DBLCLICK &&
      input.matches(`input[${PROCESSED_ATTRIBUTE}="${MODE_DBLCLICK}"]`)
    ) {
      input.type = input.type === "password" ? "text" : "password";
    }
  }

  function initializeEventListeners() {
    document.body.addEventListener("mouseenter", showPasswordOnHover, true);
    document.body.addEventListener("mouseleave", hidePasswordOnLeave, true);
    document.body.addEventListener("dblclick", togglePasswordOnDoubleClick);
  }

  function handleKeyDown(event) {
    if (
      (event.ctrlKey || event.metaKey) &&
      event.altKey &&
      event.code === "KeyP"
    ) {
      event.preventDefault();
      event.stopPropagation();

      const currentIndex = VALID_MODES.indexOf(currentMode);
      const nextIndex = (currentIndex + 1) % VALID_MODES.length;
      const nextMode = VALID_MODES[nextIndex];

      setMode(nextMode);
    }
  }

  const observerCallback = (mutationsList) => {
    for (const mutation of mutationsList) {
      if (mutation.type === "childList") {
        mutation.addedNodes.forEach((node) => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            const inputsToProcess = findAllPasswordInputs(node);
            inputsToProcess.forEach((input) =>
              processPasswordInput(input, currentMode)
            );
          }
        });
      } else if (
        mutation.type === "attributes" &&
        mutation.attributeName === "type"
      ) {
        const targetInput = mutation.target;
        if (
          targetInput.nodeType === Node.ELEMENT_NODE &&
          targetInput.matches &&
          targetInput.matches('input[type="password"]') &&
          !targetInput.hasAttribute(PROCESSED_ATTRIBUTE)
        ) {
          processPasswordInput(targetInput, currentMode);
        }
      }
    }
  };

  const observer = new MutationObserver(observerCallback);

  if (!VALID_MODES.includes(currentMode)) {
    currentMode = MODE_HOVER;
    GM_setValue(MODE_KEY, currentMode);
  }

  injectNotificationStyles();
  findAllPasswordInputs(document.body).forEach((input) =>
    processPasswordInput(input, currentMode)
  );

  initializeEventListeners();
  document.addEventListener("keydown", handleKeyDown, true);

  observer.observe(document.body, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ["type"],
  });

  registerModeMenuCommands();
})();