fix(close): Edge fallback + better tryClose + honest hint

There is no JavaScript override for browser tab-close security:
``window.close()`` only succeeds on windows JS opened (Chrome --app
windows qualify; a regular browser tab does not). What we can do is
make the --app path easier to hit and the failure case more
actionable.

Three changes:

1. ``src/gui/__main__.py`` — extend browser detection. PATH lookup
   now also looks for ``msedge`` / ``microsoft-edge``; Windows install
   candidates include the Edge install path; macOS candidates include
   Edge and Chromium. Edge is Chromium-based, supports ``--app``, and
   ships on every Windows 10+ machine — so users without Chrome no
   longer fall through to the regular browser tab. When the fallback
   IS hit, print a warning to stderr explaining why Close-from-page
   will require Ctrl+W. Renamed ``_find_chrome`` to
   ``_find_app_browser`` to reflect the broader scope.

2. ``_FAREWELL_SCRIPT_TEMPLATE`` in ``components/_legacy.py`` —
   factor close attempts into a ``tryClose`` helper that runs three
   escalating tries: standard ``win.close()``, the
   ``win.open('', '_self')`` history-rewrite trick (no-op in modern
   Chrome but free), and ``win.top.close()``. Auto-close on paint AND
   the manual button now both call this helper. Skip the manual hint
   if the close eventually succeeded between the click and the 250 ms
   timeout.

3. ``quit.close_hint`` in en/es i18n packs — rewrite the message to
   tell the user honestly that this is a browser security restriction,
   tell them the Ctrl+W keystroke that works, and point them at
   ``python -m src.gui`` for the auto-closing app-mode experience.

2008 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:17:18 +00:00
parent aeead05e4c
commit ef9f8b5de4
4 changed files with 78 additions and 22 deletions

View File

@@ -16,23 +16,42 @@ from pathlib import Path
_URL = "http://localhost:8501" _URL = "http://localhost:8501"
def _find_chrome() -> str | None: def _find_app_browser() -> str | None:
"""Return the path to a Chrome/Chromium executable, or None.""" """Return the path to a Chromium-based browser that supports --app mode.
# Check PATH first
for name in ("chrome", "google-chrome", "google-chrome-stable", "chromium", "chromium-browser"): Chrome is preferred. Edge (Chromium-based, ships on every Windows
10+ install) is the fallback that lets ``window.close()`` actually
close the tab on the Close page — without it Windows users who
don't have Chrome end up in a regular browser tab where the close
button is silently blocked by browser security.
"""
# PATH first — covers Linux package installs and devs who put their
# preferred browser on PATH explicitly.
for name in (
"chrome", "google-chrome", "google-chrome-stable",
"chromium", "chromium-browser",
"msedge", "microsoft-edge",
):
found = shutil.which(name) found = shutil.which(name)
if found: if found:
return found return found
# Windows common install locations # Windows common install locations.
if sys.platform == "win32": if sys.platform == "win32":
candidates = [ candidates = [
# Chrome — preferred when present.
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
/ "Google" / "Chrome" / "Application" / "chrome.exe", / "Google" / "Chrome" / "Application" / "chrome.exe",
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
/ "Google" / "Chrome" / "Application" / "chrome.exe", / "Google" / "Chrome" / "Application" / "chrome.exe",
Path(os.environ.get("LOCALAPPDATA", "")) Path(os.environ.get("LOCALAPPDATA", ""))
/ "Google" / "Chrome" / "Application" / "chrome.exe", / "Google" / "Chrome" / "Application" / "chrome.exe",
# Edge — ships with Windows 10+, Chromium-based, also
# supports --app and the window.close() it enables.
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"))
/ "Microsoft" / "Edge" / "Application" / "msedge.exe",
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files"))
/ "Microsoft" / "Edge" / "Application" / "msedge.exe",
] ]
for c in candidates: for c in candidates:
if c.exists(): if c.exists():
@@ -40,21 +59,40 @@ def _find_chrome() -> str | None:
# macOS # macOS
if sys.platform == "darwin": if sys.platform == "darwin":
mac_chrome = Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") candidates = [
if mac_chrome.exists(): Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
return str(mac_chrome) Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
]
for c in candidates:
if c.exists():
return str(c)
return None return None
def _open_app_window() -> None: def _open_app_window() -> None:
"""Wait for the server, then open Chrome in app mode (no address bar).""" """Wait for the server, then open the URL in an --app-mode window.
--app mode makes the window look like a desktop app (no address bar,
no tabs) AND — crucially — makes ``window.close()`` work, because
the browser treats --app windows as programmatically closable. The
regular-browser fallback prints a warning so the user knows why the
Close button on the closing screen will require a manual Ctrl+W.
"""
time.sleep(2) time.sleep(2)
chrome = _find_chrome() browser = _find_app_browser()
if chrome: if browser:
subprocess.Popen([chrome, f"--app={_URL}"]) subprocess.Popen([browser, f"--app={_URL}"])
else: return
# Fallback: open in default browser (will have address bar) print(
"DataTools: Chrome and Edge were not found in their standard "
"locations. Falling back to the default browser. The Close "
"button on the shutdown screen will likely require a manual "
"Ctrl+W to close the tab (a browser security restriction "
"prevents JavaScript from closing user-opened tabs).",
file=sys.stderr,
)
import webbrowser import webbrowser
webbrowser.open(_URL) webbrowser.open(_URL)

View File

@@ -159,15 +159,33 @@ _FAREWELL_SCRIPT_TEMPLATE = """
'</div>'; '</div>';
return overlay; 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.
try { win.close(); } catch (e) {}
if (win.closed) return;
try {
var w = win.open('', '_self', '');
if (w) {
try { w.close(); } catch (e) {}
}
} catch (e) {}
if (win.closed) return;
try { win.top.close(); } 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 () {
try { win.close(); } catch (e) {} tryClose(win);
try { win.top.close(); } catch (e) {}
// If after 250ms the window is still here, the browser // If after 250ms the window is still here, the browser
// blocked the close — show the manual-close hint. // blocked the close — show the manual-close hint.
setTimeout(function () { setTimeout(function () {
if (win.closed) return;
var hint = doc.getElementById('datatools-close-hint'); var hint = doc.getElementById('datatools-close-hint');
if (hint) hint.style.display = 'block'; if (hint) hint.style.display = 'block';
}, 250); }, 250);
@@ -180,9 +198,9 @@ _FAREWELL_SCRIPT_TEMPLATE = """
doc.body.appendChild(buildOverlay(doc)); doc.body.appendChild(buildOverlay(doc));
} }
wireClose(doc, win); wireClose(doc, win);
// Try to close the tab outright too — succeeds in Chrome --app // Auto-close attempt on first paint — succeeds in Chrome --app
// windows, no-ops on regular tabs. // windows, no-ops on regular tabs.
try { win.close(); } catch (e) {} 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
// sandbox flags, but fall back gracefully): cover this iframe. // sandbox flags, but fall back gracefully): cover this iframe.

View File

@@ -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 blocked the close. Press Ctrl+W (or ⌘W on Mac) to close this tab manually." "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_page": { "close_page": {
"page_title": "DataTools — Close", "page_title": "DataTools — Close",

View File

@@ -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 bloqueó el cierre. Presiona Ctrl+W (o ⌘W en Mac) para cerrar esta pestaña manualmente." "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_page": { "close_page": {
"page_title": "DataTools — Cerrar", "page_title": "DataTools — Cerrar",