diff --git a/source/addonStore/dataManager.py b/source/addonStore/dataManager.py index fd20505353c..592983ad2f3 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-2024 NV Access Limited +# Copyright (C) 2022-2025 NV Access Limited # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -44,6 +44,8 @@ _getCacheHashURL, _LATEST_API_VER, ) +from .settings import _AddonStoreSettings + if TYPE_CHECKING: from addonHandler import Addon as AddonHandlerModel # noqa: F401 @@ -98,6 +100,7 @@ def __init__(self): pathlib.Path(WritePaths.addonStoreDir).mkdir(parents=True, exist_ok=True) pathlib.Path(self._installedAddonDataCacheDir).mkdir(parents=True, exist_ok=True) + self.storeSettings = _AddonStoreSettings() self._latestAddonCache = self._getCachedAddonData(self._cacheLatestFile) self._compatibleAddonCache = self._getCachedAddonData(self._cacheCompatibleFile) self._installedAddonsCache = _InstalledAddonsCache() @@ -110,6 +113,7 @@ def __init__(self): self._initialiseAvailableAddonsThread.start() def terminate(self): + self.storeSettings.save() if self._initialiseAvailableAddonsThread.is_alive(): self._initialiseAvailableAddonsThread.join(timeout=1) if self._initialiseAvailableAddonsThread.is_alive(): @@ -349,17 +353,27 @@ def _getCachedInstalledAddonData(self, addonId: str) -> Optional[InstalledAddonS 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 + updatableAddonStatuses = {AvailableAddonStatus.UPDATE} addonsPendingUpdate: list["_AddonGUIModel"] = [] compatibleAddons = self.getLatestCompatibleAddons() for channel in compatibleAddons: for addon in compatibleAddons[channel].values(): - if ( - getStatus(addon, _StatusFilterKey.UPDATE) == AvailableAddonStatus.UPDATE - # Only consider add-ons that have been installed through the Add-on Store - and addon._addonHandlerModel._addonStoreData is not None - ): - # Only consider add-on updates for the same channel - if addon.channel == addon._addonHandlerModel._addonStoreData.channel: + 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: + installedChannel = Channel.EXTERNAL + selectedUpdateChannel = addonDataManager.storeSettings.getAddonSettings( + addon.addonId, + ).updateChannel + availableUpdateChannels = selectedUpdateChannel._availableChannelsForAddonWithChannel( + installedChannel, + ) + if addon.channel in availableUpdateChannels: addonsPendingUpdate.append(addon) return addonsPendingUpdate diff --git a/source/addonStore/models/channel.py b/source/addonStore/models/channel.py index c1edcec3b0f..8ab621f4c93 100644 --- a/source/addonStore/models/channel.py +++ b/source/addonStore/models/channel.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022-2023 NV Access Limited +# Copyright (C) 2022-2025 NV Access Limited # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -9,7 +9,8 @@ Set, ) -from utils.displayString import DisplayStringStrEnum +import config +from utils.displayString import DisplayStringIntEnum, DisplayStringStrEnum class Channel(DisplayStringStrEnum): @@ -52,3 +53,94 @@ def _displayStringLabels(self) -> Dict["Channel", str]: """A dictionary where the keys are channel groups to filter by, and the values are which channels should be shown for a given filter. """ + + +class UpdateChannel(DisplayStringIntEnum): + """Update channel for an addon used for automatic updates.""" + + DEFAULT = -1 + """Default channel. + Specified in [addonStore][defaultUpdateChannel] section of config. + """ + + SAME = 0 + """Keep the same channel as the current version""" + + ANY = 1 + """Use any channel, keep to the latest version""" + + NO_UPDATE = 2 + """Do not update the addon""" + + STABLE = 3 + """Use the stable channel""" + + BETA_DEV = 4 + """Use the beta or development channel, keep to the latest version""" + + BETA = 5 + """Use the beta channel""" + + DEV = 6 + """Use the development channel""" + + @property + def displayString(self) -> str: + # Handle the default channel separately to avoid recursive dependency + # on _displayStringLabels. + if self is UpdateChannel.DEFAULT: + channel = UpdateChannel(config.conf["addonStore"]["defaultUpdateChannel"]) + assert channel is not UpdateChannel.DEFAULT + # Translators: Update channel for an addon. + # {defaultChannel} will be replaced with the name of the channel the user has selected as default + return _("Default ({defaultChannel})").format( + defaultChannel=self._displayStringLabels[channel], + ) + return super().displayString + + @property + def _displayStringLabels(self) -> dict["UpdateChannel", str]: + return { + # Translators: Update channel for an addon. + # Same means an add-on only updates to a newer version in the same channel. + # e.g. an installed beta version only updates to beta versions. + UpdateChannel.SAME: _("Same"), + # Translators: Update channel for an addon. + # Any means an add-on updates to the latest version regardless of the channel. + UpdateChannel.ANY: _("Any"), + # Translators: Update channel for an addon + UpdateChannel.NO_UPDATE: _("Do not update"), + # Translators: Update channel for an addon + UpdateChannel.STABLE: _("Stable"), + # Translators: Update channel for an addon + UpdateChannel.BETA_DEV: _("Beta or dev"), + # Translators: Update channel for an addon + UpdateChannel.BETA: _("Beta"), + # Translators: Update channel for an addon + UpdateChannel.DEV: _("Dev"), + } + + def _availableChannelsForAddonWithChannel(self, addonChannel: Channel) -> set[Channel]: + """Return the available update channels for an addon with the given channel and the update channel set.""" + if self == UpdateChannel.DEFAULT: + channel = UpdateChannel(config.conf["addonStore"]["defaultUpdateChannel"]) + assert channel is not UpdateChannel.DEFAULT + else: + channel = self + match channel: + case UpdateChannel.SAME: + return {addonChannel} + case UpdateChannel.ANY: + return _channelFilters[Channel.ALL] + case UpdateChannel.NO_UPDATE: + return {} + case UpdateChannel.STABLE: + return {Channel.STABLE} + case UpdateChannel.BETA_DEV: + return {Channel.BETA, Channel.DEV} + case UpdateChannel.BETA: + return {Channel.BETA} + case UpdateChannel.DEV: + return {Channel.DEV} + case _: + raise ValueError(f"Invalid update channel: {self}") diff --git a/source/addonStore/network.py b/source/addonStore/network.py index 38302b3d075..4cf7eeb60b3 100644 --- a/source/addonStore/network.py +++ b/source/addonStore/network.py @@ -210,6 +210,7 @@ def _downloadAddonToPath( False if the download is cancelled """ if not NVDAState.shouldWriteToDisk(): + log.error("Should not write to disk, cancelling download") return False # Some add-ons are quite large, so we need to allow for a long download time. diff --git a/source/addonStore/settings.py b/source/addonStore/settings.py new file mode 100644 index 00000000000..f6c807469d6 --- /dev/null +++ b/source/addonStore/settings.py @@ -0,0 +1,140 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from dataclasses import dataclass, replace +import json +import os +from typing import Any + +from logHandler import log +import NVDAState + +from .models.channel import UpdateChannel + + +@dataclass +class _AddonSettings: + """Settings for the Add-on Store management of an add-on. + + All options must have a default value. + """ + + updateChannel: UpdateChannel = UpdateChannel.DEFAULT + """Preferred update channels for the add-on.""" + + # TODO: migrate enabled/disabled/blocked state tracking + # from addonHandler.AddonState/AddonStateCategory to here. + # The set based state tracking could be replaced by maintaining state data on each add-on. + # + # blocked: bool = False + # """Whether the add-on is blocked from being running due to incompatibility.""" + # + # disabled: bool = False + # """Whether the add-on is disabled.""" + + +class _AddonStoreSettings: + """Settings for the Add-on Store.""" + + _CACHE_FILENAME: str = "_cachedSettings.json" + + _showWarning: bool + """Show warning when opening Add-on Store.""" + + _addonSettings: dict[str, _AddonSettings] + """Settings related to the management of add-ons""" + + def __init__(self): + self._storeSettingsFile = os.path.join( + NVDAState.WritePaths.addonStoreDir, + self._CACHE_FILENAME, + ) + self._showWarning = True + self._addonSettings = {} + self.load() + + def load(self): + try: + with open(self._storeSettingsFile, "r", encoding="utf-8") as storeSettingsFile: + settingsDict: dict[str, Any] = json.load(storeSettingsFile) + except FileNotFoundError: + return + except (json.JSONDecodeError, UnicodeDecodeError): + log.exception("Invalid add-on store settings") + if NVDAState.shouldWriteToDisk(): + os.remove(self._storeSettingsFile) + return + else: + self._loadFromSettingsDict(settingsDict) + + def _loadFromSettingsDict(self, settingsDict: dict[str, Any]): + try: + if not isinstance(settingsDict["addonSettings"], dict): + raise ValueError("addonSettings must be a dict") + + if not isinstance(settingsDict["showWarning"], bool): + raise ValueError("showWarning must be a bool") + + except (KeyError, ValueError): + log.exception(f"Invalid add-on store cache:\n{settingsDict}") + if NVDAState.shouldWriteToDisk(): + os.remove(self._storeSettingsFile) + return + + self._showWarning = settingsDict["showWarning"] + for addonId, settings in settingsDict["addonSettings"].items(): + try: + updateChannel = UpdateChannel(settings["updateChannel"]) + except ValueError: + log.exception(f"Invalid add-on settings for {addonId}:\n{settings}. Ignoring settings") + continue + else: + self._addonSettings[addonId] = _AddonSettings( + updateChannel=updateChannel, + ) + + def save(self): + if not NVDAState.shouldWriteToDisk(): + log.error("Shouldn't write to disk, not saving add-on store settings") + return + settingsDict = { + "showWarning": self._showWarning, + "addonSettings": { + addonId: { + "updateChannel": addonSettings.updateChannel.value, + } + for addonId, addonSettings in self._addonSettings.items() + }, + } + with open(self._storeSettingsFile, "w", encoding="utf-8") as storeSettingsFile: + json.dump(settingsDict, storeSettingsFile, ensure_ascii=False) + + def setAddonSettings(self, addonId: str, **kwargs): + """Set settings for an add-on. + + Keyword arguments the same as _AddonSettings: + - updateChannel: Update channel for the add-on. + """ + if addonId not in self._addonSettings: + self._addonSettings[addonId] = _AddonSettings(**kwargs) + else: + self._addonSettings[addonId] = replace(self._addonSettings[addonId], **kwargs) + self.save() + + def getAddonSettings(self, addonId: str) -> _AddonSettings: + """Get settings for an add-on. + + Returns default settings if the add-on has no stored settings. + """ + return self._addonSettings.get(addonId, _AddonSettings()) + + @property + def showWarning(self) -> bool: + return self._showWarning + + @showWarning.setter + def showWarning(self, showWarning: bool): + self._showWarning = showWarning + self.save() diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 4653f5ebc92..170eeb28804 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -336,9 +336,11 @@ playErrorSound = integer(0, 1, default=0) [addonStore] - showWarning = boolean(default=true) automaticUpdates = option("notify", "disabled", default="notify") baseServerURL = string(default="") + # UpdateChannel values: + # same channel (default), any channel, do not update, stable, beta & dev, beta, dev + defaultUpdateChannel = integer(0, 6, default=0) """ #: The configuration specification diff --git a/source/gui/addonStoreGui/controls/actions.py b/source/gui/addonStoreGui/controls/actions.py index 527355ad43e..70950111517 100644 --- a/source/gui/addonStoreGui/controls/actions.py +++ b/source/gui/addonStoreGui/controls/actions.py @@ -1,35 +1,41 @@ # 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. from abc import ABC, abstractmethod import functools from typing import ( - Dict, Generic, Iterable, - List, TypeVar, + cast, ) import wx +from addonStore.dataManager import addonDataManager +from addonStore.models.addon import _AddonGUIModel from addonStore.models.status import _StatusFilterKey from logHandler import log import ui -from ..viewModels.action import AddonActionVM, BatchAddonActionVM +from ..viewModels.action import ( + AddonActionVM, + AddonUpdateChannelActionVM, + BatchAddonActionVM, + UpdateChannel, +) from ..viewModels.addonList import AddonListItemVM from ..viewModels.store import AddonStoreVM -AddonActionT = TypeVar("AddonActionT", AddonActionVM, BatchAddonActionVM) +AddonActionT = TypeVar("AddonActionT", AddonActionVM, BatchAddonActionVM, AddonUpdateChannelActionVM) class _ActionsContextMenuP(Generic[AddonActionT], ABC): - _actions: List[AddonActionT] - _actionMenuItemMap: Dict[AddonActionT, wx.MenuItem] + _actions: list[AddonActionT] + _actionMenuItemMap: dict[AddonActionT, wx.MenuItem] _contextMenu: wx.Menu @abstractmethod @@ -43,11 +49,19 @@ def popupContextMenuFromPosition( self._populateContextMenu() targetWindow.PopupMenu(self._contextMenu, pos=position) + def _insertToContextMenu(self, action: AddonActionT, prevActionIndex: int): + # Overridable to use checkable items or radio items + self._actionMenuItemMap[action] = self._contextMenu.Insert( + prevActionIndex, + id=wx.ID_ANY, + item=action.displayName, + ) + def _populateContextMenu(self): prevActionIndex = -1 for action in self._actions: menuItem = self._actionMenuItemMap.get(action) - menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) + menuItems: list[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) isMenuItemInContextMenu = menuItem is not None and menuItem in menuItems if isMenuItemInContextMenu: @@ -60,11 +74,7 @@ def _populateContextMenu(self): else: # Insert menu item into context menu prevActionIndex += 1 - self._actionMenuItemMap[action] = self._contextMenu.Insert( - prevActionIndex, - id=-1, - item=action.displayName, - ) + self._insertToContextMenu(action, prevActionIndex) # Bind the menu item to the latest action VM self._contextMenu.Bind( @@ -79,7 +89,7 @@ def _populateContextMenu(self): self._contextMenu.Remove(menuItem) del self._actionMenuItemMap[action] - menuItems: List[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) + menuItems: list[wx.MenuItem] = list(self._contextMenu.GetMenuItems()) for menuItem in menuItems: if menuItem not in self._actionMenuItemMap.values(): # The menu item is not in the action menu item map. @@ -87,6 +97,41 @@ def _populateContextMenu(self): self._contextMenu.Remove(menuItem) +class _UpdateChannelSubMenu(_ActionsContextMenuP[AddonUpdateChannelActionVM]): + def __init__(self, storeVM: AddonStoreVM): + self._storeVM = storeVM + self._actionMenuItemMap = {} + self._contextMenu = wx.Menu() + self._populateContextMenu() + + def popupContextMenuFromPosition( + self, + targetWindow: wx.Window, + position: wx.Position = wx.DefaultPosition, + ): + raise NotImplementedError("This context menu should not be used directly") + + def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonUpdateChannelActionVM): + selectedAddon = actionVM.actionTarget + actionVM.actionHandler(selectedAddon) + log.debug(f"update channel changed for selectedAddon: {selectedAddon} changed to {actionVM.channel}") + + def _insertToContextMenu(self, action: AddonUpdateChannelActionVM, prevActionIndex: int): + self._actionMenuItemMap[action] = self._contextMenu.InsertRadioItem( + prevActionIndex, + id=wx.ID_ANY, + item=action.displayName, + ) + addonModel = cast(_AddonGUIModel, action.actionTarget.model) + updateChannel = addonDataManager.storeSettings.getAddonSettings(addonModel.addonId).updateChannel + self._actionMenuItemMap[action].Check(updateChannel == action.channel) + + @property + def _actions(self) -> list[AddonUpdateChannelActionVM]: + selectedListItem: AddonListItemVM | None = self._storeVM.listVM.getSelection() + return [AddonUpdateChannelActionVM(selectedListItem, channel) for channel in UpdateChannel] + + class _MonoActionsContextMenu(_ActionsContextMenuP[AddonActionVM]): """Context menu for actions for a single add-on""" @@ -101,9 +146,23 @@ def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonActionVM): actionVM.actionHandler(selectedAddon) @property - def _actions(self) -> List[AddonActionVM]: + def _actions(self) -> list[AddonActionVM]: return self._storeVM.actionVMList + def _populateContextMenu(self): + super()._populateContextMenu() + self._appendUpdateChannelSubMenu() + + def _appendUpdateChannelSubMenu(self): + if self._storeVM._filteredStatusKey in (_StatusFilterKey.UPDATE, _StatusFilterKey.INSTALLED): + _updateChannelSubMenu = _UpdateChannelSubMenu(self._storeVM) + self._contextMenu.AppendSubMenu( + _updateChannelSubMenu._contextMenu, + # Translators: Label for a submenu that allows the user to change the default update + # channel of the selected add-on + _("Upd&ate channel"), + ) + class _BatchActionsContextMenu(_ActionsContextMenuP[BatchAddonActionVM]): """Context menu for actions for a group of add-ons""" @@ -135,7 +194,7 @@ def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: BatchAddonActionV actionVM.actionHandler(self._selectedAddons) @property - def _actions(self) -> List[BatchAddonActionVM]: + def _actions(self) -> list[BatchAddonActionVM]: return [ BatchAddonActionVM( # Translators: Label for an action that installs the selected add-ons @@ -202,7 +261,7 @@ def _actions(self) -> List[BatchAddonActionVM]: class AddonListValidator: - def __init__(self, addonsList: List[AddonListItemVM]): + def __init__(self, addonsList: list[AddonListItemVM]): self.addonsList = addonsList def canUseInstallAction(self) -> bool: diff --git a/source/gui/addonStoreGui/controls/messageDialogs.py b/source/gui/addonStoreGui/controls/messageDialogs.py index fc21ff9d995..17b5a0a55be 100644 --- a/source/gui/addonStoreGui/controls/messageDialogs.py +++ b/source/gui/addonStoreGui/controls/messageDialogs.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2023-2024 NV Access Limited, Cyrille Bougot +# Copyright (C) 2023-2025 NV Access Limited, Cyrille Bougot # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -355,7 +355,7 @@ def __init__(self, parent: wx.Window): self.CentreOnScreen() def onOkButton(self, evt: wx.CommandEvent): - config.conf["addonStore"]["showWarning"] = not self.dontShowAgainCheckbox.GetValue() + addonDataManager.storeSettings.showWarning = not self.dontShowAgainCheckbox.GetValue() self.EndModal(wx.ID_OK) diff --git a/source/gui/addonStoreGui/controls/storeDialog.py b/source/gui/addonStoreGui/controls/storeDialog.py index cd06baeea53..9511ffb2b27 100644 --- a/source/gui/addonStoreGui/controls/storeDialog.py +++ b/source/gui/addonStoreGui/controls/storeDialog.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022-2024 NV Access Limited, Cyrille Bougot, łukasz Golonka +# Copyright (C) 2022-2025 NV Access Limited, Cyrille Bougot, łukasz Golonka # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -20,7 +20,6 @@ _statusFilters, _StatusFilterKey, ) -import config from core import callLater import globalVars import gui @@ -53,7 +52,7 @@ def __init__(self, parent: wx.Window, storeVM: AddonStoreVM, openToTab: _StatusF self._actionsContextMenu = _MonoActionsContextMenu(self._storeVM) self.openToTab = openToTab super().__init__(parent, resizeable=True, buttons={wx.CLOSE}) - if config.conf["addonStore"]["showWarning"]: + if addonDataManager.storeSettings.showWarning: displayDialogAsModal(_SafetyWarningDialog(parent)) self.Maximize() diff --git a/source/gui/addonStoreGui/viewModels/action.py b/source/gui/addonStoreGui/viewModels/action.py index 74585670ecc..1bf4c7694aa 100644 --- a/source/gui/addonStoreGui/viewModels/action.py +++ b/source/gui/addonStoreGui/viewModels/action.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2022-2023 NV Access Limited +# Copyright (C) 2022-2025 NV Access Limited # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -13,6 +13,9 @@ TYPE_CHECKING, ) +from addonStore.dataManager import addonDataManager +from addonStore.models.addon import _AddonGUIModel +from addonStore.models.channel import UpdateChannel import extensionPoints from logHandler import log @@ -166,3 +169,20 @@ def actionTarget(self, newActionTarget: Iterable["AddonListItemVM"]): self._actionTarget = newActionTarget self._notify() + + +class AddonUpdateChannelActionVM(AddonActionVM): + """Action for updating the channel of an addon""" + + def __init__(self, actionTarget: "AddonListItemVM[_AddonGUIModel]", channel: UpdateChannel): + super().__init__( + displayName=channel.displayString, + actionHandler=self._updateChannel, + # Always valid, as the channel can always be changed from the installed/update tabs. + validCheck=lambda aVM: True, + actionTarget=actionTarget, + ) + self.channel = channel + + def _updateChannel(self, listItemVM: "AddonListItemVM[_AddonGUIModel]"): + addonDataManager.storeSettings.setAddonSettings(listItemVM.model.addonId, updateChannel=self.channel) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index ead93257296..c61f79f0595 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -8,6 +8,7 @@ # Burman's Computer and Education Ltd, hwf1324, Cary-rowen. # This file is covered by the GNU General Public License. # See the file COPYING for more details. + import logging from abc import ABCMeta, abstractmethod import copy @@ -54,6 +55,7 @@ import braille import brailleTables import brailleInput +from addonStore.models.channel import UpdateChannel import vision import vision.providerInfo import vision.providerBase @@ -3251,6 +3253,20 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None: index = [x.value for x in AddonsAutomaticUpdate].index(config.conf["addonStore"]["automaticUpdates"]) self.automaticUpdatesComboBox.SetSelection(index) + self.defaultUpdateChannelComboBox = sHelper.addLabeledControl( + # Translators: This is the label for the default update channel combo box in the Add-on Store Settings dialog. + _("Default update &channel:"), + wx.Choice, + # The default update channel for a specific add-on (UpdateChannel.DEFAULT) refers to this channel, + # so it should be skipped. + choices=[ + channel.displayString for channel in UpdateChannel if channel is not UpdateChannel.DEFAULT + ], + ) + self.bindHelpEvent("DefaultAddonUpdateChannel", self.defaultUpdateChannelComboBox) + index = config.conf["addonStore"]["defaultUpdateChannel"] + self.defaultUpdateChannelComboBox.SetSelection(index) + # 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() @@ -3327,6 +3343,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"]["defaultUpdateChannel"] = self.defaultUpdateChannelComboBox.GetSelection() class TouchInteractionPanel(SettingsPanel): diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 42ae74214be..b7bea23730c 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,7 +1,7 @@ # 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) 2022-2024 NV Access Limited, Cyrille Bougot, Leonard de Ruijter +# Copyright (C) 2022-2025 NV Access Limited, Cyrille Bougot, Leonard de Ruijter from collections.abc import Callable, Generator import enum diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 0ac92629dc6..774c8a113d5 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -6,6 +6,13 @@ ### 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. + * 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) * Support for math in PDFs has been added. This works for formulas with associated MathML, such as some files generated by newer versions of TeX/LaTeX. Currently this is only supported in Foxit Reader & Foxit Editor. (#9288, @NSoiffer) @@ -15,10 +22,7 @@ To use this feature, "allow NVDA to control the volume of other applications" mu * When editing in Microsoft PowerPoint text boxes, you can now move per sentence with `alt+upArrow`/`alt+downArrow`. (#17015, @LeonarddeR) * In Mozilla Firefox, NVDA will report the highlighted text when a URL containing a text fragment is visited. (#16910, @jcsteh) * NVDA can now report when a link destination points to the current page. (#141, @LeonarddeR, @nvdaes) -* Added an action in the Add-on Store to cancel the install of add-ons. (#15578, @hwf1324) -* Added an action in the Add-on Store to retry the installation if the download/installation of an add-on fails. (#17090, @hwf1324) * It is now possible to specify mirror URLs to use for NVDA updates and the Add-on Store. (#14974, #17151) -* The add-ons lists in the Add-on Store can be sorted by columns, including publication date, in ascending and descending order. (#15277, #16681, @nvdaes) * When decreasing or increasing the font size in LibreOffice Writer using the corresponding keyboard shortcuts, NVDA announces the new font size. (#6915, @michaelweghorn) * When applying the "Body Text" or a heading paragraph style using the corresponding keyboard shortcut in LibreOffice Writer 25.2 or newer, NVDA announces the new paragraph style. (#6915, @michaelweghorn) * When toggling double underline in LibreOffice Writer using the corresponding keyboard shortcut, NVDA announces the new state ("double underline on"/"double underline off"). (#6915, @michaelweghorn) @@ -174,6 +178,8 @@ As the NVDA update check URL is now configurable directly within NVDA, no replac * The following symbols have been removed with no replacement: `languageHandler.getLanguageCliArgs`, `__main__.quitGroup` and `__main__.installGroup` . (#17486, @CyrilleB79) * Prefix matching on command line flags, e.g. using `--di` for `--disable-addons` is no longer supported. (#11644, @CyrilleB79) * The `useAsFallBack` keyword argument of `bdDetect.DriverRegistrar` has been renamed to `useAsFallback`. (#17521, @LeonarddeR) +* The `[addonStore][showWarning]` configuration setting has been removed. +Instead use `addonStore.dataManager.addonDataManager.storeSettings.showWarning`. (#17191) * `ui.browseableMessage` now takes a parameter `sanitizeHtmlFunc`. This defaults to `nh3.clean` with default arguments. This means any HTML passed into `ui.browseableMessage` using `isHtml=True` is now sanitized by default. diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 45d5c0e6449..bdbade60e6c 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -3068,8 +3068,8 @@ This category allows you to adjust the behaviour of the Add-on Store. 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. +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). | . {.hideHeaderRow} |.| |---|---| @@ -3081,6 +3081,28 @@ For example, for installed beta add-ons, you will only be notified of updates wi |Notify |Notify when updates are available to add-ons within the same channel | |Disabled |Do not automatically check for updates to add-ons | +##### Default Update Channel {#DefaultAddonUpdateChannel} + +When [Automatic add-on updates](#AutomaticAddonUpdates) are enabled, by default, add-ons only update to the same [channel](#AddonStoreFilterChannel). +For example, an installed beta version will only update to a newer beta version. +This option sets the default update channel for all add-ons. +You can also change the update channel for a [specific add-on individually from the Add-on Store](#AddonStoreUpdateChannel). + +| . {.hideHeaderRow} |.| +|---|---| +| Options | Same (Default), Any, Do not update, Stable, Beta or dev, Beta, Dev | +| Default | Same | + +| Option | Behaviour | +|---|---| +| Same | Add-ons will remain on their channel | +| Any | Add-ons will automatically update to the latest version, regardless of channel | +| Do not update | Add-ons will not automatically update by default, you must enable them individually | +| Stable | Add-ons will automatically update to stable versions | +| Beta or dev | Add-ons will automatically update to beta or dev versions | +| Beta | Add-ons will automatically update to beta versions | +| Dev | Add-ons will automatically update to dev versions | + ##### Mirror server {#AddonStoreMetadataMirror} These controls allow you to specify an alternative URL to download Add-on Store data from. @@ -3723,6 +3745,23 @@ This links to a GitHub Discussion webpage, where you will be able to read and wr Please be aware that this doesn't replace direct communication with add-on developers. Instead, the purpose of this feature is to share feedback to help users decide if an add-on may be useful for them. +#### Changing the automatic update channel (#AddonStoreUpdateChannel) + +You can manage the automatic update channels for add-ons from the [installed and updatable add-ons tabs](#AddonStoreFilterStatus). +When [Automatic add-on updates](#AutomaticAddonUpdates) are enabled, add-ons will update to the same [channel](#AddonStoreFilterChannel) they were installed from by [default](#DefaultAddonUpdateChannel). +From an add-on's actions menu, using the submenu "Update channel", you can modify the channels an add-on will automatically update to. + +| Option | Behaviour | +|---|---| +| Default | Add-on will follow the [default update channel](#DefaultAddonUpdateChannel) | +| Same | Add-on will remain on the same channel | +| Any | Add-on will automatically update to the latest version, regardless of channel | +| Do not update | Add-on will not automatically update | +| Stable | Add-on will automatically update to stable versions | +| Beta or dev | Add-on will automatically update to beta or dev versions | +| Beta | Add-on will automatically update to beta versions | +| Dev | Add-on will automatically update to dev versions | + ### Incompatible Add-ons {#incompatibleAddonsManager} Some older add-ons may no longer be compatible with the version of NVDA that you have.