您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Protects clipboard access with permission prompts and enhanced UI
// ==UserScript== // @name 剪贴板守护 (v30.0 终极版) // @name:en Clipboard Guard (v30.0 Ultimate Edition) // @namespace https://tampermonkey.net/ // @version 30.0 // @description Protects clipboard access with permission prompts and enhanced UI // @author WillArixq // @match *://*/* // @grant unsafeWindow // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_setClipboard // @grant GM_registerMenuCommand // @grant GM_listValues // @grant GM_deleteValue // @run-at document-start // @license MIT // ==/UserScript== /* MIT License Copyright (c) 2025 WillArixq Licensed under the MIT License: https://opensource.org/licenses/MIT Contact: X (https://x.com/sturverse9731), Email ([email protected]) */ (async function() { 'use strict'; // --- [1] 全局配置 (增强) --- const CONFIG = { DEBUG_MODE: true, DEBOUNCE_MS: 150, PERMISSION_KEY_PREFIX: 'cb_perm_v12_', DIALOG_MAX_WIDTH: '90vw', TOAST_DURATION: 2500, THEME_KEY: 'cb_theme_v12', ACTIVE_KEY: 'cb_active_v12', STATUS_INDICATOR: true, AUTO_DENY_TIMEOUT: 18000 // 18 seconds }; // --- [2] 日志系统 --- const Logger = { levels: { DEBUG: 1, INFO: 2, ERROR: 3 }, currentLevel: CONFIG.DEBUG_MODE ? 1 : 2, log(level, ...args) { if (this.levels[level] >= this.currentLevel) { console[level.toLowerCase()]( `%c[守护 v30.0]`, 'background: #3949AB; color: #fff; padding: 2px 4px; border-radius: 4px;', ...args ); } }, debug(...args) { this.log('DEBUG', ...args); }, info(...args) { this.log('INFO', ...args); }, error(...args) { this.log('ERROR', ...args); } }; // --- [3] 国际化 --- const I18N = { zh: { title_read: '剪贴板"读取"请求', title_write: '剪贴板"写入"请求', batch_title: '批量剪贴板请求', deny_once: '拒绝', allow_once: '允许', deny_always: '永久禁止', allow_always: '永久允许', deny_all: '全部禁止', allow_all: '全部允许', source: '请求来源: <b>{hostname}</b>', preview: '预览 ({count} 字符)', batch_summary: '该网站在短时间内发起了 {write} 次写入{read}请求。', batch_preview: '以下为第一个写入请求的预览:', reset_permissions: '重置所有剪贴板权限', permission_saved: '权限设置已保存!', navigation_blocked: '请先处理剪贴板权限请求', toggle_on: '激活脚本', toggle_off: '禁用脚本', script_enabled: '剪贴板守护已激活', script_disabled: '剪贴板守护已禁用', theme_light: '切换至浅色主题', theme_dark: '切换至深色主题', theme_auto: '切换至自动主题' }, en: { title_read: 'Clipboard "Read" Request', title_write: 'Clipboard "Write" Request', batch_title: 'Batch Clipboard Requests', deny_once: 'Deny', allow_once: 'Allow', deny_always: 'Permanently Deny', allow_always: 'Permanently Allow', deny_all: 'Deny All', allow_all: 'Allow All', source: 'Request from: <b>{hostname}</b>', preview: 'Preview ({count} characters)', batch_summary: 'This site initiated {write} write{read} requests in a short time.', batch_preview: 'Below is a preview of the first write request:', reset_permissions: 'Reset All Clipboard Permissions', permission_saved: 'Permission settings saved!', navigation_blocked: 'Please handle the clipboard permission request first', toggle_on: 'Enable Script', toggle_off: 'Disable Script', script_enabled: 'Clipboard guard is now enabled', script_disabled: 'Clipboard guard is now disabled', theme_light: 'Switch to Light Theme', theme_dark: 'Switch to Dark Theme', theme_auto: 'Switch to Auto Theme' } }; const getLang = () => navigator.language.startsWith('zh') ? 'zh' : 'en'; // --- [4] 图标 (增强) --- const ICONS = { shield: `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>`, globe: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`, copy: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`, checkmark: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`, info: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="8"></line></svg>`, sun: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>`, moon: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>`, desktop: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>` }; // --- [5] CSS 样式(模块化与极致美化) --- const CSS_VARIABLES = ` :root { --cb-transition-speed: 0.4s; --cb-bg: rgba(255, 255, 255, 0.95); --cb-text: #1d1d1f; --cb-text-light: #6e6e73; --cb-title: #000; --cb-pre-bg: rgba(120, 120, 128, 0.08); --cb-btn-secondary-bg: rgba(120, 120, 128, 0.15); --cb-btn-secondary-bg-hover: rgba(120, 120, 128, 0.25); --cb-btn-primary-bg: linear-gradient(145deg, #007FFF, #006AE0); --cb-btn-primary-bg-hover: linear-gradient(145deg, #0088FF, #0070E0); --cb-btn-primary-text: #fff; --cb-btn-secondary-text: #007aff; --cb-btn-deny-text-hover: #ff3b30; --cb-overlay-bg: rgba(0, 0, 0, 0.4); --cb-shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.35), 0 0 1px rgba(0,0,0,0.1); --cb-border: 1px solid rgba(255, 255, 255, 0.6); --cb-icon-color: #007aff; --cb-toast-bg: rgba(0, 0, 0, 0.75); --cb-toast-text: #fff; --cb-status-indicator-active: #28a745; --cb-status-indicator-inactive: #6c757d; --cb-handle-bg: rgba(120, 120, 128, 0.2); } .cb-sentinel-dark-theme { --cb-bg: rgba(28, 28, 30, 0.92); --cb-text: #f2f2f7; --cb-text-light: #8e8e93; --cb-title: #fff; --cb-pre-bg: rgba(120, 120, 128, 0.2); --cb-btn-secondary-bg: rgba(120, 120, 128, 0.25); --cb-btn-secondary-bg-hover: rgba(120, 120, 128, 0.35); --cb-btn-primary-bg: linear-gradient(145deg, #0A84FF, #0063C7); --cb-btn-primary-bg-hover: linear-gradient(145deg, #0B97FF, #0A73E0); --cb-btn-secondary-text: #0a84ff; --cb-btn-deny-text-hover: #ff453a; --cb-overlay-bg: rgba(0, 0, 0, 0.5); --cb-shadow: 0 20px 60px -20px rgba(0, 0, 0, 0.65), 0 0 1px rgba(255,255,255,0.1); --cb-border: 1px solid rgba(255, 255, 255, 0.1); --cb-icon-color: #0a84ff; --cb-toast-bg: rgba(0, 0, 0, 0.85); --cb-toast-text: #f2f2f7; --cb-handle-bg: rgba(120, 120, 128, 0.3); } `; const CSS_ANIMATIONS = ` @keyframes enter { from { opacity: 0; transform: scale(0.95) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } @keyframes exit { from { opacity: 1; transform: scale(1) translateY(0); } to { opacity: 0; transform: scale(0.95) translateY(10px); } } @keyframes wiggle { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } } @keyframes toast-enter { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } @keyframes toast-exit { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(20px); } } @keyframes status-pulse { 0% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(40, 167, 69, 0); } 100% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); } } .is-wiggling { animation: wiggle 0.3s ease-in-out; } .is-pulsing { animation: status-pulse 1.5s infinite; } `; const CSS_LAYOUT = ` .cb-sentinel-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: var(--cb-overlay-bg); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); z-index: 2147483647; display: flex; justify-content: center; align-items: center; opacity: 0; animation: enter var(--cb-transition-speed) cubic-bezier(0.16, 1, 0.3, 1) forwards; transition: all var(--cb-transition-speed) ease; } .cb-sentinel-overlay.is-closing { animation: exit calc(var(--cb-transition-speed) * 0.75) cubic-bezier(0.7, 0, 0.84, 0) forwards; } .cb-sentinel-dialog { background-color: var(--cb-bg); color: var(--cb-text); padding: 24px; border-radius: 22px; box-shadow: var(--cb-shadow); max-width: min(400px, ${CONFIG.DIALOG_MAX_WIDTH}); border: var(--cb-border); display: flex; flex-direction: column; gap: 16px; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; transition: all var(--cb-transition-speed) ease; opacity: 0; animation: enter var(--cb-transition-speed) cubic-bezier(0.16, 1, 0.3, 1) 0.05s forwards; position: relative; } .cb-sentinel-overlay.is-closing .cb-sentinel-dialog { animation: exit calc(var(--cb-transition-speed) * 0.75) cubic-bezier(0.7, 0, 0.84, 0) forwards; } .cb-dialog-handle { position: absolute; top: 0; left: 0; right: 0; height: 30px; cursor: move; border-radius: 22px 22px 0 0; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s ease; } .cb-dialog-handle:hover { opacity: 1; background-color: var(--cb-handle-bg); } .cb-header, .cb-origin { display: flex; align-items: center; justify-content: center; gap: 10px; text-align: center; } .cb-header .icon, .cb-origin .icon { display: inline-flex; align-items: center; justify-content: center; } .cb-header .icon { color: var(--cb-icon-color); transition: color var(--cb-transition-speed) ease; } .cb-header h3 { margin: 0; color: var(--cb-title); font-size: 19px; font-weight: 600; transition: color var(--cb-transition-speed) ease; } .cb-origin { color: var(--cb-text-light); font-size: 13.5px; line-height: 1.4; background: var(--cb-pre-bg); padding: 8px 14px; border-radius: 12px; transition: all var(--cb-transition-speed) ease; } .cb-origin b { font-weight: 500; color: var(--cb-text); } .cb-content-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: -8px; padding: 0 4px; } .cb-content-header span { font-size: 12px; color: var(--cb-text-light); } .cb-copy-button { background: none; border: none; cursor: pointer; color: var(--cb-text-light); padding: 4px; border-radius: 4px; transition: all 0.2s ease; } .cb-copy-button:hover { background: var(--cb-btn-secondary-bg); color: var(--cb-icon-color); } .cb-sentinel-dialog pre { background-color: var(--cb-pre-bg); border: none; padding: 12px; color: var(--cb-text); border-radius: 10px; max-height: 140px; overflow-y: auto; text-align: left; white-space: pre-wrap; word-break: break-all; font-size: 13px; font-family: "SF Mono", "Menlo", monospace; transition: all var(--cb-transition-speed) ease; } .cb-sentinel-buttons { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; } .cb-button-group { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .cb-button-separator { height: 1px; background-color: rgba(120,120,128,0.16); margin: 4px 0; } .cb-sentinel-buttons button { padding: 13px 0; border-radius: 12px; border: none; cursor: pointer; font-size: 16px; font-weight: 500; transition: all 0.2s ease; box-shadow: 0 1px 2px rgba(0,0,0,0.05); } .cb-sentinel-buttons button:hover { transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); } .cb-sentinel-buttons button:active { transform: scale(0.97); filter: brightness(0.95); box-shadow: inset 0 1px 2px rgba(0,0,0,0.1); } .cb-sentinel-buttons button:focus { outline: 2px solid var(--cb-icon-color); outline-offset: 2px; } .cb-sentinel-buttons .secondary { background-color: var(--cb-btn-secondary-bg); color: var(--cb-btn-secondary-text); } .cb-sentinel-buttons .secondary:hover { background-color: var(--cb-btn-secondary-bg-hover); } .cb-sentinel-buttons .primary { background-image: var(--cb-btn-primary-bg); color: var(--cb-btn-primary-text); font-weight: 600; } .cb-sentinel-buttons .primary:hover { background-image: var(--cb-btn-primary-bg-hover); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,123,255,0.3); } .cb-sentinel-buttons .deny_always:hover { color: var(--cb-btn-deny-text-hover); } .cb-toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: var(--cb-toast-bg); color: var(--cb-toast-text); padding: 10px 18px; border-radius: 10px; font-size: 14px; z-index: 2147483648; opacity: 0; animation: toast-enter 0.3s ease forwards; } .cb-toast.is-closing { animation: toast-exit 0.3s ease forwards; } .cb-status-indicator { position: fixed; bottom: 15px; right: 15px; width: 36px; height: 36px; background-color: var(--cb-bg); border-radius: 50%; z-index: 2147483646; display: flex; justify-content: center; align-items: center; box-shadow: var(--cb-shadow); border: var(--cb-border); cursor: pointer; transition: all 0.2s ease; } .cb-status-indicator:hover { transform: translateY(-2px); box-shadow: var(--cb-shadow); } .cb-status-indicator .icon { color: var(--cb-status-indicator-inactive); transition: color 0.2s ease; } .cb-status-indicator.is-active .icon { color: var(--cb-status-indicator-active); } `; // 异步加载 CSS const loadCSS = () => GM_addStyle(`${CSS_VARIABLES}${CSS_ANIMATIONS}${CSS_LAYOUT}`); requestAnimationFrame(loadCSS); // --- [6] 工具函数 --- const escape = (str) => { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }; const showToast = (message) => { const existingToast = document.querySelector('.cb-toast'); if (existingToast) existingToast.remove(); const toast = document.createElement('div'); toast.className = 'cb-toast'; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.classList.add('is-closing'); toast.addEventListener('animationend', () => toast.remove(), { once: true }); }, CONFIG.TOAST_DURATION); }; // --- [7] 导航锁模块 --- const NavigationLocker = { isLocked: false, activate() { this.isLocked = true; }, deactivate() { this.isLocked = false; } }; const handleWindowEvents = (e) => { if (!NavigationLocker.isLocked || e.target.closest('.cb-sentinel-overlay')) return; if (e.type === 'beforeunload') { e.preventDefault(); e.returnValue = I18N[getLang()].navigation_blocked; return e.returnValue; } if (e.cancelable) { e.preventDefault(); e.stopPropagation(); const dialog = activeDialog?.querySelector('.cb-sentinel-dialog'); if (dialog) { dialog.classList.add('is-wiggling'); dialog.addEventListener('animationend', () => dialog.classList.remove('is-wiggling'), { once: true }); showToast(I18N[getLang()].navigation_blocked); } } }; // --- [8] 对话框管理器 --- const DialogManager = { close() { if (activeDialog) { const overlay = activeDialog; overlay.classList.add('is-closing'); overlay.addEventListener('animationend', () => { overlay.remove(); if (activeDialog === overlay) activeDialog = null; }, { once: true }); NavigationLocker.deactivate(); } }, create(options) { this.close(); NavigationLocker.activate(); return new Promise(resolve => { const overlay = document.createElement('div'); overlay.className = 'cb-sentinel-overlay'; activeDialog = overlay; const dialog = document.createElement('div'); dialog.className = 'cb-sentinel-dialog'; dialog.innerHTML = options.html; const handleButtonClick = (e) => { const button = e.target.closest('button[data-value]'); if (button) { resolve(button.dataset.value); this.close(); return; } }; const handleCopyClick = (e) => { const copyButton = e.target.closest('.cb-copy-button'); if (copyButton) { const contentNode = dialog.querySelector('pre'); if (contentNode) { GM_setClipboard(contentNode.textContent, 'text/plain'); copyButton.innerHTML = ICONS.checkmark; copyButton.disabled = true; setTimeout(() => { copyButton.innerHTML = ICONS.copy; copyButton.disabled = false; }, 1500); } } }; const handleKeydown = (e) => { if (e.key === 'Enter') { const denyButton = dialog.querySelector('.deny_once'); if (denyButton) { resolve(denyButton.dataset.value); this.close(); } } else if (e.key === 'Escape') { resolve('deny_once'); this.close(); } }; dialog.addEventListener('click', handleButtonClick); dialog.addEventListener('click', handleCopyClick); dialog.addEventListener('keydown', handleKeydown); dialog.tabIndex = 0; overlay.appendChild(dialog); (document.body || document.documentElement).appendChild(overlay); dialog.focus(); const applyTheme = (theme) => { if (theme === 'dark') { overlay.classList.add('cb-sentinel-dark-theme'); } else if (theme === 'light') { overlay.classList.remove('cb-sentinel-dark-theme'); } else { const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)'); overlay.classList.toggle('cb-sentinel-dark-theme', darkThemeMq.matches); } }; GM_getValue(CONFIG.THEME_KEY, 'auto').then(applyTheme); }); } }; // --- [9] 核心逻辑 --- let activeDialog = null; let requestQueue = []; const buildContentPreviewHTML = (content, lang) => { if (!content) return ''; const charCount = content.length; return `<div class="cb-content-header"><span>${lang.preview.replace('{count}', charCount)}</span><button class="cb-copy-button" title="${lang.preview}">${ICONS.copy}</button></div><pre>${escape(content)}</pre>`; }; const buildDialogHeaderHTML = (title, hostname, lang) => ` <div class="cb-dialog-handle"></div> <div class="cb-header"><span class="icon">${ICONS.shield}</span><h3>${title}</h3></div> <div class="cb-origin"><span class="icon">${ICONS.globe}</span><span>${lang.source.replace('{hostname}', hostname)}</span></div> `; const buildSingleRequestDialogHTML = (hostname, type, content) => { const lang = I18N[getLang()]; const title = lang[`title_${type}`]; const contentHTML = type === 'write' ? buildContentPreviewHTML(content, lang) : ''; return ` ${buildDialogHeaderHTML(title, hostname, lang)} ${contentHTML} <div class="cb-sentinel-buttons"> <button class="primary deny_once" data-value="deny_once" title="${lang.deny_once}">${lang.deny_once}</button> <div class="cb-button-separator"></div> <button class="secondary deny_always" data-value="deny_always" title="${lang.deny_always}">${lang.deny_always}</button> </div>`; }; const buildBatchRequestDialogHTML = (hostname, requests) => { const lang = I18N[getLang()]; const writeCount = requests.filter(r => r.type === 'write').length; const readCount = requests.filter(r => r.type === 'read').length; let summary = lang.batch_summary.replace('{write}', writeCount > 0 ? ` <b>${writeCount}</b>` : ''); summary = summary.replace('{read}', readCount > 0 ? `${writeCount > 0 ? ' and ' : ''} <b>${readCount}</b>` : ''); const writeRequests = requests.filter(r => r.type === 'write'); let previewsHTML = ''; if (writeRequests.length > 0) { previewsHTML = ` <p style="font-size:12px;text-align:center;color:var(--cb-text-light);margin-top:-10px;">${lang.batch_preview}</p> ${writeRequests.map(req => buildContentPreviewHTML(req.content, lang)).join('')} `; } return ` ${buildDialogHeaderHTML(lang.batch_title, hostname, lang)} <p style="text-align:center; padding: 10px 0; color:var(--cb-text-light);">${summary}</p> ${previewsHTML} <div class="cb-sentinel-buttons" style="grid-template-columns: 1fr; display: grid;"> <button class="primary" data-value="allow_all" title="${I18N[getLang()].allow_all}">${I18N[getLang()].allow_all}</button> <button class="secondary deny_all" data-value="deny_all" title="${I18N[getLang()].deny_all}">${I18N[getLang()].deny_all}</button> </div>`; }; const processRequestQueue = async () => { if (requestQueue.length === 0 || activeDialog) return; const isEnabled = await GM_getValue(CONFIG.ACTIVE_KEY, true); if (!isEnabled) { Logger.info('脚本已禁用,请求直接通过。'); requestQueue.forEach(req => req.executor().then(req.resolve).catch(req.reject)); requestQueue = []; return; } try { const hostname = unsafeWindow.location.hostname; const requestsToProcess = [...requestQueue]; requestQueue = []; const firstRequest = requestsToProcess[0]; const permissionKey = `${CONFIG.PERMISSION_KEY_PREFIX}${hostname}_${firstRequest.type}`; const storedPermission = await GM_getValue(permissionKey); if (storedPermission === 'deny') { Logger.info('检测到永久禁止权限,自动拒绝请求。'); requestsToProcess.forEach(req => req.reject(new DOMException('Clipboard access permanently denied by user.', 'NotAllowedError'))); return; } if (storedPermission === 'allow') { Logger.info('检测到永久允许权限,自动允许请求。'); requestsToProcess.forEach(req => req.executor().then(req.resolve).catch(req.reject)); return; } const html = requestsToProcess.length === 1 ? buildSingleRequestDialogHTML(hostname, firstRequest.type, firstRequest.content) : buildBatchRequestDialogHTML(hostname, requestsToProcess); const userChoice = await DialogManager.create({ html }); let shouldDeny = false; switch (userChoice) { case 'deny_always': await GM_setValue(permissionKey, 'deny'); shouldDeny = true; showToast(I18N[getLang()].permission_saved); break; case 'deny_once': case 'deny_all': shouldDeny = true; break; case 'allow_all': await GM_setValue(permissionKey, 'allow'); shouldDeny = false; showToast(I18N[getLang()].permission_saved); break; default: shouldDeny = true; break; } if (shouldDeny) { requestsToProcess.forEach(req => req.reject(new DOMException('Clipboard access denied by user.', 'NotAllowedError'))); } else { requestsToProcess.forEach(req => req.executor().then(req.resolve).catch(req.reject)); } } catch (e) { Logger.error('处理请求队列时出错:', e); requestQueue.forEach(req => req.reject(new DOMException('Internal error in request queue.', 'AbortError'))); requestQueue = []; } }; const enqueueRequest = (requestDetails) => { return new Promise((resolve, reject) => { requestQueue.push({ ...requestDetails, resolve, reject }); processRequestQueue(); }); }; // --- [10] 拦截钩子模块 --- let originalClipboard = null; let originalExecCommand = null; const applyguardHooks = async () => { if (unsafeWindow.navigator.clipboard?.isguard) return; originalClipboard = originalClipboard || unsafeWindow.navigator.clipboard || {}; originalExecCommand = originalExecCommand || unsafeWindow.document.execCommand; const clipboardProxy = new Proxy(originalClipboard, { get(target, prop) { if (!isScriptActive) { return Reflect.get(target, prop); } if (['readText', 'read', 'writeText', 'write'].includes(prop) && target[prop]) { return async function(...args) { const type = prop.includes('read') ? 'read' : 'write'; const content = (prop === 'writeText' && args[0]) || (prop === 'write' && args[0]?.toString()) || null; if (prop === 'writeText' && GM_setClipboard) { const executor = () => { GM_setClipboard(content, 'text/plain'); return Promise.resolve(content); }; return enqueueRequest({ type, content, executor }); } const executor = () => Reflect.apply(target[prop], target, args); return enqueueRequest({ type, content, executor }); }; } if (prop === 'isguard') return true; return Reflect.get(target, prop); } }); Object.defineProperty(unsafeWindow.navigator, 'clipboard', { value: clipboardProxy, writable: true, configurable: true }); if (originalExecCommand) { if (unsafeWindow.document.execCommand?.isguard) return; const execCommandOverride = function(cmd, ...args) { const isEnabled = isScriptActive; if (!isEnabled) { return Reflect.apply(originalExecCommand, unsafeWindow.document, [cmd, ...args]); } const command = cmd.toLowerCase(); if (['copy', 'cut', 'paste'].includes(command)) { const type = command === 'paste' ? 'read' : 'write'; const content = type === 'write' ? unsafeWindow.getSelection()?.toString() : null; const executor = () => Promise.resolve(Reflect.apply(originalExecCommand, unsafeWindow.document, [cmd, ...args])); return enqueueRequest({ type, content, executor }); } return Reflect.apply(originalExecCommand, unsafeWindow.document, [cmd, ...args]); }; execCommandOverride.isguard = true; Object.defineProperty(unsafeWindow.document, 'execCommand', { value: execCommandOverride, writable: true, configurable: true }); } }; const clipboardEventListener = async (e) => { if (!(await GM_getValue(CONFIG.ACTIVE_KEY, true))) return; e.preventDefault(); const type = e.type === 'paste' ? 'read' : 'write'; const content = type === 'write' ? unsafeWindow.getSelection()?.toString() : null; enqueueRequest({ type, content, executor: () => { const event = new Event(e.type, { bubbles: true, cancelable: true }); window.dispatchEvent(event); return Promise.resolve(); } }); }; // --- [11] 状态指示器模块 --- const StatusIndicator = { element: null, async init() { if (!CONFIG.STATUS_INDICATOR || document.querySelector('.cb-status-indicator')) return; this.element = document.createElement('div'); this.element.className = 'cb-status-indicator'; this.element.innerHTML = `<span class="icon">${ICONS.shield}</span>`; this.element.addEventListener('click', () => { showToast('Clipboard guard'); }); document.body.appendChild(this.element); this.updateStatus(); }, async updateStatus() { if (!this.element) return; const isActive = await GM_getValue(CONFIG.ACTIVE_KEY, true); this.element.classList.toggle('is-active', isActive); this.element.title = isActive ? I18N[getLang()].script_enabled : I18N[getLang()].script_disabled; } }; // --- [12] 初始化与监控 --- let isScriptActive; const toggleScript = async () => { isScriptActive = !isScriptActive; await GM_setValue(CONFIG.ACTIVE_KEY, isScriptActive); showToast(isScriptActive ? I18N[getLang()].script_enabled : I18N[getLang()].script_disabled); StatusIndicator.updateStatus(); updateMenuCommands(); }; const updateMenuCommands = () => { GM_registerMenuCommand(I18N[getLang()].reset_permissions, async () => { const keys = await GM_listValues(); for (const key of keys) { if (key.startsWith(CONFIG.PERMISSION_KEY_PREFIX)) { await GM_deleteValue(key); } } showToast(I18N[getLang()].reset_permissions); }); GM_registerMenuCommand(isScriptActive ? I18N[getLang()].toggle_off : I18N[getLang()].toggle_on, toggleScript); GM_registerMenuCommand(I18N[getLang()].theme_auto, async () => { await GM_setValue(CONFIG.THEME_KEY, 'auto'); showToast(`Theme set to Auto.`); }); GM_registerMenuCommand(I18N[getLang()].theme_light, async () => { await GM_setValue(CONFIG.THEME_KEY, 'light'); showToast(`Theme set to Light.`); }); GM_registerMenuCommand(I18N[getLang()].theme_dark, async () => { await GM_setValue(CONFIG.THEME_KEY, 'dark'); showToast(`Theme set to Dark.`); }); }; // 注册核心事件监听器 isScriptActive = await GM_getValue(CONFIG.ACTIVE_KEY, true); applyguardHooks(); window.addEventListener('copy', clipboardEventListener, true); window.addEventListener('cut', clipboardEventListener, true); window.addEventListener('paste', clipboardEventListener, true); window.addEventListener('click', handleWindowEvents, { capture: true }); window.addEventListener('submit', handleWindowEvents, { capture: true }); window.addEventListener('beforeunload', handleWindowEvents, { capture: true }); window.addEventListener('popstate', handleWindowEvents, { capture: true }); // 监听DOM变化,确保钩子保持激活 if (document.body) { const observer = new MutationObserver(() => { if (!unsafeWindow.navigator.clipboard?.isguard || !unsafeWindow.document.execCommand?.isguard) { Logger.debug('检测到DOM变化,重新应用钩子。'); applyguardHooks(); } }); observer.observe(document.body, { childList: true, subtree: true }); window.addEventListener('unload', () => observer.disconnect()); } // 注册菜单命令 updateMenuCommands(); // 初始化状态指示器 if (CONFIG.STATUS_INDICATOR) { document.addEventListener('DOMContentLoaded', () => { StatusIndicator.init(); }); } Logger.info('剪贴板守护初始化完成。'); })();