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 <konstantin.khlopkov93@gmail.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user