Skip to content

Commit f87035a

Browse files
authored
Merge bb0a20e into e917b7c
2 parents e917b7c + bb0a20e commit f87035a

41 files changed

Lines changed: 4299 additions & 422 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

devDocs/developerGuide.t2t

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,7 @@ addonHandler.isCLIParamKnown.register(processArgs)
759759
```
760760

761761
+ Packaging Code as NVDA Add-ons +[Addons]
762-
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.
762+
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.
763763
Add-on packages are only supported in NVDA 2012.2 and later.
764764
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.
765765

@@ -865,7 +865,7 @@ For more information about gettext and NVDA translation in general, please read
865865
Documentation for an add-on should be placed in a doc directory in the archive.
866866
Similar to the locale directory, this directory should contain directories for each language in which documentation is available.
867867

868-
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.
868+
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.
869869
This will open the file named in the docFileName parameter of the manifest.
870870
NVDA will search for this file in the appropriate language directories.
871871
For example, if docFileName is set to readme.html and the user is using English, NVDA will open doc\en\readme.html.

requirements.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ comtypes==1.1.11
66
pyserial==3.5
77
wxPython==4.1.1
88
git+https://github.com/DiffSK/configobj@3e2f4cc#egg=configobj
9+
requests==2.28.2
10+
# Required to use a pinned old version for requests.
11+
# py2exe fails to compile properly without this.
12+
# This can be removed when upgrading py2exe to 0.13+ and python to 3.8+.
13+
# https://github.com/Ousret/charset_normalizer/issues/253
14+
charset-normalizer==2.1.1
915

1016
#NVDA_DMP requires diff-match-patch
1117
diff_match_patch_python==1.0.2

source/_addonStore/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2022 NV Access Limited
3+
# This file is covered by the GNU General Public License.
4+
# See the file COPYING for more details.

source/_addonStore/dataManager.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2022-2023 NV Access Limited
3+
# This file is covered by the GNU General Public License.
4+
# See the file COPYING for more details.
5+
6+
# Needed for type hinting CaseInsensitiveDict
7+
# Can be removed in a future version of python (3.8+)
8+
from __future__ import annotations
9+
10+
from datetime import datetime, timedelta
11+
import json
12+
import os
13+
import pathlib
14+
import threading
15+
from typing import (
16+
TYPE_CHECKING,
17+
Optional,
18+
)
19+
20+
import requests
21+
from requests.structures import CaseInsensitiveDict
22+
23+
import addonAPIVersion
24+
from baseObject import AutoPropertyObject
25+
import config
26+
from core import callLater
27+
import globalVars
28+
import languageHandler
29+
from logHandler import log
30+
31+
from .models.addon import (
32+
AddonStoreModel,
33+
CachedAddonsModel,
34+
_createAddonGUICollection,
35+
_createStoreModelFromData,
36+
_createStoreCollectionFromJson,
37+
)
38+
from .models.channel import Channel
39+
from .network import (
40+
AddonFileDownloader,
41+
_getAddonStoreURL,
42+
_getCurrentApiVersionForURL,
43+
_LATEST_API_VER,
44+
)
45+
46+
if TYPE_CHECKING:
47+
from addonHandler import Addon as AddonHandlerModel # noqa: F401
48+
# AddonGUICollectionT must only be imported when TYPE_CHECKING
49+
from .models.addon import AddonGUICollectionT # noqa: F401
50+
from gui.message import DisplayableError # noqa: F401
51+
52+
53+
addonDataManager: Optional["_DataManager"] = None
54+
55+
56+
def initialize():
57+
global addonDataManager
58+
if config.isAppX:
59+
log.info("Add-ons not supported when running as a Windows Store application")
60+
return
61+
log.debug("initializing addonStore data manager")
62+
addonDataManager = _DataManager()
63+
64+
65+
class _DataManager:
66+
_cacheLatestFilename: str = "_cachedLatestAddons.json"
67+
_cacheCompatibleFilename: str = "_cachedCompatibleAddons.json"
68+
_cachePeriod = timedelta(hours=6)
69+
70+
def __init__(self):
71+
self._shouldCacheToDisk = not (globalVars.appArgs.secure or globalVars.appArgs.launcher)
72+
cacheDirLocation = os.path.join(globalVars.appArgs.configPath, "addonStore")
73+
self._lang = languageHandler.getLanguage()
74+
self._preferredChannel = Channel.ALL
75+
self._cacheLatestFile = os.path.join(cacheDirLocation, _DataManager._cacheLatestFilename)
76+
self._cacheCompatibleFile = os.path.join(cacheDirLocation, _DataManager._cacheCompatibleFilename)
77+
self._addonDownloadCacheDir = os.path.join(cacheDirLocation, "_dl")
78+
self._installedAddonDataCacheDir = os.path.join(cacheDirLocation, "addons")
79+
# ensure caching dirs exist
80+
pathlib.Path(cacheDirLocation).mkdir(parents=True, exist_ok=True)
81+
pathlib.Path(self._addonDownloadCacheDir).mkdir(parents=True, exist_ok=True)
82+
pathlib.Path(self._installedAddonDataCacheDir).mkdir(parents=True, exist_ok=True)
83+
84+
self._latestAddonCache = self._getCachedAddonData(self._cacheLatestFile)
85+
self._compatibleAddonCache = self._getCachedAddonData(self._cacheCompatibleFile)
86+
self._installedAddonsCache = _InstalledAddonsCache()
87+
# Fetch available add-ons cache early
88+
threading.Thread(
89+
target=self.getLatestCompatibleAddons,
90+
name="initialiseAvailableAddons",
91+
).start()
92+
93+
def getFileDownloader(self) -> AddonFileDownloader:
94+
return AddonFileDownloader(self._addonDownloadCacheDir)
95+
96+
def _getLatestAddonsDataForVersion(self, apiVersion: str) -> Optional[bytes]:
97+
url = _getAddonStoreURL(self._preferredChannel, self._lang, apiVersion)
98+
try:
99+
response = requests.get(url)
100+
except requests.exceptions.RequestException as e:
101+
log.debugWarning(f"Unable to fetch addon data: {e}")
102+
return None
103+
if response.status_code != requests.codes.OK:
104+
log.error(
105+
f"Unable to get data from API ({url}),"
106+
f" response ({response.status_code}): {response.content}"
107+
)
108+
return None
109+
return response.content
110+
111+
def _cacheCompatibleAddons(self, addonData: str, fetchTime: datetime):
112+
if not self._shouldCacheToDisk:
113+
return
114+
if not addonData:
115+
return
116+
cacheData = {
117+
"cacheDate": fetchTime.isoformat(),
118+
"data": addonData,
119+
"cachedLanguage": self._lang,
120+
"nvdaAPIVersion": addonAPIVersion.CURRENT,
121+
}
122+
with open(self._cacheCompatibleFile, 'w') as cacheFile:
123+
json.dump(cacheData, cacheFile, ensure_ascii=False)
124+
125+
def _cacheLatestAddons(self, addonData: str, fetchTime: datetime):
126+
if not self._shouldCacheToDisk:
127+
return
128+
if not addonData:
129+
return
130+
cacheData = {
131+
"cacheDate": fetchTime.isoformat(),
132+
"data": addonData,
133+
"cachedLanguage": self._lang,
134+
"nvdaAPIVersion": _LATEST_API_VER,
135+
}
136+
with open(self._cacheLatestFile, 'w') as cacheFile:
137+
json.dump(cacheData, cacheFile, ensure_ascii=False)
138+
139+
def _getCachedAddonData(self, cacheFilePath: str) -> Optional[CachedAddonsModel]:
140+
if not os.path.exists(cacheFilePath):
141+
return None
142+
with open(cacheFilePath, 'r') as cacheFile:
143+
cacheData = json.load(cacheFile)
144+
if not cacheData:
145+
return None
146+
fetchTime = datetime.fromisoformat(cacheData["cacheDate"])
147+
return CachedAddonsModel(
148+
cachedAddonData=_createStoreCollectionFromJson(cacheData["data"]),
149+
cachedAt=fetchTime,
150+
cachedLanguage=cacheData["cachedLanguage"],
151+
nvdaAPIVersion=tuple(cacheData["nvdaAPIVersion"]), # loads as list
152+
)
153+
154+
# Translators: A title of the dialog shown when fetching add-on data from the store fails
155+
_updateFailureMessage = pgettext("addonStore", "Add-on data update failure")
156+
157+
def getLatestCompatibleAddons(
158+
self,
159+
onDisplayableError: Optional[DisplayableError.OnDisplayableErrorT] = None,
160+
) -> "AddonGUICollectionT":
161+
shouldRefreshData = (
162+
not self._compatibleAddonCache
163+
or self._compatibleAddonCache.nvdaAPIVersion != addonAPIVersion.CURRENT
164+
or _DataManager._cachePeriod < (datetime.now() - self._compatibleAddonCache.cachedAt)
165+
or self._compatibleAddonCache.cachedLanguage != self._lang
166+
)
167+
if shouldRefreshData:
168+
fetchTime = datetime.now()
169+
apiData = self._getLatestAddonsDataForVersion(_getCurrentApiVersionForURL())
170+
if apiData:
171+
decodedApiData = apiData.decode()
172+
self._cacheCompatibleAddons(
173+
addonData=decodedApiData,
174+
fetchTime=fetchTime,
175+
)
176+
self._compatibleAddonCache = CachedAddonsModel(
177+
cachedAddonData=_createStoreCollectionFromJson(decodedApiData),
178+
cachedAt=fetchTime,
179+
cachedLanguage=self._lang,
180+
nvdaAPIVersion=addonAPIVersion.CURRENT,
181+
)
182+
elif onDisplayableError is not None:
183+
from gui.message import DisplayableError
184+
displayableError = DisplayableError(
185+
# Translators: A message shown when fetching add-on data from the store fails
186+
pgettext("addonStore", "Unable to fetch latest add-on data for compatible add-ons."),
187+
self._updateFailureMessage,
188+
)
189+
callLater(delay=0, callable=onDisplayableError.notify, displayableError=displayableError)
190+
191+
if self._compatibleAddonCache is None:
192+
return _createAddonGUICollection()
193+
return self._compatibleAddonCache.cachedAddonData
194+
195+
def getLatestAddons(
196+
self,
197+
onDisplayableError: Optional[DisplayableError.OnDisplayableErrorT] = None,
198+
) -> "AddonGUICollectionT":
199+
shouldRefreshData = (
200+
not self._latestAddonCache
201+
or _DataManager._cachePeriod < (datetime.now() - self._latestAddonCache.cachedAt)
202+
or self._latestAddonCache.cachedLanguage != self._lang
203+
)
204+
if shouldRefreshData:
205+
fetchTime = datetime.now()
206+
apiData = self._getLatestAddonsDataForVersion(_LATEST_API_VER)
207+
if apiData:
208+
decodedApiData = apiData.decode()
209+
self._cacheLatestAddons(
210+
addonData=decodedApiData,
211+
fetchTime=fetchTime,
212+
)
213+
self._latestAddonCache = CachedAddonsModel(
214+
cachedAddonData=_createStoreCollectionFromJson(decodedApiData),
215+
cachedAt=fetchTime,
216+
cachedLanguage=self._lang,
217+
nvdaAPIVersion=_LATEST_API_VER,
218+
)
219+
elif onDisplayableError is not None:
220+
from gui.message import DisplayableError
221+
displayableError = DisplayableError(
222+
# Translators: A message shown when fetching add-on data from the store fails
223+
pgettext("addonStore", "Unable to fetch latest add-on data for incompatible add-ons."),
224+
self._updateFailureMessage
225+
)
226+
callLater(delay=0, callable=onDisplayableError.notify, displayableError=displayableError)
227+
228+
if self._latestAddonCache is None:
229+
return _createAddonGUICollection()
230+
return self._latestAddonCache.cachedAddonData
231+
232+
def _deleteCacheInstalledAddon(self, addonId: str):
233+
addonCachePath = os.path.join(self._installedAddonDataCacheDir, f"{addonId}.json")
234+
if pathlib.Path(addonCachePath).exists():
235+
os.remove(addonCachePath)
236+
237+
def _cacheInstalledAddon(self, addonData: AddonStoreModel):
238+
if not self._shouldCacheToDisk:
239+
return
240+
if not addonData:
241+
return
242+
addonCachePath = os.path.join(self._installedAddonDataCacheDir, f"{addonData.addonId}.json")
243+
with open(addonCachePath, 'w') as cacheFile:
244+
json.dump(addonData.asdict(), cacheFile, ensure_ascii=False)
245+
246+
def _getCachedInstalledAddonData(self, addonId: str) -> Optional[AddonStoreModel]:
247+
addonCachePath = os.path.join(self._installedAddonDataCacheDir, f"{addonId}.json")
248+
if not os.path.exists(addonCachePath):
249+
return None
250+
with open(addonCachePath, 'r') as cacheFile:
251+
cacheData = json.load(cacheFile)
252+
if not cacheData:
253+
return None
254+
return _createStoreModelFromData(cacheData)
255+
256+
257+
class _InstalledAddonsCache(AutoPropertyObject):
258+
cachePropertiesByDefault = True
259+
260+
installedAddons: CaseInsensitiveDict["AddonHandlerModel"]
261+
installedAddonGUICollection: "AddonGUICollectionT"
262+
263+
def _get_installedAddons(self) -> CaseInsensitiveDict["AddonHandlerModel"]:
264+
"""
265+
Add-ons that have the same ID except differ in casing cause a path collision,
266+
as add-on IDs are installed to a case insensitive path.
267+
Therefore addon IDs should be treated as case insensitive.
268+
"""
269+
from addonHandler import getAvailableAddons
270+
return CaseInsensitiveDict({a.name: a for a in getAvailableAddons()})
271+
272+
def _get_installedAddonGUICollection(self) -> "AddonGUICollectionT":
273+
addons = _createAddonGUICollection()
274+
for addonId in self.installedAddons:
275+
addonStoreData = self.installedAddons[addonId]._addonStoreData
276+
if addonStoreData:
277+
addons[addonStoreData.channel][addonId] = addonStoreData
278+
else:
279+
addons[Channel.STABLE][addonId] = self.installedAddons[addonId]._addonGuiModel
280+
return addons

0 commit comments

Comments
 (0)