From e96d5901f4c66a42034a82fa0b5d05ab30a96c74 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 17 May 2026 00:07:51 +0000 Subject: [PATCH] fix(close): graceful about:blank fallback + display-mode aware hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: user asked whether we can send Alt+F4 / Ctrl+W to the browser from JavaScript to force-close a tab. Honest answer that's now baked into the hint message: NO. Synthesized keyboard events from page JS only reach DOM event listeners, not the browser chrome or the OS. There is no flag, API, or trick that lets a page close a tab the user opened themselves. The page CAN close a window it opened (window.opener trail) or one whose display-mode is ``standalone`` (Chrome/Edge ``--app=URL``) — that's what ``python -m src.gui`` arranges, and that's the path that actually closes the window without a manual Ctrl+W. Improvements landed: 1. ``isStandalone(win)`` detects Chrome --app windows up front (``matchMedia('(display-mode: standalone)').matches``). In a regular tab the manual hint surfaces immediately on the "Close this window" click; in --app mode we only show it if the close attempt actually fails. 2. ``fallbackToBlank(win)`` navigates the tab to ``about:blank`` via ``location.replace`` (no history pollution) so the user sees a clean empty tab instead of the farewell overlay frozen over Streamlit's connection-error banner. They still have to Ctrl+W the blank tab, but the screen is no longer a misleading "did it close or not?" mess. Fires 250 ms after a failed close in --app mode (very rare path), or 1.5 s in a regular tab so the user has time to read the hint. 3. Hint message rewritten in en + es to explain WHY the close is blocked (browser security — not something we can override), to acknowledge the Alt+F4 / Ctrl+W question directly (those don't work either, for the same reason), and to point at ``python -m src.gui`` as the path that gives a clean auto-close. 2220 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/gui/components/_legacy.py | 73 +++++++++++++++++++++-------------- src/i18n/packs/en.json | 2 +- src/i18n/packs/es.json | 2 +- 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/src/gui/components/_legacy.py b/src/gui/components/_legacy.py index 5acac17..bee8daf 100644 --- a/src/gui/components/_legacy.py +++ b/src/gui/components/_legacy.py @@ -121,20 +121,27 @@ _FAREWELL_SCRIPT_TEMPLATE = """ // Strategy: append a full-screen overlay directly to the parent's // document.body (Streamlit's component iframes carry // allow-same-origin, so cross-frame DOM access is permitted). - // The overlay uses z-index 2147483647 (max int32) so it sits on - // top of Streamlit's "Connection error" banner when the websocket - // drops a moment later. It's appended as a sibling of React's - // root, so React's re-renders won't touch it. // - // We don't navigate to a data: URL here because Chrome blocks - // top-frame navigation to data: URLs (anti-phishing, Chrome 60+). + // Closing the tab via JavaScript only works in windows JS opened — + // Chrome/Edge --app windows qualify; a regular browser tab does + // NOT, and there's no way to override that from page JS (no flag, + // no API, no keystroke injection — synthesized keydown events + // never reach the browser chrome or the OS). When close fails we + // navigate the window to ``about:blank`` so the user at least + // sees a clean blank tab instead of the connection-error overlay + // Streamlit shows when the websocket drops. // - // The overlay carries a manual "Close this window" button as a - // fallback for browsers that block the auto-close. ``window.close()`` - // only succeeds when the tab was JS-opened (Chrome --app windows - // qualify; regular browser tabs do not) — the button click counts - // as a user gesture and gives one more chance. A short timeout - // reveals a Ctrl+W hint if the browser still refused. + // Display-mode detection (``standalone`` for --app windows, + // ``browser`` for regular tabs) lets us skip the futile close + // attempt on regular tabs and route straight to the about:blank + // fallback. + function isStandalone(win) { + try { + return win.matchMedia('(display-mode: standalone)').matches + || win.matchMedia('(display-mode: minimal-ui)').matches + || win.matchMedia('(display-mode: fullscreen)').matches; + } catch (e) { return false; } + } function buildOverlay(doc) { var overlay = doc.createElement('div'); overlay.id = 'datatools-farewell-overlay'; @@ -160,35 +167,43 @@ _FAREWELL_SCRIPT_TEMPLATE = """ return overlay; } function tryClose(win) { - // Three escalating attempts. Modern browsers only honour close() - // for windows that JS opened (Chrome --app windows qualify; a - // regular browser tab does not) — there is no JS override for - // that policy. The window.open('', '_self') trick used to rewrite - // a window's "opener" lineage and let close() through in older - // Chrome; modern Chrome patched it but it costs nothing to try. + // Escalating attempts. None of these can override the browser's + // close-restriction policy on regular tabs. try { win.close(); } catch (e) {} - if (win.closed) return; + if (win.closed) return true; try { var w = win.open('', '_self', ''); if (w) { try { w.close(); } catch (e) {} } } catch (e) {} - if (win.closed) return; + if (win.closed) return true; try { win.top.close(); } catch (e) {} + return win.closed; + } + function fallbackToBlank(win) { + // Navigate to about:blank so the user sees a clean empty tab + // instead of the farewell overlay frozen on a connection-error + // page. They can still close the tab themselves (Ctrl+W / + // ⌘W / clicking the tab's X). Done as a single fast call — no + // history entry pollution because location.replace doesn't + // push to history. + try { win.location.replace('about:blank'); } catch (e) {} } function wireClose(doc, win) { var btn = doc.getElementById('datatools-close-btn'); if (!btn) return; btn.onclick = function () { - tryClose(win); - // If after 250ms the window is still here, the browser - // blocked the close — show the manual-close hint. + var standalone = isStandalone(win); + if (tryClose(win)) return; + // Close failed (or definitely will fail in a regular tab). + // Surface the hint immediately, then redirect to about:blank + // after a short delay so the user has a moment to read why. + var hint = doc.getElementById('datatools-close-hint'); + if (hint) hint.style.display = 'block'; setTimeout(function () { - if (win.closed) return; - var hint = doc.getElementById('datatools-close-hint'); - if (hint) hint.style.display = 'block'; - }, 250); + if (!win.closed) fallbackToBlank(win); + }, standalone ? 250 : 1500); }; } try { @@ -199,7 +214,9 @@ _FAREWELL_SCRIPT_TEMPLATE = """ } wireClose(doc, win); // Auto-close attempt on first paint — succeeds in Chrome --app - // windows, no-ops on regular tabs. + // windows, fails silently on regular tabs (and we don't redirect + // automatically here; the manual button drives that path so the + // user is in control). tryClose(win); } catch (e) { // Cross-origin access denied (shouldn't happen given Streamlit's diff --git a/src/i18n/packs/en.json b/src/i18n/packs/en.json index 26cc762..3548628 100644 --- a/src/i18n/packs/en.json +++ b/src/i18n/packs/en.json @@ -53,7 +53,7 @@ "farewell_title": "DataTools has shut down", "farewell_subtitle": "You can close this window.", "close_window_button": "Close this window", - "close_hint": "Your browser is blocking the close — this is a security restriction on tabs you opened yourself, not something we can override. Press Ctrl+W (or ⌘W on Mac) to close this tab. Launch DataTools with python -m src.gui to get an auto-closing app window." + "close_hint": "Browsers don't let JavaScript close a tab you opened yourself — and they don't let it send Ctrl+W or Alt+F4 either (those keystrokes are intercepted by the OS, not the page). Launch DataTools with `python -m src.gui` to get a Chrome/Edge --app window that DOES close cleanly. In the meantime, this tab will fall back to a blank page in a moment — close it manually with Ctrl+W (or ⌘W on Mac)." }, "close_page": { "page_title": "DataTools — Close", diff --git a/src/i18n/packs/es.json b/src/i18n/packs/es.json index 4f7eba3..3e67db8 100644 --- a/src/i18n/packs/es.json +++ b/src/i18n/packs/es.json @@ -53,7 +53,7 @@ "farewell_title": "DataTools se ha cerrado", "farewell_subtitle": "Ya puedes cerrar esta ventana.", "close_window_button": "Cerrar esta ventana", - "close_hint": "Tu navegador está bloqueando el cierre — es una restricción de seguridad para pestañas que abriste tú mismo, no algo que podamos anular. Presiona Ctrl+W (o ⌘W en Mac) para cerrar esta pestaña. Ejecuta DataTools con python -m src.gui para tener una ventana de aplicación que se cierra sola." + "close_hint": "Los navegadores no permiten que JavaScript cierre una pestaña que tú abriste — tampoco pueden enviar Ctrl+W ni Alt+F4 (esas combinaciones las intercepta el SO, no la página). Ejecuta DataTools con `python -m src.gui` para obtener una ventana Chrome/Edge --app que sí se cierra. Mientras tanto, esta pestaña pasará a una página en blanco en un momento — ciérrala con Ctrl+W (o ⌘W en Mac)." }, "close_page": { "page_title": "DataTools — Cerrar",