Files
rustguac/static/client.html
T
pletch 010f41d15a feat(mouse): replay the moves the browser coalesced away during a drag
A busy main thread does not delay pointer movement, it discards it: moves
arriving while a handler runs are merged into one, and what is dispatched
afterwards carries only the last position. A drag feels that immediately --
the path between two points is gone, so a dragged scrollbar thumb is dropped
rather than merely lagged -- while a hover or a video degrades gracefully
under the same load.

getCoalescedEvents() returns what was merged. Replaying it in order restores
the path, though not the timing: nothing running on a blocked thread can do
that, and the positions still arrive together once the thread is free. What
changes is that the remote end sees where the pointer went rather than only
where it ended up.

Deliberately additive. The mousemove listener is untouched and still delivers
every position it did before, so touch, the compatibility mouse events it
produces and the counter that swallows them all behave as they did; the
dispatched position arrives twice and the second is dropped by the
unchanged-coordinates check already in move(). Migrating this file to pointer
events outright is the tidier shape and a far larger change -- every listener
would have to move together, or synthetic events from touch would arrive with
nothing left counting them down.

Only while a button is held, because the path matters for a drag and the
endpoint is all that matters for a hover, and replaying every merged move of
an idle pointer would multiply a session's upstream mouse traffic for movement
nobody can perceive. Capped and evenly sampled, because a pointer reporting at
1000Hz behind a thread blocked for 100ms offers a hundred positions for one
drag, and answering a rendering problem with a traffic one is no answer. An
ordinary pointer never reaches the cap.

The pop-out monitor windows get the same replay. They bypass Guacamole.Mouse
entirely -- it does not track the X axis correctly in a popup -- and map the
pointer themselves, so a drag on a second head would otherwise lose its path
exactly as the first one did. The sampler moves to a static so there is one
definition of the rule rather than two that can drift, and that path needs one
thing the main display does not: it sends unconditionally, where move() drops
an unchanged position, so it keeps what it last sent and drops a repeat --
keyed on the buttons as well as the position, since a press and a release
happen at one place and are not repeats of each other. Feature-detected on the
event rather than on PointerEvent, because a pop-out window is a realm of its
own and the constructor this page can see is not the one that made the event
it is handed.

That pointer handling lives in Guacamole.MonitorPointer rather than inline in
client.html, because these windows cannot be opened without a live
multi-monitor session: while it sat in the page the only thing a test could do
was match the source text and hope, and the two things most worth checking
there are a coordinate mapping and a suppression rule -- exactly the kind that
look right in a diff and are off by a term. It takes the canvas, a function
returning this monitor's rectangle in the combined framebuffer (a call rather
than a captured value, since the layout changes under a running window) and a
function to send a state.

Writing those tests found a real defect in the wheel, which sends a momentary
press and release: it built one state object, sent it, mutated it and sent it
again, which works only while every consumer copies synchronously. It sends
two states now.

Tested rather than tried, because this path only runs once the browser has
already thrown something away: the case it exists for is the one that cannot
be reliably produced, and a broken version feels like the behaviour it
replaced. Twenty tests cover the mapping, the button mask, the repeat
suppression, the wheel's reset of it and the replay, plus a harness driving
real pointer input through the debugger with the main thread deliberately
blocked -- because the unit tests feed events shaped the way the code expects,
and that assumption is the one worth checking against a browser.

(cherry picked from commit d8ce743fe0801bd8c776281ee5541abf8040a339)
2026-10-03 13:00:09 +10:00

