mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-26 05:29:51 +00:00
dd339ecb5e
(cherry picked from commit 1c90cb120e)
2333 lines
127 KiB
HTML
2333 lines
127 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%;
|
|
}
|
|
/* Actual size view: always-visible scroll bars, so a desktop wider
|
|
than the window shows that there is more of it (overlay scroll
|
|
bars, as on macOS, stay hidden until you scroll). */
|
|
#display::-webkit-scrollbar { width: 14px; height: 14px; }
|
|
#display::-webkit-scrollbar-track { background: #111; }
|
|
#display::-webkit-scrollbar-thumb { background: #555; border: 3px solid #111; border-radius: 7px; }
|
|
#display::-webkit-scrollbar-thumb:hover { background: #777; }
|
|
#display::-webkit-scrollbar-corner { background: #111; }
|
|
#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); }
|
|
/* Fullscreen-on-connect gate: covers the session until the browser
|
|
is fullscreen, so the first picture the user sees is the one that
|
|
fits (the fullscreen desktop), not a scaled-down windowed fit. */
|
|
#fs-gate {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: var(--bg);
|
|
z-index: 3100; /* above the side tabs (3001); hidden before the disconnect overlay (3000) is shown */
|
|
justify-content: center;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
}
|
|
#fs-gate.visible { display: flex; }
|
|
#fs-gate-box {
|
|
text-align: center;
|
|
font-family: monospace;
|
|
color: var(--text);
|
|
max-width: 600px;
|
|
padding: 2em 3em;
|
|
}
|
|
#fs-gate-spinner {
|
|
width: 30px; height: 30px; margin: 0 auto 1.2em;
|
|
border: 3px solid var(--border);
|
|
border-top-color: var(--accent);
|
|
border-radius: 50%;
|
|
animation: fs-gate-spin 0.8s linear infinite;
|
|
}
|
|
@keyframes fs-gate-spin { to { transform: rotate(360deg); } }
|
|
#fs-gate-title { font-size: 1.3em; font-weight: bold; margin-bottom: 0.5em; }
|
|
#fs-gate-status { color: var(--text-dim); font-size: 0.95em; min-height: 1.2em; letter-spacing: 0.02em; }
|
|
#fs-gate-status.connected { color: var(--accent); }
|
|
#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="fs-gate">
|
|
<div id="fs-gate-box">
|
|
<div id="fs-gate-spinner"></div>
|
|
<div id="fs-gate-title"></div>
|
|
<div id="fs-gate-status"></div>
|
|
</div>
|
|
</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');
|
|
// Embedded in a full-viewport iframe on the launcher tab (that tab is
|
|
// already fullscreen, so this page does no fullscreen of its own and
|
|
// just fills the frame; it tells the parent when the session ends).
|
|
var embedded = urlParams.get('embedded') === '1';
|
|
// ?fs=0: opened by the launcher's "open in a new window" button (⧉).
|
|
// An explicitly windowed session: no fullscreen-on-connect, whatever
|
|
// the entry says, so no cover asking for a click.
|
|
var windowed = urlParams.get('fs') === '0';
|
|
function rgNotifyParent(kind) { if (!embedded) return; try { window.parent.postMessage({ rgSession: kind }, location.origin); } catch (e) {} }
|
|
// Every navigation away from the session goes through here. Embedded,
|
|
// it moves the whole tab: the frame may only hold this session's
|
|
// client, and any other page refuses to load in a frame.
|
|
function rgGo(url) {
|
|
if (embedded) { try { window.top.location.href = url; return; } catch (e) {} }
|
|
window.location.href = url;
|
|
}
|
|
// Embedded: the launcher enters fullscreen, waits for the viewport to
|
|
// stop changing and creates the session at that measured size, so the
|
|
// frame paints into exactly the size the remote was given. The connect
|
|
// cover stays up, and the fit-to-window sends wait, until the launcher
|
|
// confirms that (its 'viewport' message, sent once this frame reports
|
|
// ready), so nothing is shown or sent against a viewport that is still
|
|
// moving.
|
|
var viewportSettled = !embedded;
|
|
var _onViewportSettled = [];
|
|
function rgWhenViewportSettled(fn) { if (viewportSettled) fn(); else _onViewportSettled.push(fn); }
|
|
function rgMarkViewportSettled() {
|
|
if (viewportSettled) return;
|
|
viewportSettled = true;
|
|
var fns = _onViewportSettled; _onViewportSettled = [];
|
|
for (var i = 0; i < fns.length; i++) { try { fns[i](); } catch (e) {} }
|
|
}
|
|
if (embedded) {
|
|
window.addEventListener('message', function (e) {
|
|
if (e.origin !== location.origin || !e.data || e.data.rgLauncher !== 'viewport') return;
|
|
rgMarkViewportSettled();
|
|
});
|
|
}
|
|
// 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;
|
|
var bitmapSession = false; // rdp/vnc: a server-side resize is slow and visible
|
|
var fsEverEntered = false; // after the first fullscreen, size to the window again
|
|
// #257: the launcher adds ?fs=1 for a fullscreen-on-connect entry. We
|
|
// request fullscreen immediately, here, before the async session fetch
|
|
// — the window still holds the user activation carried from the Connect
|
|
// click, which may have lapsed by the time the metadata returns. If the
|
|
// browser honours it there is no click at all; if not, the cover's
|
|
// armed gesture still enters it. Showing the cover now also avoids any
|
|
// flash of the page before the session loads.
|
|
// Embedded frames show the same cover from the start (no fullscreen
|
|
// request of their own: the parent tab holds it) and lift it once
|
|
// connected and the viewport has settled.
|
|
var _rgEarlyCover = function () {
|
|
try {
|
|
var _g = document.getElementById('fs-gate');
|
|
if (!_g) return;
|
|
var _t = document.getElementById('fs-gate-title');
|
|
if (_t) _t.textContent = entryName || 'Session';
|
|
var _s = document.getElementById('fs-gate-status');
|
|
if (_s) _s.textContent = 'Connecting…';
|
|
_g.className = 'visible';
|
|
} catch (e) {}
|
|
};
|
|
// Windowed (⧉) sessions arrive from the matching splash on
|
|
// /launch.html, so they keep the cover up until connected too.
|
|
if (embedded || windowed) {
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _rgEarlyCover);
|
|
else _rgEarlyCover();
|
|
}
|
|
if (urlParams.get('fs') === '1' && !embedded) {
|
|
wantFullscreen = true;
|
|
var _rgEarlyFs = function () {
|
|
_rgEarlyCover();
|
|
try {
|
|
var _el = document.documentElement;
|
|
var _req = _el.requestFullscreen || _el.webkitRequestFullscreen || _el.mozRequestFullScreen || _el.msRequestFullscreen;
|
|
if (_req) { var _p = _req.call(_el); if (_p && _p.catch) _p.catch(function () {}); }
|
|
} catch (e) {}
|
|
};
|
|
// Requesting fullscreen during initial parse is refused, so wait for
|
|
// the document to be interactive — but still before the async
|
|
// session fetch, so the window keeps the Connect click's activation.
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _rgEarlyFs);
|
|
else _rgEarlyFs();
|
|
}
|
|
// 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 && !embedded && !windowed) wantFullscreen = true;
|
|
bitmapSession = !!(session && (session.session_type === 'rdp' || session.session_type === 'vnc'));
|
|
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">🏠 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.
|
|
rgGo('/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;
|
|
|
|
// View: 'fit' scales the desktop to the window, 'actual' draws it
|
|
// at 100% with scroll bars. Remembered per browser.
|
|
var viewMode = 'fit';
|
|
try { if (localStorage.getItem('rustguac_view_mode') === 'actual') viewMode = 'actual'; } catch (e) {}
|
|
function setViewMode(mode) {
|
|
viewMode = mode === 'actual' ? 'actual' : 'fit';
|
|
try { localStorage.setItem('rustguac_view_mode', viewMode); } catch (e) {}
|
|
scaleDisplay();
|
|
}
|
|
|
|
// 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;">VIEW</div>' +
|
|
'<label style="display:flex;align-items:center;gap:0.5em;padding:0.25em 0;font-size:0.9em;cursor:pointer;"><input type="radio" name="ctrl-view" id="ctrl-view-fit" value="fit"><span>Fit to window</span></label>' +
|
|
'<label style="display:flex;align-items:center;gap:0.5em;padding:0.25em 0;font-size:0.9em;cursor:pointer;"><input type="radio" name="ctrl-view" id="ctrl-view-actual" value="actual"><span>Actual size, with scroll bars</span></label>' +
|
|
'<div style="color:var(--text-dim);font-size:0.75em;margin-top:0.4em;">For a remote desktop bigger than this window, such as two monitors viewed from a laptop.</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);
|
|
});
|
|
document.getElementById('ctrl-view-' + viewMode).checked = true;
|
|
['fit', 'actual'].forEach(function(m) {
|
|
document.getElementById('ctrl-view-' + m).addEventListener('change', function(e) {
|
|
if (e.target.checked) setViewMode(m);
|
|
});
|
|
});
|
|
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) {
|
|
rgAlert('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;">×</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);
|
|
};
|
|
stream.sendAck('OK', Guacamole.Status.Code.SUCCESS);
|
|
};
|
|
|
|
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, #257) ──
|
|
// A fullscreen-on-connect entry goes fullscreen before its picture
|
|
// is shown, so the desktop is painted once at the final size with
|
|
// no windowed frame and no resize. The browser needs a user
|
|
// gesture to enter fullscreen; the Connect click on the
|
|
// connections page is carried into this window (opened within that
|
|
// click), so rgEnterFullscreen() is tried immediately on load and
|
|
// usually succeeds with no further interaction. A cover stays over
|
|
// the page the whole time — while fullscreen is requested, while
|
|
// the tunnel connects, while the server paints — and lifts only
|
|
// once the window is fullscreen and the session connected. If the
|
|
// browser won't honour the load-time request, the next click or
|
|
// key (armed below) enters fullscreen instead; that press is taken
|
|
// in the capture phase so it never reaches the remote (#256).
|
|
function rgFullscreenEl() {
|
|
return document.fullscreenElement
|
|
|| document.webkitFullscreenElement
|
|
|| document.mozFullScreenElement
|
|
|| document.msFullscreenElement || null;
|
|
}
|
|
// A bitmap (RDP/VNC) fullscreen-on-connect session is created at
|
|
// the screen's size and kept there until fullscreen lands, so the
|
|
// picture painted behind the cover is already the fullscreen one —
|
|
// no resize when it is revealed. Everything else, and everything
|
|
// after the first fullscreen, tracks the window.
|
|
function rgTargetSize(anyPanelOpen) {
|
|
if (wantFullscreen && bitmapSession && !fsEverEntered && !rgFullscreenEl() && window.screen && screen.width > 0 && screen.height > 0)
|
|
return [screen.width, screen.height];
|
|
return [anyPanelOpen ? window.innerWidth - 380 : window.innerWidth, window.innerHeight];
|
|
}
|
|
// Try to enter fullscreen. onReject runs if the browser refuses
|
|
// (e.g. no user activation on load): the caller decides whether to
|
|
// wait for a gesture or give up and show the windowed session.
|
|
var fsKeyboardLocked = false;
|
|
function rgOnFullscreenEntered() {
|
|
if (fsKeyboardLocked) return;
|
|
fsKeyboardLocked = true;
|
|
if (navigator.keyboard && navigator.keyboard.lock) {
|
|
navigator.keyboard.lock(['Escape']).catch(rgShowEscNoticeOnce);
|
|
} else {
|
|
rgShowEscNoticeOnce();
|
|
}
|
|
}
|
|
function rgEnterFullscreen(onReject) {
|
|
var el = document.documentElement;
|
|
var req = el.requestFullscreen
|
|
|| el.webkitRequestFullscreen
|
|
|| el.mozRequestFullScreen
|
|
|| el.msRequestFullscreen;
|
|
if (!req) { if (onReject) onReject(); return; }
|
|
var p;
|
|
try { p = req.call(el); } catch (e) { if (onReject) onReject(); return; }
|
|
if (p && p.then) p.then(rgOnFullscreenEntered).catch(function(){ if (onReject) onReject(); }); else rgOnFullscreenEntered();
|
|
}
|
|
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) {} }
|
|
}
|
|
var fsConnected = false; // the tunnel has reached CONNECTED
|
|
var fsGestureArmed = false;
|
|
// Reveal the session only once it is both fullscreen and
|
|
// connected: the picture behind the cover is then the final one.
|
|
function rgMaybeReveal() {
|
|
if (fsConnected && rgFullscreenEl()) rgHideConnectCover();
|
|
}
|
|
// Fallback when the load-time fullscreen request is refused: wait
|
|
// for the user's next click or key and enter fullscreen on it. The
|
|
// press is swallowed (capture phase) so it isn't sent to the
|
|
// remote, and a hint on the cover tells the user what to do.
|
|
function rgArmFullscreenGesture() {
|
|
if (fsGestureArmed) return;
|
|
fsGestureArmed = true;
|
|
var fire = function(e) {
|
|
if (e.type === 'keydown' && (e.key === 'Escape' || e.key === 'Tab')) return;
|
|
rgDisarmFullscreenGesture();
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
rgEnterFullscreen(function() {
|
|
// Still refused: show the windowed session rather than
|
|
// trap the user behind the cover forever.
|
|
rgHideConnectCover();
|
|
});
|
|
};
|
|
window.addEventListener('mousedown', fire, true);
|
|
window.addEventListener('keydown', fire, true);
|
|
rgArmFullscreenGesture._fire = fire;
|
|
}
|
|
function rgDisarmFullscreenGesture() {
|
|
if (!fsGestureArmed) return;
|
|
fsGestureArmed = false;
|
|
var fire = rgArmFullscreenGesture._fire;
|
|
if (fire) {
|
|
window.removeEventListener('mousedown', fire, true);
|
|
window.removeEventListener('keydown', fire, true);
|
|
}
|
|
}
|
|
// The connect cover (#fs-gate): an opaque panel with a spinner,
|
|
// the entry name and a status line. It hides everything until the
|
|
// window is fullscreen and the session painted. No button, no
|
|
// decision to make.
|
|
function rgGateStatus(text, connected) {
|
|
var el = document.getElementById('fs-gate-status');
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.className = connected ? 'connected' : '';
|
|
}
|
|
function rgShowConnectCover(status, connected) {
|
|
var g = document.getElementById('fs-gate');
|
|
if (!g) return;
|
|
document.getElementById('fs-gate-title').textContent =
|
|
entryDisplayName || ('Session ' + sessionId.substring(0,8));
|
|
rgGateStatus(status || 'Connecting…', !!connected);
|
|
g.className = 'visible';
|
|
}
|
|
function rgHideConnectCover() {
|
|
var g = document.getElementById('fs-gate');
|
|
if (g) g.className = '';
|
|
}
|
|
// Request fullscreen as soon as the page is up, using the
|
|
// activation carried from the Connect click. If it is honoured the
|
|
// user never has to act; if not, wait for their next press.
|
|
function rgStartFullscreenOnConnect() {
|
|
if (!document.getElementById('fs-gate') || !document.getElementById('fs-gate').classList.contains('visible'))
|
|
rgShowConnectCover('Connecting\u2026', false);
|
|
if (rgFullscreenEl()) return; // early fs=1 request already landed
|
|
rgArmFullscreenGesture(); // fallback if the browser refused it
|
|
}
|
|
// Tell the remote that nothing is held: every key the keyboard
|
|
// tracks as down, and all mouse buttons at the last pointer
|
|
// position. Used after a fullscreen transition, during which the
|
|
// browser may not deliver the releases to the display element.
|
|
function rgReleaseAllInput() {
|
|
try { if (keyboard) keyboard.reset(); } catch (e) {}
|
|
try {
|
|
if (!mouse || !mouse.currentState) return;
|
|
var st = new Guacamole.Mouse.State(mouse.currentState);
|
|
st.left = st.middle = st.right = st.up = st.down = false;
|
|
if (fillX !== 1 || fillY !== 1) { st.x /= fillX; st.y /= fillY; }
|
|
client.sendMouseState(st, true);
|
|
} catch (e) {}
|
|
}
|
|
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 (inFs) { fsEverEntered = true; rgDisarmFullscreenGesture(); rgOnFullscreenEntered(); rgReleaseAllInput(); rgMaybeReveal(); } else { rgHideConnectCover(); }
|
|
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) {}
|
|
rgGo('/connections.html');
|
|
});
|
|
|
|
client.onstatechange = function(state) {
|
|
switch (state) {
|
|
case Guacamole.Client.State.CONNECTING: statusEl.textContent = 'Connecting...'; statusEl.className = ''; rgGateStatus('Connecting...', false); break;
|
|
case Guacamole.Client.State.WAITING: statusEl.textContent = 'Waiting for server...'; statusEl.className = ''; rgGateStatus('Waiting for server...', false); break;
|
|
case Guacamole.Client.State.CONNECTED:
|
|
statusEl.textContent = 'Connected'; statusEl.className = 'connected';
|
|
rgNotifyParent('ready');
|
|
fsConnected = true;
|
|
rgGateStatus('Connected', true);
|
|
rgMaybeReveal();
|
|
// 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 ts = rgTargetSize(anyPanelOpen);
|
|
var sw = ts[0], sh = ts[1];
|
|
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);
|
|
}
|
|
};
|
|
var scheduleFit = function() {
|
|
setTimeout(fitToWindow, 800);
|
|
setTimeout(fitToWindow, 2000);
|
|
setTimeout(fitToWindow, 4000);
|
|
};
|
|
// Embedded: not before the viewport has settled,
|
|
// or an 800ms send mid-transition would resize
|
|
// the remote to a size it is about to leave.
|
|
rgWhenViewportSettled(scheduleFit);
|
|
})();
|
|
// Never hold the embedded cover on a parent that does
|
|
// not answer (e.g. a stale launcher script).
|
|
if (embedded) setTimeout(rgMarkViewportSettled, 3000);
|
|
// 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));
|
|
// Connected. If this is a fullscreen-on-connect entry
|
|
// that is already fullscreen, the cover lifts now
|
|
// (rgMaybeReveal above) onto the final picture. If
|
|
// fullscreen hasn't landed yet, give the load-time
|
|
// request a moment; if it still hasn't, the cover
|
|
// invites the single click that the armed gesture turns
|
|
// into fullscreen. Non-fullscreen sessions just reveal.
|
|
if (wantFullscreen && !rgFullscreenEl()) {
|
|
rgGateStatus('Connected', true);
|
|
setTimeout(function() {
|
|
if (fsConnected && !rgFullscreenEl())
|
|
rgGateStatus('Connected \u2014 click to enter fullscreen', true);
|
|
}, 1200);
|
|
} else if (!wantFullscreen) {
|
|
// Embedded: reveal only onto the final-size fit.
|
|
rgWhenViewportSettled(function() { scaleDisplay(); rgHideConnectCover(); });
|
|
}
|
|
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 = '';
|
|
rgDisarmFullscreenGesture(); rgHideConnectCover();
|
|
if (_thumbInterval) { clearInterval(_thumbInterval); _thumbInterval = null; }
|
|
if (embedded) { rgNotifyParent('end'); break; }
|
|
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 = '';
|
|
rgDisarmFullscreenGesture(); rgHideConnectCover();
|
|
// 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() {
|
|
// Embedded: the launcher closes the frame and the tab is back
|
|
// on Connections.
|
|
if (embedded) { rgNotifyParent('end'); return; }
|
|
window.close();
|
|
// window.close() may be blocked if not opened by script — navigate away as fallback
|
|
rgGo('/');
|
|
});
|
|
// 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) {
|
|
rgGo('/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) {
|
|
rgGo('/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,
|
|
screen_width: screen.width,
|
|
screen_height: screen.height,
|
|
dpi: Math.round((window.devicePixelRatio || 1) * 96),
|
|
keyboard_layout: (function() {
|
|
try { return localStorage.getItem('rustguac_keyboard_layout') || undefined; } catch (e) { return undefined; }
|
|
})()
|
|
};
|
|
|
|
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.
|
|
rgGo('/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);
|
|
}
|
|
rgGo(dest);
|
|
} else {
|
|
rgGo('/connections.html');
|
|
}
|
|
});
|
|
})
|
|
.catch(function() {
|
|
rgGo('/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. Pointer positions come in screen pixels of the
|
|
// display element; sendMouseState divides out the uniform display
|
|
// scale, and the fill factors from scaleDisplay() are divided out
|
|
// here, since they are a transform the display knows nothing about.
|
|
var fillX = 1, fillY = 1;
|
|
var mouse = new Guacamole.Mouse(client.getDisplay().getElement());
|
|
mouse.onEach(['mousedown', 'mousemove', 'mouseup'], function(e) {
|
|
var state = e.state;
|
|
if (fillX !== 1 || fillY !== 1) {
|
|
state = new Guacamole.Mouse.State(state);
|
|
state.x /= fillX;
|
|
state.y /= fillY;
|
|
}
|
|
client.sendMouseState(state, true);
|
|
});
|
|
|
|
// Absorb a small shortfall between the framebuffer and the
|
|
// viewport. The uniform fit in scaleDisplay() makes one axis match
|
|
// exactly and leaves the other short when the server could not
|
|
// honour the requested size. For RDP that is our own guacd:
|
|
// patches/007-rdp-disp-mod16 rounds both dimensions down to a
|
|
// multiple of 16, with or without GFX, so a 1710-wide window gets
|
|
// a 1696-wide desktop and up to 15px of margin (#251). That
|
|
// rounding is deliberate (it prevents H.264's green band along the
|
|
// edge) and must not be relaxed to make the margin go away. A
|
|
// larger gap is a real aspect mismatch (fixed resolution, no
|
|
// resize support) and keeps the letterbox.
|
|
var FILL_MAX = 1.025;
|
|
// pixelExact: the picture is already drawn one framebuffer pixel per
|
|
// physical pixel (effective scale x devicePixelRatio == 1), so any
|
|
// stretch would be visibly soft. Then we never stretch — the small
|
|
// mod-16 margin is centred and shows the dark background instead
|
|
// (#251). When the picture is already being resampled, the stretch
|
|
// costs no extra sharpness, so we take it to fill the gap.
|
|
function applyFill(el, w, h, availW, availH, pixelExact) {
|
|
fillX = 1;
|
|
fillY = 1;
|
|
if (!pixelExact) {
|
|
if (w > 0 && w < availW && availW / w <= FILL_MAX) fillX = availW / w;
|
|
if (h > 0 && h < availH && availH / h <= FILL_MAX) fillY = availH / h;
|
|
}
|
|
if (fillX !== 1 || fillY !== 1) {
|
|
el.style.transformOrigin = '0 0';
|
|
el.style.transform = 'scale(' + fillX + ', ' + fillY + ')';
|
|
} else {
|
|
el.style.transformOrigin = '';
|
|
el.style.transform = '';
|
|
}
|
|
}
|
|
|
|
// 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';
|
|
applyFill(el, 0, 0, 0, 0);
|
|
}
|
|
else if (viewMode === 'actual') {
|
|
// Actual size: one framebuffer pixel per CSS pixel, with
|
|
// scroll bars when the desktop is bigger than the window
|
|
// (a two-monitor desktop on a one-monitor laptop). The
|
|
// container is positioned so it is the display's
|
|
// offsetParent, which is how Guacamole.Position takes its
|
|
// scroll offset into account for the pointer.
|
|
display.scale(1);
|
|
applyFill(el, 0, 0, 0, 0, true);
|
|
displayEl.style.overflow = 'auto';
|
|
displayEl.style.position = 'absolute';
|
|
displayEl.style.left = panelOffset + 'px';
|
|
displayEl.style.top = '0';
|
|
displayEl.style.width = availW + 'px';
|
|
displayEl.style.height = availH + 'px';
|
|
displayEl.style.marginLeft = '0';
|
|
// Centre a desktop smaller than the window. clientWidth
|
|
// excludes any scroll bar, so the other axis still centres.
|
|
el.style.marginLeft = Math.max(0, (displayEl.clientWidth - dw) / 2) + 'px';
|
|
el.style.marginTop = Math.max(0, (displayEl.clientHeight - dh) / 2) + 'px';
|
|
}
|
|
else {
|
|
// Single display: scale the whole framebuffer to fit.
|
|
var scale = Math.min(availW / dw, availH / dh);
|
|
display.scale(scale);
|
|
var sw = dw * scale, sh = dh * scale;
|
|
var pixelExact = Math.abs(scale * (window.devicePixelRatio || 1) - 1) < 0.002;
|
|
applyFill(el, sw, sh, availW, availH, pixelExact);
|
|
displayEl.style.overflow = '';
|
|
displayEl.style.position = '';
|
|
displayEl.style.width = '';
|
|
displayEl.style.height = '';
|
|
displayEl.style.top = '';
|
|
displayEl.style.left = '';
|
|
displayEl.style.marginLeft = '0';
|
|
displayEl.scrollLeft = 0;
|
|
displayEl.scrollTop = 0;
|
|
// Centre the (possibly stretched) framebuffer in the
|
|
// available area so any residual mod-16 margin is split
|
|
// evenly and shows the dark page background, instead of a
|
|
// bare strip on one edge (#251). offsetLeft includes these
|
|
// margins and ignores the fill transform, so the pointer
|
|
// maths (which divides out fillX/fillY) stays correct.
|
|
var fw = sw * fillX, fh = sh * fillY;
|
|
el.style.marginLeft = (panelOffset + Math.max(0, (availW - fw) / 2)) + 'px';
|
|
el.style.marginTop = Math.max(0, (availH - fh) / 2) + 'px';
|
|
}
|
|
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() {
|
|
if (!viewportSettled) return; // embedded, mid-transition: the settle fit follows
|
|
var anyPanelOpen = panelOpen || filePanelOpen;
|
|
var ts = rgTargetSize(anyPanelOpen);
|
|
var sw = ts[0], sh = ts[1];
|
|
if (sizeWouldChange(sw, sh))
|
|
client.sendSize(sw, sh);
|
|
}, 500);
|
|
});
|
|
|
|
if (wantFullscreen) rgStartFullscreenOnConnect();
|
|
var connectData = shareToken ? 'token=' + encodeURIComponent(shareToken) : '';
|
|
client.connect(connectData);
|
|
}
|
|
</script>
|
|
<script src="/ui.js"></script>
|
|
</body>
|
|
</html>
|