Repository: nvaccess/nvda

Issue:
Windows 10 versions: use Windows Registry to obtain release Id/display name
Hi,

I'd be happy to provide a pull request regarding this - after all, what I'm proposing was already implemented as part of an add-on.

### Introduction
As part of Windows as a Service (WaaS) model, Microsoft releases new releases of Windows every six months or so. As of June 2021, the latest release is 21H1 (May 2021 Update).

Until Windows 10 RTM (Version 1507/build 10240), one way to determine Windows releases was build number (Windows 10 uses Windows NT version 10.0 internally). With Version 1511 (November Update released in 2015), Microsoft added "ReleaseId" (aka YYMM) to identify releases, with this info displayed as part of About Windows (WinVer) dialog. The release Id can be found under the Windows Registry path HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion.

Then in 2020, Microsoft made two related changes: the codename for a feature update became a public name for a semi-annual update, the best known being Version 20H2 (October 2020 Update). To reflect this change, a new key named "DisplayName" was added to the mentioned Registry path, which is used by WinVer instead of ReleaseId when displaying version information. If you recall, a few months ago we had a discussion about Windows 10 versions to be used (2009 versus 20H2) as part of #11795 (see that issue for details), and the new Registry key is the reason why.

The picture became complicated in 2021. As of June 2021, Microsoft is working on at least four code bases: Vibranium (2004/20H2/21H1), Iron Server 2022 and initial (and now canceled) version of Windows 10 X), Cobalt (a likely 21H2 build), and Nickel (next year and first bits may show up to dev channel Windows Insiders in a few weeks). Consequently, four build ranges were spotted by Windows Insiders: 1904x for Vibranium, 203xx for Iron, 21xxx for Cobalt, 22xxx for Nickel. As a result, it becomes harder to keep track of which release is which in the current Windows 10 version implementation, which is described below.

### Is your feature request related to a problem? Please describe.
Currently winversion module includes a record of which Windows 10 release is tied to a specific build. This is used by winVersion.WinVersion class to display Windows 10 release information.

As described above, with multiple code bases being developed, it becomes harder to associate a feature update with a specific build number. This will become an issue when recognizing 21H2 later this year, as one needs to use a tuple or a similar structure to record multiple builds for a given release and look up the correct index based on product type.

### Describe the solution you'd like
Retrieve Windows 10 version information (Release Id/display name) directly from Windows Registry, and to use an updated releases to builds map as a fallback if Registry access is denied. In short, the following solution (source: Resource Monitor add-on which I am currently maintaining) is proposed:

1. For Windows 10 build 10240, return "1507".
2. Look for "DisplayName" key from the Registry path noted above.
3. If it fails, look up "ReleaseId" key.
4. If Registry access is completely denied, use the refined Windows 10 releases to builds map as a fallback. I expect it won't be necessary unless Registry access is denied to user programs such as NVDA.

This solution is applicable to Windows Server releases as well i.e. Server 2019 will say "Windows 10 1809" when winVersion.GetWinVer() is called.

The biggest advantage is that there is no need to add entries to releases to builds map when making NVDA recognize new Windows 10/Server releases - all the developer needs to do is add a WIN10_* constant (for Server 2022, if it turns out the build number is different then client releases, I think it would be better to call it WIN10_VER_SERVER, or perhaps WINSERVER_2022) since the needed info comes from Windows Registry. Plus, for Insiders on dev channel, display name is "Dev", making it easier for developers to determine which Windows 10 release is in use by simply looking for "Windows 10 whatever".

The biggest issue is prerelease builds that are being tested by beta and release preview Insiders. Prior to general availability, Microsoft assigns a specific build as the feature update build, and this will be noticed by beta and release preview Insiders ahead of time by months to weeks, respectively. Therefore, to make sure people can add the correct Windows version information/constants, I propose that people should wait until a feature update is released to release preview Insiders before sending a pull request to update winVersion module (this was the route I took to add 21H1 information a few weeks ago).

Perhaps an unlikely issue is Registry access denial. I doubt this will be the case, but just in case this happens, I think existing releases to builds map should be used as a fallback to some degree.

### Describe alternatives you've considered
Leave it as is and refine releases to builds map to account for a given feature release using different build numbers based on product type. One approach (although folks will need to wait until 2022.1 cycle) is change releases to builds map so that it records builds:releases instead of releases:builds.

### Additional context
For a practical implementation, see:
https://github.com/josephsl/resourceMonitor

Don't worry about Windows Server LTS bits as it is not applicable to this issue. Also, if anyone would like to base a PR on bits of resource Monitor, feel free to do so.

Thanks.


Discussion:
Hi,

