diff --git a/devDocs/developerGuide.t2t b/devDocs/developerGuide.t2t index 46df9b9b0e7..d0b63f4f854 100644 --- a/devDocs/developerGuide.t2t +++ b/devDocs/developerGuide.t2t @@ -759,7 +759,7 @@ addonHandler.isCLIParamKnown.register(processArgs) ``` + Packaging Code as NVDA Add-ons +[Addons] -To make it easy for users to share and install plugins and drivers, they can be packaged in to a single NVDA add-on package which the user can then install into a copy of NVDA via the Add-ons Manager found under Tools in the NVDA menu. +To make it easy for users to share and install plugins and drivers, they can be packaged in to a single NVDA add-on package which the user can then install into a copy of NVDA via the Add-on Store found under Tools in the NVDA menu. Add-on packages are only supported in NVDA 2012.2 and later. An add-on package is simply a standard zip archive with the file extension of "``nvda-addon``" which contains a manifest file, optional install/uninstall code and one or more directories containing plugins and/or drivers. @@ -865,7 +865,7 @@ For more information about gettext and NVDA translation in general, please read Documentation for an add-on should be placed in a doc directory in the archive. Similar to the locale directory, this directory should contain directories for each language in which documentation is available. -Users can access documentation for a particular add-on by opening the Add-ons Manager, selecting the add-on and pressing the Add-on help button. +Users can access documentation for a particular add-on by opening the Add-on Store, selecting the add-on and pressing the Add-on help button. This will open the file named in the docFileName parameter of the manifest. NVDA will search for this file in the appropriate language directories. For example, if docFileName is set to readme.html and the user is using English, NVDA will open doc\en\readme.html. diff --git a/requirements.txt b/requirements.txt index 79cc3d32320..dea7d491c95 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,12 @@ comtypes==1.1.11 pyserial==3.5 wxPython==4.1.1 git+https://github.com/DiffSK/configobj@3e2f4cc#egg=configobj +requests==2.28.2 +# Required to use a pinned old version for requests. +# py2exe fails to compile properly without this. +# This can be removed when upgrading py2exe to 0.13+ and python to 3.8+. +# https://github.com/Ousret/charset_normalizer/issues/253 +charset-normalizer==2.1.1 #NVDA_DMP requires diff-match-patch diff_match_patch_python==1.0.2 diff --git a/source/NVDAState.py b/source/NVDAState.py index 0283d57f6d7..1abaec31760 100644 --- a/source/NVDAState.py +++ b/source/NVDAState.py @@ -46,6 +46,16 @@ def _setExitCode(exitCode: int) -> None: globalVars.exitCode = exitCode +def shouldWriteToDisk() -> bool: + """ + Never save config or state if running securely or if running from the launcher. + When running from the launcher we don't save settings because the user may decide not to + install this version, and these settings may not be compatible with the already + installed version. See #7688 + """ + return not (globalVars.appArgs.secure or globalVars.appArgs.launcher) + + class _TrackNVDAInitialization: """ During NVDA initialization, diff --git a/source/_addonStore/__init__.py b/source/_addonStore/__init__.py new file mode 100644 index 00000000000..1e6d4fd0d05 --- /dev/null +++ b/source/_addonStore/__init__.py @@ -0,0 +1,4 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. diff --git a/source/_addonStore/dataManager.py b/source/_addonStore/dataManager.py new file mode 100644 index 00000000000..edea266e98a --- /dev/null +++ b/source/_addonStore/dataManager.py @@ -0,0 +1,280 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +# Needed for type hinting CaseInsensitiveDict +# Can be removed in a future version of python (3.8+) +from __future__ import annotations + +from datetime import datetime, timedelta +import json +import os +import pathlib +import threading +from typing import ( + TYPE_CHECKING, + Optional, +) + +import requests +from requests.structures import CaseInsensitiveDict + +import addonAPIVersion +from baseObject import AutoPropertyObject +import config +from core import callLater +import globalVars +import languageHandler +from logHandler import log +import NVDAState + +from .models.addon import ( + AddonStoreModel, + CachedAddonsModel, + _createAddonGUICollection, + _createStoreModelFromData, + _createStoreCollectionFromJson, +) +from .models.channel import Channel +from .network import ( + AddonFileDownloader, + _getAddonStoreURL, + _getCurrentApiVersionForURL, + _LATEST_API_VER, +) + +if TYPE_CHECKING: + from addonHandler import Addon as AddonHandlerModel # noqa: F401 + # AddonGUICollectionT must only be imported when TYPE_CHECKING + from .models.addon import AddonGUICollectionT # noqa: F401 + from gui.message import DisplayableError # noqa: F401 + + +addonDataManager: Optional["_DataManager"] = None + + +def initialize(): + global addonDataManager + if config.isAppX: + log.info("Add-ons not supported when running as a Windows Store application") + return + log.debug("initializing addonStore data manager") + addonDataManager = _DataManager() + + +class _DataManager: + _cacheLatestFilename: str = "_cachedLatestAddons.json" + _cacheCompatibleFilename: str = "_cachedCompatibleAddons.json" + _cachePeriod = timedelta(hours=6) + + def __init__(self): + cacheDirLocation = os.path.join(globalVars.appArgs.configPath, "addonStore") + self._lang = languageHandler.getLanguage() + self._preferredChannel = Channel.ALL + self._cacheLatestFile = os.path.join(cacheDirLocation, _DataManager._cacheLatestFilename) + self._cacheCompatibleFile = os.path.join(cacheDirLocation, _DataManager._cacheCompatibleFilename) + self._addonDownloadCacheDir = os.path.join(cacheDirLocation, "_dl") + self._installedAddonDataCacheDir = os.path.join(cacheDirLocation, "addons") + # ensure caching dirs exist + pathlib.Path(cacheDirLocation).mkdir(parents=True, exist_ok=True) + pathlib.Path(self._addonDownloadCacheDir).mkdir(parents=True, exist_ok=True) + pathlib.Path(self._installedAddonDataCacheDir).mkdir(parents=True, exist_ok=True) + + self._latestAddonCache = self._getCachedAddonData(self._cacheLatestFile) + self._compatibleAddonCache = self._getCachedAddonData(self._cacheCompatibleFile) + self._installedAddonsCache = _InstalledAddonsCache() + # Fetch available add-ons cache early + threading.Thread( + target=self.getLatestCompatibleAddons, + name="initialiseAvailableAddons", + ).start() + + def getFileDownloader(self) -> AddonFileDownloader: + return AddonFileDownloader(self._addonDownloadCacheDir) + + def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]: + url = _getAddonStoreURL(self._preferredChannel, self._lang, apiVersion) + try: + response = requests.get(url) + except requests.exceptions.RequestException as e: + log.debugWarning(f"Unable to fetch addon data: {e}") + return None + if response.status_code != requests.codes.OK: + log.error( + f"Unable to get data from API ({url})," + f" response ({response.status_code}): {response.content}" + ) + return None + return response.content + + def _cacheCompatibleAddons(self, addonData: str, fetchTime: datetime): + if not NVDAState.shouldWriteToDisk(): + return + if not addonData: + return + cacheData = { + "cacheDate": fetchTime.isoformat(), + "data": addonData, + "cachedLanguage": self._lang, + "nvdaAPIVersion": addonAPIVersion.CURRENT, + } + with open(self._cacheCompatibleFile, 'w') as cacheFile: + json.dump(cacheData, cacheFile, ensure_ascii=False) + + def _cacheLatestAddons(self, addonData: str, fetchTime: datetime): + if not NVDAState.shouldWriteToDisk(): + return + if not addonData: + return + cacheData = { + "cacheDate": fetchTime.isoformat(), + "data": addonData, + "cachedLanguage": self._lang, + "nvdaAPIVersion": _LATEST_API_VER, + } + with open(self._cacheLatestFile, 'w') as cacheFile: + json.dump(cacheData, cacheFile, ensure_ascii=False) + + def _getCachedAddonData(self, cacheFilePath: str) -> Optional[CachedAddonsModel]: + if not os.path.exists(cacheFilePath): + return None + with open(cacheFilePath, 'r') as cacheFile: + cacheData = json.load(cacheFile) + if not cacheData: + return None + fetchTime = datetime.fromisoformat(cacheData["cacheDate"]) + return CachedAddonsModel( + cachedAddonData=_createStoreCollectionFromJson(cacheData["data"]), + cachedAt=fetchTime, + cachedLanguage=cacheData["cachedLanguage"], + nvdaAPIVersion=tuple(cacheData["nvdaAPIVersion"]), # loads as list + ) + + # Translators: A title of the dialog shown when fetching add-on data from the store fails + _updateFailureMessage = pgettext("addonStore", "Add-on data update failure") + + def getLatestCompatibleAddons( + self, + onDisplayableError: Optional[DisplayableError.OnDisplayableErrorT] = None, + ) -> "AddonGUICollectionT": + shouldRefreshData = ( + not self._compatibleAddonCache + or self._compatibleAddonCache.nvdaAPIVersion != addonAPIVersion.CURRENT + or _DataManager._cachePeriod < (datetime.now() - self._compatibleAddonCache.cachedAt) + or self._compatibleAddonCache.cachedLanguage != self._lang + ) + if shouldRefreshData: + fetchTime = datetime.now() + apiData = self._getLatestAddonsDataForVersion(_getCurrentApiVersionForURL()) + if apiData: + decodedApiData = apiData.decode() + self._cacheCompatibleAddons( + addonData=decodedApiData, + fetchTime=fetchTime, + ) + self._compatibleAddonCache = CachedAddonsModel( + cachedAddonData=_createStoreCollectionFromJson(decodedApiData), + cachedAt=fetchTime, + cachedLanguage=self._lang, + nvdaAPIVersion=addonAPIVersion.CURRENT, + ) + elif onDisplayableError is not None: + from gui.message import DisplayableError + displayableError = DisplayableError( + # Translators: A message shown when fetching add-on data from the store fails + pgettext("addonStore", "Unable to fetch latest add-on data for compatible add-ons."), + self._updateFailureMessage, + ) + callLater(delay=0, callable=onDisplayableError.notify, displayableError=displayableError) + + if self._compatibleAddonCache is None: + return _createAddonGUICollection() + return self._compatibleAddonCache.cachedAddonData + + def getLatestAddons( + self, + onDisplayableError: Optional[DisplayableError.OnDisplayableErrorT] = None, + ) -> "AddonGUICollectionT": + shouldRefreshData = ( + not self._latestAddonCache + or _DataManager._cachePeriod < (datetime.now() - self._latestAddonCache.cachedAt) + or self._latestAddonCache.cachedLanguage != self._lang + ) + if shouldRefreshData: + fetchTime = datetime.now() + apiData = self._getLatestAddonsDataForVersion(_LATEST_API_VER) + if apiData: + decodedApiData = apiData.decode() + self._cacheLatestAddons( + addonData=decodedApiData, + fetchTime=fetchTime, + ) + self._latestAddonCache = CachedAddonsModel( + cachedAddonData=_createStoreCollectionFromJson(decodedApiData), + cachedAt=fetchTime, + cachedLanguage=self._lang, + nvdaAPIVersion=_LATEST_API_VER, + ) + elif onDisplayableError is not None: + from gui.message import DisplayableError + displayableError = DisplayableError( + # Translators: A message shown when fetching add-on data from the store fails + pgettext("addonStore", "Unable to fetch latest add-on data for incompatible add-ons."), + self._updateFailureMessage + ) + callLater(delay=0, callable=onDisplayableError.notify, displayableError=displayableError) + + if self._latestAddonCache is None: + return _createAddonGUICollection() + return self._latestAddonCache.cachedAddonData + + def _deleteCacheInstalledAddon(self, addonId: str): + addonCachePath = os.path.join(self._installedAddonDataCacheDir, f"{addonId}.json") + if pathlib.Path(addonCachePath).exists(): + os.remove(addonCachePath) + + def _cacheInstalledAddon(self, addonData: AddonStoreModel): + if not NVDAState.shouldWriteToDisk(): + return + if not addonData: + return + addonCachePath = os.path.join(self._installedAddonDataCacheDir, f"{addonData.addonId}.json") + with open(addonCachePath, 'w') as cacheFile: + json.dump(addonData.asdict(), cacheFile, ensure_ascii=False) + + def _getCachedInstalledAddonData(self, addonId: str) -> Optional[AddonStoreModel]: + addonCachePath = os.path.join(self._installedAddonDataCacheDir, f"{addonId}.json") + if not os.path.exists(addonCachePath): + return None + with open(addonCachePath, 'r') as cacheFile: + cacheData = json.load(cacheFile) + if not cacheData: + return None + return _createStoreModelFromData(cacheData) + + +class _InstalledAddonsCache(AutoPropertyObject): + cachePropertiesByDefault = True + + installedAddons: CaseInsensitiveDict["AddonHandlerModel"] + installedAddonGUICollection: "AddonGUICollectionT" + + def _get_installedAddons(self) -> CaseInsensitiveDict["AddonHandlerModel"]: + """ + Add-ons that have the same ID except differ in casing cause a path collision, + as add-on IDs are installed to a case insensitive path. + Therefore addon IDs should be treated as case insensitive. + """ + from addonHandler import getAvailableAddons + return CaseInsensitiveDict({a.name: a for a in getAvailableAddons()}) + + def _get_installedAddonGUICollection(self) -> "AddonGUICollectionT": + addons = _createAddonGUICollection() + for addonId in self.installedAddons: + addonStoreData = self.installedAddons[addonId]._addonStoreData + if addonStoreData: + addons[addonStoreData.channel][addonId] = addonStoreData + else: + addons[Channel.STABLE][addonId] = self.installedAddons[addonId]._addonGuiModel + return addons diff --git a/source/_addonStore/install.py b/source/_addonStore/install.py new file mode 100644 index 00000000000..357dea1953b --- /dev/null +++ b/source/_addonStore/install.py @@ -0,0 +1,100 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +# Needed for type hinting CaseInsensitiveDict +# Can be removed in a future version of python (3.8+) +from __future__ import annotations + +from os import ( + PathLike, +) +from typing import ( + TYPE_CHECKING, + cast, + Optional, +) + +from logHandler import log + +from .dataManager import ( + addonDataManager, +) + +if TYPE_CHECKING: + from addonHandler import AddonBundle, Addon as AddonHandlerModel # noqa: F401 + + +def _getAddonBundleToInstallIfValid(addonPath: str) -> "AddonBundle": + """ + @param addonPath: path to the 'nvda-addon' file. + @return: the addonBundle, if valid + @raise DisplayableError if the addon bundle is invalid / incompatible. + """ + from addonHandler import AddonBundle, AddonError + from gui.message import DisplayableError + + try: + bundle = AddonBundle(addonPath) + except AddonError: + log.error("Error opening addon bundle from %s" % addonPath, exc_info=True) + raise DisplayableError( + displayMessage=pgettext( + "addonStore", + # Translators: The message displayed when an error occurs when opening an add-on package for adding. + # The %s will be replaced with the path to the add-on that could not be opened. + "Failed to open add-on package file at {filePath} - missing file or invalid file format" + ).format(filePath=addonPath) + ) + + if not bundle.isCompatible and not bundle.overrideIncompatibility: + # This should not happen, only compatible or overridable add-ons are + # intended to be presented in the add-on store. + raise DisplayableError( + displayMessage=pgettext( + "addonStore", + # Translators: The message displayed when an add-on is not supported by this version of NVDA. + # The %s will be replaced with the path to the add-on that is not supported. + "Add-on not supported %s" + ) % addonPath + ) + return bundle + + +def _getPreviouslyInstalledAddonById(addon: "AddonBundle") -> Optional["AddonHandlerModel"]: + assert addonDataManager + installedAddon = addonDataManager._installedAddonsCache.installedAddons.get(addon.name) + if installedAddon is None or installedAddon.isPendingRemove: + return None + return installedAddon + + +def installAddon(addonPath: PathLike) -> None: + """ Installs the addon at path. + Any error messages / warnings are presented to the user via a GUI message box. + If attempting to install an addon that is pending removal, it will no longer be pending removal. + @note See also L{gui.addonGui.installAddon} + @raise DisplayableError on failure + """ + from addonHandler import AddonError, installAddonBundle + from gui.message import DisplayableError + + addonPath = cast(str, addonPath) + bundle = _getAddonBundleToInstallIfValid(addonPath) + prevAddon = _getPreviouslyInstalledAddonById(bundle) + + try: + if prevAddon: + prevAddon.requestRemove() + installAddonBundle(bundle) + except AddonError: # Handle other exceptions as they are known + log.error("Error installing addon bundle from %s" % addonPath, exc_info=True) + raise DisplayableError( + displayMessage=pgettext( + "addonStore", + # Translators: The message displayed when an error occurs when installing an add-on package. + # The %s will be replaced with the path to the add-on that could not be installed. + "Failed to install add-on from %s" + ) % addonPath + ) diff --git a/source/_addonStore/models/__init__.py b/source/_addonStore/models/__init__.py new file mode 100644 index 00000000000..f486e03765b --- /dev/null +++ b/source/_addonStore/models/__init__.py @@ -0,0 +1,4 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. diff --git a/source/_addonStore/models/addon.py b/source/_addonStore/models/addon.py new file mode 100644 index 00000000000..c1d80ecb0b3 --- /dev/null +++ b/source/_addonStore/models/addon.py @@ -0,0 +1,230 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +# Needed for type hinting CaseInsensitiveDict +# Can be removed in a future version of python (3.8+) +from __future__ import annotations + +import dataclasses +from datetime import datetime +import json +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Generator, + List, + Optional, + Union, +) +from typing_extensions import ( + Protocol, +) + +from requests.structures import CaseInsensitiveDict + +import addonAPIVersion + +from .channel import Channel +from .status import SupportsAddonState +from .version import ( + MajorMinorPatch, + SupportsVersionCheck, +) + +if TYPE_CHECKING: + from addonHandler import ( # noqa: F401 + Addon as AddonHandlerModel, + AddonBase as AddonHandlerBaseModel, + ) + AddonGUICollectionT = Dict[Channel, CaseInsensitiveDict["_AddonGUIModel"]] + """ + Add-ons that have the same ID except differ in casing cause a path collision, + as add-on IDs are installed to a case insensitive path. + Therefore addon IDs should be treated as case insensitive. + """ + + +AddonHandlerModelGeneratorT = Generator["AddonHandlerModel", None, None] + + +class _AddonGUIModel(SupportsAddonState, SupportsVersionCheck, Protocol): + """Needed to display information in add-on store. + May come from manifest or add-on store data. + """ + addonId: str + displayName: str + description: str + publisher: str + addonVersionName: str + channel: Channel + homepage: Optional[str] + minNVDAVersion: MajorMinorPatch + lastTestedVersion: MajorMinorPatch + legacy: bool + """ + Legacy add-ons contain invalid metadata + and should not be accessible through the add-on store. + """ + + @property + def minimumNVDAVersion(self) -> addonAPIVersion.AddonApiVersionT: + """In order to support SupportsVersionCheck""" + return self.minNVDAVersion + + @property + def lastTestedNVDAVersion(self) -> addonAPIVersion.AddonApiVersionT: + """In order to support SupportsVersionCheck""" + return self.lastTestedVersion + + @property + def _addonHandlerModel(self) -> Optional["AddonHandlerModel"]: + """Returns the Addon model tracked in addonHandler, if it exists.""" + from ..dataManager import addonDataManager + if addonDataManager is None: + return None + return addonDataManager._installedAddonsCache.installedAddons.get(self.addonId) + + @property + def name(self) -> str: + """In order to support SupportsVersionCheck""" + return self.addonId + + @property + def listItemVMId(self) -> str: + return f"{self.addonId}-{self.channel}" + + def asdict(self) -> Dict[str, Any]: + jsonData = dataclasses.asdict(self) + for field in jsonData: + # dataclasses.asdict parses NamedTuples to JSON arrays, + # rather than JSON object dictionaries, + # which is expected by add-on infrastructure. + fieldValue = getattr(self, field) + if isinstance(fieldValue, MajorMinorPatch): + jsonData[field] = fieldValue._asdict() + return jsonData + + +@dataclasses.dataclass(frozen=True) +class AddonGUIModel(_AddonGUIModel): + """Can be displayed in the add-on store GUI. + May come from manifest or add-on store data. + """ + addonId: str + displayName: str + description: str + publisher: str + addonVersionName: str + channel: Channel + homepage: Optional[str] + minNVDAVersion: MajorMinorPatch + lastTestedVersion: MajorMinorPatch + legacy: bool = False + """ + Legacy add-ons contain invalid metadata + and should not be accessible through the add-on store. + """ + + +@dataclasses.dataclass(frozen=True) # once created, it should not be modified. +class AddonStoreModel(_AddonGUIModel): + """ + Data from an add-on from the add-on store. + """ + addonId: str + displayName: str + description: str + publisher: str + addonVersionName: str + channel: Channel + homepage: Optional[str] + license: str + licenseURL: Optional[str] + sourceURL: str + URL: str + sha256: str + addonVersionNumber: MajorMinorPatch + minNVDAVersion: MajorMinorPatch + lastTestedVersion: MajorMinorPatch + legacy: bool = False + """ + Legacy add-ons contain invalid metadata + and should not be accessible through the add-on store. + """ + + +@dataclasses.dataclass +class CachedAddonsModel: + cachedAddonData: "AddonGUICollectionT" + cachedAt: datetime + cachedLanguage: str + # AddonApiVersionT or the string .network._LATEST_API_VER + nvdaAPIVersion: Union[addonAPIVersion.AddonApiVersionT, str] + + +def _createStoreModelFromData(addon: Dict[str, Any]) -> AddonStoreModel: + return AddonStoreModel( + addonId=addon["addonId"], + displayName=addon["displayName"], + description=addon["description"], + publisher=addon["publisher"], + channel=Channel(addon["channel"]), + addonVersionName=addon["addonVersionName"], + addonVersionNumber=MajorMinorPatch(**addon["addonVersionNumber"]), + homepage=addon.get("homepage"), + license=addon["license"], + licenseURL=addon.get("licenseURL"), + sourceURL=addon["sourceURL"], + URL=addon["URL"], + sha256=addon["sha256"], + minNVDAVersion=MajorMinorPatch(**addon["minNVDAVersion"]), + lastTestedVersion=MajorMinorPatch(**addon["lastTestedVersion"]), + legacy=addon.get("legacy", False), + ) + + +def _createGUIModelFromManifest(addon: "AddonHandlerBaseModel") -> AddonGUIModel: + homepage = addon.manifest.get("url") + if homepage == "None": + # Manifest strings can be set to "None" + homepage = None + return AddonGUIModel( + addonId=addon.name, + displayName=addon.manifest["summary"], + description=addon.manifest["description"], + publisher=addon.manifest["author"], + channel=Channel.EXTERNAL, + addonVersionName=addon.version, + homepage=homepage, + minNVDAVersion=MajorMinorPatch(*addon.minimumNVDAVersion), + lastTestedVersion=MajorMinorPatch(*addon.lastTestedNVDAVersion), + ) + + +def _createAddonGUICollection() -> "AddonGUICollectionT": + """ + Add-ons that have the same ID except differ in casing cause a path collision, + as add-on IDs are installed to a case insensitive path. + Therefore addon IDs should be treated as case insensitive. + """ + return { + channel: CaseInsensitiveDict() + for channel in Channel + if channel != Channel.ALL + } + + +def _createStoreCollectionFromJson(jsonData: str) -> "AddonGUICollectionT": + """Use json string to construct a listing of available addons. + See https://github.com/nvaccess/addon-datastore#api-data-generation-details + for details of the data. + """ + data: List[Dict[str, Any]] = json.loads(jsonData) + addonCollection = _createAddonGUICollection() + + for addon in data: + addonCollection[addon["channel"]][addon["addonId"]] = _createStoreModelFromData(addon) + return addonCollection diff --git a/source/_addonStore/models/channel.py b/source/_addonStore/models/channel.py new file mode 100644 index 00000000000..86e932d9888 --- /dev/null +++ b/source/_addonStore/models/channel.py @@ -0,0 +1,52 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from typing import ( + Dict, + OrderedDict, + Set, +) + +from utils.displayString import DisplayStringStrEnum + + +class Channel(DisplayStringStrEnum): + ALL = "all" + STABLE = "stable" + BETA = "beta" + DEV = "dev" + EXTERNAL = "external" # for add-ons installed externally + + @property + def _displayStringLabels(self) -> Dict["Channel", str]: + return { + # Translators: Label for add-on channel in the add-on sotre + self.ALL: pgettext("addonStore", "All"), + # Translators: Label for add-on channel in the add-on sotre + self.STABLE: pgettext("addonStore", "Stable"), + # Translators: Label for add-on channel in the add-on sotre + self.BETA: pgettext("addonStore", "Beta"), + # Translators: Label for add-on channel in the add-on sotre + self.DEV: pgettext("addonStore", "Dev"), + # Translators: Label for add-on channel in the add-on sotre + self.EXTERNAL: pgettext("addonStore", "External"), + } + + +_channelFilters: OrderedDict[Channel, Set[Channel]] = OrderedDict({ + Channel.ALL: { + Channel.STABLE, + Channel.BETA, + Channel.DEV, + Channel.EXTERNAL, + }, + Channel.STABLE: {Channel.STABLE}, + Channel.BETA: {Channel.BETA}, + Channel.DEV: {Channel.DEV}, + Channel.EXTERNAL: {Channel.EXTERNAL}, +}) +"""A dictionary where the keys are channel groups to filter by, +and the values are which channels should be shown for a given filter. +""" diff --git a/source/_addonStore/models/status.py b/source/_addonStore/models/status.py new file mode 100644 index 00000000000..2fc81b5239e --- /dev/null +++ b/source/_addonStore/models/status.py @@ -0,0 +1,364 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import enum +import os +from pathlib import Path +from typing import ( + Dict, + Optional, + OrderedDict, + Set, + TYPE_CHECKING, +) +from typing_extensions import ( + Protocol, +) + +import globalVars +from logHandler import log +from utils.displayString import DisplayStringEnum + +from .version import SupportsVersionCheck + +if TYPE_CHECKING: + from .addon import AddonGUIModel # noqa: F401 + from addonHandler import AddonsState # noqa: F401 + + +class EnabledStatus(DisplayStringEnum): + ALL = enum.auto() + ENABLED = enum.auto() + DISABLED = enum.auto() + + @property + def _displayStringLabels(self) -> Dict["EnabledStatus", str]: + return { + # Translators: The label of an option to filter the list of add-ons in the add-on store dialog. + self.ALL: pgettext("addonStore", "All"), + # Translators: The label of an option to filter the list of add-ons in the add-on store dialog. + self.ENABLED: pgettext("addonStore", "Enabled"), + # Translators: The label of an option to filter the list of add-ons in the add-on store dialog. + self.DISABLED: pgettext("addonStore", "Disabled"), + } + + +@enum.unique +class AvailableAddonStatus(DisplayStringEnum): + """ Values to represent the status of add-ons within the NVDA add-on store. + Although related, these are independent of the states in L{addonHandler} + """ + PENDING_REMOVE = enum.auto() + AVAILABLE = enum.auto() + UPDATE = enum.auto() + REPLACE_SIDE_LOAD = enum.auto() + """ + Used when an addon in the store matches an installed add-on ID. + However, it cannot be determined if it is an upgrade. + Encourage the user to compare the version strings. + """ + INCOMPATIBLE = enum.auto() + DOWNLOADING = enum.auto() + DOWNLOAD_FAILED = enum.auto() + DOWNLOAD_SUCCESS = enum.auto() + INSTALLING = enum.auto() + INSTALL_FAILED = enum.auto() + INSTALLED = enum.auto() # installed, requires restart + PENDING_INCOMPATIBLE_DISABLED = enum.auto() # incompatible, disabled after restart + INCOMPATIBLE_DISABLED = enum.auto() # disabled due to being incompatible + PENDING_DISABLE = enum.auto() # disabled after restart + DISABLED = enum.auto() + PENDING_INCOMPATIBLE_ENABLED = enum.auto() # overriden incompatible, enabled after restart + INCOMPATIBLE_ENABLED = enum.auto() # enabled, overriden incompatible + PENDING_ENABLE = enum.auto() # enabled after restart + RUNNING = enum.auto() # enabled / active. + + @property + def _displayStringLabels(self) -> Dict["AvailableAddonStatus", str]: + return { + # Translators: Status for addons shown in the add-on store dialog + self.PENDING_REMOVE: pgettext("addonStore", "Pending removal"), + # Translators: Status for addons shown in the add-on store dialog + self.AVAILABLE: pgettext("addonStore", "Available"), + # Translators: Status for addons shown in the add-on store dialog + self.UPDATE: pgettext("addonStore", "Update Available"), + # Translators: Status for addons shown in the add-on store dialog + self.REPLACE_SIDE_LOAD: pgettext("addonStore", "Migrate to add-on store"), + # Translators: Status for addons shown in the add-on store dialog + self.INCOMPATIBLE: pgettext("addonStore", "Incompatible"), + # Translators: Status for addons shown in the add-on store dialog + self.DOWNLOADING: pgettext("addonStore", "Downloading"), + # Translators: Status for addons shown in the add-on store dialog + self.DOWNLOAD_FAILED: pgettext("addonStore", "Download failed"), + # Translators: Status for addons shown in the add-on store dialog + self.DOWNLOAD_SUCCESS: pgettext("addonStore", "Downloaded, pending install"), + # Translators: Status for addons shown in the add-on store dialog + self.INSTALLING: pgettext("addonStore", "Installing"), + # Translators: Status for addons shown in the add-on store dialog + self.INSTALL_FAILED: pgettext("addonStore", "Install failed"), + # Translators: Status for addons shown in the add-on store dialog + self.INSTALLED: pgettext("addonStore", "Installed, pending restart"), + # Translators: Status for addons shown in the add-on store dialog + self.PENDING_DISABLE: pgettext("addonStore", "Disabled, pending restart"), + # Translators: Status for addons shown in the add-on store dialog + self.DISABLED: pgettext("addonStore", "Disabled"), + # Translators: Status for addons shown in the add-on store dialog + self.PENDING_INCOMPATIBLE_DISABLED: pgettext("addonStore", "Disabled (incompatible), pending restart"), + # Translators: Status for addons shown in the add-on store dialog + self.INCOMPATIBLE_DISABLED: pgettext("addonStore", "Disabled (incompatible)"), + # Translators: Status for addons shown in the add-on store dialog + self.PENDING_INCOMPATIBLE_ENABLED: pgettext("addonStore", "Enabled (incompatible), pending restart"), + # Translators: Status for addons shown in the add-on store dialog + self.INCOMPATIBLE_ENABLED: pgettext("addonStore", "Enabled (incompatible)"), + # Translators: Status for addons shown in the add-on store dialog + self.PENDING_ENABLE: pgettext("addonStore", "Enabled, pending restart"), + # Translators: Status for addons shown in the add-on store dialog + self.RUNNING: pgettext("addonStore", "Enabled"), + } + + +class AddonStateCategory(str, enum.Enum): + """ + For backwards compatibility, the enums must remain functionally a string. + I.E. the following must be true: + > assert isinstance(AddonStateCategory.PENDING_REMOVE, str) + > assert AddonStateCategory.PENDING_REMOVE == "pendingRemovesSet" + """ + PENDING_REMOVE = "pendingRemovesSet" + PENDING_INSTALL = "pendingInstallsSet" + DISABLED = "disabledAddons" + PENDING_ENABLE = "pendingEnableSet" + PENDING_DISABLE = "pendingDisableSet" + OVERRIDE_COMPATIBILITY = "overrideCompatibility" + """ + Should be reset when changing to a new breaking release, + add-ons should be removed from this list when they are updated, disabled or removed + """ + BLOCKED = "blocked" + """Add-ons that are blocked from running because they are incompatible""" + + +def getStatus(model: "AddonGUIModel") -> Optional[AvailableAddonStatus]: + from addonHandler import ( + state as addonHandlerState, + ) + from ..dataManager import addonDataManager + from .addon import AddonStoreModel + from .version import MajorMinorPatch + addonHandlerModel = model._addonHandlerModel + if addonHandlerModel is None: + if not model.isCompatible: + # Installed incompatible add-ons have a status of disabled or running + return AvailableAddonStatus.INCOMPATIBLE + + # Any compatible add-on which is not installed should be listed as available + return AvailableAddonStatus.AVAILABLE + + for storeState, handlerStateCategories in _addonStoreStateToAddonHandlerState.items(): + # Match addonHandler states early for installed add-ons. + # Includes enabled, pending enabled, disabled, e.t.c. + if all( + model.addonId in addonHandlerState[stateCategory] + for stateCategory in handlerStateCategories + ): + # Return the add-on store state if the add-on + # is in all of the addonHandlerStates + # required to match to an add-on store state. + # Most states are a 1-to-1 match, + # however incompatible add-ons match to two states: + # one to flag if that its incompatible, + # and another for enabled/disabled. + return storeState + + addonStoreData = addonDataManager._getCachedInstalledAddonData(model.addonId) + if isinstance(model, AddonStoreModel): + # If the listed add-on is installed from a side-load + # and not available on the add-on store + # the type will not be AddonStoreModel + if addonStoreData is not None: + if model.addonVersionNumber > addonStoreData.addonVersionNumber: + return AvailableAddonStatus.UPDATE + else: + # Parsing from a side-loaded add-on + try: + manifestAddonVersion = MajorMinorPatch._parseVersionFromVersionStr(addonHandlerModel.version) + except ValueError: + # Parsing failed to get a numeric version. + # Ideally a numeric version would be compared, + # however the manifest only has a version string. + # Ensure the user is aware that it may be a downgrade or reinstall. + # Encourage users to re-install or upgrade the add-on from the add-on store. + return AvailableAddonStatus.REPLACE_SIDE_LOAD + + if model.addonVersionNumber > manifestAddonVersion: + return AvailableAddonStatus.UPDATE + + if addonHandlerModel.isRunning: + return AvailableAddonStatus.RUNNING + + log.debugWarning(f"Add-on in unknown state: {model.addonId}") + return None + + +_addonStoreStateToAddonHandlerState: OrderedDict[ + AvailableAddonStatus, + Set[AddonStateCategory] + ] = OrderedDict({ + # Pending states must be first as the pending state may be altering another state. + AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED: { + AddonStateCategory.BLOCKED, + AddonStateCategory.PENDING_DISABLE, + }, + AvailableAddonStatus.PENDING_INCOMPATIBLE_ENABLED: { + AddonStateCategory.OVERRIDE_COMPATIBILITY, + AddonStateCategory.PENDING_ENABLE, + }, + AvailableAddonStatus.PENDING_REMOVE: {AddonStateCategory.PENDING_REMOVE}, + AvailableAddonStatus.PENDING_ENABLE: {AddonStateCategory.PENDING_ENABLE}, + AvailableAddonStatus.PENDING_DISABLE: {AddonStateCategory.PENDING_DISABLE}, + AvailableAddonStatus.INCOMPATIBLE_DISABLED: {AddonStateCategory.BLOCKED}, + AvailableAddonStatus.INCOMPATIBLE_ENABLED: {AddonStateCategory.OVERRIDE_COMPATIBILITY}, + AvailableAddonStatus.DISABLED: {AddonStateCategory.DISABLED}, + AvailableAddonStatus.INSTALLED: {AddonStateCategory.PENDING_INSTALL}, +}) + + +class _StatusFilterKey(DisplayStringEnum): + """Keys for filtering by status in the NVDA add-on store.""" + INSTALLED = enum.auto() + UPDATE = enum.auto() + AVAILABLE = enum.auto() + INCOMPATIBLE = enum.auto() + + @property + def _displayStringLabels(self) -> Dict["_StatusFilterKey", str]: + return { + # Translators: A selection option to display installed add-ons in the add-on store + self.INSTALLED: pgettext("addonStore", "Installed add-ons"), + # Translators: A selection option to display updatable add-ons in the add-on store + self.UPDATE: pgettext("addonStore", "Updatable add-ons"), + # Translators: A selection option to display available add-ons in the add-on store + self.AVAILABLE: pgettext("addonStore", "Available add-ons"), + # Translators: A selection option to display incompatible add-ons in the add-on store + self.INCOMPATIBLE: pgettext("addonStore", "Installed incompatible add-ons"), + } + + +_statusFilters: OrderedDict[_StatusFilterKey, Set[AvailableAddonStatus]] = OrderedDict({ + _StatusFilterKey.INSTALLED: { + AvailableAddonStatus.UPDATE, + AvailableAddonStatus.REPLACE_SIDE_LOAD, + AvailableAddonStatus.INSTALLED, + AvailableAddonStatus.PENDING_DISABLE, + AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED, + AvailableAddonStatus.PENDING_INCOMPATIBLE_ENABLED, + AvailableAddonStatus.INCOMPATIBLE_ENABLED, + AvailableAddonStatus.INCOMPATIBLE_DISABLED, + AvailableAddonStatus.DISABLED, + AvailableAddonStatus.PENDING_ENABLE, + AvailableAddonStatus.PENDING_REMOVE, + AvailableAddonStatus.RUNNING, + }, + _StatusFilterKey.UPDATE: { + AvailableAddonStatus.UPDATE, + AvailableAddonStatus.REPLACE_SIDE_LOAD, + }, + _StatusFilterKey.AVAILABLE: { + AvailableAddonStatus.INCOMPATIBLE, + AvailableAddonStatus.AVAILABLE, + AvailableAddonStatus.UPDATE, + AvailableAddonStatus.REPLACE_SIDE_LOAD, + AvailableAddonStatus.DOWNLOAD_FAILED, + AvailableAddonStatus.DOWNLOAD_SUCCESS, + AvailableAddonStatus.DOWNLOADING, + AvailableAddonStatus.INSTALLING, + AvailableAddonStatus.INSTALL_FAILED, + AvailableAddonStatus.INSTALLED, + }, + _StatusFilterKey.INCOMPATIBLE: { + AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED, + AvailableAddonStatus.PENDING_INCOMPATIBLE_ENABLED, + AvailableAddonStatus.INCOMPATIBLE_DISABLED, + AvailableAddonStatus.INCOMPATIBLE_ENABLED, + }, +}) +"""A dictionary where the keys are a status to filter by, +and the values are which statuses should be shown for a given filter. +""" + + +class SupportsAddonState(SupportsVersionCheck, Protocol): + @property + def _stateHandler(self) -> "AddonsState": + from addonHandler import state + return state + + @property + def isRunning(self) -> bool: + return not ( + globalVars.appArgs.disableAddons + or self.isPendingInstall + or self.isDisabled + or self.isBlocked + ) + + @property + def pendingInstallPath(self) -> str: + from addonHandler import ADDON_PENDINGINSTALL_SUFFIX + return os.path.join( + globalVars.appArgs.configPath, + "addons", + self.name + ADDON_PENDINGINSTALL_SUFFIX + ) + + @property + def installPath(self) -> str: + return os.path.join( + globalVars.appArgs.configPath, + "addons", + self.name + ) + + @property + def isPendingInstall(self) -> bool: + """True if this addon has not yet been fully installed.""" + return Path(self.pendingInstallPath).exists() + + @property + def isPendingRemove(self) -> bool: + """True if this addon is marked for removal.""" + return ( + not self.isPendingInstall + and self.name in self._stateHandler[AddonStateCategory.PENDING_REMOVE] + ) + + @property + def isDisabled(self) -> bool: + return self.name in self._stateHandler[AddonStateCategory.DISABLED] + + @property + def isBlocked(self) -> bool: + return self.name in self._stateHandler[AddonStateCategory.BLOCKED] + + @property + def isPendingEnable(self) -> bool: + return self.name in self._stateHandler[AddonStateCategory.PENDING_ENABLE] + + @property + def isPendingDisable(self) -> bool: + return self.name in self._stateHandler[AddonStateCategory.PENDING_DISABLE] + + @property + def requiresRestart(self) -> bool: + return ( + self.isPendingInstall + or self.isPendingRemove + or self.isPendingEnable + or self.isPendingDisable + ) + + @property + def isInstalled(self) -> bool: + return Path(self.installPath).exists() diff --git a/source/_addonStore/models/version.py b/source/_addonStore/models/version.py new file mode 100644 index 00000000000..5b8a2ea606b --- /dev/null +++ b/source/_addonStore/models/version.py @@ -0,0 +1,140 @@ +# -*- coding: UTF-8 -*- +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2018-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from typing import ( + NamedTuple, + Optional, +) +from typing_extensions import Protocol # Python 3.8 adds native support +import addonAPIVersion + + +class MajorMinorPatch(NamedTuple): + major: int + minor: int + patch: int = 0 + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + @classmethod + def _parseVersionFromVersionStr(cls, version: str) -> "MajorMinorPatch": + versionParts = version.split(".") + versionLen = len(versionParts) + if versionLen < 2 or versionLen > 3: + raise ValueError(f"Version string not valid: {version}") + return cls( + int(versionParts[0]), + int(versionParts[1]), + 0 if len(versionParts) == 2 else int(versionParts[2]) + ) + + +class SupportsVersionCheck(Protocol): + """ Examples implementing this protocol include: + - addonHandler.Addon + - addonHandler.AddonBundle + - _addonStore.models.AddonGUIModel + - _addonStore.models.AddonStoreModel + """ + minimumNVDAVersion: addonAPIVersion.AddonApiVersionT + lastTestedNVDAVersion: addonAPIVersion.AddonApiVersionT + name: str + + @property + def overrideIncompatibility(self) -> bool: + from addonHandler import AddonStateCategory, state + return ( + self.name in state[AddonStateCategory.OVERRIDE_COMPATIBILITY] + and self.canOverrideCompatibility + ) + + def enableCompatibilityOverride(self): + """ + Should be reset when changing to a new breaking release, + and when this add-on is updated, disabled or removed. + """ + from addonHandler import AddonStateCategory, state + overiddenAddons = state[AddonStateCategory.OVERRIDE_COMPATIBILITY] + assert self.name not in overiddenAddons and self.canOverrideCompatibility + overiddenAddons.add(self.name) + state[AddonStateCategory.BLOCKED].discard(self.name) + state[AddonStateCategory.DISABLED].discard(self.name) + + @property + def canOverrideCompatibility(self) -> bool: + from addonHandler.addonVersionCheck import hasAddonGotRequiredSupport, isAddonTested + return hasAddonGotRequiredSupport(self) and not isAddonTested(self) + + @property + def _isTested(self) -> bool: + from addonHandler.addonVersionCheck import isAddonTested + return isAddonTested(self) + + @property + def _hasGotRequiredSupport(self) -> bool: + from addonHandler.addonVersionCheck import hasAddonGotRequiredSupport + return hasAddonGotRequiredSupport(self) + + @property + def isCompatible(self) -> bool: + return self._isTested and self._hasGotRequiredSupport + + def getIncompatibleReason( + self, + backwardsCompatToVersion: addonAPIVersion.AddonApiVersionT = addonAPIVersion.BACK_COMPAT_TO, + currentAPIVersion: addonAPIVersion.AddonApiVersionT = addonAPIVersion.CURRENT, + ) -> Optional[str]: + from addonHandler.addonVersionCheck import hasAddonGotRequiredSupport, isAddonTested + if not hasAddonGotRequiredSupport(self, currentAPIVersion): + return pgettext( + "addonStore", + # Translators: The reason an add-on is not compatible. + # A more recent version of NVDA is required for the add-on to work. + # The placeholder will be replaced with Year.Major.Minor (e.g. 2019.1). + "An updated version of NVDA is required. " + "NVDA version {nvdaVersion} or later." + ).format( + nvdaVersion=addonAPIVersion.formatForGUI(self.minimumNVDAVersion) + ) + elif not isAddonTested(self, backwardsCompatToVersion): + return pgettext( + "addonStore", + # Translators: The reason an add-on is not compatible. + # The addon relies on older, removed features of NVDA, an updated add-on is required. + # The placeholder will be replaced with Year.Major.Minor (e.g. 2019.1). + "An updated version of this add-on is required. " + "The minimum supported API version is now {nvdaVersion}. " + "This add-on was last tested with {lastTestedNVDAVersion}. " + "You can enable this add-on at your own risk. " + ).format( + nvdaVersion=addonAPIVersion.formatForGUI(backwardsCompatToVersion), + lastTestedNVDAVersion=addonAPIVersion.formatForGUI(self.lastTestedNVDAVersion), + ) + else: + return None + + +def getAddonCompatibilityMessage() -> str: + return pgettext( + "addonStore", + # Translators: A message indicating that some add-ons will be disabled + # unless reviewed before installation. + "Your NVDA configuration contains add-ons that are incompatible with this version of NVDA. " + "These add-ons will be disabled after installation. " + "After installation, you will be able to manually re-enable these add-ons at your own risk. " + "If you rely on these add-ons, please review the list to decide whether to continue with the installation. " + ) + + +def getAddonCompatibilityConfirmationMessage() -> str: + return pgettext( + "addonStore", + # Translators: A message to confirm that the user understands that incompatible add-ons + # will be disabled after installation, and can be manually re-enabled. + "I understand that incompatible add-ons will be disabled " + "and can be manually re-enabled at my own risk after installation." + ) diff --git a/source/_addonStore/network.py b/source/_addonStore/network.py new file mode 100644 index 00000000000..c4c005169ff --- /dev/null +++ b/source/_addonStore/network.py @@ -0,0 +1,218 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from concurrent.futures import ( + Future, + ThreadPoolExecutor, +) +import os +from typing import ( + TYPE_CHECKING, + cast, + Callable, + Dict, + Optional, + Tuple, +) + +import requests + +import addonAPIVersion +from core import callLater +from logHandler import log +from utils.security import sha256_checksum + +from .models.addon import AddonStoreModel +from .models.channel import Channel + + +if TYPE_CHECKING: + from gui.message import DisplayableError + + +_LATEST_API_VER = "latest" +""" +A string value used in the add-on store to fetch the latest version of all add-ons, +i.e include older incompatible versions. +""" + + +def _getCurrentApiVersionForURL() -> str: + year, major, minor = addonAPIVersion.CURRENT + return f"{year}.{major}.{minor}" + + +def _getAddonStoreURL(channel: Channel, lang: str, nvdaApiVersion: str) -> str: + _baseURL = "https://nvaccess.org/addonStore/" + return _baseURL + f"{lang}/{channel.value}/{nvdaApiVersion}.json" + + +class AddonFileDownloader: + OnCompleteT = Callable[[AddonStoreModel, Optional[os.PathLike]], None] + + def __init__(self, cacheDir: os.PathLike): + self._cacheDir = cacheDir + self.progress: Dict[AddonStoreModel, int] = {} # Number of chunks received + self._pending: Dict[ + Future, + Tuple[ + AddonStoreModel, + AddonFileDownloader.OnCompleteT, + "DisplayableError.OnDisplayableErrorT" + ] + ] = {} + self.complete: Dict[AddonStoreModel, os.PathLike] = {} # Path to downloaded file + self._executor = ThreadPoolExecutor( + max_workers=1, + thread_name_prefix="AddonDownloader", + ) + + def download( + self, + addonData: AddonStoreModel, + onComplete: OnCompleteT, + onDisplayableError: "DisplayableError.OnDisplayableErrorT", + ): + self.progress[addonData] = 0 + f: Future = self._executor.submit( + self._download, addonData, + ) + self._pending[f] = addonData, onComplete, onDisplayableError + f.add_done_callback(self._done) + + def _done(self, downloadAddonFuture: Future): + isCancelled = downloadAddonFuture not in self._pending + addonId = "CANCELLED" if isCancelled else self._pending[downloadAddonFuture][0].addonId + log.debug(f"Done called for {addonId}") + if isCancelled: + log.debug("Download was cancelled, not calling onComplete") + return + if not downloadAddonFuture.done() or downloadAddonFuture.cancelled(): + log.error("Logic error with download in BG thread.") + return + addonData, onComplete, onDisplayableError = self._pending[downloadAddonFuture] + downloadAddonFutureException = downloadAddonFuture.exception() + if downloadAddonFutureException: + cacheFilePath = None + from gui.message import DisplayableError + if not isinstance(downloadAddonFutureException, DisplayableError): + log.error(f"Unhandled exception in _download", exc_info=downloadAddonFuture.exception()) + else: + callLater( + delay=0, + callable=onDisplayableError.notify, + displayableError=downloadAddonFutureException + ) + else: + cacheFilePath: Optional[os.PathLike] = downloadAddonFuture.result() + + del self._pending[downloadAddonFuture] + del self.progress[addonData] + self.complete[addonData] = cacheFilePath + onComplete(addonData, cacheFilePath) + + def cancelAll(self): + log.debug("Cancelling all") + for f in self._pending.keys(): + f.cancel() + self._executor.shutdown(wait=False) + self._executor = None + self.progress.clear() + self._pending.clear() + + def _downloadAddonToPath(self, addonData: AddonStoreModel, downloadFilePath: str) -> bool: + """ + @return: True if the add-on is downloaded successfully, + False if the download is cancelled + """ + with requests.get(addonData.URL, stream=True) as r: + with open(downloadFilePath, 'wb') as fd: + # Most add-ons are small. This value was chosen quite arbitrarily, but with the intention to allow + # interrupting the download. This is particularly important on a slow connection, to provide + # a responsive UI when cancelling. + # A size has been selected attempting to balance the maximum throughput, with responsiveness for + # users with a slow connection. + # This could be improved by dynamically adjusting the chunk size based on the time elapsed between + # chunk, starting with small chunks and increasing up until a maximum wait time is reached. + chunkSize = 128000 + for chunk in r.iter_content(chunk_size=chunkSize): + fd.write(chunk) + if addonData in self.progress: # Removed when the download should be cancelled. + self.progress[addonData] += 1 + else: + log.debug(f"Cancelled download: {addonData.addonId}") + return False # The download was cancelled + return True + + def _download(self, addonData: AddonStoreModel) -> Optional[os.PathLike]: + from gui.message import DisplayableError + # Translators: A title for a dialog notifying a user of an add-on download failure. + _addonDownloadFailureMessageTitle = pgettext("addonStore", "Add-on download failure") + + log.debug(f"starting download: {addonData.addonId}") + cacheFilePath = os.path.join( + self._cacheDir, + self._getCacheFilenameForAddon(addonData) + ) + if os.path.exists(cacheFilePath): + log.debug(f"Cache file already exists, deleting {cacheFilePath}") + os.remove(cacheFilePath) + inProgressFilePath = cacheFilePath + ".download" + if addonData not in self.progress: + log.debug("the download was cancelled before it started.") + return None # The download was cancelled + try: + if not self._downloadAddonToPath(addonData, inProgressFilePath): + return None # The download was cancelled + except requests.exceptions.RequestException as e: + log.debugWarning(f"Unable to download addon file: {e}") + raise DisplayableError( + pgettext( + "addonStore", + # Translators: A message to the user if an add-on download fails + "Unable to download add-on: {name}" + ).format(name=addonData.displayName), + _addonDownloadFailureMessageTitle, + ) + except OSError as e: + log.debugWarning(f"Unable to save addon file ({inProgressFilePath}): {e}") + raise DisplayableError( + pgettext( + "addonStore", + # Translators: A message to the user if an add-on download fails + "Unable to save add-on as a file: {name}" + ).format(name=addonData.displayName), + _addonDownloadFailureMessageTitle, + ) + if not self._checkChecksum(inProgressFilePath, addonData): + os.remove(inProgressFilePath) + log.debugWarning(f"Cache file deleted, checksum mismatch: {inProgressFilePath}") + raise DisplayableError( + pgettext( + "addonStore", + # Translators: A message to the user if an add-on download is not safe + "Add-on download not safe: checksum failed for {name}" + ).format(name=addonData.displayName), + _addonDownloadFailureMessageTitle, + ) + log.debug(f"Download complete: {inProgressFilePath}") + os.rename(src=inProgressFilePath, dst=cacheFilePath) + log.debug(f"Cache file available: {cacheFilePath}") + return cast(os.PathLike, cacheFilePath) + + @staticmethod + def _checkChecksum(addonFilePath: str, addonData: AddonStoreModel) -> Optional[os.PathLike]: + with open(addonFilePath, "rb") as f: + sha256Addon = sha256_checksum(f) + return sha256Addon.casefold() == addonData.sha256.casefold() + + @staticmethod + def _getCacheFilenameForAddon(addonData: AddonStoreModel) -> str: + return f"{addonData.addonId}-{addonData.addonVersionName}.nvda-addon" + + def __del__(self): + if self._executor is not None: + self._executor.shutdown(wait=False) + self._executor = None diff --git a/source/addonAPIVersion.py b/source/addonAPIVersion.py index 647aa3b6197..438fe133b73 100644 --- a/source/addonAPIVersion.py +++ b/source/addonAPIVersion.py @@ -3,8 +3,12 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. + import buildVersion import re +from typing import ( + Tuple, +) from logHandler import log """ @@ -12,8 +16,15 @@ how the API has changed as well as the range of API versions supported by this build of NVDA """ -CURRENT = (buildVersion.version_year, buildVersion.version_major, buildVersion.version_minor) -BACK_COMPAT_TO = (2023, 1, 0) +AddonApiVersionT = Tuple[int, int, int] + +CURRENT: AddonApiVersionT = ( + buildVersion.version_year, + buildVersion.version_major, + buildVersion.version_minor +) + +BACK_COMPAT_TO: AddonApiVersionT = (2023, 1, 0) """ As BACK_COMPAT_TO is incremented, the changed / removed parts / or reasoning should be added below. These only serve to act as a reminder, the changelog should be consulted for a comprehensive listing. @@ -26,24 +37,35 @@ (0, 0, 0): API version zero, used to signify addons released prior to API version checks. """ -#: Compiled regular expression to match an addon API version string. -#: Supports year.major.minor versions (e.g. 2018.1.1). -# Although year and major are mandatory, minor is optional. -#: Resulting match objects expose three groups reflecting release year, release major, and release minor version, -# respectively. -# As minor is optional, the final group in the resulting match object may be None if minor is not provided in the original string. In this case it should be treated as being 0. -#: @type: RegexObject -ADDON_API_VERSION_REGEX = re.compile(r"^(0|\d{4})\.(\d)(?:\.(\d))?$") +ADDON_API_VERSION_REGEX: re.Pattern = re.compile(r"^(0|\d{4})\.(\d)(?:\.(\d))?$") +""" +Compiled regular expression to match an addon API version string. +Supports year.major.minor versions (e.g. 2018.1.1). +Although year and major are mandatory, minor is optional. +Resulting match objects expose three groups reflecting: +- release year +- release major +- release minor +As minor is optional, the final group in the resulting match object may be None if minor is not provided +in the original string. In this case it should be treated as being 0. +See also: L{tests.unit.test_addonVersionCheck.TestGetAPIVersionTupleFromString} +""" + -def getAPIVersionTupleFromString(version): - """Converts a string containing an NVDA version to a tuple of the form (versionYear, versionMajor, versionMinor)""" +def getAPIVersionTupleFromString(version: str) -> AddonApiVersionT: + """ + Converts a string containing an NVDA version to a tuple of the form: + (versionYear, versionMajor, versionMinor) + @raises: ValueError when unable to parse version string. + See also: L{tests.unit.test_addonVersionCheck.TestGetAPIVersionTupleFromString} + """ match = ADDON_API_VERSION_REGEX.match(version) if not match: raise ValueError(version) return tuple(int(i) if i is not None else 0 for i in match.groups()) -def formatForGUI(versionTuple): +def formatForGUI(versionTuple: AddonApiVersionT) -> str: """Converts a version tuple to a string for displaying in the GUI Examples: - (2018, 1, 1) becomes "2018.1.1" diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index a630d58ff0f..19b6cc5d782 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -4,6 +4,11 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +# Needed for type hinting CaseInsensitiveDict, UserDict +# Can be removed in a future version of python (3.8+) +from __future__ import annotations + +from abc import abstractmethod, ABC import sys import os.path import gettext @@ -15,24 +20,46 @@ from io import StringIO import pickle from six import string_types -import typing +from typing import ( + Callable, + Dict, + Optional, + Set, + TYPE_CHECKING, + Tuple, +) import globalVars import zipfile from configobj import ConfigObj from configobj.validate import Validator -from .packaging import initializeModulePackagePaths import config import languageHandler from logHandler import log import winKernel import addonAPIVersion -from . import addonVersionCheck -from .addonVersionCheck import isAddonCompatible -from .packaging import isModuleName import importlib +import NVDAState from types import ModuleType -import extensionPoints +from _addonStore.models.status import AddonStateCategory, SupportsAddonState +from _addonStore.models.version import SupportsVersionCheck +import extensionPoints +from utils.caseInsensitiveCollections import CaseInsensitiveSet + +from .addonVersionCheck import ( + isAddonCompatible, +) +from .packaging import ( + initializeModulePackagePaths, + isModuleName, +) + +if TYPE_CHECKING: + from _addonStore.models.addon import ( # noqa: F401 + AddonGUIModel, + AddonHandlerModelGeneratorT, + AddonStoreModel, + ) MANIFEST_FILENAME = "manifest.ini" stateFilename="addonsState.pickle" @@ -42,9 +69,6 @@ ADDON_PENDINGINSTALL_SUFFIX=".pendingInstall" DELETEDIR_SUFFIX=".delete" -# Add-ons that are blocked from running because they are incompatible -_blockedAddons=set() - # Allows add-ons to process additional command line arguments when NVDA starts. # Each handler is called with one keyword argument `cliArgument` @@ -54,15 +78,23 @@ class AddonsState(collections.UserDict): - """Subclasses `collections.UserDict` to preserve backwards compatibility.""" + """ + Subclasses `collections.UserDict` to preserve backwards compatibility. + In future versions of python (3.8+) UserDict[AddonStateCategory, CaseInsensitiveSet[str]] + can have type information added. + AddonStateCategory string enums mapped to a set of the add-on "name/id" currently in that state. + Add-ons that have the same ID except differ in casing cause a path collision, + as add-on IDs are installed to a case insensitive path. + Therefore add-on IDs should be treated as case insensitive. + """ + + @staticmethod + def _generateDefaultStateContent() -> Dict[AddonStateCategory, CaseInsensitiveSet[str]]: + return { + category: CaseInsensitiveSet() for category in AddonStateCategory + } - _DEFAULT_STATE_CONTENT = { - "pendingRemovesSet": set(), - "pendingInstallsSet": set(), - "disabledAddons": set(), - "pendingEnableSet": set(), - "pendingDisableSet": set(), - } + data: Dict[AddonStateCategory, CaseInsensitiveSet[str]] @property def statePath(self) -> os.PathLike: @@ -71,11 +103,15 @@ def statePath(self) -> os.PathLike: def load(self) -> None: """Populates state with the default content and then loads values from the config.""" - self.update(self._DEFAULT_STATE_CONTENT) + state = self._generateDefaultStateContent() + self.update(state) try: # #9038: Python 3 requires binary format when working with pickles. with open(self.statePath, "rb") as f: - state = pickle.load(f) + pickledState: Dict[str, Set[str]] = pickle.load(f) + for category in pickledState: + # Make pickles case insensitive + state[AddonStateCategory(category)] = CaseInsensitiveSet(pickledState[category]) self.update(state) except FileNotFoundError: pass # Clean config - no point logging in this case @@ -83,6 +119,8 @@ def load(self) -> None: log.debug("Error when reading state file", exc_info=True) except pickle.UnpicklingError: log.debugWarning("Failed to unpickle state", exc_info=True) + except Exception: + log.exception() def removeStateFile(self) -> None: try: @@ -94,14 +132,21 @@ def removeStateFile(self) -> None: def save(self) -> None: """Saves content of the state to a file unless state is empty in which case this would be pointless.""" + if not NVDAState.shouldWriteToDisk(): + log.error("NVDA should not write to disk from secure mode or launcher", stack_info=True) + return + 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) + # since older versions of NVDA aren't aware about this class and they're expecting + # the state to be using inbuilt data types only. + pickleableState: Dict[str, Set[str]] = dict() + for category in self.data: + pickleableState[category.value] = set(self.data[category]) + pickle.dump(pickleableState, f, protocol=0) except (IOError, pickle.PicklingError): log.debugWarning("Error saving state", exc_info=True) else: @@ -113,28 +158,32 @@ def cleanupRemovedDisabledAddons(self) -> None: 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"]): + installedAddonNames = CaseInsensitiveSet(a.name for a in getAvailableAddons()) + for disabledAddonName in CaseInsensitiveSet(self[AddonStateCategory.DISABLED]): + # Iterate over copy of set to prevent updating the set while iterating over it. if disabledAddonName not in installedAddonNames: - self["disabledAddons"].discard(disabledAddonName) + log.debug(f"Discarding {disabledAddonName} from disabled add-ons as it has been uninstalled.") + self[AddonStateCategory.DISABLED].discard(disabledAddonName) -state = AddonsState() +state: AddonsState[AddonStateCategory, CaseInsensitiveSet[str]] = AddonsState() -def getRunningAddons(): +def getRunningAddons() -> "AddonHandlerModelGeneratorT": """ Returns currently loaded add-ons. """ return getAvailableAddons(filterFunc=lambda addon: addon.isRunning) + def getIncompatibleAddons( currentAPIVersion=addonAPIVersion.CURRENT, - backCompatToAPIVersion=addonAPIVersion.BACK_COMPAT_TO): + backCompatToAPIVersion=addonAPIVersion.BACK_COMPAT_TO +) -> "AddonHandlerModelGeneratorT": """ Returns a generator of the add-ons that are not compatible. """ return getAvailableAddons( filterFunc=lambda addon: ( - not addonVersionCheck.isAddonCompatible( + not isAddonCompatible( addon, currentAPIVersion=currentAPIVersion, backwardsCompatToVersion=backCompatToAPIVersion @@ -150,14 +199,18 @@ def removeFailedDeletion(path: os.PathLike): def disableAddonsIfAny(): """ - Disables add-ons if told to do so by the user from add-ons manager. + Disables add-ons if told to do so by the user from add-on store. This is usually executed before refreshing the list of available add-ons. """ # Pull in and enable add-ons that should be disabled and enabled, respectively. - state["disabledAddons"] |= state["pendingDisableSet"] - state["disabledAddons"] -= state["pendingEnableSet"] - state["pendingDisableSet"].clear() - state["pendingEnableSet"].clear() + state[AddonStateCategory.DISABLED] |= state[AddonStateCategory.PENDING_DISABLE] + state[AddonStateCategory.DISABLED] -= state[AddonStateCategory.PENDING_ENABLE] + # Remove disabled add-ons from having overriden compatibility + state[AddonStateCategory.OVERRIDE_COMPATIBILITY] -= state[AddonStateCategory.DISABLED] + # Clear pending disables and enables + state[AddonStateCategory.PENDING_DISABLE].clear() + state[AddonStateCategory.PENDING_ENABLE].clear() + def initialize(): """ Initializes the add-ons subsystem. """ @@ -168,8 +221,9 @@ def initialize(): # #3090: Are there add-ons that are supposed to not run for this session? disableAddonsIfAny() getAvailableAddons(refresh=True, isFirstLoad=True) - state.cleanupRemovedDisabledAddons() - state.save() + if NVDAState.shouldWriteToDisk(): + state.cleanupRemovedDisabledAddons() + state.save() initializeModulePackagePaths() @@ -189,12 +243,13 @@ def _getDefaultAddonPaths(): return addon_paths -def _getAvailableAddonsFromPath(path, isFirstLoad=False): +def _getAvailableAddonsFromPath( + path: str, + isFirstLoad: bool = False +) -> "AddonHandlerModelGeneratorT": """ 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. - @type path: string - @rtype generator of Addon instances """ log.debug("Listing add-ons from %s", path) for p in os.listdir(path): @@ -213,7 +268,7 @@ def _getAvailableAddonsFromPath(path, isFirstLoad=False): name = a.manifest['name'] if ( isFirstLoad - and name in state["pendingRemovesSet"] + and name in state[AddonStateCategory.PENDING_REMOVE] and not a.path.endswith(ADDON_PENDINGINSTALL_SUFFIX) ): try: @@ -223,7 +278,10 @@ def _getAvailableAddonsFromPath(path, isFirstLoad=False): continue if( isFirstLoad - and (name in state["pendingInstallsSet"] or a.path.endswith(ADDON_PENDINGINSTALL_SUFFIX)) + and ( + name in state[AddonStateCategory.PENDING_INSTALL] + or a.path.endswith(ADDON_PENDINGINSTALL_SUFFIX) + ) ): newPath = a.completeInstall() if newPath: @@ -237,9 +295,12 @@ def _getAvailableAddonsFromPath(path, isFirstLoad=False): )) if a.isDisabled: log.debug("Disabling add-on %s", name) - if not isAddonCompatible(a): + if not ( + isAddonCompatible(a) + or a.overrideIncompatibility + ): log.debugWarning("Add-on %s is considered incompatible", name) - _blockedAddons.add(a.name) + state[AddonStateCategory.BLOCKED].add(a.name) yield a except: log.error("Error loading Addon from path: %s", addon_path, exc_info=True) @@ -249,9 +310,9 @@ def _getAvailableAddonsFromPath(path, isFirstLoad=False): def getAvailableAddons( refresh: bool = False, - filterFunc: typing.Optional[typing.Callable[["Addon"], bool]] = None, + filterFunc: Optional[Callable[["Addon"], bool]] = None, isFirstLoad: bool = False -) -> typing.Generator["Addon", None, None]: +) -> "AddonHandlerModelGeneratorT": """ Gets all available addons on the system. @param refresh: Whether or not to query the file system for available add-ons. @param filterFunc: A function that allows filtering of add-ons. @@ -269,11 +330,13 @@ def getAvailableAddons( _availableAddons[addon.path] = addon return (addon for addon in _availableAddons.values() if not filterFunc or filterFunc(addon)) -def installAddonBundle(bundle): - """Extracts an Addon bundle in to a unique subdirectory of the user addons directory, marking the addon as needing install completion on NVDA restart.""" - addonPath = os.path.join(globalVars.appArgs.configPath, "addons",bundle.manifest['name']+ADDON_PENDINGINSTALL_SUFFIX) - bundle.extract(addonPath) - addon=Addon(addonPath) + +def installAddonBundle(bundle: "AddonBundle") -> "Addon": + """ Extracts an Addon bundle in to a unique subdirectory of the user addons directory, + marking the addon as needing 'install completion' on NVDA restart. + """ + bundle.extract() + addon = Addon(bundle.pendingInstallPath) # #2715: The add-on must be added to _availableAddons here so that # translations can be used in installTasks module. _availableAddons[addon.path]=addon @@ -284,40 +347,66 @@ def installAddonBundle(bundle): del _availableAddons[addon.path] addon.completeRemove(runUninstallTask=False) raise AddonError("Installation failed") - state['pendingInstallsSet'].add(bundle.manifest['name']) + state[AddonStateCategory.PENDING_INSTALL].add(bundle.manifest['name']) state.save() return addon class AddonError(Exception): """ Represents an exception coming from the addon subsystem. """ -class AddonBase(object): + +class AddonBase(SupportsAddonState, SupportsVersionCheck, ABC): """The base class for functionality that is available both for add-on bundles and add-ons on the file system. Subclasses should at least implement L{manifest}. """ @property - def name(self): + def name(self) -> str: + """A unique name, the id of the add-on. + """ return self.manifest['name'] @property - def version(self): + def version(self) -> str: + """A display version. Not necessarily semantic + """ return self.manifest['version'] @property - def minimumNVDAVersion(self): + def minimumNVDAVersion(self) -> addonAPIVersion.AddonApiVersionT: return self.manifest.get('minimumNVDAVersion') @property - def lastTestedNVDAVersion(self): + def lastTestedNVDAVersion(self) -> addonAPIVersion.AddonApiVersionT: return self.manifest.get('lastTestedNVDAVersion') + @property + @abstractmethod + def manifest(self) -> "AddonManifest": + ... + + @property + def _addonStoreData(self) -> Optional["AddonStoreModel"]: + from _addonStore.dataManager import addonDataManager + assert addonDataManager + return addonDataManager._getCachedInstalledAddonData(self.name) + + @property + def _addonGuiModel(self) -> "AddonGUIModel": + from _addonStore.models.addon import _createGUIModelFromManifest + return _createGUIModelFromManifest(self) + + class Addon(AddonBase): """ Represents an Add-on available on the file system.""" - def __init__(self, path): + + @property + def manifest(self) -> "AddonManifest": + return self._manifest + + def __init__(self, path: str): """ Constructs an L{Addon} from. @param path: the base directory for the addon data. - @type path: string """ self.path = path self._extendedPackages = set() @@ -330,47 +419,36 @@ def __init__(self, path): log.debug("Using manifest translation from %s", p) translatedInput = open(p, 'rb') break - self.manifest = AddonManifest(f, translatedInput) + self._manifest = AddonManifest(f, translatedInput) if self.manifest.errors is not None: _report_manifest_errors(self.manifest) raise AddonError("Manifest file has errors.") - @property - def isPendingInstall(self): - """True if this addon has not yet been fully installed.""" - return self.path.endswith(ADDON_PENDINGINSTALL_SUFFIX) - - @property - 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 + def completeInstall(self) -> str: try: - os.rename(oldPath, newPath) - state['pendingInstallsSet'].discard(self.name) - return newPath + os.rename(self.pendingInstallPath, self.installPath) + state[AddonStateCategory.PENDING_INSTALL].discard(self.name) + return self.installPath except OSError: log.error(f"Failed to complete addon installation for {self.name}", exc_info=True) def requestRemove(self): - """Markes this addon for removal on NVDA restart.""" + """Marks this addon for removal on NVDA restart.""" if self.isPendingInstall: self.completeRemove() - state['pendingInstallsSet'].discard(self.name) + state[AddonStateCategory.PENDING_INSTALL].discard(self.name) + state[AddonStateCategory.OVERRIDE_COMPATIBILITY].discard(self.name) #Force availableAddons to be updated getAvailableAddons(refresh=True) else: - state['pendingRemovesSet'].add(self.name) + state[AddonStateCategory.PENDING_REMOVE].add(self.name) # There's no point keeping a record of this add-on pending being disabled now. # 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) + # the status in add-on store continues to say "disabled" + state[AddonStateCategory.PENDING_INSTALL].discard(self.name) state.save() - def completeRemove(self,runUninstallTask=True): + def completeRemove(self, runUninstallTask: bool = True) -> None: if runUninstallTask: try: # #2715: The add-on must be added to _availableAddons here so that @@ -392,11 +470,19 @@ def completeRemove(self,runUninstallTask=True): # clean up the addons state. If an addon with the same name is installed, it should not be automatically # disabled / blocked. 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) + state[AddonStateCategory.DISABLED].discard(self.name) + state[AddonStateCategory.PENDING_REMOVE].discard(self.name) + state[AddonStateCategory.OVERRIDE_COMPATIBILITY].discard(self.name) + state[AddonStateCategory.BLOCKED].discard(self.name) state.save() + if not self.isPendingInstall: + # Don't delete add-on store cache if it's an upgrade, + # the add-on manager has already replaced the cache file. + from _addonStore.dataManager import addonDataManager + assert addonDataManager + addonDataManager._deleteCacheInstalledAddon(self.name) + def addToPackagePath(self, package): """ Adds this L{Addon} extensions to the specific package path if those exist. This allows the addon to "run" / be available because the package is able to search its path, @@ -422,10 +508,16 @@ def addToPackagePath(self, package): self._extendedPackages.add(package) log.debug("Addon %s added to %s package path", self.manifest['name'], package.__name__) - def enable(self, shouldEnable): - """Sets this add-on to be disabled or enabled when NVDA restarts.""" + def enable(self, shouldEnable: bool) -> None: + """ + Sets this add-on to be disabled or enabled when NVDA restarts. + @raises: AddonError on failure. + """ if shouldEnable: - if not isAddonCompatible(self): + if not ( + isAddonCompatible(self) + or self.overrideIncompatibility + ): import addonAPIVersion raise AddonError( "Add-on is not compatible:" @@ -437,42 +529,33 @@ def enable(self, shouldEnable): addonAPIVersion.BACK_COMPAT_TO ) ) - if self.name in state["pendingDisableSet"]: + if self.name in state[AddonStateCategory.PENDING_DISABLE]: # Undoing a pending disable. - state["pendingDisableSet"].discard(self.name) + state[AddonStateCategory.PENDING_DISABLE].discard(self.name) else: - state["pendingEnableSet"].add(self.name) + if self.canOverrideCompatibility and not self.overrideIncompatibility: + from gui import mainFrame + from gui._addonStoreGui.controls.messageDialogs import _shouldInstallWhenAddonTooOldDialog + if not _shouldInstallWhenAddonTooOldDialog(mainFrame, self._addonGuiModel): + import addonAPIVersion + raise AddonError("Add-on is not compatible and over ride was abandoned") + state[AddonStateCategory.PENDING_ENABLE].add(self.name) + if self.overrideIncompatibility: + state[AddonStateCategory.BLOCKED].discard(self.name) else: - if self.name in state["pendingEnableSet"]: + if self.name in state[AddonStateCategory.PENDING_ENABLE]: # Undoing a pending enable. - state["pendingEnableSet"].discard(self.name) + state[AddonStateCategory.PENDING_ENABLE].discard(self.name) # No need to disable an addon that is already disabled. # This also prevents the status in the add-ons dialog from saying "disabled, pending disable" - elif self.name not in state["disabledAddons"]: - state["pendingDisableSet"].add(self.name) + elif self.name not in state[AddonStateCategory.DISABLED]: + state[AddonStateCategory.PENDING_DISABLE].add(self.name) + if not self.isCompatible: + state[AddonStateCategory.BLOCKED].add(self.name) + state[AddonStateCategory.OVERRIDE_COMPATIBILITY].discard(self.name) # Record enable/disable flags as a way of preparing for disaster such as sudden NVDA crash. state.save() - @property - def isRunning(self): - return not (globalVars.appArgs.disableAddons or self.isPendingInstall or self.isDisabled or self.isBlocked) - - @property - def isDisabled(self): - return self.name in state["disabledAddons"] - - @property - def isBlocked(self): - return self.name in _blockedAddons - - @property - def isPendingEnable(self): - return self.name in state["pendingEnableSet"] - - @property - def isPendingDisable(self): - return self.name in state["pendingDisableSet"] - def _getPathForInclusionInPackage(self, package): extension_path = os.path.join(self.path, package.__name__) return extension_path @@ -637,7 +720,7 @@ class AddonBundle(AddonBase): """ Represents the contents of an NVDA addon suitable for distribution. The bundle is compressed using the zip file format. Manifest information is available without the need for extraction.""" - def __init__(self, bundlePath): + def __init__(self, bundlePath: str): """ Constructs an L{AddonBundle} from a filename. @param bundlePath: The path for the bundle file. """ @@ -663,12 +746,14 @@ def __init__(self, bundlePath): _report_manifest_errors(self.manifest) raise AddonError("Manifest file has errors.") - def extract(self, addonPath): + def extract(self, addonPath: Optional[str] = None): """ Extracts the bundle content to the specified path. The addon will be extracted to L{addonPath} @param addonPath: Path where to extract contents. - @type addonPath: string """ + if addonPath is None: + addonPath = self.pendingInstallPath + with zipfile.ZipFile(self._path, 'r') as z: for info in z.infolist(): if isinstance(info.filename, bytes): @@ -679,9 +764,8 @@ def extract(self, addonPath): z.extract(info, addonPath) @property - def manifest(self): + def manifest(self) -> "AddonManifest": """ Gets the manifest for the represented Addon. - @rtype: AddonManifest """ return self._manifest @@ -801,14 +885,17 @@ def _validateApiVersionRange(self): minRequiredVersion = self.get("minimumNVDAVersion") return minRequiredVersion <= lastTested -def validate_apiVersionString(value): + +def validate_apiVersionString(value: str) -> Tuple[int, int, int]: + """ + @raises: configobj.validate.ValidateError on validation error + """ from configobj.validate import ValidateError if not value or value == "None": return (0, 0, 0) if not isinstance(value, string_types): raise ValidateError('Expected an apiVersion in the form of a string. EG "2019.1.0"') try: - tuple = addonAPIVersion.getAPIVersionTupleFromString(value) - return tuple + return addonAPIVersion.getAPIVersionTupleFromString(value) except ValueError as e: raise ValidateError('"{}" is not a valid API Version string: {}'.format(value, e)) diff --git a/source/addonHandler/addonVersionCheck.py b/source/addonHandler/addonVersionCheck.py index ae4881670a5..c8fa4eee85f 100644 --- a/source/addonHandler/addonVersionCheck.py +++ b/source/addonHandler/addonVersionCheck.py @@ -1,19 +1,31 @@ # -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2018 NV Access Limited +# Copyright (C) 2018-2023 NV Access Limited # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from typing import TYPE_CHECKING + import addonAPIVersion -def hasAddonGotRequiredSupport(addon, currentAPIVersion=addonAPIVersion.CURRENT): +if TYPE_CHECKING: + from _addonStore.models.addon import SupportsVersionCheck # noqa: F401 + + +def hasAddonGotRequiredSupport( + addon: "SupportsVersionCheck", + currentAPIVersion: addonAPIVersion.AddonApiVersionT = addonAPIVersion.CURRENT +) -> bool: """True if NVDA provides the add-on with an API version high enough to meet the add-on's minimum requirements """ minVersion = addon.minimumNVDAVersion return minVersion <= currentAPIVersion -def isAddonTested(addon, backwardsCompatToVersion=addonAPIVersion.BACK_COMPAT_TO): +def isAddonTested( + addon: "SupportsVersionCheck", + backwardsCompatToVersion: addonAPIVersion.AddonApiVersionT = addonAPIVersion.BACK_COMPAT_TO +) -> bool: """True if this add-on is tested for the given API version. By default, the current version of NVDA is evaluated. """ @@ -21,10 +33,10 @@ def isAddonTested(addon, backwardsCompatToVersion=addonAPIVersion.BACK_COMPAT_TO def isAddonCompatible( - addon, - currentAPIVersion=addonAPIVersion.CURRENT, - backwardsCompatToVersion=addonAPIVersion.BACK_COMPAT_TO -): + addon: "SupportsVersionCheck", + currentAPIVersion: addonAPIVersion.AddonApiVersionT = addonAPIVersion.CURRENT, + backwardsCompatToVersion: addonAPIVersion.AddonApiVersionT = addonAPIVersion.BACK_COMPAT_TO +) -> bool: """Tests if the addon is compatible. The compatibility is defined by having the required features in NVDA, and by having been tested / built against an API version that is still supported by this version of NVDA. diff --git a/source/config/__init__.py b/source/config/__init__.py index 6725e7a7601..a24ce168c9c 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -538,11 +538,6 @@ def __init__(self): self._shouldHandleProfileSwitch: bool = True self._pendingHandleProfileSwitch: bool = False self._suspendedTriggers: Optional[List[ProfileTrigger]] = None - # Never save the config if running securely or if running from the launcher. - # When running from the launcher we don't save settings because the user may decide not to - # install this version, and these settings may not be compatible with the already - # installed version. See #7688 - self._shouldWriteProfile: bool = not (globalVars.appArgs.secure or globalVars.appArgs.launcher) self._initBaseConf() #: Maps triggers to profiles. self.triggersToProfiles: Optional[Dict[ProfileTrigger, ConfigObj]] = None @@ -600,7 +595,7 @@ def _loadConfig(self, fn, fileError=False): profile.newlines = "\r\n" profileCopy = deepcopy(profile) try: - writeProfileFunc = self._writeProfileToFile if self._shouldWriteProfile else None + writeProfileFunc = self._writeProfileToFile if NVDAState.shouldWriteToDisk() else None profileUpgrader.upgrade(profile, self.validator, writeProfileFunc) except Exception as e: # Log at level info to ensure that the profile is logged. @@ -704,7 +699,7 @@ def save(self): """ # #7598: give others a chance to either save settings early or terminate tasks. pre_configSave.notify() - if not self._shouldWriteProfile: + if not NVDAState.shouldWriteToDisk(): log.info("Not writing profile, either --secure or --launcher args present") return try: diff --git a/source/core.py b/source/core.py index 8397fde0bb8..324617da52f 100644 --- a/source/core.py +++ b/source/core.py @@ -240,6 +240,8 @@ def resetConfiguration(factoryDefaults=False): log.debug("setting language to %s"%lang) languageHandler.setLanguage(lang) # Addons + from _addonStore import dataManager + dataManager.initialize() addonHandler.initialize() # Hardware background i/o log.debug("initializing background i/o") @@ -374,7 +376,7 @@ def _closeAllWindows(): for instance, state in nonWeak.items(): if state is _SettingsDialog.DialogState.DESTROYED: - log.error( + log.debugWarning( "Destroyed but not deleted instance of gui.SettingsDialog exists" f": {instance.title} - {instance.__class__.__qualname__} - {instance}" ) @@ -518,6 +520,8 @@ def main(): import socket socket.setdefaulttimeout(10) log.debug("Initializing add-ons system") + from _addonStore import dataManager + dataManager.initialize() addonHandler.initialize() if globalVars.appArgs.disableAddons: log.info("Add-ons are disabled. Restart NVDA to enable them.") @@ -835,6 +839,11 @@ def _terminate(module, name=None): except: log.exception("Error terminating %s" % name) + +def isMainThread() -> bool: + return threading.get_ident() == mainThreadId + + def requestPump(): """Request a core pump. This will perform any queued activity. @@ -869,7 +878,7 @@ def callLater(delay, callable, *args, **kwargs): # If NVDA has not fully initialized yet, the wxApp may not be initialized. # wx.CallLater and wx.CallAfter requires the wxApp to be initialized. raise NVDANotInitializedError("Cannot schedule callable, wx.App is not initialized") - if threading.get_ident() == mainThreadId: + if isMainThread(): return wx.CallLater(delay, _callLaterExec, callable, args, kwargs) else: return wx.CallAfter(wx.CallLater,delay, _callLaterExec, callable, args, kwargs) diff --git a/source/globalCommands.py b/source/globalCommands.py index 4c0f80141fc..2947a2378e7 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -3130,12 +3130,12 @@ def script_activatePythonConsole(self,gesture): pythonConsole.activate() @script( - # Translators: Input help mode message for activate manage add-ons command. - description=_("Activates the NVDA Add-ons Manager to install and uninstall add-on packages for NVDA"), + # Translators: Input help mode message to activate Add-on Store command. + description=_("Activates the Add-on Store to browse and manage add-on packages for NVDA"), category=SCRCAT_TOOLS ) - def script_activateAddonsManager(self,gesture): - wx.CallAfter(gui.mainFrame.onAddonsManagerCommand, None) + def script_activateAddonsManager(self, gesture: inputCore.InputGesture): + wx.CallAfter(gui.mainFrame.onAddonStoreCommand, None) @script( description=_( diff --git a/source/gui/__init__.py b/source/gui/__init__.py index c3a3f0739e1..56b428ddfeb 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -22,6 +22,9 @@ import speech import queueHandler import core +from typing import ( + Optional, +) import systemUtils from .message import ( # messageBox is accessed through `gui.messageBox` as opposed to `gui.message.messageBox` throughout NVDA, @@ -70,11 +73,14 @@ def quit(): DONATE_URL = "http://www.nvaccess.org/donate/" ### Globals -mainFrame = None +mainFrame: Optional["MainFrame"] = None +"""Set by initialize. Should be used as the parent for "top level" dialogs. +""" class MainFrame(wx.Frame): - + """A hidden window, intended to act as the parent to all dialogs. + """ def __init__(self): style = wx.DEFAULT_FRAME_STYLE ^ wx.MAXIMIZE_BOX ^ wx.MINIMIZE_BOX | wx.FRAME_NO_TASKBAR super(MainFrame, self).__init__(None, wx.ID_ANY, versionInfo.name, size=(1,1), style=style) @@ -319,15 +325,31 @@ def onPythonConsoleCommand(self, evt): pythonConsole.initialize() pythonConsole.activate() + if NVDAState._allowDeprecatedAPI(): + def onAddonsManagerCommand(self, evt: wx.MenuEvent): + log.warning("onAddonsManagerCommand is deprecated, use onAddonStoreCommand instead.") + self.onAddonStoreCommand(evt) + @blockAction.when( blockAction.Context.SECURE_MODE, blockAction.Context.MODAL_DIALOG_OPEN, + blockAction.Context.WINDOWS_LOCKED, + blockAction.Context.WINDOWS_STORE_VERSION, + blockAction.Context.RUNNING_LAUNCHER, ) - def onAddonsManagerCommand(self,evt): + def onAddonStoreCommand(self, evt: wx.MenuEvent): self.prePopup() - from .addonGui import AddonsDialog - d=AddonsDialog(gui.mainFrame) - d.Show() + from ._addonStoreGui import AddonStoreDialog + from ._addonStoreGui.viewModels.store import AddonStoreVM + _storeVM = AddonStoreVM() + try: + d = AddonStoreDialog(mainFrame, _storeVM) + except SettingsDialog.MultiInstanceErrorWithDialog as errorWithDialog: + errorWithDialog.dialog.SetFocus() + else: + _storeVM.refresh() + d.Maximize() + d.Show() self.postPopup() def onReloadPluginsCommand(self, evt): @@ -447,13 +469,16 @@ def __init__(self, frame: MainFrame): self.menu_tools_toggleBrailleViewer.Check(brailleViewer.isBrailleViewerActive()) brailleViewer.postBrailleViewerToolToggledAction.register(frame.onBrailleViewerChangedState) + if not config.isAppX and NVDAState.shouldWriteToDisk(): + # Translators: The label of a menu item to open the Add-on store + item = menu_tools.Append(wx.ID_ANY, _("Add-on &store...")) + self.Bind(wx.EVT_MENU, frame.onAddonStoreCommand, item) + if not globalVars.appArgs.secure and not config.isAppX: # Translators: The label for the menu item to open NVDA Python Console. item = menu_tools.Append(wx.ID_ANY, _("Python console")) self.Bind(wx.EVT_MENU, frame.onPythonConsoleCommand, item) - # Translators: The label of a menu item to open the Add-ons Manager. - item = menu_tools.Append(wx.ID_ANY, _("Manage &add-ons...")) - self.Bind(wx.EVT_MENU, frame.onAddonsManagerCommand, item) + if not globalVars.appArgs.secure and not config.isAppX and not NVDAState.isRunningAsSource(): # Translators: The label for the menu item to create a portable copy of NVDA from an installed or another portable version. item = menu_tools.Append(wx.ID_ANY, _("Create portable copy...")) @@ -611,7 +636,7 @@ def _appendConfigManagementSection(self, frame: wx.Frame) -> None: _("Reset all settings to default state") ) self.Bind(wx.EVT_MENU, frame.onRevertToDefaultConfigurationCommand, item) - if not (globalVars.appArgs.secure or globalVars.appArgs.launcher): + if NVDAState.shouldWriteToDisk(): item = self.menu.Append( wx.ID_SAVE, # Translators: The label for the menu item to save current settings. diff --git a/source/gui/_addonStoreGui/__init__.py b/source/gui/_addonStoreGui/__init__.py new file mode 100644 index 00000000000..578fa7a0487 --- /dev/null +++ b/source/gui/_addonStoreGui/__init__.py @@ -0,0 +1,10 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from .controls.storeDialog import AddonStoreDialog + +__all__ = [ + "AddonStoreDialog", +] diff --git a/source/gui/_addonStoreGui/controls/__init__.py b/source/gui/_addonStoreGui/controls/__init__.py new file mode 100644 index 00000000000..f486e03765b --- /dev/null +++ b/source/gui/_addonStoreGui/controls/__init__.py @@ -0,0 +1,4 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. diff --git a/source/gui/_addonStoreGui/controls/actions.py b/source/gui/_addonStoreGui/controls/actions.py new file mode 100644 index 00000000000..354bc64407f --- /dev/null +++ b/source/gui/_addonStoreGui/controls/actions.py @@ -0,0 +1,73 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import functools +from typing import ( + Dict, + List, +) + +import wx + +from logHandler import log + +from ..viewModels.action import AddonActionVM +from ..viewModels.store import AddonStoreVM + + +class _ActionsContextMenu: + def __init__(self, storeVM: AddonStoreVM): + self._storeVM = storeVM + self._actionMenuItemMap: Dict[AddonActionVM, wx.MenuItem] = {} + self._contextMenu = wx.Menu() + + def popupContextMenuFromPosition( + self, + targetWindow: wx.Window, + position: wx.Position = wx.DefaultPosition + ): + self._populateContextMenu() + targetWindow.PopupMenu(self._contextMenu, pos=position) + + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionVM): + selectedAddon = actionVM.listItemVM + log.debug(f"action selected: actionVM: {actionVM}, selectedAddon: {selectedAddon}") + actionVM.actionHandler(selectedAddon) + + def _populateContextMenu(self): + prevActionIndex = -1 + for action in self._storeVM.actionVMList: + menuItem = self._actionMenuItemMap.get(action) + menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) + isMenuItemInContextMenu = menuItem is not None and menuItem in menuItems + + if isMenuItemInContextMenu: + # Always unbind as we need to rebind menu items to the latest action VM + self._contextMenu.Unbind(wx.EVT_MENU, source=menuItem) + + if action.isValid: + if isMenuItemInContextMenu: + prevActionIndex = menuItems.index(menuItem) + else: + # Insert menu item into context menu + prevActionIndex += 1 + self._actionMenuItemMap[action] = self._contextMenu.Insert( + prevActionIndex, + id=-1, + item=action.displayName + ) + + # Bind the menu item to the latest action VM + self._contextMenu.Bind( + event=wx.EVT_MENU, + handler=functools.partial(self._menuItemClicked, actionVM=action), + source=self._actionMenuItemMap[action], + ) + + elif isMenuItemInContextMenu: + # The action is invalid but the menu item exists and is in the context menu. + # Remove the menu item from the context menu. + self._contextMenu.RemoveItem(menuItem) + del self._actionMenuItemMap[action] diff --git a/source/gui/_addonStoreGui/controls/addonList.py b/source/gui/_addonStoreGui/controls/addonList.py new file mode 100644 index 00000000000..09375d5e357 --- /dev/null +++ b/source/gui/_addonStoreGui/controls/addonList.py @@ -0,0 +1,153 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from typing import ( + Optional, +) + +import wx + +from gui import ( + guiHelper, + nvdaControls, +) +from gui.dpiScalingHelper import DpiScalingHelperMixinWithoutInit +from logHandler import log + +from .actions import _ActionsContextMenu +from ..viewModels.addonList import AddonListVM + + +class AddonVirtualList( + nvdaControls.AutoWidthColumnListCtrl, + DpiScalingHelperMixinWithoutInit, +): + def __init__( + self, + parent: wx.Window, + addonsListVM: AddonListVM, + actionsContextMenu: _ActionsContextMenu, + ): + super().__init__( + parent, + style=( + wx.LC_REPORT # Single or multicolumn report view, with optional header. + | wx.LC_VIRTUAL # The application provides items text on demand. May only be used with LC_REPORT. + | wx.LC_SINGLE_SEL # Single selection (default is multiple). + | wx.LC_HRULES # Draws light horizontal rules between rows in report mode. + | wx.LC_VRULES # Draws light vertical rules between columns in report mode. + ), + autoSizeColumn=1, + ) + self._addonsListVM = addonsListVM + self._actionsContextMenu = actionsContextMenu + + self.SetMinSize(self.scaleSize((500, 500))) + + self._refreshColumns() + self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.OnItemSelected) + self.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.OnItemActivated) + self.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.OnItemDeselected) + self.Bind(wx.EVT_LIST_COL_CLICK, self.OnColClick) + + self.Bind(event=wx.EVT_CONTEXT_MENU, handler=self._popupContextMenuFromList) + + self.SetItemCount(addonsListVM.getCount()) + selIndex = self._addonsListVM.getSelectedIndex() + if selIndex is not None: + self.Select(selIndex) + self.Focus(selIndex) + self._addonsListVM.itemUpdated.register(self._itemDataUpdated) + self._addonsListVM.updated.register(self._doRefresh) + + def _refreshColumns(self): + self.ClearAll() + for colIndex, col in enumerate(self._addonsListVM.presentedFields): + self.InsertColumn(colIndex, col.displayString, width=self.scaleSize(col.width)) + self.Layout() + + def _getListSelectionPosition(self) -> Optional[wx.Position]: + firstSelectedIndex: int = self.GetFirstSelected() + if firstSelectedIndex < 0: + return None + itemRect: wx.Rect = self.GetItemRect(firstSelectedIndex) + return itemRect.GetBottomLeft() + + def _popupContextMenuFromList(self, evt: wx.ContextMenuEvent): + listSelectionPosition = self._getListSelectionPosition() + if listSelectionPosition is None: + return + eventPosition: wx.Position = evt.GetPosition() + if eventPosition == wx.DefaultPosition: + # keyboard triggered context menu (due to "applications" key) + # don't have position set. It must be fetched from the selected item. + self._actionsContextMenu.popupContextMenuFromPosition(self, listSelectionPosition) + else: + # Mouse (right click) triggered context menu. + # In this case the menu is positioned better with GetPopupMenuSelectionFromUser. + self._actionsContextMenu.popupContextMenuFromPosition(self) + + def _itemDataUpdated(self, index: int): + log.debug(f"index: {index}") + self.RefreshItem(index) + + def OnItemSelected(self, evt: wx.ListEvent): + newIndex = evt.GetIndex() + log.debug(f"item selected: {newIndex}") + self._addonsListVM.setSelection(index=newIndex) + + def OnItemActivated(self, evt: wx.ListEvent): + position = self._getListSelectionPosition() + self._actionsContextMenu.popupContextMenuFromPosition(self, position) + log.debug(f"item activated: {evt.GetIndex()}") + + def OnItemDeselected(self, evt: wx.ListEvent): + log.debug(f"item deselected") + self._addonsListVM.setSelection(None) + + def OnGetItemText(self, itemIndex: int, colIndex: int) -> str: + dataItem = self._addonsListVM.getAddonFieldText( + itemIndex, + self._addonsListVM.presentedFields[colIndex] + ) + if dataItem is None: + # Failed to get dataItem, index may have been lost in refresh. + return '' + return str(dataItem) + + def OnColClick(self, evt: wx.ListEvent): + colIndex = evt.GetColumn() + log.debug(f"col clicked: {colIndex}") + self._addonsListVM.setSortField(self._addonsListVM.presentedFields[colIndex]) + + def _doRefresh(self): + with guiHelper.autoThaw(self): + newCount = self._addonsListVM.getCount() + self.SetItemCount(newCount) + self._refreshSelection() + + def _refreshSelection(self): + selected = self.GetFirstSelected() + newSelectedIndex = self._addonsListVM.getSelectedIndex() + log.debug(f"_refreshSelection {newSelectedIndex}") + if newSelectedIndex is not None: + self.Select(newSelectedIndex) + self.Focus(newSelectedIndex) + # wx.ListCtrl doesn't send a selection event if the index hasn't changed, + # however, the item at that index may have changed as a result of filtering. + # To ensure parent dialogs are notified, explicitly send an event. + if selected == newSelectedIndex: + evt = wx.ListEvent(wx.wxEVT_LIST_ITEM_SELECTED, self.GetId()) + evt.SetIndex(newSelectedIndex) + evt.SetClientObject(self._addonsListVM.getSelection()) + self.GetEventHandler().ProcessEvent(evt) + elif newSelectedIndex is None: + # wx.ListCtrl doesn't send a deselection event when the list is emptied. + # To ensure parent dialogs are notified, explicitly send an event. + self.Select(selected, on=0) + evt = wx.ListEvent(wx.wxEVT_LIST_ITEM_DESELECTED, self.GetId()) + evt.SetIndex(-1) + evt.SetClientObject(None) + self.GetEventHandler().ProcessEvent(evt) diff --git a/source/gui/_addonStoreGui/controls/details.py b/source/gui/_addonStoreGui/controls/details.py new file mode 100644 index 00000000000..6bc49545e90 --- /dev/null +++ b/source/gui/_addonStoreGui/controls/details.py @@ -0,0 +1,309 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import wx + +from _addonStore.models.addon import ( + AddonStoreModel, +) +from gui import guiHelper +from gui.dpiScalingHelper import DpiScalingHelperMixinWithoutInit +from logHandler import log + +from ..viewModels.addonList import AddonDetailsVM, AddonListField + +from .actions import _ActionsContextMenu + +_fontFaceName = "Segoe UI" +_fontFaceName_semiBold = "Segoe UI Semibold" + + +class AddonDetails( + wx.Panel, + DpiScalingHelperMixinWithoutInit, +): + _labelSpace = " " # em space, wider than regular space, for visual layout. + + # Translators: Header (usually the add-on name) when add-ons are loading. In the add-on store dialog. + _loadingAddonsLabelText: str = pgettext("addonStore", "Loading add-ons...") + + # Translators: Header (usually the add-on name) when no add-on is selected. In the add-on store dialog. + _noAddonSelectedLabelText: str = pgettext("addonStore", "No add-on selected.") + + # Translators: Label for the text control containing a description of the selected add-on. + # In the add-on store dialog. + _descriptionLabelText: str = pgettext("addonStore", "Description:") + + # Translators: Label for the text control containing a description of the selected add-on. + # In the add-on store dialog. + _statusLabelText: str = pgettext("addonStore", "S&tatus:") + + # Translators: Label for the text control containing a description of the selected add-on. + # In the add-on store dialog. + _actionsLabelText: str = pgettext("addonStore", "&Actions") + + def __init__( + self, + parent: wx.Window, + detailsVM: AddonDetailsVM, + actionsContextMenu: _ActionsContextMenu, + ): + self._detailsVM: AddonDetailsVM = detailsVM + self._actionsContextMenu = actionsContextMenu + + wx.Panel.__init__( + self, + parent, + style=wx.TAB_TRAVERSAL | wx.BORDER_THEME + ) + + selfSizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(selfSizer) + parentSizer = wx.BoxSizer(wx.VERTICAL) + # To make the text fields less ugly. + # See Windows explorer file properties dialog for an example. + self.SetBackgroundColour(wx.Colour("white")) + + self.addonNameCtrl = wx.StaticText( + self, + style=wx.ALIGN_CENTRE_HORIZONTAL | wx.ST_NO_AUTORESIZE + ) + self.updateAddonName(AddonDetails._noAddonSelectedLabelText) + self._setAddonNameCtrlStyle() + selfSizer.Add(self.addonNameCtrl, flag=wx.EXPAND) + selfSizer.Add( + parentSizer, + border=guiHelper.BORDER_FOR_DIALOGS, + proportion=1, # make vertically stretchable + flag=( + wx.EXPAND # make horizontally stretchable + | wx.ALL # and make border all around + ), + ) + + self.contents = wx.BoxSizer(wx.VERTICAL) + self.contentsPanel = wx.Panel(self) + self.contentsPanel.SetSizer(self.contents) + parentSizer.Add(self.contentsPanel, proportion=1, flag=wx.EXPAND | wx.ALL) + + self.contents.AddSpacer(guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS) + + # It would be nice to override the name using wx.Accessible, + # but using it on a TextCtrl breaks the accessibility of the control entirely (all state/role is reset) + # Instead, add a hidden label for the textBox, Windows exposes this as the accessible name. + self.descriptionLabel = wx.StaticText( + self.contentsPanel, + label=AddonDetails._descriptionLabelText + ) + self.contents.Add(self.descriptionLabel, flag=wx.EXPAND) + self.descriptionLabel.Hide() + self.descriptionTextCtrl = wx.TextCtrl( + self.contentsPanel, + style=( + 0 # purely to allow subsequent items to line up. + | wx.TE_MULTILINE # details will require multiple lines + | wx.TE_READONLY # the details shouldn't be user editable + | wx.BORDER_NONE + ) + ) + panelWidth = -1 # maximize width + descriptionMinSize = wx.Size(self.scaleSize((panelWidth, 100))) + descriptionMaxSize = wx.Size(self.scaleSize((panelWidth, 800))) + self.descriptionTextCtrl.SetMinSize(descriptionMinSize) + self.descriptionTextCtrl.SetMaxSize(descriptionMaxSize) + self.contents.Add(self.descriptionTextCtrl, flag=wx.EXPAND) + + self.contents.Add(wx.StaticLine(self.contentsPanel), flag=wx.EXPAND) + self.contents.AddSpacer(guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS) + + self.actionsButton = wx.Button(self.contentsPanel, label=self._actionsLabelText) + self.contents.Add(self.actionsButton) + self.actionsButton.Bind( + event=wx.EVT_BUTTON, + handler=lambda e: self._actionsContextMenu.popupContextMenuFromPosition(self, self.actionsButton.Position) + ) + + self.contents.AddSpacer(guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS) + self.contents.Add(wx.StaticLine(self.contentsPanel), flag=wx.EXPAND) + self.contents.AddSpacer(guiHelper.SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS) + + # It would be nice to override the name using wx.Accessible, + # but using it on a TextCtrl breaks the accessibility of the control entirely (all state/role is reset) + # Instead, add a hidden label for the textBox, Windows exposes this as the accessible name. + self.otherDetailsLabel = wx.StaticText( + self.contentsPanel, + # Translators: Label for the text control containing extra details about the selected add-on. + # In the add-on store dialog. + label=pgettext("addonStore", "&Other Details:") + ) + self.contents.Add(self.otherDetailsLabel, flag=wx.EXPAND) + self.otherDetailsLabel.Hide() + self.otherDetailsTextCtrl = wx.TextCtrl( + self.contentsPanel, + size=self.scaleSize((panelWidth, 400)), + style=( + 0 # purely to allow subsequent items to line up. + | wx.TE_MULTILINE # details will require multiple lines + | wx.TE_READONLY # the details shouldn't be user editable + | wx.TE_RICH2 + | wx.TE_NO_VSCROLL # No scroll by default. + | wx.BORDER_NONE + ) + ) + self._createRichTextStyles() + self.contents.Add(self.otherDetailsTextCtrl, flag=wx.EXPAND, proportion=1) + self._refresh() # ensure that the visual state matches. + self._detailsVM.updated.register(self._updatedListItem) + self.Layout() + + def _createRichTextStyles(self): + # Set up the text styles for the "other details" (which contains several fields) + # Note, wx seems to merge text styles when using 'SetDefaultStyle', + # so if color is used in one text attr, the others need to override it also. + # If this isn't done and E.G. style1 doesn't specify color, style2 is blue, then + # setting style1 as the default style will continue to result in blue text. + self.defaultStyle = wx.TextAttr() + self.defaultStyle.SetFontFaceName(_fontFaceName) + self.defaultStyle.SetTextColour("black") + self.defaultStyle.SetFontSize(10) + + self.labelStyle = wx.TextAttr(self.defaultStyle) + # Note: setting font weight doesn't seem to work for RichText, instead specify via the font face name + self.labelStyle.SetFontFaceName(_fontFaceName_semiBold) + + def _setAddonNameCtrlStyle(self): + addonNameFont: wx.Font = self.addonNameCtrl.GetFont() + addonNameFont.SetPointSize(18) + # Note: setting font weight via the font face name doesn't seem to work on staticText + # set explicitly using SetWeight + addonNameFont.SetWeight(wx.FONTWEIGHT_BOLD) + addonNameFont.SetFaceName(_fontFaceName) + self.addonNameCtrl.SetFont(addonNameFont) + self.addonNameCtrl.SetForegroundColour("white") + nvdaPurple = wx.Colour((71, 47, 95)) + self.addonNameCtrl.SetBackgroundColour(nvdaPurple) + + def updateAddonName(self, displayName: str): + self.addonNameCtrl.SetLabelText(displayName) + self.SetLabel(displayName) + + def _updatedListItem(self, addonDetailsVM: AddonDetailsVM): + log.debug(f"Setting listItem: {addonDetailsVM.listItem}") + assert self._detailsVM.listItem == addonDetailsVM.listItem + self._refresh() + + def _refresh(self): + details = None if self._detailsVM.listItem is None else self._detailsVM.listItem.model + + with guiHelper.autoThaw(self): + # AppendText is used to build up the details so that formatting can be set as text is added, via + # SetDefaultStyle, however, this means the text control must start empty. + self.otherDetailsTextCtrl.SetValue("") + if not details: + self.contentsPanel.Hide() + if self._detailsVM._isLoading: + self.updateAddonName(AddonDetails._loadingAddonsLabelText) + else: + self.updateAddonName(AddonDetails._noAddonSelectedLabelText) + else: + self.updateAddonName(details.displayName) + self.descriptionLabel.SetLabelText(AddonDetails._descriptionLabelText) + # For a ExpandoTextCtr, SetDefaultStyle can not be used to set the style (along with the use + # of AppendText) because AppendText has been overridden to use SetValue(GetValue()+newStr) + # which drops formatting. Instead, set the text, then the style. + self.descriptionTextCtrl.SetValue(details.description) + self.descriptionTextCtrl.SetStyle( + 0, + self.descriptionTextCtrl.GetLastPosition(), + self.defaultStyle + ) + + self._appendDetailsLabelValue( + # Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog. + pgettext("addonStore", "Publisher:"), + details.publisher + ) + currentStatusKey = self._actionsContextMenu._storeVM._filteredStatusKey + if currentStatusKey not in AddonListField.currentAddonVersionName.hideStatuses: + self._appendDetailsLabelValue( + # Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog. + pgettext("addonStore", "Installed version:"), + details._addonHandlerModel.version + ) + if currentStatusKey not in AddonListField.availableAddonVersionName.hideStatuses: + self._appendDetailsLabelValue( + # Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog. + pgettext("addonStore", "Available version:"), + details.addonVersionName + ) + self._appendDetailsLabelValue( + # Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog. + pgettext("addonStore", "Channel:"), + details.channel + ) + + incompatibleReason = details.getIncompatibleReason() + if incompatibleReason: + self._appendDetailsLabelValue( + # Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog. + pgettext("addonStore", "Incompatible Reason:"), + incompatibleReason + ) + + # Links and license info + if details.homepage is not None: + self._appendDetailsLabelValue( + # Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog. + pgettext("addonStore", "Homepage:"), + details.homepage + ) + + if isinstance(details, AddonStoreModel): + self._appendDetailsLabelValue( + # Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog. + pgettext("addonStore", "License:"), + details.license + ) + if details.licenseURL is not None: + self._appendDetailsLabelValue( + # Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog. + pgettext("addonStore", "License URL:"), + details.licenseURL + ) + + self._appendDetailsLabelValue( + # Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog. + pgettext("addonStore", "Download URL:"), + details.URL + ) + + self._appendDetailsLabelValue( + # Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog. + pgettext("addonStore", "Source URL:"), + details.sourceURL + ) + + self.contentsPanel.Show() + + self.Layout() + # Set caret/insertion point at the beginning so that NVDA users can more easily read from the start. + self.otherDetailsTextCtrl.SetInsertionPoint(0) + + def _addDetailsLabel(self, label: str): + detailsTextCtrl = self.otherDetailsTextCtrl + detailsTextCtrl.SetDefaultStyle(self.labelStyle) + detailsTextCtrl.AppendText(label) + detailsTextCtrl.SetDefaultStyle(self.defaultStyle) + + def _appendDetailsLabelValue(self, label: str, value: str): + detailsTextCtrl = self.otherDetailsTextCtrl + + if detailsTextCtrl.GetValue(): + detailsTextCtrl.AppendText('\n') + + self._addDetailsLabel(label) + detailsTextCtrl.SetDefaultStyle(self.defaultStyle) + detailsTextCtrl.AppendText(self._labelSpace) + detailsTextCtrl.AppendText(value) diff --git a/source/gui/_addonStoreGui/controls/messageDialogs.py b/source/gui/_addonStoreGui/controls/messageDialogs.py new file mode 100644 index 00000000000..d8920544f5e --- /dev/null +++ b/source/gui/_addonStoreGui/controls/messageDialogs.py @@ -0,0 +1,173 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from typing import ( + TYPE_CHECKING, +) + +import wx + +import addonAPIVersion +from _addonStore.models.addon import AddonGUIModel +from gui.addonGui import ErrorAddonInstallDialog +from gui.message import messageBox + +if TYPE_CHECKING: + from _addonStore.models.version import SupportsVersionCheck + from guiHelper import ButtonHelper + + +class ErrorAddonInstallDialogWithCancelButton(ErrorAddonInstallDialog): + def _addButtons(self, buttonHelper: "ButtonHelper") -> None: + super()._addButtons(buttonHelper) + cancelButton = buttonHelper.addButton( + self, + id=wx.ID_CANCEL, + # Translators: A button in the addon installation blocked dialog which will dismiss the dialog. + label=pgettext("addonStore", "Cancel") + ) + cancelButton.SetDefault() + cancelButton.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.CANCEL)) + + +def _shouldProceedWhenInstalledAddonVersionUnknown( + parent: wx.Window, + addon: AddonGUIModel +) -> bool: + # an installed add-on should have an addon Handler Model + assert addon._addonHandlerModel + incompatibleMessage = pgettext( + "addonStore", + # Translators: The message displayed when installing an incompatible add-on package, + # because it requires a new version than is currently installed. + "Warning: add-on installation may result in downgrade: {name}. " + "The installed add-on version cannot be compared with the add-on store version. " + "Installed version: {oldVersion}. " + "Available version: {version}. " + "Proceed with installation anyway? " + ).format( + name=addon.displayName, + version=addon.addonVersionName, + oldVersion=addon._addonHandlerModel.version, + lastTestedNVDAVersion=addonAPIVersion.formatForGUI(addon.lastTestedNVDAVersion), + NVDAVersion=addonAPIVersion.formatForGUI(addonAPIVersion.CURRENT) + ) + return ErrorAddonInstallDialogWithCancelButton( + parent=parent, + # Translators: The title of a dialog presented when an error occurs. + title=pgettext("addonStore", "Add-on not compatible"), + message=incompatibleMessage, + showAddonInfoFunction=lambda: _showAddonInfo(addon) + ).ShowModal() == wx.OK + + +def _shouldProceedToRemoveAddonDialog( + addon: "SupportsVersionCheck" +) -> bool: + return messageBox( + pgettext( + "addonStore", + # Translators: Presented when attempting to remove the selected add-on. + # {addon} is replaced with the add-on name. + "Are you sure you wish to remove the {addon} add-on from NVDA? " + "This cannot be undone." + ).format(addon=addon.name), + # Translators: Title for message asking if the user really wishes to remove the selected Addon. + pgettext("addonStore", "Remove Add-on"), + wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING + ) == wx.YES + + +def _shouldInstallWhenAddonTooOldDialog( + parent: wx.Window, + addon: AddonGUIModel +) -> bool: + incompatibleMessage = pgettext( + "addonStore", + # Translators: The message displayed when installing an incompatible add-on package, + # because it requires a new version than is currently installed. + "Warning: add-on is incompatible: {name} {version}. " + "Check for an updated version of this add-on if possible. " + "The last tested NVDA version for this add-on is {lastTestedNVDAVersion}, " + "your current NVDA version is {NVDAVersion}. " + "Installation may cause unstable behavior in NVDA. " + "Proceed with installation anyway? " + ).format( + name=addon.displayName, + version=addon.addonVersionName, + lastTestedNVDAVersion=addonAPIVersion.formatForGUI(addon.lastTestedNVDAVersion), + NVDAVersion=addonAPIVersion.formatForGUI(addonAPIVersion.CURRENT) + ) + return ErrorAddonInstallDialogWithCancelButton( + parent=parent, + # Translators: The title of a dialog presented when an error occurs. + title=pgettext("addonStore", "Add-on not compatible"), + message=incompatibleMessage, + showAddonInfoFunction=lambda: _showAddonInfo(addon) + ).ShowModal() == wx.OK + + +def _shouldEnableWhenAddonTooOldDialog( + parent: wx.Window, + addon: AddonGUIModel +) -> bool: + incompatibleMessage = pgettext( + "addonStore", + # Translators: The message displayed when enabling an incompatible add-on package, + # because it requires a new version than is currently installed. + "Warning: add-on is incompatible: {name} {version}. " + "Check for an updated version of this add-on if possible. " + "The last tested NVDA version for this add-on is {lastTestedNVDAVersion}, " + "your current NVDA version is {NVDAVersion}. " + "Enabling may cause unstable behavior in NVDA. " + "Proceed with enabling anyway? " + ).format( + name=addon.displayName, + version=addon.addonVersionName, + lastTestedNVDAVersion=addonAPIVersion.formatForGUI(addon.lastTestedNVDAVersion), + NVDAVersion=addonAPIVersion.formatForGUI(addonAPIVersion.CURRENT) + ) + return ErrorAddonInstallDialogWithCancelButton( + parent=parent, + # Translators: The title of a dialog presented when an error occurs. + title=pgettext("addonStore", "Add-on not compatible"), + message=incompatibleMessage, + showAddonInfoFunction=lambda: _showAddonInfo(addon) + ).ShowModal() == wx.OK + + +def _showAddonInfo(addon: AddonGUIModel) -> None: + message = [ + pgettext( + "addonStore", + # Translators: message shown in the Addon Information dialog. + "{summary} ({name})\n" + "Version: {version}\n" + "Publisher: {publisher}\n" + "Description: {description}\n" + ).format( + summary=addon.displayName, + name=addon.addonId, + version=addon.addonVersionName, + publisher=addon.publisher, + description=addon.description, + ) + ] + if addon.homepage: + # Translators: the url part of the About Add-on information + message.append(pgettext("addonStore", "Homepage: {url}").format(url=addon.homepage)) + minimumNVDAVersion = addonAPIVersion.formatForGUI(addon.minimumNVDAVersion) + message.append( + # Translators: the minimum NVDA version part of the About Add-on information + pgettext("addonStore", "Minimum required NVDA version: {}").format(minimumNVDAVersion) + ) + lastTestedNVDAVersion = addonAPIVersion.formatForGUI(addon.lastTestedNVDAVersion) + message.append( + # Translators: the last NVDA version tested part of the About Add-on information + pgettext("addonStore", "Last NVDA version tested: {}").format(lastTestedNVDAVersion) + ) + # Translators: title for the Addon Information dialog + title = pgettext("addonStore", "Add-on Information") + messageBox("\n".join(message), title, wx.OK) diff --git a/source/gui/_addonStoreGui/controls/storeDialog.py b/source/gui/_addonStoreGui/controls/storeDialog.py new file mode 100644 index 00000000000..961f446fe89 --- /dev/null +++ b/source/gui/_addonStoreGui/controls/storeDialog.py @@ -0,0 +1,336 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from typing import ( + cast, +) + +import wx + +from addonHandler import ( + BUNDLE_EXTENSION, +) +from _addonStore.models.channel import Channel, _channelFilters +from _addonStore.models.status import ( + EnabledStatus, + _statusFilters, + _StatusFilterKey, +) +from core import callLater +import gui +from gui import ( + guiHelper, + addonGui, +) +from gui.message import DisplayableError +from gui.settingsDialogs import SettingsDialog +from logHandler import log + +from ..viewModels.store import AddonStoreVM +from .actions import _ActionsContextMenu +from .addonList import AddonVirtualList +from .details import AddonDetails + + +class AddonStoreDialog(SettingsDialog): + # Translators: The title of the addonStore dialog where the user can find and download add-ons + title = pgettext("addonStore", "Add-on Store") + helpId = "addonStore" + + def __init__(self, parent: wx.Window, storeVM: AddonStoreVM): + self._storeVM = storeVM + self._storeVM.onDisplayableError.register(self.handleDisplayableError) + self._actionsContextMenu = _ActionsContextMenu(self._storeVM) + super().__init__(parent, resizeable=True, buttons={wx.CLOSE}) + + def _enterActivatesOk_ctrlSActivatesApply(self, evt: wx.KeyEvent): + """Disables parent behaviour which overrides behaviour for enter and ctrl+s""" + evt.Skip() + + def handleDisplayableError(self, displayableError: DisplayableError): + displayableError.displayError(gui.mainFrame) + + def makeSettings(self, settingsSizer: wx.BoxSizer): + splitViewSizer = wx.BoxSizer(wx.HORIZONTAL) + + self.addonListTabs = wx.Notebook(self) + # Use a single tab page for every tab. + # Instead perform dynamic updates to the tab page when the tab is changed. + dynamicTabPage = wx.Panel(self.addonListTabs) + tabPageHelper = guiHelper.BoxSizerHelper(dynamicTabPage, wx.VERTICAL) + splitViewSizer.Add(tabPageHelper.sizer, flag=wx.EXPAND, proportion=1) + for statusFilter in _statusFilters: + self.addonListTabs.AddPage(dynamicTabPage, statusFilter.displayString) + tabPageHelper.addItem(self.addonListTabs, flag=wx.EXPAND) + if any(self._storeVM._installedAddons[channel] for channel in self._storeVM._installedAddons): + # If there's any installed add-ons, use the installed add-ons page by default + self.addonListTabs.SetSelection(0) + else: + availableTabIndex = list(_statusFilters.keys()).index(_StatusFilterKey.AVAILABLE) + self.addonListTabs.SetSelection(availableTabIndex) + self.addonListTabs.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGED, self.onListTabPageChange, self.addonListTabs) + + self.filterCtrlHelper = guiHelper.BoxSizerHelper(self, wx.VERTICAL) + self._createFilterControls() + tabPageHelper.addItem(self.filterCtrlHelper.sizer, flag=wx.EXPAND) + + tabPageHelper.sizer.AddSpacer(5) + + settingsSizer.Add(splitViewSizer, flag=wx.EXPAND, proportion=1) + + # add a label for the AddonListVM so that it is announced with a name in NVDA + self.listLabel = wx.StaticText(self) + tabPageHelper.addItem( + self.listLabel, + flag=wx.EXPAND + ) + self.listLabel.Hide() + self._setListLabels() + + self.addonListView = AddonVirtualList( + parent=self, + addonsListVM=self._storeVM.listVM, + actionsContextMenu=self._actionsContextMenu, + ) + self.bindHelpEvent("AddonStoreBrowsing", self.addonListView) + # Add alt+l accelerator key + _setFocusToAddonListView_eventId = wx.NewIdRef(count=1) + self.Bind(wx.EVT_MENU, lambda e: self.addonListView.SetFocus(), _setFocusToAddonListView_eventId) + self.SetAcceleratorTable(wx.AcceleratorTable([ + wx.AcceleratorEntry(wx.ACCEL_ALT, ord("l"), _setFocusToAddonListView_eventId) + ])) + tabPageHelper.addItem(self.addonListView, flag=wx.EXPAND, proportion=1) + splitViewSizer.AddSpacer(5) + + self.addonDetailsView = AddonDetails( + parent=self, + detailsVM=self._storeVM.detailsVM, + actionsContextMenu=self._actionsContextMenu, + ) + splitViewSizer.Add(self.addonDetailsView, flag=wx.EXPAND, proportion=1) + self.bindHelpEvent("AddonStoreActions", self.addonDetailsView.actionsButton) + + generalActions = guiHelper.ButtonHelper(wx.HORIZONTAL) + # Translators: The label for a button in add-ons Store dialog to install an external add-on. + externalInstallLabelText = pgettext("addonStore", "Install from e&xternal source") + self.externalInstallButton = generalActions.addButton(self, label=externalInstallLabelText) + self.externalInstallButton.Bind(wx.EVT_BUTTON, self.openExternalInstall, self.externalInstallButton) + self.bindHelpEvent("AddonStoreInstalling", self.externalInstallButton) + + settingsSizer.Add(generalActions.sizer) + self.onListTabPageChange(None) + + def _createFilterControls(self): + filterCtrlsLine0 = guiHelper.BoxSizerHelper(self, wx.HORIZONTAL) + filterCtrlsLine1 = guiHelper.BoxSizerHelper(self, wx.HORIZONTAL) + self.filterCtrlHelper.addItem(filterCtrlsLine0.sizer) + + # Add margin left padding + FILTER_MARGIN_PADDING = 15 + filterCtrlsLine0.sizer.AddSpacer(FILTER_MARGIN_PADDING) + filterCtrlsLine1.sizer.AddSpacer(FILTER_MARGIN_PADDING) + self.filterCtrlHelper.addItem(filterCtrlsLine1.sizer, flag=wx.EXPAND, proportion=1) + + self.channelFilterCtrl = cast(wx.Choice, filterCtrlsLine0.addLabeledControl( + # Translators: The label of a selection field to filter the list of add-ons in the add-on store dialog. + labelText=pgettext("addonStore", "Cha&nnel:"), + wxCtrlClass=wx.Choice, + choices=list(c.displayString for c in _channelFilters), + )) + self.channelFilterCtrl.Bind(wx.EVT_CHOICE, self.onChannelFilterChange, self.channelFilterCtrl) + self.bindHelpEvent("AddonStoreFilterChannel", self.channelFilterCtrl) + + # Translators: The label of a checkbox to filter the list of add-ons in the add-on store dialog. + incompatibleAddonsLabel = _("Include &incompatible add-ons") + self.includeIncompatibleCtrl = cast(wx.CheckBox, filterCtrlsLine0.addItem( + wx.CheckBox(self, label=incompatibleAddonsLabel) + )) + self.includeIncompatibleCtrl.SetValue(0) + self.includeIncompatibleCtrl.Bind( + wx.EVT_CHECKBOX, + self.onIncompatibleFilterChange, + self.includeIncompatibleCtrl + ) + self.bindHelpEvent("AddonStoreFilterIncompatible", self.includeIncompatibleCtrl) + + self.enabledFilterCtrl = cast(wx.Choice, filterCtrlsLine0.addLabeledControl( + # Translators: The label of a selection field to filter the list of add-ons in the add-on store dialog. + labelText=pgettext("addonStore", "Ena&bled/disabled:"), + wxCtrlClass=wx.Choice, + choices=list(c.displayString for c in EnabledStatus), + )) + self.enabledFilterCtrl.Bind(wx.EVT_CHOICE, self.onEnabledFilterChange, self.enabledFilterCtrl) + self.bindHelpEvent("AddonStoreFilterEnabled", self.enabledFilterCtrl) + + # Translators: The label of a text field to filter the list of add-ons in the add-on store dialog. + searchFilterLabel = wx.StaticText(self, label=pgettext("addonStore", "&Search:")) + # noinspection PyAttributeOutsideInit + self.searchFilterCtrl = wx.TextCtrl(self) + self.searchFilterCtrl.Bind(wx.EVT_TEXT, self.onFilterTextChange, self.searchFilterCtrl) + self.bindHelpEvent("AddonStoreFilterSearch", self.searchFilterCtrl) + + filterCtrlsLine1.addItem(searchFilterLabel) + filterCtrlsLine1.addItem(self.searchFilterCtrl, proportion=1) + + # Add end margin right padding + filterCtrlsLine0.sizer.AddSpacer(FILTER_MARGIN_PADDING) + filterCtrlsLine1.sizer.AddSpacer(FILTER_MARGIN_PADDING) + + def postInit(self): + self.addonListView.SetFocus() + + def _onWindowDestroy(self, evt: wx.WindowDestroyEvent): + requiresRestart = self._requiresRestart + super()._onWindowDestroy(evt) + if requiresRestart: + wx.CallAfter(addonGui.promptUserForRestart) + + # Translators: Title for message shown prior to installing add-ons when closing the add-on store dialog. + _installationPromptTitle = pgettext("addonStore", "Add-on installation") + + def onClose(self, evt: wx.CommandEvent): + numInProgress = len(self._storeVM._downloader.progress) + if numInProgress: + res = gui.messageBox( + # Translators: Message shown prior to installing add-ons when closing the add-on store dialog + # The placeholder {} will be replaced with the number of add-ons to be installed + pgettext("addonStore", "Download of {} add-ons in progress, cancel downloading?").format( + numInProgress + ), + self._installationPromptTitle, + style=wx.YES_NO + ) + if res == wx.YES: + log.debug("Cancelling the download.") + self._storeVM.cancelDownloads() + # Continue to installation if any downloads completed + else: + # Let the user return to the add-on store and inspect add-ons being downloaded. + return + + if self._storeVM._pendingInstalls: + installingDialog = gui.IndeterminateProgressDialog( + self, + self._installationPromptTitle, + # Translators: Message shown while installing add-ons after closing the add-on store dialog + # The placeholder {} will be replaced with the number of add-ons to be installed + pgettext("addonStore", "Installing {} add-ons, please wait.").format(len(self._storeVM._pendingInstalls)) + ) + self._storeVM.installPending() + wx.CallAfter(installingDialog.done) + + # let the dialog exit. + super().onClose(evt) + + @property + def _requiresRestart(self) -> bool: + if self._storeVM._pendingInstalls: + return True + + for addonsForChannel in self._storeVM._installedAddons.values(): + for addon in addonsForChannel.values(): + if addon._addonHandlerModel.requiresRestart: + log.debug(f"Add-on {addon.name} modified, restart required") + return True + + return False + + @property + def _statusFilterKey(self) -> _StatusFilterKey: + index = self.addonListTabs.GetSelection() + return list(_statusFilters.keys())[index] + + @property + def _channelFilterKey(self) -> Channel: + index = self.channelFilterCtrl.GetSelection() + return list(_channelFilters.keys())[index] + + @property + def _titleText(self) -> str: + return f"{self.title} - {self._listLabelText}" + + @property + def _listLabelText(self) -> str: + return f"{self._channelFilterKey.displayString} {self._statusFilterKey.displayString}" + + def _setListLabels(self): + self.listLabel.SetLabelText(self._listLabelText) + self.SetTitle(self._titleText) + + def _toggleFilterControls(self): + if self._storeVM._filteredStatusKey in { + _StatusFilterKey.AVAILABLE, + _StatusFilterKey.UPDATE, + }: + self._storeVM._filterChannelKey = Channel.STABLE + self.enabledFilterCtrl.Hide() + self.enabledFilterCtrl.Disable() + self.includeIncompatibleCtrl.Enable() + self.includeIncompatibleCtrl.Show() + else: + self._storeVM._filterChannelKey = Channel.ALL + self.enabledFilterCtrl.Show() + self.enabledFilterCtrl.Enable() + self.includeIncompatibleCtrl.Hide() + self.includeIncompatibleCtrl.Disable() + + def onListTabPageChange(self, evt: wx.EVT_CHOICE): + self._storeVM._filterEnabledDisabled = EnabledStatus.ALL + self.enabledFilterCtrl.SetSelection(0) + + self._storeVM._filteredStatusKey = self._statusFilterKey + self.addonListView._refreshColumns() + self._toggleFilterControls() + + channelFilterIndex = list(_channelFilters.keys()).index(self._storeVM._filterChannelKey) + self.channelFilterCtrl.SetSelection(channelFilterIndex) + self._storeVM.listVM.setSelection(None) + self._setListLabels() + self._storeVM.refresh() + self.Layout() + + def onChannelFilterChange(self, evt: wx.EVT_CHOICE): + self._storeVM._filterChannelKey = self._channelFilterKey + self._storeVM.listVM.setSelection(None) + self._setListLabels() + self._storeVM.refresh() + + def onFilterTextChange(self, evt: wx.EVT_TEXT): + filterText = self.searchFilterCtrl.GetValue() + self.filter(filterText) + + def onEnabledFilterChange(self, evt: wx.EVT_CHOICE): + index = self.enabledFilterCtrl.GetCurrentSelection() + self._storeVM._filterEnabledDisabled = list(EnabledStatus)[index] + self._storeVM.refresh() + + def onIncompatibleFilterChange(self, evt: wx.EVT_CHECKBOX): + self._storeVM._filterIncludeIncompatible = self.includeIncompatibleCtrl.GetValue() + self._storeVM.refresh() + + def filter(self, filterText: str): + self._storeVM.listVM.applyFilter(filterText) + + def openExternalInstall(self, evt: wx.EVT_BUTTON): + # Translators: the label for the NVDA add-on package file type in the Choose add-on dialog. + fileTypeLabel = pgettext("addonStore", "NVDA Add-on Package (*.{ext})") + fd = wx.FileDialog( + self, + # Translators: The message displayed in the dialog that + # allows you to choose an add-on package for installation. + message=pgettext("addonStore", "Choose Add-on Package File"), + wildcard=(fileTypeLabel + "|*.{ext}").format(ext=BUNDLE_EXTENSION), + defaultDir="c:", + style=wx.FD_OPEN, + ) + if fd.ShowModal() != wx.ID_OK: + return + addonPath = fd.GetPath() + try: + addonGui.installAddon(self, addonPath) + except DisplayableError as displayableError: + callLater(delay=0, callable=self._storeVM.onDisplayableError.notify, displayableError=displayableError) + return + self._storeVM.refresh() diff --git a/source/gui/_addonStoreGui/viewModels/__init__.py b/source/gui/_addonStoreGui/viewModels/__init__.py new file mode 100644 index 00000000000..f486e03765b --- /dev/null +++ b/source/gui/_addonStoreGui/viewModels/__init__.py @@ -0,0 +1,4 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. diff --git a/source/gui/_addonStoreGui/viewModels/action.py b/source/gui/_addonStoreGui/viewModels/action.py new file mode 100644 index 00000000000..9ddddc55cde --- /dev/null +++ b/source/gui/_addonStoreGui/viewModels/action.py @@ -0,0 +1,79 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from typing import ( + Callable, + Optional, + TYPE_CHECKING, +) + +import extensionPoints + +if TYPE_CHECKING: + from .addonList import AddonListItemVM + + +class AddonActionVM: + """ Actions/behaviour that can be embedded within other views/viewModels that can apply to a single + L{AddonListItemVM}. + Use the L{AddonActionVM.updated} extensionPoint.Action to be notified about changes. + E.G.: + - Updates within the AddonListItemVM (perhaps changing the action validity) + - Entirely changing the AddonListItemVM action will be applied to, the validity can be checked for the new + item. + """ + def __init__( + self, + displayName: str, + actionHandler: Callable[["AddonListItemVM", ], None], + validCheck: Callable[["AddonListItemVM", ], bool], + listItemVM: Optional["AddonListItemVM"], + ): + """ + @param displayName: Translated string, to be displayed to the user. Should describe the action / behaviour. + @param actionHandler: Call when the action is triggered. + @param validCheck: Is the action valid for the current listItemVM + @param listItemVM: The listItemVM this action will be applied to. L{updated} notifies of modification. + """ + self.displayName: str = displayName + self.actionHandler: Callable[["AddonListItemVM", ], None] = actionHandler + self._validCheck: Callable[["AddonListItemVM", ], bool] = validCheck + self._listItemVM: Optional["AddonListItemVM"] = listItemVM + if listItemVM: + listItemVM.updated.register(self._listItemChanged) + self.updated = extensionPoints.Action() + """Notify of changes to the action""" + + def _listItemChanged(self, addonListItemVM: "AddonListItemVM"): + """Something inside the AddonListItemVM has changed""" + assert self._listItemVM == addonListItemVM + self._notify() + + def _notify(self): + # ensure calling on the main thread. + from core import callLater + callLater(delay=0, callable=self.updated.notify, addonActionVM=self) + + @property + def isValid(self) -> bool: + return ( + self._listItemVM is not None + and self._validCheck(self._listItemVM) + ) + + @property + def listItemVM(self) -> Optional["AddonListItemVM"]: + return self._listItemVM + + @listItemVM.setter + def listItemVM(self, listItemVM: Optional["AddonListItemVM"]): + if self._listItemVM == listItemVM: + return + if self._listItemVM: + self._listItemVM.updated.unregister(self._listItemChanged) + if listItemVM: + listItemVM.updated.register(self._listItemChanged) + self._listItemVM = listItemVM + self._notify() diff --git a/source/gui/_addonStoreGui/viewModels/addonList.py b/source/gui/_addonStoreGui/viewModels/addonList.py new file mode 100644 index 00000000000..dd0cfa17c71 --- /dev/null +++ b/source/gui/_addonStoreGui/viewModels/addonList.py @@ -0,0 +1,367 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +# Needed for type hinting CaseInsensitiveDict +# Can be removed in a future version of python (3.8+) +from __future__ import annotations +from dataclasses import dataclass +from enum import Enum + +from locale import strxfrm +from typing import ( + FrozenSet, + List, + Optional, + TYPE_CHECKING, +) + +from requests.structures import CaseInsensitiveDict + +from _addonStore.models.addon import ( + AddonGUIModel, +) +from _addonStore.models.status import ( + _StatusFilterKey, + AvailableAddonStatus, +) +import core +import extensionPoints +from logHandler import log + + +if TYPE_CHECKING: + # Remove when https://github.com/python/typing/issues/760 is resolved + from _typeshed import SupportsLessThan # noqa: F401 + from .store import AddonStoreVM + + +@dataclass +class _AddonListFieldData: + displayString: str + width: int + hideStatuses: FrozenSet[_StatusFilterKey] = frozenset() + """Hide this field if the current tab filter is in hideStatuses.""" + + +class AddonListField(_AddonListFieldData, Enum): + """An ordered enum of fields to use as columns in the add-on list.""" + + displayName = ( + # Translators: The name of the column that contains names of addons. + pgettext("addonStore", "Name"), + 150, + ) + currentAddonVersionName = ( + # Translators: The name of the column that contains the installed addons version string. + pgettext("addonStore", "Installed version"), + 100, + frozenset({_StatusFilterKey.AVAILABLE}), + ) + availableAddonVersionName = ( + # Translators: The name of the column that contains the available addons version string. + pgettext("addonStore", "Available version"), + 100, + frozenset({_StatusFilterKey.INCOMPATIBLE, _StatusFilterKey.INSTALLED}), + ) + channel = ( + # Translators: The name of the column that contains the channel of the addon (e.g stable, beta, dev). + pgettext("addonStore", "Channel"), + 50, + ) + publisher = ( + # Translators: The name of the column that contains the addons publisher. + pgettext("addonStore", "Publisher"), + 100 + ) + status = ( + # Translators: The name of the column that contains the status of the addon. + # e.g. available, downloading installing + pgettext("addonStore", "Status"), + 150 + ) + + +class AddonListItemVM: + def __init__( + self, + model: AddonGUIModel, + status: AvailableAddonStatus = AvailableAddonStatus.AVAILABLE + ): + self._model: AddonGUIModel = model # read-only + self._status: AvailableAddonStatus = status # modifications triggers L{updated.notify} + self.updated = extensionPoints.Action() # Notify of changes to VM, argument: addonListItemVM + + @property + def model(self) -> AddonGUIModel: + return self._model + + @property + def status(self) -> AvailableAddonStatus: + return self._status + + @status.setter + def status(self, newStatus: AvailableAddonStatus): + if newStatus != self.status: + log.debug(f"addon status change: {self.Id}: status: {newStatus}") + self._status = newStatus + # ensure calling on the main thread. + core.callLater(delay=0, callable=self.updated.notify, addonListItemVM=self) + + @property + def Id(self) -> str: + return self._model.listItemVMId + + def __repr__(self) -> str: + return f"{self.__class__.__name__}: {self.Id}, {self.status}" + + +class AddonDetailsVM: + def __init__(self, listItem: Optional[AddonListItemVM] = None): + self._listItem: Optional[AddonListItemVM] = listItem + self._isLoading: bool = False + self.updated = extensionPoints.Action() # triggered by setting L{self._listItem} + + @property + def listItem(self) -> Optional[AddonListItemVM]: + return self._listItem + + @listItem.setter + def listItem(self, newListItem: Optional[AddonListItemVM]): + if ( + self._listItem == newListItem # both may be same ref or None + or ( + None not in (newListItem, self._listItem) + and self._listItem.Id == newListItem.Id # confirm with addonId + ) + ): + # already set, exit early + return + self._listItem = newListItem + # ensure calling on the main thread. + core.callLater(delay=0, callable=self.updated.notify, addonDetailsVM=self) + + +class AddonListVM: + def __init__( + self, + addons: List[AddonListItemVM], + storeVM: "AddonStoreVM", + ): + self._addons: CaseInsensitiveDict[AddonListItemVM] = CaseInsensitiveDict() + self._storeVM = storeVM + self.itemUpdated = extensionPoints.Action() + self.updated = extensionPoints.Action() + self.selectionChanged = extensionPoints.Action() + self.selectedAddonId: Optional[str] = None + self.lastSelectedAddonId = self.selectedAddonId + self._sortByModelField: AddonListField = AddonListField.displayName + self._filterString: Optional[str] = None + + self._setSelectionPending = False + self._addonsFilteredOrdered: List[str] = self._getFilteredSortedIds() + self._validate( + sortField=self._sortByModelField, + selectionIndex=self.getSelectedIndex(), + selectionId=self.selectedAddonId + ) + self.selectedAddonId = self._tryPersistSelection(self._addonsFilteredOrdered) + self.resetListItems(addons) + + @property + def presentedFields(self) -> List[AddonListField]: + return [c for c in AddonListField if self._storeVM._filteredStatusKey not in c.hideStatuses] + + def _itemDataUpdated(self, addonListItemVM: AddonListItemVM): + addonId: str = addonListItemVM.Id + log.debug(f"Item updated: {addonListItemVM!r}") + assert addonListItemVM == self._addons[addonId], "Must be the same instance." + if addonId in self._addonsFilteredOrdered: + log.debug("Notifying of update") + index = self._addonsFilteredOrdered.index(addonId) + # ensure calling on the main thread. + core.callLater(delay=0, callable=self.itemUpdated.notify, index=index) + + def resetListItems(self, listVMs: List[AddonListItemVM]): + log.debug("resetting list items") + + # Ensure that old listItemVMs can no longer notify of updates. + for _addonListItemVM in self._addons.values(): + _addonListItemVM.updated.unregister(self._itemDataUpdated) + + # set new ID:listItemVM mapping. + self._addons = CaseInsensitiveDict({ + vm.Id: vm + for vm in listVMs + }) + self._updateAddonListing() + + # allow new listItemVMs to notify of updates. + for _addonListItemVM in listVMs: + _addonListItemVM.updated.register(self._itemDataUpdated) + + # Notify observers of change in the list. + # ensure calling on the main thread. + core.callLater(delay=0, callable=self.updated.notify) + + def getAddonFieldText(self, index: int, field: AddonListField) -> Optional[str]: + """ Get the text for an item's attribute. + @param index: The index of the item in _addonsFilteredOrdered + @param field: The field attribute for the addon. See L{AddonList.presentedFields} + @return: The text for the addon attribute + """ + try: + addonId = self._addonsFilteredOrdered[index] + except IndexError: + # Failed to get addonId, index may have been lost in refresh. + return None + try: + listItemVM = self._addons[addonId] + except KeyError: + # Failed to get addon, may have been lost in refresh. + return None + return self._getAddonFieldText(listItemVM, field) + + def _getAddonFieldText(self, listItemVM: AddonListItemVM, field: AddonListField) -> str: + assert field in AddonListField + if field is AddonListField.currentAddonVersionName: + return listItemVM.model._addonHandlerModel.version + if field is AddonListField.availableAddonVersionName: + return listItemVM.model.addonVersionName + if field is AddonListField.status: # special handling, not on the model. + return listItemVM.status.displayString + if field is AddonListField.channel: + return listItemVM.model.channel.displayString + return getattr(listItemVM.model, field.name) + + def getCount(self) -> int: + return len(self._addonsFilteredOrdered) + + def getSelectedIndex(self) -> Optional[int]: + if self._addonsFilteredOrdered and self.selectedAddonId in self._addonsFilteredOrdered: + return self._addonsFilteredOrdered.index(self.selectedAddonId) + return None + + def setSelection(self, index: Optional[int]) -> Optional[AddonListItemVM]: + self._validate(selectionIndex=index) + self.selectedAddonId = None + if index is not None: + try: + self.selectedAddonId = self._addonsFilteredOrdered[index] + except IndexError: + # Failed to get addonId, index may have been lost in refresh. + pass + selectedItemVM: Optional[AddonListItemVM] = self.getSelection() + log.debug(f"selected Item: {selectedItemVM}") + # ensure calling on the main thread. + core.callLater(delay=0, callable=self.selectionChanged.notify) + return selectedItemVM + + def getSelection(self) -> Optional[AddonListItemVM]: + if self.selectedAddonId is None: + return None + return self._addons.get(self.selectedAddonId) + + def _validate( + self, + sortField: Optional[AddonListField] = None, + selectionIndex: Optional[int] = None, + selectionId: Optional[str] = None, + ): + if sortField is not None: + assert sortField in AddonListField + if selectionIndex is not None: + assert 0 <= selectionIndex and selectionIndex < len(self._addonsFilteredOrdered) + if selectionId is not None: + assert selectionId in self._addons.keys() + + def setSortField(self, modelField: AddonListField): + oldOrder = self._addonsFilteredOrdered + self._validate(sortField=modelField) + self._sortByModelField = modelField + self._updateAddonListing() + if oldOrder != self._addonsFilteredOrdered: + # ensure calling on the main thread. + core.callLater(delay=0, callable=self.updated.notify) + + def _getFilteredSortedIds(self) -> List[str]: + def _getSortFieldData(listItemVM: AddonListItemVM) -> "SupportsLessThan": + return strxfrm(self._getAddonFieldText(listItemVM, self._sortByModelField)) + + def _containsTerm(detailsVM: AddonListItemVM, term: str) -> bool: + term = term.casefold() + model = detailsVM.model + return ( + term in model.displayName.casefold() + or term in model.description.casefold() + or term in model.publisher.casefold() + ) + + filtered = ( + vm for vm in self._addons.values() + if self._filterString is None or _containsTerm(vm, self._filterString) + ) + filteredSorted = list([ + vm.Id for vm in sorted(filtered, key=_getSortFieldData) + ]) + return filteredSorted + + def _tryPersistSelection( + self, + newOrder: List[str], + ) -> Optional[str]: + """Get the ID of the selection in new order, _addonsFilteredOrdered should not have changed yet. + """ + selectedIndex = self.getSelectedIndex() + selectedId = self.selectedAddonId + if selectedId in newOrder: + # nothing else to do, selection doesn't have to change. + log.debug(f"Selected Id in new order {selectedId}") + return selectedId + elif not newOrder: + log.debug(f"No entries in new order") + # no entries after filter, select None + return None + elif selectedIndex is not None: + # select the addon at the closest index + oldMaxIndex: int = len(self._addonsFilteredOrdered) - 1 + oldIndexNorm: float = selectedIndex / max(oldMaxIndex, 1) # min-max scaling / normalization + newMaxIndex: int = len(newOrder) - 1 + approxNewIndex = int(oldIndexNorm * newMaxIndex) + newSelectedIndex = max(0, min(approxNewIndex, newMaxIndex)) + log.debug( + "Approximate from position " + f"oldSelectedIndex: {selectedIndex}, " + f"oldMaxIndex: {oldMaxIndex}, " + f"newSelectedIndex: {newSelectedIndex}, " + f"newMaxIndex: {newMaxIndex}" + ) + return newOrder[newSelectedIndex] + elif self.lastSelectedAddonId in newOrder: + log.debug(f"lastSelected in new order: {self.lastSelectedAddonId}") + return self.lastSelectedAddonId + elif newOrder: + # if there is any addon select it. + return newOrder[0] + else: + log.debug(f"No selection") + # no selection. + return None + + def _updateAddonListing(self): + newOrder = self._getFilteredSortedIds() + self.selectedAddonId = self._tryPersistSelection(newOrder) + if self.selectedAddonId: + self.lastSelectedAddonId = self.selectedAddonId + self._addonsFilteredOrdered = newOrder + + def applyFilter(self, filterText: str) -> None: + oldOrder = self._addonsFilteredOrdered + if not filterText: + filterText = None + self._filterString = filterText + self._updateAddonListing() + if oldOrder != self._addonsFilteredOrdered: + # ensure calling on the main thread. + core.callLater(delay=0, callable=self.updated.notify) diff --git a/source/gui/_addonStoreGui/viewModels/store.py b/source/gui/_addonStoreGui/viewModels/store.py new file mode 100644 index 00000000000..9fa77acc290 --- /dev/null +++ b/source/gui/_addonStoreGui/viewModels/store.py @@ -0,0 +1,444 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2022-2023 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +# Needed for type hinting CaseInsensitiveDict +# Can be removed in a future version of python (3.8+) +from __future__ import annotations + +from os import ( + PathLike, + startfile, +) +from typing import ( + List, + Optional, + Tuple, + cast, +) +import threading + +import addonHandler +from _addonStore.dataManager import addonDataManager +from _addonStore.install import installAddon +from _addonStore.models.addon import ( + AddonStoreModel, + _createAddonGUICollection, + _AddonGUIModel, +) +from _addonStore.models.channel import ( + Channel, + _channelFilters, +) +from _addonStore.models.status import ( + EnabledStatus, + getStatus, + _statusFilters, + _StatusFilterKey, + AvailableAddonStatus, +) +import core +import extensionPoints +from gui.message import DisplayableError +from logHandler import log + +from ..controls.messageDialogs import ( + _shouldEnableWhenAddonTooOldDialog, + _shouldProceedToRemoveAddonDialog, + _shouldInstallWhenAddonTooOldDialog, + _shouldProceedWhenInstalledAddonVersionUnknown, +) + +from .action import AddonActionVM +from .addonList import ( + AddonDetailsVM, + AddonListItemVM, + AddonListVM, +) + + +class AddonStoreVM: + def __init__(self): + self._installedAddons = addonDataManager._installedAddonsCache.installedAddonGUICollection + self._availableAddons = _createAddonGUICollection() + self.hasError = extensionPoints.Action() + self.onDisplayableError = DisplayableError.OnDisplayableErrorT() + """ + An extension point used to notify the add-on store VM when an error + occurs that can be displayed to the user. + + This allows the add-on store GUI to handle displaying an error. + + @param displayableError: Error that can be displayed to the user. + @type displayableError: gui.message.DisplayableError + """ + self._filteredStatusKey: _StatusFilterKey = _StatusFilterKey.INSTALLED + """ + Filters the add-on list view model by add-on status. + Add-ons with a status in _statusFilters[self._filteredStatusKey] should be displayed in the list. + """ + self._filterChannelKey: Channel = Channel.ALL + """ + Filters the add-on list view model by add-on channel. + Add-ons with a channel in _channelFilters[self._filterChannelKey] should be displayed in the list. + """ + self._filterEnabledDisabled: EnabledStatus = EnabledStatus.ALL + """ + Filters the add-on list view model by enabled or disabled. + """ + self._filterIncludeIncompatible: bool = False + + self._downloader = addonDataManager.getFileDownloader() + self._pendingInstalls: List[Tuple[AddonListItemVM, PathLike]] = [] + + self.listVM: AddonListVM = AddonListVM( + addons=self._createListItemVMs(), + storeVM=self, + ) + self.detailsVM: AddonDetailsVM = AddonDetailsVM( + listItem=self.listVM.getSelection() + ) + self.actionVMList = self._makeActionsList() + self.listVM.selectionChanged.register(self._onSelectedItemChanged) + + def _onSelectedItemChanged(self): + selectedVM = self.listVM.getSelection() + log.debug(f"Setting selection: {selectedVM}") + self.detailsVM.listItem = selectedVM + for action in self.actionVMList: + action.listItemVM = selectedVM + + def _makeActionsList(self): + selectedListItem: Optional[AddonListItemVM] = self.listVM.getSelection() + return [ + AddonActionVM( + # Translators: Label for an action that installs the selected addon + displayName=pgettext("addonStore", "&Install"), + actionHandler=self.getAddon, + validCheck=lambda aVM: aVM.status == AvailableAddonStatus.AVAILABLE, + listItemVM=selectedListItem + ), + AddonActionVM( + # Translators: Label for an action that installs the selected addon + displayName=pgettext("addonStore", "&Install (override incompatibility)"), + actionHandler=self.installOverrideIncompatibilityForAddon, + validCheck=lambda aVM: ( + aVM.status == AvailableAddonStatus.INCOMPATIBLE + and aVM.model.canOverrideCompatibility + ), + listItemVM=selectedListItem + ), + AddonActionVM( + # Translators: Label for an action that updates the selected addon + displayName=pgettext("addonStore", "&Update"), + actionHandler=self.getAddon, + validCheck=lambda aVM: aVM.status == AvailableAddonStatus.UPDATE, + listItemVM=selectedListItem + ), + AddonActionVM( + # Translators: Label for an action that replaces the selected addon with + # an add-on store version. + displayName=pgettext("addonStore", "Re&place"), + actionHandler=self.replaceAddon, + validCheck=lambda aVM: aVM.status == AvailableAddonStatus.REPLACE_SIDE_LOAD, + listItemVM=selectedListItem + ), + AddonActionVM( + # Translators: Label for an action that disables the selected addon + displayName=pgettext("addonStore", "&Disable"), + actionHandler=self.disableAddon, + validCheck=lambda aVM: aVM.model.isInstalled and aVM.status not in ( + AvailableAddonStatus.DISABLED, + AvailableAddonStatus.PENDING_DISABLE, + AvailableAddonStatus.INCOMPATIBLE_DISABLED, + AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED, + AvailableAddonStatus.PENDING_REMOVE, + ), + listItemVM=selectedListItem + ), + AddonActionVM( + # Translators: Label for an action that enables the selected addon + displayName=pgettext("addonStore", "&Enable"), + actionHandler=self.enableAddon, + validCheck=lambda aVM: ( + aVM.status == AvailableAddonStatus.DISABLED + or aVM.status == AvailableAddonStatus.PENDING_DISABLE + ), + listItemVM=selectedListItem + ), + AddonActionVM( + # Translators: Label for an action that enables the selected addon + displayName=pgettext("addonStore", "&Enable (override incompatibility)"), + actionHandler=self.enableOverrideIncompatibilityForAddon, + validCheck=lambda aVM: ( + aVM.status in ( + AvailableAddonStatus.INCOMPATIBLE_DISABLED, + AvailableAddonStatus.PENDING_INCOMPATIBLE_DISABLED, + ) + and aVM.model.canOverrideCompatibility + ), + listItemVM=selectedListItem + ), + AddonActionVM( + # Translators: Label for an action that removes the selected addon + displayName=pgettext("addonStore", "&Remove"), + actionHandler=self.removeAddon, + validCheck=lambda aVM: ( + aVM.model.isInstalled + and aVM.status != AvailableAddonStatus.PENDING_REMOVE + and self._filteredStatusKey in ( + # Removing add-ons in the updatable view fails, + # as the updated version cannot be removed. + _StatusFilterKey.INSTALLED, + _StatusFilterKey.INCOMPATIBLE, + ) + ), + listItemVM=selectedListItem + ), + AddonActionVM( + # Translators: Label for an action that opens help for the selected addon + displayName=pgettext("addonStore", "&Help"), + actionHandler=self.helpAddon, + validCheck=lambda aVM: aVM.model.isInstalled and self._filteredStatusKey in ( + # Showing help in the updatable add-ons view is misleading + # as we can only fetch the add-on help from the installed version. + _StatusFilterKey.INSTALLED, + _StatusFilterKey.INCOMPATIBLE, + ), + listItemVM=selectedListItem + ), + AddonActionVM( + # Translators: Label for an action that opens the homepage for the selected addon + displayName=pgettext("addonStore", "Ho&mepage"), + actionHandler=lambda aVM: startfile(aVM.model.homepage), + validCheck=lambda aVM: aVM.model.homepage is not None, + listItemVM=selectedListItem + ), + AddonActionVM( + # Translators: Label for an action that opens the license for the selected addon + displayName=pgettext("addonStore", "&License"), + actionHandler=lambda aVM: startfile(cast(AddonStoreModel, aVM.model).licenseURL), + validCheck=lambda aVM: ( + isinstance(aVM.model, AddonStoreModel) + and aVM.model.licenseURL is not None + ), + listItemVM=selectedListItem + ), + AddonActionVM( + # Translators: Label for an action that opens the source code for the selected addon + displayName=pgettext("addonStore", "Source &Code"), + actionHandler=lambda aVM: startfile(cast(AddonStoreModel, aVM.model).sourceURL), + validCheck=lambda aVM: isinstance(aVM.model, AddonStoreModel), + listItemVM=selectedListItem + ), + ] + + def helpAddon(self, listItemVM: AddonListItemVM) -> None: + path = listItemVM.model._addonHandlerModel.getDocFilePath() + startfile(path) + + def removeAddon(self, listItemVM: AddonListItemVM) -> None: + if _shouldProceedToRemoveAddonDialog(listItemVM.model): + listItemVM.model._addonHandlerModel.requestRemove() + self.refresh() + listItemVM.status = getStatus(listItemVM.model) + + def installOverrideIncompatibilityForAddon(self, listItemVM: AddonListItemVM) -> None: + from gui import mainFrame + if _shouldInstallWhenAddonTooOldDialog(mainFrame, listItemVM.model): + listItemVM.model.enableCompatibilityOverride() + self.getAddon(listItemVM) + self.refresh() + + _enableErrorMessage: str = pgettext( + "addonStore", + # Translators: The message displayed when the add-on cannot be enabled. + # {addon} is replaced with the add-on name. + "Could not enable the add-on: {addon}." + ) + + _disableErrorMessage: str = pgettext( + "addonStore", + # Translators: The message displayed when the add-on cannot be disabled. + # {addon} is replaced with the add-on name. + "Could not disable the add-on: {addon}." + ) + + def _handleEnableDisable(self, listItemVM: AddonListItemVM, shouldEnable: bool) -> None: + try: + listItemVM.model._addonHandlerModel.enable(shouldEnable) + except addonHandler.AddonError: + log.debug(exc_info=True) + if shouldEnable: + errorMessage = self._enableErrorMessage + else: + errorMessage = self._disableErrorMessage + displayableError = DisplayableError( + displayMessage=errorMessage.format(addon=listItemVM.model.displayName) + ) + # ensure calling on the main thread. + core.callLater(delay=0, callable=self.onDisplayableError.notify, displayableError=displayableError) + + listItemVM.status = getStatus(listItemVM.model) + self.refresh() + + def enableOverrideIncompatibilityForAddon(self, listItemVM: AddonListItemVM) -> None: + from ... import mainFrame + if _shouldEnableWhenAddonTooOldDialog(mainFrame, listItemVM.model): + listItemVM.model.enableCompatibilityOverride() + self._handleEnableDisable(listItemVM, True) + + def enableAddon(self, listItemVM: AddonListItemVM) -> None: + self._handleEnableDisable(listItemVM, True) + + def disableAddon(self, listItemVM: AddonListItemVM) -> None: + self._handleEnableDisable(listItemVM, False) + + def replaceAddon(self, listItemVM: AddonListItemVM) -> None: + from ... import mainFrame + if _shouldProceedWhenInstalledAddonVersionUnknown(mainFrame, listItemVM.model): + self.getAddon(listItemVM) + + def getAddon(self, listItemVM: AddonListItemVM) -> None: + listItemVM.status = AvailableAddonStatus.DOWNLOADING + log.debug(f"{listItemVM.Id} status: {listItemVM.status}") + self._downloader.download(listItemVM.model, self._downloadComplete, self.onDisplayableError) + + def _downloadComplete(self, addonDetails: AddonStoreModel, fileDownloaded: Optional[PathLike]): + listItemVM: Optional[AddonListItemVM] = self.listVM._addons[addonDetails.listItemVMId] + if listItemVM is None: + log.error(f"No list item VM for addon with id: {addonDetails.addonId}") + return + + if fileDownloaded is None: + # Download may have been cancelled or otherwise failed + listItemVM.status = AvailableAddonStatus.DOWNLOAD_FAILED + log.debugWarning(f"Error during download of {listItemVM.Id}", exc_info=True) + return + + listItemVM.status = AvailableAddonStatus.DOWNLOAD_SUCCESS + log.debug(f"Queuing add-on for install on dialog exit: {listItemVM.Id}") + # Add-ons can have "installTasks", which often call the GUI assuming they are on the main thread. + self._pendingInstalls.append((listItemVM, fileDownloaded)) + + def installPending(self): + if not core.isMainThread(): + # Add-ons can have "installTasks", which often call the GUI assuming they are on the main thread. + log.error("installation must happen on main thread.") + for listItemVM, fileDownloaded in self._pendingInstalls: + self._doInstall(listItemVM, fileDownloaded) + + def _doInstall(self, listItemVM: AddonListItemVM, fileDownloaded: PathLike): + if not core.isMainThread(): + # Add-ons can have "installTasks", which often call the GUI assuming they are on the main thread. + log.error("installation must happen on main thread.") + return + listItemVM.status = AvailableAddonStatus.INSTALLING + log.debug(f"{listItemVM.Id} status: {listItemVM.status}") + try: + installAddon(fileDownloaded) + except DisplayableError as displayableError: + listItemVM.status = AvailableAddonStatus.INSTALL_FAILED + log.debug(f"{listItemVM.Id} status: {listItemVM.status}") + core.callLater(delay=0, callable=self.onDisplayableError.notify, displayableError=displayableError) + return + listItemVM.status = AvailableAddonStatus.INSTALLED + addonDataManager._cacheInstalledAddon(listItemVM.model) + log.debug(f"{listItemVM.Id} status: {listItemVM.status}") + + def refresh(self): + if self._filteredStatusKey in { + _StatusFilterKey.AVAILABLE, + _StatusFilterKey.UPDATE, + }: + threading.Thread(target=self._getAvailableAddonsInBG, name="getAddonData").start() + + elif self._filteredStatusKey in { + _StatusFilterKey.INSTALLED, + _StatusFilterKey.INCOMPATIBLE, + }: + self._installedAddons = addonDataManager._installedAddonsCache.installedAddonGUICollection + self.listVM.resetListItems(self._createListItemVMs()) + self.detailsVM.listItem = self.listVM.getSelection() + else: + raise NotImplementedError(f"Unhandled status filter key {self._filteredStatusKey}") + + def _getAvailableAddonsInBG(self): + self.detailsVM._isLoading = True + self.listVM.resetListItems([]) + log.debug("getting available addons in the background") + assert addonDataManager + availableAddons = addonDataManager.getLatestCompatibleAddons(self.onDisplayableError) + if self._filterIncludeIncompatible: + incompatibleAddons = addonDataManager.getLatestAddons(self.onDisplayableError) + for channel in incompatibleAddons: + for addonId in incompatibleAddons[channel]: + # only include incompatible add-ons if: + # - no compatible or installed versions are available + # - the user can override the compatibility of the add-on + # (it's too old and not too new) + if ( + addonId not in availableAddons[channel] + and addonId not in self._installedAddons[channel] + and incompatibleAddons[channel][addonId].canOverrideCompatibility + ): + availableAddons[channel][addonId] = incompatibleAddons[channel][addonId] + log.debug("completed getting addons in the background") + self._availableAddons = availableAddons + self.listVM.resetListItems(self._createListItemVMs()) + self.detailsVM.listItem = self.listVM.getSelection() + self.detailsVM._isLoading = False + log.debug("completed refresh") + + def cancelDownloads(self): + for a in self._downloader.progress.keys(): + self.listVM._addons[a.listItemVMId].status = AvailableAddonStatus.AVAILABLE + self._downloader.cancelAll() + + def _filterByEnabledKey(self, model: _AddonGUIModel) -> bool: + if EnabledStatus.ALL == self._filterEnabledDisabled: + return True + + elif EnabledStatus.ENABLED == self._filterEnabledDisabled: + return model.isPendingEnable or ( + not model.isDisabled + and not model.isPendingDisable + ) + + elif EnabledStatus.DISABLED == self._filterEnabledDisabled: + return model.isDisabled or model.isPendingDisable + + raise NotImplementedError(f"Invalid EnabledStatus: {self._filterEnabledDisabled}") + + def _createListItemVMs(self) -> List[AddonListItemVM]: + if self._filteredStatusKey in { + _StatusFilterKey.AVAILABLE, + _StatusFilterKey.UPDATE, + }: + addons = self._availableAddons + + elif self._filteredStatusKey in { + _StatusFilterKey.INSTALLED, + _StatusFilterKey.INCOMPATIBLE, + }: + addons = self._installedAddons + else: + raise NotImplementedError(f"Unhandled status filter key {self._filteredStatusKey}") + + addonsWithStatus = ( + (model, getStatus(model)) + for channel in addons + for model in addons[channel].values() + ) + + return [ + AddonListItemVM(model=model, status=status) + for model, status in addonsWithStatus + if status in _statusFilters[self._filteredStatusKey] + and model.channel in _channelFilters[self._filterChannelKey] + # Legacy add-ons contain invalid metadata + # and should not be accessible through the add-on store. + and not model.legacy + and self._filterByEnabledKey(model) + ] diff --git a/source/gui/addonGui.py b/source/gui/addonGui.py index 96f2e364f43..bf6f3d7fede 100644 --- a/source/gui/addonGui.py +++ b/source/gui/addonGui.py @@ -1,10 +1,14 @@ # A part of NonVisual Desktop Access (NVDA) # This file is covered by the GNU General Public License. # See the file COPYING for more details. -# Copyright (C) 2012-2019 NV Access Limited, Beqa Gozalishvili, Joseph Lee, +# Copyright (C) 2012-2023 NV Access Limited, Beqa Gozalishvili, Joseph Lee, # Babbage B.V., Ethan Holliger, Arnold Loubriat, Thomas Stivers import os +from typing import ( + List, + Optional, +) import weakref from locale import strxfrm @@ -13,14 +17,13 @@ import core import config import gui -from addonHandler import addonVersionCheck +from addonHandler import Addon from logHandler import log import addonHandler import globalVars -import buildVersion from . import guiHelper from . import nvdaControls -from .dpiScalingHelper import DpiScalingHelperMixin, DpiScalingHelperMixinWithoutInit +from .dpiScalingHelper import DpiScalingHelperMixinWithoutInit import gui.contextHelp @@ -110,34 +113,6 @@ def _addButtons(self, buttonHelper): okButton.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.OK)) -def _showAddonInfo(addon): - manifest = addon.manifest - message=[_( - # Translators: message shown in the Addon Information dialog. - "{summary} ({name})\n" - "Version: {version}\n" - "Author: {author}\n" - "Description: {description}\n" - ).format(**manifest)] - url=manifest.get('url') - if url: - # Translators: the url part of the About Add-on information - message.append(_("URL: {url}").format(url=url)) - minimumNVDAVersion = addonAPIVersion.formatForGUI(addon.minimumNVDAVersion) - message.append( - # Translators: the minimum NVDA version part of the About Add-on information - _("Minimum required NVDA version: {}").format(minimumNVDAVersion) - ) - lastTestedNVDAVersion = addonAPIVersion.formatForGUI(addon.lastTestedNVDAVersion) - message.append( - # Translators: the last NVDA version tested part of the About Add-on information - _("Last NVDA version tested: {}").format(lastTestedNVDAVersion) - ) - # Translators: title for the Addon Information dialog - title=_("Add-on Information") - gui.messageBox("\n".join(message), title, wx.OK) - - class AddonsDialog( DpiScalingHelperMixinWithoutInit, gui.contextHelp.ContextHelpMixin, @@ -288,7 +263,7 @@ def __init__(self, parent): self.CentreOnScreen() self.addonsList.SetFocus() - def onAddClick(self, evt): + def onAddClick(self, evt: wx.EVT_BUTTON): # Translators: The message displayed in the dialog that allows you to choose an add-on package for installation. fd = wx.FileDialog(self, message=_("Choose Add-on Package File"), # Translators: the label for the NVDA add-on package file type in the Choose add-on dialog. @@ -302,22 +277,13 @@ def onAddClick(self, evt): else: self.refreshAddonsList() - def onRemoveClick(self,evt): - index = self.addonsList.GetFirstSelected() + def onRemoveClick(self, evt: wx.EVT_BUTTON): + index: int = self.addonsList.GetFirstSelected() if index < 0: return addon = self.curAddons[index] - if gui.messageBox( - (_( - # Translators: Presented when attempting to remove the selected add-on. - # {addon} is replaced with the add-on name. - "Are you sure you wish to remove the {addon} add-on from NVDA? " - "This cannot be undone." - )).format(addon=addon.name), - # Translators: Title for message asking if the user really wishes to remove the selected Addon. - _("Remove Add-on"), - wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING - ) != wx.YES: + from gui._addonStoreGui.controls.messageDialogs import _shouldProceedToRemoveAddonDialog + if not _shouldProceedToRemoveAddonDialog(addon): return addon.requestRemove() self.refreshAddonsList(activeIndex=index) @@ -366,11 +332,14 @@ def getAddonStatus(self, addon): statusList.append(_("Enabled after restart")) return ", ".join(statusList) - def refreshAddonsList(self,activeIndex=0): + def refreshAddonsList(self, activeIndex: int = 0) -> None: self.addonsList.DeleteAllItems() - self.curAddons=[] + self.curAddons: List[Addon] = [] anyAddonIncompatible = False - for addon in sorted(addonHandler.getAvailableAddons(), key=lambda a: strxfrm(a.manifest['summary'])): + from _addonStore.dataManager import addonDataManager + assert addonDataManager + installedAddons = addonDataManager._installedAddonsCache.installedAddons + for addon in sorted(installedAddons.values(), key=lambda a: strxfrm(a.manifest['summary'])): self.addonsList.Append(( addon.manifest['summary'], self.getAddonStatus(addon), @@ -380,11 +349,7 @@ def refreshAddonsList(self,activeIndex=0): self.curAddons.append(addon) anyAddonIncompatible = ( anyAddonIncompatible # once we find one incompatible addon we don't need to continue - or not addonVersionCheck.isAddonCompatible( - addon, - currentAPIVersion=addonAPIVersion.CURRENT, - backwardsCompatToVersion=addonAPIVersion.BACK_COMPAT_TO - ) + or not addon.isCompatible ) self.incompatAddonsButton.Enable(anyAddonIncompatible) # select the given active addon or the first addon if not given @@ -404,9 +369,9 @@ def refreshAddonsList(self,activeIndex=0): def _shouldDisable(self, addon): return not (addon.isPendingDisable or (addon.isDisabled and not addon.isPendingEnable)) - def onListItemSelected(self, evt): - index=evt.GetIndex() - addon=self.curAddons[index] if index>=0 else None + def onListItemSelected(self, evt: wx.ListEvent) -> None: + index: int = evt.GetIndex() + addon: Optional[Addon] = self.curAddons[index] if index >= 0 else None # #3090: Change toggle button label to indicate action to be taken if clicked. if addon is not None: # Translators: The label for a button in Add-ons Manager dialog to enable or disable the selected add-on. @@ -414,31 +379,33 @@ def onListItemSelected(self, evt): self.aboutButton.Enable(addon is not None and not addon.isPendingRemove) self.helpButton.Enable(bool(addon is not None and not addon.isPendingRemove and addon.getDocFilePath())) self.enableDisableButton.Enable( - addon is not None and - not addon.isPendingRemove and - addonVersionCheck.isAddonCompatible(addon) + addon is not None + and not addon.isPendingRemove + and ( + addon.isCompatible + or addon.overrideIncompatibility + ) ) self.removeButton.Enable(addon is not None and not addon.isPendingRemove) - def onClose(self,evt): + def onClose(self, evt: wx.CloseEvent): self.DestroyChildren() self.Destroy() needsRestart = False for addon in self.curAddons: - if (addon.isPendingInstall or addon.isPendingRemove - or addon.isDisabled and addon.isPendingEnable - or addon.isRunning and addon.isPendingDisable - or not addon.isDisabled and addon.isPendingDisable): + if addon.requiresRestart: + log.debug(f"Add-on {addon.name} modified, restart required") needsRestart = True break if needsRestart: promptUserForRestart() - def onAbout(self,evt): - index=self.addonsList.GetFirstSelected() + def onAbout(self, evt: wx.EVT_BUTTON): + index: int = self.addonsList.GetFirstSelected() if index<0: return - addon=self.curAddons[index] - _showAddonInfo(addon) + addon = self.curAddons[index] + from gui._addonStoreGui.controls.messageDialogs import _showAddonInfo + _showAddonInfo(addon._addonGuiModel) def onHelp(self, evt): index = self.addonsList.GetFirstSelected() @@ -447,24 +414,22 @@ def onHelp(self, evt): path = self.curAddons[index].getDocFilePath() os.startfile(path) - def onEnableDisable(self, evt): - index=self.addonsList.GetFirstSelected() - if index<0: return - addon=self.curAddons[index] + def onEnableDisable(self, evt: wx.EVT_BUTTON): + index: int = self.addonsList.GetFirstSelected() + if index < 0: + return + addon = self.curAddons[index] shouldDisable = self._shouldDisable(addon) try: # Counterintuitive, but makes sense when context is taken into account. addon.enable(not shouldDisable) except addonHandler.AddonError: + from gui._addonStoreGui.viewModels.store import AddonStoreVM log.error("Couldn't change state for %s add-on"%addon.name, exc_info=True) if shouldDisable: - # Translators: The message displayed when the add-on cannot be disabled. - message = _("Could not disable the {description} add-on.").format( - description=addon.manifest['summary']) + message = AddonStoreVM._disableErrorMessage.format(addon=addon.manifest['summary']) else: - # Translators: The message displayed when the add-on cannot be enabled. - message = _("Could not enable the {description} add-on.").format( - description=addon.manifest['summary']) + message = AddonStoreVM._enableErrorMessage.format(addon=addon.manifest['summary']) gui.messageBox( message, # Translators: The title of a dialog presented when an error occurs. @@ -486,10 +451,16 @@ def onIncompatAddonsShowClick(self, evt): # the defaults from the addon GUI are fine. We are testing against the running version. ).ShowModal() -def installAddon(parentWindow, addonPath): - """ Installs the addon at path. Any error messages / warnings are presented to the user via a GUI message box. + +# C901 'installAddon' is too complex (16) +# Note: when working on installAddon, look for opportunities to simplify +# and move logic out into smaller helper functions. +def installAddon(parentWindow: wx.Window, addonPath: str) -> bool: # noqa: C901 + """ Installs the addon at path. + Any error messages / warnings are presented to the user via a GUI message box. If attempting to install an addon that is pending removal, it will no longer be pending removal. - :return True on success or False on failure. + @return True on success or False on failure. + @note See also L{_addonStore.install.installAddon} """ try: bundle = addonHandler.AddonBundle(addonPath) @@ -504,20 +475,22 @@ def installAddon(parentWindow, addonPath): ) return False # Exit early, can't install an invalid bundle - if not addonVersionCheck.hasAddonGotRequiredSupport(bundle): + if not bundle._hasGotRequiredSupport: _showAddonRequiresNVDAUpdateDialog(parentWindow, bundle) return False # Exit early, addon does not have required support - elif not addonVersionCheck.isAddonTested(bundle): - _showAddonTooOldDialog(parentWindow, bundle) - return False # Exit early, addon is not up to date with the latest API version. + elif bundle.canOverrideCompatibility: + from _addonStoreGui.controls.messageDialogs import _shouldInstallWhenAddonTooOldDialog + if _shouldInstallWhenAddonTooOldDialog(parentWindow, bundle._addonGuiModel): + # Install incompatible version + bundle.enableCompatibilityOverride() + else: + # Exit early, addon is not up to date with the latest API version. + return False elif wx.YES != _showConfirmAddonInstallDialog(parentWindow, bundle): return False # Exit early, User changed their mind about installation. - prevAddon = None - for addon in addonHandler.getAvailableAddons(): - if not addon.isPendingRemove and bundle.name.lower()==addon.manifest['name'].lower(): - prevAddon=addon - break + from _addonStore.install import _getPreviouslyInstalledAddonById + prevAddon = _getPreviouslyInstalledAddonById(bundle) if prevAddon: summary=bundle.manifest["summary"] curVersion=prevAddon.manifest["version"] @@ -547,7 +520,7 @@ def installAddon(parentWindow, addonPath): messageBoxTitle, wx.YES|wx.NO|wx.ICON_WARNING ) != wx.YES: - return False + return False from contextlib import contextmanager @@ -591,7 +564,7 @@ def doneAndDestroy(window): return False -def handleRemoteAddonInstall(addonPath): +def handleRemoteAddonInstall(addonPath: str): # Add-ons cannot be installed into a Windows store version of NVDA if config.isAppX: gui.messageBox( @@ -603,11 +576,14 @@ def handleRemoteAddonInstall(addonPath): return gui.mainFrame.prePopup() if installAddon(gui.mainFrame, addonPath): - promptUserForRestart() + wx.CallAfter(promptUserForRestart) gui.mainFrame.postPopup() -def _showAddonRequiresNVDAUpdateDialog(parent, bundle): +def _showAddonRequiresNVDAUpdateDialog( + parent: wx.Window, + bundle: addonHandler.AddonBundle +) -> None: incompatibleMessage = _( # Translators: The message displayed when installing an add-on package is prohibited, # because it requires a later version of NVDA than is currently installed. @@ -619,35 +595,20 @@ def _showAddonRequiresNVDAUpdateDialog(parent, bundle): minimumNVDAVersion=addonAPIVersion.formatForGUI(bundle.minimumNVDAVersion), NVDAVersion=addonAPIVersion.formatForGUI(addonAPIVersion.CURRENT) ) + from gui._addonStoreGui.controls.messageDialogs import _showAddonInfo ErrorAddonInstallDialog( parent=parent, # Translators: The title of a dialog presented when an error occurs. title=_("Add-on not compatible"), message=incompatibleMessage, - showAddonInfoFunction=lambda: _showAddonInfo(bundle) + showAddonInfoFunction=lambda: _showAddonInfo(bundle._addonGuiModel) ).ShowModal() -def _showAddonTooOldDialog(parent, bundle): - confirmInstallMessage = _( - # Translators: A message informing the user that this addon can not be installed - # because it is not compatible. - "Installation of {summary} {version} has been blocked." - " An updated version of this add-on is required," - " the minimum add-on API supported by this version of NVDA is {backCompatToAPIVersion}" - ).format( - backCompatToAPIVersion=addonAPIVersion.formatForGUI(addonAPIVersion.BACK_COMPAT_TO), - **bundle.manifest - ) - return ErrorAddonInstallDialog( - parent=parent, - # Translators: The title of a dialog presented when an error occurs. - title=_("Add-on not compatible"), - message=confirmInstallMessage, - showAddonInfoFunction=lambda: _showAddonInfo(bundle) - ).ShowModal() - -def _showConfirmAddonInstallDialog(parent, bundle): +def _showConfirmAddonInstallDialog( + parent: wx.Window, + bundle: addonHandler.AddonBundle +) -> int: confirmInstallMessage = _( # Translators: A message asking the user if they really wish to install an addon. "Are you sure you want to install this add-on?\n" @@ -655,12 +616,13 @@ def _showConfirmAddonInstallDialog(parent, bundle): "Addon: {summary} {version}" ).format(**bundle.manifest) + from gui._addonStoreGui.controls.messageDialogs import _showAddonInfo return ConfirmAddonInstallDialog( parent=parent, # Translators: Title for message asking if the user really wishes to install an Addon. title=_("Add-on Installation"), message=confirmInstallMessage, - showAddonInfoFunction=lambda: _showAddonInfo(bundle) + showAddonInfoFunction=lambda: _showAddonInfo(bundle._addonGuiModel) ).ShowModal() @@ -773,32 +735,14 @@ def __init__( self.CentreOnScreen() self.addonsList.SetFocus() - def _getIncompatReason(self, addon): - if not addonVersionCheck.hasAddonGotRequiredSupport( - addon, - currentAPIVersion=self._APIVersion - ): - # Translators: The reason an add-on is not compatible. A more recent version of NVDA is - # required for the add-on to work. The placeholder will be replaced with Year.Major.Minor (EG 2019.1). - return _("An updated version of NVDA is required. NVDA version {} or later." - ).format(addonAPIVersion.formatForGUI(addon.minimumNVDAVersion)) - elif not addonVersionCheck.isAddonTested( - addon, - backwardsCompatToVersion=self._APIBackwardsCompatToVersion - ): - # Translators: The reason an add-on is not compatible. The addon relies on older, removed features of NVDA, - # an updated add-on is required. The placeholder will be replaced with Year.Major.Minor (EG 2019.1). - return _("An updated version of this add-on is required. The minimum supported API version is now {}" - ).format(addonAPIVersion.formatForGUI(self._APIBackwardsCompatToVersion)) - def refreshAddonsList(self): self.addonsList.DeleteAllItems() - self.curAddons=[] + self.curAddons: List[Addon] = [] for idx, addon in enumerate(self.unknownCompatibilityAddonsList): self.addonsList.Append(( addon.manifest['summary'], addon.version, - self._getIncompatReason(addon) + addon.getIncompatibleReason(self._APIBackwardsCompatToVersion, self._APIVersion), )) self.curAddons.append(addon) # onAbout depends on being able to recall the current addon based on selected index activeIndex=0 @@ -806,11 +750,12 @@ def refreshAddonsList(self): self.addonsList.SetItemState(activeIndex, wx.LIST_STATE_FOCUSED, wx.LIST_STATE_FOCUSED) self.aboutButton.Enable(True) - def onAbout(self,evt): - index=self.addonsList.GetFirstSelected() + def onAbout(self, evt: wx.EVT_BUTTON): + index: int = self.addonsList.GetFirstSelected() if index<0: return - addon=self.curAddons[index] - _showAddonInfo(addon) + addon = self.curAddons[index] + from gui._addonStoreGui.controls.messageDialogs import _showAddonInfo + _showAddonInfo(addon._addonGuiModel) def onClose(self, evt): evt.Skip() diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index 4e31bb9d33d..a6d4aa71a6f 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -166,6 +166,10 @@ class LabeledControlHelper(object): # A handler is automatically added to the control to ensure the label is also enabled/disabled. EnableChanged, EVT_ENABLE_CHANGED = newevent.NewEvent() + # When the control is shown / hidden this event is raised. + # A handler is automatically added to the control to ensure the label is also shown / hidden. + ShowChanged, EVT_SHOW_CHANGED = newevent.NewEvent() + def __init__(self, parent: wx.Window, labelText: str, wxCtrlClass: wx.Control, **kwargs): """ @param parent: An instance of the parent wx window. EG wx.Dialog @param labelText: The text to associate with a wx control. @@ -183,13 +187,23 @@ def _onDestroy(self, evt: wx.WindowDestroyEvent): def listenForEnableChanged(self, _ctrl: wx.Window): self.Bind(wx.EVT_WINDOW_DESTROY, self._onDestroy) + self._labelText = self.GetLabelText() _ctrl.Bind(LabeledControlHelper.EVT_ENABLE_CHANGED, self._onEnableChanged) + _ctrl.Bind(LabeledControlHelper.EVT_SHOW_CHANGED, self._onShowChanged) self.isListening = True def _onEnableChanged(self, evt: wx.Event): if self.isListening and not self.isDestroyed: self.Enable(evt.isEnabled) + def _onShowChanged(self, evt: wx.Event): + if self.isListening and not self.isDestroyed: + if evt.shouldShow: + self.SetLabelText(self._labelText) + else: + self.SetLabelText("") + self.Parent.Layout() + class WxCtrlWithEnableEvnt(wxCtrlClass): def Enable(self, enable=True): evt = LabeledControlHelper.EnableChanged(isEnabled=enable) @@ -201,6 +215,16 @@ def Disable(self): wx.PostEvent(self, evt) super().Disable() + def Show(self, show: bool = True): + evt = LabeledControlHelper.ShowChanged(shouldShow=show) + wx.PostEvent(self, evt) + super().Show(show) + + def Hide(self): + evt = LabeledControlHelper.ShowChanged(shouldShow=False) + wx.PostEvent(self, evt) + super().Hide() + self._label = LabelEnableChangedListener(parent, label=labelText) self._ctrl = WxCtrlWithEnableEvnt(parent, **kwargs) self._label.listenForEnableChanged(self._ctrl) diff --git a/source/gui/installerGui.py b/source/gui/installerGui.py index 2f976556d08..b1b878f4e08 100644 --- a/source/gui/installerGui.py +++ b/source/gui/installerGui.py @@ -1,7 +1,7 @@ # A part of NonVisual Desktop Access (NVDA) # This file is covered by the GNU General Public License. # See the file COPYING for more details. -# Copyright (C) 2011-2021 NV Access Limited, Babbage B.v., Cyrille Bougot, Julien Cochuyt, Accessolutions, +# Copyright (C) 2011-2023 NV Access Limited, Babbage B.v., Cyrille Bougot, Julien Cochuyt, Accessolutions, # Bill Dengler, Joseph Lee, Takuya Nishimoto import os @@ -152,12 +152,16 @@ def __init__(self, parent, isUpdate): super().__init__(parent, title=_("Install NVDA")) import addonHandler + from _addonStore.models.version import ( + getAddonCompatibilityConfirmationMessage, + getAddonCompatibilityMessage, + ) shouldAskAboutAddons = any(addonHandler.getIncompatibleAddons( # the defaults from the installer are ok. We are testing against the running version. )) mainSizer = self.mainSizer = wx.BoxSizer(wx.VERTICAL) - sHelper = gui.guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) + sHelper = guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) # Translators: An informational message in the Install NVDA dialog. msg=_("To install NVDA to your hard drive, please press the Continue button.") @@ -168,24 +172,17 @@ def __init__(self, parent, isUpdate): # Translators: a message in the installer telling the user NVDA is now located in a different place. msg+=" "+_("The installation path for NVDA has changed. it will now be installed in {path}").format(path=installer.defaultInstallPath) if shouldAskAboutAddons: - msg+=_( - # Translators: A message in the installer to let the user know that - # some addons are not compatible. - "\n\n" - "However, your NVDA configuration contains add-ons that are incompatible with this version of NVDA. " - "These add-ons will be disabled after installation. If you rely on these add-ons, " - "please review the list to decide whether to continue with the installation" - ) + msg += "\n\n" + getAddonCompatibilityMessage() text = sHelper.addItem(wx.StaticText(self, label=msg)) text.Wrap(self.scaleSize(self.textWrapWidth)) if shouldAskAboutAddons: - self.confirmationCheckbox = sHelper.addItem(wx.CheckBox( + self.confirmationCheckbox = sHelper.addItem( + wx.CheckBox( self, - # Translators: A message to confirm that the user understands that addons that have not been reviewed and made - # available, will be disabled after installation. - label=_("I understand that these incompatible add-ons will be disabled") - )) + label=getAddonCompatibilityConfirmationMessage() + ) + ) self.bindHelpEvent("InstallWithIncompatibleAddons", self.confirmationCheckbox) self.confirmationCheckbox.SetFocus() @@ -353,7 +350,7 @@ def __init__(self, parent): # Translators: The title of the Create Portable NVDA dialog. super().__init__(parent, title=_("Create Portable NVDA")) mainSizer = self.mainSizer = wx.BoxSizer(wx.VERTICAL) - sHelper = gui.guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) + sHelper = guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) # Translators: An informational message displayed in the Create Portable NVDA dialog. dialogCaption=_("To create a portable copy of NVDA, please select the path and other options and then press Continue") @@ -363,14 +360,14 @@ def __init__(self, parent): # in the Create Portable NVDA dialog. directoryGroupText = _("Portable &directory:") groupSizer = wx.StaticBoxSizer(wx.VERTICAL, self, label=directoryGroupText) - groupHelper = sHelper.addItem(gui.guiHelper.BoxSizerHelper(self, sizer=groupSizer)) + groupHelper = sHelper.addItem(guiHelper.BoxSizerHelper(self, sizer=groupSizer)) groupBox = groupSizer.GetStaticBox() # Translators: The label of a button to browse for a directory. browseText = _("Browse...") # Translators: The title of the dialog presented when browsing for the # destination directory when creating a portable copy of NVDA. dirDialogTitle = _("Select portable directory") - directoryPathHelper = gui.guiHelper.PathSelectionHelper(groupBox, browseText, dirDialogTitle) + directoryPathHelper = guiHelper.PathSelectionHelper(groupBox, browseText, dirDialogTitle) directoryEntryControl = groupHelper.addItem(directoryPathHelper) self.portableDirectoryEdit = directoryEntryControl.pathControl if globalVars.appArgs.portablePath: @@ -387,7 +384,7 @@ def __init__(self, parent): self.startAfterCreateCheckbox = sHelper.addItem(wx.CheckBox(self, label=startAfterCreateText)) self.startAfterCreateCheckbox.Value = False - bHelper = sHelper.addDialogDismissButtons(gui.guiHelper.ButtonHelper(wx.HORIZONTAL), separated=True) + bHelper = sHelper.addDialogDismissButtons(guiHelper.ButtonHelper(wx.HORIZONTAL), separated=True) continueButton = bHelper.addButton(self, label=_("&Continue"), id=wx.ID_OK) continueButton.SetDefault() @@ -397,7 +394,7 @@ def __init__(self, parent): # If we bind this using button.Bind, it fails to trigger when the dialog is closed. self.Bind(wx.EVT_BUTTON, self.onCancel, id=wx.ID_CANCEL) - mainSizer.Add(sHelper.sizer, border=gui.guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL) + mainSizer.Add(sHelper.sizer, border=guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL) self.Sizer = mainSizer mainSizer.Fit(self) self.CentreOnScreen() diff --git a/source/gui/message.py b/source/gui/message.py index 9621733ced9..2324b30304e 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -7,8 +7,11 @@ import threading from typing import Optional + import wx +import extensionPoints + _messageBoxCounterLock = threading.Lock() _messageBoxCounter = 0 @@ -76,3 +79,36 @@ def messageBox( _messageBoxCounter -= 1 return res + + +class DisplayableError(Exception): + OnDisplayableErrorT = extensionPoints.Action + """ + A type of extension point used to notify a handler when an error occurs. + This allows a handler to handle displaying an error. + + @param displayableError: Error that can be displayed to the user. + @type displayableError: DisplayableError + """ + + def __init__(self, displayMessage: str, titleMessage: Optional[str] = None): + """ + @param displayMessage: A translated message, to be displayed to the user. + @param titleMessage: A translated message, to be used as a title for the display message. + If left None, "Error" is presented as the title by default. + """ + self.displayMessage = displayMessage + if titleMessage is None: + # Translators: A message indicating that an error occurred. + self.titleMessage = _("Error") + else: + self.titleMessage = titleMessage + + def displayError(self, parentWindow: wx.Window): + wx.CallAfter( + messageBox, + message=self.displayMessage, + caption=self.titleMessage, + style=wx.OK | wx.ICON_ERROR, + parent=parentWindow, + ) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index af69a226a1c..61a36af4f89 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -48,7 +48,13 @@ import vision import vision.providerInfo import vision.providerBase -from typing import Callable, List, Optional, Any +from typing import ( + Any, + Callable, + List, + Optional, + Set, +) import core import keyboardHandler import characterProcessing @@ -164,48 +170,55 @@ def _setInstanceDestroyedState(self): ) SettingsDialog._instances[self] = self.DialogState.DESTROYED - def __init__( - self, parent, - resizeable=False, - hasApplyButton=False, - settingsSizerOrientation=wx.VERTICAL, - multiInstanceAllowed=False + self, + parent: wx.Window, + resizeable: bool = False, + hasApplyButton: bool = False, + settingsSizerOrientation: int = wx.VERTICAL, + multiInstanceAllowed: bool = False, + buttons: Set[int] = {wx.OK, wx.CANCEL}, ): """ @param parent: The parent for this dialog; C{None} for no parent. - @type parent: wx.Window @param resizeable: True if the settings dialog should be resizable by the user, only set this if you have tested that the components resize correctly. - @type resizeable: bool @param hasApplyButton: C{True} to add an apply button to the dialog; defaults to C{False} for backwards compatibility. - @type hasApplyButton: bool + Deprecated, use buttons instead. @param settingsSizerOrientation: Either wx.VERTICAL or wx.HORIZONTAL. This controls the orientation of the sizer that is passed into L{makeSettings}. The default is wx.VERTICAL. - @type settingsSizerOrientation: wx.Orientation @param multiInstanceAllowed: Whether multiple instances of SettingsDialog may exist. Note that still only one instance of a particular SettingsDialog subclass may exist at one time. - @type multiInstanceAllowed: bool + @param buttons: Buttons to add to the settings dialog. + Should be a subset of {wx.OK, wx.CANCEL, wx.APPLY, wx.CLOSE}. """ if gui._isDebug(): startTime = time.time() windowStyle = wx.DEFAULT_DIALOG_STYLE if resizeable: - windowStyle |= wx.RESIZE_BORDER | wx.MAXIMIZE_BOX + windowStyle |= wx.RESIZE_BORDER | wx.MAXIMIZE_BOX | wx.MINIMIZE_BOX super().__init__(parent, title=self.title, style=windowStyle) - self.hasApply = hasApplyButton + buttonFlag = 0 + for button in buttons: + buttonFlag |= button + if hasApplyButton: + log.debugWarning( + "The hasApplyButton parameter is deprecated. " + "Use buttons instead. " + ) + buttonFlag |= wx.APPLY + self.hasApply = hasApplyButton or wx.APPLY in buttons + if not buttons.issubset({wx.OK, wx.CANCEL, wx.APPLY, wx.CLOSE}): + log.error(f"Unexpected buttons set provided: {buttons}") self.mainSizer=wx.BoxSizer(wx.VERTICAL) self.settingsSizer=wx.BoxSizer(settingsSizerOrientation) self.makeSettings(self.settingsSizer) self.mainSizer.Add(self.settingsSizer, border=guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL | wx.EXPAND, proportion=1) - buttons = wx.OK | wx.CANCEL - if hasApplyButton: - buttons |= wx.APPLY self.mainSizer.Add( - self.CreateSeparatedButtonSizer(buttons), + self.CreateSeparatedButtonSizer(buttonFlag), border=guiHelper.BORDER_FOR_DIALOGS, flag=wx.EXPAND | wx.BOTTOM | wx.LEFT | wx.RIGHT ) @@ -215,6 +228,7 @@ def __init__( self.Bind(wx.EVT_BUTTON, self.onOk, id=wx.ID_OK) self.Bind(wx.EVT_BUTTON, self.onCancel, id=wx.ID_CANCEL) + self.Bind(wx.EVT_BUTTON, self.onClose, id=wx.ID_CLOSE) self.Bind(wx.EVT_BUTTON, self.onApply, id=wx.ID_APPLY) self.Bind(wx.EVT_CHAR_HOOK, self._enterActivatesOk_ctrlSActivatesApply) # Garbage collection normally handles removing the settings instance, however this may not happen immediately @@ -258,7 +272,7 @@ def postInit(self): Sub-classes may override this method. """ - def onOk(self, evt): + def onOk(self, evt: wx.CommandEvent): """Take action in response to the OK button being pressed. Sub-classes may extend this method. This base method should always be called to clean up the dialog. @@ -266,7 +280,7 @@ def onOk(self, evt): self.DestroyLater() self.SetReturnCode(wx.ID_OK) - def onCancel(self, evt): + def onCancel(self, evt: wx.CommandEvent): """Take action in response to the Cancel button being pressed. Sub-classes may extend this method. This base method should always be called to clean up the dialog. @@ -274,7 +288,15 @@ def onCancel(self, evt): self.DestroyLater() self.SetReturnCode(wx.ID_CANCEL) - def onApply(self, evt): + def onClose(self, evt: wx.CommandEvent): + """Take action in response to the Close button being pressed. + Sub-classes may extend this method. + This base method should always be called to clean up the dialog. + """ + self.DestroyLater() + self.SetReturnCode(wx.ID_CLOSE) + + def onApply(self, evt: wx.CommandEvent): """Take action in response to the Apply button being pressed. Sub-classes may extend or override this method. This base method should be called to run the postInit method. @@ -467,8 +489,8 @@ def __init__(self, parent, initialCategory=None): super(MultiCategorySettingsDialog, self).__init__( parent, resizeable=True, - hasApplyButton=True, - settingsSizerOrientation=wx.HORIZONTAL + settingsSizerOrientation=wx.HORIZONTAL, + buttons={wx.OK, wx.CANCEL, wx.APPLY}, ) # setting the size must be done after the parent is constructed. @@ -2555,6 +2577,19 @@ def onSave(self): self.paragraphStyleCombo.saveCurrentValueToConf() +class AddonStorePanel(SettingsPanel): + # Translators: This is the label for the addon navigation settings panel. + title = _("Add-on Store") + helpId = "AddonStoreSettings" + + def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: + # sHelper = guiHelper.BoxSizerHelper(self, sizer=settingsSizer) + pass + + def onSave(self): + pass + + class TouchInteractionPanel(SettingsPanel): # Translators: This is the label for the touch interaction settings panel. title = _("Touch Interaction") @@ -4134,6 +4169,7 @@ class NVDASettingsDialog(MultiCategorySettingsDialog): BrowseModePanel, DocumentFormattingPanel, DocumentNavigationPanel, + # AddonStorePanel, currently empty ] if touchHandler.touchSupported(): categoryClasses.append(TouchInteractionPanel) diff --git a/source/updateCheck.py b/source/updateCheck.py index 5ba75db2393..5efb44ebff8 100644 --- a/source/updateCheck.py +++ b/source/updateCheck.py @@ -48,6 +48,10 @@ import gui from gui import guiHelper from addonHandler import getCodeAddon, AddonError, getIncompatibleAddons +from _addonStore.models.version import ( # noqa: E402 + getAddonCompatibilityMessage, + getAddonCompatibilityConfirmationMessage, +) from logHandler import log, isPathExternalToNVDA import config import shellapi @@ -374,19 +378,10 @@ def __init__(self, parent, updateInfo: Optional[Dict], auto: bool) -> None: backCompatToAPIVersion=self.backCompatTo )) if showAddonCompat: - message = message + _( - # Translators: A message indicating that some add-ons will be disabled - # unless reviewed before installation. - "\n\n" - "However, your NVDA configuration contains add-ons that are incompatible with this version of NVDA. " - "These add-ons will be disabled after installation. If you rely on these add-ons, " - "please review the list to decide whether to continue with the installation" - ) + message += + "\n\n" + getAddonCompatibilityMessage() confirmationCheckbox = sHelper.addItem(wx.CheckBox( self, - # Translators: A message to confirm that the user understands that addons that have not been - # reviewed and made available, will be disabled after installation. - label=_("I understand that these incompatible add-ons will be disabled") + label=getAddonCompatibilityConfirmationMessage() )) confirmationCheckbox.Bind( wx.EVT_CHECKBOX, @@ -497,23 +492,14 @@ def __init__(self, parent, destPath, version, apiVersion, backCompatTo): backCompatToAPIVersion=self.backCompatTo )) if showAddonCompat: - message = message + _( - # Translators: A message indicating that some add-ons will be disabled - # unless reviewed before installation. - "\n" - "However, your NVDA configuration contains add-ons that are incompatible with this version of NVDA. " - "These add-ons will be disabled after installation. If you rely on these add-ons, " - "please review the list to decide whether to continue with the installation" - ) + message += "\n" + getAddonCompatibilityMessage() text = sHelper.addItem(wx.StaticText(self, label=message)) text.Wrap(self.scaleSize(500)) if showAddonCompat: self.confirmationCheckbox = sHelper.addItem(wx.CheckBox( self, - # Translators: A message to confirm that the user understands that addons that have not been reviewed and made - # available, will be disabled after installation. - label=_("I understand that these incompatible add-ons will be disabled") + label=getAddonCompatibilityConfirmationMessage() )) bHelper = sHelper.addDialogDismissButtons(guiHelper.ButtonHelper(wx.HORIZONTAL)) diff --git a/source/utils/caseInsensitiveCollections.py b/source/utils/caseInsensitiveCollections.py new file mode 100644 index 00000000000..1ab0c824f6c --- /dev/null +++ b/source/utils/caseInsensitiveCollections.py @@ -0,0 +1,37 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2023 NV Access Limited +# This file may be used under the terms of the GNU General Public License, version 2 or later. +# For more details see: https://www.gnu.org/licenses/gpl-2.0.html + +from typing import ( + Iterable, +) + + +class CaseInsensitiveSet(set): + def __init__(self, *args: Iterable[str]): + if len(args) > 1: + raise TypeError( + f"{type(self).__name__} expected at most 1 argument, " + f"got {len(args)}" + ) + values = args[0] if args else () + for v in values: + self.add(v) + + def add(self, __element: str) -> None: + __element = __element.casefold() + return super().add(__element) + + def discard(self, __element: str) -> None: + __element = __element.casefold() + return super().discard(__element) + + def remove(self, __element: str) -> None: + __element = __element.casefold() + return super().remove(__element) + + def __contains__(self, __o: object) -> bool: + if isinstance(__o, str): + __o = __o.casefold() + return super().__contains__(__o) diff --git a/source/utils/displayString.py b/source/utils/displayString.py index b629f188564..652b3c10839 100644 --- a/source/utils/displayString.py +++ b/source/utils/displayString.py @@ -7,6 +7,7 @@ from enum import ( Enum, EnumMeta, + Flag, IntEnum, IntFlag, ) @@ -66,6 +67,11 @@ class DisplayStringEnum(_DisplayStringEnumMixin, Enum, metaclass=_DisplayStringE pass +class DisplayStringFlag(_DisplayStringEnumMixin, Flag, metaclass=_DisplayStringEnumMixinMeta): + """A Flag class that adds a displayString property defined by _displayStringLabels""" + pass + + class DisplayStringStrEnum(_DisplayStringEnumMixin, str, Enum, metaclass=_DisplayStringEnumMixinMeta): """A str Enum class that adds a displayString property defined by _displayStringLabels""" pass diff --git a/source/utils/security.py b/source/utils/security.py index cf5b1ff35e2..3e53a720022 100644 --- a/source/utils/security.py +++ b/source/utils/security.py @@ -3,13 +3,15 @@ # This file may be used under the terms of the GNU General Public License, version 2 or later. # For more details see: https://www.gnu.org/licenses/gpl-2.0.html -import typing +import hashlib from typing import ( Any, + BinaryIO, Callable, List, Optional, Set, + TYPE_CHECKING, ) import extensionPoints @@ -17,7 +19,7 @@ from winAPI.sessionTracking import _isLockScreenModeActive import winUser -if typing.TYPE_CHECKING: +if TYPE_CHECKING: import scriptHandler # noqa: F401, use for typing import NVDAObjects # noqa: F401, use for typing @@ -383,3 +385,21 @@ def warnSessionLockStateUnknown() -> None: caption=_("Lock screen not secure while using NVDA"), style=wx.ICON_ERROR | wx.OK, ) + + +#: The read size for each chunk read from the file, prevents memory overuse with large files. +SHA_BLOCK_SIZE = 65536 + + +def sha256_checksum(binaryReadModeFile: BinaryIO, blockSize: int = SHA_BLOCK_SIZE) -> str: + """ + @param binaryReadModeFile: An open file (mode=='rb'). Calculate its sha256 hash. + @param blockSize: The size of each read. + @returns: The sha256 hex digest. + """ + sha256sum = hashlib.sha256() + assert binaryReadModeFile.readable() and binaryReadModeFile.mode == 'rb' + f = binaryReadModeFile + for block in iter(lambda: f.read(blockSize), b''): + sha256sum.update(block) + return sha256sum.hexdigest() diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md new file mode 100644 index 00000000000..d172dc6caf5 --- /dev/null +++ b/tests/manual/addonStore.md @@ -0,0 +1,185 @@ + +## Browsing add-ons + +### Filter add-ons by channel +Add-ons can be filtered by channel: e.g. stable, beta, dev, external. + +1. Open the add-on store +1. Jump to the filter-by channel field (`alt+n`) +1. Filter by each channel grouping, ensure expected add-ons are displayed. + +### Filtering by add-on information +Add-ons can be filtered by display name, publisher and description. + +1. Open the add-on store +1. Jump to the search text field (`alt+s`) +1. Search for a string, for example part of a publisher name, display name or description. +1. Ensure expected add-ons appear after the filter view refreshes. +1. Remove the filter-by string +1. Ensure the list of add-ons refreshes to all add-ons, unfiltered. + +### Filtering where no add-ons are found +1. Open the add-on store +1. Jump to the search text field (`alt+s`) +1. Search for a string which yields no add-ons. +1. Ensure the add-on information dialog states "no add-on selected". + +### Browsing incompatible add-ons available for download +1. Open the Add-on Store +1. Change to the "Available add-ons" tab +1. Enable the "Include incompatible add-ons" filter +1. Ensure add-ons with status "incompatible" are included in the list with the available add-ons. + +### Failure to fetch add-ons available for download +1. Disable your internet connection +1. Go to your NVDA user configuration folder: + - For source: `.\source\userConfig` + - For installed copies: `%APPDATA%\nvda` +1. To delete the current cache of available add-on store add-ons, delete the file: `addonStore\_cachedCompatibleAddons.json` +1. Open the Add-on Store +1. Ensure a warning is displayed: "Unable to fetch latest compatible add-ons" +1. Ensure installed add-ons are still available in the add-on store. + + +## Installing add-ons + +### Install add-on from add-on store +1. Open the add-on store. +1. Select an add-on. +1. Navigate to and press the install button for the add-on. +1. Exit the dialog +1. Restart NVDA as prompted. +1. Confirm the add-on is listed in the add-ons store. + +### Install add-on from external source in add-on store +1. Open the add-on store. +1. Jump to the install from external source button (`alt+x`) +1. Find an `*.nvda-addon` file, and open it +1. Proceed with the installation + +### Install and override incompatible add-on from add-on store +1. Open the add-on store. +1. Find an add-on listed as "incompatible" for download. +1. Navigate to and press the "install (override compatibility)" button for the add-on. +1. Confirm the warning message about add-on compatibility. +1. Proceed with the installation. + +### Install and override incompatible add-on from external source +1. Start the install of an incompatible add-on bundle. +You can do this by: + - opening an `.nvda-addon` file while NVDA is running + - using the "install from external source" button +1. Confirm the warning message about add-on compatibility. +1. Proceed with the installation. + +### Enable and override compatibility for an installed add-on +1. Browse incompatible disabled add-ons in the add-on store +1. Press enable on a disabled add-on +1. Confirm the warning message about add-on compatibility. +1. Exit the add-on store dialog +1. You should be prompted for restart, restart NVDA +1. Confirm the add-on is enabled in the add-ons store + +### Failure to download add-on +1. Open the Add-on Store +1. Filter by available add-ons. +1. Disable your internet connection +1. Press install on a cached add-on +1. Ensure a warning is displayed: "Unable download add-on" + + +## Updating add-ons + +### Updating from add-on installed from add-on store +1. [Install an add-on from the add-on store](#install-add-on) +For example: "Clock". +1. Go to your NVDA user configuration folder: + - For source: `.\source\userConfig` + - For installed copies: `%APPDATA%\nvda` +1. To spoof an old release, we need to edit 2 files: + - Add-on store JSON metadata + - Located in: `.\addonStore\addons\`. + - Example: `source\userConfig\addonStore\addons\clock.json` + - Edit "addonVersionNumber" and "addonVersionName": decrease the major release value number. + - Add-on manifest + - Located in: `.\addons\`. + - Example: `source\userConfig\addons\clock\manifest.ini` + - Edit "version": decrease the major release value number to match earlier edits. +1. Open the add-on store +1. Ensure the same add-on you edited is available on the add-on store with the status "update". +1. Install the add-on again to test the "update" path. + +### Updating from add-on installed externally with valid version +1. [Install an add-on from the add-on store](#install-add-on) +For example: "Clock". +1. Go to your NVDA user configuration folder: + - For source: `.\source\userConfig` + - For installed copies: `%APPDATA%\nvda` +1. To spoof an externally loaded older release, we need to edit 2 files: + - Add-on store JSON metadata + - Located in: `.\addonStore\addons\`. + - Example: `source\userConfig\addonStore\addons\clock.json` + - Delete this file. + - Add-on manifest + - Located in: `.\addons\`. + - Example: `source\userConfig\addons\clock\manifest.ini` + - Edit "version": decrease the major release value number to match earlier edits. +1. Open the add-on store +1. Ensure the same add-on you edited is available on the add-on store with the status "update". +1. Install the add-on again to test the "update" path. + +### Migrating from add-on installed externally with invalid version +This tests a path where an add-on was previously installed, but we are uncertain of the version. +This means using the latest add-on store version might be a downgrade or sidegrade. + +1. [Install an add-on from the add-on store](#install-add-on) +For example: "Clock". +1. Go to your NVDA user configuration folder: + - For source: `.\source\userConfig` + - For installed copies: `%APPDATA%\nvda` +1. To spoof an externally loaded release, with an invalid version, we need to edit 2 files: + - Add-on store JSON metadata + - Located in: `.\addonStore\addons\`. + - Example: `source\userConfig\addonStore\addons\clock.json` + - Delete this file. + - Add-on manifest + - Located in: `.\addons\`. + - Example: `source\userConfig\addons\clock\manifest.ini` + - Edit "version": to something invalid e.g. "foo". +1. Open the add-on store +1. Ensure the same add-on you edited is available on the add-on store with the status "Migrate to add-on store". +1. Install the add-on again to test the "migrate" path. + +### Updating multiple add-ons +Updating multiple add-ons at once is currently unsupported. + +### Automatic updating +Automatic updating of add-ons is currently unsupported. + +## Other add-on actions + +### Disabling an add-on +1. Find a running add-on in the add-on store +1. Press the disable button +1. Exit the add-on store dialog +1. You should be prompted for restart, restart NVDA +1. Confirm the add-on is disabled in the add-ons store + +### Enabling an add-on +1. Find a disabled add-on in the add-on store +1. Press the enable button +1. Exit the add-on store dialog +1. You should be prompted for restart, restart NVDA +1. Confirm the add-on is disabled in the add-ons store + +### Removing an add-on +1. Find an installed add-on in the add-on store +1. Press the remove button +1. Exit the add-on store dialog +1. You should be prompted for restart, restart NVDA +1. Confirm the add-on is removed in the add-ons store + +### Browse an add-ons help +1. Find an installed add-on in the add-on store +1. Press the "add-on help" button +1. A window should open with the help documentation diff --git a/tests/unit/test_addonVersionCheck.py b/tests/unit/test_addonVersionCheck.py index 57418bc9133..2874e7c6e3e 100644 --- a/tests/unit/test_addonVersionCheck.py +++ b/tests/unit/test_addonVersionCheck.py @@ -8,15 +8,25 @@ import unittest import addonAPIVersion -from addonHandler import AddonBase -from addonHandler import addonVersionCheck +from addonHandler.addonVersionCheck import ( + hasAddonGotRequiredSupport, + isAddonCompatible, + isAddonTested, +) +from addonHandler import AddonBase, AddonManifest latestVersionTuple = (2018, 2, 0) nextVersionTuple = (2018, 3, 0) previousVersionTuple = (2018, 1, 0) oldVersionTuple = (2017, 1, 0) + class mockAddon(AddonBase): + @property + def manifest(self) -> AddonManifest: + """Satisfy abstract base class requirements""" + raise NotImplementedError("Not expected to be required for unit tests") + def __init__( self, minAPIVersion, @@ -46,6 +56,7 @@ def minimumNVDAVersion(self): def lastTestedNVDAVersion(self): return self._lastTestedAPIVersion + class TestAddonVersionCheck(unittest.TestCase): """Tests that the addon version check works as expected.""" @@ -53,17 +64,17 @@ def test_addonCompat_addonRequiresNewFeature(self): """Test an addon that has just been developed, requiring an API feature introduced in the current release.""" addon = mockAddon(minAPIVersion=latestVersionTuple, lastTestedAPIVersion=latestVersionTuple) nvda_current, nvda_backwardsCompatTo = latestVersionTuple, previousVersionTuple - self.assertTrue(addonVersionCheck.hasAddonGotRequiredSupport(addon, nvda_current)) - self.assertTrue(addonVersionCheck.isAddonTested(addon, nvda_backwardsCompatTo)) - self.assertTrue(addonVersionCheck.isAddonCompatible(addon, nvda_current, nvda_backwardsCompatTo)) + self.assertTrue(hasAddonGotRequiredSupport(addon, nvda_current)) + self.assertTrue(isAddonTested(addon, nvda_backwardsCompatTo)) + self.assertTrue(isAddonCompatible(addon, nvda_current, nvda_backwardsCompatTo)) def test_addonCompat_testedAgainstLastBackwardsCompatVersion(self): """Test an addon has been maintained and tested against the backwardsCompatTo version.""" addon = mockAddon(minAPIVersion=oldVersionTuple, lastTestedAPIVersion=previousVersionTuple) nvda_current, nvda_backwardsCompatTo = latestVersionTuple, previousVersionTuple - self.assertTrue(addonVersionCheck.hasAddonGotRequiredSupport(addon, nvda_current)) - self.assertTrue(addonVersionCheck.isAddonTested(addon, nvda_backwardsCompatTo)) - self.assertTrue(addonVersionCheck.isAddonCompatible(addon, nvda_current, nvda_backwardsCompatTo)) + self.assertTrue(hasAddonGotRequiredSupport(addon, nvda_current)) + self.assertTrue(isAddonTested(addon, nvda_backwardsCompatTo)) + self.assertTrue(isAddonCompatible(addon, nvda_current, nvda_backwardsCompatTo)) def test_addonCompat_lastTestedAgainstNowNoLongerSupportedAPIVersion(self): """Test an addon is considered incompatible if the backwards compatible to version is moved forward for an addon @@ -71,19 +82,18 @@ def test_addonCompat_lastTestedAgainstNowNoLongerSupportedAPIVersion(self): addon = mockAddon(minAPIVersion=oldVersionTuple, lastTestedAPIVersion=previousVersionTuple) # NVDA backwards compatible to has been moved forward one version: nvda_current, nvda_backwardsCompatTo = latestVersionTuple, latestVersionTuple - self.assertTrue(addonVersionCheck.hasAddonGotRequiredSupport(addon, nvda_current)) - self.assertFalse(addonVersionCheck.isAddonTested(addon, nvda_backwardsCompatTo)) - self.assertFalse(addonVersionCheck.isAddonCompatible(addon, nvda_current, nvda_backwardsCompatTo)) + self.assertTrue(hasAddonGotRequiredSupport(addon, nvda_current)) + self.assertFalse(isAddonTested(addon, nvda_backwardsCompatTo)) + self.assertFalse(isAddonCompatible(addon, nvda_current, nvda_backwardsCompatTo)) def test_addonCompat_attemptingToUseAddonRequiringNewAPIFeaturesWithOldNVDA(self): """Test that is considered incompatible if a user tries to install a new addon with an old version of NVDA""" # addon requires API features in the future release addon = mockAddon(minAPIVersion=nextVersionTuple, lastTestedAPIVersion=nextVersionTuple) nvda_current, nvda_backwardsCompatTo = latestVersionTuple, previousVersionTuple - self.assertFalse(addonVersionCheck.hasAddonGotRequiredSupport(addon, latestVersionTuple)) - self.assertTrue(addonVersionCheck.isAddonTested(addon, latestVersionTuple)) - self.assertFalse(addonVersionCheck.isAddonCompatible(addon, nvda_current, nvda_backwardsCompatTo)) - + self.assertFalse(hasAddonGotRequiredSupport(addon, latestVersionTuple)) + self.assertTrue(isAddonTested(addon, latestVersionTuple)) + self.assertFalse(isAddonCompatible(addon, nvda_current, nvda_backwardsCompatTo)) class TestGetAPIVersionTupleFromString(unittest.TestCase): diff --git a/tests/unit/test_util/test_caseInsensitiveCollections.py b/tests/unit/test_util/test_caseInsensitiveCollections.py new file mode 100644 index 00000000000..5062021d63f --- /dev/null +++ b/tests/unit/test_util/test_caseInsensitiveCollections.py @@ -0,0 +1,46 @@ +# A part of NonVisual Desktop Access (NVDA) +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. +# Copyright (C) 2023 NV Access Limited. + +"""Unit tests for the caseInsensitiveCollections submodule. +""" + +import unittest + +from utils.caseInsensitiveCollections import CaseInsensitiveSet + + +class Test_CaseInsensitiveSet(unittest.TestCase): + """Tests should cover all expected uses of an instantiated CaseInsensitiveSet""" + + def test_caseInsensitiveInit(self): + self.assertSetEqual(CaseInsensitiveSet({"foo", "FOO"}), CaseInsensitiveSet({"foo"})) + + def test_caseInsensitiveAdd(self): + base = CaseInsensitiveSet({"foo"}) + base.add("FOO") + self.assertSetEqual(base, CaseInsensitiveSet({"foo"})) + + def test_caseInsensitiveDiscard(self): + base = CaseInsensitiveSet({"foo"}) + base.discard("FOO") + self.assertSetEqual(base, CaseInsensitiveSet()) + + def test_caseInsensitiveRemove(self): + base = CaseInsensitiveSet({"foo"}) + base.remove("FOO") + self.assertSetEqual(base, CaseInsensitiveSet()) + + def test_caseInsensitiveIn(self): + self.assertIn("FOO", CaseInsensitiveSet({"foo"})) + + def test_caseInsensitiveSubtract(self): + base = CaseInsensitiveSet({"foo", "bar", "lorem"}) + base -= CaseInsensitiveSet({"foo", "BAR"}) + self.assertSetEqual(base, CaseInsensitiveSet({"lorem"})) + + def test_caseInsensitiveAddSet(self): + base = CaseInsensitiveSet({"foo", "bar"}) + base |= CaseInsensitiveSet({"lorem", "BAR"}) + self.assertSetEqual(base, CaseInsensitiveSet({"foo", "bar", "lorem"})) diff --git a/user_docs/en/userGuide.t2t b/user_docs/en/userGuide.t2t index a1d82885d34..fc642276205 100644 --- a/user_docs/en/userGuide.t2t +++ b/user_docs/en/userGuide.t2t @@ -311,6 +311,7 @@ If you have add-ons already installed there may also be a warning that incompati Before you're able to press the Continue button you will have to use the checkbox to confirm that you understand that these add-ons will be disabled. There will also be a button present to review the add-ons that will be disabled. Refer to the [incompatible add-ons dialog section #incompatibleAddonsManager] for more help on this button. +After installation, you are able to re-enable incompatible add-ons at your own risk from within the [Add-on Store #AddonsManager]. +++ Use NVDA during sign-in +++[StartAtWindowsLogon] This option allows you to choose whether or not NVDA should automatically start while at the Windows sign-in screen, before you have entered a password. @@ -2590,63 +2591,102 @@ To toggle the braille viewer from anywhere, please assign a custom gesture using The NVDA Python console, found under Tools in the NVDA menu, is a development tool which is useful for debugging, general inspection of NVDA internals or inspection of the accessibility hierarchy of an application. For more information, please see the [NVDA Developer Guide https://www.nvaccess.org/files/nvda/documentation/developerGuide.html]. -++ Add-ons Manager ++[AddonsManager] -The Add-ons Manager, accessed by selecting Manage add-ons under Tools in the NVDA menu, allows you to install, uninstall, enable and disable add-on packages for NVDA. +++ Add-on Store ++[AddonsManager] +The Add-on Store allows you to browse and manage add-on packages for NVDA. These packages are provided by the community and contain custom code that may add or change features in NVDA or even provide support for extra Braille displays or speech synthesizers. +The Add-on Store is accessed by the Tools submenu in the NVDA menu. +To access the Add-on Store from anywhere, please assign a custom gesture using the [Input Gestures dialog #InputGestures]. + ++++ Browsing add-ons +++[AddonStoreBrowsing] +The Add-on Store displays a list of add-ons. +You can jump to the list with ``alt+l``. +If you have not installed an add-on before, the add-on store will open on a list of add-ons available to install. +If you have installed add-ons, the list will display currently installed add-ons. + +Selecting an add-on will display the details for the add-on. +Add-ons have associated actions that you can access through an [actions menu #AddonStoreActions], such as install, help, disable, remove. + +++++ Add-on list views ++++[AddonStoreFilterStatus] +To change the view of add-ons, change the active tab of the add-ons list using ``ctrl+tab``. +There are different views for installed, updatable, available and incompatible. + +++++ Include incompatible add-ons ++++[AddonStoreFilterEnabled] +Installed and incompatible add-ons can be filtered by enabled or disabled. +The default shows both enabled and disabled add-ons. + +++++ Include incompatible add-ons ++++[AddonStoreFilterIncompatible] +Available and updatable add-ons can be filtered to include [incompatible add-ons #incompatibleAddonsManager] available to install. + +++++ Filter add-ons by channel ++++[AddonStoreFilterChannel] +To list add-ons only for specific channels, change the "Channel" filter selection. + +Add-ons can be distributed through up to four channels: +- Stable: The developer has released this as a tested add-on with a released version of NVDA. +- Beta: This add-on may need further testing. +Suggested for early adopters. +- Dev: This channel is suggested to be used by add-on developers to test unreleased API changes. +NVDA alpha testers may need to use a "Dev" version of their add-ons. +- External: Add-ons installed from external sources, outside of the add-on store. +- + +++++ Search add-ons ++++[AddonStoreFilterSearch] +Add-ons can be filtered by display name, publisher and description. +To search add-ons, use the "Search" text box. + ++++ Add-on actions +++[AddonStoreActions] +Add-ons have associated actions, such as install, help, disable, remove. +The actions menu can be accessed for an add-on in the add-on list by pressing ``enter``, right click or double click. +There is also an Actions button in the selected add-on's details. -The Add-ons Manager contains a list that displays all the add-ons currently installed in your NVDA user configuration. -Package name, status, version and author are shown for each add-on, though further information such as a description and URL can be viewed by selecting the add-on and pressing the About add-on button. -If there is help available for the selected add-on, you can access it by pressing the Add-on help button. +++++ Installing add-ons ++++[AddonStoreInstalling] +It is very important to only install add-ons from sources you trust. +The functionality of add-ons is unrestricted inside NVDA. +This could include accessing your personal data or even the entire system. -To browse and download available add-ons online, press the Get add-ons button. -This button opens the [NVDA Add-ons page https://addons.nvda-project.org/]. -If NVDA is installed and running on your system, you can open the add-on directly from the browser to begin the installation process as described below. -Otherwise, save the add-on package and follow the instructions below. +You can install and update add-ons by [browsing Available add-ons #AddonStoreBrowsing]. +Select an add-on from the "Available add-ons" or "Updatable add-ons" tab. +Then use the update, install, or replace action to start the installation. -To install an Add-on you previously obtained, press the Install button. -This will allow you to browse for an add-on package (.nvda-addon file) somewhere on your computer or on a network. -Once you press Open, the installation process will begin. +To install an add-on you have obtained outside of the Add-on Store, press the "Install from external source" button. +This will allow you to browse for an add-on package (``.nvda-addon`` file) somewhere on your computer or on a network. +Once you open the add-on package, the installation process will begin. -When an add-on is being installed, NVDA will first ask you to confirm that you really wish to install the add-on. -As the functionality of add-ons is unrestricted inside NVDA, which in theory could include accessing your personal data or even the entire system if NVDA is an installed copy, it is very important to only install add-ons from sources you trust. +If NVDA is installed and running on your system, you can also open an add-on file directly from the browser or file system to begin the installation process. + +When an add-on is being installed, NVDA will ask you to confirm the installation. Once the add-on is installed, NVDA must be restarted for the add-on to start running. -Until you do, a status of "install" will show for that add-on in the list. -To remove an add-on, select the add-on from the list and press the Remove button. -NVDA will ask if you really wish to do this. -As with installing, NVDA must be restarted for the add-on to be fully removed. -Until you do, a status of "remove" will be shown for that add-on in the list. +++++ Removing Add-ons ++++[AddonStoreRemoving] +To remove an add-on, select the add-on from the list and use the Remove action. +NVDA will ask you to confirm this action. +As with installing, NVDA must be restarted for the add-on to be fully removed. +Until you do, a status of "Pending removal" will be shown for that add-on in the list. -To disable an add-on, press the "disable" button. -To enable a previously disabled add-on, press the "enable" button. +++++ Disabling and Enabling Add-ons ++++[AddonStoreDisablingEnabling] +To disable an add-on, use the "disable" action. +To enable a previously disabled add-on, use the "enable" action. You can disable an add-on if the add-on status indicates it is "enabled", or enable it if the add-on is "disabled". -For each press of the enable/disable button, add-on status changes to indicate what will happen when NVDA restarts. +For each use of the enable/disable action, add-on status changes to indicate what will happen when NVDA restarts. If the add-on was previously "disabled", a status will show "enabled after restart". If the add-on was previously "enabled", a status will show "disabled after restart". Just like when you install or remove add-ons, you need to restart NVDA in order for changes to take effect. -The manager also has a Close button to close the dialog. -If you have installed, removed or changed the status of an add-on, NVDA will first ask you if you wish to restart so that your changes can take effect. - ++++ Incompatible Add-ons +++[incompatibleAddonsManager] Some older add-ons may no longer be compatible with the version of NVDA that you have. When using an older version of NVDA, some new add-ons may not be compatible either. Attempting to install an incompatible add-on will result in an error explaining why the add-on is considered incompatible. -To inspect these incompatible add-ons, you can use the "view incompatible add-ons" button to launch the incompatible add-ons manager. - -To access the Add-ons Manager from anywhere, please assign a custom gesture using the [Input Gestures dialog #InputGestures]. - -+++ Incompatible Add-ons Manager +++[incompatibleAddonsManager] -The Incompatible Add-ons Manager, which can be accessed via the "view incompatible add-ons" button in the Add-on manager, allows you to inspect any incompatible add-ons, and the reason they are considered incompatible. -Add-ons are considered incompatible when they have not been updated to work with significant changes to NVDA, or when they rely on a feature not available in the version of NVDA you are using. -The Incompatible add-ons manager has a short message to explain its purpose as well as the version of NVDA. -The incompatible add-ons are presented in a list with the following columns: -+ Package, the name of the add-on -+ Version, the version of the add-on -+ Incompatible reason, an explanation of why the add-on is considered incompatible -+ -The Incompatible add-ons manager also has an "About add-on..." button. -This dialog will provide you with the full details of the add-on, which is helpful when contacting the add-on author. +For older add-ons, you can override the incompatibility at your own risk. +Incompatible add-ons may not work with your version of NVDA, and can cause unstable or unexpected behaviour including crashing. +You can override compatibility when enabling or installing an add-on. +If the incompatible add-on causes issues, you can disable or remove it. + +To restart NVDA with all add-ons disabled, choose the appropriate option when quitting NVDA. + +Alternatively, use the [command line option #CommandLineOptions] ``--disable-addons``. + +You can browse available incompatible add-ons using the [available and updatable add-ons tabs #AddonStoreFilterStatus]. +You can browse installed incompatible add-ons using the [incompatible add-ons tab #AddonStoreFilterStatus]. ++ Create portable copy ++[CreatePortableCopy] This will open a dialog which allows you to create a portable copy of NVDA out of the installed version.