From d7facd1c80565909ec4bef658974d940eacf74bf Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 12 Jun 2024 11:37:41 +1000 Subject: [PATCH] Notifying users of available add-on updates (#16636) Closes #15035 Summary of the issue: Users would like a push notification on NVDA start-up letting them know if add-ons have available updates Description of user facing changes When starting NVDA, a dialog will appear notifying users of updatable add-ons if there are available updates. This will only notify users if the available update is in the same channel as the installed add-on. There is an "update all" button on the dialog and a list of add-ons with updates available. There is also an "Open Add-on Store" button to open the add-on store to the updatable add-ons tab. A setting to disable this. This is a combobox so that in future users can decide between: notify on updates (default) automatic update (See Ability to automatically update add-ons #3208) disabled Description of development approach Created a scheduling module to schedule tasks on NVDA start up, using the schedule pip module. This is so we can have conflict free scheduling. e.g. so NVDA's update notification won't clash with the add-on update notification. Uses their suggested code to run a background thread for task scheduling: schedule.readthedocs.io/en/stable/background-execution.html Changed much of the Add-on Store downloading code to be classmethods. This allows us to use it from the message dialog without creating a store instance. --- requirements.txt | 5 +- source/addonStore/dataManager.py | 20 +- source/config/__init__.py | 13 +- source/config/configFlags.py | 20 +- source/config/configSpec.py | 3 +- source/core.py | 5 + source/gui/__init__.py | 17 +- source/gui/addonStoreGui/__init__.py | 13 +- .../addonStoreGui/controls/messageDialogs.py | 215 ++++++++++++++++- .../gui/addonStoreGui/controls/storeDialog.py | 17 +- source/gui/addonStoreGui/viewModels/store.py | 75 +++--- source/gui/settingsDialogs.py | 27 ++- source/utils/schedule.py | 218 ++++++++++++++++++ tests/manual/addonStore.md | 126 +++++++--- tests/manual/createUpdatableAddons.ps1 | 40 ++++ tests/unit/test_util/test_schedule.py | 141 +++++++++++ user_docs/en/changes.md | 10 +- user_docs/en/userGuide.md | 27 +++ 18 files changed, 891 insertions(+), 101 deletions(-) create mode 100644 source/utils/schedule.py create mode 100644 tests/manual/createUpdatableAddons.ps1 create mode 100644 tests/unit/test_util/test_schedule.py diff --git a/requirements.txt b/requirements.txt index 4bd39cd80f1..09bbf93325c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ pyserial==3.5 ./miscDeps/python/wxPython-4.2.2a1-cp311-cp311-win32.whl git+https://github.com/DiffSK/configobj@e2ba4457c4651fa54f8d59d8dcdd3da950e956b8#egg=configobj requests==2.32.0 +schedule==1.2.1 # Pillow is an implicit dependency and requires zlib and jpeg by default, but we don't need it Pillow==10.3.0 -C "zlib=disable" -C "jpeg=disable" @@ -14,10 +15,10 @@ Pillow==10.3.0 -C "zlib=disable" -C "jpeg=disable" fast-diff-match-patch==2.1.0 # typing_extensions are required for specifying default value for `TypeVar`, which is not yet possible with any released version of Python (see PEP 696) -typing-extensions==4.9.0 +typing-extensions==4.9.0 # pycaw is a Core Audio Windows Library used for sound split -pycaw==20240210 +pycaw==20240210 # Packaging NVDA git+https://github.com/py2exe/py2exe@4e7b2b2c60face592e67cb1bc935172a20fa371d#egg=py2exe diff --git a/source/addonStore/dataManager.py b/source/addonStore/dataManager.py index e234c9d6541..ced86a4bcf5 100644 --- a/source/addonStore/dataManager.py +++ b/source/addonStore/dataManager.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022-2023 NV Access Limited +# Copyright (C) 2022-2024 NV Access Limited # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -37,6 +37,7 @@ _createStoreCollectionFromJson, ) from .models.channel import Channel +from .models.status import AvailableAddonStatus, getStatus, _StatusFilterKey from .network import ( _getCurrentApiVersionForURL, _getAddonStoreURL, @@ -47,7 +48,7 @@ if TYPE_CHECKING: from addonHandler import Addon as AddonHandlerModel # noqa: F401 # AddonGUICollectionT must only be imported when TYPE_CHECKING - from .models.addon import AddonGUICollectionT, _AddonStoreModel # noqa: F401 + from .models.addon import AddonGUICollectionT, _AddonGUIModel, _AddonStoreModel # noqa: F401 from gui.addonStoreGui.viewModels.addonList import AddonListItemVM # noqa: F401 from gui.message import DisplayableError # noqa: F401 @@ -308,6 +309,21 @@ def _getCachedInstalledAddonData(self, addonId: str) -> Optional[InstalledAddonS return None return _createInstalledStoreModelFromData(cacheData) + def _addonsPendingUpdate(self) -> list["_AddonGUIModel"]: + addonsPendingUpdate: list["_AddonGUIModel"] = [] + compatibleAddons = self.getLatestCompatibleAddons() + for channel in compatibleAddons: + for addon in compatibleAddons[channel].values(): + if ( + getStatus(addon, _StatusFilterKey.UPDATE) == AvailableAddonStatus.UPDATE + # Only consider add-ons that have been installed through the Add-on Store + and addon._addonHandlerModel._addonStoreData is not None + ): + # Only consider add-on updates for the same channel + if addon.channel == addon._addonHandlerModel._addonStoreData.channel: + addonsPendingUpdate.append(addon) + return addonsPendingUpdate + class _InstalledAddonsCache(AutoPropertyObject): cachePropertiesByDefault = True diff --git a/source/config/__init__.py b/source/config/__init__.py index 9abeed7adbc..33a76b3363b 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -506,12 +506,13 @@ class ConfigManager(object): #: Sections that only apply to the base configuration; #: i.e. they cannot be overridden in profiles. - BASE_ONLY_SECTIONS = { - "general", - "update", - "upgrade", - "development", -} + BASE_ONLY_SECTIONS = frozenset({ + "general", + "update", + "upgrade", + "development", + "addonStore", + }) def __init__(self): self.spec = confspec diff --git a/source/config/configFlags.py b/source/config/configFlags.py index 0b6d858ea7c..58fdda21994 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022-2023 NV Access Limited, Cyrille Bougot +# Copyright (C) 2022-2024 NV Access Limited, Cyrille Bougot # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -202,3 +202,21 @@ def _displayStringLabels(self): # document formatting settings panel. ReportCellBorders.COLOR_AND_STYLE: _("Both Colors and Styles"), } + + +class AddonsAutomaticUpdate(DisplayStringStrEnum): + NOTIFY = "notify" + # TODO: uncomment when implementing #3208 + # UPDATE = "update" + DISABLED = "disabled" + + @property + def _displayStringLabels(self): + return { + # Translators: This is a label for the automatic update behaviour for add-ons. + # It will notify the user when updates are available. + self.NOTIFY: _("Notify"), + # self.UPDATE: _("Update Automatically"), + # Translators: This is a label for the automatic update behaviour for add-ons. + self.DISABLED: _("Disabled"), + } diff --git a/source/config/configSpec.py b/source/config/configSpec.py index e60c3e7e990..17f8aa69ada 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2023 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt, +# Copyright (C) 2006-2024 NV Access Limited, Babbage B.V., Davy Kager, Bill Dengler, Julien Cochuyt, # Joseph Lee, Dawid Pieper, mltony, Bram Duvigneau, Cyrille Bougot, Rob Meredith, # Burman's Computer and Education Ltd., Leonard de Ruijter, Łukasz Golonka # This file is covered by the GNU General Public License. @@ -325,6 +325,7 @@ [addonStore] showWarning = boolean(default=true) + automaticUpdates = option("notify", "disabled", default="notify") """ #: The configuration specification diff --git a/source/core.py b/source/core.py index b78555f8057..0ababdd32ca 100644 --- a/source/core.py +++ b/source/core.py @@ -661,6 +661,8 @@ def main(): log.info(f"Windows version: {winVersion.getWinVer()}") log.info("Using Python version %s"%sys.version) log.info("Using comtypes version %s"%comtypes.__version__) + from utils import schedule + schedule.initialize() import configobj log.info("Using configobj version %s with validate version %s"%(configobj.__version__,configobj.validate.__version__)) # Set a reasonable timeout for any socket connections NVDA makes. @@ -670,6 +672,8 @@ def main(): from addonStore import dataManager dataManager.initialize() addonHandler.initialize() + from gui import addonStoreGui + addonStoreGui.initialize() if globalVars.appArgs.disableAddons: log.info("Add-ons are disabled. Restart NVDA to enable them.") import appModuleHandler @@ -959,6 +963,7 @@ def _doPostNvdaStartupAction(): _terminate(addonHandler) _terminate(dataManager, name="addon dataManager") _terminate(garbageHandler) + _terminate(schedule, name="task scheduler") # DMP is only started if needed. # Terminate manually (and let it write to the log if necessary) # as core._terminate always writes an entry. diff --git a/source/gui/__init__.py b/source/gui/__init__.py index 67d11420154..c2264bfaf97 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -1,6 +1,6 @@ # -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2023 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Mesar Hameed, Joseph Lee, +# Copyright (C) 2006-2024 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Mesar Hameed, Joseph Lee, # Thomas Stivers, Babbage B.V., Accessolutions, Julien Cochuyt, Cyrille Bougot, Luke Davis # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -408,6 +408,21 @@ def onAddonStoreCommand(self, evt: wx.MenuEvent): _storeVM.refresh() self.popupSettingsDialog(AddonStoreDialog, _storeVM) + @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 onAddonStoreUpdatableCommand(self, evt: wx.MenuEvent | None): + from .addonStoreGui import AddonStoreDialog + from .addonStoreGui.viewModels.store import AddonStoreVM + from addonStore.models.status import _StatusFilterKey + _storeVM = AddonStoreVM() + _storeVM.refresh() + self.popupSettingsDialog(AddonStoreDialog, _storeVM, openToTab=_StatusFilterKey.UPDATE) + def onReloadPluginsCommand(self, evt): import appModuleHandler, globalPluginHandler from NVDAObjects import NVDAObject diff --git a/source/gui/addonStoreGui/__init__.py b/source/gui/addonStoreGui/__init__.py index 578fa7a0487..d85ba5c1a99 100644 --- a/source/gui/addonStoreGui/__init__.py +++ b/source/gui/addonStoreGui/__init__.py @@ -1,10 +1,21 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022 NV Access Limited +# Copyright (C) 2022-2024 NV Access Limited # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from utils.schedule import scheduleThread, ThreadTarget + from .controls.storeDialog import AddonStoreDialog +from .controls.messageDialogs import UpdatableAddonsDialog __all__ = [ "AddonStoreDialog", + "initialize", ] + + +def initialize(): + scheduleThread.scheduleDailyJobAtStartUp( + UpdatableAddonsDialog._checkForUpdatableAddons, + queueToThread=ThreadTarget.GUI, + ) diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index 803c9a43b45..12c42792d0a 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -15,8 +15,15 @@ _AddonStoreModel, _AddonManifestModel, ) +from addonStore.dataManager import addonDataManager +from addonStore.models.status import AvailableAddonStatus import config -from gui.addonGui import ConfirmAddonInstallDialog, ErrorAddonInstallDialog +from config.configFlags import AddonsAutomaticUpdate +import globalVars +import gui +from gui import nvdaControls +from gui.addonGui import ConfirmAddonInstallDialog, ErrorAddonInstallDialog, promptUserForRestart +from gui.addonStoreGui.viewModels.addonList import AddonListItemVM from gui.contextHelp import ContextHelpMixin from gui.guiHelper import ( BoxSizerHelper, @@ -24,7 +31,9 @@ ButtonHelper, SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS, ) -from gui.message import displayDialogAsModal, messageBox +from gui.message import DisplayableError, displayDialogAsModal, messageBox +from logHandler import log +import ui import windowUtils if TYPE_CHECKING: @@ -244,7 +253,7 @@ def _showConfirmAddonInstallDialog( return displayDialogAsModal(ConfirmAddonInstallDialog( parent=parent, # Translators: Title for message asking if the user really wishes to install an Addon. - title=_("Add-on Installation"), + title=pgettext("addonStore", "Add-on Installation"), message=confirmInstallMessage, showAddonInfoFunction=lambda: _showAddonInfo(addon) )) @@ -348,3 +357,203 @@ def __init__(self, parent: wx.Window): def onOkButton(self, evt: wx.CommandEvent): config.conf["addonStore"]["showWarning"] = not self.dontShowAgainCheckbox.GetValue() self.EndModal(wx.ID_OK) + + +class UpdatableAddonsDialog( + ContextHelpMixin, + wx.Dialog # wxPython does not seem to call base class initializer, put last in MRO +): + """A dialog notifying users that updatable add-ons are available""" + + helpId = "AutomaticAddonUpdates" + + def __init__(self, parent: wx.Window, addonsPendingUpdate: list[_AddonGUIModel]): + # Translators: The warning of a dialog + super().__init__(parent, title=pgettext("addonStore", "Add-on updates available")) + self.addonsPendingUpdate = addonsPendingUpdate + self.onDisplayableError = DisplayableError.OnDisplayableErrorT() + self._setupUI() + self.Raise() + self.SetFocus() + + def _setupUI(self): + self.Bind(wx.EVT_CLOSE, self.onClose) + self.Bind(wx.EVT_CHAR_HOOK, self.onCharHook) + mainSizer = wx.BoxSizer(wx.VERTICAL) + sHelper = BoxSizerHelper(self, orientation=wx.VERTICAL) + self._setupMessage(sHelper) + self._createAddonsPanel(sHelper) + self._setupButtons(sHelper) + mainSizer.Add(sHelper.sizer, border=BORDER_FOR_DIALOGS, flag=wx.ALL) + self.Sizer = mainSizer + mainSizer.Fit(self) + self.CentreOnScreen() + + def onCharHook(self, evt: wx.KeyEvent): + if evt.KeyCode == wx.WXK_ESCAPE: + self.Close() + evt.Skip() + + def _setupMessage(self, sHelper: BoxSizerHelper): + _message = pgettext( + "addonStore", + # Translators: Message displayed when updates are available for some installed add-ons. + "Updates are available for some of your installed add-ons. " + ) + + sText = sHelper.addItem(wx.StaticText(self, label=_message)) + # the wx.Window must be constructed before we can get the handle. + self.scaleFactor = windowUtils.getWindowScalingFactor(self.GetHandle()) + # 600 was fairly arbitrarily chosen by a visual user to look acceptable on their machine. + sText.Wrap(self.scaleFactor * 600) + + def _setupButtons(self, sHelper: BoxSizerHelper): + bHelper = sHelper.addDialogDismissButtons(ButtonHelper(wx.HORIZONTAL)) + + # Translators: The label of a button in a dialog + openStoreLabel = pgettext("addonStore", "Open Add-on &Store") + self.openStoreButton = bHelper.addButton(self, wx.ID_CLOSE, label=openStoreLabel) + self.openStoreButton.Bind(wx.EVT_BUTTON, self.onOpenStoreButton) + + # Translators: The label of a button in a dialog + self.updateAllButton = bHelper.addButton(self, wx.ID_CLOSE, label=pgettext("addonStore", "&Update all")) + self.updateAllButton.Bind(wx.EVT_BUTTON, self.onUpdateAllButton) + + # Translators: The label of a button in a dialog + closeButton = bHelper.addButton(self, wx.ID_CLOSE, label=pgettext("addonStore", "&Close")) + closeButton.Bind(wx.EVT_BUTTON, self.onCloseButton) + + def _createAddonsPanel(self, sHelper: BoxSizerHelper): + # Translators: the label for the addons list in the updatable addons dialog. + entriesLabel = pgettext("addonStore", "Updatable Add-ons") + self.addonsList = sHelper.addLabeledControl( + entriesLabel, + nvdaControls.AutoWidthColumnListCtrl, + style=wx.LC_REPORT | wx.LC_SINGLE_SEL, + ) + + # Translators: Label for an extra detail field for an add-on. In the add-on store UX. + nameLabel = pgettext("addonStore", "Name") + # Translators: Label for an extra detail field for an add-on. In the add-on store UX. + installedVersionLabel = pgettext("addonStore", "Installed version") + # Translators: Label for an extra detail field for an add-on. In the add-on store UX. + availableVersionLabel = pgettext("addonStore", "Available version") + # Translators: Label for an extra detail field for an add-on. In the add-on store UX. + channelLabel = pgettext("addonStore", "Channel") + # Translators: Label for an extra detail field for an add-on. In the add-on store UX. + statusLabel = pgettext("addonStore", "Status") + + self.addonsList.AppendColumn(nameLabel, width=300) + self.addonsList.AppendColumn(installedVersionLabel, width=200) + self.addonsList.AppendColumn(availableVersionLabel, width=200) + self.addonsList.AppendColumn(channelLabel, width=150) + self.addonsList.AppendColumn(statusLabel, width=300) + for addon in self.addonsPendingUpdate: + self.addonsList.Append(( + addon.displayName, + addon._addonHandlerModel.version, + addon.addonVersionName, + addon.channel.displayString, + AvailableAddonStatus.UPDATE.displayString, + )) + self.addonsList.Refresh() + + def onOpenStoreButton(self, evt: wx.CommandEvent): + """Open the Add-on Store to update add-ons""" + # call later so current dialog is dismissed and doesn't block the store from opening + wx.CallLater(100, gui.mainFrame.onAddonStoreUpdatableCommand, None) + self.EndModal(wx.ID_CLOSE) + + def onUpdateAllButton(self, evt: wx.CommandEvent): + from gui.addonStoreGui.viewModels.store import AddonStoreVM + self.listItemVMs: list[AddonListItemVM] = [] + for addon in self.addonsPendingUpdate: + listItemVM = AddonListItemVM(addon, status=AvailableAddonStatus.UPDATE) + listItemVM.updated.register(self._statusUpdate) + self.listItemVMs.append(listItemVM) + AddonStoreVM.getAddons(self.listItemVMs) + self.addonsList.Refresh() + # Translators: Message shown when updating add-ons in the updatable add-ons dialog + ui.message(pgettext("addonStore", "Updating add-ons...")) + self.updateAllButton.Disable() + self.openStoreButton.Disable() + self.addonsList.SetFocus() + self.addonsList.Focus(0) + + def _statusUpdate(self, addonListItemVM: AddonListItemVM): + log.debug(f"{addonListItemVM.Id} status: {addonListItemVM.status}") + index = self.listItemVMs.index(addonListItemVM) + self.addonsList.SetItem(index, column=4, label=addonListItemVM.status.displayString) + + def onCloseButton(self, evt: wx.CommandEvent): + self.Close() + + def onClose(self, evt: wx.CloseEvent): + from gui.addonStoreGui.viewModels.store import AddonStoreVM + from .storeDialog import AddonStoreDialog + evt.Veto() + + numInProgress = len(AddonStoreVM._downloader.progress) + if numInProgress: + res = gui.messageBox( + npgettext( + "addonStore", + # 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 + "Download of {} add-on in progress, cancel downloading?", + "Download of {} add-ons in progress, cancel downloading?", + numInProgress, + ).format(numInProgress), + AddonStoreDialog._installationPromptTitle, + style=wx.YES_NO + ) + if res == wx.YES: + log.debug("Cancelling the download.") + AddonStoreVM.cancelDownloads() + # Continue to installation if any downloads completed + else: + # Let the user return to the dialog and inspect add-ons being downloaded. + return + + if addonDataManager._downloadsPendingInstall: + nAddonsPendingInstall = len(addonDataManager._downloadsPendingInstall) + installingDialog = gui.IndeterminateProgressDialog( + self, + AddonStoreDialog._installationPromptTitle, + npgettext( + "addonStore", + # 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 + "Installing {} add-on, please wait.", + "Installing {} add-ons, please wait.", + nAddonsPendingInstall, + ).format(nAddonsPendingInstall) + ) + AddonStoreVM.installPending() + + def postInstall(): + installingDialog.done() + # let the dialog exit. + self.DestroyLater() + self.SetReturnCode(wx.ID_CLOSE) + wx.CallLater(500, promptUserForRestart) + + return wx.CallAfter(postInstall) + + # let the dialog exit. + self.DestroyLater() + self.SetReturnCode(wx.ID_CLOSE) + + @classmethod + def _checkForUpdatableAddons(cls): + if ( + globalVars.appArgs.secure + or (AddonsAutomaticUpdate.DISABLED == config.conf["addonStore"]["automaticUpdates"]) + ): + log.debug("automatic add-on updates are disabled") + return + log.debug("checking for updatable add-ons") + addonsPendingUpdate = addonDataManager._addonsPendingUpdate() + if addonsPendingUpdate: + log.debug("updatable add-ons found") + displayDialogAsModal(cls(gui.mainFrame, addonsPendingUpdate)) diff --git a/source/gui/addonStoreGui/controls/storeDialog.py b/source/gui/addonStoreGui/controls/storeDialog.py index cb60c2b622a..ad5d5f538b3 100644 --- a/source/gui/addonStoreGui/controls/storeDialog.py +++ b/source/gui/addonStoreGui/controls/storeDialog.py @@ -47,10 +47,11 @@ class AddonStoreDialog(SettingsDialog): # point to the Add-on Store one. helpId = "AddonsManager" - def __init__(self, parent: wx.Window, storeVM: AddonStoreVM): + def __init__(self, parent: wx.Window, storeVM: AddonStoreVM, openToTab: _StatusFilterKey | None = None): self._storeVM = storeVM self._storeVM.onDisplayableError.register(self.handleDisplayableError) self._actionsContextMenu = _MonoActionsContextMenu(self._storeVM) + self.openToTab = openToTab super().__init__(parent, resizeable=True, buttons={wx.CLOSE}) if config.conf["addonStore"]["showWarning"]: displayDialogAsModal(_SafetyWarningDialog(parent)) @@ -78,12 +79,14 @@ def makeSettings(self, settingsSizer: wx.BoxSizer): 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) + openToTab = self.openToTab + if openToTab is None: + if any(self._storeVM._installedAddons[channel] for channel in self._storeVM._installedAddons): + openToTab = _StatusFilterKey.INSTALLED + else: + openToTab = _StatusFilterKey.AVAILABLE + openToTabIndex = list(_statusFilters.keys()).index(openToTab) + self.addonListTabs.SetSelection(openToTabIndex) self.addonListTabs.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGED, self.onListTabPageChange, self.addonListTabs) filterCtrlHelper = guiHelper.BoxSizerHelper(self, wx.VERTICAL) diff --git a/source/gui/addonStoreGui/viewModels/store.py b/source/gui/addonStoreGui/viewModels/store.py index 5568db328f5..6b6bd735884 100644 --- a/source/gui/addonStoreGui/viewModels/store.py +++ b/source/gui/addonStoreGui/viewModels/store.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022-2023 NV Access Limited, Cyrille Bougot +# Copyright (C) 2022-2024 NV Access Limited, Cyrille Bougot # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -58,21 +58,24 @@ class AddonStoreVM: + 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 + """ + + _downloader = AddonFileDownloader() + def __init__(self): assert addonDataManager 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. @@ -89,8 +92,6 @@ def __init__(self): """ self._filterIncludeIncompatible: bool = False - self._downloader = AddonFileDownloader() - self.listVM: AddonListVM = AddonListVM( addons=self._createListItemVMs(), storeVM=self, @@ -300,9 +301,10 @@ def removeAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) useRememberChoiceCheckbox=True, ) + @classmethod def installOverrideIncompatibilityForAddon( - self, - listItemVM: AddonListItemVM, + cls, + listItemVM: AddonListItemVM[_AddonStoreModel], askConfirmation: bool = True, useRememberChoiceCheckbox: bool = False, ) -> tuple[bool, bool]: @@ -318,7 +320,7 @@ def installOverrideIncompatibilityForAddon( shouldRememberChoice = True if shouldInstall: listItemVM.model.enableCompatibilityOverride() - self.getAddon(listItemVM) + cls.getAddon(listItemVM) return shouldInstall, shouldRememberChoice _enableErrorMessage: str = pgettext( @@ -412,8 +414,9 @@ def disableAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]] else: self.disableAddon(aVM) + @classmethod def replaceAddon( - self, + cls, listItemVM: AddonListItemVM, askConfirmation: bool = True, useRememberChoiceCheckbox: bool = False, @@ -430,7 +433,7 @@ def replaceAddon( shouldReplace = True shouldRememberChoice = True if shouldReplace: - self.getAddon(listItemVM) + cls.getAddon(listItemVM) return shouldReplace, shouldRememberChoice def replaceAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: @@ -454,31 +457,33 @@ def replaceAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]] useRememberChoiceCheckbox=True, ) - def getAddon(self, listItemVM: AddonListItemVM[_AddonStoreModel]) -> None: + @classmethod + def getAddon(cls, listItemVM: AddonListItemVM[_AddonStoreModel]) -> None: assert addonDataManager addonDataManager._downloadsPendingCompletion.add(listItemVM) listItemVM.status = AvailableAddonStatus.DOWNLOADING log.debug(f"{listItemVM.Id} status: {listItemVM.status}") - self._downloader.download(listItemVM, self._downloadComplete, self.onDisplayableError) + cls._downloader.download(listItemVM, cls._downloadComplete, cls.onDisplayableError) - def getAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: + @classmethod + def getAddons(cls, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: shouldReplace = True shouldInstallIncompatible = True shouldRememberReplaceChoice = False shouldRememberInstallChoice = False for aVM in listItemVMs: if aVM.canUseInstallAction() or aVM.canUseUpdateAction(): - self.getAddon(aVM) + cls.getAddon(aVM) elif aVM.canUseReplaceAction(): if shouldRememberReplaceChoice: if shouldReplace: - self.replaceAddon(aVM, askConfirmation=False) + cls.replaceAddon(aVM, askConfirmation=False) else: log.debug( f"Skipping {aVM.Id} as replacement has been previously declined for all remaining add-ons." ) else: - shouldReplace, shouldRememberReplaceChoice = self.replaceAddon( + shouldReplace, shouldRememberReplaceChoice = cls.replaceAddon( aVM, askConfirmation=True, useRememberChoiceCheckbox=True, @@ -486,14 +491,14 @@ def getAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> elif not aVM.model.isCompatible and aVM.model.canOverrideCompatibility: if shouldRememberInstallChoice: if shouldInstallIncompatible: - self.installOverrideIncompatibilityForAddon(aVM, askConfirmation=False) + cls.installOverrideIncompatibilityForAddon(aVM, askConfirmation=False) else: log.debug( f"Skipping {aVM.Id} as override incompatibility has been previously declined for all remaining" " add-ons." ) else: - shouldInstallIncompatible, shouldRememberInstallChoice = self.installOverrideIncompatibilityForAddon( + shouldInstallIncompatible, shouldRememberInstallChoice = cls.installOverrideIncompatibilityForAddon( aVM, askConfirmation=True, useRememberChoiceCheckbox=True @@ -501,8 +506,9 @@ def getAddons(self, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> else: log.debug(f"Skipping {aVM.Id} ({aVM.status}) as it is not available or updatable") + @classmethod def _downloadComplete( - self, + cls, listItemVM: AddonListItemVM[_AddonStoreModel], fileDownloaded: Optional[PathLike] ): @@ -523,15 +529,17 @@ def _downloadComplete( assert addonDataManager addonDataManager._downloadsPendingInstall.add((listItemVM, fileDownloaded)) - def installPending(self): + @classmethod + def installPending(cls): 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.") while addonDataManager._downloadsPendingInstall: listItemVM, fileDownloaded = addonDataManager._downloadsPendingInstall.pop() - self._doInstall(listItemVM, fileDownloaded) + cls._doInstall(listItemVM, fileDownloaded) - def _doInstall(self, listItemVM: AddonListItemVM, fileDownloaded: PathLike): + @classmethod + def _doInstall(cls, 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.") @@ -543,7 +551,7 @@ def _doInstall(self, listItemVM: AddonListItemVM, fileDownloaded: PathLike): 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) + core.callLater(delay=0, callable=cls.onDisplayableError.notify, displayableError=displayableError) return listItemVM.status = AvailableAddonStatus.INSTALLED addonDataManager._cacheInstalledAddon(listItemVM.model) @@ -604,11 +612,12 @@ def _getAvailableAddonsInBG(self): core.callLater(delay=0, callable=self.detailsVM.updated.notify, addonDetailsVM=self.detailsVM) log.debug("completed refresh") - def cancelDownloads(self): + @classmethod + def cancelDownloads(cls): while addonDataManager._downloadsPendingCompletion: listItem = addonDataManager._downloadsPendingCompletion.pop() listItem.status = AvailableAddonStatus.AVAILABLE - self._downloader.cancelAll() + cls._downloader.cancelAll() def _filterByEnabledKey(self, model: _AddonGUIModel) -> bool: if EnabledStatus.ALL == self._filterEnabledDisabled: diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 94ad8748f11..a30d0934406 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -27,6 +27,7 @@ from synthDriverHandler import changeVoice, getSynth, getSynthList, setSynth, SynthDriver import config from config.configFlags import ( + AddonsAutomaticUpdate, NVDAKey, ShowMessages, TetherTo, @@ -2897,11 +2898,23 @@ class AddonStorePanel(SettingsPanel): helpId = "AddonStoreSettings" def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: - # sHelper = guiHelper.BoxSizerHelper(self, sizer=settingsSizer) - pass + sHelper = guiHelper.BoxSizerHelper(self, sizer=settingsSizer) + # Translators: This is a label for the automatic updates combo box in the Add-on Store Settings dialog. + automaticUpdatesLabelText = _("&Update notifications:") + # TODO: change label to the following when the feature is implemented + # automaticUpdatesLabelText = _("Automatic &updates:") + self.automaticUpdatesComboBox = sHelper.addLabeledControl( + automaticUpdatesLabelText, + wx.Choice, + choices=[mode.displayString for mode in AddonsAutomaticUpdate] + ) + self.bindHelpEvent("AutomaticAddonUpdates", self.automaticUpdatesComboBox) + index = [x.value for x in AddonsAutomaticUpdate].index(config.conf["addonStore"]["automaticUpdates"]) + self.automaticUpdatesComboBox.SetSelection(index) def onSave(self): - pass + index = self.automaticUpdatesComboBox.GetSelection() + config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AddonsAutomaticUpdate][index] class TouchInteractionPanel(SettingsPanel): @@ -4662,7 +4675,7 @@ class NVDASettingsDialog(MultiCategorySettingsDialog): BrowseModePanel, DocumentFormattingPanel, DocumentNavigationPanel, - # AddonStorePanel, currently empty + AddonStorePanel, ] if touchHandler.touchSupported(): categoryClasses.append(TouchInteractionPanel) @@ -4682,7 +4695,11 @@ def makeSettings(self, settingsSizer): def _doOnCategoryChange(self): global NvdaSettingsDialogActiveConfigProfile NvdaSettingsDialogActiveConfigProfile = config.conf.profiles[-1].name - if not NvdaSettingsDialogActiveConfigProfile or isinstance(self.currentCategory, GeneralSettingsPanel): + if ( + not NvdaSettingsDialogActiveConfigProfile + or isinstance(self.currentCategory, GeneralSettingsPanel) + or isinstance(self.currentCategory, AddonStorePanel) + ): # Translators: The profile name for normal configuration NvdaSettingsDialogActiveConfigProfile = _("normal configuration") self.SetTitle(self._getDialogTitle()) diff --git a/source/utils/schedule.py b/source/utils/schedule.py new file mode 100644 index 00000000000..f611f972e4b --- /dev/null +++ b/source/utils/schedule.py @@ -0,0 +1,218 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2024 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from datetime import datetime +from enum import Enum, auto +import threading +import time +from typing import Callable + +import schedule + +from logHandler import log +import NVDAState + +scheduleThread: "ScheduleThread | None" = None + + +class ThreadTarget(Enum): + """ + When running a task, specify the thread to run the task on. + ScheduleThread blocks until the scheduled task is complete. + To avoid blocking the ScheduleThread, all tasks should be run on a separate thread. + Using GUI or DAEMON thread targets ensures that the ScheduleThread is not blocked. + CUSTOM thread target is used for tasks where the supplier of the task is responsible + for running the task on a separate thread. + """ + + GUI = auto() + """ + Uses wx.CallAfter to run the job on the GUI thread. + This is encouraged for tasks that interact with the GUI, such as dialogs. + """ + + DAEMON = auto() + """ + Uses threading.Thread(daemon=True) to run the job in the background. + """ + + CUSTOM = auto() + """ + No thread target. + Runs directly and blocks `scheduleThread`. + Authors of tasks are responsible for running the task on a + separate thread to ensure that `scheduleThread` is not blocked. + """ + + +class JobClashError(Exception): + """Raised when a job time clashes with an existing job.""" + pass + + +class ScheduleThread(threading.Thread): + name = "ScheduleThread" + + KILL = threading.Event() + """Event which can be set to cease continuous run.""" + + SLEEP_INTERVAL_SECS = 0.5 + """ + Note that the behaviour of ScheduleThread is to not run missed jobs. + For example, if you've registered a job that should run every minute and + you set a continuous run interval of one hour then your job won't be run 60 times + at each interval but only once. + """ + + DAILY_JOB_MINUTE_OFFSET = 3 + """ + Offset in minutes to schedule daily jobs. + Daily scheduled jobs occur offset by X minutes to avoid overlapping jobs. + """ + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.scheduledDailyJobCount = 0 + + @classmethod + def run(cls): + while not cls.KILL.is_set(): + schedule.run_pending() + time.sleep(cls.SLEEP_INTERVAL_SECS) + + def _calculateDailyTimeOffset(self) -> str: + startTime = datetime.fromtimestamp(NVDAState.getStartTime()) + # Schedule jobs so that they occur offset by a regular period to avoid overlapping jobs. + # Start with a delay to give time for NVDA to start up. + startTimeMinuteOffset = startTime.minute + (self.scheduledDailyJobCount + 1) * self.DAILY_JOB_MINUTE_OFFSET + # Handle the case where the minute offset is greater than 60. + startTimeHourOffset = startTime.hour + (startTimeMinuteOffset // 60) + startTimeMinuteOffset = startTimeMinuteOffset % 60 + # Handle the case where the hour offset is greater than 24. + startTimeHourOffset = startTimeHourOffset % 24 + return f"{startTimeHourOffset:02d}:{startTimeMinuteOffset:02d}" + + def scheduleDailyJobAtStartUp( + self, + task: Callable, + queueToThread: ThreadTarget, + *args, + **kwargs + ) -> schedule.Job: + """ + Schedule a daily job to run at startup. + Designed to handle clashes in a smart way to offset jobs. + :param task: The task to run. + :param queueToThread: The thread to run the task on. + :param args: Arguments to pass to the task. + :param kwargs: Keyword arguments to pass to the task. + :return: The scheduled job. + """ + try: + job = self.scheduleDailyJob(task, self._calculateDailyTimeOffset(), queueToThread, *args, **kwargs) + except JobClashError as e: + log.warning(f"Failed to schedule daily job due to clash: {e}") + self.scheduledDailyJobCount += 1 + log.debugWarning(f"Attempting to reschedule daily job {self.DAILY_JOB_MINUTE_OFFSET} min later") + return self.scheduleDailyJobAtStartUp(task, queueToThread, *args, **kwargs) + else: + self.scheduledDailyJobCount += 1 + return job + + def scheduleDailyJob( + self, + task: Callable, + cronTime: str, + queueToThread: ThreadTarget, + *args, + **kwargs + ) -> schedule.Job: + """ + Schedule a daily job to run at specific times. + :param task: The task to run. + :param cronTime: The time to run the job using a valid cron string. + It is recommended to use minute level precision at most. + https://schedule.readthedocs.io/en/stable/examples.html#run-a-job-every-x-minute + :param queueToThread: The thread to run the task on. + :param args: Arguments to pass to the task. + :param kwargs: Keyword arguments to pass to the task. + :return: The scheduled job. + :raises JobClashError: If the job's next run clashes with an existing job's next run. + """ + scheduledJob = schedule.every().day.at(cronTime) + return self.scheduleJob(task, scheduledJob, queueToThread, *args, **kwargs) + + def scheduleJob( + self, + task: Callable, + jobSchedule: schedule.Job, + queueToThread: ThreadTarget, + *args, + **kwargs + ) -> schedule.Job: + """ + Schedule a job to run at specific times. + :param task: The task to run. + :param jobSchedule: The schedule to run the task on. + Constructed using schedule e.g. `schedule.every().day.at("**:15")`. + :param cronTime: The time to run the job at using a valid cron string. + https://schedule.readthedocs.io/en/stable/examples.html#run-a-job-every-x-minute + :param queueToThread: The thread to run the task on. + :param args: Arguments to pass to the task. + :param kwargs: Keyword arguments to pass to the task. + :return: The scheduled job. + :raises JobClashError: If the job's next run clashes with an existing job's next run. + """ + match queueToThread: + case ThreadTarget.GUI: + def callJobOnThread(*args, **kwargs): + import wx + log.debug(f"Starting thread for job: {task.__name__} on GUI thread") + wx.CallAfter(task, *args, **kwargs) + case ThreadTarget.DAEMON: + def callJobOnThread(*args, **kwargs): # noqa F811: lint bug with flake8 4.0.1 not recognizing case statement + t = threading.Thread(target=task, args=args, kwargs=kwargs, daemon=True, name=f"{task.__name__}") + log.debug(f"Starting thread for job: {task.__name__} on thread {t.ident}") + t.run() + case ThreadTarget.CUSTOM: + def callJobOnThread(*args, **kwargs): # noqa F811: lint bug with flake8 4.0.1 not recognizing case statement + log.debug(f"Starting thread for job: {task.__name__} on custom thread") + task(*args, **kwargs) + case _: + raise ValueError(f"Invalid queueToThread value: {queueToThread}") + + # Check if scheduled job time clashes with existing jobs. + for existingJob in schedule.jobs: + if ( + ( + jobSchedule.at_time is not None + and existingJob.at_time == jobSchedule.at_time + ) + or ( + jobSchedule.next_run is not None + and existingJob.next_run == jobSchedule.next_run + ) + ): + # raise warning that job time clashes with existing job + raise JobClashError( + f"Job time {jobSchedule.at_time} clashes with existing job: " + f"{existingJob.job_func} and {task.__name__}" + ) + return jobSchedule.do(callJobOnThread, *args, **kwargs) + + +def initialize(): + global scheduleThread + scheduleThread = ScheduleThread(daemon=True) + scheduleThread.start() + + +def terminate(): + global scheduleThread + if scheduleThread is not None: + schedule.clear() + scheduleThread.KILL.set() + scheduleThread.join() + scheduleThread = None diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md index 99830ab2429..095b6cc6f17 100644 --- a/tests/manual/addonStore.md +++ b/tests/manual/addonStore.md @@ -32,9 +32,7 @@ Add-ons can be filtered by display name, publisher and description. ### 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. Go to your [NVDA user configuration folder](#editing-user-configuration) 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" @@ -43,7 +41,8 @@ Add-ons can be filtered by display name, publisher and description. ## Installing add-ons -### Install add-on from add-on store +### Install add-on + 1. Open the add-on store. 1. Navigate to the available add-ons tab. 1. Select an add-on. @@ -52,7 +51,8 @@ Add-ons can be filtered by display name, publisher and description. 1. Restart NVDA as prompted. 1. Confirm the add-ons are listed in the installed add-ons tab of the add-ons store. -### Batch install add-ons from add-on store +### Batch install add-ons + 1. Open the add-on store. 1. Navigate to the available add-ons tab. 1. Select multiple add-ons using `shift` and `ctrl`. @@ -61,13 +61,15 @@ Add-ons can be filtered by display name, publisher and description. 1. Restart NVDA as prompted. 1. Confirm the add-ons are listed in the installed add-ons tab of the add-ons store. -### Install add-on from external source in add-on store +### Install add-on from external source + 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 +### Install and override incompatible add-on + 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. @@ -100,38 +102,52 @@ You can do this by: ## 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) +### Simulate creating an updatable add-on + +Without having an installed add-on which has an update pending, it is hard to test updatable add-ons. +This process allows you to mock an update for an add-on. + +#### Manual process + +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` +1. Go to the "addons" folder in your [NVDA user configuration folder](#editing-user-configuration) +1. To mock an old release, we need to edit 2 files: + - Add-on Store JSON metadata + - Example: `source\userConfig\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". + +#### Using a script +1. [Install an add-on from the Add-on Store](#install-add-on) +For example: "Clock". +1. From PowerShell, call the following script to make the add-on updatable. + - `tests\manual\createUpdatableAddons.ps1 $addonName $configPath` + - Replace `$configPath` with your [NVDA user configuration folder](#editing-user-configuration). + This script defaults to using the installed user config folder in `%APPDATA%`. + - Example when running from source: `tests\manual\createUpdatableAddons.ps1 clock source\userConfig` + - Example when running an installed copy: `tests\manual\createUpdatableAddons.ps1 clock` + - Note this script sets the add-on version to 0.0.0. + +### Updating from add-on originally installed via Add-on Store + +1. [Simulate creating an updatable add-on](#simulate-creating-an-updatable-add-on) +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 available". 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) + +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` +1. Go to the "addons" folder in your [NVDA user configuration folder](#editing-user-configuration) +1. To mock an externally loaded older release, we need to edit 2 files: + - Add-on Store JSON metadata + - Example: `source\userConfig\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 @@ -144,16 +160,12 @@ This means using the latest add-on store version might be a downgrade or sidegra 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` +1. Go to the "addons" folder in your [NVDA user configuration folder](#editing-user-configuration) +1. To mock an externally loaded release, with an invalid version, we need to edit 2 files: + - Add-on Store JSON metadata + - Example: `source\userConfig\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 @@ -161,10 +173,39 @@ For example: "Clock". 1. Install the add-on again to test the "migrate" path. ### Updating multiple add-ons -Updating multiple add-ons at once is currently unsupported. + +1. Repeatedly [create updatable add-ons](#simulate-creating-an-updatable-add-on). +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 available". +1. Select multiple add-ons using `shift` and `ctrl`. +1. Using the context menu, install the add-ons. +1. Exit the dialog +1. Restart NVDA when prompted. +1. Confirm the up-to-date add-ons are listed in the installed add-ons tab of the Add-ons Store. + +### Automatic update notifications + +1. Repeatedly [create updatable add-ons](#simulate-creating-an-updatable-add-on). +1. Start NVDA +1. Ensure Automatic update notifications are enabled in the Add-on Store panel +1. Trigger the update notification manually, or alternatively wait for the notification to occur + 1. From the NVDA Python console, find the scheduled thread + ```py + import schedule + schedule.jobs + ``` + 1. Replace `i` with the index of the scheduled thread to find the job + ```py + schedule.jobs[i].run() + ``` +1. Test various buttons: + - Press "Update All": Ensure NVDA installs the add-ons. + - Press "Close": Ensure that NVDA prompts for restart if any add-ons have been installed, enabled, disabled or removed + - Press "Open Add-on Store": Ensure NVDA opens to the Updatable tab in the Add-on Store ### Automatic updating -Automatic updating of add-ons is currently unsupported. + +Full automatic updating is not currently supported. ## Other add-on actions @@ -233,3 +274,12 @@ Typically, this requires a contributor creating 3 different versions of the same | Downgrade to a different but compatible API version | X.2 | X.1 | Add-ons which remain incompatible are listed as incompatible on upgrading. Preserves state of enabled incompatible add-ons | | Upgrade to an API breaking version | X.1 | (X+1).1 | All incompatible add-ons are listed as incompatible on upgrading, overridden compatibility is reset. | | Downgrade to an API breaking version | (X+1).1 | X.1 | Add-ons which remain incompatible listed as incompatible on upgrading. Preserves state of enabled incompatible add-ons. Add-ons which are now compatible are re-enabled. | + +## Miscellaneous + +## Editing User Configuration + +Where you can find your NVDA user configuration folder: +- For installed copies: `%APPDATA%\nvda` +- For source copies: `source\userConfig` +- Inside a portable copy directory: `userConfig` diff --git a/tests/manual/createUpdatableAddons.ps1 b/tests/manual/createUpdatableAddons.ps1 new file mode 100644 index 00000000000..34774246478 --- /dev/null +++ b/tests/manual/createUpdatableAddons.ps1 @@ -0,0 +1,40 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2024 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +param( + [string]$addonName = "clock", + [string]$configFolder = "$env:APPDATA\nvda\userConfig", + [string]$newVersionMajor = "0", + [string]$newVersionMinor = "0" +) + +# Define paths to the JSON metadata and manifest file +$jsonPath = Join-Path -Path $configFolder -ChildPath "\addons\$addonName.json" +$manifestPath = Join-Path -Path $configFolder -ChildPath "\addons\$addonName\manifest.ini" + +# Function to update JSON metadata +function Update-JsonMetadata { + if (Test-Path $jsonPath) { + (Get-Content $jsonPath) -replace '"addonVersionName": "[\d+\.]+"', "`"addonVersionName`": `"$newVersionMajor.$newVersionMinor`"" | Set-Content $jsonPath + (Get-Content $jsonPath) -replace '"addonVersionNumber": {[^}]+}', "`"addonVersionNumber`": {`"major`": $newVersionMajor, `"minor`": $newVersionMinor, `"patch`": 0}" | Set-Content $jsonPath + Write-Host "Updated JSON metadata at $jsonPath" + } else { + Write-Host "JSON metadata file not found at $jsonPath" + } +} + +# Function to update manifest file +function Update-Manifest { + if (Test-Path $manifestPath) { + (Get-Content $manifestPath) -replace '^version = [\d+\.]+', "version = $newVersionMajor.$newVersionMinor" | Set-Content $manifestPath + Write-Host "Updated manifest file at $manifestPath" + } else { + Write-Host "Manifest file not found at $manifestPath" + } +} + +# Execute updates +Update-JsonMetadata +Update-Manifest diff --git a/tests/unit/test_util/test_schedule.py b/tests/unit/test_util/test_schedule.py new file mode 100644 index 00000000000..9cf923cce58 --- /dev/null +++ b/tests/unit/test_util/test_schedule.py @@ -0,0 +1,141 @@ +# 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) 2024 NV Access Limited. + +from datetime import datetime +import unittest +from unittest.mock import MagicMock + +import schedule + +import NVDAState +# import the entire module to make accessing top level global variables safer +# i.e. scheduleThread +from utils import schedule as _sch +from utils.schedule import ( + JobClashError, + ScheduleThread, + ThreadTarget, + initialize, + terminate, +) + + +class ScheduleThreadTests(unittest.TestCase): + def setUp(self): + self.oldNVDAStateGetStartTime = NVDAState.getStartTime + NVDAState.getStartTime = MagicMock(return_value=datetime.now().timestamp()) + self.assertEqual(len(schedule.jobs), 0, "No jobs should be scheduled at the start of the test.") + self.assertIsNone(_sch.scheduleThread, "scheduleThread should be None at the start of the test.") + initialize() + + def tearDown(self): + terminate() + NVDAState.getStartTime = self.oldNVDAStateGetStartTime + + def test_scheduleDailyJobAtStartUp(self): + scheduledVals = [0, 0, 0] + + def incrementA(scheduledVals: list): + scheduledVals[0] += 1 + + def incrementB(scheduledVals: list): + scheduledVals[1] += 1 + + def incrementC(scheduledVals: list): + scheduledVals[2] += 1 + + _sch.scheduleThread.scheduleDailyJobAtStartUp(incrementA, ThreadTarget.CUSTOM, scheduledVals) + _sch.scheduleThread.scheduleDailyJobAtStartUp(incrementB, ThreadTarget.CUSTOM, scheduledVals) + _sch.scheduleThread.scheduleDailyJobAtStartUp(incrementC, ThreadTarget.CUSTOM, scheduledVals) + # Sanity checks (have failed in development) + self.assertEqual(len(schedule.jobs), 3) + self.assertEqual(_sch.scheduleThread.scheduledDailyJobCount, 3) + + expectedResult = [0, 0, 0] + self.assertEqual(scheduledVals, expectedResult) + for jobIndex in range(3): + startTime = NVDAState.getStartTime() + currentJob = schedule.jobs[jobIndex] + + # Ensure that the job is scheduled to run at the expected time + expectedSecsOffsetMin = jobIndex * ScheduleThread.DAILY_JOB_MINUTE_OFFSET * 60 + expectedSecsOffsetMax = (jobIndex + 1) * ScheduleThread.DAILY_JOB_MINUTE_OFFSET * 60 + nextRun = currentJob.next_run.timestamp() + actualSecsOffset = nextRun - startTime + self.assertLessEqual( + actualSecsOffset, + expectedSecsOffsetMax, + f"Job {jobIndex} was not scheduled as expected. Job: {currentJob}" + ) + self.assertGreaterEqual( + actualSecsOffset, + expectedSecsOffsetMin, + f"Job {jobIndex} was not scheduled as expected. Job: {currentJob}" + ) + + # Ensure the job runs as expected + currentJob.run() + expectedResult[jobIndex] += 1 + self.assertEqual( + scheduledVals, + expectedResult, + f"Job {jobIndex} did not run as expected. Scheduled jobs: {schedule.jobs}" + ) + + def test_scheduleJob(self): + def jobFunc(): + # Job function implementation + pass + + cronTime = "12:00" # Example cron time + jobSchedule = schedule.every().day.at(cronTime) + + # Call the scheduleJob method + _sch.scheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) + + # Assert that the job is scheduled correctly + self.assertEqual(len(schedule.jobs), 1) + scheduledJob = schedule.jobs[0] + self.assertEqual(f"{scheduledJob.at_time.hour:02d}:{scheduledJob.at_time.minute:02d}", cronTime) + + def test_scheduleJob_jobClash(self): + def jobFunc(): + # Job function implementation + pass + + cronTime = "12:00" # Example cron time + jobSchedule = schedule.every().day.at(cronTime) + + # Call the scheduleJob method + _sch.scheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) + + with self.assertRaises(JobClashError): + # Call the scheduleJob method with the same cron time + _sch.scheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) + + def test_calculateDailyTimeOffset(self): + todayAtMidnight = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + NVDAState.getStartTime = MagicMock(return_value=todayAtMidnight.timestamp()) + offset = _sch.scheduleThread._calculateDailyTimeOffset() + # Assert that the offset is calculated correctly + self.assertEqual(offset, f"00:{ScheduleThread.DAILY_JOB_MINUTE_OFFSET:02d}") + + _sch.scheduleThread.scheduledDailyJobCount = 1 + offset = _sch.scheduleThread._calculateDailyTimeOffset() + self.assertEqual(offset, f"00:{ScheduleThread.DAILY_JOB_MINUTE_OFFSET * 2:02d}") + + # Test the case where the start time is 11:59 to ensure the hour offset is calculated correctly + NVDAState.getStartTime = MagicMock(return_value=todayAtMidnight.replace(hour=11, minute=59).timestamp()) + _sch.scheduleThread.scheduledDailyJobCount = 0 + offset = _sch.scheduleThread._calculateDailyTimeOffset() + expectedMinOffset = (ScheduleThread.DAILY_JOB_MINUTE_OFFSET + 59) % 60 + self.assertEqual(offset, f"12:{expectedMinOffset:02d}") + + # Test the case where the start time is 23:59 to ensure the day and hour offset is calculated correctly + NVDAState.getStartTime = MagicMock(return_value=todayAtMidnight.replace(hour=23, minute=59).timestamp()) + _sch.scheduleThread.scheduledDailyJobCount = 0 + offset = _sch.scheduleThread._calculateDailyTimeOffset() + expectedMinOffset = (ScheduleThread.DAILY_JOB_MINUTE_OFFSET + 59) % 60 + self.assertEqual(offset, f"00:{expectedMinOffset:02d}") diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index cf472031aed..8b4b2273858 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -1,11 +1,11 @@ # What's New in NVDA ## 2024.3 +The Add-on Store will now notify you if any add-on updates are available on NVDA start up. This release adds support for Unicode Normalization to speech and braille output. This can be useful when reading characters that are unknown to a particular speech synthesizer or braille table and which have a compatible alternative, like the bold and italic characters commonly used on social media. It also allows reading of equations in the Microsoft Word equation editor. -You can enable this functionality for both speech and braille in their respective settings categories in the NVDA Settings dialog. There are several bug fixes, particularly for the Windows 11 Emoji Panel and Clipboard history. For web browsers, there are fixes for reporting error messages, figures, captions, table labels and checkbox/radio button menu items. @@ -21,6 +21,10 @@ Unicode CLDR has been updated. * This can be useful when reading characters that are unknown to a particular speech synthesizer or braille table and which have a compatible alternative, like the bold and italic characters commonly used on social media. * It also allows reading of equations in the Microsoft Word equation editor. (#4631) * You can enable this functionality for both speech and braille in their respective settings categories in the NVDA Settings dialog. +* By default, after NVDA startup, you will be notified if any add-on updates are available. (#15035) + * This can be disabled in the "Add-on Store" category of settings. + * NVDA checks daily for add-on updates. + * Only updates within the same channel will be checked (e.g. installed beta add-ons will only notify for updates in the beta channel). ### Changes @@ -64,6 +68,10 @@ It is especially useful to read the error location markers in tracebacks. (#1632 * When a `gainFocus` event is queued with an object that has a valid `focusRedirect` property, the object pointed to by the `focusRedirect` property is now held by `eventHandler.lastQueuedFocusObject`, rather than the originally queued object. (#15843) * NVDA will log its executable architecture (x86) at startup. (#16432, @josephsl) * `wx.CallAfter`, which is wrapped in `monkeyPatches/wxMonkeyPatches.py`, now includes proper `functools.wraps` indication. (#16520, @XLTechie) +* There is a new module for scheduling tasks `utils.schedule`, using the pip module `schedule`. (#16636) + * You can use `scheduleThread.scheduleDailyJobAtStartUp` to automatically schedule a job that happens after NVDA starts, and every 24 hours after that. + Jobs are scheduled with a delay to avoid conflicts. + * `scheduleThread.scheduleDailyJob` and `scheduleJob` can be used to schedule jobs at custom times, where a `JobClashError` will be raised on a known job scheduling clash. #### Deprecations diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index b8cc40a3005..3f299bed4fb 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -361,6 +361,9 @@ The status of the add-on will be listed as "Update available". The list will display the currently installed version and the available version. Press `enter` on the add-on to open the actions list; choose "Update". +By default, after NVDA startup, you will be notified if any add-on updates are available. +To learn more about and configure this behaviour, refer to ["Update Notifications"](#AutomaticAddonUpdates). + ### Community {#Community} NVDA has a vibrant user community. @@ -2916,6 +2919,27 @@ Note that this paragraph style cannot be used in Microsoft Word or Microsoft Out You may toggle through the available paragraph styles from anywhere by assigning a key in the [Input Gestures dialog](#InputGestures). +#### Add-on Store Settings {#AddonStoreSettings} + +This category allows you to adjust the behaviour of the Add-on Store. + +##### Update Notifications {#AutomaticAddonUpdates} + +When this option is set to "Notify", the Add-on Store will notify you after NVDA startup if any add-on updates are available. +This check is performed every 24 hours. +Notifications will only occur for add-ons with updates available within the same channel. +For example, for installed beta add-ons, you will only be notified of updates within the beta channel. + +| . {.hideHeaderRow} |.| +|---|---| +|Options |Notify (Default), Disabled | +|Default |Notify | + +|Option |Behaviour | +|---|---| +|Enabled |Notify when updates are available to add-ons within the same channel | +|Disabled |Do not automatically check for updates to add-ons | + #### Windows OCR Settings {#Win10OcrSettings} The settings in this category allow you to configure [Windows OCR](#Win10Ocr). @@ -3501,6 +3525,9 @@ If NVDA is installed and running on your system, you can also open an add-on fil When an add-on is being installed from an external source, 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, although you may postpone restarting NVDA if you have other add-ons to install or update. +By default, after NVDA startup, you will be notified if any add-on updates are available. +To learn more about and configure this behaviour, refer to ["Update Notifications"](#AutomaticAddonUpdates). + #### Removing Add-ons {#AddonStoreRemoving} To remove an add-on, select the add-on from the list and use the Remove action.