Files
rustguac/static/client.html
T
Dave Kempe e70aebf81d Fix zombie WebSocket: wire tunnel.onerror/onstatechange in client.html
When a mid-path middlebox silently drops the TCP between the browser
and HAProxy, HAProxy closes the backend which rustguac logs as
"Connection reset without closing handshake" (termination state CD--
in HAProxy's log). Firefox's WS socket then fires onclose on the
browser side, Tunnel.js's close_tunnel() runs, and the tunnel's
internal state transitions to CLOSED.

But upstream Guacamole's Client.js doesn't listen for tunnel.onerror
or tunnel.onstatechange — the Apache webapp's AngularJS wiring does
that externally. Our lean client.html inherited Tunnel.js + Client.js
verbatim from upstream but missed that glue, so tunnel errors fired
into the void and the Guacamole client stayed in CONNECTED forever.

Effect on users: a dead session that looks like a freeze. Mouse moves
locally, clicks don't register (they're being sent into a closed
WebSocket whose send() silently no-ops at the browser layer). The
"Session Ended" overlay never appears. Meanwhile the thumbnail
uploader keeps running on its own XHR stream, getting 404s from the
already-cleaned-up session.

Fix: wire tunnel.onerror to forward into the existing client.onerror
handler, and wire tunnel.onstatechange so CLOSED also triggers the
overlay (and UNSTABLE updates the status text). This reuses the
existing overlay wiring at client.html:895; no new UI, no new state
machine, no new heartbeat. The upstream-inherited 5s nop keepalive
and 15s receiveTimeout in Tunnel.js are already present and
correct — the missing piece was just propagating their output into
the client's state cascade.

Triggered a lot of detective work chasing false leads (H.264 decoder
closed-state hypothesis, h2 bridging bug, HAProxy timeout tuning,
client/server heartbeats). The actual cause was much simpler and
the diff is six lines of JavaScript.
2026-04-25 15:40:08 +10:00

1049 lines
55 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='aurora';
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'aurora';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;gap:0.6em;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;">Session</h3>' +
'<button id="cp-home" style="background:var(--input);border:1px solid var(--border);color:var(--accent);font-family:monospace;font-size:0.9em;padding:0.35em 0.8em;border-radius:3px;cursor:pointer;" title="Return to the Connections page">&#127968; Home</button>' +
'<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-home').addEventListener('click', function() {
// Provides the escape hatch for users whose only entry
// has auto_open_if_singleton set — navigates this tab
// back to Connections. Session may keep running until
// socket closes; user can explicitly terminate from
// Connections' active-session card.
window.location.href = '/connections.html';
});
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';
};
// Wire tunnel events back into the client. Upstream Guacamole's
// Client.js deliberately doesn't listen on tunnel.onerror /
// tunnel.onstatechange — the Apache webapp installs those
// handlers externally via AngularJS. Our lean client.html
// inherits Tunnel.js + Client.js from upstream but used to
// miss this glue, so when a mid-path middlebox silently
// dropped the WS and socket.onclose fired, the tunnel would
// transition to CLOSED, fire onerror into the void, and the
// Guacamole client would stay stuck in CONNECTED forever.
// Effect: the user saw a frozen tab, no "Session Ended"
// overlay, and the browser kept uploading thumbnails into
// 404 responses for hours. Forwarding tunnel events into the
// existing client.onerror closes that loop.
tunnel.onerror = function(status) {
if (typeof console !== 'undefined')
console.log('[rustguac] tunnel error:',
status && status.message,
'code=' + (status && status.code));
if (client.onerror) client.onerror(status || {
code: 519,
message: 'Connection lost'
});
};
tunnel.onstatechange = function(state) {
if (state === Guacamole.Tunnel.State.CLOSED) {
if (typeof console !== 'undefined')
console.log('[rustguac] tunnel closed');
if (client.onerror) client.onerror({
code: 519,
message: 'Connection lost'
});
} else if (state === Guacamole.Tunnel.State.UNSTABLE) {
if (typeof console !== 'undefined')
console.log('[rustguac] tunnel unstable');
statusEl.textContent = 'Connection unstable...';
}
};
// 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>