mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-24 20:52:24 +00:00
010f41d15a
A busy main thread does not delay pointer movement, it discards it: moves arriving while a handler runs are merged into one, and what is dispatched afterwards carries only the last position. A drag feels that immediately -- the path between two points is gone, so a dragged scrollbar thumb is dropped rather than merely lagged -- while a hover or a video degrades gracefully under the same load. getCoalescedEvents() returns what was merged. Replaying it in order restores the path, though not the timing: nothing running on a blocked thread can do that, and the positions still arrive together once the thread is free. What changes is that the remote end sees where the pointer went rather than only where it ended up. Deliberately additive. The mousemove listener is untouched and still delivers every position it did before, so touch, the compatibility mouse events it produces and the counter that swallows them all behave as they did; the dispatched position arrives twice and the second is dropped by the unchanged-coordinates check already in move(). Migrating this file to pointer events outright is the tidier shape and a far larger change -- every listener would have to move together, or synthetic events from touch would arrive with nothing left counting them down. Only while a button is held, because the path matters for a drag and the endpoint is all that matters for a hover, and replaying every merged move of an idle pointer would multiply a session's upstream mouse traffic for movement nobody can perceive. Capped and evenly sampled, because a pointer reporting at 1000Hz behind a thread blocked for 100ms offers a hundred positions for one drag, and answering a rendering problem with a traffic one is no answer. An ordinary pointer never reaches the cap. The pop-out monitor windows get the same replay. They bypass Guacamole.Mouse entirely -- it does not track the X axis correctly in a popup -- and map the pointer themselves, so a drag on a second head would otherwise lose its path exactly as the first one did. The sampler moves to a static so there is one definition of the rule rather than two that can drift, and that path needs one thing the main display does not: it sends unconditionally, where move() drops an unchanged position, so it keeps what it last sent and drops a repeat -- keyed on the buttons as well as the position, since a press and a release happen at one place and are not repeats of each other. Feature-detected on the event rather than on PointerEvent, because a pop-out window is a realm of its own and the constructor this page can see is not the one that made the event it is handed. That pointer handling lives in Guacamole.MonitorPointer rather than inline in client.html, because these windows cannot be opened without a live multi-monitor session: while it sat in the page the only thing a test could do was match the source text and hope, and the two things most worth checking there are a coordinate mapping and a suppression rule -- exactly the kind that look right in a diff and are off by a term. It takes the canvas, a function returning this monitor's rectangle in the combined framebuffer (a call rather than a captured value, since the layout changes under a running window) and a function to send a state. Writing those tests found a real defect in the wheel, which sends a momentary press and release: it built one state object, sent it, mutated it and sent it again, which works only while every consumer copies synchronously. It sends two states now. Tested rather than tried, because this path only runs once the browser has already thrown something away: the case it exists for is the one that cannot be reliably produced, and a broken version feels like the behaviour it replaced. Twenty tests cover the mapping, the button mask, the repeat suppression, the wheel's reset of it and the replay, plus a harness driving real pointer input through the debugger with the main thread deliberately blocked -- because the unit tests feed events shaped the way the code expects, and that assumption is the one worth checking against a browser. (cherry picked from commit d8ce743fe0801bd8c776281ee5541abf8040a339)
1962 lines
104 KiB
HTML
1962 lines
104 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>rustguac - SSH Session</title>
|
|
<style>
|
|
:root { --primary: #e94560; --primary-hover: #c73652; --accent: #5bc0be; --accent-hover: #4aa3a1; --bg: #1a1a2e; --surface: #16213e; --input: #0f3460; --text: #e0e0e0; --text-muted: #aaa; --border: #333; --text-dim: #888; --text-on-primary: #fff; --btn-disabled: #555; --status-pending: #f0c040; --status-active: #5bc0be; --status-completed: #888; --status-error: #e94560; --status-expired: #666; --type-ssh-bg: #1a3a2a; --type-ssh-fg: #5bc0be; --type-rdp-bg: #2a1a3a; --type-rdp-fg: #a78bfa; --type-vnc-bg: #3a2a1a; --type-vnc-fg: #f0c040; --type-web-bg: #1a2a3a; --type-web-fg: #60a5fa; --hop-bg: #1e3a5f; --hop-fg: #7ec8e3; --bg-pattern: none; }
|
|
html, body {
|
|
margin: 0;
|
|
padding: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
background: #000;
|
|
}
|
|
#display {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
#status {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
padding: 8px 16px;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: var(--text-muted);
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
z-index: 1000;
|
|
transition: opacity 0.5s;
|
|
}
|
|
#status.connected { opacity: 0; pointer-events: none; }
|
|
#banner-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(0, 0, 0, 0.9);
|
|
z-index: 2000;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
#banner-overlay.visible { display: flex; }
|
|
#banner-box {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 2em 3em;
|
|
max-width: 600px;
|
|
text-align: center;
|
|
font-family: monospace;
|
|
color: var(--text);
|
|
}
|
|
#banner-text {
|
|
font-size: 1.1em;
|
|
line-height: 1.6;
|
|
margin-bottom: 1.5em;
|
|
white-space: pre-wrap;
|
|
}
|
|
#banner-continue {
|
|
padding: 0.6em 2em;
|
|
background: var(--primary);
|
|
color: var(--text-on-primary);
|
|
border: none;
|
|
font-family: monospace;
|
|
font-size: 1em;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
#banner-continue:hover { background: var(--primary-hover); }
|
|
#disconnected-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
background: rgba(0, 0, 0, 0.85);
|
|
z-index: 3000;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
#disconnected-overlay.visible { display: flex; }
|
|
#disconnected-box {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 2em 3em;
|
|
max-width: 500px;
|
|
text-align: center;
|
|
font-family: monospace;
|
|
color: var(--text);
|
|
}
|
|
#disconnected-box h2 {
|
|
margin: 0 0 0.5em 0;
|
|
color: var(--text);
|
|
font-size: 1.3em;
|
|
}
|
|
#disconnected-box p {
|
|
color: var(--text-muted);
|
|
margin: 0 0 1.5em 0;
|
|
font-size: 0.95em;
|
|
}
|
|
#disconnected-box button {
|
|
padding: 0.6em 2em;
|
|
border: none;
|
|
font-family: monospace;
|
|
font-size: 1em;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
margin: 0 0.3em;
|
|
}
|
|
#btn-close-session {
|
|
background: var(--primary);
|
|
color: var(--text-on-primary);
|
|
}
|
|
#btn-close-session:hover { background: var(--primary-hover); }
|
|
#btn-reconnect {
|
|
background: var(--input);
|
|
color: var(--text);
|
|
border: 1px solid var(--border) !important;
|
|
}
|
|
#btn-reconnect:hover { background: var(--border); }
|
|
#fs-bar {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0;
|
|
height: 32px;
|
|
background: rgba(20,20,30,0.92);
|
|
color: var(--text);
|
|
border-bottom: 1px solid var(--border);
|
|
z-index: 1500;
|
|
align-items: center;
|
|
padding: 0 12px;
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
transform: translateY(-100%);
|
|
transition: transform 0.25s ease;
|
|
}
|
|
#fs-bar.visible { display: flex; }
|
|
#fs-bar.visible.revealed { transform: translateY(0); }
|
|
#fs-bar-title {
|
|
flex: 1;
|
|
color: var(--text-muted);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
padding-right: 12px;
|
|
}
|
|
#fs-bar-buttons button {
|
|
background: none;
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
border-radius: 3px;
|
|
padding: 3px 10px;
|
|
margin-left: 6px;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
#fs-bar-buttons button:hover { background: var(--input); color: var(--accent); }
|
|
#fs-bar-buttons #fs-disconnect:hover { color: var(--primary); border-color: var(--primary); }
|
|
#fs-esc-notice {
|
|
display: none;
|
|
position: fixed;
|
|
top: 42px;
|
|
right: 12px;
|
|
z-index: 1600;
|
|
background: rgba(0,0,0,0.88);
|
|
color: var(--text-muted);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 8px 12px;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
max-width: 320px;
|
|
}
|
|
</style>
|
|
<script>(function(){var c=localStorage.getItem('rustguac_theme_colors');if(c){try{var o=JSON.parse(c),r=document.documentElement.style;for(var k in o)r.setProperty('--'+k.replace(/_/g,'-'),o[k]);if(o.bg_pattern&&o.bg_pattern!=='none'){var s=document.createElement('style');s.id='bg-pattern-style';s.textContent='body{background-image:'+o.bg_pattern+';background-attachment:fixed}';document.head.appendChild(s)}}catch(e){}}})();</script>
|
|
|
|
<!-- Guacamole common JS modules -->
|
|
<script src="/guac/Namespace.js"></script>
|
|
<script src="/guac/Client.js"></script>
|
|
<script src="/guac/Display.js"></script>
|
|
<script src="/guac/Event.js"></script>
|
|
<script src="/guac/InputSink.js"></script>
|
|
<script src="/guac/IntegerPool.js"></script>
|
|
<script src="/guac/Keyboard.js"></script>
|
|
<script src="/guac/KeyEventInterpreter.js"></script>
|
|
<script src="/guac/Layer.js"></script>
|
|
<script src="/guac/Mouse.js"></script>
|
|
<script src="/guac/MonitorPointer.js"></script>
|
|
<script src="/guac/Parser.js"></script>
|
|
<script src="/guac/Position.js"></script>
|
|
<script src="/guac/Status.js"></script>
|
|
<script src="/guac/Touch.js"></script>
|
|
<script src="/guac/Tunnel.js"></script>
|
|
<script src="/guac/UTF8Parser.js"></script>
|
|
<script src="/guac/InputStream.js"></script>
|
|
<script src="/guac/OutputStream.js"></script>
|
|
<script src="/guac/StringReader.js"></script>
|
|
<script src="/guac/StringWriter.js"></script>
|
|
<script src="/guac/ArrayBufferReader.js"></script>
|
|
<script src="/guac/ArrayBufferWriter.js"></script>
|
|
<script src="/guac/BlobReader.js"></script>
|
|
<script src="/guac/BlobWriter.js"></script>
|
|
<script src="/guac/DataURIReader.js"></script>
|
|
<script src="/guac/AudioContextFactory.js"></script>
|
|
<script src="/guac/AudioPlayer.js"></script>
|
|
<script src="/guac/AudioRecorder.js"></script>
|
|
<script src="/guac/VideoPlayer.js"></script>
|
|
<script src="/guac/Yuv444.js"></script>
|
|
<script src="/guac/H264Decoder.js"></script>
|
|
<script src="/guac/JSONReader.js"></script>
|
|
<script src="/guac/Object.js"></script>
|
|
<script src="/guac/RawAudioFormat.js"></script>
|
|
<script src="/guac/SessionRecording.js"></script>
|
|
</head>
|
|
<body>
|
|
<div id="disconnected-overlay">
|
|
<div id="disconnected-box">
|
|
<h2 id="disconnected-title">Session Ended</h2>
|
|
<p id="disconnected-message">The remote session has been disconnected.</p>
|
|
<div>
|
|
<button id="btn-reconnect">Reconnect</button>
|
|
<button id="btn-close-session">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="banner-overlay">
|
|
<div id="banner-box">
|
|
<div id="banner-text"></div>
|
|
<button id="banner-continue">Continue</button>
|
|
</div>
|
|
</div>
|
|
<div id="status">Loading...</div>
|
|
<div id="fs-bar">
|
|
<div id="fs-bar-title"></div>
|
|
<div id="fs-bar-buttons">
|
|
<button id="fs-exit" title="Exit fullscreen">Exit fullscreen</button>
|
|
<button id="fs-disconnect" title="Disconnect">Disconnect</button>
|
|
</div>
|
|
</div>
|
|
<div id="fs-esc-notice"></div>
|
|
<div id="display"></div>
|
|
|
|
<script>
|
|
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
|
var _themePresets={},_adminPreset='aurora';
|
|
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'aurora';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);}
|
|
var pathParts = window.location.pathname.split('/');
|
|
var sessionId = pathParts[pathParts.length - 1];
|
|
|
|
if (!sessionId) {
|
|
document.getElementById('status').textContent = 'Error: No session ID in URL';
|
|
throw new Error('No session ID');
|
|
}
|
|
|
|
// Whether asking the server for this size would actually change
|
|
// anything, keyed by monitor index.
|
|
//
|
|
// The RDP display patch rounds both dimensions down to a multiple of
|
|
// 16 (patch 007-rdp-disp-mod16), so two viewport sizes inside the same
|
|
// 16px band resolve to the same framebuffer. A resize between them is
|
|
// a no-op on the wire -- but not a free one: a resolution change tears
|
|
// down and rebuilds the browser's hardware H.264 decoder, measured at
|
|
// ~6 seconds of stalled video on D3D11. Dragging a window across a
|
|
// hundred pixels in pauses could buy several of those for nothing.
|
|
//
|
|
// Only the decision is taken on the rounded value; the size sent is
|
|
// the true one, since the client cannot tell an RDP session from a VNC
|
|
// one and a VNC server does no such rounding -- it should not be handed
|
|
// a framebuffer up to 15px short.
|
|
var lastRequestedSize = {};
|
|
|
|
function sizeWouldChange(width, height, index) {
|
|
|
|
var key = (index === undefined || index === null) ? 0 : index;
|
|
var rounded = (width - width % 16) + 'x' + (height - height % 16);
|
|
|
|
if (lastRequestedSize[key] === rounded)
|
|
return false;
|
|
|
|
lastRequestedSize[key] = rounded;
|
|
return true;
|
|
|
|
}
|
|
|
|
var statusEl = document.getElementById('status');
|
|
var urlParams = new URLSearchParams(window.location.search);
|
|
var shareToken = urlParams.get('token');
|
|
var apiKey = sessionStorage.getItem('rustguac_api_key');
|
|
// Pre-minted single-use WebSocket ticket passed in the URL. Lets a
|
|
// headless API integration authenticate the owner connection without an
|
|
// OIDC session or a stored API key: the backend mints it via
|
|
// POST /api/ws-ticket and hands the browser /client/{id}?ticket=...
|
|
var urlTicket = urlParams.get('ticket');
|
|
var entryName = urlParams.get('name');
|
|
// Populated from /api/sessions/:id when the session is backed by a
|
|
// Connections entry. Used by the Reconnect button to launch a fresh
|
|
// session against the same entry instead of reloading a dead one.
|
|
var relaunchInfo = null;
|
|
// #154: fullscreen-on-connect flag + the entry display name we'll
|
|
// show in the in-fullscreen top bar. Populated from the same fetch.
|
|
var entryDisplayName = '';
|
|
var wantFullscreen = false;
|
|
// Per-entry autohide of the clipboard/files side tabs; populated from
|
|
// the same /api/sessions/:id fetch and read when the tabs are built.
|
|
var wantAutohideTabs = false;
|
|
|
|
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
|
|
var siteTitle = d.site_title || 'rustguac';
|
|
document.title = entryName ? (entryName + ' — ' + siteTitle) : (siteTitle + ' - Session');
|
|
initTheme(d.theme);
|
|
});
|
|
|
|
function showBanner(bannerText) {
|
|
if (!bannerText) return Promise.resolve();
|
|
return new Promise(function(resolve) {
|
|
document.getElementById('banner-text').textContent = bannerText;
|
|
var keyMatch = bannerText.match(/ssh-ed25519\s+\S+(\s+\S+)?/);
|
|
if (keyMatch) {
|
|
var copyBtn = document.createElement('button');
|
|
copyBtn.textContent = 'Copy public key';
|
|
copyBtn.style.cssText = 'margin-right:1em;padding:0.4em 1em;background:var(--accent);color:var(--bg);border:none;font-family:monospace;font-size:0.9em;border-radius:4px;cursor:pointer;';
|
|
copyBtn.addEventListener('click', function() {
|
|
var keyText = keyMatch[0];
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(keyText).then(function() {
|
|
copyBtn.textContent = 'Copied!';
|
|
});
|
|
} else {
|
|
var ta = document.createElement('textarea');
|
|
ta.value = keyText;
|
|
ta.style.position = 'fixed';
|
|
ta.style.opacity = '0';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
copyBtn.textContent = 'Copied!';
|
|
}
|
|
});
|
|
document.getElementById('banner-box').insertBefore(copyBtn, document.getElementById('banner-continue'));
|
|
}
|
|
document.getElementById('banner-overlay').className = 'visible';
|
|
document.getElementById('banner-continue').addEventListener('click', function() {
|
|
document.getElementById('banner-overlay').className = '';
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
if (urlTicket) {
|
|
// Headless API integration: the pre-minted ticket authenticates the
|
|
// WebSocket directly. Skip the /api/sessions metadata fetch — it
|
|
// needs its own auth, and routing the one-shot ticket through it
|
|
// would consume it before the WebSocket. Connect straight away.
|
|
startGuacamole();
|
|
} else if (shareToken) {
|
|
fetch('/api/sessions/' + sessionId + '/banner?token=' + encodeURIComponent(shareToken))
|
|
.then(function(res) { return res.ok ? res.json() : null; })
|
|
.then(function(data) { return showBanner(data && data.banner); })
|
|
.then(function() { startGuacamole(); })
|
|
.catch(function() { startGuacamole(); });
|
|
} else {
|
|
var fetchHeaders = {};
|
|
if (apiKey) fetchHeaders['Authorization'] = 'Bearer ' + apiKey;
|
|
fetch('/api/sessions/' + sessionId, { headers: fetchHeaders, credentials: 'same-origin' })
|
|
.then(function(res) {
|
|
if (!res.ok) { statusEl.textContent = 'Error: Session not found'; throw new Error('Session not found'); }
|
|
return res.json();
|
|
})
|
|
.then(function(session) {
|
|
if (session && session.address_book_entry) {
|
|
relaunchInfo = {
|
|
entry_key: session.address_book_entry,
|
|
folder: session.address_book_folder || '',
|
|
display_name: session.entry_display_name || ''
|
|
};
|
|
}
|
|
if (session && session.entry_display_name) entryDisplayName = session.entry_display_name;
|
|
if (session && session.fullscreen_on_connect) wantFullscreen = true;
|
|
if (session && session.autohide_side_tabs) wantAutohideTabs = true;
|
|
return showBanner(session.banner);
|
|
})
|
|
.then(function() { startGuacamole(); })
|
|
.catch(function(err) { statusEl.textContent = 'Error: ' + err.message; });
|
|
}
|
|
|
|
function connectWebSocket(ticketParam) {
|
|
var wsProtocol = (window.location.protocol === 'https:') ? 'wss:' : 'ws:';
|
|
var wsUrl = wsProtocol + '//' + window.location.host + '/ws/' + sessionId;
|
|
if (ticketParam) wsUrl += '?ticket=' + encodeURIComponent(ticketParam);
|
|
|
|
var tunnel = new Guacamole.WebSocketTunnel(wsUrl);
|
|
var client = new Guacamole.Client(tunnel);
|
|
// Expose for console debugging
|
|
window.__guac_client = client;
|
|
setupClient(client, tunnel);
|
|
}
|
|
|
|
function startGuacamole() {
|
|
statusEl.textContent = 'Connecting to session ' + sessionId.substring(0, 8) + '...';
|
|
|
|
// Pre-minted WebSocket ticket from the URL (headless API integration)
|
|
// takes priority: use it directly as the owner credential.
|
|
if (urlTicket) {
|
|
connectWebSocket(urlTicket);
|
|
return;
|
|
}
|
|
|
|
// API key users: exchange key for a single-use ticket before connecting.
|
|
// This keeps the API key out of the WebSocket URL (visible in logs).
|
|
if (apiKey) {
|
|
fetch('/api/ws-ticket', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': 'Bearer ' + apiKey }
|
|
})
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
if (data.ticket) {
|
|
connectWebSocket(data.ticket);
|
|
} else {
|
|
statusEl.textContent = 'Failed to obtain WebSocket ticket';
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
statusEl.textContent = 'Ticket error: ' + err.message;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// OIDC users: connect directly (session cookie handles auth)
|
|
connectWebSocket(null);
|
|
}
|
|
|
|
function setupClient(client, tunnel) {
|
|
|
|
// ── Draw-op diagnostics (#118 black-tile investigation) ──
|
|
// The browser receives exactly what guacd emits, so tapping the
|
|
// instruction stream here shows the server's actual drawing.
|
|
// A ring buffer records recent draw ops (decoded to coordinates)
|
|
// with near-zero overhead and no console noise. After reproducing
|
|
// a black tile, call rustguacDumpDraws() in the console to print
|
|
// the recent history, or rustguacDumpDraws(x, y) to show only ops
|
|
// whose rectangle covers that pixel. Append ?debug=draw to the URL
|
|
// for live per-op logging.
|
|
var DRAW_RING = [];
|
|
var DRAW_RING_MAX = 2000;
|
|
var drawLiveLog = /[?&]debug=draw\b/.test(location.search);
|
|
|
|
// Decode the draw opcodes we care about into {op, layer, rect, note}.
|
|
// Arg orders per the Guacamole protocol.
|
|
function decodeDraw(opcode, a) {
|
|
switch (opcode) {
|
|
case 'rect': // layer,x,y,w,h
|
|
return { op: 'rect', layer: +a[0], rect: [+a[1], +a[2], +a[3], +a[4]] };
|
|
case 'cfill': { // mask,layer,r,g,b,a (fills the current path/rect)
|
|
var r = +a[2], g = +a[3], b = +a[4], al = +a[5];
|
|
return { op: 'cfill', layer: +a[1],
|
|
note: 'rgba(' + r + ',' + g + ',' + b + ',' + al + ')'
|
|
+ (r === 0 && g === 0 && b === 0 && al >= 255 ? ' <BLACK>' : '') };
|
|
}
|
|
case 'copy': // srclayer,srcx,srcy,srcw,srch,mask,dstlayer,dstx,dsty
|
|
return { op: 'copy', layer: +a[6],
|
|
rect: [+a[7], +a[8], +a[3], +a[4]],
|
|
note: 'src L' + a[0] + ' (' + a[1] + ',' + a[2] + ')' };
|
|
case 'img': // stream,mask,layer,mimetype,x,y
|
|
return { op: 'img', layer: +a[2], rect: [+a[4], +a[5], null, null],
|
|
note: a[3] };
|
|
case 'dispose': // layer
|
|
return { op: 'dispose', layer: +a[0] };
|
|
case 'size': // layer,w,h
|
|
return { op: 'size', layer: +a[0], note: a[1] + 'x' + a[2] };
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
var seenOpcodes = {};
|
|
var origOnInstruction = tunnel.oninstruction;
|
|
tunnel.oninstruction = function(opcode, args) {
|
|
if (!seenOpcodes[opcode]) {
|
|
seenOpcodes[opcode] = true;
|
|
if (typeof console !== 'undefined') console.log('[rustguac] instruction: ' + opcode + (args.length ? ' (' + args.length + ' args)' : ''));
|
|
}
|
|
var d = decodeDraw(opcode, args);
|
|
if (d) {
|
|
d.t = Date.now();
|
|
DRAW_RING.push(d);
|
|
if (DRAW_RING.length > DRAW_RING_MAX) DRAW_RING.shift();
|
|
if (drawLiveLog) {
|
|
console.log('[draw]', d.op, 'L' + d.layer,
|
|
d.rect ? '@[' + d.rect.join(',') + ']' : '',
|
|
d.note || '');
|
|
}
|
|
}
|
|
if (origOnInstruction) origOnInstruction(opcode, args);
|
|
};
|
|
|
|
// Dump the recent draw-op history. With (x,y) filters to ops whose
|
|
// rectangle covers that pixel on the default layer — point this at
|
|
// a black tile's top-left to see what last painted there.
|
|
window.rustguacDumpDraws = function(x, y) {
|
|
var rows = DRAW_RING;
|
|
if (typeof x === 'number' && typeof y === 'number') {
|
|
rows = rows.filter(function(d) {
|
|
if (!d.rect) return d.op === 'dispose' || d.op === 'size';
|
|
var rx = d.rect[0], ry = d.rect[1], rw = d.rect[2], rh = d.rect[3];
|
|
if (rw == null) return false;
|
|
return x >= rx && y >= ry && x < rx + rw && y < ry + rh;
|
|
});
|
|
}
|
|
console.log('[rustguac] ' + rows.length + ' draw ops'
|
|
+ (typeof x === 'number' ? ' covering (' + x + ',' + y + ')' : '') + ':');
|
|
console.table(rows.map(function(d) {
|
|
return { op: d.op, layer: d.layer,
|
|
rect: d.rect ? d.rect.join(',') : '',
|
|
note: d.note || '',
|
|
t: new Date(d.t).toISOString().substr(11, 12) };
|
|
}));
|
|
return rows;
|
|
};
|
|
|
|
var displayEl = document.getElementById('display');
|
|
displayEl.appendChild(client.getDisplay().getElement());
|
|
|
|
// ── Resume AudioContext on user interaction (browser autoplay policy) ──
|
|
function resumeAudio() {
|
|
var ctx = Guacamole.AudioContextFactory.getAudioContext();
|
|
if (ctx && ctx.state === 'suspended') {
|
|
ctx.resume().then(function() {
|
|
if (typeof console !== 'undefined') console.log('[rustguac] AudioContext resumed');
|
|
});
|
|
}
|
|
}
|
|
// Try on every interaction type — browsers are strict about this
|
|
['click', 'keydown', 'mousedown', 'touchstart'].forEach(function(evt) {
|
|
document.addEventListener(evt, resumeAudio, true);
|
|
});
|
|
|
|
// ── Clipboard state ──
|
|
var remoteClipboard = '';
|
|
var panelOpen = false;
|
|
var clipboardPanel = null;
|
|
var clipboardTextarea = null;
|
|
var clipboardStatusEl = null;
|
|
|
|
function setClipboardStatus(msg) {
|
|
if (!clipboardStatusEl) return;
|
|
clipboardStatusEl.textContent = msg;
|
|
setTimeout(function() {
|
|
if (clipboardStatusEl && clipboardStatusEl.textContent === msg) clipboardStatusEl.textContent = '';
|
|
}, 3000);
|
|
}
|
|
|
|
// ── Build clipboard panel dynamically ──
|
|
function buildClipboardPanel() {
|
|
if (clipboardPanel) return;
|
|
|
|
clipboardPanel = document.createElement('div');
|
|
clipboardPanel.style.cssText = 'display:none;position:fixed;top:0;left:0;bottom:0;width:380px;background:var(--bg);border-right:2px solid var(--primary);z-index:3000;font-family:monospace;color:var(--text);flex-direction:column;';
|
|
|
|
clipboardPanel.innerHTML =
|
|
'<div style="display:flex;align-items:center;gap:0.6em;padding:0.8em 1em;border-bottom:1px solid var(--border);background:var(--surface);">' +
|
|
'<h3 style="margin:0;flex:1;color:var(--primary);font-size:1.1em;">Session</h3>' +
|
|
'<button id="cp-home" style="background:var(--input);border:1px solid var(--border);color:var(--accent);font-family:monospace;font-size:0.9em;padding:0.35em 0.8em;border-radius:3px;cursor:pointer;" title="Return to the Connections page">🏠 Home</button>' +
|
|
'<button id="cp-fullscreen" style="background:var(--input);border:1px solid var(--border);color:var(--accent);font-family:monospace;font-size:0.9em;padding:0.35em 0.8em;border-radius:3px;cursor:pointer;" title="Enter fullscreen">⛶ Fullscreen</button>' +
|
|
'<button id="cp-close" style="background:none;border:none;color:var(--text-dim);font-size:1.6em;cursor:pointer;font-family:monospace;padding:0 0.3em;">×</button>' +
|
|
'</div>' +
|
|
'<div style="flex:1;display:flex;flex-direction:column;padding:1em;gap:0.8em;overflow-y:auto;min-height:0;">' +
|
|
'<div style="color:var(--text-muted);font-size:0.95em;">Shared clipboard. Paste text below and click Send, or copy text received from the remote session.</div>' +
|
|
'<textarea id="cp-text" style="flex:1;min-height:150px;width:100%;background:var(--input);border:1px solid var(--border);color:var(--text);font-family:monospace;font-size:1em;padding:0.6em;resize:none;border-radius:3px;box-sizing:border-box;" placeholder="Paste text here..."></textarea>' +
|
|
'<div style="display:flex;gap:0.6em;flex-wrap:wrap;">' +
|
|
'<button id="cp-send" style="padding:0.4em 0.8em;background:var(--accent);color:var(--bg);font-weight:bold;border:none;font-family:monospace;font-size:0.95em;border-radius:3px;cursor:pointer;">Send to session</button>' +
|
|
'<button id="cp-copy" style="padding:0.4em 0.8em;background:var(--input);color:var(--accent);border:1px solid var(--border);font-family:monospace;font-size:0.95em;border-radius:3px;cursor:pointer;">Copy to clipboard</button>' +
|
|
'<button id="cp-clear" style="padding:0.4em 0.8em;background:var(--border);color:var(--text-muted);border:none;font-family:monospace;font-size:0.95em;border-radius:3px;cursor:pointer;">Clear</button>' +
|
|
'</div>' +
|
|
'<div id="cp-status" style="color:var(--accent);font-size:0.9em;min-height:1.2em;"></div>' +
|
|
'<div style="color:var(--text-dim);font-size:0.85em;line-height:1.4;">Press <b>Ctrl+Alt+Shift</b> to toggle this panel.<br>Text copied in the remote session appears here automatically.</div>' +
|
|
'</div>';
|
|
|
|
document.body.appendChild(clipboardPanel);
|
|
clipboardTextarea = document.getElementById('cp-text');
|
|
clipboardStatusEl = document.getElementById('cp-status');
|
|
|
|
document.getElementById('cp-close').addEventListener('click', function() {
|
|
toggleClipboardPanel();
|
|
});
|
|
document.getElementById('cp-home').addEventListener('click', function() {
|
|
// Provides the escape hatch for users whose only entry
|
|
// has auto_open_if_singleton set — navigates this tab
|
|
// back to Connections. Session may keep running until
|
|
// socket closes; user can explicitly terminate from
|
|
// Connections' active-session card.
|
|
window.location.href = '/connections.html';
|
|
});
|
|
document.getElementById('cp-fullscreen').addEventListener('click', function() {
|
|
rgEnterFullscreen();
|
|
if (panelOpen) toggleClipboardPanel();
|
|
});
|
|
document.getElementById('cp-send').addEventListener('click', function() {
|
|
if (clipboardTextarea.value) sendClipboardToRemote(clipboardTextarea.value);
|
|
});
|
|
document.getElementById('cp-copy').addEventListener('click', function() {
|
|
var text = clipboardTextarea.value;
|
|
if (!text) return;
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(function() {
|
|
setClipboardStatus('Copied to browser clipboard.');
|
|
}).catch(function() {
|
|
clipboardTextarea.select();
|
|
document.execCommand('copy');
|
|
setClipboardStatus('Copied.');
|
|
});
|
|
} else {
|
|
clipboardTextarea.select();
|
|
document.execCommand('copy');
|
|
setClipboardStatus('Copied.');
|
|
}
|
|
});
|
|
document.getElementById('cp-clear').addEventListener('click', function() {
|
|
clipboardTextarea.value = '';
|
|
remoteClipboard = '';
|
|
});
|
|
}
|
|
|
|
// ── Build toggle tab (always visible on right edge) ──
|
|
var toggleTab = document.createElement('div');
|
|
toggleTab.textContent = '📋 Clipboard';
|
|
toggleTab.title = 'Toggle clipboard panel (Ctrl+Alt+Shift)';
|
|
toggleTab.style.cssText = 'position:fixed;left:-1px;top:50%;transform:translateY(-50%);writing-mode:vertical-rl;background:var(--primary);color:var(--text-on-primary);padding:0.6em 0.3em;font-family:monospace;font-size:0.75em;cursor:pointer;z-index:3001;border-radius:0 4px 4px 0;opacity:0.6;letter-spacing:0.1em;';
|
|
toggleTab.addEventListener('mouseenter', function() { toggleTab.style.opacity = '1'; });
|
|
toggleTab.addEventListener('mouseleave', function() { toggleTab.style.opacity = '0.6'; });
|
|
toggleTab.addEventListener('click', function() { toggleClipboardPanel(); });
|
|
document.body.appendChild(toggleTab);
|
|
|
|
// ── Multi-monitor (A1: receive + log; per-monitor windows are A2) ──
|
|
// Max monitors the server allows, delivered via a "secondary-monitors"
|
|
// argv stream. secondary-monitors = N means N+1 monitors total.
|
|
var maxMonitors = 1;
|
|
var monitorLayout = {}; // index -> {left, top, width, height} in the combined framebuffer
|
|
var monitorWindows = {}; // index -> { win, canvas, ctx, mouse, keyboard, running }
|
|
client.onargv = function(stream, mimetype, name) {
|
|
var reader = new Guacamole.StringReader(stream);
|
|
var buf = '';
|
|
reader.ontext = function(t) { buf += t; };
|
|
reader.onend = function() {
|
|
if (name !== 'secondary-monitors') return;
|
|
var secondary = parseInt(buf, 10);
|
|
if (!isNaN(secondary)) maxMonitors = secondary + 1;
|
|
console.log('[rustguac] multimon: server allows ' + maxMonitors
|
|
+ ' monitor(s) (secondary-monitors=' + buf.trim() + ')');
|
|
// Refresh the Controls panel's monitor section if it's built.
|
|
if (typeof renderMonitorControls === 'function') renderMonitorControls();
|
|
};
|
|
};
|
|
// Current monitor layout (each monitor's rect within the combined
|
|
// framebuffer), JSON via a "multimon-layout" set param.
|
|
client.onmultimonlayout = function(layer, value) {
|
|
try {
|
|
monitorLayout = JSON.parse(value);
|
|
} catch (e) {
|
|
console.warn('[rustguac] multimon-layout parse error:', e, value);
|
|
return;
|
|
}
|
|
console.log('[rustguac] multimon-layout:', monitorLayout);
|
|
// Reflect the newly-active monitors in the Controls panel. Open
|
|
// windows read monitorLayout[index] each frame, so no per-window
|
|
// update is needed here.
|
|
if (typeof renderMonitorControls === 'function') renderMonitorControls();
|
|
// Re-crop/scale the main window to monitor 0 now that the layout
|
|
// (and hence which region the main window shows) may have changed.
|
|
if (typeof scaleDisplay === 'function') scaleDisplay();
|
|
};
|
|
|
|
// ── Receive clipboard from remote ──
|
|
client.onclipboard = function(stream, mimetype) {
|
|
if (mimetype !== 'text/plain') {
|
|
var r = new Guacamole.StringReader(stream);
|
|
r.ontext = function() {};
|
|
r.onend = function() {};
|
|
return;
|
|
}
|
|
var data = '';
|
|
var reader = new Guacamole.StringReader(stream);
|
|
reader.ontext = function(text) { data += text; };
|
|
reader.onend = function() {
|
|
remoteClipboard = data;
|
|
if (panelOpen && clipboardTextarea) clipboardTextarea.value = data;
|
|
if (navigator.clipboard && navigator.clipboard.writeText && document.hasFocus()) {
|
|
navigator.clipboard.writeText(data).then(function() {
|
|
setClipboardStatus('Remote clipboard synced.');
|
|
}).catch(function() {});
|
|
}
|
|
};
|
|
};
|
|
|
|
// ── Send clipboard to remote ──
|
|
function sendClipboardToRemote(text) {
|
|
var stream = client.createClipboardStream('text/plain');
|
|
var writer = new Guacamole.StringWriter(stream);
|
|
for (var i = 0; i < text.length; i += 4096) {
|
|
writer.sendText(text.substring(i, i + 4096));
|
|
}
|
|
writer.sendEnd();
|
|
setClipboardStatus('Sent to session (' + text.length + ' chars).');
|
|
}
|
|
|
|
// ── Toggle panel ──
|
|
function toggleClipboardPanel() {
|
|
panelOpen = !panelOpen;
|
|
if (panelOpen) {
|
|
buildClipboardPanel();
|
|
clipboardPanel.style.display = 'flex';
|
|
clipboardTextarea.value = remoteClipboard;
|
|
clipboardTextarea.focus();
|
|
toggleTab.style.display = 'none';
|
|
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
navigator.clipboard.readText().then(function(text) {
|
|
if (text && text !== remoteClipboard) clipboardTextarea.value = text;
|
|
}).catch(function() {});
|
|
}
|
|
} else {
|
|
if (clipboardPanel) clipboardPanel.style.display = 'none';
|
|
toggleTab.style.display = '';
|
|
displayEl.focus();
|
|
keyboard.reset();
|
|
}
|
|
scaleDisplay();
|
|
}
|
|
|
|
// ── Auto-sync clipboard on window focus ──
|
|
window.addEventListener('focus', function() {
|
|
if (!panelOpen && navigator.clipboard && navigator.clipboard.readText) {
|
|
navigator.clipboard.readText().then(function(text) {
|
|
if (text && text !== remoteClipboard) {
|
|
sendClipboardToRemote(text);
|
|
remoteClipboard = text;
|
|
}
|
|
}).catch(function() {});
|
|
}
|
|
});
|
|
|
|
// ── File Manager state ──
|
|
var filesystem = null;
|
|
var filePanelOpen = false;
|
|
var filePanel = null;
|
|
var fileListEl = null;
|
|
var fileBreadcrumb = null;
|
|
var fileStatusEl = null;
|
|
var currentPath = '/';
|
|
var cachedListings = {}; // Cache directory listings from unsolicited body responses
|
|
|
|
// ── Build file manager toggle tab (hidden until filesystem available) ──
|
|
var fileTab = document.createElement('div');
|
|
fileTab.textContent = 'Files';
|
|
fileTab.title = 'Toggle file manager';
|
|
fileTab.style.cssText = 'display:none;position:fixed;left:-1px;top:calc(50% + 60px);transform:translateY(-50%);writing-mode:vertical-rl;background:var(--accent);color:var(--text-on-primary);padding:0.6em 0.3em;font-family:monospace;font-size:0.75em;cursor:pointer;z-index:3001;border-radius:0 4px 4px 0;opacity:0.6;letter-spacing:0.1em;font-weight:bold;';
|
|
fileTab.addEventListener('mouseenter', function() { fileTab.style.opacity = '1'; });
|
|
fileTab.addEventListener('mouseleave', function() { fileTab.style.opacity = '0.6'; });
|
|
fileTab.addEventListener('click', function() { toggleFilePanel(); });
|
|
document.body.appendChild(fileTab);
|
|
|
|
// ── Controls side panel (send-keys now; monitor selection next) ──
|
|
var controlsTab = document.createElement('div');
|
|
controlsTab.textContent = '⌨ Controls';
|
|
controlsTab.title = 'Send key combinations and select monitors';
|
|
controlsTab.style.cssText = 'position:fixed;left:-1px;top:calc(50% + 120px);transform:translateY(-50%);writing-mode:vertical-rl;background:var(--primary);color:var(--text-on-primary);padding:0.6em 0.3em;font-family:monospace;font-size:0.75em;cursor:pointer;z-index:3001;border-radius:0 4px 4px 0;opacity:0.6;letter-spacing:0.1em;';
|
|
controlsTab.addEventListener('mouseenter', function() { controlsTab.style.opacity = '1'; });
|
|
controlsTab.addEventListener('mouseleave', function() { controlsTab.style.opacity = '0.6'; });
|
|
controlsTab.addEventListener('click', function() { toggleControlsPanel(); });
|
|
document.body.appendChild(controlsTab);
|
|
|
|
var controlsPanel = null, controlsPanelOpen = false;
|
|
|
|
// Press each keysym in order, then release in reverse. Used for
|
|
// combos the browser/OS would otherwise intercept (Ctrl+Alt+Del).
|
|
function sendKeyCombo(keys) {
|
|
if (!client) return;
|
|
for (var i = 0; i < keys.length; i++) client.sendKeyEvent(1, keys[i]);
|
|
for (var j = keys.length - 1; j >= 0; j--) client.sendKeyEvent(0, keys[j]);
|
|
}
|
|
|
|
// X11 keysyms for the send-keys buttons (Guacamole uses keysyms).
|
|
var SEND_KEYS = [
|
|
{ label: 'Ctrl+Alt+Del', keys: [0xFFE3, 0xFFE9, 0xFFFF] },
|
|
{ label: 'Ctrl+Alt+Bksp', keys: [0xFFE3, 0xFFE9, 0xFF08] },
|
|
{ label: 'Alt+F4', keys: [0xFFE9, 0xFFC1] },
|
|
{ label: 'Alt+Tab', keys: [0xFFE9, 0xFF09] },
|
|
{ label: 'Win', keys: [0xFFEB] },
|
|
{ label: 'Esc', keys: [0xFF1B] },
|
|
{ label: 'PrtSc', keys: [0xFF61] }
|
|
];
|
|
|
|
function buildControlsPanel() {
|
|
if (controlsPanel) return;
|
|
controlsPanel = document.createElement('div');
|
|
controlsPanel.style.cssText = 'display:none;position:fixed;top:0;left:0;bottom:0;width:300px;background:var(--bg);border-right:2px solid var(--primary);z-index:3000;font-family:monospace;color:var(--text);flex-direction:column;overflow-y:auto;';
|
|
controlsPanel.innerHTML =
|
|
'<div style="display:flex;align-items:center;padding:0.8em 1em;border-bottom:1px solid var(--border);background:var(--surface);">' +
|
|
'<h3 style="margin:0;flex:1;color:var(--primary);font-size:1.1em;">Controls</h3>' +
|
|
'<button id="ctrl-close" style="background:none;border:none;color:var(--text-dim);font-size:1.6em;cursor:pointer;font-family:monospace;padding:0 0.3em;">×</button>' +
|
|
'</div>' +
|
|
'<div style="padding:0.8em 1em;">' +
|
|
'<div style="color:var(--text-muted);font-size:0.85em;margin-bottom:0.5em;letter-spacing:0.05em;">SEND KEYS</div>' +
|
|
'<div id="ctrl-keys" style="display:flex;flex-wrap:wrap;gap:0.4em;"></div>' +
|
|
'</div>' +
|
|
'<div style="padding:0.8em 1em;border-top:1px solid var(--border);">' +
|
|
'<div style="color:var(--text-muted);font-size:0.85em;margin-bottom:0.5em;letter-spacing:0.05em;">MONITORS</div>' +
|
|
'<div id="ctrl-monitors"></div>' +
|
|
'</div>';
|
|
document.body.appendChild(controlsPanel);
|
|
document.getElementById('ctrl-close').addEventListener('click', function() { toggleControlsPanel(); });
|
|
var keysEl = document.getElementById('ctrl-keys');
|
|
SEND_KEYS.forEach(function(k) {
|
|
var b = document.createElement('button');
|
|
b.textContent = k.label;
|
|
b.style.cssText = 'padding:0.4em 0.7em;background:var(--input);color:var(--primary);border:1px solid var(--border);font-family:monospace;font-size:0.85em;border-radius:3px;cursor:pointer;';
|
|
b.addEventListener('click', function() { sendKeyCombo(k.keys); });
|
|
keysEl.appendChild(b);
|
|
});
|
|
renderMonitorControls();
|
|
}
|
|
|
|
function toggleControlsPanel() {
|
|
buildControlsPanel();
|
|
controlsPanelOpen = !controlsPanelOpen;
|
|
controlsPanel.style.display = controlsPanelOpen ? 'flex' : 'none';
|
|
if (controlsPanelOpen) renderMonitorControls();
|
|
}
|
|
|
|
// Monitors section — one tick-box per allowed monitor. Monitor 1 is
|
|
// the primary (always on, in this window). Ticking a secondary opens
|
|
// it in its own window; monitors must stay contiguous, so monitor N
|
|
// can only be toggled when monitor N-1 is active.
|
|
function renderMonitorControls() {
|
|
if (!controlsPanel) return;
|
|
var el = document.getElementById('ctrl-monitors');
|
|
if (!el) return;
|
|
if (maxMonitors <= 1) {
|
|
el.innerHTML = '<div style="color:var(--text-dim);font-size:0.85em;">This connection offers a single monitor.</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = '';
|
|
var isActive = function(i) { return i === 0 || !!monitorWindows[i]; };
|
|
for (var i = 0; i < maxMonitors; i++) {
|
|
(function(idx) {
|
|
var row = document.createElement('label');
|
|
row.style.cssText = 'display:flex;align-items:center;gap:0.5em;padding:0.25em 0;font-size:0.9em;cursor:pointer;';
|
|
var cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.checked = isActive(idx);
|
|
cb.disabled = (idx === 0) || !isActive(idx - 1);
|
|
cb.addEventListener('change', function() { toggleMonitor(idx, cb.checked); });
|
|
row.appendChild(cb);
|
|
var span = document.createElement('span');
|
|
span.textContent = (idx === 0) ? 'Monitor 1 (primary)' : ('Monitor ' + (idx + 1));
|
|
row.appendChild(span);
|
|
el.appendChild(row);
|
|
})(i);
|
|
}
|
|
var hint = document.createElement('div');
|
|
hint.style.cssText = 'color:var(--text-dim);font-size:0.75em;margin-top:0.4em;';
|
|
hint.textContent = 'Extra monitors open in their own window. Drag each to a physical display and press F11 to fullscreen.';
|
|
el.appendChild(hint);
|
|
}
|
|
|
|
function toggleMonitor(index, enable) {
|
|
if (index === 0) return; // primary is always on, in this window
|
|
if (enable) openMonitorWindow(index);
|
|
else closeMonitorWindow(index);
|
|
}
|
|
|
|
// Open a secondary monitor in its own browser window: request the
|
|
// monitor from the server, blit its region of the combined
|
|
// framebuffer each frame, and forward mouse/keyboard in combined
|
|
// coordinates.
|
|
function openMonitorWindow(index) {
|
|
if (monitorWindows[index]) return;
|
|
var win = window.open('', 'rustguac-mon-' + index,
|
|
'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no');
|
|
if (!win) {
|
|
alert('Popup blocked. Allow popups for this site to open an extra monitor.');
|
|
renderMonitorControls();
|
|
return;
|
|
}
|
|
win.document.title = 'Monitor ' + (index + 1);
|
|
win.document.body.style.cssText = 'margin:0;background:#000;overflow:hidden;';
|
|
var canvas = win.document.createElement('canvas');
|
|
// Stretch the monitor to fill the popup window. The mouse maps
|
|
// from the canvas's live on-screen rect (getBoundingClientRect),
|
|
// so whatever size the window is, clicks map correctly; the guest
|
|
// is asked (via sendSize below) to adopt the window's resolution,
|
|
// which keeps the aspect ratio right.
|
|
canvas.style.cssText = 'display:block;width:100%;height:100%;';
|
|
win.document.body.appendChild(canvas);
|
|
var ctx = canvas.getContext('2d');
|
|
var rec = { win: win, canvas: canvas, ctx: ctx, running: true,
|
|
mouse: null, keyboard: null };
|
|
monitorWindows[index] = rec;
|
|
|
|
// Ask the server for this monitor at the window's inner size
|
|
// (SPICE wants width as a multiple of 8). Contiguous index. Guard
|
|
// against a not-yet-laid-out popup reporting 0 (a positive size is
|
|
// required or the server reads it as a monitor-close).
|
|
var reqW = ((win.innerWidth || 1280) & ~0x7) || 1280;
|
|
var reqH = win.innerHeight || 800;
|
|
if (reqW < 8) reqW = 1280;
|
|
if (reqH < 8) reqH = 800;
|
|
client.sendSize(reqW, reqH, index, 0);
|
|
|
|
// Per-frame blit of this monitor's region from the combined
|
|
// framebuffer (the default layer's canvas). During a resize the
|
|
// client can briefly hold a new layout rect while the source
|
|
// canvas is still the old size (or vice versa); blitting the
|
|
// out-of-range region then produces garbage. Guard by only
|
|
// drawing the portion of this monitor's rect that actually lies
|
|
// within the current source canvas, and skip the frame entirely
|
|
// if the layout and source are momentarily inconsistent.
|
|
var srcLayer = client.getDisplay().getDefaultLayer();
|
|
(function frame() {
|
|
if (!rec.running || win.closed) return;
|
|
win.requestAnimationFrame(frame);
|
|
var rect = monitorLayout[index];
|
|
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
|
var src = srcLayer && srcLayer.getCanvas ? srcLayer.getCanvas() : null;
|
|
if (!src || !src.width || !src.height) return;
|
|
if (rect.left < 0 || rect.top < 0) return;
|
|
|
|
// Portion of this monitor's rect available in the source now.
|
|
var sw = Math.min(rect.width, src.width - rect.left);
|
|
var sh = Math.min(rect.height, src.height - rect.top);
|
|
if (sw <= 0 || sh <= 0) return; // rect not yet within source
|
|
|
|
if (canvas.width !== rect.width || canvas.height !== rect.height) {
|
|
canvas.width = rect.width;
|
|
canvas.height = rect.height;
|
|
}
|
|
try {
|
|
ctx.drawImage(src, rect.left, rect.top, sw, sh, 0, 0, sw, sh);
|
|
} catch (e) { /* source not ready this frame */ }
|
|
})();
|
|
|
|
// Re-fit on popup resize and, after a short debounce, ask the
|
|
// server for this monitor at the new size (a resizable guest
|
|
// adopts it; a fixed-resolution console just keeps scaling to fit).
|
|
var popResizeTimer = null;
|
|
win.addEventListener('resize', function() {
|
|
if (popResizeTimer) win.clearTimeout(popResizeTimer);
|
|
popResizeTimer = win.setTimeout(function() {
|
|
var w = (win.innerWidth || 0) & ~0x7;
|
|
var h = win.innerHeight || 0;
|
|
if (w >= 8 && h >= 8 && 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);
|
|
};
|
|
};
|
|
|
|
function tracing_log(msg) {
|
|
if (typeof console !== 'undefined') console.log('[rustguac] ' + msg);
|
|
}
|
|
|
|
// Also support drag-drop on the display
|
|
displayEl.addEventListener('dragover', function(e) {
|
|
if (filesystem) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }
|
|
});
|
|
displayEl.addEventListener('drop', function(e) {
|
|
if (!filesystem) return;
|
|
e.preventDefault();
|
|
for (var i = 0; i < e.dataTransfer.files.length; i++) {
|
|
uploadFile(e.dataTransfer.files[i]);
|
|
}
|
|
});
|
|
|
|
// ── State changes ──
|
|
// ── Thumbnail capture (owner only, not shared viewers) ──
|
|
var _thumbInterval = null;
|
|
function captureAndUploadThumbnail() {
|
|
try {
|
|
var display = client.getDisplay();
|
|
var canvas = display.flatten();
|
|
if (!canvas || canvas.width === 0) return;
|
|
// Scale down to 320px wide
|
|
var thumb = document.createElement('canvas');
|
|
thumb.width = 320;
|
|
thumb.height = Math.round(320 * canvas.height / canvas.width) || 180;
|
|
thumb.getContext('2d').drawImage(canvas, 0, 0, thumb.width, thumb.height);
|
|
thumb.toBlob(function(blob) {
|
|
if (!blob) return;
|
|
var headers = {};
|
|
if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey;
|
|
fetch('/api/sessions/' + sessionId + '/thumbnail', {
|
|
method: 'PUT',
|
|
headers: headers,
|
|
credentials: 'same-origin',
|
|
body: blob
|
|
}).catch(function() {}); // fire and forget
|
|
}, 'image/jpeg', 0.6);
|
|
} catch(e) { /* ignore capture errors */ }
|
|
}
|
|
|
|
// ── Fullscreen (#154) ──
|
|
// The browser API requires a fresh user gesture to enter
|
|
// fullscreen, and navigation from connections.html consumes
|
|
// the prior gesture. So when the entry is marked
|
|
// fullscreen-on-connect we don't try to fire on page load.
|
|
// Instead we arm a one-shot listener for the next mousedown
|
|
// or keydown after CONNECTED. The Ctrl+Alt+Shift menu's
|
|
// fullscreen button uses its own click gesture and works at
|
|
// any time once shown.
|
|
function rgFullscreenEl() {
|
|
return document.fullscreenElement
|
|
|| document.webkitFullscreenElement
|
|
|| document.mozFullScreenElement
|
|
|| document.msFullscreenElement || null;
|
|
}
|
|
function rgEnterFullscreen() {
|
|
var el = document.documentElement;
|
|
var req = el.requestFullscreen
|
|
|| el.webkitRequestFullscreen
|
|
|| el.mozRequestFullScreen
|
|
|| el.msRequestFullscreen;
|
|
if (!req) return;
|
|
var p;
|
|
try { p = req.call(el); } catch (e) { return; }
|
|
var onOk = function() {
|
|
if (navigator.keyboard && navigator.keyboard.lock) {
|
|
navigator.keyboard.lock(['Escape']).catch(rgShowEscNoticeOnce);
|
|
} else {
|
|
rgShowEscNoticeOnce();
|
|
}
|
|
};
|
|
if (p && p.then) p.then(onOk).catch(function(){}); else onOk();
|
|
}
|
|
function rgExitFullscreen() {
|
|
try { if (navigator.keyboard && navigator.keyboard.unlock) navigator.keyboard.unlock(); } catch (e) {}
|
|
var x = document.exitFullscreen
|
|
|| document.webkitExitFullscreen
|
|
|| document.mozCancelFullScreen
|
|
|| document.msExitFullscreen;
|
|
if (x) { try { x.call(document); } catch (e) {} }
|
|
}
|
|
function rgArmFullscreenGesture() {
|
|
var fire = function() {
|
|
document.removeEventListener('mousedown', fire, true);
|
|
document.removeEventListener('keydown', fire, true);
|
|
rgEnterFullscreen();
|
|
};
|
|
document.addEventListener('mousedown', fire, true);
|
|
document.addEventListener('keydown', fire, true);
|
|
}
|
|
function rgShowEscNoticeOnce() {
|
|
if (sessionStorage.getItem('rustguac_esc_notice_seen')) return;
|
|
sessionStorage.setItem('rustguac_esc_notice_seen', '1');
|
|
var t = document.getElementById('fs-esc-notice');
|
|
if (!t) return;
|
|
t.textContent = 'Press Esc to exit fullscreen in this browser.';
|
|
t.style.display = 'block';
|
|
setTimeout(function() { t.style.display = 'none'; }, 6000);
|
|
}
|
|
// Auto-hide top bar: show on enter for a few seconds, then
|
|
// slide up out of view to free the remote's top edge (xfce4
|
|
// panels, Windows taskbar, etc). Reveals again when the mouse
|
|
// approaches the top of the screen, hides again when it
|
|
// leaves the bar area.
|
|
var rgBarHideTimer = null;
|
|
function rgRevealBar() {
|
|
var bar = document.getElementById('fs-bar');
|
|
if (!bar || !bar.classList.contains('visible')) return;
|
|
bar.classList.add('revealed');
|
|
if (rgBarHideTimer) { clearTimeout(rgBarHideTimer); rgBarHideTimer = null; }
|
|
}
|
|
function rgScheduleBarHide(delayMs) {
|
|
if (rgBarHideTimer) clearTimeout(rgBarHideTimer);
|
|
rgBarHideTimer = setTimeout(function() {
|
|
var bar = document.getElementById('fs-bar');
|
|
if (bar) bar.classList.remove('revealed');
|
|
rgBarHideTimer = null;
|
|
}, delayMs);
|
|
}
|
|
function rgOnFullscreenMouseMove(e) {
|
|
if (!rgFullscreenEl()) return;
|
|
var bar = document.getElementById('fs-bar');
|
|
if (!bar) return;
|
|
// Top 4px: reveal (and cancel any pending hide).
|
|
// Over the bar area (<= 36px): keep revealed, cancel hide.
|
|
// Below the bar with a small buffer (> 48px): schedule
|
|
// hide. The gap between 36 and 48 is hysteresis so a
|
|
// jittering pointer doesn't flicker the bar.
|
|
if (e.clientY <= 4) {
|
|
rgRevealBar();
|
|
} else if (e.clientY <= 36) {
|
|
if (rgBarHideTimer) { clearTimeout(rgBarHideTimer); rgBarHideTimer = null; }
|
|
} else if (bar.classList.contains('revealed') && e.clientY > 48) {
|
|
// Only arm if no hide is already pending. Without this
|
|
// gate, every mousemove in the session resets the
|
|
// countdown and the bar never hides.
|
|
if (!rgBarHideTimer) rgScheduleBarHide(600);
|
|
}
|
|
}
|
|
function rgOnFullscreenChange() {
|
|
var inFs = !!rgFullscreenEl();
|
|
var bar = document.getElementById('fs-bar');
|
|
if (bar) {
|
|
if (inFs) {
|
|
bar.classList.add('visible');
|
|
// Show for a moment so the user knows it's there,
|
|
// then auto-hide.
|
|
setTimeout(rgRevealBar, 50);
|
|
rgScheduleBarHide(2500);
|
|
document.addEventListener('mousemove', rgOnFullscreenMouseMove);
|
|
} else {
|
|
bar.classList.remove('visible');
|
|
bar.classList.remove('revealed');
|
|
if (rgBarHideTimer) { clearTimeout(rgBarHideTimer); rgBarHideTimer = null; }
|
|
document.removeEventListener('mousemove', rgOnFullscreenMouseMove);
|
|
}
|
|
}
|
|
}
|
|
['fullscreenchange','webkitfullscreenchange','mozfullscreenchange','MSFullscreenChange']
|
|
.forEach(function(ev){ document.addEventListener(ev, rgOnFullscreenChange); });
|
|
document.getElementById('fs-exit').addEventListener('click', rgExitFullscreen);
|
|
document.getElementById('fs-disconnect').addEventListener('click', function() {
|
|
rgExitFullscreen();
|
|
try { if (window.__guac_client) window.__guac_client.disconnect(); } catch (e) {}
|
|
window.location.href = '/connections.html';
|
|
});
|
|
|
|
client.onstatechange = function(state) {
|
|
switch (state) {
|
|
case Guacamole.Client.State.CONNECTING: statusEl.textContent = 'Connecting...'; statusEl.className = ''; break;
|
|
case Guacamole.Client.State.WAITING: statusEl.textContent = 'Waiting for server...'; statusEl.className = ''; break;
|
|
case Guacamole.Client.State.CONNECTED:
|
|
statusEl.textContent = 'Connected'; statusEl.className = 'connected';
|
|
// Fit the session to the actual window on connect. The session
|
|
// may have been created at different dimensions (e.g. opened in a
|
|
// new window at a fixed size), and the client otherwise only sends
|
|
// a size on a window-resize event, so the user has to resize
|
|
// manually to get the right fit on first connect. A single early
|
|
// send loses a race: right after CONNECTED the resize path is not
|
|
// ready yet (RDP's Display Control channel may not be connected;
|
|
// SPICE's agent/display-ready gate may be unmet), so the size is
|
|
// silently dropped. Retry a few times past that window — guacd
|
|
// coalesces to a single resize since the requested size is
|
|
// unchanged, so the redundant sends are cheap no-ops.
|
|
// A fresh connection knows nothing about what the
|
|
// server is already displaying.
|
|
lastRequestedSize = {};
|
|
(function() {
|
|
var fitToWindow = function() {
|
|
scaleDisplay();
|
|
var anyPanelOpen = panelOpen || filePanelOpen;
|
|
var sw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth;
|
|
var sh = window.innerHeight;
|
|
if (sw > 0 && sh > 0) {
|
|
// Sent whether or not it would change
|
|
// anything: the repeats above are
|
|
// deliberate, covering a window where the
|
|
// server drops the size silently, and
|
|
// suppressing them would defeat that.
|
|
// Recorded all the same, so that a user
|
|
// resize inside the same 16px band
|
|
// afterwards is correctly skipped.
|
|
sizeWouldChange(sw, sh);
|
|
client.sendSize(sw, sh);
|
|
}
|
|
};
|
|
setTimeout(fitToWindow, 800);
|
|
setTimeout(fitToWindow, 2000);
|
|
setTimeout(fitToWindow, 4000);
|
|
})();
|
|
// Start thumbnail capture for session owner
|
|
if (!shareToken && !_thumbInterval) {
|
|
_thumbInterval = setInterval(captureAndUploadThumbnail, 10000);
|
|
// First capture after 3s (let display render)
|
|
setTimeout(captureAndUploadThumbnail, 3000);
|
|
}
|
|
document.getElementById('fs-bar-title').textContent =
|
|
entryDisplayName || ('Session ' + sessionId.substring(0,8));
|
|
if (wantFullscreen) rgArmFullscreenGesture();
|
|
break;
|
|
case Guacamole.Client.State.DISCONNECTING:
|
|
statusEl.textContent = 'Disconnecting...'; statusEl.className = '';
|
|
// Final thumbnail capture before disconnect
|
|
if (_thumbInterval) {
|
|
clearInterval(_thumbInterval);
|
|
_thumbInterval = null;
|
|
captureAndUploadThumbnail();
|
|
}
|
|
break;
|
|
case Guacamole.Client.State.DISCONNECTED:
|
|
statusEl.textContent = 'Disconnected'; statusEl.className = '';
|
|
if (_thumbInterval) { clearInterval(_thumbInterval); _thumbInterval = null; }
|
|
document.getElementById('disconnected-overlay').className = 'visible';
|
|
if (rgFullscreenEl()) rgExitFullscreen();
|
|
// Reflect the disconnect in any open monitor pop-outs
|
|
// (freeze the stale blit + show a "Disconnected" overlay).
|
|
teardownMonitorWindows('disconnect');
|
|
break;
|
|
}
|
|
};
|
|
|
|
client.onerror = function(status) {
|
|
statusEl.textContent = 'Error: ' + (status.message || 'Unknown error');
|
|
statusEl.className = '';
|
|
// Stop the 10s thumbnail uploader; the session is dead and
|
|
// each XHR would otherwise 404 against a cleaned-up id.
|
|
if (_thumbInterval) { clearInterval(_thumbInterval); _thumbInterval = null; }
|
|
document.getElementById('disconnected-title').textContent = 'Connection Error';
|
|
document.getElementById('disconnected-message').textContent = status.message || 'An error occurred.';
|
|
document.getElementById('disconnected-overlay').className = 'visible';
|
|
if (rgFullscreenEl()) rgExitFullscreen();
|
|
};
|
|
|
|
// Wire tunnel events back into the client. Upstream Guacamole's
|
|
// Client.js deliberately doesn't listen on tunnel.onerror /
|
|
// tunnel.onstatechange — the Apache webapp installs those
|
|
// handlers externally via AngularJS. Our lean client.html
|
|
// inherits Tunnel.js + Client.js from upstream but used to
|
|
// miss this glue, so when a mid-path middlebox silently
|
|
// dropped the WS and socket.onclose fired, the tunnel would
|
|
// transition to CLOSED, fire onerror into the void, and the
|
|
// Guacamole client would stay stuck in CONNECTED forever.
|
|
// Effect: the user saw a frozen tab, no "Session Ended"
|
|
// overlay, and the browser kept uploading thumbnails into
|
|
// 404 responses for hours. Forwarding tunnel events into the
|
|
// existing client.onerror closes that loop.
|
|
tunnel.onerror = function(status) {
|
|
if (typeof console !== 'undefined')
|
|
console.log('[rustguac] tunnel error:',
|
|
status && status.message,
|
|
'code=' + (status && status.code));
|
|
if (client.onerror) client.onerror(status || {
|
|
code: 519,
|
|
message: 'Connection lost'
|
|
});
|
|
};
|
|
tunnel.onstatechange = function(state) {
|
|
if (state === Guacamole.Tunnel.State.CLOSED) {
|
|
if (typeof console !== 'undefined')
|
|
console.log('[rustguac] tunnel closed');
|
|
if (client.onerror) client.onerror({
|
|
code: 519,
|
|
message: 'Connection lost'
|
|
});
|
|
} else if (state === Guacamole.Tunnel.State.UNSTABLE) {
|
|
if (typeof console !== 'undefined')
|
|
console.log('[rustguac] tunnel unstable');
|
|
statusEl.textContent = 'Connection unstable...';
|
|
}
|
|
};
|
|
|
|
// Disconnected overlay buttons
|
|
document.getElementById('btn-close-session').addEventListener('click', function() {
|
|
window.close();
|
|
// window.close() may be blocked if not opened by script — navigate away as fallback
|
|
window.location.href = '/';
|
|
});
|
|
// Reconnect launches a fresh session against the same Connections
|
|
// entry, then navigates to it. Reloading the dead session URL
|
|
// would just re-trigger the overlay because the original session
|
|
// transitioned to Completed the moment the WebSocket closed.
|
|
document.getElementById('btn-reconnect').addEventListener('click', function() {
|
|
var btn = this;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Reconnecting...';
|
|
|
|
// Share-token / shadow viewers can't mint a new session.
|
|
// Reload keeps current behaviour (it will fail the same way,
|
|
// but they have no other recourse from this page).
|
|
if (shareToken) {
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
// Ad-hoc sessions have no entry to relaunch from. Send the
|
|
// user back to Connections so they can pick where to go.
|
|
if (!relaunchInfo) {
|
|
window.location.href = '/connections.html';
|
|
return;
|
|
}
|
|
|
|
// Composite key is "scope/folder/entry". Folder may itself
|
|
// contain slashes; entry names cannot (validated server-side
|
|
// in vault.rs validate_name), so first/last segment splits
|
|
// cleanly recover scope and entry.
|
|
var key = relaunchInfo.entry_key;
|
|
var firstSlash = key.indexOf('/');
|
|
var lastSlash = key.lastIndexOf('/');
|
|
if (firstSlash < 0 || lastSlash <= firstSlash) {
|
|
window.location.href = '/connections.html';
|
|
return;
|
|
}
|
|
var scope = key.substring(0, firstSlash);
|
|
var folder = relaunchInfo.folder;
|
|
var entry = key.substring(lastSlash + 1);
|
|
|
|
var headers = { 'Content-Type': 'application/json' };
|
|
if (apiKey) headers['Authorization'] = 'Bearer ' + apiKey;
|
|
|
|
var body = {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
dpi: Math.round((window.devicePixelRatio || 1) * 96)
|
|
};
|
|
|
|
var url = '/api/addressbook/folders/' + encodeURIComponent(scope)
|
|
+ '/' + encodeURIComponent(folder)
|
|
+ '/entries/' + encodeURIComponent(entry) + '/connect';
|
|
|
|
fetch(url, {
|
|
method: 'POST',
|
|
headers: headers,
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(body)
|
|
})
|
|
.then(function(res) {
|
|
if (!res.ok) {
|
|
// 412 missing creds, 403 forbidden, 404 entry gone:
|
|
// none of those are fixable from this page. Punt to
|
|
// Connections where the user can fill in what's
|
|
// missing or pick a different entry.
|
|
window.location.href = '/connections.html';
|
|
return;
|
|
}
|
|
return res.json().then(function(data) {
|
|
if (data && data.client_url) {
|
|
var dest = data.client_url;
|
|
if (relaunchInfo.display_name) {
|
|
dest += '?name=' + encodeURIComponent(relaunchInfo.display_name);
|
|
}
|
|
window.location.href = dest;
|
|
} else {
|
|
window.location.href = '/connections.html';
|
|
}
|
|
});
|
|
})
|
|
.catch(function() {
|
|
window.location.href = '/connections.html';
|
|
});
|
|
});
|
|
|
|
// ── Keyboard — attached to display so it doesn't steal from textarea ──
|
|
displayEl.tabIndex = 0;
|
|
displayEl.style.outline = 'none';
|
|
displayEl.focus();
|
|
var keyboard = new Guacamole.Keyboard(displayEl);
|
|
var lastToggleTime = 0;
|
|
|
|
// Ctrl+Alt+Shift toggle on document (capture phase) — works everywhere
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.ctrlKey && e.altKey && e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
var now = Date.now();
|
|
if (now - lastToggleTime < 500) return; // debounce
|
|
lastToggleTime = now;
|
|
toggleClipboardPanel();
|
|
}
|
|
}, true);
|
|
|
|
// ── Ctrl+V clipboard sync ──
|
|
// Intercept Ctrl+V (capture on document, fires before Guacamole's
|
|
// handler on displayEl). Reads browser clipboard, syncs to remote,
|
|
// then sends key events. Firefox shows a one-time permission popup.
|
|
var _pasteIntercepted = false;
|
|
document.addEventListener('keydown', function(e) {
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V') && !e.altKey && !e.repeat && !panelOpen) {
|
|
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
_pasteIntercepted = true;
|
|
navigator.clipboard.readText().then(function(text) {
|
|
if (text && text !== remoteClipboard) {
|
|
sendClipboardToRemote(text);
|
|
remoteClipboard = text;
|
|
}
|
|
}).catch(function() {}).finally(function() {
|
|
// Send Ctrl+V key events to remote after clipboard sync
|
|
client.sendKeyEvent(1, 0x76); // v down
|
|
client.sendKeyEvent(0, 0x76); // v up
|
|
});
|
|
}
|
|
}
|
|
}, true);
|
|
document.addEventListener('keyup', function(e) {
|
|
if (_pasteIntercepted && (e.key === 'v' || e.key === 'V')) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
_pasteIntercepted = false;
|
|
}
|
|
}, true);
|
|
|
|
keyboard.onkeydown = function(keysym) {
|
|
client.sendKeyEvent(1, keysym);
|
|
};
|
|
keyboard.onkeyup = function(keysym) {
|
|
client.sendKeyEvent(0, keysym);
|
|
};
|
|
|
|
// Release all held keys when the window loses focus or is hidden.
|
|
// Modifier keyups are otherwise lost when focus shifts away (e.g.
|
|
// the clipboard-permission prompt triggered by Ctrl+V, or alt-tab
|
|
// while holding a modifier), leaving Ctrl/Alt/Shift stuck "down"
|
|
// on the remote until the key is pressed again. keyboard.reset()
|
|
// releases every key it currently tracks as pressed, sending the
|
|
// matching keyup to the remote.
|
|
window.addEventListener('blur', function() { keyboard.reset(); });
|
|
document.addEventListener('visibilitychange', function() {
|
|
if (document.hidden) keyboard.reset();
|
|
});
|
|
|
|
// Mouse input
|
|
var mouse = new Guacamole.Mouse(client.getDisplay().getElement());
|
|
mouse.onEach(['mousedown', 'mousemove', 'mouseup'], function(e) {
|
|
client.sendMouseState(e.state, true);
|
|
});
|
|
|
|
// Show the remote pointer image as the browser's own pointer, and
|
|
// only fall back to the display's software cursor layer where CSS
|
|
// cursors are unsupported. Guacamole.Display puts that layer in
|
|
// the DOM from construction, so without this handover both are
|
|
// drawn: the browser's arrow, composited by the OS, and the
|
|
// software one, moved by the page on each mousemove -- which
|
|
// overlap at rest and visibly separate during fast movement,
|
|
// further the busier the main thread.
|
|
//
|
|
// Not Guacamole.Mouse.setCursor(), which declares the image at one
|
|
// image pixel per CSS pixel: under Native Resolution the display
|
|
// is scaled by 1/devicePixelRatio, and a host scaling its UI sends
|
|
// a pointer image to match, so that would draw it at twice its
|
|
// size. image-set() declares its density instead, from the
|
|
// display's scale, so it lands at one image pixel per framebuffer
|
|
// pixel like everything else on screen. The hotspot is then in CSS
|
|
// pixels. Re-applied from scaleDisplay(), since the scale changes
|
|
// under a pointer that does not.
|
|
var remoteCursor = null;
|
|
function applyRemoteCursor() {
|
|
if (!remoteCursor) return;
|
|
var display = client.getDisplay();
|
|
var el = display.getElement();
|
|
var scale = display.getScale();
|
|
var url = 'url(' + remoteCursor.url + ')';
|
|
el.style.cursor = '';
|
|
if (Math.abs(scale - 1) > 0.01)
|
|
el.style.cursor = 'image-set(' + url + ' ' + (1 / scale) + 'x) '
|
|
+ Math.round(remoteCursor.x * scale) + ' '
|
|
+ Math.round(remoteCursor.y * scale) + ', auto';
|
|
if (!el.style.cursor)
|
|
el.style.cursor = url + ' ' + remoteCursor.x + ' '
|
|
+ remoteCursor.y + ', auto';
|
|
display.showCursor(!el.style.cursor);
|
|
}
|
|
client.getDisplay().oncursor = function(canvas, x, y) {
|
|
remoteCursor = { url: canvas.toDataURL('image/png'), x: x, y: y };
|
|
applyRemoteCursor();
|
|
};
|
|
|
|
// Auto-scale display. When a multi-monitor layout is active, the
|
|
// main window shows only monitor 0 (always at the origin of the
|
|
// combined framebuffer); the other heads are available as pop-out
|
|
// windows via the Controls sidebar. Otherwise the whole framebuffer
|
|
// is scaled to fit.
|
|
function scaleDisplay() {
|
|
var display = client.getDisplay();
|
|
var dw = display.getWidth();
|
|
var dh = display.getHeight();
|
|
if (dw <= 0 || dh <= 0) return;
|
|
|
|
var anyPanelOpen = panelOpen || filePanelOpen;
|
|
var panelOffset = anyPanelOpen ? 380 : 0;
|
|
var availW = window.innerWidth - panelOffset;
|
|
var availH = window.innerHeight;
|
|
var el = display.getElement();
|
|
|
|
var m0 = monitorLayout && monitorLayout['0'];
|
|
var multimon = m0 && Object.keys(monitorLayout).length > 1
|
|
&& m0.width > 0 && m0.height > 0;
|
|
|
|
if (multimon) {
|
|
// Fit monitor 0 into the available area and clip away the
|
|
// rest of the combined framebuffer (the other heads).
|
|
var factor = Math.min(availW / m0.width, availH / m0.height);
|
|
display.scale(factor);
|
|
var cw = Math.round(m0.width * factor);
|
|
var ch = Math.round(m0.height * factor);
|
|
displayEl.style.overflow = 'hidden';
|
|
displayEl.style.position = 'absolute';
|
|
displayEl.style.width = cw + 'px';
|
|
displayEl.style.height = ch + 'px';
|
|
displayEl.style.left = (panelOffset + Math.max(0, (availW - cw) / 2)) + 'px';
|
|
displayEl.style.top = Math.max(0, (availH - ch) / 2) + 'px';
|
|
// Monitor 0 is at combined (0,0); no negative offset needed.
|
|
el.style.marginLeft = '0';
|
|
el.style.marginTop = '0';
|
|
}
|
|
else {
|
|
// Single display: scale the whole framebuffer to fit.
|
|
display.scale(Math.min(availW / dw, availH / dh));
|
|
displayEl.style.overflow = '';
|
|
displayEl.style.position = '';
|
|
displayEl.style.width = '';
|
|
displayEl.style.height = '';
|
|
displayEl.style.top = '';
|
|
displayEl.style.left = '';
|
|
el.style.marginLeft = '';
|
|
el.style.marginTop = '';
|
|
// Offset display to the right when left panel is open
|
|
displayEl.style.marginLeft = anyPanelOpen ? '380px' : '0';
|
|
}
|
|
applyRemoteCursor();
|
|
}
|
|
|
|
client.getDisplay().onresize = function() { scaleDisplay(); };
|
|
var resizeTimer = null;
|
|
window.addEventListener('resize', function() {
|
|
scaleDisplay();
|
|
// Debounce sendSize. Half a second rather than a quarter
|
|
// because the cost of a resolution change is a decoder
|
|
// rebuild, not just an RDP display update.
|
|
clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(function() {
|
|
var anyPanelOpen = panelOpen || filePanelOpen;
|
|
var sw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth;
|
|
var sh = window.innerHeight;
|
|
if (sizeWouldChange(sw, sh))
|
|
client.sendSize(sw, sh);
|
|
}, 500);
|
|
});
|
|
|
|
var connectData = shareToken ? 'token=' + encodeURIComponent(shareToken) : '';
|
|
client.connect(connectData);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|