Files
rustguac/static/client.html
T
Dave Kempe e7c515654e feat(client): per-entry auto-hide for the clipboard/files side tabs
Add an autohide_side_tabs option (Option<bool>, default off) on the address
book entry, threaded through the same path as fullscreen_on_connect
(AddressBookEntry, EntryInfo, CreateSessionRequest, Session, SessionInfo, the
API connect/quick-connect builders, and import defaults). When set, client.html
slides the left-edge Clipboard and Files tabs off screen when idle and brings
them back when the pointer nears the left edge; defaults preserve the current
always-visible behaviour. Checkbox added to the entry editor.
2026-07-20 15:19:35 +10:00

1476 lines
76 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); }
#fs-bar {
display: none;
position: fixed;
top: 0; left: 0; right: 0;
height: 32px;
background: rgba(20,20,30,0.92);
color: var(--text);
border-bottom: 1px solid var(--border);
z-index: 1500;
align-items: center;
padding: 0 12px;
font-family: monospace;
font-size: 13px;
transform: translateY(-100%);
transition: transform 0.25s ease;
}
#fs-bar.visible { display: flex; }
#fs-bar.visible.revealed { transform: translateY(0); }
#fs-bar-title {
flex: 1;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 12px;
}
#fs-bar-buttons button {
background: none;
border: 1px solid var(--border);
color: var(--text);
border-radius: 3px;
padding: 3px 10px;
margin-left: 6px;
font-family: monospace;
font-size: 12px;
cursor: pointer;
}
#fs-bar-buttons button:hover { background: var(--input); color: var(--accent); }
#fs-bar-buttons #fs-disconnect:hover { color: var(--primary); border-color: var(--primary); }
#fs-esc-notice {
display: none;
position: fixed;
top: 42px;
right: 12px;
z-index: 1600;
background: rgba(0,0,0,0.88);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px 12px;
font-family: monospace;
font-size: 12px;
max-width: 320px;
}
</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="fs-bar">
<div id="fs-bar-title"></div>
<div id="fs-bar-buttons">
<button id="fs-exit" title="Exit fullscreen">Exit fullscreen</button>
<button id="fs-disconnect" title="Disconnect">Disconnect</button>
</div>
</div>
<div id="fs-esc-notice"></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');
// Populated from /api/sessions/:id when the session is backed by a
// Connections entry. Used by the Reconnect button to launch a fresh
// session against the same entry instead of reloading a dead one.
var relaunchInfo = null;
// #154: fullscreen-on-connect flag + the entry display name we'll
// show in the in-fullscreen top bar. Populated from the same fetch.
var entryDisplayName = '';
var wantFullscreen = false;
// Per-entry autohide of the clipboard/files side tabs; populated from
// the same /api/sessions/:id fetch and read when the tabs are built.
var wantAutohideTabs = false;
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) {
if (session && session.address_book_entry) {
relaunchInfo = {
entry_key: session.address_book_entry,
folder: session.address_book_folder || '',
display_name: session.entry_display_name || ''
};
}
if (session && session.entry_display_name) entryDisplayName = session.entry_display_name;
if (session && session.fullscreen_on_connect) wantFullscreen = true;
if (session && session.autohide_side_tabs) wantAutohideTabs = true;
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) {
// ── Draw-op diagnostics (#118 black-tile investigation) ──
// The browser receives exactly what guacd emits, so tapping the
// instruction stream here shows the server's actual drawing.
// A ring buffer records recent draw ops (decoded to coordinates)
// with near-zero overhead and no console noise. After reproducing
// a black tile, call rustguacDumpDraws() in the console to print
// the recent history, or rustguacDumpDraws(x, y) to show only ops
// whose rectangle covers that pixel. Append ?debug=draw to the URL
// for live per-op logging.
var DRAW_RING = [];
var DRAW_RING_MAX = 2000;
var drawLiveLog = /[?&]debug=draw\b/.test(location.search);
// Decode the draw opcodes we care about into {op, layer, rect, note}.
// Arg orders per the Guacamole protocol.
function decodeDraw(opcode, a) {
switch (opcode) {
case 'rect': // layer,x,y,w,h
return { op: 'rect', layer: +a[0], rect: [+a[1], +a[2], +a[3], +a[4]] };
case 'cfill': { // mask,layer,r,g,b,a (fills the current path/rect)
var r = +a[2], g = +a[3], b = +a[4], al = +a[5];
return { op: 'cfill', layer: +a[1],
note: 'rgba(' + r + ',' + g + ',' + b + ',' + al + ')'
+ (r === 0 && g === 0 && b === 0 && al >= 255 ? ' <BLACK>' : '') };
}
case 'copy': // srclayer,srcx,srcy,srcw,srch,mask,dstlayer,dstx,dsty
return { op: 'copy', layer: +a[6],
rect: [+a[7], +a[8], +a[3], +a[4]],
note: 'src L' + a[0] + ' (' + a[1] + ',' + a[2] + ')' };
case 'img': // stream,mask,layer,mimetype,x,y
return { op: 'img', layer: +a[2], rect: [+a[4], +a[5], null, null],
note: a[3] };
case 'dispose': // layer
return { op: 'dispose', layer: +a[0] };
case 'size': // layer,w,h
return { op: 'size', layer: +a[0], note: a[1] + 'x' + a[2] };
default:
return null;
}
}
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)' : ''));
}
var d = decodeDraw(opcode, args);
if (d) {
d.t = Date.now();
DRAW_RING.push(d);
if (DRAW_RING.length > DRAW_RING_MAX) DRAW_RING.shift();
if (drawLiveLog) {
console.log('[draw]', d.op, 'L' + d.layer,
d.rect ? '@[' + d.rect.join(',') + ']' : '',
d.note || '');
}
}
if (origOnInstruction) origOnInstruction(opcode, args);
};
// Dump the recent draw-op history. With (x,y) filters to ops whose
// rectangle covers that pixel on the default layer — point this at
// a black tile's top-left to see what last painted there.
window.rustguacDumpDraws = function(x, y) {
var rows = DRAW_RING;
if (typeof x === 'number' && typeof y === 'number') {
rows = rows.filter(function(d) {
if (!d.rect) return d.op === 'dispose' || d.op === 'size';
var rx = d.rect[0], ry = d.rect[1], rw = d.rect[2], rh = d.rect[3];
if (rw == null) return false;
return x >= rx && y >= ry && x < rx + rw && y < ry + rh;
});
}
console.log('[rustguac] ' + rows.length + ' draw ops'
+ (typeof x === 'number' ? ' covering (' + x + ',' + y + ')' : '') + ':');
console.table(rows.map(function(d) {
return { op: d.op, layer: d.layer,
rect: d.rect ? d.rect.join(',') : '',
note: d.note || '',
t: new Date(d.t).toISOString().substr(11, 12) };
}));
return rows;
};
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-fullscreen" 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="Enter fullscreen">&#9974; Fullscreen</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-fullscreen').addEventListener('click', function() {
rgEnterFullscreen();
if (panelOpen) 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);
// ── Auto-hide side tabs (per-entry autohide_side_tabs) ──
// When enabled, the Clipboard/Files tabs slide off the left edge
// when idle and reappear when the pointer nears the left edge.
var autohideSideTabs = wantAutohideTabs;
if (autohideSideTabs) {
var SIDE_EDGE_PX = 40, SIDE_HIDE_MS = 1800;
var sideTabsShown = true, sideTabsHideTimer = null;
toggleTab.style.transition = 'left 0.2s ease, opacity 0.2s';
fileTab.style.transition = 'left 0.2s ease, opacity 0.2s';
var applySideTabs = function(show) {
var l = show ? '-1px' : '-64px';
toggleTab.style.left = l;
fileTab.style.left = l;
sideTabsShown = show;
};
var scheduleSideTabsHide = function() {
if (sideTabsHideTimer) return; // do not reset an in-flight timer
sideTabsHideTimer = setTimeout(function() {
sideTabsHideTimer = null;
if (!panelOpen && !filePanelOpen) applySideTabs(false);
}, SIDE_HIDE_MS);
};
document.addEventListener('mousemove', function(e) {
if (panelOpen || filePanelOpen) return; // panels manage tab visibility
if (e.clientX <= SIDE_EDGE_PX) {
if (sideTabsHideTimer) { clearTimeout(sideTabsHideTimer); sideTabsHideTimer = null; }
if (!sideTabsShown) applySideTabs(true);
} else if (sideTabsShown) {
scheduleSideTabsHide();
}
});
// Visible briefly on connect, then auto-hide.
scheduleSideTabsHide();
}
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 */ }
}
// ── Fullscreen (#154) ──
// The browser API requires a fresh user gesture to enter
// fullscreen, and navigation from connections.html consumes
// the prior gesture. So when the entry is marked
// fullscreen-on-connect we don't try to fire on page load.
// Instead we arm a one-shot listener for the next mousedown
// or keydown after CONNECTED. The Ctrl+Alt+Shift menu's
// fullscreen button uses its own click gesture and works at
// any time once shown.
function rgFullscreenEl() {
return document.fullscreenElement
|| document.webkitFullscreenElement
|| document.mozFullScreenElement
|| document.msFullscreenElement || null;
}
function rgEnterFullscreen() {
var el = document.documentElement;
var req = el.requestFullscreen
|| el.webkitRequestFullscreen
|| el.mozRequestFullScreen
|| el.msRequestFullscreen;
if (!req) return;
var p;
try { p = req.call(el); } catch (e) { return; }
var onOk = function() {
if (navigator.keyboard && navigator.keyboard.lock) {
navigator.keyboard.lock(['Escape']).catch(rgShowEscNoticeOnce);
} else {
rgShowEscNoticeOnce();
}
};
if (p && p.then) p.then(onOk).catch(function(){}); else onOk();
}
function rgExitFullscreen() {
try { if (navigator.keyboard && navigator.keyboard.unlock) navigator.keyboard.unlock(); } catch (e) {}
var x = document.exitFullscreen
|| document.webkitExitFullscreen
|| document.mozCancelFullScreen
|| document.msExitFullscreen;
if (x) { try { x.call(document); } catch (e) {} }
}
function rgArmFullscreenGesture() {
var fire = function() {
document.removeEventListener('mousedown', fire, true);
document.removeEventListener('keydown', fire, true);
rgEnterFullscreen();
};
document.addEventListener('mousedown', fire, true);
document.addEventListener('keydown', fire, true);
}
function rgShowEscNoticeOnce() {
if (sessionStorage.getItem('rustguac_esc_notice_seen')) return;
sessionStorage.setItem('rustguac_esc_notice_seen', '1');
var t = document.getElementById('fs-esc-notice');
if (!t) return;
t.textContent = 'Press Esc to exit fullscreen in this browser.';
t.style.display = 'block';
setTimeout(function() { t.style.display = 'none'; }, 6000);
}
// Auto-hide top bar: show on enter for a few seconds, then
// slide up out of view to free the remote's top edge (xfce4
// panels, Windows taskbar, etc). Reveals again when the mouse
// approaches the top of the screen, hides again when it
// leaves the bar area.
var rgBarHideTimer = null;
function rgRevealBar() {
var bar = document.getElementById('fs-bar');
if (!bar || !bar.classList.contains('visible')) return;
bar.classList.add('revealed');
if (rgBarHideTimer) { clearTimeout(rgBarHideTimer); rgBarHideTimer = null; }
}
function rgScheduleBarHide(delayMs) {
if (rgBarHideTimer) clearTimeout(rgBarHideTimer);
rgBarHideTimer = setTimeout(function() {
var bar = document.getElementById('fs-bar');
if (bar) bar.classList.remove('revealed');
rgBarHideTimer = null;
}, delayMs);
}
function rgOnFullscreenMouseMove(e) {
if (!rgFullscreenEl()) return;
var bar = document.getElementById('fs-bar');
if (!bar) return;
// Top 4px: reveal (and cancel any pending hide).
// Over the bar area (<= 36px): keep revealed, cancel hide.
// Below the bar with a small buffer (> 48px): schedule
// hide. The gap between 36 and 48 is hysteresis so a
// jittering pointer doesn't flicker the bar.
if (e.clientY <= 4) {
rgRevealBar();
} else if (e.clientY <= 36) {
if (rgBarHideTimer) { clearTimeout(rgBarHideTimer); rgBarHideTimer = null; }
} else if (bar.classList.contains('revealed') && e.clientY > 48) {
// Only arm if no hide is already pending. Without this
// gate, every mousemove in the session resets the
// countdown and the bar never hides.
if (!rgBarHideTimer) rgScheduleBarHide(600);
}
}
function rgOnFullscreenChange() {
var inFs = !!rgFullscreenEl();
var bar = document.getElementById('fs-bar');
if (bar) {
if (inFs) {
bar.classList.add('visible');
// Show for a moment so the user knows it's there,
// then auto-hide.
setTimeout(rgRevealBar, 50);
rgScheduleBarHide(2500);
document.addEventListener('mousemove', rgOnFullscreenMouseMove);
} else {
bar.classList.remove('visible');
bar.classList.remove('revealed');
if (rgBarHideTimer) { clearTimeout(rgBarHideTimer); rgBarHideTimer = null; }
document.removeEventListener('mousemove', rgOnFullscreenMouseMove);
}
}
}
['fullscreenchange','webkitfullscreenchange','mozfullscreenchange','MSFullscreenChange']
.forEach(function(ev){ document.addEventListener(ev, rgOnFullscreenChange); });
document.getElementById('fs-exit').addEventListener('click', rgExitFullscreen);
document.getElementById('fs-disconnect').addEventListener('click', function() {
rgExitFullscreen();
try { if (window.__guac_client) window.__guac_client.disconnect(); } catch (e) {}
window.location.href = '/connections.html';
});
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);
}
document.getElementById('fs-bar-title').textContent =
entryDisplayName || ('Session ' + sessionId.substring(0,8));
if (wantFullscreen) rgArmFullscreenGesture();
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';
if (rgFullscreenEl()) rgExitFullscreen();
break;
}
};
client.onerror = function(status) {
statusEl.textContent = 'Error: ' + (status.message || 'Unknown error');
statusEl.className = '';
// Stop the 10s thumbnail uploader; the session is dead and
// each XHR would otherwise 404 against a cleaned-up id.
if (_thumbInterval) { clearInterval(_thumbInterval); _thumbInterval = null; }
document.getElementById('disconnected-title').textContent = 'Connection Error';
document.getElementById('disconnected-message').textContent = status.message || 'An error occurred.';
document.getElementById('disconnected-overlay').className = 'visible';
if (rgFullscreenEl()) rgExitFullscreen();
};
// 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 = '/';
});
// Reconnect launches a fresh session against the same Connections
// entry, then navigates to it. Reloading the dead session URL
// would just re-trigger the overlay because the original session
// transitioned to Completed the moment the WebSocket closed.
document.getElementById('btn-reconnect').addEventListener('click', function() {
var btn = this;
btn.disabled = true;
btn.textContent = 'Reconnecting...';
// Share-token / shadow viewers can't mint a new session.
// Reload keeps current behaviour (it will fail the same way,
// but they have no other recourse from this page).
if (shareToken) {
window.location.reload();
return;
}
// Ad-hoc sessions have no entry to relaunch from. Send the
// user back to Connections so they can pick where to go.
if (!relaunchInfo) {
window.location.href = '/connections.html';
return;
}
// Composite key is "scope/folder/entry". Folder may itself
// contain slashes; entry names cannot (validated server-side
// in vault.rs validate_name), so first/last segment splits
// cleanly recover scope and entry.
var key = relaunchInfo.entry_key;
var firstSlash = key.indexOf('/');
var lastSlash = key.lastIndexOf('/');
if (firstSlash < 0 || lastSlash <= firstSlash) {
window.location.href = '/connections.html';
return;
}
var scope = key.substring(0, firstSlash);
var folder = relaunchInfo.folder;
var entry = key.substring(lastSlash + 1);
var headers = { 'Content-Type': 'application/json' };
if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey;
var body = {
width: window.innerWidth,
height: window.innerHeight,
dpi: Math.round((window.devicePixelRatio || 1) * 96)
};
var url = '/api/addressbook/folders/' + encodeURIComponent(scope)
+ '/' + encodeURIComponent(folder)
+ '/entries/' + encodeURIComponent(entry) + '/connect';
fetch(url, {
method: 'POST',
headers: headers,
credentials: 'same-origin',
body: JSON.stringify(body)
})
.then(function(res) {
if (!res.ok) {
// 412 missing creds, 403 forbidden, 404 entry gone:
// none of those are fixable from this page. Punt to
// Connections where the user can fill in what's
// missing or pick a different entry.
window.location.href = '/connections.html';
return;
}
return res.json().then(function(data) {
if (data && data.client_url) {
var dest = data.client_url;
if (relaunchInfo.display_name) {
dest += '?name=' + encodeURIComponent(relaunchInfo.display_name);
}
window.location.href = dest;
} else {
window.location.href = '/connections.html';
}
});
})
.catch(function() {
window.location.href = '/connections.html';
});
});
// ── 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);
};
// Release all held keys when the window loses focus or is hidden.
// Modifier keyups are otherwise lost when focus shifts away (e.g.
// the clipboard-permission prompt triggered by Ctrl+V, or alt-tab
// while holding a modifier), leaving Ctrl/Alt/Shift stuck "down"
// on the remote until the key is pressed again. keyboard.reset()
// releases every key it currently tracks as pressed, sending the
// matching keyup to the remote.
window.addEventListener('blur', function() { keyboard.reset(); });
document.addEventListener('visibilitychange', function() {
if (document.hidden) keyboard.reset();
});
// 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>