Skip to content

Instantly share code, notes, and snippets.

@AdamsGH
Last active March 24, 2026 20:23
Show Gist options
  • Select an option

  • Save AdamsGH/61d7c9e3e23d734c26edfcd521b801d4 to your computer and use it in GitHub Desktop.

Select an option

Save AdamsGH/61d7c9e3e23d734c26edfcd521b801d4 to your computer and use it in GitHub Desktop.
User script to copy & paste text into noVNC Proxmox terminal

noVNC Clipboard for Proxmox

Bidirectional clipboard between your browser and VMs in the Proxmox noVNC console.

The problem

Proxmox's built-in noVNC console has no clipboard support — you can't paste text into a VM or copy text out of it. This script fixes that.

How it works

Paste (host → VM): Click 📋 or right-click the console canvas. The script reads your system clipboard and types each character into the VM via simulated keyboard events. Optionally it can set the VM clipboard via the RFB protocol and send Ctrl+V (faster, but requires a guest agent).

Copy (VM → host): When you copy text inside the VM, the VNC server sends a ServerCutText message over the WebSocket. The script intercepts it and writes the text to your system clipboard automatically.

Requirements

  • ScriptCat browser extension (v1.1+ recommended for @early-start support)
  • Also works with Tampermonkey/Violentmonkey, but without @early-start the WebSocket interception may be less reliable
  • Proxmox VE with noVNC console (tested on PVE 8.x)

Install

  1. Install ScriptCat
  2. Click the .user.js file in this gist — ScriptCat will offer to install it
  3. Open any VM console in Proxmox — the clipboard buttons appear in the bottom-right corner

Usage

Button Action
📋 Paste system clipboard → VM
📥 Toggle clipboard buffer (textarea)
🔧 Show debug info (WebSocket status, capture method)

Right-click on the console canvas also triggers paste.

The textarea buffer auto-populates when the VM sends clipboard content. You can also paste text into it manually and click ⌨️ Send buffer → VM.

Settings

Open ScriptCat's settings for this script to configure:

  • Keystroke delay (default: 8ms) — interval between simulated keystrokes in type mode
  • Paste mode:
    • type — simulate keystrokes one by one (slow but always works)
    • rfb — send ClientCutText + Ctrl+V (fast, needs guest agent in VM)
    • both — try rfb first, fall back to type

Matching your Proxmox URL

The default @match is https://*:8006/* which covers standard Proxmox installations. If your setup uses a different port or URL, edit the @match line in the script header.

VM → Host clipboard

In many setups copy from VM works out of the box — QEMU's built-in VNC server forwards the guest clipboard via the standard RFB ServerCutText message without any extra software.

If it doesn't work for you, install a clipboard agent inside the guest:

Linux:

sudo apt install spice-vdagent    # Debian/Ubuntu
sudo dnf install spice-vdagent    # Fedora/RHEL
sudo systemctl enable --now spice-vdagentd

Windows: Install SPICE Guest Tools or the QEMU Guest Agent from the VirtIO drivers ISO.

Note: Paste into VM always works regardless (via keystroke simulation).

Architecture

The script runs in two phases:

  1. Early phase (@early-start + @run-at document-start) — patches the WebSocket constructor before noVNC creates its connection, plus hooks WebSocket.prototype.send as a fallback
  2. Full phase (after CAT_scriptLoaded()) — initializes UI, reads user settings via GM_getValue, registers menu commands

Three strategies ensure the VNC WebSocket is captured:

  • Constructor patch — intercepts new WebSocket() calls matching VNC URL patterns
  • Send hook — catches sockets on first .send() if the constructor patch lost the timing race
  • Periodic scan — looks for noVNC's UI.rfb._sock._websocket object as a last resort
// ==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)
}
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment