mirror of
https://github.com/sol1/rustguac.git
synced 2026-10-25 21:19:45 +00:00
connect: zero-click fullscreen via a same-tab iframe, sized to the measured viewport
A browser only lets the tab that holds the Connect gesture go fullscreen; opening a new tab drops that gesture, so a fullscreen-on-connect entry always fell back to a manual click. Connect now fullscreens the CURRENT tab on the click — the one moment a valid gesture exists — and loads the session in a full-viewport same-origin iframe (rgLaunchFullscreen in search.js, shared by the connections page and the global search): overlay, spinner and an Exit button, torn down on Exit, on fullscreen exit or on a session-end message from the embedded client. The client in ?embedded=1 mode skips all in-client fullscreen logic (the parent tab owns it) and posts ready/end to the parent. X-Frame-Options becomes SAMEORIGIN (was DENY) with CSP frame-ancestors 'self', so a same-origin page can host the client in a frame while cross-origin framing stays blocked. The final viewport size cannot be predicted (a fullscreen viewport on a notched Mac is shorter than the screen), so it is measured: the launcher enters fullscreen, waits until the viewport has held one size for 150ms, then sends the connect with that width and height; a refused request connects at the tab's size at once and a 2.5s cap never leaves the user on a spinner. The embedded client shows its connect cover from load and lifts it — then runs the post-connect fit sends — only on the launcher's 'viewport' message, so the user sees only the final picture. The ⧉ (open in a new window) button opens the client explicitly windowed (fs=0) on a small splash page, static/launch.html, which measures its own viewport once stable and posts it to the opener (rgOpenSessionWindow); the session is created at that size, so nothing resizes on load. No screen_* is sent for a popup. When the browser refuses the load-time request, the next click or key enters fullscreen, taken in the capture phase and never sent to the remote, and everything is released once fullscreen lands. Fixes #257 tests: same-origin framing only, and the zero-click fullscreen launcher The CSP test pins frame-ancestors to exactly 'self'. The browser spec drives Connect on a fullscreen-on-connect entry: the launcher overlay opens in the same tab, the connect request carries the measured viewport and no screen size, the embedded client loads in the frame, and Exit tears it down.
This commit is contained in:
+24
-2
@@ -668,7 +668,8 @@ const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
|
||||
script-src 'self' 'unsafe-inline'; \
|
||||
style-src 'self' 'unsafe-inline'; \
|
||||
connect-src 'self' wss: ws:; \
|
||||
img-src 'self' data: blob:";
|
||||
img-src 'self' data: blob:; \
|
||||
frame-ancestors 'self'";
|
||||
|
||||
async fn security_headers(
|
||||
tls: Extension<TlsEnabled>,
|
||||
@@ -678,7 +679,11 @@ async fn security_headers(
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
headers.insert("X-Content-Type-Options", "nosniff".parse().unwrap());
|
||||
headers.insert("X-Frame-Options", "DENY".parse().unwrap());
|
||||
// SAMEORIGIN (not DENY) so the Connections page can host a session in a
|
||||
// full-viewport same-origin iframe for zero-click fullscreen-on-connect.
|
||||
// Cross-origin framing stays blocked (clickjacking); CSP frame-ancestors
|
||||
// 'self' is the modern equivalent for browsers that honour it.
|
||||
headers.insert("X-Frame-Options", "SAMEORIGIN".parse().unwrap());
|
||||
headers.insert(
|
||||
"Referrer-Policy",
|
||||
"strict-origin-when-cross-origin".parse().unwrap(),
|
||||
@@ -1919,6 +1924,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csp_allows_same_origin_framing_only() {
|
||||
// Zero-click fullscreen loads the client in a same-origin iframe
|
||||
// (#257), which needs frame-ancestors 'self'; anything wider would
|
||||
// reopen clickjacking from other origins.
|
||||
let directives: Vec<&str> = CONTENT_SECURITY_POLICY.split(';').map(str::trim).collect();
|
||||
let fa = directives
|
||||
.iter()
|
||||
.find(|d| d.starts_with("frame-ancestors "))
|
||||
.expect("CSP must carry a frame-ancestors directive");
|
||||
let sources: Vec<&str> = fa.split_whitespace().skip(1).collect();
|
||||
assert_eq!(
|
||||
sources,
|
||||
vec!["'self'"],
|
||||
"only the same origin may frame the client"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_csp_allows_data_and_blob_images() {
|
||||
// Safari and Firefox draw Guacamole image streams through data: URIs
|
||||
|
||||
+266
-44
@@ -68,6 +68,38 @@
|
||||
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;
|
||||
@@ -239,6 +271,13 @@
|
||||
</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>
|
||||
@@ -293,6 +332,37 @@
|
||||
// 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) {} }
|
||||
// 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.
|
||||
@@ -301,6 +371,51 @@
|
||||
// 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;
|
||||
@@ -377,7 +492,8 @@
|
||||
};
|
||||
}
|
||||
if (session && session.entry_display_name) entryDisplayName = session.entry_display_name;
|
||||
if (session && session.fullscreen_on_connect) wantFullscreen = true;
|
||||
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);
|
||||
})
|
||||
@@ -1424,38 +1540,59 @@
|
||||
} catch(e) { /* ignore capture errors */ }
|
||||
}
|
||||
|
||||
// ── Fullscreen (#154) ──
|
||||
// The browser API requires a fresh user gesture to enter
|
||||
// fullscreen, and navigation from connections.html consumes
|
||||
// the prior gesture. So when the entry is marked
|
||||
// fullscreen-on-connect we don't try to fire on page load.
|
||||
// Instead we arm a one-shot listener for the next mousedown
|
||||
// or keydown after CONNECTED. The Ctrl+Alt+Shift menu's
|
||||
// fullscreen button uses its own click gesture and works at
|
||||
// any time once shown.
|
||||
// ── 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;
|
||||
}
|
||||
function rgEnterFullscreen() {
|
||||
// A bitmap (RDP/VNC) fullscreen-on-connect session is created at
|
||||
// the screen's size and kept there until fullscreen lands, so the
|
||||
// picture painted behind the cover is already the fullscreen one —
|
||||
// no resize when it is revealed. Everything else, and everything
|
||||
// after the first fullscreen, tracks the window.
|
||||
function rgTargetSize(anyPanelOpen) {
|
||||
if (wantFullscreen && bitmapSession && !fsEverEntered && !rgFullscreenEl() && window.screen && screen.width > 0 && screen.height > 0)
|
||||
return [screen.width, screen.height];
|
||||
return [anyPanelOpen ? window.innerWidth - 380 : window.innerWidth, window.innerHeight];
|
||||
}
|
||||
// Try to enter fullscreen. onReject runs if the browser refuses
|
||||
// (e.g. no user activation on load): the caller decides whether to
|
||||
// wait for a gesture or give up and show the windowed session.
|
||||
var fsKeyboardLocked = false;
|
||||
function rgOnFullscreenEntered() {
|
||||
if (fsKeyboardLocked) return;
|
||||
fsKeyboardLocked = true;
|
||||
if (navigator.keyboard && navigator.keyboard.lock) {
|
||||
navigator.keyboard.lock(['Escape']).catch(rgShowEscNoticeOnce);
|
||||
} else {
|
||||
rgShowEscNoticeOnce();
|
||||
}
|
||||
}
|
||||
function rgEnterFullscreen(onReject) {
|
||||
var el = document.documentElement;
|
||||
var req = el.requestFullscreen
|
||||
|| el.webkitRequestFullscreen
|
||||
|| el.mozRequestFullScreen
|
||||
|| el.msRequestFullscreen;
|
||||
if (!req) return;
|
||||
if (!req) { if (onReject) onReject(); return; }
|
||||
var p;
|
||||
try { p = req.call(el); } catch (e) { return; }
|
||||
var onOk = function() {
|
||||
if (navigator.keyboard && navigator.keyboard.lock) {
|
||||
navigator.keyboard.lock(['Escape']).catch(rgShowEscNoticeOnce);
|
||||
} else {
|
||||
rgShowEscNoticeOnce();
|
||||
}
|
||||
};
|
||||
if (p && p.then) p.then(onOk).catch(function(){}); else onOk();
|
||||
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) {}
|
||||
@@ -1465,23 +1602,74 @@
|
||||
|| 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) {
|
||||
document.removeEventListener('mousedown', fire, true);
|
||||
document.removeEventListener('keydown', fire, true);
|
||||
// This press is the browser's fullscreen gesture, not
|
||||
// input for the remote. The transition it starts re-lays
|
||||
// out the display and swallows the matching release, and
|
||||
// a press guacd never sees released turns every later
|
||||
// pointer move into a drag selection (#256). Keep it from
|
||||
// the display's handlers; rgReleaseAllInput() covers a
|
||||
// release lost for any other reason once fullscreen lands.
|
||||
if (e.type === 'keydown' && (e.key === 'Escape' || e.key === 'Tab')) return;
|
||||
rgDisarmFullscreenGesture();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
rgEnterFullscreen();
|
||||
rgEnterFullscreen(function() {
|
||||
// Still refused: show the windowed session rather than
|
||||
// trap the user behind the cover forever.
|
||||
rgHideConnectCover();
|
||||
});
|
||||
};
|
||||
document.addEventListener('mousedown', fire, true);
|
||||
document.addEventListener('keydown', fire, true);
|
||||
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
|
||||
@@ -1549,7 +1737,7 @@
|
||||
function rgOnFullscreenChange() {
|
||||
var inFs = !!rgFullscreenEl();
|
||||
var bar = document.getElementById('fs-bar');
|
||||
if (inFs) rgReleaseAllInput();
|
||||
if (inFs) { fsEverEntered = true; rgDisarmFullscreenGesture(); rgOnFullscreenEntered(); rgReleaseAllInput(); rgMaybeReveal(); } else { rgHideConnectCover(); }
|
||||
if (bar) {
|
||||
if (inFs) {
|
||||
bar.classList.add('visible');
|
||||
@@ -1577,10 +1765,14 @@
|
||||
|
||||
client.onstatechange = function(state) {
|
||||
switch (state) {
|
||||
case Guacamole.Client.State.CONNECTING: statusEl.textContent = 'Connecting...'; statusEl.className = ''; break;
|
||||
case Guacamole.Client.State.WAITING: statusEl.textContent = 'Waiting for server...'; statusEl.className = ''; break;
|
||||
case Guacamole.Client.State.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
|
||||
@@ -1599,8 +1791,8 @@
|
||||
var fitToWindow = function() {
|
||||
scaleDisplay();
|
||||
var anyPanelOpen = panelOpen || filePanelOpen;
|
||||
var sw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth;
|
||||
var sh = window.innerHeight;
|
||||
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
|
||||
@@ -1614,10 +1806,19 @@
|
||||
client.sendSize(sw, sh);
|
||||
}
|
||||
};
|
||||
setTimeout(fitToWindow, 800);
|
||||
setTimeout(fitToWindow, 2000);
|
||||
setTimeout(fitToWindow, 4000);
|
||||
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);
|
||||
@@ -1626,7 +1827,23 @@
|
||||
}
|
||||
document.getElementById('fs-bar-title').textContent =
|
||||
entryDisplayName || ('Session ' + sessionId.substring(0,8));
|
||||
if (wantFullscreen) rgArmFullscreenGesture();
|
||||
// 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 = '';
|
||||
@@ -1639,7 +1856,9 @@
|
||||
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
|
||||
@@ -1652,6 +1871,7 @@
|
||||
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; }
|
||||
@@ -2026,14 +2246,16 @@
|
||||
// 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 sw = anyPanelOpen ? window.innerWidth - 380 : window.innerWidth;
|
||||
var sh = window.innerHeight;
|
||||
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);
|
||||
}
|
||||
|
||||
+32
-17
@@ -3016,10 +3016,18 @@
|
||||
// carries the user activation, so the client can enter fullscreen
|
||||
// on load with no second click. The ⧉ button gets a sized popup;
|
||||
// a plain Connect gets a tab.
|
||||
var targetWin = null;
|
||||
var targetWin = null, popup = null;
|
||||
if (newWindow) {
|
||||
targetWin = window.open('', '_blank',
|
||||
'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no');
|
||||
// Measured popup (search.js rgOpenSessionWindow): the splash
|
||||
// page reports its laid-out viewport, and the session is
|
||||
// created at exactly that size so nothing resizes on load.
|
||||
if (typeof window.rgOpenSessionWindow === 'function') {
|
||||
popup = window.rgOpenSessionWindow(name);
|
||||
targetWin = popup.win;
|
||||
} else {
|
||||
targetWin = window.open('', '_blank',
|
||||
'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no');
|
||||
}
|
||||
} else {
|
||||
targetWin = window.open('', '_blank');
|
||||
}
|
||||
@@ -3029,20 +3037,16 @@
|
||||
if (!body.width && bw > 0 && bh > 0) {
|
||||
body.width = bw;
|
||||
body.height = bh;
|
||||
if (!body.dpi) body.dpi = Math.round((window.devicePixelRatio || 1) * 96);
|
||||
}
|
||||
// And the screen's, so an entry that opens fullscreen on connect
|
||||
// is created at the size it will be shown at (no resize after
|
||||
// the fullscreen transition).
|
||||
if (!body.screen_width && window.screen && screen.width > 0 && screen.height > 0) {
|
||||
body.screen_width = screen.width;
|
||||
body.screen_height = screen.height;
|
||||
}
|
||||
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect', {
|
||||
method: 'POST',
|
||||
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body)
|
||||
if (!body.dpi) body.dpi = Math.round((window.devicePixelRatio || 1) * 96);
|
||||
(popup ? popup.size : Promise.resolve(null)).then(function(sz) {
|
||||
if (sz) { body.width = sz.width; body.height = sz.height; }
|
||||
return fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect', {
|
||||
method: 'POST',
|
||||
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
@@ -3058,7 +3062,14 @@
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
var url = data.client_url + '?name=' + encodeURIComponent(name);
|
||||
// Carry the fullscreen-on-connect flag so the client can go
|
||||
// fullscreen immediately, using the activation from this click.
|
||||
var _e0 = null;
|
||||
for (var _k = 0; _k < currentEntries.length; _k++) { if (currentEntries[_k].name === name) { _e0 = currentEntries[_k]; break; } }
|
||||
// A new window (⧉) is explicitly windowed: fs=0 tells the
|
||||
// client not to fullscreen-on-connect even if the entry asks.
|
||||
var url = data.client_url + '?name=' + encodeURIComponent(name)
|
||||
+ (newWindow ? '&fs=0' : (_e0 && _e0.fullscreen_on_connect ? '&fs=1' : ''));
|
||||
if (targetWin && !targetWin.closed) targetWin.location.href = url;
|
||||
else window.open(url, '_blank');
|
||||
})
|
||||
@@ -3158,6 +3169,10 @@
|
||||
// (RDP and SSH entries without passwords/keys)
|
||||
if (entry && (entry.prompt_credentials || !entry.has_credentials) && entry.session_type !== 'web' && entry.session_type !== 'vdi' && entry.session_type !== 'proxmox') {
|
||||
showCredPrompt(entry, scope, folder, btn, newWindow);
|
||||
} else if (entry && entry.fullscreen_on_connect && !newWindow && typeof window.rgLaunchFullscreen === 'function') {
|
||||
// Zero-click fullscreen: fullscreen this tab on the click and
|
||||
// load the session in a full-viewport iframe (search.js).
|
||||
window.rgLaunchFullscreen(scope, folder, name, btn);
|
||||
} else {
|
||||
doConnect(scope, folder, name, btn, undefined, newWindow);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Connecting…</title>
|
||||
<style>
|
||||
:root { --bg: #1a1a2e; --text: #e0e0e0; --text-dim: #888; --accent: #5bc0be; --border: #333; }
|
||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font-family: monospace; }
|
||||
#box { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; text-align: center; }
|
||||
#spinner { width: 30px; height: 30px; margin: 0 auto 1.2em; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
#title { font-size: 1.3em; font-weight: bold; margin-bottom: 0.5em; }
|
||||
#status { color: var(--text-dim); font-size: 0.95em; letter-spacing: 0.02em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="box"><div><div id="spinner"></div><div id="title"></div><div id="status">Connecting…</div></div></div>
|
||||
<script>
|
||||
// Popup splash for "open in a new window" (⧉). A popup's viewport size
|
||||
// cannot be read reliably from the opener right after window.open (it
|
||||
// reports the opener's size until the new window is laid out), so the
|
||||
// popup measures itself: once loaded and the size has held for 150ms,
|
||||
// it tells the opener, which creates the session at exactly that size
|
||||
// and navigates this window to it. No resize after the first paint.
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var name = q.get('name') || 'Session';
|
||||
document.getElementById('title').textContent = name;
|
||||
document.title = name + ' — connecting…';
|
||||
// Match the site theme (same source as the client page).
|
||||
fetch('/api/auth/status', { credentials: 'same-origin' }).then(function (r) { return r.json(); }).then(function (d) {
|
||||
var t = d && d.theme; if (!t) return;
|
||||
var u = null; try { u = localStorage.getItem('rustguac_theme'); } catch (e) {}
|
||||
var presets = t.presets || {}, admin = t.admin_preset || 'aurora';
|
||||
var active = u && presets[u] ? u : admin;
|
||||
var colors = (active === admin) ? t.admin_colors : presets[active];
|
||||
if (!colors) return;
|
||||
for (var k in colors) if (Object.prototype.hasOwnProperty.call(colors, k)) document.documentElement.style.setProperty('--' + String(k).replace(/_/g, '-'), colors[k]);
|
||||
}).catch(function () {});
|
||||
if (!window.opener) { document.getElementById('status').textContent = 'Opened directly — returning to Connections…'; setTimeout(function () { location.replace('/connections.html'); }, 1500); return; }
|
||||
var sent = false, timer = null, lastW = 0, lastH = 0;
|
||||
function send() {
|
||||
if (sent) return; sent = true;
|
||||
window.removeEventListener('resize', arm);
|
||||
try { window.opener.postMessage({ rgLaunch: 'size', width: window.innerWidth, height: window.innerHeight }, location.origin); } catch (e) {}
|
||||
}
|
||||
function arm() {
|
||||
lastW = window.innerWidth; lastH = window.innerHeight;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () { if (window.innerWidth === lastW && window.innerHeight === lastH) send(); else arm(); }, 150);
|
||||
}
|
||||
window.addEventListener('resize', arm);
|
||||
if (document.readyState === 'complete') arm(); else window.addEventListener('load', arm);
|
||||
setTimeout(send, 2000); // never keep the opener waiting
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+181
-7
@@ -291,17 +291,58 @@
|
||||
content.scrollTop = 0;
|
||||
}
|
||||
|
||||
// "Open in a new window" (⧉): open a sized popup synchronously (inside the
|
||||
// click, so it is not blocked) on the splash page /launch.html, which
|
||||
// measures its own viewport once laid out and posts it back. The caller
|
||||
// creates the session at exactly that size, so nothing resizes after the
|
||||
// first paint. A popup's size cannot be read from here right after
|
||||
// window.open — it reports the opener's size until the window is laid out
|
||||
// (that was the RDP resize on ⧉). Resolves {width,height} either way:
|
||||
// defaults if the popup was blocked, or after 2.5s if it never answers.
|
||||
function rgOpenSessionWindow(name) {
|
||||
var win = window.open('/launch.html?name=' + encodeURIComponent(name), '_blank',
|
||||
'width=1280,height=800,menubar=no,toolbar=no,location=no,status=no');
|
||||
var size = new Promise(function (resolve) {
|
||||
var done = false;
|
||||
function fin(w, h) {
|
||||
if (done) return; done = true;
|
||||
window.removeEventListener('message', onMsg); clearTimeout(cap);
|
||||
resolve({ width: w > 0 ? w : 1280, height: h > 0 ? h : 800 });
|
||||
}
|
||||
function onMsg(e) {
|
||||
if (e.origin !== location.origin || !e.data || e.data.rgLaunch !== 'size' || e.source !== win) return;
|
||||
fin(e.data.width, e.data.height);
|
||||
}
|
||||
var cap = null;
|
||||
if (!win) { fin(0, 0); return; }
|
||||
window.addEventListener('message', onMsg);
|
||||
cap = setTimeout(function () {
|
||||
var w = 0, h = 0; try { w = win.innerWidth; h = win.innerHeight; } catch (e) {}
|
||||
fin(w, h);
|
||||
}, 2500);
|
||||
});
|
||||
return { win: win, size: size };
|
||||
}
|
||||
window.rgOpenSessionWindow = rgOpenSessionWindow;
|
||||
|
||||
// Connect to an entry: open the client in a window opened within this click
|
||||
// (keeps activation for fullscreen). Missing credentials send the user to
|
||||
// the connections page to set them.
|
||||
function doConnect(scope, folder, name, btn) {
|
||||
var win = window.open('', '_blank');
|
||||
function doConnect(scope, folder, name, btn, newWindow) {
|
||||
// The ⧉ button gets a sized popup (measured, see above), a plain Connect a tab.
|
||||
var pop = newWindow ? rgOpenSessionWindow(name) : null;
|
||||
var win = pop ? pop.win : window.open('', '_blank');
|
||||
if (btn) { btn.classList.add('loading'); btn.disabled = true; }
|
||||
var reset = function () { if (btn) { btn.classList.remove('loading'); btn.disabled = false; } };
|
||||
var body = { width: window.innerWidth, height: window.innerHeight, dpi: Math.round((window.devicePixelRatio || 1) * 96) };
|
||||
if (window.screen && screen.width) { body.screen_width = screen.width; body.screen_height = screen.height; }
|
||||
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect',
|
||||
{ method: 'POST', headers: headers({ 'Content-Type': 'application/json' }), credentials: 'same-origin', body: JSON.stringify(body) })
|
||||
// screen_* only for the tab path (the server prefers it for a
|
||||
// fullscreen bitmap entry); a popup is sized by its measurement alone.
|
||||
if (!newWindow && window.screen && screen.width) { body.screen_width = screen.width; body.screen_height = screen.height; }
|
||||
(pop ? pop.size : Promise.resolve(null)).then(function (sz) {
|
||||
if (sz) { body.width = sz.width; body.height = sz.height; }
|
||||
return fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect',
|
||||
{ method: 'POST', headers: headers({ 'Content-Type': 'application/json' }), credentials: 'same-origin', body: JSON.stringify(body) });
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
if (win && !win.closed) win.close();
|
||||
@@ -312,12 +353,134 @@
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
var url = data.client_url + '?name=' + encodeURIComponent(name);
|
||||
var _row = null, _si = searchIndex || [];
|
||||
for (var _k = 0; _k < _si.length; _k++) { var _r = _si[_k]; if (_r.scope === scope && _r.folderPath === folder && _r.entry.name === name) { _row = _r; break; } }
|
||||
var _fs = _row && _row.entry && _row.entry.fullscreen_on_connect;
|
||||
// A new window is explicitly windowed: fs=0 tells the client
|
||||
// not to fullscreen-on-connect even if the entry asks for it.
|
||||
var url = data.client_url + '?name=' + encodeURIComponent(name) + (newWindow ? '&fs=0' : (_fs ? '&fs=1' : ''));
|
||||
if (win && !win.closed) win.location.href = url; else window.open(url, '_blank');
|
||||
})
|
||||
.catch(function () { if (win && !win.closed) win.close(); })
|
||||
.then(reset, reset);
|
||||
}
|
||||
// Zero-click fullscreen: a browser only lets the tab that holds the Connect
|
||||
// gesture go fullscreen, so for a fullscreen-on-connect entry we fullscreen
|
||||
// THIS tab now and load the session in a full-viewport iframe (the embedded
|
||||
// client fills it). No new tab, no second click. If the browser refuses
|
||||
// fullscreen the session still loads in the overlay, filling the tab.
|
||||
function rgLaunchFullscreen(scope, folder, name, btn) {
|
||||
var el = document.documentElement;
|
||||
var req = el.requestFullscreen || el.webkitRequestFullscreen || el.mozRequestFullScreen || el.msRequestFullscreen;
|
||||
// The session must be created at the viewport's FINAL size — the one
|
||||
// the frame will paint into — or the first picture is a fit of some
|
||||
// other size that the post-connect resize then snaps. That size cannot
|
||||
// be predicted: screen.width/height overshoot on a notched Mac (the
|
||||
// strip beside the camera is never part of a fullscreen viewport) and
|
||||
// the transition is asynchronous. So: enter fullscreen first, wait for
|
||||
// the viewport to stop changing, measure it, then connect. Refused (or
|
||||
// no Fullscreen API): the overlay fills the tab as it is, so measure
|
||||
// that straight away. Costs the transition time (~half a second)
|
||||
// before the connect request goes out; buys zero resizes.
|
||||
var fsState = null, frameReady = false; // null = pending, true = entered, false = refused
|
||||
var started = false, settleTimer = null, lastW = 0, lastH = 0;
|
||||
var w0 = window.innerWidth, h0 = window.innerHeight, fsAt = 0; // pre-fullscreen size
|
||||
function pushViewport() {
|
||||
if (!frameReady || fsState === null) return;
|
||||
var fr = document.getElementById('rg-session-frame');
|
||||
try { if (fr && fr.contentWindow) fr.contentWindow.postMessage({ rgLauncher: 'viewport', fullscreen: fsState }, location.origin); } catch (e) {}
|
||||
}
|
||||
// Connect once the viewport has held the same size for 150ms.
|
||||
function armSettle() {
|
||||
lastW = window.innerWidth; lastH = window.innerHeight;
|
||||
clearTimeout(settleTimer);
|
||||
settleTimer = setTimeout(function () {
|
||||
if (window.innerWidth !== lastW || window.innerHeight !== lastH) { armSettle(); return; }
|
||||
// Entered fullscreen but the viewport still has its windowed
|
||||
// size: the resize has not landed yet (some browsers fire
|
||||
// fullscreenchange first). Keep waiting, up to 1.5s.
|
||||
if (fsState === true && window.innerWidth === w0 && window.innerHeight === h0 && Date.now() - fsAt < 1500) { armSettle(); return; }
|
||||
startConnect();
|
||||
}, 150);
|
||||
}
|
||||
function onResize() { if (!started) armSettle(); }
|
||||
function fsRefused() { if (fsState === null) { fsState = false; pushViewport(); startConnect(); } }
|
||||
window.addEventListener('resize', onResize);
|
||||
if (req) {
|
||||
try { var fp = req.call(el); if (fp && fp.catch) fp.catch(fsRefused); } catch (e) { fsRefused(); }
|
||||
// No fullscreenchange within 3s (a browser that returns no promise
|
||||
// and never enters): treat as refused.
|
||||
setTimeout(fsRefused, 3000);
|
||||
} else fsRefused();
|
||||
var ov = document.createElement('div'); ov.id = 'rg-session-overlay';
|
||||
ov.innerHTML = '<div id="rg-session-spinner"></div>' +
|
||||
'<button id="rg-session-exit" type="button" title="Exit">× Exit</button>' +
|
||||
'<iframe id="rg-session-frame" allow="fullscreen; clipboard-read; clipboard-write" title="Session"></iframe>';
|
||||
document.body.appendChild(ov);
|
||||
if (btn) { btn.classList.add('loading'); btn.disabled = true; }
|
||||
var done = false, enteredFs = false;
|
||||
function teardown() {
|
||||
if (done) return; done = true;
|
||||
window.removeEventListener('message', onMsg);
|
||||
window.removeEventListener('resize', onResize);
|
||||
clearTimeout(settleTimer);
|
||||
document.removeEventListener('fullscreenchange', onFsChange);
|
||||
var o = document.getElementById('rg-session-overlay'); if (o) o.remove();
|
||||
if (btn) { btn.classList.remove('loading'); btn.disabled = false; }
|
||||
try { if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen(); } catch (e) {}
|
||||
}
|
||||
function onMsg(e) {
|
||||
if (e.origin !== location.origin || !e.data) return;
|
||||
if (e.data.rgSession === 'ready') { var sp = document.getElementById('rg-session-spinner'); if (sp) sp.style.display = 'none'; frameReady = true; pushViewport(); }
|
||||
else if (e.data.rgSession === 'end') teardown();
|
||||
}
|
||||
function onFsChange() {
|
||||
if (document.fullscreenElement) {
|
||||
enteredFs = true;
|
||||
if (fsState === null) { fsState = true; fsAt = Date.now(); pushViewport(); }
|
||||
// Entered: the viewport may already be final or still animating;
|
||||
// the settle check covers both.
|
||||
if (!started) armSettle();
|
||||
}
|
||||
else if (enteredFs) { teardown(); }
|
||||
}
|
||||
window.addEventListener('message', onMsg);
|
||||
document.addEventListener('fullscreenchange', onFsChange);
|
||||
document.getElementById('rg-session-exit').addEventListener('click', teardown);
|
||||
// Create the session at the measured viewport size — the size the
|
||||
// frame paints into — so there is nothing to resize afterwards.
|
||||
function startConnect() {
|
||||
if (started || done) return;
|
||||
started = true;
|
||||
clearTimeout(settleTimer);
|
||||
window.removeEventListener('resize', onResize);
|
||||
// Window size only: the measured viewport IS the fullscreen size.
|
||||
// No screen_width/height — the server prefers those for a
|
||||
// fullscreen bitmap session, and the physical screen overshoots
|
||||
// the viewport on a notched Mac (that was the RDP resize).
|
||||
var body = { dpi: Math.round((window.devicePixelRatio || 1) * 96), width: window.innerWidth, height: window.innerHeight };
|
||||
fetch('/api/addressbook/folders/' + encodeURIComponent(scope) + '/' + encodeURIComponent(folder) + '/entries/' + encodeURIComponent(name) + '/connect',
|
||||
{ method: 'POST', headers: headers({ 'Content-Type': 'application/json' }), credentials: 'same-origin', body: JSON.stringify(body) })
|
||||
.then(function (res) {
|
||||
if (!res.ok) { if (res.status === 412) { teardown(); openFolder(scope, folder); return null; } return res.text().then(function (t) { throw new Error(t); }); }
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data) return;
|
||||
var fr = document.getElementById('rg-session-frame');
|
||||
if (!fr) return;
|
||||
fr.src = data.client_url + '?name=' + encodeURIComponent(name) + '&embedded=1';
|
||||
// Fallback: hide the spinner if 'ready' never arrives.
|
||||
setTimeout(function () { var sp = document.getElementById('rg-session-spinner'); if (sp) sp.style.display = 'none'; }, 8000);
|
||||
})
|
||||
.catch(function (err) { teardown(); if (window.rgAlert) rgAlert('Connect failed: ' + err.message); });
|
||||
}
|
||||
// Whatever happens, never leave the user on a spinner: connect at the
|
||||
// current size if nothing has settled it within 2.5s.
|
||||
setTimeout(startConnect, 2500);
|
||||
}
|
||||
window.rgLaunchFullscreen = rgLaunchFullscreen;
|
||||
|
||||
function openFolder(scope, path) {
|
||||
if (typeof window.rgSelectFolder === 'function') { close(); window.rgSelectFolder(scope, path); return; }
|
||||
location.href = '/connections.html#open=' + encodeURIComponent(scope + '/' + path);
|
||||
@@ -344,7 +507,18 @@
|
||||
var open = t.closest && t.closest('.search-open-folder');
|
||||
if (open) { e.preventDefault(); openFolder(open.getAttribute('data-open-scope'), open.getAttribute('data-open-folder')); return; }
|
||||
var conn = t.closest && t.closest('[data-sc-connect]');
|
||||
if (conn) { e.preventDefault(); doConnect(conn.getAttribute('data-scope'), conn.getAttribute('data-folder'), conn.getAttribute('data-sc-connect'), conn); }
|
||||
if (conn) {
|
||||
e.preventDefault();
|
||||
var sc = conn.getAttribute('data-scope'), fo = conn.getAttribute('data-folder'), nm = conn.getAttribute('data-sc-connect');
|
||||
var row = null, si = searchIndex || [];
|
||||
for (var k = 0; k < si.length; k++) { if (si[k].scope === sc && si[k].folderPath === fo && si[k].entry.name === nm) { row = si[k]; break; } }
|
||||
var fsoc = row && row.entry && row.entry.fullscreen_on_connect;
|
||||
// The regular Connect goes zero-click fullscreen in this tab; the
|
||||
// ⧉ button is an explicit "open in a new window", so it keeps the
|
||||
// new-window path.
|
||||
if (fsoc && !conn.classList.contains('btn-connect-win')) { close(); rgLaunchFullscreen(sc, fo, nm, conn); }
|
||||
else doConnect(sc, fo, nm, conn, conn.classList.contains('btn-connect-win'));
|
||||
}
|
||||
});
|
||||
// Escape closes the overlay from anywhere (not only the search box);
|
||||
// capture phase so it runs before page-level handlers. If a facet menu
|
||||
|
||||
@@ -32,6 +32,7 @@ One spec per change, named after the branch that introduced it:
|
||||
| `themed-dialogs` | `rgConfirm`/`rgAlert` replace native dialogs |
|
||||
| `global-search` | `/` opens the overlay on every signed-in page, folder view untouched; nothing on the login page, for anonymous or viewer callers, or from a previous login's cache |
|
||||
| `search-under-modals` | a page modal covers the search box |
|
||||
| `zero-click-fullscreen` | #257: same-tab iframe sized from the measured viewport |
|
||||
|
||||
The fixture suppresses the first-visit onboarding tour (`options.onboarding`
|
||||
re-enables it) and defaults to the admin role; pass `role` to test gating.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// #257: Connect on a fullscreen-on-connect entry fullscreens the current tab
|
||||
// and loads the session in a same-origin iframe sized to the measured
|
||||
// viewport. Headless Chromium refuses the fullscreen request, which is the
|
||||
// documented fallback: the launcher connects at the tab's size at once, the
|
||||
// frame still loads the embedded client, and Exit tears everything down.
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { installApi, settle } from '../lib/api.mjs';
|
||||
|
||||
test('Connect on a fullscreen entry launches the embedded frame with the measured size', async ({ page }) => {
|
||||
const api = await installApi(page);
|
||||
await page.goto('/connections.html');
|
||||
await settle(page);
|
||||
const acme = page.locator('.folder-row').filter({ hasText: /acme/ }).first();
|
||||
await acme.click();
|
||||
const kiosk = page.locator('.entries-table tbody tr').filter({ hasText: 'kiosk' });
|
||||
await expect(kiosk).toHaveCount(1);
|
||||
await kiosk.locator('button', { hasText: /^Connect/ }).first().click();
|
||||
|
||||
// The launcher overlay appears in this tab, no new tab or window.
|
||||
const overlay = page.locator('#rg-session-overlay');
|
||||
await expect(overlay).toBeVisible();
|
||||
await expect(page.locator('#rg-session-exit')).toBeVisible();
|
||||
|
||||
// The connect request carries the viewport the frame will be shown at.
|
||||
await expect.poll(() => api.calls.find(c => c.method === 'POST' && c.path.endsWith('/entries/kiosk/connect'))).toBeTruthy();
|
||||
const connect = api.calls.find(c => c.method === 'POST' && c.path.endsWith('/entries/kiosk/connect'));
|
||||
const vp = page.viewportSize();
|
||||
expect(connect.body.width).toBe(vp.width);
|
||||
expect(connect.body.height).toBe(vp.height);
|
||||
expect(connect.body.screen_width, 'a framed session is sized from the viewport, not the screen').toBeUndefined();
|
||||
|
||||
// The embedded client loads inside the frame, same origin.
|
||||
const frame = page.locator('#rg-session-frame');
|
||||
await expect(frame).toHaveAttribute('src', /client\.html\?.*embedded=1/);
|
||||
const src = await frame.getAttribute('src');
|
||||
expect(src.startsWith('/') || src.startsWith(page.url().split('/connections')[0])).toBe(true);
|
||||
|
||||
// Exit tears it down and the page underneath is unchanged.
|
||||
await page.locator('#rg-session-exit').click();
|
||||
await expect(overlay).toHaveCount(0);
|
||||
await expect(page.locator('#entries-title')).toBeVisible();
|
||||
});
|
||||
|
||||
test('a non-fullscreen entry connects the old way, with no frame', async ({ page }) => {
|
||||
const api = await installApi(page);
|
||||
await page.goto('/connections.html');
|
||||
await settle(page);
|
||||
await page.locator('.folder-row').filter({ hasText: /acme/ }).first().click();
|
||||
const row = page.locator('.entries-table tbody tr').filter({ hasText: 'web01' });
|
||||
const [popup] = await Promise.all([
|
||||
page.waitForEvent('popup', { timeout: 5000 }).catch(() => null),
|
||||
row.locator('button', { hasText: /^Connect/ }).first().click()
|
||||
]);
|
||||
await expect.poll(() => api.calls.some(c => c.method === 'POST' && c.path.endsWith('/entries/web01/connect'))).toBe(true);
|
||||
expect(await page.locator('#rg-session-overlay').count()).toBe(0);
|
||||
if (popup) await popup.close();
|
||||
});
|
||||
Reference in New Issue
Block a user