Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add-on store #13985

Merged
merged 112 commits into from
Jun 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
112 commits
Select commit Hold shift + click to select a range
be23cf0
List addons in store
feerrenrut May 24, 2022
c17174d
Update data on background thread.
feerrenrut May 24, 2022
f51cd7c
Installation of Add-ons
feerrenrut Jun 8, 2022
a8d2b58
Show more accurate status for items in Addon store.
feerrenrut Jun 10, 2022
17824e4
Serialize installation step for add-on store
feerrenrut Jul 22, 2022
140a2e6
Handle missing addonStore dir
feerrenrut Aug 4, 2022
abb6f41
fix translator comments
feerrenrut Aug 4, 2022
d1677a4
Add note about download chunk size
feerrenrut Aug 8, 2022
e70f86b
Merge remote-tracking branch 'origin/master' into addonStore-base
feerrenrut Dec 9, 2022
296961d
Review actions
feerrenrut Dec 12, 2022
a4346a9
make widths more comfortable
feerrenrut Dec 12, 2022
067a834
fix link conversion on Windows 7
feerrenrut Dec 12, 2022
68df9c5
Add requests to requirements.txt explicitly
feerrenrut Dec 12, 2022
173572d
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd Feb 28, 2023
6ecdb0b
Add-on store: support for optional homepage and licenseURL (#14683)
seanbudd Mar 1, 2023
3ec6d46
create support for latest endpoint and dev channel (#14684)
seanbudd Mar 1, 2023
6e14a93
Addon store: Invalidate cache based on NVDA API Version (#14685)
seanbudd Mar 1, 2023
1d1693b
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd Apr 3, 2023
9f24148
Addon store: Handle lost selection when filtering (#14777)
seanbudd Apr 3, 2023
d56f5eb
Addon store: Implement updating add-ons (#14778)
seanbudd Apr 3, 2023
666efa5
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd Apr 4, 2023
3f4cf4d
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd Apr 4, 2023
811f278
Add-on store: bypass the compatibility checks for an add-on (#14797)
seanbudd Apr 18, 2023
ef72ee2
Add-on store: Group by add-on status, install from external source (#…
seanbudd Apr 18, 2023
3ecd094
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd Apr 18, 2023
7a8a653
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd Apr 19, 2023
2feb0ff
Add-on store: Add setting to view incompatible add-ons available to d…
seanbudd Apr 20, 2023
7b970fa
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd Apr 20, 2023
0c3a2d9
Add-on store: fix missing typing
seanbudd Apr 20, 2023
309cc8d
Add-on store: minor addonHandler fixup (#14845)
seanbudd Apr 20, 2023
5689f87
Add-on store: Notify user when download/fetch fails (#14846)
seanbudd Apr 20, 2023
587afd7
Add-on store: check SHA256 when downloading add-on (#14847)
seanbudd Apr 20, 2023
b00fec5
Add-on store: Fixup #14846
seanbudd Apr 21, 2023
31bdfae
Add-on store: add missing pgettext wrappings
seanbudd Apr 24, 2023
a50f52e
Add-on store: Disable add-ons manager (#14853)
seanbudd Apr 24, 2023
140908a
Add-on store: Display add-on incompatible reason (#14869)
seanbudd Apr 27, 2023
345e77e
Add-on store: Use close button instead of OK/Cancel (#14854)
seanbudd Apr 27, 2023
98a5988
Add-on Store: Add filter by channel (#14852)
seanbudd Apr 27, 2023
86017eb
Add-on store: Fixup translator comment
seanbudd Apr 27, 2023
539cd88
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd Apr 27, 2023
66a6b34
Add-on store: fix up references to add-ons manager
seanbudd Apr 28, 2023
1c7c312
Add User Guide documentation
seanbudd Apr 28, 2023
6439cbb
Add-on store: filter legacy add-ons (#14882)
seanbudd May 1, 2023
5b70155
Add-on store: make state pickling safer for downgrading (#14868)
seanbudd May 1, 2023
c70809c
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd May 1, 2023
8792796
Add-on store: Make add-on version contextual on views (#14891)
seanbudd May 1, 2023
f54fd1f
Add-on store: refactor and code cleanup
seanbudd May 2, 2023
e479e65
Add-on store: mark new code as private
seanbudd May 2, 2023
03567da
Add-on store: Minor fixups
seanbudd May 3, 2023
e7077b0
Merge branch 'addonStore-base' into addDocumentation
seanbudd May 3, 2023
ea847dd
Add-on Store: Update channel filter when changing status filter
seanbudd May 8, 2023
ac7e12f
Add-on store: Add accelerator key for add-ons list
seanbudd May 8, 2023
caf9b23
Add-on store: Fixup sorting - case/locale insensitive
seanbudd May 8, 2023
04c3363
Add-on store: Add accelerator key for other details
seanbudd May 8, 2023
66b8cda
Add-on store: Turn details links into buttons
seanbudd May 8, 2023
c3c3e3b
Add-on store: Show help for installed views only
seanbudd May 8, 2023
90a8933
Add-on store: Re-focus when opening add-on store dialog twice
seanbudd May 8, 2023
ade51ed
Add-on store: progress dialog for installing add-ons
seanbudd May 8, 2023
5eabc23
Add-on store: Hide disable button for removed addons
seanbudd May 8, 2023
bde9a69
Add-on store: Toggle accelerator key for action buttons with visibility
seanbudd May 8, 2023
ec016eb
Add-on store: Prioritise pending remove
seanbudd May 8, 2023
ec6526c
Add-on store: Use notebook tabs for status filters
seanbudd May 9, 2023
e99ca1e
Add-on store: maximize dialog
seanbudd May 9, 2023
438cfa6
Add-on store: Fix-up tab paging
seanbudd May 10, 2023
eb44aab
Add-on store: Include status tab in dialog title
seanbudd May 10, 2023
249e638
Add-on store: Show links as plain text
seanbudd May 10, 2023
d185497
Add-on store: fixup requires restart state
seanbudd May 15, 2023
54c87b6
Merge branch 'addDocumentation' into addonStore-base
seanbudd May 15, 2023
046837b
Add-on store: fix context menu
seanbudd May 16, 2023
7ee93f0
Add-on store: 'pending removal' string fix
seanbudd May 16, 2023
dbbef29
Add-on store: accelerate key for homepage
seanbudd May 16, 2023
975ebc1
Add-on store: encourage users to manually focus status control
seanbudd May 16, 2023
3811d38
Add-on store: add external channel
seanbudd May 16, 2023
c39c8ff
Add-on store: update status when removing add-ons
seanbudd May 16, 2023
19a28c7
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd May 16, 2023
5a737a3
Add-on store: reset selection when changing views
seanbudd May 16, 2023
729c29c
Add-on store: fix up install paths
seanbudd May 17, 2023
0b0c6bd
Add-on store: create incompatible tab to replace disabled tab
seanbudd May 18, 2023
64dd10c
Add-on store: improve visual column widths
seanbudd May 18, 2023
4374cca
Add-on store: enable minimize button for resizable settings dialogs
seanbudd May 18, 2023
bbc7257
Add-on store: Improve installed add-on caching
seanbudd May 18, 2023
f22a54c
Add-on store: Filter by disable/enable
seanbudd May 18, 2023
b4223e9
Add-on store: display available add-ons if no installed add-ons
seanbudd May 18, 2023
764abb1
Add-on store: open context menu from enter on selected add-on
seanbudd May 18, 2023
ac0e5d5
Add-on store: add keys for column sorting
seanbudd May 18, 2023
6da7724
Add-on store: Add more accelerator keys
seanbudd May 23, 2023
00c8415
Add-on store: turn action buttons into context menu
seanbudd May 25, 2023
ba6396e
Add-on store: set default channel as stable for updatable add-ons
seanbudd May 25, 2023
50be870
Add-on store: Have 2 version fields: installed & available
seanbudd May 25, 2023
5b36e30
Add-on store: Move incompatible add-ons filter from settings dialog
seanbudd May 26, 2023
f75845d
Add-on store: Improve loading experience for available add-ons
seanbudd May 26, 2023
b1cccd4
Add-on store: fetch and cache using NVDA language
seanbudd May 26, 2023
ed096ef
Add-on store: Add 'All' option for enabled filter
seanbudd May 26, 2023
204ccb6
Add-on store: fix caching
seanbudd May 29, 2023
9e4152b
Add-on store: fix various install issues
seanbudd May 29, 2023
a0d2b2a
Add-on store: improve installed status checks for actions
seanbudd May 30, 2023
7009a95
Add-on store: focus list on tab page change
seanbudd May 30, 2023
9110c92
Add-on store: move search control to it's own line
seanbudd May 30, 2023
69d8f8f
Add-on store: fixup translation comments
seanbudd May 30, 2023
e6eb1f2
Add-on store: improve documentation and context help
seanbudd May 30, 2023
0b8f8e3
Add-on store: don't delete cache during upgrade
seanbudd May 30, 2023
35c8838
Add-on store: documentation fixups
seanbudd May 30, 2023
1aca3f7
Add-on store: filter remove add-ons to installed views
seanbudd May 31, 2023
81d1748
Add-on store: filter remove add-ons to installed views
seanbudd May 31, 2023
643fa00
Add-on store: address review feedback
seanbudd Jun 1, 2023
1849e1a
Add-on store: fix up docs
seanbudd Jun 1, 2023
becccf5
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd Jun 1, 2023
62986cd
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd Jun 1, 2023
60581ac
Add-on store: Improve disk management for launcher and secure copies …
seanbudd Jun 2, 2023
094085e
Merge remote-tracking branch 'origin/master' into addonStore-base
seanbudd Jun 2, 2023
36a95d9
Add-on store: update user guide
seanbudd Jun 5, 2023
4cb5654
fix spelling
seanbudd Jun 5, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions devDocs/developerGuide.t2t
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions source/NVDAState.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions source/_addonStore/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
280 changes: 280 additions & 0 deletions source/_addonStore/dataManager.py
Original file line number Diff line number Diff line change
@@ -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
Loading