diff --git a/source/addonHandler/__init__.py b/source/addonHandler/__init__.py index 7a32d1b943e..a4abb871867 100644 --- a/source/addonHandler/__init__.py +++ b/source/addonHandler/__init__.py @@ -20,6 +20,7 @@ from typing import ( Callable, Dict, + IO, Literal, Optional, Set, @@ -1059,12 +1060,11 @@ class AddonManifest(ConfigObj): ), ) - def __init__(self, input, translatedInput=None): - """Constructs an L{AddonManifest} instance from manifest string data - @param input: data to read the manifest information - @type input: a fie-like object. - @param translatedInput: translated manifest input - @type translatedInput: file-like object + def __init__(self, input: IO[bytes], translatedInput: IO[bytes] | None = None): + """Constructs an :class:`AddonManifest` instance from manifest string data + + :param input: data to read the manifest information + :param translatedInput: Optional translated manifest input, defaults to ``None`` """ super().__init__(input, configspec=self.configspec, encoding="utf-8", default_encoding="utf-8") self._errors = None diff --git a/source/addonStore/dataManager.py b/source/addonStore/dataManager.py index 592983ad2f3..1fa077bdbb2 100644 --- a/source/addonStore/dataManager.py +++ b/source/addonStore/dataManager.py @@ -37,7 +37,7 @@ _createStoreCollectionFromJson, ) from .models.channel import Channel -from .models.status import AvailableAddonStatus, getStatus, _StatusFilterKey +from .models.status import AvailableAddonStatus, _canUpdateAddon, getStatus, _StatusFilterKey from .network import ( _getCurrentApiVersionForURL, _getAddonStoreURL, @@ -352,17 +352,22 @@ def _getCachedInstalledAddonData(self, addonId: str) -> Optional[InstalledAddonS return None return _createInstalledStoreModelFromData(cacheData) - def _addonsPendingUpdate(self) -> list["_AddonGUIModel"]: - # TODO: Add AvailableAddonStatus.UPDATE_INCOMPATIBLE, - # to allow updates that are incompatible with the current NVDA version, - # only if a config setting is enabled + def _addonsPendingUpdate( + self, + onDisplayableError: "DisplayableError.OnDisplayableErrorT | None" = None, + ) -> list["_AddonGUIModel"]: updatableAddonStatuses = {AvailableAddonStatus.UPDATE} - addonsPendingUpdate: list["_AddonGUIModel"] = [] - compatibleAddons = self.getLatestCompatibleAddons() + addonsPendingUpdate: dict["str", "_AddonGUIModel"] = {} + if config.conf["addonStore"]["allowIncompatibleUpdates"]: + updatableAddonStatuses.add(AvailableAddonStatus.UPDATE_INCOMPATIBLE) + compatibleAddons = self.getLatestAddons(onDisplayableError) + else: + compatibleAddons = self.getLatestCompatibleAddons(onDisplayableError) for channel in compatibleAddons: + # Ensure add-on update channel is within the preferred update channels for addon in compatibleAddons[channel].values(): + # Ensure add-on is updatable if getStatus(addon, _StatusFilterKey.UPDATE) in updatableAddonStatuses: - # Ensure add-on update channel is within the preferred update channels if (installedStoreData := addon._addonHandlerModel._addonStoreData) is not None: installedChannel = installedStoreData.channel else: @@ -373,9 +378,15 @@ def _addonsPendingUpdate(self) -> list["_AddonGUIModel"]: availableUpdateChannels = selectedUpdateChannel._availableChannelsForAddonWithChannel( installedChannel, ) + # Ensure add-on channel is valid to update to given update preferences if addon.channel in availableUpdateChannels: - addonsPendingUpdate.append(addon) - return addonsPendingUpdate + if addon.name in addonsPendingUpdate: + # See if this version is newer than the currently tracked versions + if _canUpdateAddon(addon, addonsPendingUpdate[addon.name]): + addonsPendingUpdate[addon.name] = addon + else: + addonsPendingUpdate[addon.name] = addon + return list(addonsPendingUpdate.values()) class _InstalledAddonsCache(AutoPropertyObject): diff --git a/source/addonStore/models/addon.py b/source/addonStore/models/addon.py index 499e7d6958a..aa17a08138c 100644 --- a/source/addonStore/models/addon.py +++ b/source/addonStore/models/addon.py @@ -221,6 +221,10 @@ def description(self) -> str: return "" return description + @property + def installDate(self) -> datetime: + return datetime.fromtimestamp(os.path.getctime(self.installPath)) + @property def author(self) -> str: return self.manifest["author"] diff --git a/source/addonStore/models/status.py b/source/addonStore/models/status.py index ab522b6f3eb..fa3c219b400 100644 --- a/source/addonStore/models/status.py +++ b/source/addonStore/models/status.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022-2023 NV Access Limited, Cyrille Bougot +# Copyright (C) 2022-2025 NV Access Limited, Cyrille Bougot # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -23,7 +23,7 @@ from .version import MajorMinorPatch, SupportsVersionCheck if TYPE_CHECKING: - from .addon import _AddonGUIModel # noqa: F401 + from .addon import _AddonGUIModel, AddonHandlerModel, _AddonStoreModel # noqa: F401 from addonHandler import AddonsState # noqa: F401 @@ -243,32 +243,29 @@ def _getDownloadableStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonSt return None -def _getUpdateStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]: - from .addon import AddonStoreModel +def _canUpdateAddon( + availableAddon: "_AddonStoreModel", + baseAddon: "_AddonStoreModel | AddonHandlerModel", +) -> bool | None: + """Check if an add-on can be updated. + + :param model: Add-on to check if it can be updated. + :return: True if the add-on can be updated, False if it cannot, + None if it is unknown (e.g. cannot parse current version string). + """ + from .addon import _AddonStoreModel + from addonHandler import Addon as AddonHandlerModel from ..dataManager import addonDataManager assert addonDataManager is not None - if not isinstance(model, AddonStoreModel): - # If the listed add-on is installed from a side-load - # and not available on the add-on store - # the type will not be AddonStoreModel - return None - - if model._anyPendingInstallForId: - return None - - addonStoreInstalledData = addonDataManager._getCachedInstalledAddonData(model.addonId) - if addonStoreInstalledData is not None: - if model.addonVersionNumber > addonStoreInstalledData.addonVersionNumber: - if not model.isCompatible: - return AvailableAddonStatus.UPDATE_INCOMPATIBLE - return AvailableAddonStatus.UPDATE - else: + if isinstance(baseAddon, _AddonStoreModel): + return availableAddon.addonVersionNumber > baseAddon.addonVersionNumber + elif isinstance(baseAddon, AddonHandlerModel): # Parsing from a side-loaded add-on try: manifestAddonVersion = MajorMinorPatch._parseVersionFromVersionStr( - model._addonHandlerModel.version, + baseAddon.version, ) except ValueError: # Parsing failed to get a numeric version. @@ -276,14 +273,61 @@ def _getUpdateStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]: # however the manifest only has a version string. # Ensure the user is aware that it may be a downgrade or reinstall. # Encourage users to re-install or upgrade the add-on from the add-on store. - return AvailableAddonStatus.REPLACE_SIDE_LOAD + return None + else: + return availableAddon.addonVersionNumber > manifestAddonVersion + else: + raise TypeError(f"Unexpected type: {type(baseAddon)}") - if model.addonVersionNumber > manifestAddonVersion: - if not model.isCompatible: - return AvailableAddonStatus.UPDATE_INCOMPATIBLE - return AvailableAddonStatus.UPDATE - return None +def _getUpdateStatus(model: "_AddonGUIModel") -> AvailableAddonStatus | None: + """Get the update status for an add-on. + + :param model: Add-on to check if it can be updated. + :return: Update status of add-on for the context of the current tab. + None if the add-on is not installed or cannot be updated. + """ + from ..dataManager import addonDataManager + from ..models.addon import AddonStoreModel + + if not isinstance(model, AddonStoreModel): + # If the listed add-on is installed from a side-load + # and not available on the add-on store + # the type will not be AddonStoreModel + return None + + if model._anyPendingInstallForId: + # Update/install already pending + return None + + installedAddonData: "_AddonStoreModel | AddonHandlerModel | None" = ( + addonDataManager._getCachedInstalledAddonData(model.addonId) + ) + if installedAddonData is None: + # Use manifest if add-on store data is not available + installedAddonData = model._addonHandlerModel + if installedAddonData is None: + # Add-on is not installed. + # No update status. + return None + + canUpdateAddon = _canUpdateAddon(model, installedAddonData) + match canUpdateAddon: + case None: + # Cannot determine if add-on can be updated, + # e.g. version string cannot be parsed. + return AvailableAddonStatus.REPLACE_SIDE_LOAD + case True: + # Add-on is installed and can be updated. + if model.isCompatible: + return AvailableAddonStatus.UPDATE + return AvailableAddonStatus.UPDATE_INCOMPATIBLE + case False: + # Add-on is not installed or cannot be updated. + # No update status. + return None + case _: + raise ValueError(f"Unexpected value: {canUpdateAddon}") def _getInstalledStatus(model: "_AddonGUIModel") -> Optional[AvailableAddonStatus]: diff --git a/source/config/configFlags.py b/source/config/configFlags.py index c3ebf2789a4..77017488558 100644 --- a/source/config/configFlags.py +++ b/source/config/configFlags.py @@ -235,8 +235,7 @@ def _displayStringLabels(self): class AddonsAutomaticUpdate(DisplayStringStrEnum): NOTIFY = "notify" - # TODO: uncomment when implementing #3208 - # UPDATE = "update" + UPDATE = "update" DISABLED = "disabled" @property @@ -245,7 +244,8 @@ def _displayStringLabels(self): # Translators: This is a label for the automatic update behaviour for add-ons. # It will notify the user when updates are available. self.NOTIFY: _("Notify"), - # self.UPDATE: _("Update Automatically"), + # Translators: This is a label for the automatic update behaviour for add-ons. + self.UPDATE: _("Update Automatically"), # Translators: This is a label for the automatic update behaviour for add-ons. self.DISABLED: _("Disabled"), } diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 87eed2546e5..0a83b52dfdf 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -338,7 +338,8 @@ playErrorSound = integer(0, 1, default=0) [addonStore] - automaticUpdates = option("notify", "disabled", default="notify") + automaticUpdates = option("notify", "update", "disabled", default="notify") + allowIncompatibleUpdates = boolean(default=false) baseServerURL = string(default="") # UpdateChannel values: # same channel (default), any channel, do not update, stable, beta & dev, beta, dev diff --git a/source/gui/addonStoreGui/controls/details.py b/source/gui/addonStoreGui/controls/details.py index b213d769b73..42f252739fb 100644 --- a/source/gui/addonStoreGui/controls/details.py +++ b/source/gui/addonStoreGui/controls/details.py @@ -344,6 +344,14 @@ def _refresh(self): details.reviewURL, ) + if isinstance(details, _AddonManifestModel): + # Installed add-ons with a manifest only + self._appendDetailsLabelValue( + # Translators: Label for an extra detail field for the selected add-on in the add-on store dialog. + pgettext("addonStore", "Install date:"), + details.installDate.strftime("%x"), + ) + if details.publicationDate is not None: self._appendDetailsLabelValue( # Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog. diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index 17b5a0a55be..8a86112b964 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -3,9 +3,12 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +import threading +from time import sleep from typing import ( TYPE_CHECKING, ) +import winsound import wx @@ -16,7 +19,7 @@ _AddonManifestModel, ) from addonStore.dataManager import addonDataManager -from addonStore.models.status import AvailableAddonStatus +from addonStore.models.status import _StatusFilterKey, AvailableAddonStatus, getStatus import config from config.configFlags import AddonsAutomaticUpdate import gui @@ -30,9 +33,10 @@ ButtonHelper, SPACE_BETWEEN_VERTICAL_DIALOG_ITEMS, ) -from gui.message import DisplayableError, displayDialogAsModal, messageBox +from gui.message import DisplayableError, displayDialogAsModal, messageBox, _countAsMessageBox from logHandler import log import NVDAState +from speech.priorities import SpeechPriority import ui import windowUtils @@ -366,12 +370,12 @@ class UpdatableAddonsDialog( """A dialog notifying users that updatable add-ons are available""" helpId = "AutomaticAddonUpdates" + onDisplayableError = DisplayableError.OnDisplayableErrorT() def __init__(self, parent: wx.Window, addonsPendingUpdate: list[_AddonGUIModel]): # Translators: The warning of a dialog super().__init__(parent, title=pgettext("addonStore", "Add-on updates available")) self.addonsPendingUpdate = addonsPendingUpdate - self.onDisplayableError = DisplayableError.OnDisplayableErrorT() self._setupUI() self.Raise() self.SetFocus() @@ -475,7 +479,7 @@ def onUpdateAllButton(self, evt: wx.CommandEvent): self.listItemVMs: list[AddonListItemVM] = [] for addon in self.addonsPendingUpdate: - listItemVM = AddonListItemVM(addon, status=AvailableAddonStatus.UPDATE) + listItemVM = AddonListItemVM(addon, status=getStatus(addon, _StatusFilterKey.UPDATE)) listItemVM.updated.register(self._statusUpdate) self.listItemVMs.append(listItemVM) AddonStoreVM.getAddons(self.listItemVMs) @@ -552,6 +556,11 @@ def postInstall(): self.DestroyLater() self.SetReturnCode(wx.ID_CLOSE) + @staticmethod + def handleDisplayableError(displayableError: DisplayableError): + # Fail silently as we don't care if we can't fetch an update. + log.exception("Error occurred while checking for updatable add-ons", exc_info=displayableError) + @classmethod def _checkForUpdatableAddons(cls): if not NVDAState.shouldWriteToDisk() or ( @@ -560,11 +569,68 @@ def _checkForUpdatableAddons(cls): log.debug("automatic add-on updates are disabled") return log.debug("checking for updatable add-ons") - addonsPendingUpdate = addonDataManager._addonsPendingUpdate() - if addonsPendingUpdate: - log.debug("updatable add-ons found") - def delayCreateDialog(): - displayDialogAsModal(cls(gui.mainFrame, addonsPendingUpdate)) + UpdatableAddonsDialog.onDisplayableError.register(UpdatableAddonsDialog.handleDisplayableError) + addonsPendingUpdate = addonDataManager._addonsPendingUpdate(UpdatableAddonsDialog.onDisplayableError) + UpdatableAddonsDialog.onDisplayableError.unregister(UpdatableAddonsDialog.handleDisplayableError) + + if not addonsPendingUpdate: + log.debug("no updatable add-ons found") + return + + log.debug("updatable add-ons found") + + match config.conf["addonStore"]["automaticUpdates"]: + case AddonsAutomaticUpdate.NOTIFY: + + def delayCreateDialog(): + winsound.MessageBeep(winsound.MB_ICONEXCLAMATION) + displayDialogAsModal(cls(gui.mainFrame, addonsPendingUpdate)) + + wx.CallAfter(delayCreateDialog) + + case AddonsAutomaticUpdate.UPDATE: + threading.Thread( + name="AutomaticAddonUpdate", + target=_updateAddons, + args=(addonsPendingUpdate,), + daemon=True, + ).start() + + case _: + raise NotImplementedError("Unknown automatic update setting") + + +@_countAsMessageBox() +def _updateAddons(addonsPendingUpdate: list[_AddonGUIModel]): + """Update the add-ons in the background. + Blocks while downloading occurs. + This function is treated as message box to prevent NVDA from exiting while the download/install is in progress. + """ + from ..viewModels.store import AddonStoreVM + + # Translators: Message shown when updating add-ons automatically + ui.message(pgettext("addonStore", "Updating add-ons..."), SpeechPriority.NEXT) + listVMs = {AddonListItemVM(a, status=getStatus(a, _StatusFilterKey.UPDATE)) for a in addonsPendingUpdate} + AddonStoreVM.getAddons( + listVMs, + shouldReplace=True, + shouldInstallIncompatible=True, + shouldRememberReplaceChoice=True, + shouldRememberInstallChoice=True, + ) + + while AddonStoreVM._downloader.progress: + log.debug(f"Waiting for add-ons to be downloaded {AddonStoreVM._downloader.progress}") + sleep(0.1) + + def mainThreadCallback(): + # Add-on installations must happen on main thread + AddonStoreVM.installPending() + ui.message( + # Translators: Message shown when updating add-ons automatically + pgettext("addonStore", "Add-ons updated, restart NVDA to activate changes"), + SpeechPriority.NEXT, + ) - wx.CallAfter(delayCreateDialog) + wx.CallAfter(mainThreadCallback) diff --git a/source/gui/addonStoreGui/viewModels/store.py b/source/gui/addonStoreGui/viewModels/store.py index 99f804a8f22..36c155f01a2 100644 --- a/source/gui/addonStoreGui/viewModels/store.py +++ b/source/gui/addonStoreGui/viewModels/store.py @@ -493,11 +493,14 @@ def getAddon(cls, listItemVM: AddonListItemVM[_AddonStoreModel]) -> None: cls._downloader.download(listItemVM, cls._downloadComplete, cls.onDisplayableError) @classmethod - def getAddons(cls, listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]]) -> None: - shouldReplace = True - shouldInstallIncompatible = True - shouldRememberReplaceChoice = False - shouldRememberInstallChoice = False + def getAddons( + cls, + listItemVMs: Iterable[AddonListItemVM[_AddonStoreModel]], + shouldReplace: bool = True, + shouldInstallIncompatible: bool = True, + shouldRememberReplaceChoice: bool = False, + shouldRememberInstallChoice: bool = False, + ) -> None: for aVM in listItemVMs: if aVM.canUseInstallAction() or aVM.canUseUpdateAction(): cls.getAddon(aVM) diff --git a/source/gui/message.py b/source/gui/message.py index ac0dff60739..deeaa5e9aac 100644 --- a/source/gui/message.py +++ b/source/gui/message.py @@ -1,6 +1,6 @@ # -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2024 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Mesar Hameed, Joseph Lee, +# Copyright (C) 2006-2025 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Mesar Hameed, Joseph Lee, # Thomas Stivers, Babbage B.V., Accessolutions, Julien Cochuyt # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -13,7 +13,7 @@ from collections import deque from collections.abc import Callable, Collection from enum import Enum, IntEnum, auto -from functools import partialmethod, singledispatchmethod +from functools import partialmethod, singledispatchmethod, wraps from typing import Any, Literal, NamedTuple, Optional, Self, TypeAlias import core @@ -52,6 +52,29 @@ def isModalMessageBoxActive() -> bool: return _messageBoxCounter != 0 +def _countAsMessageBox(): + """Wrapper to increment and decrement the message box counter around the wrapped function.""" + + def _wrap(func): + @wraps(func) + def funcWrapper(*args, **kwargs): + global _messageBoxCounter + with _messageBoxCounterLock: + _messageBoxCounter += 1 + try: + return func(*args, **kwargs) + except Exception: + raise + finally: + with _messageBoxCounterLock: + _messageBoxCounter -= 1 + + return funcWrapper + + return _wrap + + +@_countAsMessageBox() def displayDialogAsModal(dialog: wx.Dialog) -> int: """Display a dialog as modal. @return: Same as for wx.MessageBox. @@ -67,10 +90,6 @@ def displayDialogAsModal(dialog: wx.Dialog) -> int: Because an answer is required to continue after a modal messageBox is opened, some actions such as shutting down are prevented while NVDA is in a possibly uncertain state. """ - global _messageBoxCounter - with _messageBoxCounterLock: - _messageBoxCounter += 1 - try: if not dialog.GetParent(): gui.mainFrame.prePopup() @@ -78,8 +97,6 @@ def displayDialogAsModal(dialog: wx.Dialog) -> int: finally: if not dialog.GetParent(): gui.mainFrame.postPopup() - with _messageBoxCounterLock: - _messageBoxCounter -= 1 return res diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 228a1ff8f2b..568be6c562a 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -3259,9 +3259,7 @@ class AddonStorePanel(SettingsPanel): def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: 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:") + automaticUpdatesLabelText = _("Automatic &updates:") self.automaticUpdatesComboBox = sHelper.addLabeledControl( automaticUpdatesLabelText, wx.Choice, @@ -3285,6 +3283,13 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: index = config.conf["addonStore"]["defaultUpdateChannel"] self.defaultUpdateChannelComboBox.SetSelection(index) + self.allowIncompatibleUpdates = sHelper.addItem( + # Translators: Mute other apps checkbox in settings + wx.CheckBox(self, label=_("Allow automatic updates to install incompatible add-ons")), + ) + self.bindHelpEvent("AllowIncompatibleAddonUpdates", self.allowIncompatibleUpdates) + self.allowIncompatibleUpdates.SetValue(config.conf["addonStore"]["allowIncompatibleUpdates"]) + # Translators: The label for the mirror server on the Add-on Store Settings panel. mirrorBoxSizer = wx.StaticBoxSizer(wx.HORIZONTAL, self, label=_("Mirror server")) mirrorBox = mirrorBoxSizer.GetStaticBox() @@ -3361,6 +3366,7 @@ def onPanelActivated(self): def onSave(self): index = self.automaticUpdatesComboBox.GetSelection() config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AddonsAutomaticUpdate][index] + config.conf["addonStore"]["allowIncompatibleUpdates"] = self.allowIncompatibleUpdates.IsChecked() config.conf["addonStore"]["defaultUpdateChannel"] = self.defaultUpdateChannelComboBox.GetSelection() diff --git a/source/utils/schedule.py b/source/utils/schedule.py index 9a10e65573e..f4ee2a3a36f 100644 --- a/source/utils/schedule.py +++ b/source/utils/schedule.py @@ -73,6 +73,12 @@ class ScheduleThread(threading.Thread): Daily scheduled jobs occur offset by X minutes to avoid overlapping jobs. """ + START_MINUTE_OFFSET = 1 + """ + Offset in minutes to start scheduling daily jobs. + The first scheduled job occurs X minutes after NVDA starts. + """ + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.scheduledDailyJobCount = 0 @@ -88,7 +94,9 @@ def _calculateDailyTimeOffset(self) -> str: # Schedule jobs so that they occur offset by a regular period to avoid overlapping jobs. # Start with a delay to give time for NVDA to start up. startTimeMinuteOffset = ( - startTime.minute + (self.scheduledDailyJobCount + 1) * self.DAILY_JOB_MINUTE_OFFSET + startTime.minute + + self.START_MINUTE_OFFSET + + self.scheduledDailyJobCount * self.DAILY_JOB_MINUTE_OFFSET ) # Handle the case where the minute offset is greater than 60. startTimeHourOffset = startTime.hour + (startTimeMinuteOffset // 60) diff --git a/tests/unit/test_util/test_schedule.py b/tests/unit/test_util/test_schedule.py index 889625bb7b5..90f0e7a7229 100644 --- a/tests/unit/test_util/test_schedule.py +++ b/tests/unit/test_util/test_schedule.py @@ -24,6 +24,8 @@ class ScheduleThreadTests(unittest.TestCase): + TODAY_AT_MIDNIGHT = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + def setUp(self): self.oldNVDAStateGetStartTime = NVDAState.getStartTime NVDAState.getStartTime = MagicMock(return_value=datetime.now().timestamp()) @@ -116,31 +118,39 @@ def jobFunc(): # Call the scheduleJob method with the same cron time _sch.scheduleThread.scheduleJob(jobFunc, jobSchedule, ThreadTarget.GUI) - def test_calculateDailyTimeOffset(self): - todayAtMidnight = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - NVDAState.getStartTime = MagicMock(return_value=todayAtMidnight.timestamp()) + def test_calculateDailyTimeOffset_firstJob(self): + """Test the case where the first job time is calculated correctly""" + NVDAState.getStartTime = MagicMock(return_value=ScheduleThreadTests.TODAY_AT_MIDNIGHT.timestamp()) offset = _sch.scheduleThread._calculateDailyTimeOffset() # Assert that the offset is calculated correctly - self.assertEqual(offset, f"00:{ScheduleThread.DAILY_JOB_MINUTE_OFFSET:02d}") + self.assertEqual(offset, f"00:{ScheduleThread.START_MINUTE_OFFSET:02d}") + def test_calculateDailyTimeOffset_secondJob(self): + """Test the case where the second job time is calculated correctly""" + NVDAState.getStartTime = MagicMock(return_value=ScheduleThreadTests.TODAY_AT_MIDNIGHT.timestamp()) _sch.scheduleThread.scheduledDailyJobCount = 1 offset = _sch.scheduleThread._calculateDailyTimeOffset() - self.assertEqual(offset, f"00:{ScheduleThread.DAILY_JOB_MINUTE_OFFSET * 2:02d}") + self.assertEqual( + offset, + f"00:{ScheduleThread.START_MINUTE_OFFSET + ScheduleThread.DAILY_JOB_MINUTE_OFFSET * 1:02d}", + ) - # Test the case where the start time is 11:59 to ensure the hour offset is calculated correctly + def test_calculateDailyTimeOffset_minuteOverflow(self): + """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(), + return_value=ScheduleThreadTests.TODAY_AT_MIDNIGHT.replace(hour=11, minute=59).timestamp(), ) _sch.scheduleThread.scheduledDailyJobCount = 0 offset = _sch.scheduleThread._calculateDailyTimeOffset() - expectedMinOffset = (ScheduleThread.DAILY_JOB_MINUTE_OFFSET + 59) % 60 + expectedMinOffset = (ScheduleThread.START_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 + def test_calculateDailyTimeOffset_hourOverflow(self): + """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(), + return_value=ScheduleThreadTests.TODAY_AT_MIDNIGHT.replace(hour=23, minute=59).timestamp(), ) _sch.scheduleThread.scheduledDailyJobCount = 0 offset = _sch.scheduleThread._calculateDailyTimeOffset() - expectedMinOffset = (ScheduleThread.DAILY_JOB_MINUTE_OFFSET + 59) % 60 + expectedMinOffset = (ScheduleThread.START_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 6ca0d94366e..6d8657678f9 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -10,9 +10,14 @@ Any remaining users of SAPI4 speech synthesizers are encouraged to choose a more ### New Features * Add-on Store: - * Automatic update channels for add-ons can now be modified. (#3208) - * Automatic update channels can be selected for installed add-ons via an "Update channel" submenu. - * The default automatic update channel can be set from the Add-on Store panel in NVDA's settings. + * Automatic updates: + * Automatic update channels for add-ons can now be modified. (#3208) + * Automatic update channels can be selected for installed add-ons via an "Update channel" submenu. + * The default automatic update channel can be set from the Add-on Store panel in NVDA's settings. + * Automatic updates can now happen in the background. + * This can be enabled in the Add-on Store panel in NVDA's settings by changing "Automatic updates" to "Update Automatically". (#3208) + * Automatic updates can now update incompatible add-ons to another, newer, incompatible version. + * This can be enabled in the Add-on Store panel in NVDA's settings. (#3208) * Added an action to cancel the install of add-ons. (#15578, @hwf1324) * Added an action to retry the installation if the download/installation of an add-on fails. (#17090, @hwf1324) * The add-ons lists can be sorted by columns, including publication date, in ascending and descending order. (#15277, #16681, @nvdaes) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 5ad96db0d98..f0e68a5ba4d 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -3083,14 +3083,18 @@ This check is performed every 24 hours. By default, notifications will only occur for add-ons with updates available within the same [channel](#AddonStoreFilterChannel) (e.g. stable, beta or dev). You can configure add-on update channels [individually for each add-on](#AddonStoreUpdateChannel) or for [all add-ons](#DefaultAddonUpdateChannel). +When set to "Update Automatically", add-ons will automatically update in the background. +You will be prompted to restart NVDA when the updates are finished. + | . {.hideHeaderRow} |.| |---|---| -|Options |Notify (Default), Disabled | +|Options |Notify (Default), Update Automatically, Disabled | |Default |Notify | |Option |Behaviour | |---|---| -|Notify |Notify when updates are available to add-ons within the same channel | +|Notify |Notify when updates are available to add-ons | +|Update Automatically |Automatically update add-ons in the background | |Disabled |Do not automatically check for updates to add-ons | ##### Default Update Channel {#DefaultAddonUpdateChannel} @@ -3115,6 +3119,14 @@ You can also change the update channel for a [specific add-on individually from | Beta | Add-ons will automatically update to beta versions | | Dev | Add-ons will automatically update to dev versions | +##### Allow automatic updates to install incompatible add-ons {#AllowIncompatibleAddonUpdates} + +This setting enables automatic updates to add-ons that may not be fully compatible with the current version of NVDA. +By default, this is disabled, meaning automatic updates will only upgrade to add-on versions marked as compatible with the current version of NVDA. +Automatic updates will still update an incompatible add-on version to a compatible version when it is released. +Enabling this may be useful for switching over to using add-on breaking releases (the first release of the year). +This is particularly useful for alpha and beta testers, who are testing compatibility of add-ons during the early stages of an add-on breaking release. + ##### Mirror server {#AddonStoreMetadataMirror} These controls allow you to specify an alternative URL to download Add-on Store data from.