fix(gui): inject farewell overlay into parent DOM on shutdown

Replaces the data:-URL navigation (blocked by Chrome since v60 for
top-frame navigation) with a direct DOM-append of a full-screen
overlay onto the parent document. Uses z-index 2147483647 so it sits
above Streamlit's connection-error banner when the websocket drops.

Note: still doesn't fully suppress the connection-error banner in
testing — the next iteration will render the overlay through
Streamlit's own page rather than via a component iframe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 13:49:48 +00:00
parent 340614e642
commit 701108c9d5

View File

@@ -80,6 +80,53 @@ def hide_streamlit_chrome() -> None:
# Clean shutdown # Clean shutdown
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_FAREWELL_SCRIPT = """
<script>
(function () {
// 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+).
function buildOverlay(doc) {
var overlay = doc.createElement('div');
overlay.id = 'datatools-farewell-overlay';
overlay.style.cssText =
'position:fixed;inset:0;background:#0f1115;color:#e8eaed;' +
'z-index:2147483647;display:flex;align-items:center;' +
'justify-content:center;font-family:system-ui,-apple-system,sans-serif;';
overlay.innerHTML =
'<div style="text-align:center;padding:32px 40px;border:1px solid #252a36;' +
'border-radius:12px;background:#161922;max-width:480px;">' +
'<h1 style="margin:0 0 8px 0;font-weight:600;letter-spacing:-0.01em;">' +
'DataTools has shut down</h1>' +
'<p style="opacity:0.7;margin:0;">You can close this browser tab.</p>' +
'</div>';
return overlay;
}
try {
var doc = window.top.document;
if (!doc.getElementById('datatools-farewell-overlay')) {
doc.body.appendChild(buildOverlay(doc));
}
// Try to close the tab outright too — browsers only honour this
// for tabs a script opened, so it usually no-ops, but it's free.
try { window.top.close(); } catch (e) {}
} catch (e) {
// Cross-origin access denied (shouldn't happen given Streamlit's
// sandbox flags, but fall back gracefully): cover this iframe.
document.body.appendChild(buildOverlay(document));
}
})();
</script>
"""
def quit_button(label: str = "Quit app", *, key: str = "quit_app_button") -> None: def quit_button(label: str = "Quit app", *, key: str = "quit_app_button") -> None:
"""Render a Quit button that terminates the Streamlit server. """Render a Quit button that terminates the Streamlit server.
@@ -87,12 +134,15 @@ def quit_button(label: str = "Quit app", *, key: str = "quit_app_button") -> Non
process (SIGTERM/SIGINT) does not reliably terminate it — Streamlit process (SIGTERM/SIGINT) does not reliably terminate it — Streamlit
installs its own handlers and the tornado/asyncio loop swallows or installs its own handlers and the tornado/asyncio loop swallows or
defers the signal, so the browser sees the websocket drop while the defers the signal, so the browser sees the websocket drop while the
python process stays alive. To shut down cleanly we schedule python process stays alive. We schedule ``os._exit(0)`` on a daemon
``os._exit(0)`` on a daemon thread after a short delay; the delay thread to hard-kill the process, and inject a small JS shim that
lets the current rerun finish painting the "shutting down" message either closes the tab (when allowed) or replaces the top frame with
before the process is hard-killed. a self-contained "App closed" page so the user never sees
Streamlit's red connection-error overlay.
""" """
if st.session_state.get("_app_shutting_down"): if st.session_state.get("_app_shutting_down"):
from streamlit.components.v1 import html as _components_html
_components_html(_FAREWELL_SCRIPT, height=0)
st.success("Shutting down… you can close this browser tab.") st.success("Shutting down… you can close this browser tab.")
st.stop() st.stop()
@@ -100,7 +150,7 @@ def quit_button(label: str = "Quit app", *, key: str = "quit_app_button") -> Non
st.session_state["_app_shutting_down"] = True st.session_state["_app_shutting_down"] = True
def _hard_exit() -> None: def _hard_exit() -> None:
time.sleep(0.5) time.sleep(1.0)
os._exit(0) os._exit(0)
threading.Thread(target=_hard_exit, daemon=True).start() threading.Thread(target=_hard_exit, daemon=True).start()