fix(desktop-update): stop launching a Chrome instance for the macOS update shim

Each update started the user's Chrome binary with its own --user-data-dir,
a second instance of the same bundle that the Dock records as a new
recent-app tile. On macOS the outcome now goes through the existing
notification + next-boot result dialog.

Fixes #96374
This commit is contained in:
brooklyn!
2026-09-24 00:15:12 -05:00
parent 2054ab9cbc
commit 14a8a771de
2 changed files with 96 additions and 34 deletions

View File

@@ -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

View File

@@ -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 == []