From ef9f8b5de454d35a734b3fb191ef998f74d6dcf9 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 16 May 2026 21:17:18 +0000 Subject: [PATCH] fix(close): Edge fallback + better tryClose + honest hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/gui/__main__.py | 70 +++++++++++++++++++++++++++-------- src/gui/components/_legacy.py | 26 +++++++++++-- src/i18n/packs/en.json | 2 +- src/i18n/packs/es.json | 2 +- 4 files changed, 78 insertions(+), 22 deletions(-) diff --git a/src/gui/__main__.py b/src/gui/__main__.py index 0f4b346..b479b96 100644 --- a/src/gui/__main__.py +++ b/src/gui/__main__.py @@ -16,23 +16,42 @@ from pathlib import Path _URL = "http://localhost:8501" -def _find_chrome() -> str | None: - """Return the path to a Chrome/Chromium executable, or None.""" - # Check PATH first - for name in ("chrome", "google-chrome", "google-chrome-stable", "chromium", "chromium-browser"): +def _find_app_browser() -> str | None: + """Return the path to a Chromium-based browser that supports --app mode. + + 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) if found: return found - # Windows common install locations + # Windows common install locations. if sys.platform == "win32": candidates = [ + # Chrome — preferred when present. Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "Google" / "Chrome" / "Application" / "chrome.exe", Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) / "Google" / "Chrome" / "Application" / "chrome.exe", Path(os.environ.get("LOCALAPPDATA", "")) / "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: if c.exists(): @@ -40,23 +59,42 @@ def _find_chrome() -> str | None: # macOS if sys.platform == "darwin": - mac_chrome = Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") - if mac_chrome.exists(): - return str(mac_chrome) + candidates = [ + Path("/Applications/Google Chrome.app/Contents/MacOS/Google 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 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) - chrome = _find_chrome() - if chrome: - subprocess.Popen([chrome, f"--app={_URL}"]) - else: - # Fallback: open in default browser (will have address bar) - import webbrowser - webbrowser.open(_URL) + browser = _find_app_browser() + if browser: + subprocess.Popen([browser, f"--app={_URL}"]) + return + 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 + webbrowser.open(_URL) app_path = Path(__file__).parent / "app.py" diff --git a/src/gui/components/_legacy.py b/src/gui/components/_legacy.py index f110129..5bf5707 100644 --- a/src/gui/components/_legacy.py +++ b/src/gui/components/_legacy.py @@ -159,15 +159,33 @@ _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. + 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) { var btn = doc.getElementById('datatools-close-btn'); if (!btn) return; btn.onclick = function () { - try { win.close(); } catch (e) {} - try { win.top.close(); } catch (e) {} + tryClose(win); // If after 250ms the window is still here, the browser // blocked the close — show the manual-close hint. setTimeout(function () { + if (win.closed) return; var hint = doc.getElementById('datatools-close-hint'); if (hint) hint.style.display = 'block'; }, 250); @@ -180,9 +198,9 @@ _FAREWELL_SCRIPT_TEMPLATE = """ doc.body.appendChild(buildOverlay(doc)); } 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. - try { win.close(); } catch (e) {} + tryClose(win); } catch (e) { // Cross-origin access denied (shouldn't happen given Streamlit's // sandbox flags, but fall back gracefully): cover this iframe. diff --git a/src/i18n/packs/en.json b/src/i18n/packs/en.json index 69c525f..26cc762 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 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": { "page_title": "DataTools — Close", diff --git a/src/i18n/packs/es.json b/src/i18n/packs/es.json index 63279b9..4f7eba3 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 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": { "page_title": "DataTools — Cerrar",