mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-11 13:50:14 +00:00
e70aebf81d
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.
1049 lines
55 KiB
HTML
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">🏠 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;">×</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;">×</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,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
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);">📁</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);">📄</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>
|