For now releases to builds map will be left as is. The upcoming PR will add Registry checks as part of Windows version announcement method unless there is a need to create a separate function just for Windows 10 releases.

Thanks.

Resolving patch:
diff --git a/source/winVersion.py b/source/winVersion.py
--- a/source/winVersion.py
+++ b/source/winVersion.py
@@ -8,9 +8,55 @@
 making sure NVDA can run on a minimum supported version of Windows.
 """
 
+from typing import Optional
 import sys
 import os
 import functools
+import winreg
+from buildVersion import version_year
+
+
+# Records a mapping between Windows builds and release names.
+# These include build 10240 for Windows 10 1507 and releases with multiple release builds.
+# These are applicable to Windows 10 as they report the same system version (10.0).
+_BUILDS_TO_RELEASE_NAMES = {
+	10240: "Windows 10 1507",
+	10586: "Windows 10 1511",
+	14393: "Windows 10 1607",
+	15063: "Windows 10 1703",
+	16299: "Windows 10 1709",
+	17134: "Windows 10 1803",
+	17763: "Windows 10 1809",
+	18362: "Windows 10 1903",
+	18363: "Windows 10 1909",
+	19041: "Windows 10 2004",
+	19042: "Windows 10 20H2",
+	19043: "Windows 10 21H1"
+}
+
+
+@functools.lru_cache(maxsize=1)
+def _getRunningVersionNameFromWinReg() -> str:
+	"""Returns the Windows release name defined in Windows Registry.
+	This is applicable on Windows 10 Version 1511 (build 10586) and later.
+	"""
+	# Release name is recorded in Windows Registry from Windows 10 Version 1511 (build 10586) onwards.
+	if getWinVer() < WIN10_1511:
+		raise RuntimeError("Release name is not recorded in Windows Registry on this version of Windows")
+	# Cache the version in use on the system.
+	with winreg.OpenKey(
+		winreg.HKEY_LOCAL_MACHINE, r"Software\Microsoft\Windows NT\CurrentVersion"
+	) as currentVersion:
+		# Version 20H2 and later where a separate display version string is used.
+		try:
+			releaseId = winreg.QueryValueEx(currentVersion, "DisplayVersion")[0]
+		except OSError:
+			# Don't set anything if this is Windows 10 1507 or earlier.
+			try:
+				releaseId = winreg.QueryValueEx(currentVersion, "ReleaseID")[0]
+			except OSError:
+				releaseId = ""
+	return releaseId
 
 
 @functools.total_ordering
@@ -26,20 +72,26 @@ def __init__(
 			major: int = 0,
 			minor: int = 0,
 			build: int = 0,
+			releaseName: Optional[str] = None,
 			servicePack: str = "",
 			productType: str = ""
 	):
 		self.major = major
 		self.minor = minor
 		self.build = build
+		if releaseName:
+			self.releaseName = releaseName
+		else:
+			self.releaseName = self._getWindowsReleaseName()
 		self.servicePack = servicePack
 		self.productType = productType
 
-	def _windowsVersionToReleaseName(self):
-		"""Returns release names for a given Windows version.
+	def _getWindowsReleaseName(self) -> str:
+		"""Returns the public release name for a given Windows release based on major, minor, and build.
+		This is useful if release names are not defined when constructing this class.
 		For example, 6.1 will return 'Windows 7'.
 		For Windows 10, feature update release name will be included.
-		On server systems, client release names will be returned.
+		On server systems, unless noted otherwise, client release names will be returned.
 		For example, 'Windows 10 1809' will be returned on Server 2019 systems.
 		"""
 		if (self.major, self.minor) == (6, 1):
@@ -49,17 +101,18 @@ def _windowsVersionToReleaseName(self):
 		elif (self.major, self.minor) == (6, 3):
 			return "Windows 8.1"
 		elif self.major == 10:
-			buildsToReleases = {build: release for release, build in WIN10_RELEASE_NAME_TO_BUILDS.items()}
-			if self.build in buildsToReleases:
-				return f"Windows 10 {buildsToReleases[self.build]}"
-			else:
-				# Windows Insider build.
-				return "Windows 10 prerelease"
+			# From Version 1511 (build 10586), release Id/display version comes from Windows Registry.
+			# However there are builds with no release name (Version 1507/10240)
+			# or releases with different builds.
+			# Look these up first before asking Windows Registry.
+			if self.build in _BUILDS_TO_RELEASE_NAMES:
+				return _BUILDS_TO_RELEASE_NAMES[self.build]
+			return "Windows 10 unknown"
 		else:
-			raise RuntimeError("Unknown Windows release")
+			return "Windows release unknown"
 
 	def __repr__(self):
