|
// ==UserScript== |
|
// @name noVNC Clipboard for Proxmox |
|
// @namespace https://github.com/AdamsGH |
|
// @version 3.0.2 |
|
// @description Bidirectional clipboard for Proxmox noVNC console |
|
// @author adams |
|
// @match https://*:8006/* |
|
// @run-at document-start |
|
// @early-start |
|
// @grant GM_registerMenuCommand |
|
// @grant GM_setClipboard |
|
// @grant GM_getValue |
|
// @inject-into page |
|
// ==/UserScript== |
|
|
|
/* ==UserConfig== |
|
settings: |
|
delay: |
|
title: Keystroke delay (ms) |
|
type: number |
|
default: 8 |
|
min: 1 |
|
max: 100 |
|
unit: ms |
|
pasteMode: |
|
title: Paste mode |
|
description: > |
|
rfb — set VM clipboard via RFB + Ctrl+V (fast, needs spice-vdagent / qemu-guest-agent). |
|
type — simulate keystrokes one by one (slow but always works). |
|
both — try rfb first, fall back to type. |
|
type: select |
|
default: type |
|
options: |
|
rfb: RFB clipboard + Ctrl+V |
|
type: Simulate keystrokes |
|
both: Try RFB first, then type |
|
==/UserConfig== */ |
|
|
|
// PHASE 1 — @early-start: runs before page scripts. No GM_* available. |
|
|
|
void (function () { |
|
'use strict' |
|
|
|
var TAG = '[noVNC Clipboard]' |
|
var search = location.search || '' |
|
var isConsole = |
|
search.includes('novnc=1') || |
|
search.includes('console=kvm') || |
|
search.includes('console=lxc') |
|
|
|
if (!isConsole) return |
|
console.log(TAG, 'early phase, readyState:', document.readyState) |
|
|
|
// State |
|
var vncSocket = null |
|
var clipboardCallback = null |
|
var captureSource = 'none' |
|
|
|
function onRFBClipboard(cb) { clipboardCallback = cb } |
|
|
|
// RFB ServerCutText (type 3) — standard |
|
function parseStandardCutText(data) { |
|
if (data.byteLength < 8) return null |
|
var view = new DataView(data) |
|
if (view.getUint8(0) !== 3) return null |
|
var rawLen = view.getUint32(4, false) |
|
if (rawLen & 0x80000000) return null |
|
if (data.byteLength < 8 + rawLen) return null |
|
return new TextDecoder('latin1').decode(new Uint8Array(data, 8, rawLen)) |
|
} |
|
|
|
function handleVNCMessage(event) { |
|
if (!(event.data instanceof ArrayBuffer)) return |
|
var data = event.data |
|
|
|
var stdText = parseStandardCutText(data) |
|
if (stdText !== null) { |
|
console.log(TAG, 'clipboard (std):', stdText.slice(0, 80)) |
|
if (clipboardCallback) clipboardCallback(stdText) |
|
} |
|
} |
|
|
|
function registerVNCSocket(ws, source) { |
|
if (vncSocket === ws) return |
|
vncSocket = ws |
|
captureSource = source |
|
console.log(TAG, '✓ captured via', source) |
|
ws.addEventListener('message', handleVNCMessage) |
|
ws.addEventListener('close', function () { |
|
console.log(TAG, 'WS closed') |
|
if (vncSocket === ws) { vncSocket = null; captureSource = 'none' } |
|
}) |
|
} |
|
|
|
var VNC_URL_RE = /vncwebsocket|websockify/i |
|
|
|
// Strategy A — patch WebSocket constructor (needs @early-start to win the race) |
|
var OrigWS = window.WebSocket |
|
|
|
function PatchedWebSocket(url, protocols) { |
|
var ws = protocols ? new OrigWS(url, protocols) : new OrigWS(url) |
|
if (VNC_URL_RE.test(typeof url === 'string' ? url : '')) |
|
registerVNCSocket(ws, 'constructor') |
|
return ws |
|
} |
|
PatchedWebSocket.prototype = OrigWS.prototype |
|
PatchedWebSocket.CONNECTING = OrigWS.CONNECTING |
|
PatchedWebSocket.OPEN = OrigWS.OPEN |
|
PatchedWebSocket.CLOSING = OrigWS.CLOSING |
|
PatchedWebSocket.CLOSED = OrigWS.CLOSED |
|
window.WebSocket = PatchedWebSocket |
|
|
|
// Strategy B — hook .send() as fallback if constructor patch lost the race |
|
var origSend = OrigWS.prototype.send |
|
var checked = new WeakSet() |
|
|
|
OrigWS.prototype.send = function (data) { |
|
if (!checked.has(this)) { |
|
checked.add(this) |
|
try { if (VNC_URL_RE.test(this.url || '')) registerVNCSocket(this, 'send-hook') } catch (_) {} |
|
} |
|
return origSend.call(this, data) |
|
} |
|
|
|
// Strategy C — scan for noVNC's RFB object on the page |
|
var scanCount = 0 |
|
function scanForSocket() { |
|
if (vncSocket) return |
|
if (++scanCount > 40) return |
|
try { |
|
var ws = window.UI && window.UI.rfb && window.UI.rfb._sock && window.UI.rfb._sock._websocket |
|
if (ws && ws.readyState <= 1) { registerVNCSocket(ws, 'scan'); return } |
|
} catch (_) {} |
|
setTimeout(scanForSocket, 500) |
|
} |
|
|
|
// RFB ClientCutText (type 6) — host → VM |
|
function sendRFBClipboard(text) { |
|
if (!vncSocket || vncSocket.readyState !== 1) return false |
|
var encoded = new Uint8Array(text.length) |
|
for (var i = 0; i < text.length; i++) { |
|
var code = text.charCodeAt(i) |
|
encoded[i] = code <= 0xFF ? code : 0x3F |
|
} |
|
var buf = new ArrayBuffer(8 + encoded.length) |
|
var view = new DataView(buf) |
|
view.setUint8(0, 6) |
|
view.setUint32(4, encoded.length, false) |
|
new Uint8Array(buf, 8).set(encoded) |
|
try { vncSocket.send(buf); return true } |
|
catch (err) { console.warn(TAG, 'ClientCutText failed:', err); return false } |
|
} |
|
|
|
// Canvas & keyboard helpers |
|
function getCanvas() { |
|
var best = null, bestArea = 0, all = document.querySelectorAll('canvas') |
|
for (var i = 0; i < all.length; i++) { |
|
var a = all[i].offsetWidth * all[i].offsetHeight |
|
if (a > bestArea) { bestArea = a; best = all[i] } |
|
} |
|
return best |
|
} |
|
|
|
var CODE_MAP = {} |
|
'abcdefghijklmnopqrstuvwxyz'.split('').forEach(function (ch) { |
|
CODE_MAP[ch] = 'Key' + ch.toUpperCase() |
|
CODE_MAP[ch.toUpperCase()] = 'Key' + ch.toUpperCase() |
|
}) |
|
'0123456789'.split('').forEach(function (ch) { CODE_MAP[ch] = 'Digit' + ch }) |
|
var extras = { |
|
' ':'Space','\n':'Enter','\r':'Enter','\t':'Tab', |
|
'-':'Minus','=':'Equal','[':'BracketLeft',']':'BracketRight', |
|
'\\':'Backslash',';':'Semicolon',"'":'Quote',',':'Comma', |
|
'.':'Period','/':'Slash','`':'Backquote', |
|
'!':'Digit1','@':'Digit2','#':'Digit3','$':'Digit4', |
|
'%':'Digit5','^':'Digit6','&':'Digit7','*':'Digit8', |
|
'(':'Digit9',')':'Digit0','_':'Minus','+':'Equal', |
|
'{':'BracketLeft','}':'BracketRight','|':'Backslash', |
|
':':'Semicolon','"':'Quote','<':'Comma','>':'Period', |
|
'?':'Slash','~':'Backquote', |
|
} |
|
for (var k in extras) CODE_MAP[k] = extras[k] |
|
|
|
var SHIFT_RE = /[A-Z!@#$%^&*()_+{}|:"<>?~]/ |
|
|
|
function fireKey(canvas, type, key, code, shift) { |
|
canvas.dispatchEvent(new KeyboardEvent(type, { |
|
key: key, code: code || '', keyCode: key.length === 1 ? key.charCodeAt(0) : 0, |
|
shiftKey: !!shift, bubbles: true, cancelable: true, |
|
})) |
|
} |
|
|
|
function sendCtrlV(canvas) { |
|
canvas.dispatchEvent(new KeyboardEvent('keydown', { key:'Control', code:'ControlLeft', keyCode:17, ctrlKey:true, bubbles:true })) |
|
canvas.dispatchEvent(new KeyboardEvent('keydown', { key:'v', code:'KeyV', keyCode:86, ctrlKey:true, bubbles:true })) |
|
canvas.dispatchEvent(new KeyboardEvent('keyup', { key:'v', code:'KeyV', keyCode:86, ctrlKey:true, bubbles:true })) |
|
canvas.dispatchEvent(new KeyboardEvent('keyup', { key:'Control', code:'ControlLeft', keyCode:17, bubbles:true })) |
|
} |
|
|
|
function typeString(text, canvas, delay) { |
|
if (!canvas) return |
|
var i = 0 |
|
;(function next() { |
|
if (i >= text.length) return |
|
var ch = text[i++] |
|
var key = ch === '\n' ? 'Enter' : ch === '\t' ? 'Tab' : ch |
|
var code = CODE_MAP[ch] || '' |
|
var shift = SHIFT_RE.test(ch) |
|
if (shift) fireKey(canvas, 'keydown', 'Shift', 'ShiftLeft', false) |
|
fireKey(canvas, 'keydown', key, code, shift) |
|
fireKey(canvas, 'keyup', key, code, shift) |
|
if (shift) fireKey(canvas, 'keyup', 'Shift', 'ShiftLeft', false) |
|
setTimeout(next, delay) |
|
})() |
|
} |
|
|
|
// PHASE 2 — after CAT_scriptLoaded (GM_* available) |
|
|
|
function initFull() { |
|
var SEND_DELAY_MS = 8 |
|
var PASTE_MODE = 'type' |
|
try { SEND_DELAY_MS = GM_getValue('settings.delay', 8) } catch (_) {} |
|
try { PASTE_MODE = GM_getValue('settings.pasteMode', 'type') } catch (_) {} |
|
|
|
console.log(TAG, 'init | mode:', PASTE_MODE, '| delay:', SEND_DELAY_MS, '| ws:', captureSource) |
|
if (!vncSocket) scanForSocket() |
|
|
|
async function readClipboard() { |
|
try { var t = await navigator.clipboard.readText(); if (t) return t } catch (_) {} |
|
try { |
|
var ta = document.createElement('textarea') |
|
ta.style.cssText = 'position:fixed;top:-9999px;opacity:0' |
|
document.body.appendChild(ta); ta.focus(); document.execCommand('paste') |
|
var t2 = ta.value; document.body.removeChild(ta); if (t2) return t2 |
|
} catch (_) {} |
|
return prompt('Paste text here:') || '' |
|
} |
|
|
|
async function doPaste(text, canvas) { |
|
if (!text || !canvas) return |
|
if (PASTE_MODE === 'rfb' || PASTE_MODE === 'both') { |
|
if (sendRFBClipboard(text)) { |
|
await new Promise(function (r) { setTimeout(r, 100) }) |
|
canvas.focus(); sendCtrlV(canvas) |
|
console.log(TAG, 'pasted via RFB+Ctrl+V'); return |
|
} |
|
if (PASTE_MODE === 'rfb') return |
|
} |
|
canvas.focus(); typeString(text, canvas, SEND_DELAY_MS) |
|
} |
|
|
|
// UI |
|
function flash(el, t, d) { var o = el.textContent; el.textContent = t; setTimeout(function () { el.textContent = o }, d || 1500) } |
|
|
|
function makeBtn(emoji, title) { |
|
var b = document.createElement('div') |
|
b.textContent = emoji; b.title = title |
|
b.style.cssText = 'width:36px;height:36px;display:flex;align-items:center;justify-content:center;background:rgba(30,30,30,0.85);border:1px solid rgba(255,255,255,0.15);border-radius:8px;cursor:pointer;font-size:17px;user-select:none;box-shadow:0 2px 8px rgba(0,0,0,0.5);transition:background 0.15s' |
|
b.onmouseenter = function () { b.style.background = 'rgba(60,60,60,0.95)' } |
|
b.onmouseleave = function () { b.style.background = 'rgba(30,30,30,0.85)' } |
|
b.addEventListener('mousedown', function (e) { e.stopPropagation() }) |
|
return b |
|
} |
|
|
|
function createUI() { |
|
if (document.getElementById('__pve_clip')) return |
|
var panel = document.createElement('div') |
|
panel.id = '__pve_clip' |
|
panel.style.cssText = 'position:fixed;bottom:16px;right:16px;z-index:99999;display:flex;flex-direction:column;gap:6px;align-items:flex-end' |
|
|
|
var status = document.createElement('div') |
|
status.style.cssText = 'display:none;padding:4px 10px;background:rgba(30,30,30,0.9);border:1px solid rgba(255,255,255,0.15);border-radius:6px;color:#a6e3a1;font-size:11px;font-family:monospace;pointer-events:none' |
|
function showS(m, c, d) { status.textContent = m; status.style.color = c || '#a6e3a1'; status.style.display = 'block'; if (d) setTimeout(function () { status.style.display = 'none' }, d) } |
|
|
|
var ta = document.createElement('textarea') |
|
ta.placeholder = 'VM clipboard appears here automatically\nor paste text manually + Send ↓' |
|
ta.style.cssText = 'display:none;width:300px;height:90px;background:rgba(20,20,20,0.95);color:#cdd6f4;border:1px solid rgba(255,255,255,0.15);border-radius:8px;padding:8px;font-size:12px;font-family:monospace;resize:vertical;outline:none' |
|
ta.addEventListener('mousedown', function (e) { e.stopPropagation() }) |
|
ta.addEventListener('keydown', function (e) { e.stopPropagation() }) |
|
|
|
var btnStyle = 'display:none;padding:4px 10px;background:rgba(30,30,30,0.9);border:1px solid rgba(255,255,255,0.15);border-radius:6px;color:#cdd6f4;font-size:12px;font-family:monospace;cursor:pointer;user-select:none' |
|
|
|
var copyBtn = document.createElement('div') |
|
copyBtn.style.cssText = btnStyle |
|
copyBtn.textContent = '📤 Copy to system clipboard' |
|
copyBtn.addEventListener('mousedown', function (e) { e.stopPropagation() }) |
|
copyBtn.addEventListener('click', function (e) { |
|
e.stopPropagation(); if (!ta.value) return |
|
try { GM_setClipboard(ta.value, 'text') } catch (_) {} |
|
flash(copyBtn, '✅ Copied!') |
|
}) |
|
|
|
var sendBtn = document.createElement('div') |
|
sendBtn.style.cssText = btnStyle |
|
sendBtn.textContent = '⌨️ Send buffer → VM' |
|
sendBtn.addEventListener('mousedown', function (e) { e.stopPropagation() }) |
|
sendBtn.addEventListener('click', async function (e) { |
|
e.stopPropagation(); if (!ta.value) return |
|
var c = getCanvas() |
|
if (!c) { showS('⚠ No canvas', '#f38ba8', 2000); return } |
|
await doPaste(ta.value, c) |
|
flash(sendBtn, '✅ Sent!') |
|
showS('Sent ' + ta.value.length + ' chars (' + PASTE_MODE + ')', '#a6e3a1', 2000) |
|
}) |
|
|
|
onRFBClipboard(function (text) { |
|
ta.value = text |
|
if (ta.style.display === 'none') { ta.style.display = 'block'; copyBtn.style.display = 'block'; sendBtn.style.display = 'block' } |
|
try { GM_setClipboard(text, 'text') } catch (_) {} |
|
showS('📋 VM → system (' + text.length + ' chars)', '#a6e3a1', 3000) |
|
}) |
|
|
|
var pasteBtn = makeBtn('📋', 'Paste → VM (' + PASTE_MODE + ')') |
|
pasteBtn.addEventListener('click', async function (e) { |
|
e.preventDefault(); e.stopPropagation() |
|
var text = await readClipboard(); if (!text) return |
|
var c = getCanvas(); if (!c) { showS('⚠ No canvas', '#f38ba8', 2000); return } |
|
await doPaste(text, c) |
|
showS('Pasted ' + text.length + ' chars', '#a6e3a1', 2000) |
|
}) |
|
|
|
var taVis = false |
|
var taBtn = makeBtn('📥', 'Toggle buffer') |
|
taBtn.addEventListener('click', function (e) { |
|
e.stopPropagation(); taVis = !taVis |
|
ta.style.display = taVis ? 'block' : 'none' |
|
copyBtn.style.display = taVis ? 'block' : 'none' |
|
sendBtn.style.display = taVis ? 'block' : 'none' |
|
if (taVis) ta.focus() |
|
}) |
|
|
|
var dbgBtn = makeBtn('🔧', 'Debug') |
|
dbgBtn.addEventListener('click', function (e) { |
|
e.stopPropagation() |
|
var wsInfo = 'none' |
|
if (vncSocket) { |
|
wsInfo = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][vncSocket.readyState] || vncSocket.readyState |
|
try { wsInfo += ' ' + vncSocket.url.split('?')[0] } catch (_) {} |
|
} |
|
showS('WS:' + wsInfo + ' | via:' + captureSource + ' | canvas:' + (getCanvas() ? 'yes' : 'no') + ' | mode:' + PASTE_MODE, '#89b4fa', 8000) |
|
}) |
|
|
|
var row = document.createElement('div') |
|
row.style.cssText = 'display:flex;gap:6px' |
|
row.appendChild(dbgBtn); row.appendChild(taBtn); row.appendChild(pasteBtn) |
|
|
|
panel.appendChild(status); panel.appendChild(ta) |
|
panel.appendChild(copyBtn); panel.appendChild(sendBtn); panel.appendChild(row) |
|
document.body.appendChild(panel) |
|
|
|
document.addEventListener('mousedown', async function (e) { |
|
if (e.button !== 2) return |
|
var c = getCanvas(); if (!c || e.target !== c) return |
|
e.preventDefault(); e.stopPropagation() |
|
var text = await readClipboard(); if (!text) return |
|
await doPaste(text, c) |
|
showS('Right-click: ' + text.length + ' chars', '#a6e3a1', 2000) |
|
}, true) |
|
} |
|
|
|
try { |
|
GM_registerMenuCommand('📋 Paste → VM', async function () { |
|
var t = await readClipboard(); if (!t) return |
|
var c = getCanvas(); if (c) await doPaste(t, c) |
|
}) |
|
GM_registerMenuCommand('📥 Toggle buffer', function () { |
|
var b = document.querySelector('#__pve_clip div[title="Toggle buffer"]'); if (b) b.click() |
|
}) |
|
} catch (_) {} |
|
|
|
if (document.body) createUI() |
|
else if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { setTimeout(createUI, 300) }) |
|
else setTimeout(createUI, 300) |
|
} |
|
|
|
// Boot |
|
if (typeof CAT_scriptLoaded === 'function') { |
|
CAT_scriptLoaded().then(function () { initFull() }) |
|
} else if (document.readyState === 'loading') { |
|
document.addEventListener('DOMContentLoaded', function () { setTimeout(initFull, 200) }) |
|
} else { |
|
setTimeout(initFull, 200) |
|
} |
|
})() |