From 5419d869bbeebcf24c219508b99f1f99104f2250 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:25:25 -0700 Subject: [PATCH] fix(browser): real-profile detects the Windows 11 25H2 default browser Windows 11 25H2 Settings records the default browser under UrlAssociations\https\UserChoiceLatest\ProgId and stops updating the legacy UserChoice key, which UCPD.sys also write-protects. Reading only the legacy ProgId made real-profile browsing refuse a correctly configured Edge/Chrome default ("not a supported Chromium browser"), with WinError 2 when the key exists without a value, or picked a stale browser when it still held one. Resolve the effective ProgId via AssocQueryStringW(ASSOCSTR_PROGID) first, then fall back to UserChoiceLatest\ProgId, then the legacy UserChoice value. Classification (channels fail closed) is unchanged. Salvages #106971 (Shell API lookup); the UserChoiceLatest fallback follows #108064 and the reports on #107854. Co-authored-by: Konstantin Khlopkov --- hermes_cli/browser_connect.py | 104 ++++++------------ .../test_browser_connect_default_chromium.py | 92 ++++++++-------- 2 files changed, 82 insertions(+), 114 deletions(-) diff --git a/hermes_cli/browser_connect.py b/hermes_cli/browser_connect.py index fa0c3c713e..0f3c1ea201 100644 --- a/hermes_cli/browser_connect.py +++ b/hermes_cli/browser_connect.py @@ -233,89 +233,53 @@ def _classify_default(value: str, channels, table, match) -> str | None: return next((browser for frag, browser in table if match(value, frag)), None) +_WINDOWS_HTTPS_ASSOC = r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https" + + def _windows_shell_progid(scheme: str) -> str | None: - """Return the effective ProgId for a URL scheme via the Windows Shell API.""" + """Effective ProgId for a URL scheme as the Windows Shell resolves it.""" try: import ctypes from ctypes import wintypes - # ASSOCSTR_PROGID from shlwapi.h. - assocstr_progid = 20 - - assoc_query = ctypes.WinDLL( - "Shlwapi.dll", - use_last_error=True, - ).AssocQueryStringW - - assoc_query.argtypes = [ - wintypes.DWORD, - wintypes.DWORD, - wintypes.LPCWSTR, - wintypes.LPCWSTR, - wintypes.LPWSTR, - ctypes.POINTER(wintypes.DWORD), - ] - assoc_query.restype = ctypes.c_long - - length = wintypes.DWORD(0) - - # First call obtains the required output buffer size. - assoc_query( - 0, - assocstr_progid, - scheme, - None, - None, - ctypes.byref(length), - ) - - if not length.value: + query = ctypes.WinDLL("shlwapi", use_last_error=True).AssocQueryStringW + query.argtypes = [wintypes.DWORD, wintypes.DWORD, wintypes.LPCWSTR, wintypes.LPCWSTR, + wintypes.LPWSTR, ctypes.POINTER(wintypes.DWORD)] + query.restype = ctypes.c_long + buf = ctypes.create_unicode_buffer(256) + size = wintypes.DWORD(len(buf)) + # 20 = ASSOCSTR_PROGID. ASSOCSTR_EXECUTABLE can fail (0x80070483) on 25H2. + if query(0, 20, scheme, None, buf, ctypes.byref(size)) != 0: return None - - buffer = ctypes.create_unicode_buffer(length.value) - - result = assoc_query( - 0, - assocstr_progid, - scheme, - None, - buffer, - ctypes.byref(length), - ) - - return buffer.value if result == 0 else None - - except (AttributeError, OSError): + return buf.value or None + except (AttributeError, OSError): # non-Windows host return None -def _detect_default_windows() -> str | None: - # Prefer the effective association reported by the Windows Shell. - # Modern Windows builds may no longer expose it through the legacy - # UserChoice\ProgId registry value. - prog_id = _windows_shell_progid("https") - - # Compatibility fallback for older Windows versions/environments. - if not prog_id: +def _windows_registry_progid() -> str | None: + """Registry fallback. Windows 11 25H2 Settings writes only ``UserChoiceLatest\\ProgId``; + the legacy ``UserChoice`` key can be stale, missing, or present without a value.""" + try: + import winreg # type: ignore + except ImportError: + return None + for sub in (r"UserChoiceLatest\ProgId", "UserChoice"): try: - import winreg # type: ignore - - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\Shell\Associations" - r"\UrlAssociations\https\UserChoice", - ) as key: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, rf"{_WINDOWS_HTTPS_ASSOC}\{sub}") as key: prog_id, _ = winreg.QueryValueEx(key, "ProgId") + except OSError: + continue + if prog_id: + return str(prog_id) + return None - except (ImportError, OSError): - return None - return _classify_default( - str(prog_id or "").lower(), - _WINDOWS_CHANNEL_PROGIDS, - _WINDOWS_PROGID_MAP, - str.startswith, - ) +def _detect_default_windows() -> str | None: + prog_id = _windows_shell_progid("https") or _windows_registry_progid() + if not prog_id: + return None + return _classify_default(prog_id.lower(), _WINDOWS_CHANNEL_PROGIDS, + _WINDOWS_PROGID_MAP, str.startswith) def _run_stdout(argv: list[str]) -> str | None: diff --git a/tests/hermes_cli/test_browser_connect_default_chromium.py b/tests/hermes_cli/test_browser_connect_default_chromium.py index 3917a1f16b..c46903b700 100644 --- a/tests/hermes_cli/test_browser_connect_default_chromium.py +++ b/tests/hermes_cli/test_browser_connect_default_chromium.py @@ -59,53 +59,57 @@ class TestLaunchServicesHttpsHandler: assert bc._launchservices_https_handler(dump) == "com.microsoft.edgemac" +class _FakeKey(str): + def __enter__(self): + return self + + def __exit__(self, *_exc): + return False + + +class _FakeWinreg: + """``winreg`` stand-in: ``values`` maps an https association subkey to its ProgId value; + a subkey mapped to None exists but has no ProgId (``QueryValueEx`` raises WinError 2).""" + HKEY_CURRENT_USER = 0 + + def __init__(self, values): + self.values = values + + def OpenKey(self, _root, path): + sub = path.split("\\https\\", 1)[1] + if sub not in self.values: + raise FileNotFoundError(path) + return _FakeKey(sub) + + def QueryValueEx(self, sub, _name): + if self.values[sub] is None: + raise FileNotFoundError(2, "The system cannot find the file specified") + return self.values[sub], 1 + + def CloseKey(self, _key): + pass + + class TestDetectDefaultWindows: - @pytest.mark.parametrize( - "prog_id,expected", - [ - ("ChromeHTML", "chrome"), - ("ChromeHTML.123", "chrome"), - ("MSEdgeHTM", "edge"), - ("BraveHTML", "brave"), - ("BraveOHTML", "brave-origin"), - ("ChromiumHTM", "chromium"), - ("ChromeBHTML", bc.UNSUPPORTED_CHANNEL), - ("FirefoxURL", None), - ], - ) - def test_shell_progid_maps_to_browser(self, prog_id, expected): - with patch.object(bc, "_windows_shell_progid", return_value=prog_id): - assert bc._detect_default_windows() == expected - - def test_shell_progid_takes_precedence_over_legacy_registry(self): - with patch.object(bc, "_windows_shell_progid", return_value="ChromeHTML"): + def test_shell_association_wins_over_stale_legacy_userchoice(self): + stale = _FakeWinreg({"UserChoice": "VivaldiHTM.ABC"}) + with patch.object(bc, "_windows_shell_progid", return_value="ChromeHTML", create=True), \ + patch.dict("sys.modules", {"winreg": stale}): assert bc._detect_default_windows() == "chrome" + with patch.object(bc, "_windows_shell_progid", return_value="ChromeBHTML", create=True): + assert bc._detect_default_windows() == bc.UNSUPPORTED_CHANNEL - def test_shell_miss_falls_back_to_legacy_userchoice(self): - class FakeKey: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - return False - - class FakeWinreg: - HKEY_CURRENT_USER = object() - - @staticmethod - def OpenKey(*args, **kwargs): - return FakeKey() - - @staticmethod - def QueryValueEx(key, name): - assert name == "ProgId" - return "MSEdgeHTM", 1 - - with ( - patch.object(bc, "_windows_shell_progid", return_value=None), - patch.dict("sys.modules", {"winreg": FakeWinreg()}), - ): - assert bc._detect_default_windows() == "edge" + @pytest.mark.parametrize("values,expected", [ + # 25H2: Settings writes only UserChoiceLatest\ProgId; legacy key exists without a value. + ({"UserChoiceLatest\\ProgId": "MSEdgeHTM", "UserChoice": None}, "edge"), + ({"UserChoiceLatest\\ProgId": "ChromeHTML", "UserChoice": "MSEdgeHTM"}, "chrome"), + ({"UserChoice": "BraveHTML"}, "brave"), # pre-25H2 + ({"UserChoice": "FirefoxURL-308046B0AF4A39CB"}, None), + ]) + def test_registry_fallback_prefers_userchoicelatest(self, values, expected): + with patch.object(bc, "_windows_shell_progid", return_value=None, create=True), \ + patch.dict("sys.modules", {"winreg": _FakeWinreg(values)}): + assert bc._detect_default_windows() == expected class TestDetectDefaultDarwin: