diff --git a/scripts/desktop-update/posix.sh b/scripts/desktop-update/posix.sh index b343cc1787..c9df819122 100755 --- a/scripts/desktop-update/posix.sh +++ b/scripts/desktop-update/posix.sh @@ -26,7 +26,8 @@ # polls /progress for the current stage or a terminal event and reacts. The # stages come from the gates below, never from child output. It owns nothing -- # relaunch, result file, marker hygiene all happen here, identically, when -# no renderer exists. No chromium-family browser found = no UI, fine. +# no renderer exists. No chromium-family browser found = no UI, fine; macOS +# never opens one (see find_browser). # # ORDERING (the durable-truth rule): swap and relaunch are DECIDED AND # EXECUTED before the result file is written, the marker is removed, or a @@ -184,16 +185,16 @@ find_browser() { # (#88682). The throwaway --user-data-dir below cannot block either; the # remaining Chromium-family browsers carry no first-run chrome of their # own into a fresh profile. - if [ "$(uname)" = "Darwin" ]; then - for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ - "/Applications/Chromium.app/Contents/MacOS/Chromium"; do - [ -x "$c" ] && { echo "$c"; return; } - done - else - for c in google-chrome google-chrome-stable chromium chromium-browser; do - command -v "$c" 2>/dev/null && return - done - fi + # + # No browser at all on macOS. A second --user-data-dir is a second instance + # of the same bundle, and the Dock records every one as a new recent-app + # tile it never merges with the pinned browser: one more duplicate Chrome + # icon per update (#96374). A stable profile would not help (still a second + # instance). notify_fallback + the next-boot result dialog carry the outcome. + [ "$(uname)" = "Darwin" ] && return + for c in google-chrome google-chrome-stable chromium chromium-browser; do + command -v "$c" 2>/dev/null && return + done } # The shim is decoration; launching a browser the user does NOT use is not. @@ -204,28 +205,9 @@ find_browser() { # the durable result file carry the outcome. Best-effort on purpose: any # detection failure keeps today's behavior (0 = allowed). default_browser_is_chromium() { - local py="$1" handler="" - if [ "$(uname)" = "Darwin" ]; then - local plist="$HOME/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist" - # No explicit https handler registered = the OS default (Safari). - [ -f "$plist" ] || return 1 - handler="$("$py" -c ' -import plistlib, sys -with open(sys.argv[1], "rb") as f: - data = plistlib.load(f) -for entry in data.get("LSHandlers", []): - if entry.get("LSHandlerURLScheme") == "https": - print(entry.get("LSHandlerRoleAll", "")) - break -' "$plist" 2>/dev/null)" || return 0 - # Parsed but empty = no https override = Safari default. - [ -n "$handler" ] || return 1 - case "$handler" in - com.google.[Cc]hrome*|org.chromium.[Cc]hromium*) return 0 ;; - *) return 1 ;; - esac - fi - # Linux: xdg-settings is the authority; missing tool = permissive. + local handler="" + # Linux only (find_browser never picks one on macOS). xdg-settings is the + # authority; missing tool = permissive. command -v xdg-settings >/dev/null 2>&1 || return 0 handler="$(xdg-settings get default-web-browser 2>/dev/null)" || return 0 [ -n "$handler" ] || return 0 @@ -241,7 +223,7 @@ start_ui() { py="${INSTALL_ROOT:+$INSTALL_ROOT/venv/bin/python3}" [ -x "${py:-/nonexistent}" ] || py="$(command -v python3 2>/dev/null)" browser="$(find_browser)" - if [ -n "$browser" ] && [ -n "$py" ] && ! default_browser_is_chromium "$py"; then + if [ -n "$browser" ] && ! default_browser_is_chromium; then log "shim: default browser is not Chromium-family; skipping UI window" browser="" fi diff --git a/tests/scripts/desktop_update/test_desktop_update_macos_no_browser.py b/tests/scripts/desktop_update/test_desktop_update_macos_no_browser.py new file mode 100644 index 0000000000..49e52cf8fa --- /dev/null +++ b/tests/scripts/desktop_update/test_desktop_update_macos_no_browser.py @@ -0,0 +1,80 @@ +"""The macOS hand-off never launches a second browser instance (#96374). + +The progress shim used to start the user's Google Chrome/Chromium binary +directly with its own `--user-data-dir`. That is a second instance of the +same bundle, and the Dock records each one as another recent-app tile that it +never merges with the pinned browser, so every update added one more +duplicate icon. On macOS the outcome goes through the notification + +next-boot result dialog instead. This drives the real `posix.sh` +`--self-test-ui` path with Chrome set as the default browser and watches the +process table for a browser pointed at the shim. +""" + +from __future__ import annotations + +import os +import plistlib +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SHIM = REPO_ROOT / "scripts" / "desktop-update" / "posix.sh" + + +def _shim_browser_processes(tmp_path: Path) -> list[str]: + ps = subprocess.run( + ["ps", "-axww", "-o", "command="], capture_output=True, text=True, check=False + ) + return [ + line + for line in ps.stdout.splitlines() + if "--app=" in line and f"--user-data-dir={tmp_path}" in line + ] + + +@pytest.mark.macos_only +def test_macos_handoff_opens_no_browser_window_when_chrome_is_default(tmp_path): + # Chrome is the system default https handler: the case the + # default-browser gate let through. + prefs = tmp_path / "Library" / "Preferences" / "com.apple.LaunchServices" + prefs.mkdir(parents=True) + with open(prefs / "com.apple.launchservices.secure.plist", "wb") as f: + plistlib.dump( + {"LSHandlers": [{"LSHandlerURLScheme": "https", "LSHandlerRoleAll": "com.google.chrome"}]}, + f, + ) + install = tmp_path / "hermes-agent" + install.mkdir() + env = { + **os.environ, + "HOME": str(tmp_path), + "TMPDIR": str(tmp_path), + "PATH": f"{Path(sys.executable).parent}:/usr/bin:/bin", + "HERMES_SELFTEST_HOLD_SECONDS": "3", + } + env.pop("HERMES_SELFTEST_FAIL", None) + + proc = subprocess.Popen( + ["bash", str(SHIM), "--install-root", str(install), "--self-test-ui"], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + seen: list[str] = [] + try: + while proc.poll() is None: + seen += _shim_browser_processes(tmp_path) + time.sleep(0.2) + stdout, stderr = proc.communicate(timeout=30) + finally: + if proc.poll() is None: + proc.kill() + proc.wait(timeout=5) + + assert proc.returncode == 0, stdout + stderr + assert seen == []