-		winVersionText = [self._windowsVersionToReleaseName()]
+		winVersionText = [self.releaseName]
 		winVersionText.append(f"({self.major}.{self.minor}.{self.build})")
 		if self.servicePack != "":
 			winVersionText.append(f"service pack {self.servicePack}")
@@ -103,10 +156,16 @@ def getWinVer():
 	"""Returns a record of current Windows version NVDA is running on.
 	"""
 	winVer = sys.getwindowsversion()
+	# #12509: on Windows 10, fetch whatever Windows Registry says for the current build.
+	try:
+		releaseName = f"Windows 10 {_getRunningVersionNameFromWinReg()}"
+	except RuntimeError:
+		releaseName = None
 	return WinVersion(
 		major=winVer.major,
 		minor=winVer.minor,
 		build=winVer.build,
+		releaseName=releaseName,
 		servicePack=winVer.service_pack,
 		productType=("workstation", "domain controller", "server")[winVer.product_type - 1]
 	)
@@ -124,20 +183,23 @@ def isUwpOcrAvailable():
 	return os.path.isdir(UWP_OCR_DATA_PATH)
 
 
-WIN10_RELEASE_NAME_TO_BUILDS = {
-	"1507": 10240,
-	"1511": 10586,
-	"1607": 14393,
-	"1703": 15063,
-	"1709": 16299,
-	"1803": 17134,
-	"1809": 17763,
-	"1903": 18362,
-	"1909": 18363,
-	"2004": 19041,
-	"20H2": 19042,
-	"21H1": 19043,
-}
+# Deprecated: Windows 10 releases will be obtained from Windows Registry, no entries will be added.
+# The below map will be removed in 2022.1.
+if version_year < 2022:
+	WIN10_RELEASE_NAME_TO_BUILDS = {
+		"1507": 10240,
+		"1511": 10586,
+		"1607": 14393,
+		"1703": 15063,
+		"1709": 16299,
+		"1803": 17134,
+		"1809": 17763,
+		"1903": 18362,
+		"1909": 18363,
+		"2004": 19041,
+		"20H2": 19042,
+		"21H1": 19043,
+	}
 
 
 def isFullScreenMagnificationAvailable():


Test patch:
diff --git a/tests/unit/test_winVersion.py b/tests/unit/test_winVersion.py
--- a/tests/unit/test_winVersion.py
+++ b/tests/unit/test_winVersion.py
@@ -35,3 +35,46 @@ def test_moreRecentWinVer(self):
 		self.assertGreaterEqual(
 			audioDuckingAvailable, minimumWinVer
 		)
+
+	def test_winVerKnownReleaseNameForWinVersionConstant(self):
+		# Test the fact that later Windows releases provide version information in a consistent manner,
+		# specifically, via Windows Registry on Windows 10 1511 and later.
+		# Test with Windows Server 2016 (client release name: Windows 10 1607).
+		server2016 = winVersion.WIN10_1607
+		self.assertEqual(server2016.releaseName, "Windows 10 1607")
+
+	def test_winVerKnownBuildToReleaseName(self):
+		# Specifically to test if the correct release name is returned for use in getWinVer() function.
+		# Try Windows 10 1809.
+		knownMajor, knownMinor, knownBuild = 10, 0, 17763
+		knownPublicRelease = winVersion.WinVersion(
+			major=knownMajor, minor=knownMinor, build=knownBuild
+		)
+		self.assertEqual(knownPublicRelease.releaseName, "Windows 10 1809")
+
+	def test_winVerReleaseNameFromWindowsRegistry(self):
+		# Test to make sure something is indeed returned from Windows Registry
+		# when fetching release names for Windows 10 releases.
+		# Try Windows Insider Preview build 21390, which is recorded as 'Dev".
+		# But on public releases, version recorded on Windows Registry is returned.
+		# This will fail if release name cannot be obtained from Windows Registry
+		# ("unknown" will be recorded in release name text),
+		# usually if Release Id and/or display version key is not defined.
+		# For build 21390, as an Insider Preview build, "unknown" is fine
+		# as this is defined for testing purposes.
+		major, minor, build = 10, 0, 21390
+		insiderBuild = winVersion.WinVersion(
+			major=major, minor=minor, build=build
+		)
+		self.assertIn(
+			"unknown", insiderBuild.releaseName
+		)
+
+	def test_winVerUnknownBuildToReleaseName(self):
+		# It might be possible that Microsoft could use major.minor versions other than 10.0 in future releases.
+		# Try Windows 8.1 which is actually version 6.3.
+		unknownMajor, unknownMinor, unknownBuild = 8, 1, 0
+		badWin81Info = winVersion.WinVersion(
+			major=unknownMajor, minor=unknownMinor, build=unknownBuild
+		)
+		self.assertEqual(badWin81Info.releaseName, "Windows release unknown")