Greasy Fork

Greasy Fork is available in English.

Mobile Copy Unlocker

默认关闭、按站点启用的移动端解除网页复制限制脚本,覆盖 CSS 限制、放通复制相关事件并处理 clipboardData 劫持。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Mobile Copy Unlocker
// @namespace    https://codex.local/userscripts
// @version      1.0.0
// @description  默认关闭、按站点启用的移动端解除网页复制限制脚本,覆盖 CSS 限制、放通复制相关事件并处理 clipboardData 劫持。
// @match        *://*/*
// @run-at       document-start
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_setClipboard
// @grant        unsafeWindow
// ==/UserScript==

(function () {
  'use strict';

  const SCRIPT_ID = 'tm-mobile-copy-unlocker';
  const STORAGE_KEY = `${SCRIPT_ID}:site-rules:v1`;
  const DATA_ATTR = 'data-copy-unlocker-active';
  const PAGE_BRIDGE_KEY = '__copyUnlockerPageBridge__';
  const OPT_OUT_SELECTOR = '[data-copy-unlocker-preserve]';
  const STRATEGY_VERSION = '2026.03';
  const PROTECTED_EVENTS = [
    'copy',
    'cut',
    'contextmenu',
    'selectstart',
    'dragstart',
    'beforecopy',
    'beforecut',
    'keydown',
  ];
  const INLINE_EVENT_PROPS = [
    'oncopy',
    'oncut',
    'oncontextmenu',
    'onselectstart',
    'ondragstart',
    'onbeforecopy',
    'onbeforecut',
    'onkeydown',
  ];

  const PAGE_WINDOW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
  const state = {
    active: false,
    siteKey: getSiteKey(),
    lastSelectionText: '',
    menuCommandIds: [],
    disposers: [],
  };

  const STRATEGIES = [
    {
      id: 'style-layer',
      install: installStyleLayer,
    },
    {
      id: 'page-bridge',
      install: installPageBridgeStrategy,
    },
    {
      id: 'event-shield',
      install: installEventShield,
    },
  ];

  void boot();

  async function boot() {
    if (isTopWindow()) {
      await registerMenus();
    }

    if (await isEnabledForCurrentSite()) {
      activateRuntime();
    }
  }

  function getSiteKey() {
    return String(location.host || location.hostname || 'unknown').toLowerCase();
  }

  function isTopWindow() {
    try {
      return window.top === window.self;
    } catch (_error) {
      return true;
    }
  }

  function isPromiseLike(value) {
    return Boolean(value) && typeof value.then === 'function';
  }

  async function gmGetValue(key, fallbackValue) {
    if (typeof GM_getValue !== 'function') {
      return fallbackValue;
    }

    try {
      const result = GM_getValue(key, fallbackValue);
      return isPromiseLike(result) ? await result : result;
    } catch (_error) {
      return fallbackValue;
    }
  }

  async function gmSetValue(key, value) {
    if (typeof GM_setValue !== 'function') {
      return;
    }

    const result = GM_setValue(key, value);
    if (isPromiseLike(result)) {
      await result;
    }
  }

  async function loadSiteRules() {
    const stored = await gmGetValue(STORAGE_KEY, {});

    if (!stored || typeof stored !== 'object' || Array.isArray(stored)) {
      return {};
    }

    return stored;
  }

  async function saveSiteRules(rules) {
    await gmSetValue(STORAGE_KEY, rules);
  }

  async function isEnabledForCurrentSite() {
    const rules = await loadSiteRules();
    return Boolean(rules[state.siteKey] && rules[state.siteKey].enabled);
  }

  async function setCurrentSiteEnabled(enabled) {
    const rules = await loadSiteRules();

    if (enabled) {
      rules[state.siteKey] = {
        enabled: true,
        updatedAt: Date.now(),
        strategyVersion: STRATEGY_VERSION,
      };
    } else {
      delete rules[state.siteKey];
    }

    await saveSiteRules(rules);
  }

  async function registerMenus() {
    unregisterMenus();

    const enabled = await isEnabledForCurrentSite();
    const hostLabel = state.siteKey;
    const toggleLabel = enabled
      ? `关闭当前站点复制解锁: ${hostLabel}`
      : `开启当前站点复制解锁: ${hostLabel}`;

    state.menuCommandIds.push(
      GM_registerMenuCommand(toggleLabel, async () => {
        const nextEnabled = !(await isEnabledForCurrentSite());
        await setCurrentSiteEnabled(nextEnabled);

        if (nextEnabled) {
          activateRuntime();
          notifyUser(`复制解锁已启用: ${hostLabel}`);
        } else {
          deactivateRuntime();
          notifyUser(`复制解锁已关闭: ${hostLabel}`);
        }

        await registerMenus();
      })
    );

    state.menuCommandIds.push(
      GM_registerMenuCommand(`清除当前站点配置: ${hostLabel}`, async () => {
        await setCurrentSiteEnabled(false);
        deactivateRuntime();
        notifyUser(`站点配置已清除: ${hostLabel}`);
        await registerMenus();
      })
    );

    state.menuCommandIds.push(
      GM_registerMenuCommand(
        `查看当前状态: ${enabled ? '已启用' : '未启用'} / ${hostLabel}`,
        () => {
          const summary = {
            host: hostLabel,
            enabled,
            active: state.active,
            strategyVersion: STRATEGY_VERSION,
          };
          console.info(`[${SCRIPT_ID}]`, summary);
          notifyUser(`${hostLabel}: ${enabled ? '已启用' : '未启用'}`);
        }
      )
    );
  }

  function unregisterMenus() {
    if (typeof GM_unregisterMenuCommand !== 'function') {
      state.menuCommandIds.length = 0;
      return;
    }

    for (const menuId of state.menuCommandIds.splice(0)) {
      try {
        GM_unregisterMenuCommand(menuId);
      } catch (_error) {
        // Tampermonkey 某些版本会忽略未知 id,这里直接吞掉即可。
      }
    }
  }

  function activateRuntime() {
    if (state.active) {
      return;
    }

    state.active = true;

    for (const strategy of STRATEGIES) {
      try {
        const disposer = strategy.install();
        if (typeof disposer === 'function') {
          state.disposers.push(disposer);
        }
      } catch (error) {
        console.error(`[${SCRIPT_ID}] strategy failed: ${strategy.id}`, error);
      }
    }

    rememberSelection();
    console.info(`[${SCRIPT_ID}] activated for`, state.siteKey);
  }

  function deactivateRuntime() {
    if (!state.active) {
      return;
    }

    state.active = false;
    state.lastSelectionText = '';

    while (state.disposers.length > 0) {
      const disposer = state.disposers.pop();
      try {
        disposer();
      } catch (error) {
        console.error(`[${SCRIPT_ID}] cleanup failed`, error);
      }
    }

    try {
      document.documentElement.removeAttribute(DATA_ATTR);
    } catch (_error) {
      // ignore
    }

    console.info(`[${SCRIPT_ID}] deactivated for`, state.siteKey);
  }

  function installStyleLayer() {
    const style = document.createElement('style');
    style.id = `${SCRIPT_ID}-style`;
    style.textContent = `
html[${DATA_ATTR}="1"],
html[${DATA_ATTR}="1"] body,
html[${DATA_ATTR}="1"] *,
html[${DATA_ATTR}="1"] *::before,
html[${DATA_ATTR}="1"] *::after {
  -webkit-user-select: text !important;
  user-select: text !important;
  -webkit-touch-callout: default !important;
}

html[${DATA_ATTR}="1"] input,
html[${DATA_ATTR}="1"] textarea,
html[${DATA_ATTR}="1"] [contenteditable=""],
html[${DATA_ATTR}="1"] [contenteditable="true"],
html[${DATA_ATTR}="1"] [contenteditable="plaintext-only"] {
  -webkit-user-select: text !important;
  user-select: text !important;
}
`;

    appendToDocument(style);
    setDocumentActive(true);

    return () => {
      style.remove();
      setDocumentActive(false);
    };
  }

  function installEventShield() {
    const listenerBag = createListenerBag();
    const targetPairs = [window, document];

    for (const target of targetPairs) {
      for (const eventType of PROTECTED_EVENTS) {
        listenerBag.add(target, eventType, handleProtectedEvent, {
          capture: true,
          passive: false,
        });
      }
    }

    listenerBag.add(document, 'selectionchange', rememberSelection, {
      capture: true,
      passive: true,
    });
    listenerBag.add(document, 'keyup', rememberSelection, {
      capture: true,
      passive: true,
    });
    listenerBag.add(document, 'touchend', rememberSelection, {
      capture: true,
      passive: true,
    });

    return () => {
      listenerBag.removeAll();
    };
  }

  function installPageBridgeStrategy() {
    const bridge = ensurePageBridge();

    if (bridge && typeof bridge.install === 'function') {
      bridge.install();
      bridge.setActive(true);
    }

    return () => {
      try {
        if (bridge && typeof bridge.teardown === 'function') {
          bridge.teardown();
        }
      } catch (error) {
        console.error(`[${SCRIPT_ID}] page bridge teardown failed`, error);
      }
    };
  }

  function ensurePageBridge() {
    if (PAGE_WINDOW[PAGE_BRIDGE_KEY]) {
      return PAGE_WINDOW[PAGE_BRIDGE_KEY];
    }

    const script = document.createElement('script');
    script.id = `${SCRIPT_ID}-page-bridge`;
    script.textContent = `(() => {
  const BRIDGE_KEY = ${JSON.stringify(PAGE_BRIDGE_KEY)};
  if (window[BRIDGE_KEY]) {
    return;
  }

  const dataAttr = ${JSON.stringify(DATA_ATTR)};
  const protectedTypes = new Set(${JSON.stringify(PROTECTED_EVENTS)});
  const inlineProps = ${JSON.stringify(INLINE_EVENT_PROPS)};
  const wrappedRegistry = [];
  const wrapperCache = new WeakMap();
  const state = {
    active: false,
    installed: false,
    originals: null,
    inlineDescriptors: [],
  };

  function isActive() {
    const root = document.documentElement;
    return state.active && root && root.getAttribute(dataAttr) === '1';
  }

  function shouldGuard(type) {
    return protectedTypes.has(String(type));
  }

  function getWrappedListener(type, listener) {
    if (!shouldGuard(type) || listener == null) {
      return listener;
    }

    const isFn = typeof listener === 'function';
    const isObj = !isFn && typeof listener.handleEvent === 'function';

    if (!isFn && !isObj) {
      return listener;
    }

    let byType = wrapperCache.get(listener);
    if (!byType) {
      byType = new Map();
      wrapperCache.set(listener, byType);
    }

    const cacheKey = String(type);
    if (byType.has(cacheKey)) {
      return byType.get(cacheKey);
    }

    const wrapped = isFn
      ? function (...args) {
          if (isActive()) {
            return undefined;
          }
          return listener.apply(this, args);
        }
      : {
          handleEvent(...args) {
            if (isActive()) {
              return undefined;
            }
            return listener.handleEvent.apply(listener, args);
          },
        };

    byType.set(cacheKey, wrapped);
    return wrapped;
  }

  function patchInlineProp(proto, propName) {
    if (!proto) {
      return;
    }

    const descriptor = Object.getOwnPropertyDescriptor(proto, propName);
    if (!descriptor || typeof descriptor.get !== 'function' || typeof descriptor.set !== 'function') {
      return;
    }

    state.inlineDescriptors.push([proto, propName, descriptor]);
    Object.defineProperty(proto, propName, {
      configurable: true,
      enumerable: descriptor.enumerable,
      get: descriptor.get,
      set(value) {
        if (!value || !isActive()) {
          return descriptor.set.call(this, value);
        }

        return descriptor.set.call(this, getWrappedListener(propName.slice(2), value));
      },
    });
  }

  function removeWrappedRegistration(type, listener, options) {
    const wrapped = getWrappedListener(type, listener);
    for (let index = wrappedRegistry.length - 1; index >= 0; index -= 1) {
      const item = wrappedRegistry[index];
      if (item.type === String(type) && item.original === listener && item.wrapped === wrapped && item.options === options) {
        wrappedRegistry.splice(index, 1);
        break;
      }
    }
  }

  window[BRIDGE_KEY] = {
    install() {
      if (state.installed) {
        return;
      }

      state.originals = {
        addEventListener: EventTarget.prototype.addEventListener,
        removeEventListener: EventTarget.prototype.removeEventListener,
      };

      EventTarget.prototype.addEventListener = function (type, listener, options) {
        const wrapped = getWrappedListener(type, listener);
        if (wrapped !== listener) {
          wrappedRegistry.push({
            target: this,
            type: String(type),
            original: listener,
            wrapped,
            options,
          });
        }

        return state.originals.addEventListener.call(this, type, wrapped, options);
      };

      EventTarget.prototype.removeEventListener = function (type, listener, options) {
        removeWrappedRegistration(type, listener, options);
        return state.originals.removeEventListener.call(this, type, getWrappedListener(type, listener), options);
      };

      patchInlineProp(Window.prototype, 'oncopy');
      patchInlineProp(Window.prototype, 'oncut');
      patchInlineProp(Window.prototype, 'oncontextmenu');
      patchInlineProp(Window.prototype, 'onselectstart');
      patchInlineProp(Window.prototype, 'ondragstart');
      patchInlineProp(Window.prototype, 'onbeforecopy');
      patchInlineProp(Window.prototype, 'onbeforecut');
      patchInlineProp(Window.prototype, 'onkeydown');
      patchInlineProp(Document.prototype, 'oncopy');
      patchInlineProp(Document.prototype, 'oncut');
      patchInlineProp(Document.prototype, 'oncontextmenu');
      patchInlineProp(Document.prototype, 'onselectstart');
      patchInlineProp(Document.prototype, 'ondragstart');
      patchInlineProp(Document.prototype, 'onbeforecopy');
      patchInlineProp(Document.prototype, 'onbeforecut');
      patchInlineProp(Document.prototype, 'onkeydown');
      patchInlineProp(HTMLElement.prototype, 'oncopy');
      patchInlineProp(HTMLElement.prototype, 'oncut');
      patchInlineProp(HTMLElement.prototype, 'oncontextmenu');
      patchInlineProp(HTMLElement.prototype, 'onselectstart');
      patchInlineProp(HTMLElement.prototype, 'ondragstart');
      patchInlineProp(HTMLElement.prototype, 'onbeforecopy');
      patchInlineProp(HTMLElement.prototype, 'onbeforecut');
      patchInlineProp(HTMLElement.prototype, 'onkeydown');
      if (typeof SVGElement !== 'undefined') {
        patchInlineProp(SVGElement.prototype, 'oncopy');
        patchInlineProp(SVGElement.prototype, 'oncut');
        patchInlineProp(SVGElement.prototype, 'oncontextmenu');
        patchInlineProp(SVGElement.prototype, 'onselectstart');
        patchInlineProp(SVGElement.prototype, 'ondragstart');
        patchInlineProp(SVGElement.prototype, 'onbeforecopy');
        patchInlineProp(SVGElement.prototype, 'onbeforecut');
        patchInlineProp(SVGElement.prototype, 'onkeydown');
      }

      state.installed = true;
    },

    setActive(value) {
      state.active = Boolean(value);
    },

    teardown() {
      if (!state.installed || !state.originals) {
        delete window[BRIDGE_KEY];
        return;
      }

      state.active = false;

      for (const item of wrappedRegistry.splice(0)) {
        try {
          state.originals.removeEventListener.call(item.target, item.type, item.wrapped, item.options);
          state.originals.addEventListener.call(item.target, item.type, item.original, item.options);
        } catch (_error) {
          // ignore stale targets
        }
      }

      EventTarget.prototype.addEventListener = state.originals.addEventListener;
      EventTarget.prototype.removeEventListener = state.originals.removeEventListener;

      for (const [proto, propName, descriptor] of state.inlineDescriptors.splice(0)) {
        try {
          Object.defineProperty(proto, propName, descriptor);
        } catch (_error) {
          // ignore non-configurable descriptors on old engines
        }
      }

      state.installed = false;
      state.originals = null;
      delete window[BRIDGE_KEY];
    },
  };
})();`;

    appendToDocument(script);
    script.remove();

    return PAGE_WINDOW[PAGE_BRIDGE_KEY] || null;
  }

  function handleProtectedEvent(event) {
    if (!state.active || !event || event.isTrusted === false) {
      return;
    }

    const eventType = String(event.type || '');
    if (!PROTECTED_EVENTS.includes(eventType)) {
      return;
    }

    const target = event.target;
    if (isPreservedRegion(target)) {
      return;
    }

    if (eventType === 'keydown') {
      if (!isCopyRelatedKeydown(event)) {
        return;
      }

      stopSiteInterception(event);
      return;
    }

    if (eventType === 'contextmenu' || eventType === 'selectstart' || eventType === 'dragstart') {
      stopSiteInterception(event);
      return;
    }

    if (eventType === 'beforecopy' || eventType === 'beforecut') {
      stopSiteInterception(event);
      return;
    }

    if (eventType === 'copy' || eventType === 'cut') {
      const editableTarget = getEditableElement(target);
      const selectedText = getSelectionText(editableTarget);

      stopSiteInterception(event);

      if (!editableTarget && selectedText) {
        if (writeClipboardDataToEvent(event, selectedText)) {
          event.preventDefault();
        } else {
          void writeClipboardFallback(selectedText, eventType);
        }
      }

      if (eventType === 'copy' && editableTarget && selectedText) {
        void writeClipboardFallback(selectedText, eventType, { bestEffortOnly: true });
      }
    }
  }

  function stopSiteInterception(event) {
    try {
      event.stopImmediatePropagation();
    } catch (_error) {
      // ignore
    }

    try {
      event.stopPropagation();
    } catch (_error) {
      // ignore
    }
  }

  function rememberSelection() {
    if (!state.active) {
      return;
    }

    state.lastSelectionText = getSelectionText() || state.lastSelectionText;
  }

  function getSelectionText(editableElement) {
    const editable = editableElement || getEditableElement(document.activeElement);
    if (editable) {
      if (editable instanceof HTMLTextAreaElement) {
        return editable.value.slice(editable.selectionStart || 0, editable.selectionEnd || 0);
      }

      if (editable instanceof HTMLInputElement) {
        const supportedTypes = new Set(['text', 'search', 'url', 'tel', 'password', 'email', 'number']);
        if (supportedTypes.has(editable.type)) {
          return editable.value.slice(editable.selectionStart || 0, editable.selectionEnd || 0);
        }
      }

      if (editable.isContentEditable) {
        const selection = window.getSelection();
        return selection ? selection.toString() : '';
      }
    }

    const selection = window.getSelection();
    const text = selection ? selection.toString() : '';
    return text || state.lastSelectionText || '';
  }

  function writeClipboardDataToEvent(event, text) {
    if (!event || !event.clipboardData || !text) {
      return false;
    }

    try {
      event.clipboardData.setData('text/plain', text);
      return true;
    } catch (_error) {
      return false;
    }
  }

  async function writeClipboardFallback(text, source, options = {}) {
    if (!text) {
      return false;
    }

    const bestEffortOnly = Boolean(options.bestEffortOnly);

    if (typeof GM_setClipboard === 'function') {
      try {
        const result = GM_setClipboard(text, 'text');
        if (isPromiseLike(result)) {
          await result;
        }
        return true;
      } catch (_error) {
        // keep trying
      }
    }

    if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function' && window.isSecureContext) {
      try {
        await navigator.clipboard.writeText(text);
        return true;
      } catch (_error) {
        // iOS Safari 与部分 Android WebView 经常因为权限模型失败,继续降级。
      }
    }

    if (!bestEffortOnly) {
      return legacyExecCopy(text, source);
    }

    return false;
  }

  function legacyExecCopy(text, source) {
    if (typeof document.execCommand !== 'function' || !text) {
      return false;
    }

    const activeElement = document.activeElement;
    const selection = document.getSelection();
    const ranges = [];
    if (selection) {
      for (let index = 0; index < selection.rangeCount; index += 1) {
        ranges.push(selection.getRangeAt(index).cloneRange());
      }
    }

    const buffer = document.createElement('textarea');
    buffer.value = text;
    buffer.setAttribute('readonly', 'readonly');
    buffer.setAttribute('aria-hidden', 'true');
    buffer.setAttribute('data-copy-unlocker-buffer', source || 'copy');
    buffer.style.position = 'fixed';
    buffer.style.top = '0';
    buffer.style.left = '0';
    buffer.style.width = '1px';
    buffer.style.height = '1px';
    buffer.style.opacity = '0';
    buffer.style.pointerEvents = 'none';
    buffer.style.fontSize = '16px';

    appendToDocument(buffer);

    try {
      buffer.focus({ preventScroll: true });
    } catch (_error) {
      buffer.focus();
    }

    buffer.select();
    buffer.setSelectionRange(0, text.length);

    let copied = false;
    try {
      copied = document.execCommand('copy');
    } catch (_error) {
      copied = false;
    }

    buffer.remove();

    if (selection) {
      selection.removeAllRanges();
      for (const range of ranges) {
        selection.addRange(range);
      }
    }

    if (activeElement && typeof activeElement.focus === 'function') {
      try {
        activeElement.focus({ preventScroll: true });
      } catch (_error) {
        activeElement.focus();
      }
    }

    return copied;
  }

  function getEditableElement(node) {
    const element = asElement(node);
    if (!element) {
      return null;
    }

    if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
      return element;
    }

    return element.closest('[contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]');
  }

  function asElement(node) {
    if (!node) {
      return null;
    }

    if (node instanceof Element) {
      return node;
    }

    if (node.parentElement) {
      return node.parentElement;
    }

    return null;
  }

  function isPreservedRegion(node) {
    const element = asElement(node);
    return Boolean(element && element.closest(OPT_OUT_SELECTOR));
  }

  function isCopyRelatedKeydown(event) {
    if (!event || !(event.ctrlKey || event.metaKey)) {
      return false;
    }

    const key = String(event.key || '').toLowerCase();
    return key === 'c' || key === 'x' || key === 'insert';
  }

  function appendToDocument(node) {
    const parent = document.head || document.documentElement || document.body;
    if (parent) {
      parent.appendChild(node);
      return;
    }

    document.addEventListener(
      'DOMContentLoaded',
      () => {
        const fallbackParent = document.head || document.documentElement || document.body;
        if (fallbackParent && !node.isConnected) {
          fallbackParent.appendChild(node);
        }
      },
      { once: true }
    );
  }

  function setDocumentActive(active) {
    const applyFlag = () => {
      if (!document.documentElement) {
        return false;
      }

      if (active) {
        document.documentElement.setAttribute(DATA_ATTR, '1');
      } else {
        document.documentElement.removeAttribute(DATA_ATTR);
      }

      return true;
    };

    if (applyFlag()) {
      return;
    }

    document.addEventListener(
      'readystatechange',
      () => {
        applyFlag();
      },
      { once: true }
    );
  }

  function createListenerBag() {
    const removers = [];

    return {
      add(target, type, listener, options) {
        if (!target || typeof target.addEventListener !== 'function') {
          return;
        }

        target.addEventListener(type, listener, options);
        removers.push(() => {
          try {
            target.removeEventListener(type, listener, options);
          } catch (_error) {
            // ignore
          }
        });
      },
      removeAll() {
        while (removers.length > 0) {
          const remove = removers.pop();
          remove();
        }
      },
    };
  }

  function notifyUser(message) {
    console.info(`[${SCRIPT_ID}] ${message}`);

    if (!isTopWindow()) {
      return;
    }

    const toast = document.createElement('div');
    toast.textContent = message;
    toast.setAttribute('role', 'status');
    toast.style.position = 'fixed';
    toast.style.left = '50%';
    toast.style.bottom = '24px';
    toast.style.zIndex = '2147483647';
    toast.style.maxWidth = 'calc(100vw - 32px)';
    toast.style.padding = '10px 14px';
    toast.style.borderRadius = '999px';
    toast.style.transform = 'translateX(-50%)';
    toast.style.background = 'rgba(15, 23, 42, 0.92)';
    toast.style.color = '#ffffff';
    toast.style.fontSize = '13px';
    toast.style.lineHeight = '1.4';
    toast.style.boxShadow = '0 10px 30px rgba(15, 23, 42, 0.28)';
    toast.style.pointerEvents = 'none';

    appendToDocument(toast);

    window.setTimeout(() => {
      toast.remove();
    }, 1800);
  }
})();