mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 17:47:13 +00:00
928c6d4862
fit-on-connect: a session opened in a new window (at a fixed size), or otherwise created at dimensions that differ from the viewing window, now fits the window without a manual resize. The client previously only sent a size on a window-resize event. The one-time fit is retried past the point where the resize path becomes ready (RDP Display Control channel connected / SPICE agent and display ready) because an early send is silently dropped; guacd coalesces the retries into a single resize since the requested size is unchanged. pop-out teardown: a monitor pop-out is only a satellite of the main window (it blits from that window's canvas and sends input through its client), so it cannot outlive it. Closing the main window now closes its pop-outs instead of leaving a frozen orphan, and a session disconnect shows a "Disconnected" overlay in the pop-out rather than a stale live image.
1911 lines
102 KiB
HTML
1911 lines
102 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');
|
|
// 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 (e.g. client._h264Decoder.stats())
|
|
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">🏠 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">⛶ 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;">×</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;">×</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) client.sendSize(w, h, index, 0);
|
|
}, 300);
|
|
});
|
|
|
|
// Mouse: map the pointer from popup-window pixels to this
|
|
// monitor's native pixels using the canvas's LIVE on-screen
|
|
// geometry (getBoundingClientRect), then offset into the combined
|
|
// framebuffer. Reading the live rect each event avoids any stale
|
|
// cached scale/offset. Sent raw; guacd maps the combined
|
|
// coordinate back to this head.
|
|
// Native pointer handling (bypasses Guacamole.Mouse, which does
|
|
// not track the X axis correctly in a popup window). Map the raw
|
|
// client coordinate into the canvas's live on-screen rect as a
|
|
// 0..1 fraction, then into this monitor's native pixels and the
|
|
// combined framebuffer. Fraction-based, so it self-recalibrates
|
|
// on any resize.
|
|
function popupPointer(ev) {
|
|
var rect = monitorLayout[index];
|
|
if (!rect) return;
|
|
var cr = canvas.getBoundingClientRect();
|
|
var fx = cr.width > 0 ? (ev.clientX - cr.left) / cr.width : 0;
|
|
var fy = cr.height > 0 ? (ev.clientY - cr.top) / cr.height : 0;
|
|
if (fx < 0) fx = 0; else if (fx > 1) fx = 1;
|
|
if (fy < 0) fy = 0; else if (fy > 1) fy = 1;
|
|
client.sendMouseState(new Guacamole.Mouse.State({
|
|
x: rect.left + fx * rect.width,
|
|
y: rect.top + fy * rect.height,
|
|
left: (ev.buttons & 1) !== 0,
|
|
middle: (ev.buttons & 4) !== 0,
|
|
right: (ev.buttons & 2) !== 0,
|
|
up: false, down: false
|
|
}), false);
|
|
ev.preventDefault();
|
|
}
|
|
canvas.addEventListener('mousedown', popupPointer);
|
|
canvas.addEventListener('mousemove', popupPointer);
|
|
canvas.addEventListener('mouseup', popupPointer);
|
|
canvas.addEventListener('contextmenu', function(ev) { ev.preventDefault(); });
|
|
// Scroll wheel -> momentary up/down button.
|
|
canvas.addEventListener('wheel', function(ev) {
|
|
var rect = monitorLayout[index];
|
|
if (!rect) return;
|
|
var cr = canvas.getBoundingClientRect();
|
|
var fx = cr.width > 0 ? (ev.clientX - cr.left) / cr.width : 0;
|
|
var fy = cr.height > 0 ? (ev.clientY - cr.top) / cr.height : 0;
|
|
if (fx < 0) fx = 0; else if (fx > 1) fx = 1;
|
|
if (fy < 0) fy = 0; else if (fy > 1) fy = 1;
|
|
var base = { x: rect.left + fx * rect.width, y: rect.top + fy * rect.height,
|
|
left: false, middle: false, right: false, up: false, down: false };
|
|
var dir = ev.deltaY < 0 ? 'up' : 'down';
|
|
base[dir] = true;
|
|
client.sendMouseState(new Guacamole.Mouse.State(base), false);
|
|
base[dir] = false;
|
|
client.sendMouseState(new Guacamole.Mouse.State(base), false);
|
|
ev.preventDefault();
|
|
}, { passive: 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;">×</button>' +
|
|
'</div>' +
|
|
'<div id="fp-breadcrumb" style="padding:0.5em 1em;color:var(--text-muted);font-size:0.95em;border-bottom:1px solid var(--border);background:var(--input);word-break:break-all;"></div>' +
|
|
'<div style="padding:0.5em 1em;display:flex;gap:0.5em;border-bottom:1px solid var(--border);">' +
|
|
'<button id="fp-upload" style="padding:0.4em 0.8em;background:var(--accent);color:var(--bg);font-weight:bold;border:none;font-family:monospace;font-size:0.95em;border-radius:3px;cursor:pointer;">Upload</button>' +
|
|
'<button id="fp-refresh" style="padding:0.4em 0.8em;background:var(--input);color:var(--accent);border:1px solid var(--border);font-family:monospace;font-size:0.95em;border-radius:3px;cursor:pointer;">Refresh</button>' +
|
|
'</div>' +
|
|
'<div id="fp-list" style="flex:1;overflow-y:auto;min-height:0;"></div>' +
|
|
'<div id="fp-status" style="padding:0.5em 1em;color:var(--accent);font-size:0.9em;min-height:1.5em;border-top:1px solid var(--border);"></div>' +
|
|
'<div style="padding:0.6em 1em;color:var(--text-dim);font-size:0.85em;border-top:1px solid var(--border);">Drag files here to upload. Files are temporary and will be deleted when the session ends.</div>';
|
|
|
|
document.body.appendChild(filePanel);
|
|
fileListEl = document.getElementById('fp-list');
|
|
fileBreadcrumb = document.getElementById('fp-breadcrumb');
|
|
fileStatusEl = document.getElementById('fp-status');
|
|
|
|
document.getElementById('fp-close').addEventListener('click', function() { toggleFilePanel(); });
|
|
document.getElementById('fp-refresh').addEventListener('click', function() { refreshDirectory(); });
|
|
document.getElementById('fp-upload').addEventListener('click', function() {
|
|
var input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.multiple = true;
|
|
input.addEventListener('change', function() {
|
|
for (var i = 0; i < input.files.length; i++) {
|
|
uploadFile(input.files[i]);
|
|
}
|
|
});
|
|
input.click();
|
|
});
|
|
|
|
// Drag and drop on file panel
|
|
filePanel.addEventListener('dragover', function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
|
|
filePanel.addEventListener('drop', function(e) {
|
|
e.preventDefault();
|
|
for (var i = 0; i < e.dataTransfer.files.length; i++) {
|
|
uploadFile(e.dataTransfer.files[i]);
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleFilePanel() {
|
|
filePanelOpen = !filePanelOpen;
|
|
if (filePanelOpen) {
|
|
// Close clipboard panel if open
|
|
if (panelOpen) toggleClipboardPanel();
|
|
buildFilePanel();
|
|
filePanel.style.display = 'flex';
|
|
fileTab.style.display = 'none';
|
|
browseDirectory(currentPath);
|
|
} else {
|
|
if (filePanel) filePanel.style.display = 'none';
|
|
fileTab.style.display = '';
|
|
displayEl.focus();
|
|
keyboard.reset();
|
|
}
|
|
scaleDisplay();
|
|
}
|
|
|
|
function setFileStatus(msg) {
|
|
if (!fileStatusEl) return;
|
|
fileStatusEl.textContent = msg;
|
|
setTimeout(function() {
|
|
if (fileStatusEl && fileStatusEl.textContent === msg) fileStatusEl.textContent = '';
|
|
}, 4000);
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
var d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function fetchDirectoryListing(path) {
|
|
if (!filesystem) return;
|
|
tracing_log('Requesting directory listing for: ' + path);
|
|
filesystem.requestInputStream(path, function(stream, mimetype) {
|
|
tracing_log('Received body for ' + path + ': mimetype=' + mimetype);
|
|
|
|
if (mimetype !== Guacamole.Object.STREAM_INDEX_MIMETYPE) {
|
|
stream.sendAck('Unexpected mimetype', 0x0100);
|
|
tracing_log('Unexpected mimetype for directory: ' + mimetype);
|
|
return;
|
|
}
|
|
|
|
// Signal guacd we are ready to receive data (required for flow control)
|
|
stream.sendAck('Ready', 0x0000);
|
|
|
|
// Read the JSON listing using JSONReader (wraps StringReader).
|
|
// Ack each blob via onprogress — guacd waits for ack before
|
|
// sending next blob (same pattern as Apache Guacamole webapp).
|
|
var reader = new Guacamole.JSONReader(stream);
|
|
reader.onprogress = function() {
|
|
stream.sendAck('Received', 0x0000);
|
|
};
|
|
reader.onend = function() {
|
|
try {
|
|
var listing = reader.getJSON();
|
|
cachedListings[path] = listing;
|
|
tracing_log('Cached listing for ' + path + ': ' + Object.keys(listing).length + ' entries');
|
|
if (filePanelOpen && currentPath === path) {
|
|
renderFileListing(listing, path);
|
|
}
|
|
} catch (e) {
|
|
tracing_log('Failed to parse listing: ' + e);
|
|
if (filePanelOpen && currentPath === path && fileListEl) {
|
|
fileListEl.innerHTML = '<div style="padding:1em;color:#f88;">Error loading directory</div>';
|
|
}
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
function browseDirectory(path) {
|
|
if (!filesystem) return;
|
|
currentPath = path;
|
|
if (fileBreadcrumb) {
|
|
var parts = path.split('/').filter(function(p) { return p; });
|
|
var html = '<span style="cursor:pointer;color:var(--accent);" data-path="/">/</span>';
|
|
var built = '';
|
|
for (var i = 0; i < parts.length; i++) {
|
|
built += '/' + parts[i];
|
|
html += ' <span style="cursor:pointer;color:var(--accent);" data-path="' + escapeAttr(built) + '/">' + escapeHtml(parts[i]) + '</span>/';
|
|
}
|
|
fileBreadcrumb.innerHTML = html;
|
|
var spans = fileBreadcrumb.querySelectorAll('span[data-path]');
|
|
for (var j = 0; j < spans.length; j++) {
|
|
spans[j].addEventListener('click', function() {
|
|
browseDirectory(this.getAttribute('data-path'));
|
|
});
|
|
}
|
|
}
|
|
// Check cache first
|
|
if (cachedListings[path]) {
|
|
renderFileListing(cachedListings[path], path);
|
|
return;
|
|
}
|
|
|
|
if (fileListEl) fileListEl.innerHTML = '<div style="padding:1em;color:var(--text-dim);">Loading...</div>';
|
|
fetchDirectoryListing(path);
|
|
}
|
|
|
|
function refreshDirectory() {
|
|
delete cachedListings[currentPath];
|
|
if (fileListEl) fileListEl.innerHTML = '<div style="padding:1em;color:var(--text-dim);">Loading...</div>';
|
|
fetchDirectoryListing(currentPath);
|
|
}
|
|
|
|
function renderFileListing(listing, parentPath) {
|
|
if (!fileListEl) return;
|
|
var entries = Object.keys(listing);
|
|
if (entries.length === 0) {
|
|
fileListEl.innerHTML = '<div style="padding:1em;color:var(--text-dim);">Empty directory</div>';
|
|
return;
|
|
}
|
|
|
|
// Determine prefix to strip from full stream names.
|
|
// guacd returns entries as full paths: "/subdir", "/file.txt"
|
|
var prefix = parentPath;
|
|
if (prefix.charAt(prefix.length - 1) !== '/') prefix += '/';
|
|
|
|
// Sort: directories first, then files
|
|
var dirs = [];
|
|
var files = [];
|
|
for (var i = 0; i < entries.length; i++) {
|
|
var streamName = entries[i];
|
|
var mime = listing[streamName];
|
|
|
|
// Strip prefix to get display name
|
|
var displayName = streamName;
|
|
if (streamName.substring(0, prefix.length) === prefix) {
|
|
displayName = streamName.substring(prefix.length);
|
|
}
|
|
// Skip empty names (the directory itself)
|
|
if (!displayName || displayName === '/') continue;
|
|
|
|
if (mime === Guacamole.Object.STREAM_INDEX_MIMETYPE) {
|
|
dirs.push({ displayName: displayName, streamName: streamName });
|
|
} else {
|
|
files.push({ displayName: displayName, streamName: streamName, mimetype: mime });
|
|
}
|
|
}
|
|
dirs.sort(function(a, b) { return a.displayName.localeCompare(b.displayName); });
|
|
files.sort(function(a, b) { return a.displayName.localeCompare(b.displayName); });
|
|
|
|
if (dirs.length === 0 && files.length === 0) {
|
|
fileListEl.innerHTML = '<div style="padding:1em;color:var(--text-dim);">Empty directory</div>';
|
|
return;
|
|
}
|
|
|
|
var html = '';
|
|
for (var d = 0; d < dirs.length; d++) {
|
|
// For subdirectory browsing, use the streamName (full path)
|
|
var dirBrowsePath = dirs[d].streamName;
|
|
if (dirBrowsePath.charAt(dirBrowsePath.length - 1) !== '/') dirBrowsePath += '/';
|
|
html += '<div class="fp-entry" data-path="' + escapeAttr(dirBrowsePath) + '" data-type="dir" style="padding:0.5em 1em;cursor:pointer;display:flex;align-items:center;gap:0.5em;border-bottom:1px solid var(--bg);">' +
|
|
'<span style="color:var(--accent);">📁</span>' +
|
|
'<span style="flex:1;color:var(--text);font-size:0.95em;">' + escapeHtml(dirs[d].displayName) + '</span>' +
|
|
'</div>';
|
|
}
|
|
for (var fi = 0; fi < files.length; fi++) {
|
|
var filePath = files[fi].streamName;
|
|
html += '<div class="fp-entry" data-path="' + escapeAttr(filePath) + '" data-type="file" style="padding:0.5em 1em;cursor:pointer;display:flex;align-items:center;gap:0.5em;border-bottom:1px solid var(--bg);">' +
|
|
'<span style="color:var(--text-muted);">📄</span>' +
|
|
'<span style="flex:1;color:var(--text);font-size:0.95em;">' + escapeHtml(files[fi].displayName) + '</span>' +
|
|
'<button class="fp-dl" data-path="' + escapeAttr(filePath) + '" data-name="' + escapeAttr(files[fi].displayName) + '" style="padding:0.3em 0.6em;background:var(--input);color:var(--accent);border:1px solid var(--border);font-family:monospace;font-size:0.85em;border-radius:3px;cursor:pointer;">Download</button>' +
|
|
'</div>';
|
|
}
|
|
fileListEl.innerHTML = html;
|
|
|
|
// Bind click handlers
|
|
var entryEls = fileListEl.querySelectorAll('.fp-entry[data-type="dir"]');
|
|
for (var ei = 0; ei < entryEls.length; ei++) {
|
|
entryEls[ei].addEventListener('click', function() {
|
|
browseDirectory(this.getAttribute('data-path'));
|
|
});
|
|
}
|
|
// Hover effect
|
|
var allEntries = fileListEl.querySelectorAll('.fp-entry');
|
|
for (var ae = 0; ae < allEntries.length; ae++) {
|
|
allEntries[ae].addEventListener('mouseenter', function() { this.style.background = 'var(--surface)'; });
|
|
allEntries[ae].addEventListener('mouseleave', function() { this.style.background = ''; });
|
|
}
|
|
// Download buttons
|
|
var dlBtns = fileListEl.querySelectorAll('.fp-dl');
|
|
for (var db = 0; db < dlBtns.length; db++) {
|
|
dlBtns[db].addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
downloadFile(this.getAttribute('data-path'), this.getAttribute('data-name'));
|
|
});
|
|
}
|
|
}
|
|
|
|
function downloadFile(path, filename) {
|
|
if (!filesystem) return;
|
|
setFileStatus('Downloading ' + filename + '...');
|
|
filesystem.requestInputStream(path, function(stream, mimetype) {
|
|
// Signal guacd we are ready (required before first blob)
|
|
stream.sendAck('Ready', 0x0000);
|
|
var reader = new Guacamole.BlobReader(stream, mimetype || 'application/octet-stream');
|
|
reader.onend = function() {
|
|
var blob = reader.getBlob();
|
|
var a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000);
|
|
setFileStatus('Downloaded ' + filename);
|
|
};
|
|
reader.onerror = function() {
|
|
setFileStatus('Download failed: ' + filename);
|
|
};
|
|
});
|
|
}
|
|
|
|
function uploadFile(file) {
|
|
if (!filesystem) return;
|
|
var destPath = currentPath + file.name;
|
|
setFileStatus('Uploading ' + file.name + '...');
|
|
var stream = filesystem.createOutputStream('application/octet-stream', destPath);
|
|
var writer = new Guacamole.BlobWriter(stream);
|
|
writer.oncomplete = function() {
|
|
setFileStatus('Uploaded ' + file.name);
|
|
// Refresh listing after brief delay
|
|
setTimeout(function() { refreshDirectory(); }, 500);
|
|
};
|
|
writer.onerror = function() {
|
|
setFileStatus('Upload failed: ' + file.name);
|
|
};
|
|
writer.sendBlob(file);
|
|
}
|
|
|
|
// ── Filesystem handler (fires when drive/SFTP is available) ──
|
|
client.onfilesystem = function(object, name) {
|
|
filesystem = object;
|
|
tracing_log('Filesystem available: ' + name);
|
|
// Show file manager tab
|
|
fileTab.style.display = '';
|
|
|
|
// Pre-fetch root listing so it's cached when user opens the panel.
|
|
// Uses requestInputStream with callback (standard Guacamole pattern)
|
|
// rather than overriding onbody, which bypasses the internal dispatch.
|
|
fetchDirectoryListing('/');
|
|
};
|
|
|
|
// ── File download handler (server-initiated file transfer) ──
|
|
client.onfile = function(stream, mimetype, filename) {
|
|
var reader = new Guacamole.BlobReader(stream, mimetype);
|
|
reader.onend = function() {
|
|
var blob = reader.getBlob();
|
|
var a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
setTimeout(function() { URL.revokeObjectURL(a.href); }, 1000);
|
|
setFileStatus('Received file: ' + filename);
|
|
};
|
|
};
|
|
|
|
function tracing_log(msg) {
|
|
if (typeof console !== 'undefined') console.log('[rustguac] ' + msg);
|
|
}
|
|
|
|
// Also support drag-drop on the display
|
|
displayEl.addEventListener('dragover', function(e) {
|
|
if (filesystem) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }
|
|
});
|
|
displayEl.addEventListener('drop', function(e) {
|
|
if (!filesystem) return;
|
|
e.preventDefault();
|
|
for (var i = 0; i < e.dataTransfer.files.length; i++) {
|
|
uploadFile(e.dataTransfer.files[i]);
|
|
}
|
|
});
|
|
|
|
// ── State changes ──
|
|
// ── Thumbnail capture (owner only, not shared viewers) ──
|
|
var _thumbInterval = null;
|
|
function captureAndUploadThumbnail() {
|
|
try {
|
|
var display = client.getDisplay();
|
|
var canvas = display.flatten();
|
|
if (!canvas || canvas.width === 0) return;
|
|
// Scale down to 320px wide
|
|
var thumb = document.createElement('canvas');
|
|
thumb.width = 320;
|
|
thumb.height = Math.round(320 * canvas.height / canvas.width) || 180;
|
|
thumb.getContext('2d').drawImage(canvas, 0, 0, thumb.width, thumb.height);
|
|
thumb.toBlob(function(blob) {
|
|
if (!blob) return;
|
|
var headers = {};
|
|
if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey;
|
|
fetch('/api/sessions/' + sessionId + '/thumbnail', {
|
|
method: 'PUT',
|
|
headers: headers,
|
|
credentials: 'same-origin',
|
|
body: blob
|
|
}).catch(function() {}); // fire and forget
|
|
}, 'image/jpeg', 0.6);
|
|
} catch(e) { /* ignore capture errors */ }
|
|
}
|
|
|
|
// ── 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.
|
|
(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) 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);
|
|
});
|
|
|
|
// 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';
|
|
}
|
|
}
|
|
|
|
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>
|