Greasy Fork

来自缓存

Greasy Fork is available in English.

TOTP Two-Factor Authentication Helper

TOTP助手,支持直接识别二维码,可用于GitHub等需要双因素认证的网站

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         TOTP Two-Factor Authentication Helper
// @namespace    http://tampermonkey.net/
// @version      1.9.0
// @description  TOTP助手,支持直接识别二维码,可用于GitHub等需要双因素认证的网站
// @author       Bayn-web (https://github.com/bayn-web)
// @license      MIT
// @match        *://*/*
// @noframes
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_addValueChangeListener
// @grant        GM_addElement
// @grant        unsafeWindow
// @grant        GM_registerMenuCommand
// @grant        GM_notification
// @run-at       document-end
// ==/UserScript==

(function () {
  "use strict";
  // ==================== 加密工具类 ====================
  class CryptoUtils {
    // Base32 解码
    static base32Decode(str) {
      str = str.replace(/=+$/, "").toUpperCase();
      const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
      const bytes = [];
      let bits = 0;
      let value = 0;

      for (let i = 0; i < str.length; i++) {
        const c = str[i];
        const index = alphabet.indexOf(c);
        if (index === -1) {
          throw new Error("Invalid base32 character: " + c);
        }
        value = (value << 5) | index;
        bits += 5;
        if (bits >= 8) {
          bytes.push((value >>> (bits - 8)) & 255);
          bits -= 8;
        }
      }
      return new Uint8Array(bytes);
    }

    // 使用 Web Crypto API 实现 HMAC-SHA1
    static async hmacSha1(key, message) {
      const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]);
      const signature = await crypto.subtle.sign("HMAC", cryptoKey, message);
      return new Uint8Array(signature);
    }

    // 数字转字节数组(大端序)
    static intToBytes(num) {
      const bytes = new Uint8Array(8);
      for (let i = 7; i >= 0; i--) {
        bytes[i] = num & 0xff;
        num = num >>> 8;
      }
      return bytes;
    }

    static async generateTOTP(secret, counter = 0, digits = 6) {
      try {
        // 解码 Base32 密钥
        const key = this.base32Decode(secret);

        // 生成 8 字节计数器
        const counterBytes = this.intToBytes(counter);

        // 计算 HMAC-SHA1
        const hmac = await this.hmacSha1(key, counterBytes);

        // 动态截断
        const offset = hmac[hmac.length - 1] & 0x0f;
        const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff);

        // 生成指定位数的代码
        const otp = binary % Math.pow(10, digits);
        return otp.toString().padStart(digits, "0");
      } catch (e) {
        console.error("TOTP generation error:", e);
        throw e;
      }
    }

    // 验证 Base32 格式
    static isValidBase32(str) {
      return /^[A-Z2-7=]+$/i.test(str.replace(/\s/g, ""));
    }
  }

  // ==================== TOTP 生成器 ====================
  class TOTPGenerator {
    constructor() {
      this.keys = GM_getValue("totp_keys", []);
      GM_addValueChangeListener("totp_keys", (name, old_value, new_value, remote) => {
        // 只有当变化来自其他页面时才更新本地数据
        if (remote) {
          this.keys = new_value;
          // 如果有UI实例,更新界面
          if (unsafeWindow.totpUI) {
            unsafeWindow.totpUI.updateList();
          }
        }
      });
    }

    async generateTOTP(secret, timeStep = 30, digits = 6) {
      try {
        const counter = Math.floor(Date.now() / 1000 / timeStep);
        return await CryptoUtils.generateTOTP(secret, counter, digits);
      } catch (e) {
        console.error("Error generating TOTP:", e);
        return "ERROR";
      }
    }

    parseOtpAuthUri(uri) {
      if (!uri || !uri.startsWith("otpauth://")) {
        throw new Error("Invalid URI: must start with otpauth://");
      }

      // 移除 'otpauth://'
      const withoutProtocol = uri.substring(10);

      // 找到第一个 '/' 的位置(分隔 type 和 label+query)
      const firstSlashIndex = withoutProtocol.indexOf("/");
      if (firstSlashIndex === -1) {
        throw new Error("Invalid URI: missing path");
      }

      const type = withoutProtocol.substring(0, firstSlashIndex);
      const pathAndQuery = withoutProtocol.substring(firstSlashIndex); // e.g. "/label?secret=..."

      if (type !== "totp") {
        throw new Error(`Only 'totp' is supported, got: ${type}`);
      }

      // 分离 label 和 query string
      const qIndex = pathAndQuery.indexOf("?");
      let labelPart, queryPart;
      if (qIndex === -1) {
        labelPart = pathAndQuery.substring(1); // remove leading '/'
        queryPart = "";
      } else {
        labelPart = pathAndQuery.substring(1, qIndex);
        queryPart = pathAndQuery.substring(qIndex + 1);
      }

      const label = decodeURIComponent(labelPart);

      // 手动解析 query string(或用 URLSearchParams 包装)
      const params = new URLSearchParams(queryPart);
      const secret = params.get("secret");
      if (!secret) {
        throw new Error("Missing 'secret' parameter");
      }

      return {
        label,
        issuer: params.get("issuer") || "",
        secret,
        algorithm: (params.get("algorithm") || "SHA1").toUpperCase(),
        digits: parseInt(params.get("digits"), 10) || 6,
        period: parseInt(params.get("period"), 10) || 30,
      };
    }

    addKey(service, secret, issuer = "") {
      const newKey = {
        id: Date.now(),
        service: service,
        issuer: issuer,
        secret: secret.toUpperCase().replace(/\s/g, ""),
      };
      this.keys.push(newKey);
      this.saveKeys();
      return newKey;
    }

    removeKey(id) {
      this.keys = this.keys.filter((key) => key.id !== id);
      this.saveKeys();
    }

    saveKeys() {
      GM_setValue("totp_keys", this.keys);
    }

    getKeys() {
      return this.keys;
    }

    exportKeys() {
      return JSON.stringify(this.keys, null, 2);
    }

    importKeys(jsonString) {
      try {
        const importedKeys = JSON.parse(jsonString);
        if (Array.isArray(importedKeys)) {
          this.keys = importedKeys;
          this.saveKeys();
          return true;
        }
        return false;
      } catch (e) {
        console.error("Error importing keys:", e);
        return false;
      }
    }

    async getCurrentCodes() {
      const codes = [];
      const currentTime = Math.floor(Date.now() / 1000);

      for (const key of this.keys) {
        const code = await this.generateTOTP(key.secret, key.period || 30);
        codes.push({
          id: key.id,
          service: key.service,
          issuer: key.issuer,
          code: code,
          timeRemaining: (key.period || 30) - (currentTime % (key.period || 30)),
        });
      }
      return codes;
    }
  }

  // ==================== UI 管理器 ====================
  class TOTPUI {
    captureMode = false;
    constructor(generator) {
      this.generator = generator;
      this.isOpen = false;
      this.updateInterval = null;
      this.createUI();
    }

    createUI() {
      this.container = document.createElement("div");
      this.container.id = "totp-container";
      this.container.innerHTML = `
                <div id="totp-card">
                    <div id="totp-header">
                        <h3>🔐 TOTP Helper</h3>
                        <button id="totp-close-btn">&times;</button>
                    </div>
                    <div id="totp-content">
                        <div id="totp-add-section">
                            <button id="totp-add-btn">+ Add New Account</button>
                            <div id="totp-add-form" style="display: none;">
                                <h4>Add New Account</h4>
                                <input type="text" id="totp-service-name" placeholder="Service name (e.g. GitHub)">
                                <input type="text" id="totp-secret-key" placeholder="Secret key (Base32)">
                                <p><small>Example: JBSWY3DPEHPK3PXP</small></p>
                                <button id="totp-save-btn">Save</button>
                                <button id="totp-cancel-btn">Cancel</button>
                                <div id="totp-qr-section">
                                    <p>Or import from otpauth URI:</p>
                                    <input type="text" id="totp-otpauth-uri" placeholder="otpauth://totp/...">
                                    <button id="totp-parse-uri-btn">Parse URI</button>
                                    <p>Or capture QR code from page:</p>
                                    <button id="totp-capture-btn">Capture QR Code</button>
                                </div>
                            </div>
                        </div>
                        <div id="totp-list-section">
                            <div id="totp-list"></div>
                        </div>
                        <div id="totp-export-section">
                            <button id="totp-export-btn">📤 Export</button>
                            <button id="totp-import-btn">📥 Import</button>
                            <textarea id="totp-export-area" style="display: none;"></textarea>
                            <input type="file" id="totp-import-file" style="display: none;" accept=".json">
                        </div>
                    </div>
                </div>
            `;

      this.addStyles();
      document.body.appendChild(this.container);
      this.bindEvents();
    }
    async showToast(message) {
      const alert = document.createElement("sl-alert");
      alert.variant = "primary";
      alert.duration = 3000;
      alert.closable = true;
      alert.innerHTML = `
    <sl-icon slot="icon" name="info-circle"></sl-icon>
    <strong>Info</strong><br>${message}
  `;
      document.body.appendChild(alert);
      await customElements.whenDefined("sl-alert");
      alert.toast();
    }
    addStyles() {
      const style = document.createElement("style");
      style.textContent = `
                .totp-capture-btn-active {
                    background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
                }
                .sl-toast-stack {
                    z-index: 10000;
                }
                #totp-container {
                    height: 66vh;
                    position: fixed;
                    top: 6vh;
                    right: 20px;
                    z-index: 10000;
                }
                #totp-card {
                    display: flex;
                    flex-direction: column;
                    height: 100%;
                    width: 380px;
                    background: #ffffff;
                    border-radius: 12px;
                    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
                    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
                    overflow: hidden;
                    border: 1px solid #e0e0e0;
                }
                #totp-header {
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                    padding: 20px;
                    background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
                    color: white;
                }
                #totp-header h3 {
                    margin: 0;
                    font-size: 18px;
                }
                #totp-close-btn {
                    background: rgba(255, 255, 255, 0.2);
                    border: none;
                    font-size: 22px;
                    cursor: pointer;
                    color: white;
                    width: 30px;
                    height: 30px;
                    border-radius: 50%;
                    display: flex;
                    align-items: center;
                    justify-content: center;
                }
                #totp-content {
                    overflow: auto;
                    height: 100%;
                    padding: 20px;
                    background: #fafafa;
                }
                #totp-add-btn {
                    background: linear-gradient(135deg, #00b09b, #96c93d);
                    color: white;
                    border: none;
                    padding: 12px 20px;
                    border-radius: 8px;
                    cursor: pointer;
                    width: 100%;
                    font-size: 15px;
                }
                #totp-add-form {
                    background: white;
                    padding: 20px;
                    border-radius: 8px;
                    margin-top: 15px;
                    border: 1px solid #eaeaea;
                }
                #totp-add-form input {
                    width: 100%;
                    padding: 12px;
                    margin: 8px 0;
                    border: 1px solid #ddd;
                    border-radius: 6px;
                    box-sizing: border-box;
                }
                #totp-add-form button {
                    padding: 10px 20px;
                    margin-right: 10px;
                    margin-top: 10px;
                    border: none;
                    border-radius: 6px;
                    cursor: pointer;
                }
                #totp-save-btn {
                    background: #2196F3;
                    color: white;
                }
                #totp-cancel-btn {
                    background: #f44336;
                    color: white;
                }
                #totp-parse-uri-btn {
                    background: #9c27b0;
                    color: white;
                    width: 100%;
                }
                .totp-item {
                    background: white;
                    padding: 16px;
                    margin: 15px 0;
                    border-radius: 8px;
                    border-left: 5px solid #2196F3;
                    box-shadow: 0 3px 10px rgba(0,0,0,0.08);
                }
                .totp-item-header {
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                }
                .totp-service {
                    font-weight: 600;
                    color: #333;
                }
                .totp-code {
                    font-family: 'Courier New', monospace;
                    font-size: 24px;
                    letter-spacing: 4px;
                    margin: 12px 0;
                    text-align: center;
                    background: linear-gradient(135deg, #f5f7fa 0%, #e4e7f4 100%);
                    padding: 15px;
                    border-radius: 8px;
                    font-weight: bold;
                    color: #2c3e50;
                    cursor: pointer;
                }
                .totp-code:hover {
                    background: linear-gradient(135deg, #e8f4fd 0%, #c2e0ff 100%);
                }
                .totp-time {
                    text-align: right;
                    font-size: 13px;
                    color: #666;
                    padding-top: 8px;
                    border-top: 1px dashed #eee;
                }
                .totp-delete-btn {
                    background: #ff416c;
                    color: white;
                    border: none;
                    padding: 6px 12px;
                    border-radius: 4px;
                    cursor: pointer;
                }
                #totp-export-section button {
                    background: #795548;
                    color: white;
                    border: none;
                    padding: 10px 16px;
                    border-radius: 6px;
                    cursor: pointer;
                    margin-right: 10px;
                    margin-top: 10px;
                }
                #totp-export-area {
                    width: 100%;
                    height: 100px;
                    margin-top: 10px;
                    border: 1px solid #ddd;
                    border-radius: 6px;
                    font-family: monospace;
                    padding: 10px;
                    box-sizing: border-box;
                }
                .time-warning {
                    color: #f44336;
                    font-weight: bold;
                }
            `;
      document.head.appendChild(style);
    }

    bindEvents() {
      document.getElementById("totp-close-btn").addEventListener("click", () => this.toggleUI());
      document.getElementById("totp-add-btn").addEventListener("click", () => {
        document.getElementById("totp-add-form").style.display = document.getElementById("totp-add-form").style.display == "none" ? "block" : "none";
      });
      document.getElementById("totp-save-btn").addEventListener("click", () => this.saveNewKey());
      document.getElementById("totp-cancel-btn").addEventListener("click", () => {
        document.getElementById("totp-add-form").style.display = "none";
      });
      document.getElementById("totp-parse-uri-btn").addEventListener("click", () => this.parseUri());
      document.getElementById("totp-export-btn").addEventListener("click", () => this.exportData());
      document.getElementById("totp-import-btn").addEventListener("click", () => {
        document.getElementById("totp-import-file").click();
      });
      document.getElementById("totp-import-file").addEventListener("change", (e) => this.importData(e));
      document.getElementById("totp-capture-btn").addEventListener("click", () => {
        this.startCaptureMode();
      });

      // 一次性点击监听
      const handleClick = async (event) => {
        if (!this.captureMode) {
          return;
        }
        if (event.target.tagName !== "IMG") {
          return;
        }
        this.captureMode = false;

        const images = document.querySelectorAll("img");
        // 移除高亮
        images.forEach((img) => {
          img.style.outline = "";
          img.style.outlineOffset = "";
          img.style.cursor = "";
        });

        if (event.target.tagName === "IMG") {
          const img = event.target;

          // 尝试解码
          const result = await this.decodeImageFromElement(img);

          if (result && result.startsWith("otpauth://")) {
            try {
              const parsed = this.generator.parseOtpAuthUri(result);
              document.getElementById("totp-service-name").value = parsed.label || parsed.issuer || "Scanned Account";
              document.getElementById("totp-secret-key").value = parsed.secret;
              document.getElementById("totp-add-form").style.display = "block";
              this.showToast("✅ QR code scanned! Fill in the form and click Save.");
            } catch (parseErr) {
              this.showToast("⚠️ Scanned QR, but invalid otpauth format.");
              console.error("Parse error:", parseErr);
            }
          } else if (result) {
            this.showToast("ℹ️ QR code found, but not a TOTP URI.");
            console.log("Non-TOTP QR content:", result);
          } else {
            this.showToast("❌ No QR code detected in this image.");
          }
        }
      };

      document.addEventListener("click", handleClick);
    }

    saveNewKey() {
      const serviceName = document.getElementById("totp-service-name").value.trim();
      const secretKey = document.getElementById("totp-secret-key").value.trim();

      if (serviceName && secretKey) {
        if (CryptoUtils.isValidBase32(secretKey)) {
          this.generator.addKey(serviceName, secretKey);
          document.getElementById("totp-service-name").value = "";
          document.getElementById("totp-secret-key").value = "";
          document.getElementById("totp-add-form").style.display = "none";
          this.updateList();
          GM_notification({ text: "Account added successfully!", timeout: 2000 });
        } else {
          this.showToast("Invalid Base32 secret key. Please check and try again.");
        }
      } else {
        this.showToast("Please enter both service name and secret key");
      }
    }

    parseUri() {
      const uri = document.getElementById("totp-otpauth-uri").value.trim();
      const parsed = this.generator.parseOtpAuthUri(uri);

      if (parsed) {
        document.getElementById("totp-service-name").value = parsed.label;
        document.getElementById("totp-secret-key").value = parsed.secret;
        GM_notification({ text: "URI parsed successfully!", timeout: 2000 });
      } else {
        this.showToast("Invalid otpauth URI");
      }
    }

    exportData() {
      const exportArea = document.getElementById("totp-export-area");
      if (exportArea.style.display === "none") {
        exportArea.value = this.generator.exportKeys();
        exportArea.style.display = "block";
        exportArea.select();
      } else {
        exportArea.style.display = "none";
      }
    }

    importData(e) {
      const file = e.target.files[0];
      if (file) {
        const reader = new FileReader();
        reader.onload = (event) => {
          if (this.generator.importKeys(event.target.result)) {
            GM_notification({ text: "Import successful!", timeout: 2000 });
            this.updateList();
          } else {
            this.showToast("Import failed. Invalid file format.");
          }
        };
        reader.readAsText(file);
      }
    }
    async decodeImageFromElement(imgElement) {
      // 创建 canvas
      const canvas = document.createElement("canvas");
      const ctx = canvas.getContext("2d");
      const { naturalWidth: width, naturalHeight: height } = imgElement;

      canvas.width = width;
      canvas.height = height;
      try {
        // 绘制图片到 canvas
        ctx.drawImage(imgElement, 0, 0, width, height);

        // 获取像素数据(⚠️ 如果图片跨域且无 CORS,这里会抛错)
        const imageData = ctx.getImageData(0, 0, width, height);

        // 使用 jsQR 识别
        const code = unsafeWindow.jsQR(imageData.data, width, height);

        if (code && code.data) {
          return code.data.trim();
        }
        return null;
      } catch (err) {
        console.warn("Canvas tainted or decoding failed:", err);
        return null;
      }
    }
    async startCaptureMode() {
      try {
        await this.loadJsQR();
      } catch (err) {
        this.showToast("❌ Failed to load QR decoder. Please try again.");
        return;
      }
      this.captureMode = true;
      // 高亮所有图片
      const images = document.querySelectorAll("img");
      console.log("Starting capture mode...", images);
      images.forEach((img) => {
        img.style.outline = "3px solid #FF5722";
        img.style.outlineOffset = "2px";
        img.style.cursor = "pointer";
      });

      // 临时提示
      this.showToast("Click on a QR code image to scan it!");
    }

    async toggleUI() {
      this.container.style.display = this.container.style.display === "none" ? "block" : "none";
      this.isOpen = !this.isOpen;

      if (this.isOpen) {
        await this.updateList();
        this.updateInterval = setInterval(() => {
          this.updateList().catch((err) => console.error("Error updating TOTP list:", err));
        }, 1000);
      } else if (this.updateInterval) {
        clearInterval(this.updateInterval);
        this.updateInterval = null;
      }
    }
    async loadJsQR() {
      if (unsafeWindow.jsQR) return; // 已加载

      return new Promise((resolve, reject) => {
        const script = document.createElement("script");
        script.src = "https://cdn.jsdelivr.net/npm/[email protected]/dist/jsQR.min.js";
        script.onload = () => {
          resolve();
        };
        script.onerror = () => reject(new Error("Failed to load jsQR"));
        document.head.appendChild(script);
      });
    }
    async updateList() {
      const listContainer = document.getElementById("totp-list");
      const codes = await this.generator.getCurrentCodes();

      listContainer.innerHTML = "";

      if (codes.length === 0) {
        listContainer.innerHTML = '<p style="text-align: center; color: #666; padding: 20px;">No accounts added yet.</p>';
        return;
      }

      codes.forEach((item) => {
        const itemDiv = document.createElement("div");
        itemDiv.className = "totp-item";
        const timeClass = item.timeRemaining <= 5 ? "time-warning" : "";
        itemDiv.innerHTML = `
                    <div class="totp-item-header">
                        <span class="totp-service">${item.service || item.issuer || "Unnamed"}</span>
                        <button class="totp-delete-btn" data-id="${item.id}">Delete</button>
                    </div>
                    <div class="totp-code">${item.code}</div>
                    <div class="totp-time ${timeClass}">${item.timeRemaining}s remaining</div>
                `;

        itemDiv.querySelector(".totp-delete-btn").addEventListener("click", (e) => {
          e.stopPropagation();
          if (confirm("Remove this account?")) {
            this.generator.removeKey(item.id);
            this.updateList();
          }
        });

        itemDiv.querySelector(".totp-code").addEventListener("click", (e) => {
          e.stopPropagation();
          navigator.clipboard.writeText(item.code).then(() => {
            const originalText = e.target.textContent;
            e.target.textContent = "✓ COPIED";
            setTimeout(() => {
              e.target.textContent = originalText;
            }, 1000);
          });
        });

        listContainer.appendChild(itemDiv);
      });
    }
  }

  // ==================== 初始化 ====================
  GM_addElement("link", {
    id: "shoelace-styles",
    rel: "stylesheet",
    href: "https://cdn.jsdelivr.net/npm/@shoelace-style/[email protected]/cdn/themes/light.css",
  });

  GM_addElement("script", {
    type: "module",
    src: "https://cdn.jsdelivr.net/npm/@shoelace-style/[email protected]/cdn/shoelace-autoloader.js",
  });
  async function initAfterShoelaceReady() {
    let totpUI;
    try {
      const totpGenerator = new TOTPGenerator();
      totpUI = new TOTPUI(totpGenerator);
      unsafeWindow.totpUI = totpUI;

      // 创建浮动按钮
      const globalBtn = document.createElement("button");
      globalBtn.textContent = "🔐";
      globalBtn.style.cssText = `
      position: fixed;
      bottom: 20px;
      right: 20px;
      z-index: 9999;
      background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
      color: white;
      border: none;
      border-radius: 50%;
      width: 60px;
      height: 60px;
      cursor: pointer;
      box-shadow: 0 6px 20px rgba(37, 117, 252, 0.4);
      font-size: 24px;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: all 0.3s ease;
    `;
      globalBtn.title = "TOTP Helper";
      globalBtn.addEventListener("click", () => totpUI.toggleUI());
      globalBtn.addEventListener("mouseenter", () => {
        globalBtn.style.transform = "scale(1.1)";
      });
      globalBtn.addEventListener("mouseleave", () => {
        globalBtn.style.transform = "scale(1)";
      });

      document.body.appendChild(globalBtn);
      document.getElementById("totp-container").style.display = "none";

      GM_registerMenuCommand("🔐 Open TOTP Helper", () => totpUI.toggleUI());
    } catch (err) {
      console.error("Failed to initialize TOTP UI:", err);
      totpUI.showToast("⚠️ Failed to load TOTP Helper UI. Please refresh the page.");
    }
  }

  // 启动初始化
  initAfterShoelaceReady();
})();