From 7a70f623a77a33aff92f840a80b4fc8fe2647f00 Mon Sep 17 00:00:00 2001 From: Dave Kempe Date: Sat, 17 Oct 2026 13:52:06 +1100 Subject: [PATCH] connect: leaving fullscreen keeps the session, with a button back The launcher tore the session down whenever the tab left fullscreen, so holding Escape, F11 or a gesture from the OS ended it. Leaving fullscreen now keeps the session at the tab's size and shows a Fullscreen button that goes back; only Exit, or the session ending, closes it. Tested with the browser's fullscreen events faked, since headless Chromium refuses the real request. --- static/rustguac.css | 9 ++-- static/search.js | 17 ++++++- .../e2e/specs/zero-click-fullscreen.spec.mjs | 47 +++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/static/rustguac.css b/static/rustguac.css index 502368a..087b2f4 100644 --- a/static/rustguac.css +++ b/static/rustguac.css @@ -1141,12 +1141,15 @@ html:not([data-role-level="4"]) .admin-only { display: none !important; } border: 3px solid rgba(255,255,255,0.25); border-top-color: var(--accent, #5bc0be); border-radius: 50%; animation: rg-btn-spin 0.8s linear infinite; } -#rg-session-exit { - position: absolute; top: 10px; right: 14px; z-index: 5001; +#rg-session-exit { position: absolute; top: 10px; right: 14px; } +#rg-session-fs { position: absolute; top: 10px; right: 104px; } +#rg-session-fs[hidden] { display: none; } +.rg-session-btn { + z-index: 5001; height: 32px; padding: 0 var(--s-3, 14px); background: rgba(20,24,38,0.72); color: var(--text, #e0e0e0); border: 1px solid var(--border, #333); border-radius: var(--radius, 2px); font-family: monospace; font-size: var(--fz-sm, 16px); cursor: pointer; opacity: 0.35; transition: opacity 0.15s; } -#rg-session-exit:hover { opacity: 1; background: var(--surface, #16213e); border-color: var(--primary, #e94560); color: var(--text, #e0e0e0); } +.rg-session-btn:hover { opacity: 1; background: var(--surface, #16213e); border-color: var(--primary, #e94560); color: var(--text, #e0e0e0); } diff --git a/static/search.js b/static/search.js index f286c47..a65cf30 100644 --- a/static/search.js +++ b/static/search.js @@ -422,7 +422,8 @@ } else fsRefused(); var ov = document.createElement('div'); ov.id = 'rg-session-overlay'; ov.innerHTML = '
' + - '' + + '' + + '' + ''; document.body.appendChild(ov); if (btn) { btn.classList.add('loading'); btn.disabled = true; } @@ -450,11 +451,23 @@ // the settle check covers both. if (!started) armSettle(); } - else if (enteredFs) { teardown(); } + // Leaving fullscreen (Escape held, F11, the OS) keeps the session: + // it carries on at the tab's size and the button takes it back + // to fullscreen. Only Exit, or the session ending, closes it. + else if (enteredFs) { showFsButton(true); } + if (document.fullscreenElement) showFsButton(false); + } + function showFsButton(on) { + var b = document.getElementById('rg-session-fs'); if (b) b.hidden = !on; + if (!on) { var f = document.getElementById('rg-session-frame'); try { if (f) f.focus(); } catch (e) {} } } window.addEventListener('message', onMsg); document.addEventListener('fullscreenchange', onFsChange); document.getElementById('rg-session-exit').addEventListener('click', teardown); + document.getElementById('rg-session-fs').addEventListener('click', function () { + // A click is a fresh gesture, so the browser allows fullscreen again. + try { var p = req && req.call(el); if (p && p.catch) p.catch(function () {}); } catch (e) {} + }); // Create the session at the measured viewport size — the size the // frame paints into — so there is nothing to resize afterwards. function startConnect() { diff --git a/tests/e2e/specs/zero-click-fullscreen.spec.mjs b/tests/e2e/specs/zero-click-fullscreen.spec.mjs index b9cb908..445a6c8 100644 --- a/tests/e2e/specs/zero-click-fullscreen.spec.mjs +++ b/tests/e2e/specs/zero-click-fullscreen.spec.mjs @@ -55,3 +55,50 @@ test('a non-fullscreen entry connects the old way, with no frame', async ({ page expect(await page.locator('#rg-session-overlay').count()).toBe(0); if (popup) await popup.close(); }); + +// Leaving fullscreen (Escape held, F11, the OS) must not end the session: the +// frame stays at the tab's size and a button takes it back to fullscreen. +// Headless Chromium refuses real fullscreen, so the browser's side is faked: +// fullscreenElement is set and cleared and fullscreenchange fired, exactly +// what the launcher listens to. +test('leaving fullscreen keeps the session and offers to go back', async ({ page }) => { + await page.addInitScript(() => { + let fs = null; + Object.defineProperty(Document.prototype, 'fullscreenElement', { configurable: true, get: () => fs }); + // Like a real browser, the change event fires after the call returns. + const change = (el) => new Promise((resolve) => setTimeout(() => { + fs = el; + document.dispatchEvent(new Event('fullscreenchange')); + resolve(); + }, 50)); + Element.prototype.requestFullscreen = function () { return change(document.documentElement); }; + document.exitFullscreen = function () { return change(null); }; + window.__leaveFullscreen = () => document.exitFullscreen(); + }); + await installApi(page); + await page.goto('/connections.html'); + await settle(page); + await page.locator('.folder-row').filter({ hasText: /acme/ }).first().click(); + const kiosk = page.locator('.entries-table tbody tr').filter({ hasText: 'kiosk' }); + await kiosk.locator('button', { hasText: /^Connect/ }).first().click(); + + const overlay = page.locator('#rg-session-overlay'); + const back = page.locator('#rg-session-fs'); + await expect(overlay).toBeVisible(); + await expect(back).toBeHidden(); + + // The user leaves fullscreen: the session is still there. + await page.evaluate(() => window.__leaveFullscreen()); + await expect(overlay).toBeVisible(); + await expect(page.locator('#rg-session-frame')).toHaveCount(1); + await expect(back).toBeVisible(); + + // The button goes back to fullscreen and hides itself. + await back.click(); + await expect(back).toBeHidden(); + await expect(overlay).toBeVisible(); + + // Exit still ends it. + await page.locator('#rg-session-exit').click(); + await expect(overlay).toHaveCount(0); +});