1962 lines
104 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/MonitorPointer.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/Yuv444.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');
}
// Whether asking the server for this size would actually change
// anything, keyed by monitor index.
//
// The RDP display patch rounds both dimensions down to a multiple of
// 16 (patch 007-rdp-disp-mod16), so two viewport sizes inside the same
// 16px band resolve to the same framebuffer. A resize between them is
// a no-op on the wire -- but not a free one: a resolution change tears
// down and rebuilds the browser's hardware H.264 decoder, measured at
// ~6 seconds of stalled video on D3D11. Dragging a window across a
// hundred pixels in pauses could buy several of those for nothing.
//
// Only the decision is taken on the rounded value; the size sent is
// the true one, since the client cannot tell an RDP session from a VNC
// one and a VNC server does no such rounding -- it should not be handed
// a framebuffer up to 15px short.
var lastRequestedSize = {};
function sizeWouldChange(width, height, index) {
var key = (index === undefined || index === null) ? 0 : index;
var rounded = (width - width % 16) + 'x' + (height - height % 16);
if (lastRequestedSize[key] === rounded)
return false;
lastRequestedSize[key] = rounded;
return true;
}
var statusEl = document.getElementById('status');
var urlParams = new URLSearchParams(window.location.search);
var shareToken = urlParams.get('token');
var apiKey = sessionStorage.getItem('rustguac_api_key');
// Pre-minted single-use WebSocket ticket passed in the URL. Lets a
// headless API integration authenticate the owner connection without an
// OIDC session or a stored API key: the backend mints it via
// POST /api/ws-ticket and hands the browser /client/{id}?ticket=...
var urlTicket = urlParams.get('ticket');
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 (urlTicket) {
// Headless API integration: the pre-minted ticket authenticates the
// WebSocket directly. Skip the /api/sessions metadata fetch — it
// needs its own auth, and routing the one-shot ticket through it
// would consume it before the WebSocket. Connect straight away.
startGuacamole();
} else 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
window.__guac_client = client;
setupClient(client, tunnel);
}
function startGuacamole() {
statusEl.textContent = 'Connecting to session ' + sessionId.substring(0, 8) + '...';
// Pre-minted WebSocket ticket from the URL (headless API integration)
// takes priority: use it directly as the owner credential.
if (urlTicket) {
connectWebSocket(urlTicket);
return;
}
// 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);
// ── Multi-monitor (A1: receive + log; per-monitor windows are A2) ──
// Max monitors the server allows, delivered via a "secondary-monitors"
// argv stream. secondary-monitors = N means N+1 monitors total.
var maxMonitors = 1;
var monitorLayout = {}; // index -> {left, top, width, height} in the combined framebuffer
var monitorWindows = {}; // index -> { win, canvas, ctx, mouse, keyboard, running }
client.onargv = function(stream, mimetype, name) {
var reader = new Guacamole.StringReader(stream);
var buf = '';
reader.ontext = function(t) { buf += t; };
reader.onend = function() {
if (name !== 'secondary-monitors') return;
var secondary = parseInt(buf, 10);
if (!isNaN(secondary)) maxMonitors = secondary + 1;
console.log('[rustguac] multimon: server allows ' + maxMonitors
+ ' monitor(s) (secondary-monitors=' + buf.trim() + ')');
// Refresh the Controls panel's monitor section if it's built.
if (typeof renderMonitorControls === 'function') renderMonitorControls();
};
};
// Current monitor layout (each monitor's rect within the combined
// framebuffer), JSON via a "multimon-layout" set param.
client.onmultimonlayout = function(layer, value) {
try {
monitorLayout = JSON.parse(value);
} catch (e) {
console.warn('[rustguac] multimon-layout parse error:', e, value);
return;
}
console.log('[rustguac] multimon-layout:', monitorLayout);
// Reflect the newly-active monitors in the Controls panel. Open
// windows read monitorLayout[index] each frame, so no per-window
// update is needed here.
if (typeof renderMonitorControls === 'function') renderMonitorControls();
// Re-crop/scale the main window to monitor 0 now that the layout
// (and hence which region the main window shows) may have changed.
if (typeof scaleDisplay === 'function') scaleDisplay();
};
// ── 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);
// ── Controls side panel (send-keys now; monitor selection next) ──
var controlsTab = document.createElement('div');
controlsTab.textContent = '⌨ Controls';
controlsTab.title = 'Send key combinations and select monitors';
controlsTab.style.cssText = 'position:fixed;left:-1px;top:calc(50% + 120px);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;';
controlsTab.addEventListener('mouseenter', function() { controlsTab.style.opacity = '1'; });
controlsTab.addEventListener('mouseleave', function() { controlsTab.style.opacity = '0.6'; });
controlsTab.addEventListener('click', function() { toggleControlsPanel(); });
document.body.appendChild(controlsTab);
var controlsPanel = null, controlsPanelOpen = false;
// Press each keysym in order, then release in reverse. Used for
// combos the browser/OS would otherwise intercept (Ctrl+Alt+Del).
function sendKeyCombo(keys) {
if (!client) return;
for (var i = 0; i < keys.length; i++) client.sendKeyEvent(1, keys[i]);
for (var j = keys.length - 1; j >= 0; j--) client.sendKeyEvent(0, keys[j]);
}
// X11 keysyms for the send-keys buttons (Guacamole uses keysyms).
var SEND_KEYS = [
{ label: 'Ctrl+Alt+Del', keys: [0xFFE3, 0xFFE9, 0xFFFF] },
{ label: 'Ctrl+Alt+Bksp', keys: [0xFFE3, 0xFFE9, 0xFF08] },
{ label: 'Alt+F4', keys: [0xFFE9, 0xFFC1] },
{ label: 'Alt+Tab', keys: [0xFFE9, 0xFF09] },
{ label: 'Win', keys: [0xFFEB] },
{ label: 'Esc', keys: [0xFF1B] },
{ label: 'PrtSc', keys: [0xFF61] }
];
function buildControlsPanel() {
if (controlsPanel) return;
controlsPanel = document.createElement('div');
controlsPanel.style.cssText = 'display:none;position:fixed;top:0;left:0;bottom:0;width:300px;background:var(--bg);border-right:2px solid var(--primary);z-index:3000;font-family:monospace;color:var(--text);flex-direction:column;overflow-y:auto;';
controlsPanel.innerHTML =
'<div style="display:flex;align-items:center;padding:0.8em 1em;border-bottom:1px solid var(--border);background:var(--surface);">' +
'<h3 style="margin:0;flex:1;color:var(--primary);font-size:1.1em;">Controls</h3>' +
'<button id="ctrl-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="padding:0.8em 1em;">' +
'<div style="color:var(--text-muted);font-size:0.85em;margin-bottom:0.5em;letter-spacing:0.05em;">SEND KEYS</div>' +
'<div id="ctrl-keys" style="display:flex;flex-wrap:wrap;gap:0.4em;"></div>' +
'</div>' +
'<div style="padding:0.8em 1em;border-top:1px solid var(--border);">' +
'<div style="color:var(--text-muted);font-size:0.85em;margin-bottom:0.5em;letter-spacing:0.05em;">MONITORS</div>' +
'<div id="ctrl-monitors"></div>' +
'</div>';
document.body.appendChild(controlsPanel);
document.getElementById('ctrl-close').addEventListener('click', function() { toggleControlsPanel(); });
var keysEl = document.getElementById('ctrl-keys');
SEND_KEYS.forEach(function(k) {
var b = document.createElement('button');
b.textContent = k.label;
b.style.cssText = 'padding:0.4em 0.7em;background:var(--input);color:var(--primary);border:1px solid var(--border);font-family:monospace;font-size:0.85em;border-radius:3px;cursor:pointer;';
b.addEventListener('click', function() { sendKeyCombo(k.keys); });
keysEl.appendChild(b);
});
renderMonitorControls();
}
function toggleControlsPanel() {
buildControlsPanel();
controlsPanelOpen = !controlsPanelOpen;
controlsPanel.style.display = controlsPanelOpen ? 'flex' : 'none';
if (controlsPanelOpen) renderMonitorControls();
}
// Monitors section — one tick-box per allowed monitor. Monitor 1 is
// the primary (always on, in this window). Ticking a secondary opens
// it in its own window; monitors must stay contiguous, so monitor N
// can only be toggled when monitor N-1 is active.
function renderMonitorControls() {
if (!controlsPanel) return;
var el = document.getElementById('ctrl-monitors');
if (!el) return;
if (maxMonitors <= 1) {
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.85em;">This connection offers a single monitor.</div>';
return;
}
el.innerHTML = '';
var isActive = function(i) { return i === 0 || !!monitorWindows[i]; };
for (var i = 0; i < maxMonitors; i++) {
(function(idx) {
var row = document.createElement('label');
row.style.cssText = 'display:flex;align-items:center;gap:0.5em;padding:0.25em 0;font-size:0.9em;cursor:pointer;';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = isActive(idx);
cb.disabled = (idx === 0) || !isActive(idx - 1);
cb.addEventListener('change', function() { toggleMonitor(idx, cb.checked); });
row.appendChild(cb);
var span = document.createElement('span');
span.textContent = (idx === 0) ? 'Monitor 1 (primary)' : ('Monitor ' + (idx + 1));
row.appendChild(span);
el.appendChild(row);
})(i);
}
var hint = document.createElement('div');
hint.style.cssText = 'color:var(--text-dim);font-size:0.75em;margin-top:0.4em;';
hint.textContent = 'Extra monitors open in their own window. Drag each to a physical display and press F11 to fullscreen.';
el.appendChild(hint);
}
function toggleMonitor(index, enable) {
if (index === 0) return; // primary is always on, in this window
if (enable) openMonitorWindow(index);
else closeMonitorWindow(index);
}
// Open a secondary monitor in its own browser window: request the
// monitor from the server, blit its region of the combined
// framebuffer each frame, and forward mouse/keyboard in combined
// coordinates.
function openMonitorWindow(index) {
if (monitorWindows[index]) return;
var win = window.open('', 'rustguac-mon-' + index,
'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no');
if (!win) {
alert('Popup blocked. Allow popups for this site to open an extra monitor.');
renderMonitorControls();
return;
}
win.document.title = 'Monitor ' + (index + 1);
win.document.body.style.cssText = 'margin:0;background:#000;overflow:hidden;';
var canvas = win.document.createElement('canvas');
// Stretch the monitor to fill the popup window. The mouse maps
// from the canvas's live on-screen rect (getBoundingClientRect),
// so whatever size the window is, clicks map correctly; the guest
// is asked (via sendSize below) to adopt the window's resolution,
// which keeps the aspect ratio right.
canvas.style.cssText = 'display:block;width:100%;height:100%;';
win.document.body.appendChild(canvas);
var ctx = canvas.getContext('2d');
var rec = { win: win, canvas: canvas, ctx: ctx, running: true,
mouse: null, keyboard: null };
monitorWindows[index] = rec;
// Ask the server for this monitor at the window's inner size
// (SPICE wants width as a multiple of 8). Contiguous index. Guard
// against a not-yet-laid-out popup reporting 0 (a positive size is
// required or the server reads it as a monitor-close).
var reqW = ((win.innerWidth || 1280) & ~0x7) || 1280;
var reqH = win.innerHeight || 800;
if (reqW < 8) reqW = 1280;
if (reqH < 8) reqH = 800;
client.sendSize(reqW, reqH, index, 0);
// Per-frame blit of this monitor's region from the combined
// framebuffer (the default layer's canvas). During a resize the
// client can briefly hold a new layout rect while the source
// canvas is still the old size (or vice versa); blitting the
// out-of-range region then produces garbage. Guard by only
// drawing the portion of this monitor's rect that actually lies
// within the current source canvas, and skip the frame entirely
// if the layout and source are momentarily inconsistent.
var srcLayer = client.getDisplay().getDefaultLayer();
(function frame() {
if (!rec.running || win.closed) return;
win.requestAnimationFrame(frame);
var rect = monitorLayout[index];
if (!rect || rect.width <= 0 || rect.height <= 0) return;
var src = srcLayer && srcLayer.getCanvas ? srcLayer.getCanvas() : null;
if (!src || !src.width || !src.height) return;
if (rect.left < 0 || rect.top < 0) return;
// Portion of this monitor's rect available in the source now.
var sw = Math.min(rect.width, src.width - rect.left);
var sh = Math.min(rect.height, src.height - rect.top);
if (sw <= 0 || sh <= 0) return; // rect not yet within source
if (canvas.width !== rect.width || canvas.height !== rect.height) {
canvas.width = rect.width;
canvas.height = rect.height;
}
try {
ctx.drawImage(src, rect.left, rect.top, sw, sh, 0, 0, sw, sh);
} catch (e) { /* source not ready this frame */ }
})();
// Re-fit on popup resize and, after a short debounce, ask the
// server for this monitor at the new size (a resizable guest
// adopts it; a fixed-resolution console just keeps scaling to fit).
var popResizeTimer = null;
win.addEventListener('resize', function() {
if (popResizeTimer) win.clearTimeout(popResizeTimer);
popResizeTimer = win.setTimeout(function() {
var w = (win.innerWidth || 0) & ~0x7;
var h = win.innerHeight || 0;
if (w >= 8 && h >= 8 && sizeWouldChange(w, h, index))
client.sendSize(w, h, index, 0);
}, 500);
});
// Pointer, wheel and context menu. Guacamole.Mouse is not
// used here -- it does not track the X axis correctly in a
// popup -- so the mapping, the repeat suppression and the
// coalesced replay live in Guacamole.MonitorPointer, where a
// test can reach them. The rect is read per event rather than
// captured, because the layout changes under a running window.
Guacamole.MonitorPointer(canvas,
function () { return monitorLayout[index]; },
function (state) {
client.sendMouseState(new Guacamole.Mouse.State(state),
false);
});
// Keyboard: forward to the same client (combined session).
var keyboard = new Guacamole.Keyboard(win.document);
keyboard.onkeydown = function(sym) { client.sendKeyEvent(1, sym); };
keyboard.onkeyup = function(sym) { client.sendKeyEvent(0, sym); };
rec.keyboard = keyboard;
// Closing the window (or the tab) releases the monitor.
win.addEventListener('beforeunload', function() {
if (monitorWindows[index]) closeMonitorWindow(index);
});
renderMonitorControls();
}
function closeMonitorWindow(index) {
var rec = monitorWindows[index];
if (!rec) return;
rec.running = false;
delete monitorWindows[index];
// Tell the server to close this monitor (non-positive size).
client.sendSize(0, 0, index, 0);
try { if (rec.win && !rec.win.closed) rec.win.close(); } catch (e) {}
renderMonitorControls();
}
// Tear down all pop-out monitor windows when the main session ends.
// A pop-out is only a satellite of this window (it blits from this
// window's canvas and sends input through this window's client), so
// it cannot outlive it. mode 'close' (this window is unloading)
// closes the pop-outs; mode 'disconnect' (the session dropped but
// this window is still open) stops the now-stale live blit and shows
// a "Disconnected" overlay so the pop-out is not a frozen live image.
function teardownMonitorWindows(mode) {
Object.keys(monitorWindows).forEach(function(idx) {
var rec = monitorWindows[idx];
if (!rec) return;
rec.running = false; // stop the per-frame blit
try {
if (!rec.win || rec.win.closed) return;
if (mode === 'close') {
rec.win.close();
}
else {
var doc = rec.win.document;
if (doc && doc.body && !doc.getElementById('mon-disconnected')) {
var ov = doc.createElement('div');
ov.id = 'mon-disconnected';
ov.textContent = 'Disconnected';
ov.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;'
+ 'display:flex;align-items:center;justify-content:center;'
+ 'background:rgba(0,0,0,0.72);color:#fff;'
+ 'font:600 1.6em monospace;letter-spacing:0.05em;z-index:99999;';
doc.body.appendChild(ov);
doc.body.style.cursor = 'default';
}
}
} catch (e) { /* cross-window access may throw during teardown */ }
});
}
// When this (main) window closes, take its pop-out monitors with it.
window.addEventListener('beforeunload', function() {
teardownMonitorWindows('close');
});
// ── 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';
controlsTab.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;
controlsTab.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 && !controlsPanelOpen) applySideTabs(false);
}, SIDE_HIDE_MS);
};
document.addEventListener('mousemove', function(e) {
if (panelOpen || filePanelOpen || controlsPanelOpen) 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';
// Fit the session to the actual window on connect. The session
// may have been created at different dimensions (e.g. opened in a
// new window at a fixed size), and the client otherwise only sends
// a size on a window-resize event, so the user has to resize
// manually to get the right fit on first connect. A single early
// send loses a race: right after CONNECTED the resize path is not
// ready yet (RDP's Display Control channel may not be connected;
// SPICE's agent/display-ready gate may be unmet), so the size is
// silently dropped. Retry a few times past that window — guacd
// coalesces to a single resize since the requested size is
// unchanged, so the redundant sends are cheap no-ops.
// A fresh connection knows nothing about what the
// server is already displaying.
lastRequestedSize = {};
(function() {
var fitToWindow = function() {
scaleDisplay();
var anyPanelOpen = panelOpen || filePanelOpen;
var sw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth;
var sh = window.innerHeight;
if (sw > 0 && sh > 0) {
// Sent whether or not it would change
// anything: the repeats above are
// deliberate, covering a window where the
// server drops the size silently, and
// suppressing them would defeat that.
// Recorded all the same, so that a user
// resize inside the same 16px band
// afterwards is correctly skipped.
sizeWouldChange(sw, sh);
client.sendSize(sw, sh);
}
};
setTimeout(fitToWindow, 800);
setTimeout(fitToWindow, 2000);
setTimeout(fitToWindow, 4000);
})();
// 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();
// Reflect the disconnect in any open monitor pop-outs
// (freeze the stale blit + show a "Disconnected" overlay).
teardownMonitorWindows('disconnect');
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);
});
// Show the remote pointer image as the browser's own pointer, and
// only fall back to the display's software cursor layer where CSS
// cursors are unsupported. Guacamole.Display puts that layer in
// the DOM from construction, so without this handover both are
// drawn: the browser's arrow, composited by the OS, and the
// software one, moved by the page on each mousemove -- which
// overlap at rest and visibly separate during fast movement,
// further the busier the main thread.
//
// Not Guacamole.Mouse.setCursor(), which declares the image at one
// image pixel per CSS pixel: under Native Resolution the display
// is scaled by 1/devicePixelRatio, and a host scaling its UI sends
// a pointer image to match, so that would draw it at twice its
// size. image-set() declares its density instead, from the
// display's scale, so it lands at one image pixel per framebuffer
// pixel like everything else on screen. The hotspot is then in CSS
// pixels. Re-applied from scaleDisplay(), since the scale changes
// under a pointer that does not.
var remoteCursor = null;
function applyRemoteCursor() {
if (!remoteCursor) return;
var display = client.getDisplay();
var el = display.getElement();
var scale = display.getScale();
var url = 'url(' + remoteCursor.url + ')';
el.style.cursor = '';
if (Math.abs(scale - 1) > 0.01)
el.style.cursor = 'image-set(' + url + ' ' + (1 / scale) + 'x) '
+ Math.round(remoteCursor.x * scale) + ' '
+ Math.round(remoteCursor.y * scale) + ', auto';
if (!el.style.cursor)
el.style.cursor = url + ' ' + remoteCursor.x + ' '
+ remoteCursor.y + ', auto';
display.showCursor(!el.style.cursor);
}
client.getDisplay().oncursor = function(canvas, x, y) {
remoteCursor = { url: canvas.toDataURL('image/png'), x: x, y: y };
applyRemoteCursor();
};
// Auto-scale display. When a multi-monitor layout is active, the
// main window shows only monitor 0 (always at the origin of the
// combined framebuffer); the other heads are available as pop-out
// windows via the Controls sidebar. Otherwise the whole framebuffer
// is scaled to fit.
function scaleDisplay() {
var display = client.getDisplay();
var dw = display.getWidth();
var dh = display.getHeight();
if (dw <= 0 || dh <= 0) return;
var anyPanelOpen = panelOpen || filePanelOpen;
var panelOffset = anyPanelOpen ? 380 : 0;
var availW = window.innerWidth - panelOffset;
var availH = window.innerHeight;
var el = display.getElement();
var m0 = monitorLayout && monitorLayout['0'];
var multimon = m0 && Object.keys(monitorLayout).length > 1
&& m0.width > 0 && m0.height > 0;
if (multimon) {
// Fit monitor 0 into the available area and clip away the
// rest of the combined framebuffer (the other heads).
var factor = Math.min(availW / m0.width, availH / m0.height);
display.scale(factor);
var cw = Math.round(m0.width * factor);
var ch = Math.round(m0.height * factor);
displayEl.style.overflow = 'hidden';
displayEl.style.position = 'absolute';
displayEl.style.width = cw + 'px';
displayEl.style.height = ch + 'px';
displayEl.style.left = (panelOffset + Math.max(0, (availW - cw) / 2)) + 'px';
displayEl.style.top = Math.max(0, (availH - ch) / 2) + 'px';
// Monitor 0 is at combined (0,0); no negative offset needed.
el.style.marginLeft = '0';
el.style.marginTop = '0';
}
else {
// Single display: scale the whole framebuffer to fit.
display.scale(Math.min(availW / dw, availH / dh));
displayEl.style.overflow = '';
displayEl.style.position = '';
displayEl.style.width = '';
displayEl.style.height = '';
displayEl.style.top = '';
displayEl.style.left = '';
el.style.marginLeft = '';
el.style.marginTop = '';
// Offset display to the right when left panel is open
displayEl.style.marginLeft = anyPanelOpen ? '380px' : '0';
}
applyRemoteCursor();
}
client.getDisplay().onresize = function() { scaleDisplay(); };
var resizeTimer = null;
window.addEventListener('resize', function() {
scaleDisplay();
// Debounce sendSize. Half a second rather than a quarter
// because the cost of a resolution change is a decoder
// rebuild, not just an RDP display update.
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function() {
var anyPanelOpen = panelOpen || filePanelOpen;
var sw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth;
var sh = window.innerHeight;
if (sizeWouldChange(sw, sh))
client.sendSize(sw, sh);
}, 500);
});
var connectData = shareToken ? 'token=' + encodeURIComponent(shareToken) : '';
client.connect(connectData);
}
</script>
</body>
</html>