From e33d24ca86d79d164e597f00aad593949bc094ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Golonka?= Date: Mon, 30 Aug 2021 18:16:03 +0200 Subject: [PATCH 01/10] Fix bvarious issues when removing add-ons and improve state management in `addonHandler` --- source/addonHandler/__init__.py | 203 ++++++++++++++++++-------------- 1 file changed, 116 insertions(+), 87 deletions(-) diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index 6248a163ce3..45c72126cf7 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -1,8 +1,6 @@ -# -*- coding: UTF-8 -*- -# addonHandler.py # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2012-2019 Rui Batista, NV Access Limited, Noelia Ruiz Martínez, -# Joseph Lee, Babbage B.V., Arnold Loubriat +# Copyright (C) 2012-2021 Rui Batista, NV Access Limited, Noelia Ruiz Martínez, +# Joseph Lee, Babbage B.V., Arnold Loubriat, Łukasz Golonka # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -24,7 +22,6 @@ from configobj.validate import Validator import config -import globalVars import languageHandler from logHandler import log import winKernel @@ -40,42 +37,77 @@ ADDON_PENDINGINSTALL_SUFFIX=".pendingInstall" DELETEDIR_SUFFIX=".delete" -state={} - # Add-ons that are blocked from running because they are incompatible _blockedAddons=set() -def loadState(): - global state - statePath=os.path.join(globalVars.appArgs.configPath,stateFilename) - try: - # #9038: Python 3 requires binary format when working with pickles. - with open(statePath, "rb") as f: - state = pickle.load(f) - if "disabledAddons" not in state: - state["disabledAddons"] = set() - if "pendingDisableSet" not in state: - state["pendingDisableSet"] = set() - if "pendingEnableSet" not in state: - state["pendingEnableSet"] = set() - except: - # Defaults. - state = { - "pendingRemovesSet":set(), - "pendingInstallsSet":set(), - "disabledAddons":set(), - "pendingEnableSet":set(), - "pendingDisableSet":set(), - } + +class AddonsState(collections.UserDict): + + _DEFAULT_STATE_CONTENT = { + "pendingRemovesSet": set(), + "pendingInstallsSet": set(), + "disabledAddons": set(), + "pendingEnableSet": set(), + "pendingDisableSet": set(), + } + + @property + def statePath(self) -> os.PathLike: + """Returns path to the state file. """ + return os.path.join(globalVars.appArgs.configPath, stateFilename) + + def load(self) -> None: + """Populates state with the default content and then loads values from the config.""" + self.update(self._DEFAULT_STATE_CONTENT) + try: + # #9038: Python 3 requires binary format when working with pickles. + with open(self.statePath, "rb") as f: + state = pickle.load(f) + self.update(state) + except (IOError, pickle.UnpicklingError): + pass + + def removeStateFile(self) -> None: + try: + os.remove(self.statePath) + except FileNotFoundError: + pass # Probably clean config - no point in logging in this case. + except OSError: + log.error(f"Failed to remove state file {self.statePath}", exc_info=True) + + def save(self) -> None: + """Saves content of the state to a file unless state is empty in which case this would be pointless.""" + if any(self.values()): + try: + # #9038: Python 3 requires binary format when working with pickles. + with open(self.statePath, "wb") as f: + # We cannot pickle instance of `AddonsState` directly + # since older versions of NVDA aren't aware about this clas and they're expecting state + # to be a standard `dict`. + pickle.dump(self.data, f, protocol=0) + except (IOError, pickle.PicklingError): + log.debugWarning("Error saving state", exc_info=True) + else: + # Empty state - just delete state file and don't save anything. + self.removeStateFile() + + def cleanupRemovedDisabledAddons(self) -> None: + """Versions of NVDA before 2021.3 failed to remove add-on from list of disabled add-ons + during uninstallation. As a result after reinstalling add-on with the same name it was disabled + by default confusing users. Fix this by removing all add-ons no longer present in the config + from the list of disabled add-ons in the state.""" + installedAddonNames = tuple(a.name for a in getAvailableAddons()) + for disabledAddonName in list(self["disabledAddons"]): + if disabledAddonName not in installedAddonNames: + self["disabledAddons"].discard(disabledAddonName) + + +state = AddonsState() + def saveState(): - statePath=os.path.join(globalVars.appArgs.configPath,stateFilename) - try: - # #9038: Python 3 requires binary format when working with pickles. - with open(statePath, "wb") as f: - pickle.dump(state, f, protocol=0) - except: - log.debugWarning("Error saving state", exc_info=True) + state.save() + def getRunningAddons(): """ Returns currently loaded add-ons. @@ -96,53 +128,21 @@ def getIncompatibleAddons( ) )) -def completePendingAddonRemoves(): - """Removes any add-ons that could not be removed on the last run of NVDA""" - user_addons = os.path.join(globalVars.appArgs.configPath, "addons") - pendingRemovesSet=state['pendingRemovesSet'] - for addonName in list(pendingRemovesSet): - addonPath=os.path.join(user_addons,addonName) - if os.path.isdir(addonPath): - addon=Addon(addonPath) - try: - addon.completeRemove() - except RuntimeError: - log.exception("Failed to remove %s add-on"%addonName) - continue - pendingRemovesSet.discard(addonName) -def completePendingAddonInstalls(): - user_addons = os.path.join(globalVars.appArgs.configPath, "addons") - pendingInstallsSet=state['pendingInstallsSet'] - for addonName in pendingInstallsSet: - newPath=os.path.join(user_addons,addonName) - oldPath=newPath+ADDON_PENDINGINSTALL_SUFFIX - try: - os.rename(oldPath,newPath) - except: - log.error("Failed to complete addon installation for %s"%addonName,exc_info=True) - pendingInstallsSet.clear() +def removeFailedDeletion(path: os.PathLike): + shutil.rmtree(path, ignore_errors=True) + if os.path.exists(path): + log.error(f"Failed to delete path {path}, try removing manually") -def removeFailedDeletions(): - user_addons = os.path.join(globalVars.appArgs.configPath, "addons") - for p in os.listdir(user_addons): - if p.endswith(DELETEDIR_SUFFIX): - path=os.path.join(user_addons,p) - shutil.rmtree(path,ignore_errors=True) - if os.path.exists(path): - log.error("Failed to delete path %s, try removing manually"%path) -_disabledAddons = set() def disableAddonsIfAny(): """ Disables add-ons if told to do so by the user from add-ons manager. This is usually executed before refreshing the list of available add-ons. """ - global _disabledAddons # Pull in and enable add-ons that should be disabled and enabled, respectively. state["disabledAddons"] |= state["pendingDisableSet"] state["disabledAddons"] -= state["pendingEnableSet"] - _disabledAddons = state["disabledAddons"] state["pendingDisableSet"].clear() state["pendingEnableSet"].clear() @@ -151,13 +151,11 @@ def initialize(): if config.isAppX: log.info("Add-ons not supported when running as a Windows Store application") return - loadState() - removeFailedDeletions() - completePendingAddonRemoves() - completePendingAddonInstalls() + state.load() # #3090: Are there add-ons that are supposed to not run for this session? disableAddonsIfAny() - getAvailableAddons(refresh=True) + getAvailableAddons(refresh=True, isFirstLoad=True) + state.cleanupRemovedDisabledAddons() saveState() @@ -176,7 +174,8 @@ def _getDefaultAddonPaths(): addon_paths.append(user_addons) return addon_paths -def _getAvailableAddonsFromPath(path): + +def _getAvailableAddonsFromPath(path, isFirstLoad=False): """ Gets available add-ons from path. An addon is only considered available if the manifest file is loaded with no errors. @param path: path from where to find addon directories. @@ -185,7 +184,10 @@ def _getAvailableAddonsFromPath(path): """ log.debug("Listing add-ons from %s", path) for p in os.listdir(path): - if p.endswith(DELETEDIR_SUFFIX): continue + if p.endswith(DELETEDIR_SUFFIX): + if isFirstLoad: + removeFailedDeletion(os.path.join(path, p)) + continue addon_path = os.path.join(path, p) if os.path.isdir(addon_path) and addon_path not in ('.', '..'): if not len(os.listdir(addon_path)): @@ -195,6 +197,18 @@ def _getAvailableAddonsFromPath(path): try: a = Addon(addon_path) name = a.manifest['name'] + if( + isFirstLoad + and (name in state["pendingInstallsSet"] or a.path.endswith(ADDON_PENDINGINSTALL_SUFFIX)) + ): + newPath = a.completeInstall() + a = Addon(newPath) + if isFirstLoad and name in state["pendingRemovesSet"]: + try: + a.completeRemove() + except RuntimeError: + log.exception(f"Failed to remove {name} add-on") + continue log.debug( "Found add-on {name} - {a.version}." " Requires API: {a.minimumNVDAVersion}." @@ -210,9 +224,11 @@ def _getAvailableAddonsFromPath(path): yield a except: log.error("Error loading Addon from path: %s", addon_path, exc_info=True) - + _availableAddons = collections.OrderedDict() -def getAvailableAddons(refresh=False, filterFunc=None): + + +def getAvailableAddons(refresh=False, filterFunc=None, isFirstLoad=False): """ Gets all available addons on the system. @param refresh: Whether or not to query the file system for available add-ons. @type refresh: bool @@ -220,13 +236,15 @@ def getAvailableAddons(refresh=False, filterFunc=None): It takes an L{Addon} as its only argument and returns a C{bool} indicating whether the add-on matches the provided filter. @type filterFunc: callable + : isFirstLoad: Should add-ons that are pending installations / removal from the file system + be installed / removed. @rtype generator of Addon instances. """ if filterFunc and not callable(filterFunc): raise TypeError("The provided filterFunc is not callable") if refresh: _availableAddons.clear() - generators = [_getAvailableAddonsFromPath(path) for path in _getDefaultAddonPaths()] + generators = [_getAvailableAddonsFromPath(path, isFirstLoad) for path in _getDefaultAddonPaths()] for addon in itertools.chain(*generators): _availableAddons[addon.path] = addon return (addon for addon in _availableAddons.values() if not filterFunc or filterFunc(addon)) @@ -307,6 +325,17 @@ def isPendingRemove(self): """True if this addon is marked for removal.""" return not self.isPendingInstall and self.name in state['pendingRemovesSet'] + def completeInstall(self): + newPath = self.path.replace(ADDON_PENDINGINSTALL_SUFFIX, "") + oldPath = self.path + try: + os.rename(oldPath, newPath) + state['pendingInstallsSet'].discard(self.name) + return newPath + except OSError: + log.error(f"Failed to complete addon installation for {self.name}", exc_info=True) + raise + def requestRemove(self): """Markes this addon for removal on NVDA restart.""" if self.isPendingInstall: @@ -317,7 +346,7 @@ def requestRemove(self): else: state['pendingRemovesSet'].add(self.name) # There's no point keeping a record of this add-on pending being disabled now. - # However, if the addon is in _disabledAddons, then it needs to stay there so that + # However, if the addon is disabled, then it needs to remain disabled so that # the status in addonsManager continues to say "disabled" state['pendingDisableSet'].discard(self.name) saveState() @@ -343,8 +372,9 @@ def completeRemove(self,runUninstallTask=True): log.error("Error removing addon directory %s, deferring until next NVDA restart"%self.path) # clean up the addons state. If an addon with the same name is installed, it should not be automatically # disabled / blocked. - log.debug("removing addon {} from _disabledAddons/_blockedAddons".format(self.name)) - _disabledAddons.discard(self.name) + log.debug(f"removing addon {self.name} from the list of disabled / blocked add-ons") + state["disabledAddons"].discard(self.name) + state['pendingRemovesSet'].discard(self.name) _blockedAddons.discard(self.name) saveState() @@ -410,7 +440,7 @@ def isRunning(self): @property def isDisabled(self): - return self.name in _disabledAddons + return self.name in state["disabledAddons"] @property def isBlocked(self): @@ -508,7 +538,6 @@ def getCodeAddon(obj=None, frameDist=1): @return: L{Addon} instance or None if no code does not belong to a add-on package. @rtype: C{Addon} """ - global _availableAddons if obj is None: obj = sys._getframe(frameDist) fileName = inspect.getfile(obj) From 429c2e217f0e784fa07f8cae6806ceeff1402739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Golonka?= Date: Wed, 1 Sep 2021 21:25:53 +0200 Subject: [PATCH 02/10] Improvements --- source/addonHandler/__init__.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index 45c72126cf7..ad48b21ebfc 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -197,18 +197,23 @@ def _getAvailableAddonsFromPath(path, isFirstLoad=False): try: a = Addon(addon_path) name = a.manifest['name'] - if( + if ( isFirstLoad - and (name in state["pendingInstallsSet"] or a.path.endswith(ADDON_PENDINGINSTALL_SUFFIX)) + and name in state["pendingRemovesSet"] + and not a.path.endswith(ADDON_PENDINGINSTALL_SUFFIX) ): - newPath = a.completeInstall() - a = Addon(newPath) - if isFirstLoad and name in state["pendingRemovesSet"]: try: a.completeRemove() except RuntimeError: log.exception(f"Failed to remove {name} add-on") continue + if( + isFirstLoad + and (name in state["pendingInstallsSet"] or a.path.endswith(ADDON_PENDINGINSTALL_SUFFIX)) + ): + newPath = a.completeInstall() + if newPath: + a = Addon(newPath) log.debug( "Found add-on {name} - {a.version}." " Requires API: {a.minimumNVDAVersion}." @@ -334,7 +339,6 @@ def completeInstall(self): return newPath except OSError: log.error(f"Failed to complete addon installation for {self.name}", exc_info=True) - raise def requestRemove(self): """Markes this addon for removal on NVDA restart.""" @@ -391,7 +395,7 @@ def addToPackagePath(self, package): """ # #3090: Ensure that we don't add disabled / blocked add-ons to package path. # By returning here the addon does not "run"/ become active / registered. - if self.isDisabled or self.isBlocked: + if self.isDisabled or self.isBlocked or self.isPendingInstall: return extension_path = os.path.join(self.path, package.__name__) From 2dd5b7e7aab7fbebe94c2183fd1abd306299a25d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Golonka?= Date: Mon, 6 Sep 2021 11:30:46 +0200 Subject: [PATCH 03/10] Auto deprecation for `addonHandler.saveState` --- source/addonHandler/__init__.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index ad48b21ebfc..39de9158097 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -28,6 +28,8 @@ import addonAPIVersion from . import addonVersionCheck from .addonVersionCheck import isAddonCompatible +import buildVersion + MANIFEST_FILENAME = "manifest.ini" stateFilename="addonsState.pickle" @@ -105,8 +107,10 @@ def cleanupRemovedDisabledAddons(self) -> None: state = AddonsState() -def saveState(): - state.save() +# Deprecated - use `state.save` instead. +if buildVersion.version_year < 2022: + def saveState(): + state.save() def getRunningAddons(): @@ -156,7 +160,7 @@ def initialize(): disableAddonsIfAny() getAvailableAddons(refresh=True, isFirstLoad=True) state.cleanupRemovedDisabledAddons() - saveState() + state.save() def terminate(): @@ -270,7 +274,7 @@ def installAddonBundle(bundle): addon.completeRemove(runUninstallTask=False) raise AddonError("Installation failed") state['pendingInstallsSet'].add(bundle.manifest['name']) - saveState() + state.save() return addon class AddonError(Exception): @@ -353,7 +357,7 @@ def requestRemove(self): # However, if the addon is disabled, then it needs to remain disabled so that # the status in addonsManager continues to say "disabled" state['pendingDisableSet'].discard(self.name) - saveState() + state.save() def completeRemove(self,runUninstallTask=True): if runUninstallTask: @@ -380,7 +384,7 @@ def completeRemove(self,runUninstallTask=True): state["disabledAddons"].discard(self.name) state['pendingRemovesSet'].discard(self.name) _blockedAddons.discard(self.name) - saveState() + state.save() def addToPackagePath(self, package): """ Adds this L{Addon} extensions to the specific package path if those exist. @@ -436,7 +440,7 @@ def enable(self, shouldEnable): elif self.name not in state["disabledAddons"]: state["pendingDisableSet"].add(self.name) # Record enable/disable flags as a way of preparing for disaster such as sudden NVDA crash. - saveState() + state.save() @property def isRunning(self): From 32e2fbc9c184ed78c3f578ce34bbdb0260a48790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Golonka?= Date: Mon, 6 Sep 2021 11:32:38 +0200 Subject: [PATCH 04/10] Clarify comment Co-authored-by: Sean Budd --- source/addonHandler/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index 39de9158097..df0d72022e4 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -94,7 +94,7 @@ def save(self) -> None: self.removeStateFile() def cleanupRemovedDisabledAddons(self) -> None: - """Versions of NVDA before 2021.3 failed to remove add-on from list of disabled add-ons + """Versions of NVDA before #12792 failed to remove add-on from list of disabled add-ons during uninstallation. As a result after reinstalling add-on with the same name it was disabled by default confusing users. Fix this by removing all add-ons no longer present in the config from the list of disabled add-ons in the state.""" From cae49fbd24bde28c9c616991e2507b43eb15362a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Golonka?= Date: Mon, 6 Sep 2021 11:49:15 +0200 Subject: [PATCH 05/10] Add typing info --- source/addonHandler/__init__.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index df0d72022e4..395b2e978bb 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -16,6 +16,7 @@ from io import StringIO import pickle from six import string_types +import typing import globalVars import zipfile from configobj import ConfigObj @@ -237,17 +238,18 @@ def _getAvailableAddonsFromPath(path, isFirstLoad=False): _availableAddons = collections.OrderedDict() -def getAvailableAddons(refresh=False, filterFunc=None, isFirstLoad=False): +def getAvailableAddons( + refresh: bool = False, + filterFunc: typing.Optional[typing.Callable[["Addon"], bool]] = None, + isFirstLoad: bool = False +) -> typing.Generator["Addon", None, None]: """ Gets all available addons on the system. @param refresh: Whether or not to query the file system for available add-ons. - @type refresh: bool @param filterFunc: A function that allows filtering of add-ons. - It takes an L{Addon} as its only argument - and returns a C{bool} indicating whether the add-on matches the provided filter. - @type filterFunc: callable + It takes an L{Addon} as its only argument + and returns a C{bool} indicating whether the add-on matches the provided filter. : isFirstLoad: Should add-ons that are pending installations / removal from the file system be installed / removed. - @rtype generator of Addon instances. """ if filterFunc and not callable(filterFunc): raise TypeError("The provided filterFunc is not callable") From 6e483961aa0516e124a88b8b9aa2a6de005a3be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Golonka?= Date: Mon, 6 Sep 2021 12:40:58 +0200 Subject: [PATCH 06/10] Add explanatory comment about subclassing. --- source/addonHandler/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index 395b2e978bb..2a5f50df5dc 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -45,6 +45,7 @@ class AddonsState(collections.UserDict): + """Subclasses `collections.UserDict` to preserver backwards compatibility.""" _DEFAULT_STATE_CONTENT = { "pendingRemovesSet": set(), From 2cf763f6143f33c844fa10be89e809d12014f4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Golonka?= Date: Tue, 7 Sep 2021 09:40:00 +0200 Subject: [PATCH 07/10] Additional logging when loading state --- source/addonHandler/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index 2a5f50df5dc..04f7a71af47 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -68,8 +68,12 @@ def load(self) -> None: with open(self.statePath, "rb") as f: state = pickle.load(f) self.update(state) - except (IOError, pickle.UnpicklingError): - pass + except FileNotFoundError: + pass # Clean config - no point logging in this case + except IOError: + log.debug("Error when reading state file", exc_info=True) + except pickle.UnpicklingError: + log.debugWarning("Failed to unpickle state", exc_info=True) def removeStateFile(self) -> None: try: From 38060bc00bdc21df4b37bf030a861e66e2e1200c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Golonka?= Date: Tue, 7 Sep 2021 09:45:33 +0200 Subject: [PATCH 08/10] Back compat for `loadState` --- source/addonHandler/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index 04f7a71af47..f8b4eb3b643 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -113,11 +113,14 @@ def cleanupRemovedDisabledAddons(self) -> None: state = AddonsState() -# Deprecated - use `state.save` instead. +# Deprecated - use `state.save` and `state.load` instead. if buildVersion.version_year < 2022: def saveState(): state.save() + def loadState(): + state.load() + def getRunningAddons(): """ Returns currently loaded add-ons. From 0db8c98e5238c42fb3abd711b8eebe419a16c5bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Golonka?= Date: Tue, 7 Sep 2021 09:49:38 +0200 Subject: [PATCH 09/10] Improve comment Co-authored-by: Sean Budd --- source/addonHandler/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index f8b4eb3b643..53f1c04f881 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -45,7 +45,7 @@ class AddonsState(collections.UserDict): - """Subclasses `collections.UserDict` to preserver backwards compatibility.""" + """Subclasses `collections.UserDict` to preserve backwards compatibility.""" _DEFAULT_STATE_CONTENT = { "pendingRemovesSet": set(), From 5ce9d17f81740dd09439f9885eb12f856cb97172 Mon Sep 17 00:00:00 2001 From: buddsean Date: Wed, 8 Sep 2021 19:07:24 +1000 Subject: [PATCH 10/10] update changes --- user_docs/en/changes.t2t | 3 +++ 1 file changed, 3 insertions(+) diff --git a/user_docs/en/changes.t2t b/user_docs/en/changes.t2t index 1216d804c4b..c4a6f05ac93 100644 --- a/user_docs/en/changes.t2t +++ b/user_docs/en/changes.t2t @@ -35,6 +35,8 @@ If you need this functionality please assign a gesture to the appropriate script - NVDA no longer treats the value of UIA sliders as always percentage based. - Reporting the location of a cell in Microsoft Excel when accessed via UI Automation again works correctly on Windows 11. (#12782) - NVDA no longer sets invalid Python locales. (#12753) +- If a disabled addon is uninstalled and then re-installed it is re-enabled. (#12792) +- Fixed bugs around updating and removing addons where the addon folder has been renamed or has files opened. (#12792, #12629) - @@ -47,6 +49,7 @@ To match the production build environment, update Visual Studio to keep in sync - Transparency of text background color sourced from GDI applications (via the display model), is now exposed for add-ons or appModules. (#12658) - ``LOCALE_SLANGUAGE``, ``LOCALE_SLIST`` and ``LOCALE_SLANGDISPLAYNAME`` are moved to the ``LOCALE`` enum in languageHandler. They are still available at the module level but are deprecated and to be removed in NVDA 2022.1. (#12753) +- The usage of functions ``addonHandler.loadState`` and ``addonHandler.saveState`` should be replaced with their equivalents ``addonHandler.state.save`` and ``addonHandler.state.load`` before 2022.1. (#12792) -