fix(close): graceful about:blank fallback + display-mode aware hint
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) <noreply@anthropic.com>
This commit is contained in:
@@ -121,20 +121,27 @@ _FAREWELL_SCRIPT_TEMPLATE = """
|
|||||||
// Strategy: append a full-screen overlay directly to the parent's
|
// Strategy: append a full-screen overlay directly to the parent's
|
||||||
// document.body (Streamlit's component iframes carry
|
// document.body (Streamlit's component iframes carry
|
||||||
// allow-same-origin, so cross-frame DOM access is permitted).
|
// 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
|
// Closing the tab via JavaScript only works in windows JS opened —
|
||||||
// top-frame navigation to data: URLs (anti-phishing, Chrome 60+).
|
// 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
|
// Display-mode detection (``standalone`` for --app windows,
|
||||||
// fallback for browsers that block the auto-close. ``window.close()``
|
// ``browser`` for regular tabs) lets us skip the futile close
|
||||||
// only succeeds when the tab was JS-opened (Chrome --app windows
|
// attempt on regular tabs and route straight to the about:blank
|
||||||
// qualify; regular browser tabs do not) — the button click counts
|
// fallback.
|
||||||
// as a user gesture and gives one more chance. A short timeout
|
function isStandalone(win) {
|
||||||
// reveals a Ctrl+W hint if the browser still refused.
|
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) {
|
function buildOverlay(doc) {
|
||||||
var overlay = doc.createElement('div');
|
var overlay = doc.createElement('div');
|
||||||
overlay.id = 'datatools-farewell-overlay';
|
overlay.id = 'datatools-farewell-overlay';
|
||||||
@@ -160,35 +167,43 @@ _FAREWELL_SCRIPT_TEMPLATE = """
|
|||||||
return overlay;
|
return overlay;
|
||||||
}
|
}
|
||||||
function tryClose(win) {
|
function tryClose(win) {
|
||||||
// Three escalating attempts. Modern browsers only honour close()
|
// Escalating attempts. None of these can override the browser's
|
||||||
// for windows that JS opened (Chrome --app windows qualify; a
|
// close-restriction policy on regular tabs.
|
||||||
// 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.
|
|
||||||
try { win.close(); } catch (e) {}
|
try { win.close(); } catch (e) {}
|
||||||
if (win.closed) return;
|
if (win.closed) return true;
|
||||||
try {
|
try {
|
||||||
var w = win.open('', '_self', '');
|
var w = win.open('', '_self', '');
|
||||||
if (w) {
|
if (w) {
|
||||||
try { w.close(); } catch (e) {}
|
try { w.close(); } catch (e) {}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
if (win.closed) return;
|
if (win.closed) return true;
|
||||||
try { win.top.close(); } catch (e) {}
|
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) {
|
function wireClose(doc, win) {
|
||||||
var btn = doc.getElementById('datatools-close-btn');
|
var btn = doc.getElementById('datatools-close-btn');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
btn.onclick = function () {
|
btn.onclick = function () {
|
||||||
tryClose(win);
|
var standalone = isStandalone(win);
|
||||||
// If after 250ms the window is still here, the browser
|
if (tryClose(win)) return;
|
||||||
// blocked the close — show the manual-close hint.
|
// 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 () {
|
setTimeout(function () {
|
||||||
if (win.closed) return;
|
if (!win.closed) fallbackToBlank(win);
|
||||||
var hint = doc.getElementById('datatools-close-hint');
|
}, standalone ? 250 : 1500);
|
||||||
if (hint) hint.style.display = 'block';
|
|
||||||
}, 250);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -199,7 +214,9 @@ _FAREWELL_SCRIPT_TEMPLATE = """
|
|||||||
}
|
}
|
||||||
wireClose(doc, win);
|
wireClose(doc, win);
|
||||||
// Auto-close attempt on first paint — succeeds in Chrome --app
|
// 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);
|
tryClose(win);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cross-origin access denied (shouldn't happen given Streamlit's
|
// Cross-origin access denied (shouldn't happen given Streamlit's
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
"farewell_title": "DataTools has shut down",
|
"farewell_title": "DataTools has shut down",
|
||||||
"farewell_subtitle": "You can close this window.",
|
"farewell_subtitle": "You can close this window.",
|
||||||
"close_window_button": "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": {
|
"close_page": {
|
||||||
"page_title": "DataTools — Close",
|
"page_title": "DataTools — Close",
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
"farewell_title": "DataTools se ha cerrado",
|
"farewell_title": "DataTools se ha cerrado",
|
||||||
"farewell_subtitle": "Ya puedes cerrar esta ventana.",
|
"farewell_subtitle": "Ya puedes cerrar esta ventana.",
|
||||||
"close_window_button": "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": {
|
"close_page": {
|
||||||
"page_title": "DataTools — Cerrar",
|
"page_title": "DataTools — Cerrar",
|
||||||
|
|||||||
Reference in New Issue
Block a user