// ==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 // @downloadURL none // ==/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); } })();