mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-25 13:09:56 +00:00
44f7d8428f
Follows #319. The option moves out of the Session panel header into the Controls panel's new Keyboard section, beside View, and is remembered in the browser like the View choice. The lock follows the setting whenever fullscreen is entered, and switching it while fullscreen applies at once. Browsers without the Keyboard Lock API show it disabled with a note.
2372 lines
129 KiB
HTML
2372 lines
129 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;">KEYBOARD</div>' +
|
|
'<label style="display:flex;align-items:center;gap:0.5em;padding:0.25em 0;font-size:0.9em;cursor:pointer;"><input type="checkbox" id="ctrl-lock-keys"><span>Lock all keys in fullscreen</span></label>' +
|
|
'<div id="ctrl-lock-keys-hint" style="color:var(--text-dim);font-size:0.75em;margin-top:0.4em;">In fullscreen, Win, Alt+Tab and other shortcuts go to the remote desktop instead of this computer. Hold Esc to leave fullscreen.</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);
|
|
});
|
|
var lockKeys = document.getElementById('ctrl-lock-keys');
|
|
lockKeys.checked = lockAllKeys;
|
|
if (!rgKeyboardLockSupported()) {
|
|
lockKeys.checked = false;
|
|
lockKeys.disabled = true;
|
|
document.getElementById('ctrl-lock-keys-hint').textContent = 'This browser can\'t capture system shortcuts. Chrome and Edge can.';
|
|
}
|
|
lockKeys.addEventListener('change', function() { rgSetLockAllKeys(lockKeys.checked); });
|
|
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;
|
|
// Lock all keys (#319): in fullscreen, send Win, Alt+Tab and the
|
|
// like to the remote desktop instead of this computer. Off, only
|
|
// Escape is locked, so a single press doesn't leave fullscreen.
|
|
// Remembered per browser; set from the Controls panel.
|
|
var lockAllKeys = false;
|
|
try { lockAllKeys = localStorage.getItem('rustguac_lock_all_keys') === '1'; } catch (e) {}
|
|
function rgKeyboardLockSupported() {
|
|
return !!(navigator.keyboard && navigator.keyboard.lock);
|
|
}
|
|
function rgSetLockAllKeys(on) {
|
|
lockAllKeys = !!on;
|
|
try {
|
|
if (lockAllKeys) localStorage.setItem('rustguac_lock_all_keys', '1');
|
|
else localStorage.removeItem('rustguac_lock_all_keys');
|
|
} catch (e) {}
|
|
if (rgFullscreenEl()) rgRequestKeyboardLock();
|
|
}
|
|
function rgRequestKeyboardLock() {
|
|
if (!rgKeyboardLockSupported()) { rgShowEscNoticeOnce(); return; }
|
|
var lockAll = lockAllKeys;
|
|
var request;
|
|
try {
|
|
request = lockAll ? navigator.keyboard.lock() : navigator.keyboard.lock(['Escape']);
|
|
} catch (e) {
|
|
request = Promise.reject(e);
|
|
}
|
|
request.catch(function() {
|
|
if (!lockAll) rgShowEscNoticeOnce();
|
|
});
|
|
}
|
|
function rgOnFullscreenEntered() {
|
|
if (fsKeyboardLocked) return;
|
|
fsKeyboardLocked = true;
|
|
rgRequestKeyboardLock();
|
|
}
|
|
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 { fsKeyboardLocked = false; 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>
|