Files
rustguac/static/client.html
T
Dave Kempe 175c30b705 H.264: fix unbounded stream lag via sync gating and frame dropping
The H.264 decode path bypassed the Guacamole sync mechanism — the sync
response fired immediately without waiting for WebCodecs to finish
decoding. guacd thought the client was keeping up and sent at full rate,
causing the decode queue to grow without bound (30+ seconds of lag
observed over time).

Fix: gate the sync response on H.264 decode completion so guacd gets
real backpressure. Also drop delta frames when the decode queue exceeds
5 frames as a safety valve for transient overload (tab backgrounding).

- Add pending-decode tracking and per-frame position capture to
  H264Decoder.js (fixes shared mutable state race)
- Add waitForPending() with 1s safety timeout for sync gating
- Add frame dropping when decodeQueueSize > 5 (never drops keyframes)
- Add stats() method for console debugging (__guac_client._h264Decoder.stats())
- Gate sync response in Client.js on H.264 decode completion

Closes #93
2026-04-08 07:21:23 +10:00

1002 lines
52 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>rustguac - SSH Session</title>
<style>
:root { --primary: #e94560; --primary-hover: #c73652; --accent: #5bc0be; --accent-hover: #4aa3a1; --bg: #1a1a2e; --surface: #16213e; --input: #0f3460; --text: #e0e0e0; --text-muted: #aaa; --border: #333; --text-dim: #888; --text-on-primary: #fff; --btn-disabled: #555; --status-pending: #f0c040; --status-active: #5bc0be; --status-completed: #888; --status-error: #e94560; --status-expired: #666; --type-ssh-bg: #1a3a2a; --type-ssh-fg: #5bc0be; --type-rdp-bg: #2a1a3a; --type-rdp-fg: #a78bfa; --type-vnc-bg: #3a2a1a; --type-vnc-fg: #f0c040; --type-web-bg: #1a2a3a; --type-web-fg: #60a5fa; --hop-bg: #1e3a5f; --hop-fg: #7ec8e3; --bg-pattern: none; }
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
#display {
width: 100%;
height: 100%;
}
#status {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 8px 16px;
background: rgba(0, 0, 0, 0.8);
color: var(--text-muted);
font-family: monospace;
font-size: 12px;
z-index: 1000;
transition: opacity 0.5s;
}
#status.connected { opacity: 0; pointer-events: none; }
#banner-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 2000;
justify-content: center;
align-items: center;
}
#banner-overlay.visible { display: flex; }
#banner-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2em 3em;
max-width: 600px;
text-align: center;
font-family: monospace;
color: var(--text);
}
#banner-text {
font-size: 1.1em;
line-height: 1.6;
margin-bottom: 1.5em;
white-space: pre-wrap;
}
#banner-continue {
padding: 0.6em 2em;
background: var(--primary);
color: var(--text-on-primary);
border: none;
font-family: monospace;
font-size: 1em;
border-radius: 4px;
cursor: pointer;
}
#banner-continue:hover { background: var(--primary-hover); }
#disconnected-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 3000;
justify-content: center;
align-items: center;
}
#disconnected-overlay.visible { display: flex; }
#disconnected-box {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 2em 3em;
max-width: 500px;
text-align: center;
font-family: monospace;
color: var(--text);
}
#disconnected-box h2 {
margin: 0 0 0.5em 0;
color: var(--text);
font-size: 1.3em;
}
#disconnected-box p {
color: var(--text-muted);
margin: 0 0 1.5em 0;
font-size: 0.95em;
}
#disconnected-box button {
padding: 0.6em 2em;
border: none;
font-family: monospace;
font-size: 1em;
border-radius: 4px;
cursor: pointer;
margin: 0 0.3em;
}
#btn-close-session {
background: var(--primary);
color: var(--text-on-primary);
}
#btn-close-session:hover { background: var(--primary-hover); }
#btn-reconnect {
background: var(--input);
color: var(--text);
border: 1px solid var(--border) !important;
}
#btn-reconnect:hover { background: var(--border); }
</style>
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
<!-- Guacamole common JS modules -->
<script src="/guac/Namespace.js"></script>
<script src="/guac/Client.js"></script>
<script src="/guac/Display.js"></script>
<script src="/guac/Event.js"></script>
<script src="/guac/InputSink.js"></script>
<script src="/guac/IntegerPool.js"></script>
<script src="/guac/Keyboard.js"></script>
<script src="/guac/KeyEventInterpreter.js"></script>
<script src="/guac/Layer.js"></script>
<script src="/guac/Mouse.js"></script>
<script src="/guac/Parser.js"></script>
<script src="/guac/Position.js"></script>
<script src="/guac/Status.js"></script>
<script src="/guac/Touch.js"></script>
<script src="/guac/Tunnel.js"></script>
<script src="/guac/UTF8Parser.js"></script>
<script src="/guac/InputStream.js"></script>
<script src="/guac/OutputStream.js"></script>
<script src="/guac/StringReader.js"></script>
<script src="/guac/StringWriter.js"></script>
<script src="/guac/ArrayBufferReader.js"></script>
<script src="/guac/ArrayBufferWriter.js"></script>
<script src="/guac/BlobReader.js"></script>
<script src="/guac/BlobWriter.js"></script>
<script src="/guac/DataURIReader.js"></script>
<script src="/guac/AudioContextFactory.js"></script>
<script src="/guac/AudioPlayer.js"></script>
<script src="/guac/AudioRecorder.js"></script>
<script src="/guac/VideoPlayer.js"></script>
<script src="/guac/H264Decoder.js"></script>
<script src="/guac/JSONReader.js"></script>
<script src="/guac/Object.js"></script>
<script src="/guac/RawAudioFormat.js"></script>
<script src="/guac/SessionRecording.js"></script>
</head>
<body>
<div id="disconnected-overlay">
<div id="disconnected-box">
<h2 id="disconnected-title">Session Ended</h2>
<p id="disconnected-message">The remote session has been disconnected.</p>
<div>
<button id="btn-reconnect">Reconnect</button>
<button id="btn-close-session">Close</button>
</div>
</div>
</div>
<div id="banner-overlay">
<div id="banner-box">
<div id="banner-text"></div>
<button id="banner-continue">Continue</button>
</div>
</div>
<div id="status">Loading...</div>
<div id="display"></div>
<script>
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
var _themePresets={},_adminPreset='dark';
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);}
var pathParts = window.location.pathname.split('/');
var sessionId = pathParts[pathParts.length - 1];
if (!sessionId) {
document.getElementById('status').textContent = 'Error: No session ID in URL';
throw new Error('No session ID');
}
var statusEl = document.getElementById('status');
var urlParams = new URLSearchParams(window.location.search);
var shareToken = urlParams.get('token');
var apiKey = sessionStorage.getItem('rustguac_api_key');
var entryName = urlParams.get('name');
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
var siteTitle = d.site_title || 'rustguac';
document.title = entryName ? (entryName + ' — ' + siteTitle) : (siteTitle + ' - Session');
initTheme(d.theme);
});
function showBanner(bannerText) {
if (!bannerText) return Promise.resolve();
return new Promise(function(resolve) {
document.getElementById('banner-text').textContent = bannerText;
var keyMatch = bannerText.match(/ssh-ed25519\s+\S+(\s+\S+)?/);
if (keyMatch) {
var copyBtn = document.createElement('button');
copyBtn.textContent = 'Copy public key';
copyBtn.style.cssText = 'margin-right:1em;padding:0.4em 1em;background:var(--accent);color:var(--bg);border:none;font-family:monospace;font-size:0.9em;border-radius:4px;cursor:pointer;';
copyBtn.addEventListener('click', function() {
var keyText = keyMatch[0];
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(keyText).then(function() {
copyBtn.textContent = 'Copied!';
});
} else {
var ta = document.createElement('textarea');
ta.value = keyText;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
copyBtn.textContent = 'Copied!';
}
});
document.getElementById('banner-box').insertBefore(copyBtn, document.getElementById('banner-continue'));
}
document.getElementById('banner-overlay').className = 'visible';
document.getElementById('banner-continue').addEventListener('click', function() {
document.getElementById('banner-overlay').className = '';
resolve();
});
});
}
if (shareToken) {
fetch('/api/sessions/' + sessionId + '/banner?token=' + encodeURIComponent(shareToken))
.then(function(res) { return res.ok ? res.json() : null; })
.then(function(data) { return showBanner(data && data.banner); })
.then(function() { startGuacamole(); })
.catch(function() { startGuacamole(); });
} else {
var fetchHeaders = {};
if (apiKey) fetchHeaders['Authorization'] = 'Bearer ' + apiKey;
fetch('/api/sessions/' + sessionId, { headers: fetchHeaders, credentials: 'same-origin' })
.then(function(res) {
if (!res.ok) { statusEl.textContent = 'Error: Session not found'; throw new Error('Session not found'); }
return res.json();
})
.then(function(session) { return showBanner(session.banner); })
.then(function() { startGuacamole(); })
.catch(function(err) { statusEl.textContent = 'Error: ' + err.message; });
}
function connectWebSocket(ticketParam) {
var wsProtocol = (window.location.protocol === 'https:') ? 'wss:' : 'ws:';
var wsUrl = wsProtocol + '//' + window.location.host + '/ws/' + sessionId;
if (ticketParam) wsUrl += '?ticket=' + encodeURIComponent(ticketParam);
var tunnel = new Guacamole.WebSocketTunnel(wsUrl);
var client = new Guacamole.Client(tunnel);
// Expose for console debugging (e.g. client._h264Decoder.stats())
window.__guac_client = client;
setupClient(client, tunnel);
}
function startGuacamole() {
statusEl.textContent = 'Connecting to session ' + sessionId.substring(0, 8) + '...';
// API key users: exchange key for a single-use ticket before connecting.
// This keeps the API key out of the WebSocket URL (visible in logs).
if (apiKey) {
fetch('/api/ws-ticket', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + apiKey }
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.ticket) {
connectWebSocket(data.ticket);
} else {
statusEl.textContent = 'Failed to obtain WebSocket ticket';
}
})
.catch(function(err) {
statusEl.textContent = 'Ticket error: ' + err.message;
});
return;
}
// OIDC users: connect directly (session cookie handles auth)
connectWebSocket(null);
}
function setupClient(client, tunnel) {
// Wrap tunnel.oninstruction to log unique opcodes from guacd (diagnostic)
var seenOpcodes = {};
var origOnInstruction = tunnel.oninstruction;
tunnel.oninstruction = function(opcode, args) {
if (!seenOpcodes[opcode]) {
seenOpcodes[opcode] = true;
if (typeof console !== 'undefined') console.log('[rustguac] instruction: ' + opcode + (args.length ? ' (' + args.length + ' args)' : ''));
}
if (origOnInstruction) origOnInstruction(opcode, args);
};
var displayEl = document.getElementById('display');
displayEl.appendChild(client.getDisplay().getElement());
// ── Resume AudioContext on user interaction (browser autoplay policy) ──
function resumeAudio() {
var ctx = Guacamole.AudioContextFactory.getAudioContext();
if (ctx && ctx.state === 'suspended') {
ctx.resume().then(function() {
if (typeof console !== 'undefined') console.log('[rustguac] AudioContext resumed');
});
}
}
// Try on every interaction type — browsers are strict about this
['click', 'keydown', 'mousedown', 'touchstart'].forEach(function(evt) {
document.addEventListener(evt, resumeAudio, true);
});
// ── Clipboard state ──
var remoteClipboard = '';
var panelOpen = false;
var clipboardPanel = null;
var clipboardTextarea = null;
var clipboardStatusEl = null;
function setClipboardStatus(msg) {
if (!clipboardStatusEl) return;
clipboardStatusEl.textContent = msg;
setTimeout(function() {
if (clipboardStatusEl && clipboardStatusEl.textContent === msg) clipboardStatusEl.textContent = '';
}, 3000);
}
// ── Build clipboard panel dynamically ──
function buildClipboardPanel() {
if (clipboardPanel) return;
clipboardPanel = document.createElement('div');
clipboardPanel.style.cssText = 'display:none;position:fixed;top:0;left:0;bottom:0;width:380px;background:var(--bg);border-right:2px solid var(--primary);z-index:3000;font-family:monospace;color:var(--text);flex-direction:column;';
clipboardPanel.innerHTML =
'<div style="display:flex;align-items:center;padding:0.8em 1em;border-bottom:1px solid var(--border);background:var(--surface);">' +
'<h3 style="margin:0;flex:1;color:var(--primary);font-size:1.1em;">Clipboard</h3>' +
'<button id="cp-close" style="background:none;border:none;color:var(--text-dim);font-size:1.6em;cursor:pointer;font-family:monospace;padding:0 0.3em;">&times;</button>' +
'</div>' +
'<div style="flex:1;display:flex;flex-direction:column;padding:1em;gap:0.8em;overflow-y:auto;min-height:0;">' +
'<div style="color:var(--text-muted);font-size:0.95em;">Shared clipboard. Paste text below and click Send, or copy text received from the remote session.</div>' +
'<textarea id="cp-text" style="flex:1;min-height:150px;width:100%;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;padding:0.6em;resize:none;border-radius:3px;box-sizing:border-box;" placeholder="Paste text here..."></textarea>' +
'<div style="display:flex;gap:0.6em;flex-wrap:wrap;">' +
'<button id="cp-send" style="padding:0.4em 0.8em;background:var(--accent);color:var(--bg);font-weight:bold;border:none;font-family:monospace;font-size:0.95em;border-radius:3px;cursor:pointer;">Send to session</button>' +
'<button id="cp-copy" style="padding:0.4em 0.8em;background:var(--input);color:var(--accent);border:1px solid var(--border);font-family:monospace;font-size:0.95em;border-radius:3px;cursor:pointer;">Copy to clipboard</button>' +
'<button id="cp-clear" style="padding:0.4em 0.8em;background:var(--border);color:var(--text-muted);border:none;font-family:monospace;font-size:0.95em;border-radius:3px;cursor:pointer;">Clear</button>' +
'</div>' +
'<div id="cp-status" style="color:var(--accent);font-size:0.9em;min-height:1.2em;"></div>' +
'<div style="color:var(--text-dim);font-size:0.85em;line-height:1.4;">Press <b>Ctrl+Alt+Shift</b> to toggle this panel.<br>Text copied in the remote session appears here automatically.</div>' +
'</div>';
document.body.appendChild(clipboardPanel);
clipboardTextarea = document.getElementById('cp-text');
clipboardStatusEl = document.getElementById('cp-status');
document.getElementById('cp-close').addEventListener('click', function() {
toggleClipboardPanel();
});
document.getElementById('cp-send').addEventListener('click', function() {
if (clipboardTextarea.value) sendClipboardToRemote(clipboardTextarea.value);
});
document.getElementById('cp-copy').addEventListener('click', function() {
var text = clipboardTextarea.value;
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
setClipboardStatus('Copied to browser clipboard.');
}).catch(function() {
clipboardTextarea.select();
document.execCommand('copy');
setClipboardStatus('Copied.');
});
} else {
clipboardTextarea.select();
document.execCommand('copy');
setClipboardStatus('Copied.');
}
});
document.getElementById('cp-clear').addEventListener('click', function() {
clipboardTextarea.value = '';
remoteClipboard = '';
});
}
// ── Build toggle tab (always visible on right edge) ──
var toggleTab = document.createElement('div');
toggleTab.textContent = '📋 Clipboard';
toggleTab.title = 'Toggle clipboard panel (Ctrl+Alt+Shift)';
toggleTab.style.cssText = 'position:fixed;left:-1px;top:50%;transform:translateY(-50%);writing-mode:vertical-rl;background:var(--primary);color:var(--text-on-primary);padding:0.6em 0.3em;font-family:monospace;font-size:0.75em;cursor:pointer;z-index:3001;border-radius:0 4px 4px 0;opacity:0.6;letter-spacing:0.1em;';
toggleTab.addEventListener('mouseenter', function() { toggleTab.style.opacity = '1'; });
toggleTab.addEventListener('mouseleave', function() { toggleTab.style.opacity = '0.6'; });
toggleTab.addEventListener('click', function() { toggleClipboardPanel(); });
document.body.appendChild(toggleTab);
// ── Receive clipboard from remote ──
client.onclipboard = function(stream, mimetype) {
if (mimetype !== 'text/plain') {
var r = new Guacamole.StringReader(stream);
r.ontext = function() {};
r.onend = function() {};
return;
}
var data = '';
var reader = new Guacamole.StringReader(stream);
reader.ontext = function(text) { data += text; };
reader.onend = function() {
remoteClipboard = data;
if (panelOpen && clipboardTextarea) clipboardTextarea.value = data;
if (navigator.clipboard && navigator.clipboard.writeText && document.hasFocus()) {
navigator.clipboard.writeText(data).then(function() {
setClipboardStatus('Remote clipboard synced.');
}).catch(function() {});
}
};
};
// ── Send clipboard to remote ──
function sendClipboardToRemote(text) {
var stream = client.createClipboardStream('text/plain');
var writer = new Guacamole.StringWriter(stream);
for (var i = 0; i < text.length; i += 4096) {
writer.sendText(text.substring(i, i + 4096));
}
writer.sendEnd();
setClipboardStatus('Sent to session (' + text.length + ' chars).');
}
// ── Toggle panel ──
function toggleClipboardPanel() {
panelOpen = !panelOpen;
if (panelOpen) {
buildClipboardPanel();
clipboardPanel.style.display = 'flex';
clipboardTextarea.value = remoteClipboard;
clipboardTextarea.focus();
toggleTab.style.display = 'none';
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText().then(function(text) {
if (text && text !== remoteClipboard) clipboardTextarea.value = text;
}).catch(function() {});
}
} else {
if (clipboardPanel) clipboardPanel.style.display = 'none';
toggleTab.style.display = '';
displayEl.focus();
keyboard.reset();
}
scaleDisplay();
}
// ── Auto-sync clipboard on window focus ──
window.addEventListener('focus', function() {
if (!panelOpen && navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText().then(function(text) {
if (text && text !== remoteClipboard) {
sendClipboardToRemote(text);
remoteClipboard = text;
}
}).catch(function() {});
}
});
// ── File Manager state ──
var filesystem = null;
var filePanelOpen = false;
var filePanel = null;
var fileListEl = null;
var fileBreadcrumb = null;
var fileStatusEl = null;
var currentPath = '/';
var cachedListings = {}; // Cache directory listings from unsolicited body responses
// ── Build file manager toggle tab (hidden until filesystem available) ──
var fileTab = document.createElement('div');
fileTab.textContent = 'Files';
fileTab.title = 'Toggle file manager';
fileTab.style.cssText = 'display:none;position:fixed;left:-1px;top:calc(50% + 60px);transform:translateY(-50%);writing-mode:vertical-rl;background:var(--accent);color:var(--text-on-primary);padding:0.6em 0.3em;font-family:monospace;font-size:0.75em;cursor:pointer;z-index:3001;border-radius:0 4px 4px 0;opacity:0.6;letter-spacing:0.1em;font-weight:bold;';
fileTab.addEventListener('mouseenter', function() { fileTab.style.opacity = '1'; });
fileTab.addEventListener('mouseleave', function() { fileTab.style.opacity = '0.6'; });
fileTab.addEventListener('click', function() { toggleFilePanel(); });
document.body.appendChild(fileTab);
function buildFilePanel() {
if (filePanel) return;
filePanel = document.createElement('div');
filePanel.style.cssText = 'display:none;position:fixed;top:0;left:0;bottom:0;width:380px;background:var(--bg);border-right:2px solid var(--accent);z-index:3000;font-family:monospace;color:var(--text);flex-direction:column;';
filePanel.innerHTML =
'<div style="display:flex;align-items:center;padding:0.8em 1em;border-bottom:1px solid var(--border);background:var(--surface);">' +
'<h3 style="margin:0;flex:1;color:var(--accent);font-size:1.1em;">File Manager</h3>' +
'<button id="fp-close" style="background:none;border:none;color:var(--text-dim);font-size:1.6em;cursor:pointer;font-family:monospace;padding:0 0.3em;">&times;</button>' +
'</div>' +
'<div id="fp-breadcrumb" style="padding:0.5em 1em;color:var(--text-muted);font-size:0.95em;border-bottom:1px solid var(--border);background:var(--input);word-break:break-all;"></div>' +
'<div style="padding:0.5em 1em;display:flex;gap:0.5em;border-bottom:1px solid var(--border);">' +
'<button id="fp-upload" style="padding:0.4em 0.8em;background:var(--accent);color:var(--bg);font-weight:bold;border:none;font-family:monospace;font-size:0.95em;border-radius:3px;cursor:pointer;">Upload</button>' +
'<button id="fp-refresh" style="padding:0.4em 0.8em;background:var(--input);color:var(--accent);border:1px solid var(--border);font-family:monospace;font-size:0.95em;border-radius:3px;cursor:pointer;">Refresh</button>' +
'</div>' +
'<div id="fp-list" style="flex:1;overflow-y:auto;min-height:0;"></div>' +
'<div id="fp-status" style="padding:0.5em 1em;color:var(--accent);font-size:0.9em;min-height:1.5em;border-top:1px solid var(--border);"></div>' +
'<div style="padding:0.6em 1em;color:var(--text-dim);font-size:0.85em;border-top:1px solid var(--border);">Drag files here to upload. Files are temporary and will be deleted when the session ends.</div>';
document.body.appendChild(filePanel);
fileListEl = document.getElementById('fp-list');
fileBreadcrumb = document.getElementById('fp-breadcrumb');
fileStatusEl = document.getElementById('fp-status');
document.getElementById('fp-close').addEventListener('click', function() { toggleFilePanel(); });
document.getElementById('fp-refresh').addEventListener('click', function() { refreshDirectory(); });
document.getElementById('fp-upload').addEventListener('click', function() {
var input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.addEventListener('change', function() {
for (var i = 0; i < input.files.length; i++) {
uploadFile(input.files[i]);
}
});
input.click();
});
// Drag and drop on file panel
filePanel.addEventListener('dragover', function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
filePanel.addEventListener('drop', function(e) {
e.preventDefault();
for (var i = 0; i < e.dataTransfer.files.length; i++) {
uploadFile(e.dataTransfer.files[i]);
}
});
}
function toggleFilePanel() {
filePanelOpen = !filePanelOpen;
if (filePanelOpen) {
// Close clipboard panel if open
if (panelOpen) toggleClipboardPanel();
buildFilePanel();
filePanel.style.display = 'flex';
fileTab.style.display = 'none';
browseDirectory(currentPath);
} else {
if (filePanel) filePanel.style.display = 'none';
fileTab.style.display = '';
displayEl.focus();
keyboard.reset();
}
scaleDisplay();
}
function setFileStatus(msg) {
if (!fileStatusEl) return;
fileStatusEl.textContent = msg;
setTimeout(function() {
if (fileStatusEl && fileStatusEl.textContent === msg) fileStatusEl.textContent = '';
}, 4000);
}
function escapeHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function escapeAttr(s) {
return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function fetchDirectoryListing(path) {
if (!filesystem) return;
tracing_log('Requesting directory listing for: ' + path);
filesystem.requestInputStream(path, function(stream, mimetype) {
tracing_log('Received body for ' + path + ': mimetype=' + mimetype);
if (mimetype !== Guacamole.Object.STREAM_INDEX_MIMETYPE) {
stream.sendAck('Unexpected mimetype', 0x0100);
tracing_log('Unexpected mimetype for directory: ' + mimetype);
return;
}
// Signal guacd we are ready to receive data (required for flow control)
stream.sendAck('Ready', 0x0000);
// Read the JSON listing using JSONReader (wraps StringReader).
// Ack each blob via onprogress — guacd waits for ack before
// sending next blob (same pattern as Apache Guacamole webapp).
var reader = new Guacamole.JSONReader(stream);
reader.onprogress = function() {
stream.sendAck('Received', 0x0000);
};
reader.onend = function() {
try {
var listing = reader.getJSON();
cachedListings[path] = listing;
tracing_log('Cached listing for ' + path + ': ' + Object.keys(listing).length + ' entries');
if (filePanelOpen && currentPath === path) {
renderFileListing(listing, path);
}
} catch (e) {
tracing_log('Failed to parse listing: ' + e);
if (filePanelOpen && currentPath === path && fileListEl) {
fileListEl.innerHTML = '<div style="padding:1em;color:#f88;">Error loading directory</div>';
}
}
};
});
}
function browseDirectory(path) {
if (!filesystem) return;
currentPath = path;
if (fileBreadcrumb) {
var parts = path.split('/').filter(function(p) { return p; });
var html = '<span style="cursor:pointer;color:var(--accent);" data-path="/">/</span>';
var built = '';
for (var i = 0; i < parts.length; i++) {
built += '/' + parts[i];
html += ' <span style="cursor:pointer;color:var(--accent);" data-path="' + escapeAttr(built) + '/">' + escapeHtml(parts[i]) + '</span>/';
}
fileBreadcrumb.innerHTML = html;
var spans = fileBreadcrumb.querySelectorAll('span[data-path]');
for (var j = 0; j < spans.length; j++) {
spans[j].addEventListener('click', function() {
browseDirectory(this.getAttribute('data-path'));
});
}
}
// Check cache first
if (cachedListings[path]) {
renderFileListing(cachedListings[path], path);
return;
}
if (fileListEl) fileListEl.innerHTML = '<div style="padding:1em;color:var(--text-dim);">Loading...</div>';
fetchDirectoryListing(path);
}
function refreshDirectory() {
delete cachedListings[currentPath];
if (fileListEl) fileListEl.innerHTML = '<div style="padding:1em;color:var(--text-dim);">Loading...</div>';
fetchDirectoryListing(currentPath);
}
function renderFileListing(listing, parentPath) {
if (!fileListEl) return;
var entries = Object.keys(listing);
if (entries.length === 0) {
fileListEl.innerHTML = '<div style="padding:1em;color:var(--text-dim);">Empty directory</div>';
return;
}
// Determine prefix to strip from full stream names.
// guacd returns entries as full paths: "/subdir", "/file.txt"
var prefix = parentPath;
if (prefix.charAt(prefix.length - 1) !== '/') prefix += '/';
// Sort: directories first, then files
var dirs = [];
var files = [];
for (var i = 0; i < entries.length; i++) {
var streamName = entries[i];
var mime = listing[streamName];
// Strip prefix to get display name
var displayName = streamName;
if (streamName.substring(0, prefix.length) === prefix) {
displayName = streamName.substring(prefix.length);
}
// Skip empty names (the directory itself)
if (!displayName || displayName === '/') continue;
if (mime === Guacamole.Object.STREAM_INDEX_MIMETYPE) {
dirs.push({ displayName: displayName, streamName: streamName });
} else {
files.push({ displayName: displayName, streamName: streamName, mimetype: mime });
}
}
dirs.sort(function(a, b) { return a.displayName.localeCompare(b.displayName); });
files.sort(function(a, b) { return a.displayName.localeCompare(b.displayName); });
if (dirs.length === 0 && files.length === 0) {
fileListEl.innerHTML = '<div style="padding:1em;color:var(--text-dim);">Empty directory</div>';
return;
}
var html = '';
for (var d = 0; d < dirs.length; d++) {
// For subdirectory browsing, use the streamName (full path)
var dirBrowsePath = dirs[d].streamName;
if (dirBrowsePath.charAt(dirBrowsePath.length - 1) !== '/') dirBrowsePath += '/';
html += '<div class="fp-entry" data-path="' + escapeAttr(dirBrowsePath) + '" data-type="dir" style="padding:0.5em 1em;cursor:pointer;display:flex;align-items:center;gap:0.5em;border-bottom:1px solid var(--bg);">' +
'<span style="color:var(--accent);">&#x1F4C1;</span>' +
'<span style="flex:1;color:var(--text);font-size:0.95em;">' + escapeHtml(dirs[d].displayName) + '</span>' +
'</div>';
}
for (var fi = 0; fi < files.length; fi++) {
var filePath = files[fi].streamName;
html += '<div class="fp-entry" data-path="' + escapeAttr(filePath) + '" data-type="file" style="padding:0.5em 1em;cursor:pointer;display:flex;align-items:center;gap:0.5em;border-bottom:1px solid var(--bg);">' +
'<span style="color:var(--text-muted);">&#x1F4C4;</span>' +
'<span style="flex:1;color:var(--text);font-size:0.95em;">' + escapeHtml(files[fi].displayName) + '</span>' +
'<button class="fp-dl" data-path="' + escapeAttr(filePath) + '" data-name="' + escapeAttr(files[fi].displayName) + '" style="padding:0.3em 0.6em;background:var(--input);color:var(--accent);border:1px solid var(--border);font-family:monospace;font-size:0.85em;border-radius:3px;cursor:pointer;">Download</button>' +
'</div>';
}
fileListEl.innerHTML = html;
// Bind click handlers
var entryEls = fileListEl.querySelectorAll('.fp-entry[data-type="dir"]');
for (var ei = 0; ei < entryEls.length; ei++) {
entryEls[ei].addEventListener('click', function() {
browseDirectory(this.getAttribute('data-path'));
});
}
// Hover effect
var allEntries = fileListEl.querySelectorAll('.fp-entry');
for (var ae = 0; ae < allEntries.length; ae++) {
allEntries[ae].addEventListener('mouseenter', function() { this.style.background = 'var(--surface)'; });
allEntries[ae].addEventListener('mouseleave', function() { this.style.background = ''; });
}
// Download buttons
var dlBtns = fileListEl.querySelectorAll('.fp-dl');
for (var db = 0; db < dlBtns.length; db++) {
dlBtns[db].addEventListener('click', function(e) {
e.stopPropagation();
downloadFile(this.getAttribute('data-path'), this.getAttribute('data-name'));
});
}
}
function downloadFile(path, filename) {
if (!filesystem) return;
setFileStatus('Downloading ' + filename + '...');
filesystem.requestInputStream(path, function(stream, mimetype) {
// Signal guacd we are ready (required before first blob)
stream.sendAck('Ready', 0x0000);
var reader = new Guacamole.BlobReader(stream, mimetype || 'application/octet-stream');
reader.onend = function() {
var blob = reader.getBlob();
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000);
setFileStatus('Downloaded ' + filename);
};
reader.onerror = function() {
setFileStatus('Download failed: ' + filename);
};
});
}
function uploadFile(file) {
if (!filesystem) return;
var destPath = currentPath + file.name;
setFileStatus('Uploading ' + file.name + '...');
var stream = filesystem.createOutputStream('application/octet-stream', destPath);
var writer = new Guacamole.BlobWriter(stream);
writer.oncomplete = function() {
setFileStatus('Uploaded ' + file.name);
// Refresh listing after brief delay
setTimeout(function() { refreshDirectory(); }, 500);
};
writer.onerror = function() {
setFileStatus('Upload failed: ' + file.name);
};
writer.sendBlob(file);
}
// ── Filesystem handler (fires when drive/SFTP is available) ──
client.onfilesystem = function(object, name) {
filesystem = object;
tracing_log('Filesystem available: ' + name);
// Show file manager tab
fileTab.style.display = '';
// Pre-fetch root listing so it's cached when user opens the panel.
// Uses requestInputStream with callback (standard Guacamole pattern)
// rather than overriding onbody, which bypasses the internal dispatch.
fetchDirectoryListing('/');
};
// ── File download handler (server-initiated file transfer) ──
client.onfile = function(stream, mimetype, filename) {
var reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = function() {
var blob = reader.getBlob();
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000);
setFileStatus('Received file: ' + filename);
};
};
function tracing_log(msg) {
if (typeof console !== 'undefined') console.log('[rustguac] ' + msg);
}
// Also support drag-drop on the display
displayEl.addEventListener('dragover', function(e) {
if (filesystem) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }
});
displayEl.addEventListener('drop', function(e) {
if (!filesystem) return;
e.preventDefault();
for (var i = 0; i < e.dataTransfer.files.length; i++) {
uploadFile(e.dataTransfer.files[i]);
}
});
// ── State changes ──
// ── Thumbnail capture (owner only, not shared viewers) ──
var _thumbInterval = null;
function captureAndUploadThumbnail() {
try {
var display = client.getDisplay();
var canvas = display.flatten();
if (!canvas || canvas.width === 0) return;
// Scale down to 320px wide
var thumb = document.createElement('canvas');
thumb.width = 320;
thumb.height = Math.round(320 * canvas.height / canvas.width) || 180;
thumb.getContext('2d').drawImage(canvas, 0, 0, thumb.width, thumb.height);
thumb.toBlob(function(blob) {
if (!blob) return;
var headers = {};
if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey;
fetch('/api/sessions/' + sessionId + '/thumbnail', {
method: 'PUT',
headers: headers,
credentials: 'same-origin',
body: blob
}).catch(function() {}); // fire and forget
}, 'image/jpeg', 0.6);
} catch(e) { /* ignore capture errors */ }
}
client.onstatechange = function(state) {
switch (state) {
case Guacamole.Client.State.CONNECTING: statusEl.textContent = 'Connecting...'; statusEl.className = ''; break;
case Guacamole.Client.State.WAITING: statusEl.textContent = 'Waiting for server...'; statusEl.className = ''; break;
case Guacamole.Client.State.CONNECTED:
statusEl.textContent = 'Connected'; statusEl.className = 'connected';
// Start thumbnail capture for session owner
if (!shareToken && !_thumbInterval) {
_thumbInterval = setInterval(captureAndUploadThumbnail, 10000);
// First capture after 3s (let display render)
setTimeout(captureAndUploadThumbnail, 3000);
}
break;
case Guacamole.Client.State.DISCONNECTING:
statusEl.textContent = 'Disconnecting...'; statusEl.className = '';
// Final thumbnail capture before disconnect
if (_thumbInterval) {
clearInterval(_thumbInterval);
_thumbInterval = null;
captureAndUploadThumbnail();
}
break;
case Guacamole.Client.State.DISCONNECTED:
statusEl.textContent = 'Disconnected'; statusEl.className = '';
if (_thumbInterval) { clearInterval(_thumbInterval); _thumbInterval = null; }
document.getElementById('disconnected-overlay').className = 'visible';
break;
}
};
client.onerror = function(status) {
statusEl.textContent = 'Error: ' + (status.message || 'Unknown error');
statusEl.className = '';
document.getElementById('disconnected-title').textContent = 'Connection Error';
document.getElementById('disconnected-message').textContent = status.message || 'An error occurred.';
document.getElementById('disconnected-overlay').className = 'visible';
};
// Disconnected overlay buttons
document.getElementById('btn-close-session').addEventListener('click', function() {
window.close();
// window.close() may be blocked if not opened by script — navigate away as fallback
window.location.href = '/';
});
document.getElementById('btn-reconnect').addEventListener('click', function() {
window.location.reload();
});
// ── Keyboard — attached to display so it doesn't steal from textarea ──
displayEl.tabIndex = 0;
displayEl.style.outline = 'none';
displayEl.focus();
var keyboard = new Guacamole.Keyboard(displayEl);
var lastToggleTime = 0;
// Ctrl+Alt+Shift toggle on document (capture phase) — works everywhere
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.altKey && e.shiftKey) {
e.preventDefault();
e.stopImmediatePropagation();
var now = Date.now();
if (now - lastToggleTime < 500) return; // debounce
lastToggleTime = now;
toggleClipboardPanel();
}
}, true);
// ── Ctrl+V clipboard sync ──
// Intercept Ctrl+V (capture on document, fires before Guacamole's
// handler on displayEl). Reads browser clipboard, syncs to remote,
// then sends key events. Firefox shows a one-time permission popup.
var _pasteIntercepted = false;
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V') && !e.altKey && !e.repeat && !panelOpen) {
if (navigator.clipboard && navigator.clipboard.readText) {
e.stopPropagation();
e.preventDefault();
_pasteIntercepted = true;
navigator.clipboard.readText().then(function(text) {
if (text && text !== remoteClipboard) {
sendClipboardToRemote(text);
remoteClipboard = text;
}
}).catch(function() {}).finally(function() {
// Send Ctrl+V key events to remote after clipboard sync
client.sendKeyEvent(1, 0x76); // v down
client.sendKeyEvent(0, 0x76); // v up
});
}
}
}, true);
document.addEventListener('keyup', function(e) {
if (_pasteIntercepted && (e.key === 'v' || e.key === 'V')) {
e.stopPropagation();
e.preventDefault();
_pasteIntercepted = false;
}
}, true);
keyboard.onkeydown = function(keysym) {
client.sendKeyEvent(1, keysym);
};
keyboard.onkeyup = function(keysym) {
client.sendKeyEvent(0, keysym);
};
// Mouse input
var mouse = new Guacamole.Mouse(client.getDisplay().getElement());
mouse.onEach(['mousedown', 'mousemove', 'mouseup'], function(e) {
client.sendMouseState(e.state, true);
});
// Auto-scale display
function scaleDisplay() {
var display = client.getDisplay();
var dw = display.getWidth();
var dh = display.getHeight();
var anyPanelOpen = panelOpen || filePanelOpen;
if (dw > 0 && dh > 0) {
var aw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth;
display.scale(Math.min(aw / dw, window.innerHeight / dh));
}
// Offset display to the right when left panel is open
displayEl.style.marginLeft = anyPanelOpen ? '380px' : '0';
}
client.getDisplay().onresize = function() { scaleDisplay(); };
var resizeTimer = null;
window.addEventListener('resize', function() {
scaleDisplay();
// Debounce sendSize — RDP display updates are expensive
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function() {
var anyPanelOpen = panelOpen || filePanelOpen;
var sw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth;
var sh = window.innerHeight;
client.sendSize(sw, sh);
}, 250);
});
var connectData = shareToken ? 'token=' + encodeURIComponent(shareToken) : '';
client.connect(connectData);
}
</script>
</body>
</html>