From 4b97d4a9c5b849db74671934ad035655554bdf3b Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 31 May 2024 14:46:00 +1000 Subject: [PATCH 01/33] Create message dialog notifying users of an add-on update --- requirements.txt | 3 +- source/addonStore/dataManager.py | 12 +++- source/core.py | 5 ++ source/gui/__init__.py | 15 +++++ source/gui/addonStoreGui/__init__.py | 10 +++ .../addonStoreGui/controls/messageDialogs.py | 62 +++++++++++++++++++ .../gui/addonStoreGui/controls/storeDialog.py | 17 ++--- source/utils/schedule.py | 61 ++++++++++++++++++ 8 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 source/utils/schedule.py diff --git a/requirements.txt b/requirements.txt index 4bd39cd80f1..ae6ee8ce75d 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" @@ -17,7 +18,7 @@ fast-diff-match-patch==2.1.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..0668efd9455 100644 --- a/source/addonStore/dataManager.py +++ b/source/addonStore/dataManager.py @@ -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,15 @@ def _getCachedInstalledAddonData(self, addonId: str) -> Optional[InstalledAddonS return None return _createInstalledStoreModelFromData(cacheData) + def _addonsPendingUpdate(self) -> set["_AddonGUIModel"]: + addonsPendingUpdate = set() + compatibleAddons = self.getLatestCompatibleAddons() + for channel in compatibleAddons: + for addon in compatibleAddons[channel].values(): + if getStatus(addon, _StatusFilterKey.UPDATE) == AvailableAddonStatus.UPDATE: + addonsPendingUpdate.add(addon) + return addonsPendingUpdate + class _InstalledAddonsCache(AutoPropertyObject): cachePropertiesByDefault = True 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..dfd0269c171 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -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): + 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..7a46ed9e47e 100644 --- a/source/gui/addonStoreGui/__init__.py +++ b/source/gui/addonStoreGui/__init__.py @@ -3,8 +3,18 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +import wx + from .controls.storeDialog import AddonStoreDialog +from .controls.messageDialogs import UpdatableAddonsDialog __all__ = [ "AddonStoreDialog", + "initialize", ] + + +def initialize(): + from utils.schedule import ScheduleThread + # Ensure the GUI functionality is called from the wx thread from the schedule thread + ScheduleThread.scheduleDailyJobAtStartUp(lambda: wx.CallAfter(UpdatableAddonsDialog._checkForUpdatableAddons)) diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index 803c9a43b45..d7dfbc2c4d1 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -15,7 +15,10 @@ _AddonStoreModel, _AddonManifestModel, ) +from addonStore.dataManager import addonDataManager import config +from logHandler import log +import gui from gui.addonGui import ConfirmAddonInstallDialog, ErrorAddonInstallDialog from gui.contextHelp import ContextHelpMixin from gui.guiHelper import ( @@ -348,3 +351,62 @@ 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 = "UpdatingAddons" # TODO make custom + + def __init__(self, parent: wx.Window, addonsPendingUpdate: set[_AddonGUIModel]): + # Translators: The warning of a dialog + super().__init__(parent, title=pgettext("addonStore", "Add-on updates available")) + mainSizer = wx.BoxSizer(wx.VERTICAL) + sHelper = BoxSizerHelper(self, orientation=wx.VERTICAL) + + _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. " + "Open the Add-on Store to update them. " + ) + + 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()) + sText.Wrap( + # 600 was fairly arbitrarily chosen by a visual user to look acceptable on their machine. + self.scaleFactor * 600 + ) + + sHelper.sizer.AddSpacer(SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS) + + bHelper = sHelper.addDialogDismissButtons(ButtonHelper(wx.HORIZONTAL)) + + # Translators: The label of a button in a dialog + updateButton = bHelper.addButton(self, wx.ID_OK, label=pgettext("addonStore", "&Update Add-ons")) + updateButton.Bind(wx.EVT_BUTTON, self.onUpdateButton) + + bHelper.addButton(self, wx.ID_CLOSE, label=pgettext("addonStore", "&Close")) + + mainSizer.Add(sHelper.sizer, border=BORDER_FOR_DIALOGS, flag=wx.ALL) + self.Sizer = mainSizer + mainSizer.Fit(self) + self.CentreOnScreen() + + def onUpdateButton(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_OK) + + @classmethod + def _checkForUpdatableAddons(cls): + 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 b4c2fedc8fa..b7f3a311e96 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/utils/schedule.py b/source/utils/schedule.py new file mode 100644 index 00000000000..66163b5cf71 --- /dev/null +++ b/source/utils/schedule.py @@ -0,0 +1,61 @@ +# 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 +import threading +import time +from typing import Callable + +import schedule + +import NVDAState + +scheduleThread: "ScheduleThread | None" = None + + +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. + """ + + scheduledJobs: list[Callable[[], None]] = [] + + @classmethod + def run(cls): + while not cls.KILL.is_set(): + schedule.run_pending() + time.sleep(cls.SLEEP_INTERVAL_SECS) + + @classmethod + def scheduleDailyJobAtStartUp(cls, job: Callable, *args, **kwargs): + """Schedule a daily job to run at startup.""" + startTime = datetime.fromtimestamp(NVDAState.getStartTime()) + # We add the length of the scheduled jobs to the minute offset to avoid overlapping jobs. + startTimeMinuteOffset = startTime.minute + len(cls.scheduledJobs) + 1 + scheduledJob = schedule.every().day.at(f"{startTime.hour}:{startTimeMinuteOffset}").do(job, *args, **kwargs) + cls.scheduledJobs.append(scheduledJob) + + +def initialize(): + global scheduleThread + scheduleThread = ScheduleThread() + scheduleThread.start() + + +def terminate(): + global scheduleThread + if scheduleThread is not None: + schedule.clear() + scheduleThread.KILL.set() + scheduleThread.join() + scheduleThread = None From c236af8ffa8268578a7ea014668ea04f4334c89f Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 31 May 2024 14:50:00 +1000 Subject: [PATCH 02/33] Only notify updates from the same channel --- source/addonStore/dataManager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/addonStore/dataManager.py b/source/addonStore/dataManager.py index 0668efd9455..d95ad75c12f 100644 --- a/source/addonStore/dataManager.py +++ b/source/addonStore/dataManager.py @@ -315,7 +315,9 @@ def _addonsPendingUpdate(self) -> set["_AddonGUIModel"]: for channel in compatibleAddons: for addon in compatibleAddons[channel].values(): if getStatus(addon, _StatusFilterKey.UPDATE) == AvailableAddonStatus.UPDATE: - addonsPendingUpdate.add(addon) + # Only consider add-on updates for the same channel + if addon.channel == addon._addonHandlerModel._addonStoreData.channel: + addonsPendingUpdate.add(addon) return addonsPendingUpdate From de9ee50c24da22066acae1a686d7d754ed7d37b4 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 31 May 2024 17:40:20 +1000 Subject: [PATCH 03/33] Update requirements.txt Co-authored-by: WangFeng Huang <1398969445@qq.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ae6ee8ce75d..09bbf93325c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ 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 From 3dfc807e4621cda45ae21d6f2f1198c26ca5dab5 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 10:19:33 +1000 Subject: [PATCH 04/33] minor fixup --- source/gui/__init__.py | 2 +- source/utils/schedule.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/source/gui/__init__.py b/source/gui/__init__.py index dfd0269c171..ef79184c25f 100644 --- a/source/gui/__init__.py +++ b/source/gui/__init__.py @@ -415,7 +415,7 @@ def onAddonStoreCommand(self, evt: wx.MenuEvent): blockAction.Context.WINDOWS_STORE_VERSION, blockAction.Context.RUNNING_LAUNCHER, ) - def onAddonStoreUpdatableCommand(self, evt: wx.MenuEvent): + def onAddonStoreUpdatableCommand(self, evt: wx.MenuEvent | None): from .addonStoreGui import AddonStoreDialog from .addonStoreGui.viewModels.store import AddonStoreVM from addonStore.models.status import _StatusFilterKey diff --git a/source/utils/schedule.py b/source/utils/schedule.py index 66163b5cf71..23382ad4b91 100644 --- a/source/utils/schedule.py +++ b/source/utils/schedule.py @@ -22,7 +22,8 @@ class ScheduleThread(threading.Thread): """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. + """ + 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. From 0201b1ceed08b6a8a92aea31a8f130568c0cc940 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 10:54:01 +1000 Subject: [PATCH 05/33] Add settings and docs --- source/config/configFlags.py | 17 +++++++++++++ source/config/configSpec.py | 1 + .../addonStoreGui/controls/messageDialogs.py | 6 ++++- source/gui/settingsDialogs.py | 20 ++++++++++++---- user_docs/en/userGuide.md | 24 +++++++++++++++++++ 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/source/config/configFlags.py b/source/config/configFlags.py index 0b6d858ea7c..26b55615526 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -202,3 +202,20 @@ def _displayStringLabels(self): # document formatting settings panel. ReportCellBorders.COLOR_AND_STYLE: _("Both Colors and Styles"), } + + +class AutomaticUpdateBehaviour(DisplayStringStrEnum): + NOTIFY = "notify" + # UPDATE = "update" + DISABLED = "disabled" + + @property + def _displayStringLabels(self): + return { + # Translators: This is a label for the automatic update behaviour of an add-on. + # It will notify the user when an update is available. + self.NOTIFY: _("Notify (default)"), + # self.UPDATE: _("Update Automatically"), + # Translators: This is a label for the automatic update behaviour of an add-on. + self.DISABLED: _("Disabled"), + } diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 684d113f94b..3df165a17bd 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -324,6 +324,7 @@ [addonStore] showWarning = boolean(default=true) + automaticUpdates = option("notify", "disabled", default="notify") """ #: The configuration specification diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index d7dfbc2c4d1..a8e560b362d 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -17,6 +17,7 @@ ) from addonStore.dataManager import addonDataManager import config +from config.configFlags import AutomaticUpdateBehaviour from logHandler import log import gui from gui.addonGui import ConfirmAddonInstallDialog, ErrorAddonInstallDialog @@ -359,7 +360,7 @@ class UpdatableAddonsDialog( ): """A dialog notifying users that updatable add-ons are available""" - helpId = "UpdatingAddons" # TODO make custom + helpId = "AutomaticAddonUpdates" def __init__(self, parent: wx.Window, addonsPendingUpdate: set[_AddonGUIModel]): # Translators: The warning of a dialog @@ -405,6 +406,9 @@ def onUpdateButton(self, evt: wx.CommandEvent): @classmethod def _checkForUpdatableAddons(cls): + if AutomaticUpdateBehaviour.DISABLED == config.conf["addonStore"]["automaticUpdates"]: + log.debug("automatic updates are disabled") + return log.debug("checking for updatable add-ons") addonsPendingUpdate = addonDataManager._addonsPendingUpdate() if addonsPendingUpdate: diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index fda570fa248..8ef33007493 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 ( + AutomaticUpdateBehaviour, NVDAKey, ShowMessages, TetherTo, @@ -2873,11 +2874,22 @@ 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 AutomaticUpdateBehaviour] + ) + self.bindHelpEvent("AutomaticAddonUpdates", self.automaticUpdatesComboBox) + index = [x.value for x in AutomaticUpdateBehaviour].index(config.conf["addonStore"]["automaticUpdates"]) + self.automaticUpdatesComboBox.SetSelection(index) def onSave(self): - pass + config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AutomaticUpdateBehaviour][self.automaticUpdatesComboBox.GetSelection()] class TouchInteractionPanel(SettingsPanel): @@ -4638,7 +4650,7 @@ class NVDASettingsDialog(MultiCategorySettingsDialog): BrowseModePanel, DocumentFormattingPanel, DocumentNavigationPanel, - # AddonStorePanel, currently empty + AddonStorePanel, ] if touchHandler.touchSupported(): categoryClasses.append(TouchInteractionPanel) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index f92a9d781fb..d7464c3675d 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -352,6 +352,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, the Add-on Store will notify you 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. @@ -2891,6 +2894,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} + +By default, after NVDA startup, the Add-on Store will notify you 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). From b45ba3f2fe91b49612d6180e5a5ff2d6044141ff Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 10:58:05 +1000 Subject: [PATCH 06/33] Fix up name for AddonsAutomaticUpdate --- source/config/configFlags.py | 8 ++++---- source/gui/addonStoreGui/controls/messageDialogs.py | 4 ++-- source/gui/settingsDialogs.py | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/source/config/configFlags.py b/source/config/configFlags.py index 26b55615526..d5897b17441 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -204,7 +204,7 @@ def _displayStringLabels(self): } -class AutomaticUpdateBehaviour(DisplayStringStrEnum): +class AddonsAutomaticUpdate(DisplayStringStrEnum): NOTIFY = "notify" # UPDATE = "update" DISABLED = "disabled" @@ -212,10 +212,10 @@ class AutomaticUpdateBehaviour(DisplayStringStrEnum): @property def _displayStringLabels(self): return { - # Translators: This is a label for the automatic update behaviour of an add-on. - # It will notify the user when an update is available. + # 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 (default)"), # self.UPDATE: _("Update Automatically"), - # Translators: This is a label for the automatic update behaviour of an add-on. + # Translators: This is a label for the automatic update behaviour for add-ons. self.DISABLED: _("Disabled"), } diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index a8e560b362d..cf1f5c08990 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -17,7 +17,7 @@ ) from addonStore.dataManager import addonDataManager import config -from config.configFlags import AutomaticUpdateBehaviour +from config.configFlags import AddonsAutomaticUpdate from logHandler import log import gui from gui.addonGui import ConfirmAddonInstallDialog, ErrorAddonInstallDialog @@ -406,7 +406,7 @@ def onUpdateButton(self, evt: wx.CommandEvent): @classmethod def _checkForUpdatableAddons(cls): - if AutomaticUpdateBehaviour.DISABLED == config.conf["addonStore"]["automaticUpdates"]: + if AddonsAutomaticUpdate.DISABLED == config.conf["addonStore"]["automaticUpdates"]: log.debug("automatic updates are disabled") return log.debug("checking for updatable add-ons") diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 8ef33007493..261de4ef1ab 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -27,7 +27,7 @@ from synthDriverHandler import changeVoice, getSynth, getSynthList, setSynth, SynthDriver import config from config.configFlags import ( - AutomaticUpdateBehaviour, + AddonsAutomaticUpdate, NVDAKey, ShowMessages, TetherTo, @@ -2882,14 +2882,14 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: self.automaticUpdatesComboBox = sHelper.addLabeledControl( automaticUpdatesLabelText, wx.Choice, - choices=[mode.displayString for mode in AutomaticUpdateBehaviour] + choices=[mode.displayString for mode in AddonsAutomaticUpdate] ) self.bindHelpEvent("AutomaticAddonUpdates", self.automaticUpdatesComboBox) - index = [x.value for x in AutomaticUpdateBehaviour].index(config.conf["addonStore"]["automaticUpdates"]) + index = [x.value for x in AddonsAutomaticUpdate].index(config.conf["addonStore"]["automaticUpdates"]) self.automaticUpdatesComboBox.SetSelection(index) def onSave(self): - config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AutomaticUpdateBehaviour][self.automaticUpdatesComboBox.GetSelection()] + config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AddonsAutomaticUpdate][self.automaticUpdatesComboBox.GetSelection()] class TouchInteractionPanel(SettingsPanel): From b5b504cc1de64026a7a8c712fe3f3f1c1ea38652 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 11:16:28 +1000 Subject: [PATCH 07/33] make scheduling safer --- source/utils/schedule.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/source/utils/schedule.py b/source/utils/schedule.py index 23382ad4b91..393c6401fb0 100644 --- a/source/utils/schedule.py +++ b/source/utils/schedule.py @@ -41,9 +41,13 @@ def run(cls): def scheduleDailyJobAtStartUp(cls, job: Callable, *args, **kwargs): """Schedule a daily job to run at startup.""" startTime = datetime.fromtimestamp(NVDAState.getStartTime()) - # We add the length of the scheduled jobs to the minute offset to avoid overlapping jobs. - startTimeMinuteOffset = startTime.minute + len(cls.scheduledJobs) + 1 - scheduledJob = schedule.every().day.at(f"{startTime.hour}:{startTimeMinuteOffset}").do(job, *args, **kwargs) + # Schedule jobs so that they occur offset by 3 minutes to avoid overlapping jobs. + # Start with a delay to give time for NVDA to start up. + startTimeMinuteOffset = startTime.minute + (len(cls.scheduledJobs) + 1) * 3 + # Handle the case where the minute offset is greater than 60. + startTimeHourOffset = startTime.hour + (startTimeMinuteOffset // 60) + startTimeMinuteOffset = startTimeMinuteOffset % 60 + scheduledJob = schedule.every().day.at(f"{startTimeHourOffset}:{startTimeMinuteOffset}").do(job, *args, **kwargs) cls.scheduledJobs.append(scheduledJob) From fe10315fc5b702e5154611c8a5cfc8411e9cbfe4 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 11:17:07 +1000 Subject: [PATCH 08/33] fixup typing --- source/utils/schedule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/utils/schedule.py b/source/utils/schedule.py index 393c6401fb0..00a66a1dedf 100644 --- a/source/utils/schedule.py +++ b/source/utils/schedule.py @@ -29,7 +29,7 @@ class ScheduleThread(threading.Thread): at each interval but only once. """ - scheduledJobs: list[Callable[[], None]] = [] + scheduledJobs: list[schedule.Job] = [] @classmethod def run(cls): From 7d330646a456aaba2758c1c622823b943aa8f671 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 12:59:35 +1000 Subject: [PATCH 09/33] add unit tests for scheduler --- source/gui/addonStoreGui/__init__.py | 4 +- source/gui/settingsDialogs.py | 3 +- source/utils/schedule.py | 18 +++++-- tests/unit/test_util/test_schedule.py | 70 +++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 tests/unit/test_util/test_schedule.py diff --git a/source/gui/addonStoreGui/__init__.py b/source/gui/addonStoreGui/__init__.py index 7a46ed9e47e..06bad1c7a57 100644 --- a/source/gui/addonStoreGui/__init__.py +++ b/source/gui/addonStoreGui/__init__.py @@ -17,4 +17,6 @@ def initialize(): from utils.schedule import ScheduleThread # Ensure the GUI functionality is called from the wx thread from the schedule thread - ScheduleThread.scheduleDailyJobAtStartUp(lambda: wx.CallAfter(UpdatableAddonsDialog._checkForUpdatableAddons)) + ScheduleThread.scheduleDailyJobAtStartUp( + lambda: wx.CallAfter(UpdatableAddonsDialog._checkForUpdatableAddons) + ) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 261de4ef1ab..e5014807de5 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -2889,7 +2889,8 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: self.automaticUpdatesComboBox.SetSelection(index) def onSave(self): - config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AddonsAutomaticUpdate][self.automaticUpdatesComboBox.GetSelection()] + index = self.automaticUpdatesComboBox.GetSelection() + config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AddonsAutomaticUpdate][index] class TouchInteractionPanel(SettingsPanel): diff --git a/source/utils/schedule.py b/source/utils/schedule.py index 00a66a1dedf..61379e2dd6a 100644 --- a/source/utils/schedule.py +++ b/source/utils/schedule.py @@ -29,6 +29,12 @@ class ScheduleThread(threading.Thread): 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. + """ + scheduledJobs: list[schedule.Job] = [] @classmethod @@ -41,25 +47,29 @@ def run(cls): def scheduleDailyJobAtStartUp(cls, job: Callable, *args, **kwargs): """Schedule a daily job to run at startup.""" startTime = datetime.fromtimestamp(NVDAState.getStartTime()) - # Schedule jobs so that they occur offset by 3 minutes to avoid overlapping jobs. + # 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 + (len(cls.scheduledJobs) + 1) * 3 + startTimeMinuteOffset = startTime.minute + (len(cls.scheduledJobs) + 1) * cls.DAILY_JOB_MINUTE_OFFSET # Handle the case where the minute offset is greater than 60. startTimeHourOffset = startTime.hour + (startTimeMinuteOffset // 60) startTimeMinuteOffset = startTimeMinuteOffset % 60 - scheduledJob = schedule.every().day.at(f"{startTimeHourOffset}:{startTimeMinuteOffset}").do(job, *args, **kwargs) + # Handle the case where the hour offset is greater than 24. + startTimeHourOffset = startTimeHourOffset % 24 + scheduledTime = f"{startTimeHourOffset:02d}:{startTimeMinuteOffset:02d}" + scheduledJob = schedule.every().day.at(scheduledTime).do(job, *args, **kwargs) cls.scheduledJobs.append(scheduledJob) def initialize(): global scheduleThread - scheduleThread = ScheduleThread() + scheduleThread = ScheduleThread(daemon=True) scheduleThread.start() def terminate(): global scheduleThread if scheduleThread is not None: + ScheduleThread.scheduledJobs.clear() schedule.clear() scheduleThread.KILL.set() scheduleThread.join() diff --git a/tests/unit/test_util/test_schedule.py b/tests/unit/test_util/test_schedule.py new file mode 100644 index 00000000000..7a25b2a2f24 --- /dev/null +++ b/tests/unit/test_util/test_schedule.py @@ -0,0 +1,70 @@ +# 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 NVDAState +from utils.schedule import ScheduleThread, initialize, terminate + + +class ScheduleThreadTests(unittest.TestCase): + def setUp(self): + self.oldNVDAStateGetStartTime = NVDAState.getStartTime + NVDAState.getStartTime = MagicMock(return_value=datetime.now().timestamp()) + 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 + + ScheduleThread.scheduleDailyJobAtStartUp(incrementA, scheduledVals) + ScheduleThread.scheduleDailyJobAtStartUp(incrementB, scheduledVals) + ScheduleThread.scheduleDailyJobAtStartUp(incrementC, scheduledVals) + + expectedResult = [0, 0, 0] + self.assertEqual(scheduledVals, expectedResult) + for index in range(3): + # Simulate a forced job run + startTime = NVDAState.getStartTime() + currentJob = ScheduleThread.scheduledJobs[index] + + # Ensure that the job is scheduled to run at the expected time + expectedSecsOffsetMin = index * ScheduleThread.DAILY_JOB_MINUTE_OFFSET * 60 + expectedSecsOffsetMax = (index + 1) * ScheduleThread.DAILY_JOB_MINUTE_OFFSET * 60 + nextRun = currentJob.next_run.timestamp() + actualSecsOffset = nextRun - startTime + self.assertLessEqual( + actualSecsOffset, + expectedSecsOffsetMax, + f"Job {index} did not scheduled as expected. Job: {currentJob}" + ) + self.assertGreaterEqual( + actualSecsOffset, + expectedSecsOffsetMin, + f"Job {index} did not scheduled as expected. Job: {currentJob}" + ) + + # Ensure the job runs as expected + currentJob.run() + expectedResult[index] += 1 + self.assertEqual( + scheduledVals, + expectedResult, + f"Job {index} did not run as expected. Scheduled jobs: {ScheduleThread.scheduledJobs}" + ) From 4b959a275cd33228b63179e29282fb7bc88cc2ee Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 13:36:35 +1000 Subject: [PATCH 10/33] add add-on list to dialog --- .../addonStoreGui/controls/messageDialogs.py | 51 ++++++++++++++++--- tests/unit/test_util/test_schedule.py | 16 +++--- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index cf1f5c08990..44f82767ad4 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -18,10 +18,11 @@ from addonStore.dataManager import addonDataManager import config from config.configFlags import AddonsAutomaticUpdate -from logHandler import log import gui +from gui import nvdaControls from gui.addonGui import ConfirmAddonInstallDialog, ErrorAddonInstallDialog from gui.contextHelp import ContextHelpMixin +from gui.dpiScalingHelper import scaleSize from gui.guiHelper import ( BoxSizerHelper, BORDER_FOR_DIALOGS, @@ -29,6 +30,7 @@ SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS, ) from gui.message import displayDialogAsModal, messageBox +from logHandler import log import windowUtils if TYPE_CHECKING: @@ -248,7 +250,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) )) @@ -385,24 +387,59 @@ def __init__(self, parent: wx.Window, addonsPendingUpdate: set[_AddonGUIModel]): sHelper.sizer.AddSpacer(SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS) + # 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:") + + self.addonsList.AppendColumn(nameLabel, width=300) + self.addonsList.AppendColumn(installedVersionLabel, width=300) + self.addonsList.AppendColumn(availableVersionLabel, width=300) + self.addonsList.AppendColumn(channelLabel, width=150) + for addon in addonsPendingUpdate: + self.addonsList.Append(( + addon.name, + addon._addonHandlerModel.version, + addon.addonVersionName, + addon.channel.displayString, + )) + self.addonsList.Refresh() + bHelper = sHelper.addDialogDismissButtons(ButtonHelper(wx.HORIZONTAL)) # Translators: The label of a button in a dialog - updateButton = bHelper.addButton(self, wx.ID_OK, label=pgettext("addonStore", "&Update Add-ons")) - updateButton.Bind(wx.EVT_BUTTON, self.onUpdateButton) + openStoreButton = bHelper.addButton(self, wx.ID_YES, label=pgettext("addonStore", "Open Add-on &Store")) + openStoreButton.Bind(wx.EVT_BUTTON, self.onOpenStoreButton) - bHelper.addButton(self, wx.ID_CLOSE, label=pgettext("addonStore", "&Close")) + # 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) mainSizer.Add(sHelper.sizer, border=BORDER_FOR_DIALOGS, flag=wx.ALL) self.Sizer = mainSizer mainSizer.Fit(self) self.CentreOnScreen() - def onUpdateButton(self, evt: wx.CommandEvent): + 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_OK) + self.EndModal(wx.ID_CLOSE) + + def onCloseButton(self, evt: wx.CommandEvent): + self.EndModal(wx.ID_CLOSE) @classmethod def _checkForUpdatableAddons(cls): diff --git a/tests/unit/test_util/test_schedule.py b/tests/unit/test_util/test_schedule.py index 7a25b2a2f24..85005621157 100644 --- a/tests/unit/test_util/test_schedule.py +++ b/tests/unit/test_util/test_schedule.py @@ -39,32 +39,32 @@ def incrementC(scheduledVals: list): expectedResult = [0, 0, 0] self.assertEqual(scheduledVals, expectedResult) - for index in range(3): + for jobIndex in range(3): # Simulate a forced job run startTime = NVDAState.getStartTime() - currentJob = ScheduleThread.scheduledJobs[index] + currentJob = ScheduleThread.scheduledJobs[jobIndex] # Ensure that the job is scheduled to run at the expected time - expectedSecsOffsetMin = index * ScheduleThread.DAILY_JOB_MINUTE_OFFSET * 60 - expectedSecsOffsetMax = (index + 1) * ScheduleThread.DAILY_JOB_MINUTE_OFFSET * 60 + 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 {index} did not scheduled as expected. Job: {currentJob}" + f"Job {jobIndex} did not scheduled as expected. Job: {currentJob}" ) self.assertGreaterEqual( actualSecsOffset, expectedSecsOffsetMin, - f"Job {index} did not scheduled as expected. Job: {currentJob}" + f"Job {jobIndex} did not scheduled as expected. Job: {currentJob}" ) # Ensure the job runs as expected currentJob.run() - expectedResult[index] += 1 + expectedResult[jobIndex] += 1 self.assertEqual( scheduledVals, expectedResult, - f"Job {index} did not run as expected. Scheduled jobs: {ScheduleThread.scheduledJobs}" + f"Job {jobIndex} did not run as expected. Scheduled jobs: {ScheduleThread.scheduledJobs}" ) From 479122fc1c9464284111d696e1f91b51e6b23419 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 16:04:29 +1000 Subject: [PATCH 11/33] implement updating add-ons --- source/addonStore/dataManager.py | 8 +- source/config/configFlags.py | 2 +- source/config/configSpec.py | 2 +- source/gui/__init__.py | 2 +- source/gui/addonStoreGui/__init__.py | 5 +- .../addonStoreGui/controls/messageDialogs.py | 141 ++++++++++++++---- source/gui/addonStoreGui/viewModels/store.py | 75 ++++++---- source/utils/schedule.py | 2 +- tests/manual/addonStore.md | 56 ++++++- user_docs/en/changes.md | 7 + user_docs/en/userGuide.md | 3 + 11 files changed, 233 insertions(+), 70 deletions(-) diff --git a/source/addonStore/dataManager.py b/source/addonStore/dataManager.py index d95ad75c12f..5bc0257825f 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. @@ -309,15 +309,15 @@ def _getCachedInstalledAddonData(self, addonId: str) -> Optional[InstalledAddonS return None return _createInstalledStoreModelFromData(cacheData) - def _addonsPendingUpdate(self) -> set["_AddonGUIModel"]: - addonsPendingUpdate = set() + 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-on updates for the same channel if addon.channel == addon._addonHandlerModel._addonStoreData.channel: - addonsPendingUpdate.add(addon) + addonsPendingUpdate.append(addon) return addonsPendingUpdate diff --git a/source/config/configFlags.py b/source/config/configFlags.py index d5897b17441..a65e2a3edcb 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. diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 3df165a17bd..e4dbe9f4bbf 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. diff --git a/source/gui/__init__.py b/source/gui/__init__.py index ef79184c25f..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. diff --git a/source/gui/addonStoreGui/__init__.py b/source/gui/addonStoreGui/__init__.py index 06bad1c7a57..fae7b47aac6 100644 --- a/source/gui/addonStoreGui/__init__.py +++ b/source/gui/addonStoreGui/__init__.py @@ -1,10 +1,12 @@ # 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. import wx +from utils.schedule import ScheduleThread + from .controls.storeDialog import AddonStoreDialog from .controls.messageDialogs import UpdatableAddonsDialog @@ -15,7 +17,6 @@ def initialize(): - from utils.schedule import ScheduleThread # Ensure the GUI functionality is called from the wx thread from the schedule thread ScheduleThread.scheduleDailyJobAtStartUp( lambda: wx.CallAfter(UpdatableAddonsDialog._checkForUpdatableAddons) diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index 44f82767ad4..f4ea555b9d9 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -16,21 +16,23 @@ _AddonManifestModel, ) from addonStore.dataManager import addonDataManager +from addonStore.models.status import AvailableAddonStatus import config from config.configFlags import AddonsAutomaticUpdate import gui from gui import nvdaControls -from gui.addonGui import ConfirmAddonInstallDialog, ErrorAddonInstallDialog +from gui.addonGui import ConfirmAddonInstallDialog, ErrorAddonInstallDialog, promptUserForRestart +from gui.addonStoreGui.viewModels.addonList import AddonListItemVM from gui.contextHelp import ContextHelpMixin -from gui.dpiScalingHelper import scaleSize from gui.guiHelper import ( BoxSizerHelper, BORDER_FOR_DIALOGS, 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: @@ -364,9 +366,13 @@ class UpdatableAddonsDialog( helpId = "AutomaticAddonUpdates" - def __init__(self, parent: wx.Window, addonsPendingUpdate: set[_AddonGUIModel]): + 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.Bind(wx.EVT_CLOSE, self.onClose) + mainSizer = wx.BoxSizer(wx.VERTICAL) sHelper = BoxSizerHelper(self, orientation=wx.VERTICAL) @@ -387,12 +393,34 @@ def __init__(self, parent: wx.Window, addonsPendingUpdate: set[_AddonGUIModel]): sHelper.sizer.AddSpacer(SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS) + self._createAddonsPanel(sHelper) + + bHelper = sHelper.addDialogDismissButtons(ButtonHelper(wx.HORIZONTAL)) + + # Translators: The label of a button in a dialog + openStoreButton = bHelper.addButton(self, wx.ID_CLOSE, label=pgettext("addonStore", "Open Add-on &Store")) + 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) + + mainSizer.Add(sHelper.sizer, border=BORDER_FOR_DIALOGS, flag=wx.ALL) + self.Sizer = mainSizer + mainSizer.Fit(self) + self.CentreOnScreen() + + def _createAddonsPanel(self, sHelper: BoxSizerHelper): # Translators: the label for the addons list in the updatable addons dialog. - entriesLabel=pgettext("addonStore", "Updatable Add-ons") + entriesLabel = pgettext("addonStore", "Updatable Add-ons") self.addonsList = sHelper.addLabeledControl( entriesLabel, nvdaControls.AutoWidthColumnListCtrl, - style=wx.LC_REPORT|wx.LC_SINGLE_SEL, + 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. @@ -403,43 +431,106 @@ def __init__(self, parent: wx.Window, addonsPendingUpdate: set[_AddonGUIModel]): 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=300) - self.addonsList.AppendColumn(availableVersionLabel, width=300) + self.addonsList.AppendColumn(installedVersionLabel, width=200) + self.addonsList.AppendColumn(availableVersionLabel, width=200) self.addonsList.AppendColumn(channelLabel, width=150) - for addon in addonsPendingUpdate: + self.addonsList.AppendColumn(statusLabel, width=300) + for addon in self.addonsPendingUpdate: self.addonsList.Append(( addon.name, addon._addonHandlerModel.version, addon.addonVersionName, addon.channel.displayString, + AvailableAddonStatus.UPDATE.displayString, )) self.addonsList.Refresh() - bHelper = sHelper.addDialogDismissButtons(ButtonHelper(wx.HORIZONTAL)) - - # Translators: The label of a button in a dialog - openStoreButton = bHelper.addButton(self, wx.ID_YES, label=pgettext("addonStore", "Open Add-on &Store")) - openStoreButton.Bind(wx.EVT_BUTTON, self.onOpenStoreButton) - - # 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) - - mainSizer.Add(sHelper.sizer, border=BORDER_FOR_DIALOGS, flag=wx.ALL) - self.Sizer = mainSizer - mainSizer.Fit(self) - self.CentreOnScreen() - 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() + + 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.EndModal(wx.ID_CLOSE) + 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): 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/utils/schedule.py b/source/utils/schedule.py index 61379e2dd6a..54b1e372b4b 100644 --- a/source/utils/schedule.py +++ b/source/utils/schedule.py @@ -29,7 +29,7 @@ class ScheduleThread(threading.Thread): at each interval but only once. """ - DAILY_JOB_MINUTE_OFFSET = 3 + DAILY_JOB_MINUTE_OFFSET = 1 """ Offset in minutes to schedule daily jobs. Daily scheduled jobs occur offset by X minutes to avoid overlapping jobs. diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md index 99830ab2429..543daa9d808 100644 --- a/tests/manual/addonStore.md +++ b/tests/manual/addonStore.md @@ -161,10 +161,62 @@ 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. +### Updating from add-on installed from add-on store +1. [Install an add-on from the add-on store](#install-add-on) +For example: "Clock". +1. Go to your NVDA user configuration folder: + - For source: `.\source\userConfig` + - For installed copies: `%APPDATA%\nvda` +1. To spoof an old release, we need to edit 2 files: + - Add-on store JSON metadata + - Located in: `.\addonStore\addons\`. + - Example: `source\userConfig\addonStore\addons\clock.json` + - Edit "addonVersionNumber" and "addonVersionName": decrease the major release value number. + - Add-on manifest + - Located in: `.\addons\`. + - Example: `source\userConfig\addons\clock\manifest.ini` + - Edit "version": decrease the major release value number to match earlier edits. +1. Repeat several times to ensure multiple add-on updates are available. +1. Open the Add-on Store +1. Ensure the same add-on you edited is available on the Add-on Store with the status "update". +1. Select multiple add-ons using `shift` and `ctrl`. +1. Using the context menu, install the add-ons. +1. Exit the dialog +1. Restart NVDA as prompted. +1. Confirm the up-to-date add-ons are listed in the installed add-ons tab of the Add-ons Store. ### Automatic updating -Automatic updating of add-ons is currently unsupported. +### Updating from add-on installed from add-on store +1. [Install an add-on from the add-on store](#install-add-on) +For example: "Clock". +1. Go to your NVDA user configuration folder: + - For source: `.\source\userConfig` + - For installed copies: `%APPDATA%\nvda` +1. To spoof an old release, we need to edit 2 files: + - Add-on store JSON metadata + - Located in: `.\addonStore\addons\`. + - Example: `source\userConfig\addonStore\addons\clock.json` + - Edit "addonVersionNumber" and "addonVersionName": decrease the major release value number. + - Add-on manifest + - Located in: `.\addons\`. + - Example: `source\userConfig\addons\clock\manifest.ini` + - Edit "version": decrease the major release value number to match earlier edits. +1. 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 + from utils.schedule import ScheduleThread + ScheduleThread.scheduledJobs + ``` + 1. Replace `i` with the index of the scheduled thread to find the job + ```py + ScheduleThread.scheduledJobs[i].run() + ``` +1. Test various buttons: + - Press "Update All": Ensure NVDA installs the add-ons. + - Press "Close": Ensure if Add-ons have been installed that NVDA prompts for restart afterwards + - Press "Open Add-on Store": Ensure NVDA opens to the Updatable tab in the Add-on Store ## Other add-on actions diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index d13c77ea4c7..08bb8b247ff 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -19,6 +19,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 uses 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, the Add-on Store will notify you if any add-on updates are available. (#15035) + * This can be disabled in the "Add-on Store" section of settings. + * The notification 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 to beta add-ons). ### Changes @@ -59,6 +63,9 @@ 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, using the pip module `schedule`. +You can use `utils.schedule.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. (#16636) #### Deprecations diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index d7464c3675d..5b5b3650f06 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -3500,6 +3500,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, the Add-on Store will notify you 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. From dfebaa799b5a6e63154ceadaaafbee58ecede61f Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 16:09:44 +1000 Subject: [PATCH 12/33] fix up headings --- tests/manual/addonStore.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md index 543daa9d808..2620d5618cf 100644 --- a/tests/manual/addonStore.md +++ b/tests/manual/addonStore.md @@ -161,7 +161,6 @@ For example: "Clock". 1. Install the add-on again to test the "migrate" path. ### Updating multiple add-ons -### Updating from add-on installed from add-on store 1. [Install an add-on from the add-on store](#install-add-on) For example: "Clock". 1. Go to your NVDA user configuration folder: @@ -185,8 +184,7 @@ For example: "Clock". 1. Restart NVDA as prompted. 1. Confirm the up-to-date add-ons are listed in the installed add-ons tab of the Add-ons Store. -### Automatic updating -### Updating from add-on installed from add-on store +### Automatic update notifications 1. [Install an add-on from the add-on store](#install-add-on) For example: "Clock". 1. Go to your NVDA user configuration folder: @@ -218,6 +216,9 @@ For example: "Clock". - Press "Close": Ensure if Add-ons have been installed that NVDA prompts for restart afterwards - Press "Open Add-on Store": Ensure NVDA opens to the Updatable tab in the Add-on Store +### Automatic updating +Full automatic updates is currently unsupported. + ## Other add-on actions ### Disabling an add-on From 6680653817831dd2651f12d6a029c091366e21f0 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 16:21:43 +1000 Subject: [PATCH 13/33] simplify message dialog --- .../addonStoreGui/controls/messageDialogs.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index f4ea555b9d9..510b67a467f 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -371,11 +371,21 @@ def __init__(self, parent: wx.Window, addonsPendingUpdate: list[_AddonGUIModel]) super().__init__(parent, title=pgettext("addonStore", "Add-on updates available")) self.addonsPendingUpdate = addonsPendingUpdate self.onDisplayableError = DisplayableError.OnDisplayableErrorT() - self.Bind(wx.EVT_CLOSE, self.onClose) + self._setupUI() + def _setupUI(self): + self.Bind(wx.EVT_CLOSE, self.onClose) 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 _setupMessage(self, sHelper: BoxSizerHelper): _message = pgettext( "addonStore", # Translators: Message displayed when updates are available for some installed add-ons. @@ -386,15 +396,10 @@ def __init__(self, parent: wx.Window, addonsPendingUpdate: list[_AddonGUIModel]) 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()) - sText.Wrap( - # 600 was fairly arbitrarily chosen by a visual user to look acceptable on their machine. - self.scaleFactor * 600 - ) - - sHelper.sizer.AddSpacer(SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS) - - self._createAddonsPanel(sHelper) + # 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 @@ -409,11 +414,6 @@ def __init__(self, parent: wx.Window, addonsPendingUpdate: list[_AddonGUIModel]) closeButton = bHelper.addButton(self, wx.ID_CLOSE, label=pgettext("addonStore", "&Close")) closeButton.Bind(wx.EVT_BUTTON, self.onCloseButton) - mainSizer.Add(sHelper.sizer, border=BORDER_FOR_DIALOGS, flag=wx.ALL) - self.Sizer = mainSizer - mainSizer.Fit(self) - self.CentreOnScreen() - def _createAddonsPanel(self, sHelper: BoxSizerHelper): # Translators: the label for the addons list in the updatable addons dialog. entriesLabel = pgettext("addonStore", "Updatable Add-ons") From 7b71bbeb42cc3c6d7dfb4611c9a574bd4f32fbe8 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 17:03:00 +1000 Subject: [PATCH 14/33] add to release blurb --- user_docs/en/changes.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 08bb8b247ff..265d9253113 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -5,7 +5,8 @@ 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 uses 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. + +The Add-on Store will now notify you if any add-on updates are available on NVDA start up. 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. From ca5b985dff2b04f8cd213237b9f419f5738c26ac Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Mon, 3 Jun 2024 17:07:21 +1000 Subject: [PATCH 15/33] disable in secure mode --- source/gui/addonStoreGui/controls/messageDialogs.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index 510b67a467f..7880d43bc19 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -19,6 +19,7 @@ from addonStore.models.status import AvailableAddonStatus import config from config.configFlags import AddonsAutomaticUpdate +import globalVars import gui from gui import nvdaControls from gui.addonGui import ConfirmAddonInstallDialog, ErrorAddonInstallDialog, promptUserForRestart @@ -534,8 +535,11 @@ def postInstall(): @classmethod def _checkForUpdatableAddons(cls): - if AddonsAutomaticUpdate.DISABLED == config.conf["addonStore"]["automaticUpdates"]: - log.debug("automatic updates are disabled") + 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() From 7dab8aafc62561b381288f61456b3b6310818d8e Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 4 Jun 2024 14:27:27 +1000 Subject: [PATCH 16/33] improve the scheduleThread module and functionality --- source/gui/addonStoreGui/__init__.py | 8 +- source/utils/schedule.py | 125 ++++++++++++++++++++++++-- tests/unit/test_util/test_schedule.py | 76 +++++++++++++++- user_docs/en/changes.md | 10 +-- 4 files changed, 199 insertions(+), 20 deletions(-) diff --git a/source/gui/addonStoreGui/__init__.py b/source/gui/addonStoreGui/__init__.py index fae7b47aac6..0468f387f8b 100644 --- a/source/gui/addonStoreGui/__init__.py +++ b/source/gui/addonStoreGui/__init__.py @@ -3,9 +3,7 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -import wx - -from utils.schedule import ScheduleThread +from utils.schedule import ScheduleThread, ThreadTarget from .controls.storeDialog import AddonStoreDialog from .controls.messageDialogs import UpdatableAddonsDialog @@ -17,7 +15,7 @@ def initialize(): - # Ensure the GUI functionality is called from the wx thread from the schedule thread ScheduleThread.scheduleDailyJobAtStartUp( - lambda: wx.CallAfter(UpdatableAddonsDialog._checkForUpdatableAddons) + UpdatableAddonsDialog._checkForUpdatableAddons, + queueToThread=ThreadTarget.GUI, ) diff --git a/source/utils/schedule.py b/source/utils/schedule.py index 54b1e372b4b..fc534e57808 100644 --- a/source/utils/schedule.py +++ b/source/utils/schedule.py @@ -4,17 +4,44 @@ # 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): + 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`. + Run code on a custom thread to ensure `scheduleThread` is not blocked. + """ + + +class JobClashError(Exception): + """Raised when a job time clashes with an existing job.""" + pass + + class ScheduleThread(threading.Thread): name = "ScheduleThread" @@ -29,13 +56,14 @@ class ScheduleThread(threading.Thread): at each interval but only once. """ - DAILY_JOB_MINUTE_OFFSET = 1 + DAILY_JOB_MINUTE_OFFSET = 3 """ Offset in minutes to schedule daily jobs. Daily scheduled jobs occur offset by X minutes to avoid overlapping jobs. """ scheduledJobs: list[schedule.Job] = [] + scheduledDailyJobCount = 0 @classmethod def run(cls): @@ -44,19 +72,103 @@ def run(cls): time.sleep(cls.SLEEP_INTERVAL_SECS) @classmethod - def scheduleDailyJobAtStartUp(cls, job: Callable, *args, **kwargs): - """Schedule a daily job to run at startup.""" + def _calculateDailyTimeOffset(cls) -> 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 + (len(cls.scheduledJobs) + 1) * cls.DAILY_JOB_MINUTE_OFFSET + startTimeMinuteOffset = startTime.minute + (cls.scheduledDailyJobCount + 1) * cls.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 - scheduledTime = f"{startTimeHourOffset:02d}:{startTimeMinuteOffset:02d}" - scheduledJob = schedule.every().day.at(scheduledTime).do(job, *args, **kwargs) + return f"{startTimeHourOffset:02d}:{startTimeMinuteOffset:02d}" + + @classmethod + def scheduleDailyJobAtStartUp(cls, job: Callable, queueToThread: ThreadTarget, *args, **kwargs): + """ + Schedule a daily job to run at startup. + Designed to handle clashes in a smart way to offset jobs. + :param job: The job to run. + :param queueToThread: The thread to run the job on. + :param args: Arguments to pass to the job. + :param kwargs: Keyword arguments to pass to the job. + """ + try: + cls.scheduleDailyJob(job, cls._calculateDailyTimeOffset(), queueToThread, *args, **kwargs) + except JobClashError as e: + log.warning(f"Failed to schedule daily job due to clash: {e}") + cls.scheduledDailyJobCount += 1 + log.debugWarning(f"Attempting to reschedule daily job {cls.DAILY_JOB_MINUTE_OFFSET} min later") + return cls.scheduleDailyJobAtStartUp(job, queueToThread, *args, **kwargs) + else: + cls.scheduledDailyJobCount += 1 + + @classmethod + def scheduleDailyJob(cls, job: Callable, cronTime: str, queueToThread: ThreadTarget, *args, **kwargs): + """ + Schedule a daily job to run at specific times. + :param job: The job 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 job on. + :param args: Arguments to pass to the job. + :param kwargs: Keyword arguments to pass to the job. + :raises JobClashError: If the job's next run clashes with an existing job's next run. + """ + scheduledJob = schedule.every().day.at(cronTime) + cls.scheduleJob(job, scheduledJob, queueToThread, *args, **kwargs) + + @classmethod + def scheduleJob(cls, job: Callable, jobSchedule: schedule.Job, queueToThread: ThreadTarget, *args, **kwargs): + """ + Schedule a job to run at specific times. + :param job: The job to run. + :param jobSchedule: The schedule to run the job 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 job on. + :param args: Arguments to pass to the job. + :param kwargs: Keyword arguments to pass to the 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: {job.__name__} on GUI thread") + wx.CallAfter(job, *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=job, args=args, kwargs=kwargs, daemon=True, name=f"{job.__name__}") + log.debug(f"Starting thread for job: {job.__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: {job.__name__} on custom thread") + job(*args, **kwargs) + case _: + raise ValueError(f"Invalid queueToThread value: {queueToThread}") + # Check if scheduled job time clashes with existing jobs. + for existingJob in cls.scheduledJobs: + 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 {job}" + ) + scheduledJob = jobSchedule.do(callJobOnThread, *args, **kwargs) cls.scheduledJobs.append(scheduledJob) @@ -70,6 +182,7 @@ def terminate(): global scheduleThread if scheduleThread is not None: ScheduleThread.scheduledJobs.clear() + ScheduleThread.scheduledDailyJobCount = 0 schedule.clear() scheduleThread.KILL.set() scheduleThread.join() diff --git a/tests/unit/test_util/test_schedule.py b/tests/unit/test_util/test_schedule.py index 85005621157..63f28cba2c3 100644 --- a/tests/unit/test_util/test_schedule.py +++ b/tests/unit/test_util/test_schedule.py @@ -7,8 +7,16 @@ import unittest from unittest.mock import MagicMock +import schedule + import NVDAState -from utils.schedule import ScheduleThread, initialize, terminate +from utils.schedule import ( + JobClashError, + ScheduleThread, + ThreadTarget, + initialize, + terminate, +) class ScheduleThreadTests(unittest.TestCase): @@ -33,9 +41,12 @@ def incrementB(scheduledVals: list): def incrementC(scheduledVals: list): scheduledVals[2] += 1 - ScheduleThread.scheduleDailyJobAtStartUp(incrementA, scheduledVals) - ScheduleThread.scheduleDailyJobAtStartUp(incrementB, scheduledVals) - ScheduleThread.scheduleDailyJobAtStartUp(incrementC, scheduledVals) + ScheduleThread.scheduleDailyJobAtStartUp(incrementA, ThreadTarget.CUSTOM, scheduledVals) + ScheduleThread.scheduleDailyJobAtStartUp(incrementB, ThreadTarget.CUSTOM, scheduledVals) + ScheduleThread.scheduleDailyJobAtStartUp(incrementC, ThreadTarget.CUSTOM, scheduledVals) + # Sanity checks (have failed in development) + self.assertEqual(len(ScheduleThread.scheduledJobs), 3) + self.assertEqual(ScheduleThread.scheduledDailyJobCount, 3) expectedResult = [0, 0, 0] self.assertEqual(scheduledVals, expectedResult) @@ -68,3 +79,60 @@ def incrementC(scheduledVals: list): expectedResult, f"Job {jobIndex} did not run as expected. Scheduled jobs: {ScheduleThread.scheduledJobs}" ) + + 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 + ScheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) + + # Assert that the job is scheduled correctly + self.assertEqual(len(ScheduleThread.scheduledJobs), 1) + scheduledJob = ScheduleThread.scheduledJobs[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 + ScheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) + + with self.assertRaises(JobClashError): + # Call the scheduleJob method with the same cron time + 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()) + # Call the _calculateDailyTimeOffset method + offset = ScheduleThread._calculateDailyTimeOffset() + # Assert that the offset is calculated correctly + self.assertEqual(offset, f"00:{ScheduleThread.DAILY_JOB_MINUTE_OFFSET:02d}") + + ScheduleThread.scheduledDailyJobCount = 1 + offset = 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()) + ScheduleThread.scheduledDailyJobCount = 0 + offset = 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()) + ScheduleThread.scheduledDailyJobCount = 0 + offset = 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 265d9253113..4944891a301 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -1,13 +1,12 @@ # 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 uses on social media. It also allows reading of equations in the Microsoft Word equation editor. -The Add-on Store will now notify you if any add-on updates are available on NVDA start up. - 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. @@ -64,9 +63,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, using the pip module `schedule`. -You can use `utils.schedule.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. (#16636) +* 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 From a7ef96111974136d672d58678fb790eacf75306e Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 4 Jun 2024 17:08:37 +1000 Subject: [PATCH 17/33] review feedback --- source/addonStore/dataManager.py | 6 +- source/config/__init__.py | 13 ++-- .../addonStoreGui/controls/messageDialogs.py | 12 +-- tests/manual/addonStore.md | 77 ++++++------------- tests/unit/test_util/test_schedule.py | 5 +- user_docs/en/changes.md | 8 +- user_docs/en/userGuide.md | 6 +- 7 files changed, 52 insertions(+), 75 deletions(-) diff --git a/source/addonStore/dataManager.py b/source/addonStore/dataManager.py index 5bc0257825f..9923427e1ca 100644 --- a/source/addonStore/dataManager.py +++ b/source/addonStore/dataManager.py @@ -314,7 +314,11 @@ def _addonsPendingUpdate(self) -> list["_AddonGUIModel"]: compatibleAddons = self.getLatestCompatibleAddons() for channel in compatibleAddons: for addon in compatibleAddons[channel].values(): - if getStatus(addon, _StatusFilterKey.UPDATE) == AvailableAddonStatus.UPDATE: + if ( + # Only consider add-ons that have been installed through the Add-on Store + addon._addonHandlerModel._addonStoreData is not None + and getStatus(addon, _StatusFilterKey.UPDATE) == AvailableAddonStatus.UPDATE + ): # Only consider add-on updates for the same channel if addon.channel == addon._addonHandlerModel._addonStoreData.channel: addonsPendingUpdate.append(addon) diff --git a/source/config/__init__.py b/source/config/__init__.py index 9abeed7adbc..6b949023427 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/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index 7880d43bc19..43bc4ed328b 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -391,7 +391,6 @@ def _setupMessage(self, sHelper: BoxSizerHelper): "addonStore", # Translators: Message displayed when updates are available for some installed add-ons. "Updates are available for some of your installed add-ons. " - "Open the Add-on Store to update them. " ) sText = sHelper.addItem(wx.StaticText(self, label=_message)) @@ -435,11 +434,11 @@ def _createAddonsPanel(self, sHelper: BoxSizerHelper): # 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) + self.addonsList.AppendColumn(nameLabel, width=self.scaleFactor * 300) + self.addonsList.AppendColumn(installedVersionLabel, width=self.scaleFactor * 200) + self.addonsList.AppendColumn(availableVersionLabel, width=self.scaleFactor * 200) + self.addonsList.AppendColumn(channelLabel, width=self.scaleFactor * 150) + self.addonsList.AppendColumn(statusLabel, width=self.scaleFactor * 300) for addon in self.addonsPendingUpdate: self.addonsList.Append(( addon.name, @@ -465,6 +464,7 @@ def onUpdateAllButton(self, evt: wx.CommandEvent): self.listItemVMs.append(listItemVM) AddonStoreVM.getAddons(self.listItemVMs) self.addonsList.Refresh() + self.addonsList.Focus(0) # Translators: Message shown when updating add-ons in the updatable add-ons dialog ui.message(pgettext("addonStore", "Updating add-ons...")) self.updateAllButton.Disable() diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md index 2620d5618cf..5af0cb8005b 100644 --- a/tests/manual/addonStore.md +++ b/tests/manual/addonStore.md @@ -100,38 +100,40 @@ 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 from the Add-on Store +Without having a previously installed add-on get an update, it is hard to naturally test updatable add-ons. +This process allows you to spoof an update for an 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` + - For source: `.\source\userConfig\addons` + - For installed copies: `%APPDATA%\nvda\addons` 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` + - 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". + +### Updating from add-on installed from Add-on Store +1. [Simulate creating an updatable add-on from the Add-on Store](#simulate-creating-an-updatable-add-on-from-the-add-on-store) +1. Open the Add-on Store +1. Ensure the same add-on you edited is available on the Add-on Store with the status "update". 1. Install the add-on again to test the "update" path. ### Updating from add-on installed externally with valid version -1. [Install an add-on from the add-on store](#install-add-on) +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` + - For source: `.\source\userConfig\addons` + - For installed copies: `%APPDATA%\nvda\addons` 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` + - 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 @@ -145,15 +147,13 @@ 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` + - For source: `.\source\userConfig\addons` + - For installed copies: `%APPDATA%\nvda\addons` 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` + - 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,21 +161,7 @@ For example: "Clock". 1. Install the add-on again to test the "migrate" path. ### Updating multiple add-ons -1. [Install an add-on from the add-on store](#install-add-on) -For example: "Clock". -1. Go to your NVDA user configuration folder: - - For source: `.\source\userConfig` - - For installed copies: `%APPDATA%\nvda` -1. To spoof an old release, we need to edit 2 files: - - Add-on store JSON metadata - - Located in: `.\addonStore\addons\`. - - Example: `source\userConfig\addonStore\addons\clock.json` - - Edit "addonVersionNumber" and "addonVersionName": decrease the major release value number. - - Add-on manifest - - Located in: `.\addons\`. - - Example: `source\userConfig\addons\clock\manifest.ini` - - Edit "version": decrease the major release value number to match earlier edits. -1. Repeat several times to ensure multiple add-on updates are available. +1. Repeatedly [create updatable add-ons](#simulate-creating-an-updatable-add-on-from-the-add-on-store). 1. Open the Add-on Store 1. Ensure the same add-on you edited is available on the Add-on Store with the status "update". 1. Select multiple add-ons using `shift` and `ctrl`. @@ -185,20 +171,7 @@ For example: "Clock". 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. [Install an add-on from the add-on store](#install-add-on) -For example: "Clock". -1. Go to your NVDA user configuration folder: - - For source: `.\source\userConfig` - - For installed copies: `%APPDATA%\nvda` -1. To spoof an old release, we need to edit 2 files: - - Add-on store JSON metadata - - Located in: `.\addonStore\addons\`. - - Example: `source\userConfig\addonStore\addons\clock.json` - - Edit "addonVersionNumber" and "addonVersionName": decrease the major release value number. - - Add-on manifest - - Located in: `.\addons\`. - - Example: `source\userConfig\addons\clock\manifest.ini` - - Edit "version": decrease the major release value number to match earlier edits. +1. Repeatedly [create updatable add-ons](#simulate-creating-an-updatable-add-on-from-the-add-on-store). 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 diff --git a/tests/unit/test_util/test_schedule.py b/tests/unit/test_util/test_schedule.py index 63f28cba2c3..0b5c8405e62 100644 --- a/tests/unit/test_util/test_schedule.py +++ b/tests/unit/test_util/test_schedule.py @@ -51,7 +51,6 @@ def incrementC(scheduledVals: list): expectedResult = [0, 0, 0] self.assertEqual(scheduledVals, expectedResult) for jobIndex in range(3): - # Simulate a forced job run startTime = NVDAState.getStartTime() currentJob = ScheduleThread.scheduledJobs[jobIndex] @@ -63,12 +62,12 @@ def incrementC(scheduledVals: list): self.assertLessEqual( actualSecsOffset, expectedSecsOffsetMax, - f"Job {jobIndex} did not scheduled as expected. Job: {currentJob}" + f"Job {jobIndex} was not scheduled as expected. Job: {currentJob}" ) self.assertGreaterEqual( actualSecsOffset, expectedSecsOffsetMin, - f"Job {jobIndex} did not scheduled as expected. Job: {currentJob}" + f"Job {jobIndex} was not scheduled as expected. Job: {currentJob}" ) # Ensure the job runs as expected diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 4944891a301..0430281703a 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -19,10 +19,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 uses 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, the Add-on Store will notify you if any add-on updates are available. (#15035) - * This can be disabled in the "Add-on Store" section of settings. - * The notification 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 to beta add-ons). +* 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 diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 5b5b3650f06..dee312c3fae 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -352,7 +352,7 @@ 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, the Add-on Store will notify you if any add-on updates are available. +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} @@ -2900,7 +2900,7 @@ This category allows you to adjust the behaviour of the Add-on Store. ##### Update Notifications {#AutomaticAddonUpdates} -By default, after NVDA startup, the Add-on Store will notify you if any add-on updates are available. +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. @@ -3500,7 +3500,7 @@ 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, the Add-on Store will notify you if any add-on updates are available. +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} From c097f39f9f1223b6b7e379ad8625f32119cef275 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 4 Jun 2024 17:15:20 +1000 Subject: [PATCH 18/33] minor fixups --- source/addonStore/dataManager.py | 4 ++-- source/gui/addonStoreGui/controls/messageDialogs.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/source/addonStore/dataManager.py b/source/addonStore/dataManager.py index 9923427e1ca..ced86a4bcf5 100644 --- a/source/addonStore/dataManager.py +++ b/source/addonStore/dataManager.py @@ -315,9 +315,9 @@ def _addonsPendingUpdate(self) -> list["_AddonGUIModel"]: 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 - addon._addonHandlerModel._addonStoreData is not None - and getStatus(addon, _StatusFilterKey.UPDATE) == AvailableAddonStatus.UPDATE + and addon._addonHandlerModel._addonStoreData is not None ): # Only consider add-on updates for the same channel if addon.channel == addon._addonHandlerModel._addonStoreData.channel: diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index 43bc4ed328b..ab603dbae45 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -434,11 +434,11 @@ def _createAddonsPanel(self, sHelper: BoxSizerHelper): # 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=self.scaleFactor * 300) - self.addonsList.AppendColumn(installedVersionLabel, width=self.scaleFactor * 200) - self.addonsList.AppendColumn(availableVersionLabel, width=self.scaleFactor * 200) - self.addonsList.AppendColumn(channelLabel, width=self.scaleFactor * 150) - self.addonsList.AppendColumn(statusLabel, width=self.scaleFactor * 300) + 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.name, From 1780d8bfa126400337cc0f4f7df0a7243dbac156 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 4 Jun 2024 17:19:14 +1000 Subject: [PATCH 19/33] set focus and raise --- source/gui/addonStoreGui/controls/messageDialogs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index ab603dbae45..939ab4d51b2 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -373,6 +373,8 @@ def __init__(self, parent: wx.Window, addonsPendingUpdate: list[_AddonGUIModel]) self.addonsPendingUpdate = addonsPendingUpdate self.onDisplayableError = DisplayableError.OnDisplayableErrorT() self._setupUI() + self.Raise() + self.SetFocus() def _setupUI(self): self.Bind(wx.EVT_CLOSE, self.onClose) From aa3f7421d04de986e3f98dd36cbfa731de605de0 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 4 Jun 2024 17:38:46 +1000 Subject: [PATCH 20/33] fix lint --- source/config/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/config/__init__.py b/source/config/__init__.py index 6b949023427..33a76b3363b 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -507,8 +507,8 @@ class ConfigManager(object): #: Sections that only apply to the base configuration; #: i.e. they cannot be overridden in profiles. BASE_ONLY_SECTIONS = frozenset({ - "general", - "update", + "general", + "update", "upgrade", "development", "addonStore", From 4ba66cc47452acc5df9ed4eda71ce38347fb2cdd Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 5 Jun 2024 09:33:34 +1000 Subject: [PATCH 21/33] remove double handling of tracking jobs --- source/utils/schedule.py | 83 +++++++++++++++++---------- tests/unit/test_util/test_schedule.py | 10 ++-- 2 files changed, 57 insertions(+), 36 deletions(-) diff --git a/source/utils/schedule.py b/source/utils/schedule.py index fc534e57808..be463501892 100644 --- a/source/utils/schedule.py +++ b/source/utils/schedule.py @@ -62,7 +62,6 @@ class ScheduleThread(threading.Thread): Daily scheduled jobs occur offset by X minutes to avoid overlapping jobs. """ - scheduledJobs: list[schedule.Job] = [] scheduledDailyJobCount = 0 @classmethod @@ -85,74 +84,98 @@ def _calculateDailyTimeOffset(cls) -> str: return f"{startTimeHourOffset:02d}:{startTimeMinuteOffset:02d}" @classmethod - def scheduleDailyJobAtStartUp(cls, job: Callable, queueToThread: ThreadTarget, *args, **kwargs): + def scheduleDailyJobAtStartUp( + cls, + 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 job: The job to run. - :param queueToThread: The thread to run the job on. - :param args: Arguments to pass to the job. - :param kwargs: Keyword arguments to pass to the job. + :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: - cls.scheduleDailyJob(job, cls._calculateDailyTimeOffset(), queueToThread, *args, **kwargs) + job = cls.scheduleDailyJob(task, cls._calculateDailyTimeOffset(), queueToThread, *args, **kwargs) except JobClashError as e: log.warning(f"Failed to schedule daily job due to clash: {e}") cls.scheduledDailyJobCount += 1 log.debugWarning(f"Attempting to reschedule daily job {cls.DAILY_JOB_MINUTE_OFFSET} min later") - return cls.scheduleDailyJobAtStartUp(job, queueToThread, *args, **kwargs) + return cls.scheduleDailyJobAtStartUp(task, queueToThread, *args, **kwargs) else: cls.scheduledDailyJobCount += 1 + return job @classmethod - def scheduleDailyJob(cls, job: Callable, cronTime: str, queueToThread: ThreadTarget, *args, **kwargs): + def scheduleDailyJob( + cls, + task: Callable, + cronTime: str, + queueToThread: ThreadTarget, + *args, + **kwargs + ) -> schedule.Job: """ Schedule a daily job to run at specific times. - :param job: The job to run. + :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 job on. - :param args: Arguments to pass to the job. - :param kwargs: Keyword arguments to pass to the job. + :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) - cls.scheduleJob(job, scheduledJob, queueToThread, *args, **kwargs) + return cls.scheduleJob(task, scheduledJob, queueToThread, *args, **kwargs) @classmethod - def scheduleJob(cls, job: Callable, jobSchedule: schedule.Job, queueToThread: ThreadTarget, *args, **kwargs): + def scheduleJob( + cls, + task: Callable, + jobSchedule: schedule.Job, + queueToThread: ThreadTarget, + *args, + **kwargs + ) -> schedule.Job: """ Schedule a job to run at specific times. - :param job: The job to run. - :param jobSchedule: The schedule to run the job on. + :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 job on. - :param args: Arguments to pass to the job. - :param kwargs: Keyword arguments to pass to the job. + :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: {job.__name__} on GUI thread") - wx.CallAfter(job, *args, **kwargs) + 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=job, args=args, kwargs=kwargs, daemon=True, name=f"{job.__name__}") - log.debug(f"Starting thread for job: {job.__name__} on thread {t.ident}") + 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: {job.__name__} on custom thread") - job(*args, **kwargs) + 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 cls.scheduledJobs: + for existingJob in schedule.jobs: if ( ( jobSchedule.at_time is not None @@ -166,10 +189,9 @@ def callJobOnThread(*args, **kwargs): # noqa F811: lint bug with flake8 4.0.1 n # 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 {job}" + f"{existingJob.job_func} and {task.__name__}" ) - scheduledJob = jobSchedule.do(callJobOnThread, *args, **kwargs) - cls.scheduledJobs.append(scheduledJob) + return jobSchedule.do(callJobOnThread, *args, **kwargs) def initialize(): @@ -181,7 +203,6 @@ def initialize(): def terminate(): global scheduleThread if scheduleThread is not None: - ScheduleThread.scheduledJobs.clear() ScheduleThread.scheduledDailyJobCount = 0 schedule.clear() scheduleThread.KILL.set() diff --git a/tests/unit/test_util/test_schedule.py b/tests/unit/test_util/test_schedule.py index 0b5c8405e62..8fa831e338e 100644 --- a/tests/unit/test_util/test_schedule.py +++ b/tests/unit/test_util/test_schedule.py @@ -45,14 +45,14 @@ def incrementC(scheduledVals: list): ScheduleThread.scheduleDailyJobAtStartUp(incrementB, ThreadTarget.CUSTOM, scheduledVals) ScheduleThread.scheduleDailyJobAtStartUp(incrementC, ThreadTarget.CUSTOM, scheduledVals) # Sanity checks (have failed in development) - self.assertEqual(len(ScheduleThread.scheduledJobs), 3) + self.assertEqual(len(schedule.jobs), 3) self.assertEqual(ScheduleThread.scheduledDailyJobCount, 3) expectedResult = [0, 0, 0] self.assertEqual(scheduledVals, expectedResult) for jobIndex in range(3): startTime = NVDAState.getStartTime() - currentJob = ScheduleThread.scheduledJobs[jobIndex] + currentJob = schedule.jobs[jobIndex] # Ensure that the job is scheduled to run at the expected time expectedSecsOffsetMin = jobIndex * ScheduleThread.DAILY_JOB_MINUTE_OFFSET * 60 @@ -76,7 +76,7 @@ def incrementC(scheduledVals: list): self.assertEqual( scheduledVals, expectedResult, - f"Job {jobIndex} did not run as expected. Scheduled jobs: {ScheduleThread.scheduledJobs}" + f"Job {jobIndex} did not run as expected. Scheduled jobs: {schedule.jobs}" ) def test_scheduleJob(self): @@ -91,8 +91,8 @@ def jobFunc(): ScheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) # Assert that the job is scheduled correctly - self.assertEqual(len(ScheduleThread.scheduledJobs), 1) - scheduledJob = ScheduleThread.scheduledJobs[0] + 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): From f9742b1aa747c24e2a89efbc7c9ff9f0413bb926 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 5 Jun 2024 10:27:58 +1000 Subject: [PATCH 22/33] handle review comments --- source/gui/addonStoreGui/__init__.py | 4 +-- .../addonStoreGui/controls/messageDialogs.py | 2 +- source/utils/schedule.py | 32 ++++++++--------- tests/manual/addonStore.md | 6 ++-- tests/unit/test_util/test_schedule.py | 34 +++++++++++-------- user_docs/en/changes.md | 4 +-- 6 files changed, 42 insertions(+), 40 deletions(-) diff --git a/source/gui/addonStoreGui/__init__.py b/source/gui/addonStoreGui/__init__.py index 0468f387f8b..d85ba5c1a99 100644 --- a/source/gui/addonStoreGui/__init__.py +++ b/source/gui/addonStoreGui/__init__.py @@ -3,7 +3,7 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -from utils.schedule import ScheduleThread, ThreadTarget +from utils.schedule import scheduleThread, ThreadTarget from .controls.storeDialog import AddonStoreDialog from .controls.messageDialogs import UpdatableAddonsDialog @@ -15,7 +15,7 @@ def initialize(): - ScheduleThread.scheduleDailyJobAtStartUp( + scheduleThread.scheduleDailyJobAtStartUp( UpdatableAddonsDialog._checkForUpdatableAddons, queueToThread=ThreadTarget.GUI, ) diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index 939ab4d51b2..6a07b853f89 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -466,10 +466,10 @@ def onUpdateAllButton(self, evt: wx.CommandEvent): self.listItemVMs.append(listItemVM) AddonStoreVM.getAddons(self.listItemVMs) self.addonsList.Refresh() - self.addonsList.Focus(0) # Translators: Message shown when updating add-ons in the updatable add-ons dialog ui.message(pgettext("addonStore", "Updating add-ons...")) self.updateAllButton.Disable() + self.addonsList.SetFocus() def _statusUpdate(self, addonListItemVM: AddonListItemVM): log.debug(f"{addonListItemVM.Id} status: {addonListItemVM.status}") diff --git a/source/utils/schedule.py b/source/utils/schedule.py index be463501892..309ce2e0c4f 100644 --- a/source/utils/schedule.py +++ b/source/utils/schedule.py @@ -62,7 +62,9 @@ class ScheduleThread(threading.Thread): Daily scheduled jobs occur offset by X minutes to avoid overlapping jobs. """ - scheduledDailyJobCount = 0 + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.scheduledDailyJobCount = 0 @classmethod def run(cls): @@ -70,12 +72,11 @@ def run(cls): schedule.run_pending() time.sleep(cls.SLEEP_INTERVAL_SECS) - @classmethod - def _calculateDailyTimeOffset(cls) -> str: + 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 + (cls.scheduledDailyJobCount + 1) * cls.DAILY_JOB_MINUTE_OFFSET + 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 @@ -83,9 +84,8 @@ def _calculateDailyTimeOffset(cls) -> str: startTimeHourOffset = startTimeHourOffset % 24 return f"{startTimeHourOffset:02d}:{startTimeMinuteOffset:02d}" - @classmethod def scheduleDailyJobAtStartUp( - cls, + self, task: Callable, queueToThread: ThreadTarget, *args, @@ -101,19 +101,18 @@ def scheduleDailyJobAtStartUp( :return: The scheduled job. """ try: - job = cls.scheduleDailyJob(task, cls._calculateDailyTimeOffset(), queueToThread, *args, **kwargs) + 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}") - cls.scheduledDailyJobCount += 1 - log.debugWarning(f"Attempting to reschedule daily job {cls.DAILY_JOB_MINUTE_OFFSET} min later") - return cls.scheduleDailyJobAtStartUp(task, queueToThread, *args, **kwargs) + 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: - cls.scheduledDailyJobCount += 1 + self.scheduledDailyJobCount += 1 return job - @classmethod def scheduleDailyJob( - cls, + self, task: Callable, cronTime: str, queueToThread: ThreadTarget, @@ -133,11 +132,10 @@ def scheduleDailyJob( :raises JobClashError: If the job's next run clashes with an existing job's next run. """ scheduledJob = schedule.every().day.at(cronTime) - return cls.scheduleJob(task, scheduledJob, queueToThread, *args, **kwargs) + return self.scheduleJob(task, scheduledJob, queueToThread, *args, **kwargs) - @classmethod def scheduleJob( - cls, + self, task: Callable, jobSchedule: schedule.Job, queueToThread: ThreadTarget, @@ -174,6 +172,7 @@ def callJobOnThread(*args, **kwargs): # noqa F811: lint bug with flake8 4.0.1 n 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 ( @@ -203,7 +202,6 @@ def initialize(): def terminate(): global scheduleThread if scheduleThread is not None: - ScheduleThread.scheduledDailyJobCount = 0 schedule.clear() scheduleThread.KILL.set() scheduleThread.join() diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md index 5af0cb8005b..efce23dc1cd 100644 --- a/tests/manual/addonStore.md +++ b/tests/manual/addonStore.md @@ -177,12 +177,12 @@ For example: "Clock". 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 - from utils.schedule import ScheduleThread - ScheduleThread.scheduledJobs + import schedule + schedule.jobs ``` 1. Replace `i` with the index of the scheduled thread to find the job ```py - ScheduleThread.scheduledJobs[i].run() + schedule.jobs[i].run() ``` 1. Test various buttons: - Press "Update All": Ensure NVDA installs the add-ons. diff --git a/tests/unit/test_util/test_schedule.py b/tests/unit/test_util/test_schedule.py index 8fa831e338e..9cf923cce58 100644 --- a/tests/unit/test_util/test_schedule.py +++ b/tests/unit/test_util/test_schedule.py @@ -10,6 +10,9 @@ 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, @@ -23,6 +26,8 @@ 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): @@ -41,12 +46,12 @@ def incrementB(scheduledVals: list): def incrementC(scheduledVals: list): scheduledVals[2] += 1 - ScheduleThread.scheduleDailyJobAtStartUp(incrementA, ThreadTarget.CUSTOM, scheduledVals) - ScheduleThread.scheduleDailyJobAtStartUp(incrementB, ThreadTarget.CUSTOM, scheduledVals) - ScheduleThread.scheduleDailyJobAtStartUp(incrementC, ThreadTarget.CUSTOM, scheduledVals) + _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(ScheduleThread.scheduledDailyJobCount, 3) + self.assertEqual(_sch.scheduleThread.scheduledDailyJobCount, 3) expectedResult = [0, 0, 0] self.assertEqual(scheduledVals, expectedResult) @@ -88,7 +93,7 @@ def jobFunc(): jobSchedule = schedule.every().day.at(cronTime) # Call the scheduleJob method - ScheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) + _sch.scheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) # Assert that the job is scheduled correctly self.assertEqual(len(schedule.jobs), 1) @@ -104,34 +109,33 @@ def jobFunc(): jobSchedule = schedule.every().day.at(cronTime) # Call the scheduleJob method - ScheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) + _sch.scheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) with self.assertRaises(JobClashError): # Call the scheduleJob method with the same cron time - ScheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) + _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()) - # Call the _calculateDailyTimeOffset method - offset = ScheduleThread._calculateDailyTimeOffset() + offset = _sch.scheduleThread._calculateDailyTimeOffset() # Assert that the offset is calculated correctly self.assertEqual(offset, f"00:{ScheduleThread.DAILY_JOB_MINUTE_OFFSET:02d}") - ScheduleThread.scheduledDailyJobCount = 1 - offset = ScheduleThread._calculateDailyTimeOffset() + _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()) - ScheduleThread.scheduledDailyJobCount = 0 - offset = ScheduleThread._calculateDailyTimeOffset() + _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()) - ScheduleThread.scheduledDailyJobCount = 0 - offset = ScheduleThread._calculateDailyTimeOffset() + _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 0430281703a..163c9cf7321 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -64,9 +64,9 @@ It is especially useful to read the error location markers in tracebacks. (#1632 * 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. + * 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. + * `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 From 25f0f29aa6478671a850e6f59fdff25dc3c3f27b Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 5 Jun 2024 10:34:48 +1000 Subject: [PATCH 23/33] review docs --- source/utils/schedule.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/source/utils/schedule.py b/source/utils/schedule.py index 309ce2e0c4f..cd528bb4095 100644 --- a/source/utils/schedule.py +++ b/source/utils/schedule.py @@ -18,6 +18,16 @@ 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. @@ -33,7 +43,8 @@ class ThreadTarget(Enum): """ No thread target. Runs directly and blocks `scheduleThread`. - Run code on a custom thread to ensure `scheduleThread` is not blocked. + Authors of tasks are responsible for running the task on a + separate thread to ensure that `scheduleThread` is not blocked. """ From 32186548b353e78cc501ba86cefbd22ca4c36552 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 5 Jun 2024 10:45:15 +1000 Subject: [PATCH 24/33] fix lint --- source/utils/schedule.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/utils/schedule.py b/source/utils/schedule.py index cd528bb4095..f611f972e4b 100644 --- a/source/utils/schedule.py +++ b/source/utils/schedule.py @@ -27,7 +27,6 @@ class ThreadTarget(Enum): for running the task on a separate thread. """ - GUI = auto() """ Uses wx.CallAfter to run the job on the GUI thread. From 7a82c938b45ec7359e885a843549007b040911ca Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 5 Jun 2024 13:31:58 +1000 Subject: [PATCH 25/33] address review comments --- .../gui/addonStoreGui/controls/messageDialogs.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index 6a07b853f89..863fa77a215 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -378,6 +378,7 @@ def __init__(self, parent: wx.Window, addonsPendingUpdate: list[_AddonGUIModel]) 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) @@ -388,6 +389,11 @@ def _setupUI(self): 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", @@ -426,15 +432,15 @@ def _createAddonsPanel(self, sHelper: BoxSizerHelper): ) # Translators: Label for an extra detail field for an add-on. In the add-on store UX. - nameLabel = pgettext("addonStore", "Name:") + 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:") + 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:") + 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:") + 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:") + statusLabel = pgettext("addonStore", "Status") self.addonsList.AppendColumn(nameLabel, width=300) self.addonsList.AppendColumn(installedVersionLabel, width=200) From a92789a351720161a1c13ad3563bf6b2771ba493 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Wed, 5 Jun 2024 16:12:42 +1000 Subject: [PATCH 26/33] address review comments --- source/gui/addonStoreGui/controls/messageDialogs.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index 863fa77a215..eec0caf9f6e 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -411,8 +411,8 @@ def _setupButtons(self, sHelper: BoxSizerHelper): bHelper = sHelper.addDialogDismissButtons(ButtonHelper(wx.HORIZONTAL)) # Translators: The label of a button in a dialog - openStoreButton = bHelper.addButton(self, wx.ID_CLOSE, label=pgettext("addonStore", "Open Add-on &Store")) - openStoreButton.Bind(wx.EVT_BUTTON, self.onOpenStoreButton) + self.openStoreButton = bHelper.addButton(self, wx.ID_CLOSE, label=pgettext("addonStore", "Open Add-on &Store")) + 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")) @@ -449,7 +449,7 @@ def _createAddonsPanel(self, sHelper: BoxSizerHelper): self.addonsList.AppendColumn(statusLabel, width=300) for addon in self.addonsPendingUpdate: self.addonsList.Append(( - addon.name, + addon.displayName, addon._addonHandlerModel.version, addon.addonVersionName, addon.channel.displayString, @@ -475,6 +475,7 @@ def onUpdateAllButton(self, evt: wx.CommandEvent): # 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() def _statusUpdate(self, addonListItemVM: AddonListItemVM): From 23cee9cf8a36f8db02b855316ac26bd8e8892650 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 6 Jun 2024 10:41:14 +1000 Subject: [PATCH 27/33] fix lint and focus --- source/gui/addonStoreGui/controls/messageDialogs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index eec0caf9f6e..12c42792d0a 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -411,7 +411,8 @@ def _setupButtons(self, sHelper: BoxSizerHelper): bHelper = sHelper.addDialogDismissButtons(ButtonHelper(wx.HORIZONTAL)) # Translators: The label of a button in a dialog - self.openStoreButton = bHelper.addButton(self, wx.ID_CLOSE, label=pgettext("addonStore", "Open Add-on &Store")) + 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 @@ -477,6 +478,7 @@ def onUpdateAllButton(self, evt: wx.CommandEvent): 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}") From 2a67d7dde38889c49691921526fa162894087384 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 6 Jun 2024 10:52:48 +1000 Subject: [PATCH 28/33] Apply suggestions from code review Co-authored-by: Luke Davis <8139760+XLTechie@users.noreply.github.com> --- tests/manual/addonStore.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md index efce23dc1cd..1b03b86691e 100644 --- a/tests/manual/addonStore.md +++ b/tests/manual/addonStore.md @@ -100,8 +100,8 @@ You can do this by: ## Updating add-ons -### Simulate creating an updatable add-on from the Add-on Store -Without having a previously installed add-on get an update, it is hard to naturally test updatable add-ons. +### 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 spoof an update for an add-on. 1. [Install an add-on from the Add-on Store](#install-add-on) @@ -117,17 +117,18 @@ For example: "Clock". - Example: `source\userConfig\addons\clock\manifest.ini` - Edit "version": decrease the major release value number to match earlier edits. -### Updating from add-on installed from Add-on Store +### Updating from add-on originally installed via Add-on Store + 1. [Simulate creating an updatable add-on from the Add-on Store](#simulate-creating-an-updatable-add-on-from-the-add-on-store) 1. Open the Add-on Store -1. Ensure the same add-on you edited is available on the Add-on Store with the status "update". +1. 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) For example: "Clock". 1. Go to your NVDA user configuration folder: - - For source: `.\source\userConfig\addons` + - For source: `source\userConfig\addons` - For installed copies: `%APPDATA%\nvda\addons` 1. To spoof an externally loaded older release, we need to edit 2 files: - Add-on Store JSON metadata @@ -163,11 +164,11 @@ For example: "Clock". ### Updating multiple add-ons 1. Repeatedly [create updatable add-ons](#simulate-creating-an-updatable-add-on-from-the-add-on-store). 1. Open the Add-on Store -1. Ensure the same add-on you edited is available on the Add-on Store with the status "update". +1. 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 as prompted. +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 @@ -186,11 +187,11 @@ For example: "Clock". ``` 1. Test various buttons: - Press "Update All": Ensure NVDA installs the add-ons. - - Press "Close": Ensure if Add-ons have been installed that NVDA prompts for restart afterwards + - 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 -Full automatic updates is currently unsupported. +Full automatic updating is not currently supported. ## Other add-on actions From 1f15ac45f4c3470be1ed3eb21dbd85a74a4cca8c Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Thu, 6 Jun 2024 11:04:10 +1000 Subject: [PATCH 29/33] improve test plan docs --- tests/manual/addonStore.md | 56 ++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md index 1b03b86691e..578b1ab85d4 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. @@ -101,15 +103,14 @@ You can do this by: ## Updating add-ons ### 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 spoof an update for an add-on. +This process allows you to mock an update for an 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\addons` - - For installed copies: `%APPDATA%\nvda\addons` -1. To spoof an old release, we need to edit 2 files: +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. @@ -119,18 +120,17 @@ For example: "Clock". ### Updating from add-on originally installed via Add-on Store -1. [Simulate creating an updatable add-on from the Add-on Store](#simulate-creating-an-updatable-add-on-from-the-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) For example: "Clock". -1. Go to your NVDA user configuration folder: - - For source: `source\userConfig\addons` - - For installed copies: `%APPDATA%\nvda\addons` -1. To spoof an externally loaded older release, we need to edit 2 files: +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. @@ -147,10 +147,8 @@ 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\addons` - - For installed copies: `%APPDATA%\nvda\addons` -1. To spoof an externally loaded release, with an invalid version, we need to edit 2 files: +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. @@ -162,7 +160,8 @@ For example: "Clock". 1. Install the add-on again to test the "migrate" path. ### Updating multiple add-ons -1. Repeatedly [create updatable add-ons](#simulate-creating-an-updatable-add-on-from-the-add-on-store). + +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`. @@ -172,7 +171,8 @@ For example: "Clock". 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-from-the-add-on-store). + +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 @@ -191,6 +191,7 @@ For example: "Clock". - Press "Open Add-on Store": Ensure NVDA opens to the Updatable tab in the Add-on Store ### Automatic updating + Full automatic updating is not currently supported. ## Other add-on actions @@ -260,3 +261,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` From c6e8e39394deb24072cc9b409f183e5951681232 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 7 Jun 2024 09:44:25 +1000 Subject: [PATCH 30/33] do not announce profile change --- source/gui/settingsDialogs.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index e5014807de5..c17a57dffd2 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -4671,7 +4671,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()) From 8211187d1d63268936de74f43a187f5796df1b41 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Fri, 7 Jun 2024 09:45:45 +1000 Subject: [PATCH 31/33] remove default from label --- source/config/configFlags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/config/configFlags.py b/source/config/configFlags.py index a65e2a3edcb..028481f6d0c 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -214,7 +214,7 @@ 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 (default)"), + self.NOTIFY: _("Notify"), # self.UPDATE: _("Update Automatically"), # Translators: This is a label for the automatic update behaviour for add-ons. self.DISABLED: _("Disabled"), From 2677215f169c2b0908f0770165a203df3450a630 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 11 Jun 2024 10:40:33 +1000 Subject: [PATCH 32/33] Update source/config/configFlags.py --- source/config/configFlags.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/config/configFlags.py b/source/config/configFlags.py index 028481f6d0c..58fdda21994 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -206,6 +206,7 @@ def _displayStringLabels(self): class AddonsAutomaticUpdate(DisplayStringStrEnum): NOTIFY = "notify" + # TODO: uncomment when implementing #3208 # UPDATE = "update" DISABLED = "disabled" From 9f58dd63faafdbc2a6a303b22b5f75e9c9fdd528 Mon Sep 17 00:00:00 2001 From: Sean Budd Date: Tue, 11 Jun 2024 11:13:56 +1000 Subject: [PATCH 33/33] add script to update add-on files --- tests/manual/addonStore.md | 13 +++++++++ tests/manual/createUpdatableAddons.ps1 | 40 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 tests/manual/createUpdatableAddons.ps1 diff --git a/tests/manual/addonStore.md b/tests/manual/addonStore.md index 578b1ab85d4..095b6cc6f17 100644 --- a/tests/manual/addonStore.md +++ b/tests/manual/addonStore.md @@ -107,6 +107,8 @@ You can do this by: 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 the "addons" folder in your [NVDA user configuration folder](#editing-user-configuration) @@ -118,6 +120,17 @@ For example: "Clock". - Example: `source\userConfig\addons\clock\manifest.ini` - Edit "version": decrease the major release value number to match earlier edits. +#### 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) 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