Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add ability to customize automatic update channels for add-ons #17597

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
23 changes: 12 additions & 11 deletions source/addonStore/models/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,30 +58,30 @@ def _displayStringLabels(self) -> Dict["Channel", str]:
class UpdateChannel(DisplayStringIntEnum):
"""Update channel for an addon used for automatic updates."""

DEFAULT = 0
DEFAULT = -1
"""Default channel.
(specified in [addonStore][defaultUpdateChannel] section of config)
Specified in [addonStore][defaultUpdateChannel] section of config.
"""

SAME = 1
SAME = 0
"""Keep the same channel as the current version"""

ANY = 2
ANY = 1
"""Use any channel, keep to the latest version"""

NO_UPDATE = 3
NO_UPDATE = 2
"""Do not update the addon"""

STABLE = 4
STABLE = 3
"""Use the stable channel"""

BETA_DEV = 5
BETA_DEV = 4
"""Use the beta or development channel, keep to the latest version"""

BETA = 6
BETA = 5
"""Use the beta channel"""

DEV = 7
DEV = 6
"""Use the development channel"""

@property
Expand All @@ -91,7 +91,8 @@ def displayString(self) -> str:
if self is UpdateChannel.DEFAULT:
channel = UpdateChannel(config.conf["addonStore"]["defaultUpdateChannel"])
assert channel is not UpdateChannel.DEFAULT
# Translators: Update channel for an addon
# 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],
)
Expand All @@ -112,7 +113,7 @@ def _displayStringLabels(self) -> dict["UpdateChannel", str]:
# Translators: Update channel for an addon
UpdateChannel.STABLE: _("Stable"),
# Translators: Update channel for an addon
UpdateChannel.BETA_DEV: _("Beta/Dev"),
UpdateChannel.BETA_DEV: _("Beta or dev"),
# Translators: Update channel for an addon
UpdateChannel.BETA: _("Beta"),
# Translators: Update channel for an addon
Expand Down
1 change: 1 addition & 0 deletions source/addonStore/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 13 additions & 7 deletions source/addonStore/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class _AddonSettings:
class _AddonStoreSettings:
"""Settings for the Add-on Store."""

_storeSettingsFilename: str = "_cachedSettings.json"
_CACHE_FILENAME: str = "_cachedSettings.json"

_showWarning: bool
"""Show warning when opening Add-on Store."""
Expand All @@ -48,7 +48,7 @@ class _AddonStoreSettings:
def __init__(self):
self._storeSettingsFile = os.path.join(
NVDAState.WritePaths.addonStoreDir,
self._storeSettingsFilename,
self._CACHE_FILENAME,
)
self._showWarning = True
self._addonSettings = {}
Expand All @@ -69,26 +69,32 @@ def load(self):
if NVDAState.shouldWriteToDisk():
os.remove(self._storeSettingsFile)
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
try:
settingsDict["addonSettings"]
if not isinstance(settingsDict["addonSettings"], dict):
raise ValueError("addonSettings must be a dict")

settingsDict["showWarning"]
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)
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
seanbudd marked this conversation as resolved.
Show resolved Hide resolved

self._showWarning = settingsDict["showWarning"]
for addonId, settings in settingsDict["addonSettings"].items():
self._addonSettings[addonId] = _AddonSettings(
updateChannel=UpdateChannel(settings["updateChannel"]),
)
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
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
settingsDict = {
"showWarning": self._showWarning,
Expand Down
3 changes: 1 addition & 2 deletions source/config/configSpec.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,7 @@
baseServerURL = string(default="")
# UpdateChannel values:
# same channel (default), any channel, do not update, stable, beta & dev, beta, dev
# Not 0 based as other usages of UpdateChannel's 0-value is used to refer to this default fallback value.
defaultUpdateChannel = integer(1, 7, default=1)
defaultUpdateChannel = integer(0, 6, default=0)
"""

#: The configuration specification
Expand Down
6 changes: 3 additions & 3 deletions source/gui/addonStoreGui/controls/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def _insertToContextMenu(self, action: AddonActionT, prevActionIndex: int):
# Overridable to use checkable items or radio items
self._actionMenuItemMap[action] = self._contextMenu.Insert(
prevActionIndex,
id=-1,
id=wx.ID_ANY,
item=action.displayName,
)

Expand Down Expand Up @@ -113,13 +113,13 @@ def popupContextMenuFromPosition(

def _menuItemClicked(self, evt: wx.ContextMenuEvent, actionVM: AddonUpdateChannelActionVM):
selectedAddon = actionVM.actionTarget
log.debug(f"update channel changed for selectedAddon: {selectedAddon} changed to {actionVM.channel}")
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=-1,
id=wx.ID_ANY,
item=action.displayName,
)
addonModel = cast(_AddonGUIModel, action.actionTarget.model)
Expand Down
2 changes: 1 addition & 1 deletion source/gui/addonStoreGui/controls/messageDialogs.py
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion source/gui/addonStoreGui/controls/storeDialog.py
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
2 changes: 1 addition & 1 deletion source/gui/addonStoreGui/viewModels/action.py
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
14 changes: 5 additions & 9 deletions source/gui/settingsDialogs.py
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2006-2024 NV Access Limited, Peter Vágner, Aleksey Sadovoy,
# Copyright (C) 2006-2025 NV Access Limited, Peter Vágner, Aleksey Sadovoy,
# Rui Batista, Joseph Lee, Heiko Folkerts, Zahari Yurukov, Leonard de Ruijter,
# Derek Riemer, Babbage B.V., Davy Kager, Ethan Holliger, Bill Dengler,
# Thomas Stivers, Julien Cochuyt, Peter Vágner, Cyrille Bougot, Mesar Hameed,
Expand All @@ -8,6 +8,7 @@
# Burman's Computer and Education Ltd, hwf1324.
# 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
Expand Down Expand Up @@ -53,6 +54,7 @@
import braille
import brailleTables
import brailleInput
from addonStore.models.channel import UpdateChannel
import vision
import vision.providerInfo
import vision.providerBase
Expand Down Expand Up @@ -3262,8 +3264,6 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None:
index = [x.value for x in AddonsAutomaticUpdate].index(config.conf["addonStore"]["automaticUpdates"])
self.automaticUpdatesComboBox.SetSelection(index)

from addonStore.models.channel import UpdateChannel

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:"),
Expand All @@ -3275,8 +3275,7 @@ def makeSettings(self, settingsSizer: wx.BoxSizer) -> None:
],
)
self.bindHelpEvent("DefaultAddonUpdateChannel", self.defaultUpdateChannelComboBox)
# Subtract 1 from the index because the default update channel is 1-based, but the list is 0-based.
index = config.conf["addonStore"]["defaultUpdateChannel"] - 1
index = config.conf["addonStore"]["defaultUpdateChannel"]
self.defaultUpdateChannelComboBox.SetSelection(index)

# Translators: The label for the mirror server on the Add-on Store Settings panel.
Expand Down Expand Up @@ -3355,10 +3354,7 @@ def onPanelActivated(self):
def onSave(self):
index = self.automaticUpdatesComboBox.GetSelection()
config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AddonsAutomaticUpdate][index]
# Add 1 to the index because the default update channel is 1-based, but the list is 0-based.
config.conf["addonStore"]["defaultUpdateChannel"] = (
self.defaultUpdateChannelComboBox.GetSelection() + 1
)
config.conf["addonStore"]["defaultUpdateChannel"] = self.defaultUpdateChannelComboBox.GetSelection()


class TouchInteractionPanel(SettingsPanel):
Expand Down
4 changes: 1 addition & 3 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1046,8 +1046,8 @@ def test_outputDeviceNotFound(self):
profile["audio"]["outputDevice"]


@patch("addonStore.dataManager.addonDataManager", create=True)
class Config_upgradeProfileSteps_upgradeConfigFrom_14_to_15(unittest.TestCase):
@patch("addonStore.dataManager.addonDataManager", create=True)
def test_defaultProfile(self, mock_dataManager: MagicMock):
"""Test that the default profile is correctly upgraded."""
configString = ""
Expand All @@ -1056,7 +1056,6 @@ def test_defaultProfile(self, mock_dataManager: MagicMock):
# Ensure showWarning has not been set
self.assertNotIsInstance(mock_dataManager.storeSettings.showWarning, bool)
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved

@patch("addonStore.dataManager.addonDataManager", create=True)
def test_profileWithShowWarningSetFalse(self, mock_dataManager: MagicMock):
"""Test that a profile with showWarning set is correctly upgraded."""
configString = """
Expand All @@ -1068,7 +1067,6 @@ def test_profileWithShowWarningSetFalse(self, mock_dataManager: MagicMock):
self.assertIsInstance(mock_dataManager.storeSettings.showWarning, bool)
self.assertEqual(mock_dataManager.storeSettings.showWarning, False)

@patch("addonStore.dataManager.addonDataManager", create=True)
def test_profileWithShowWarningSetTrue(self, mock_dataManager: MagicMock):
"""Test that a profile with showWarning not set is correctly upgraded."""
configString = """
Expand Down
10 changes: 10 additions & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,16 @@ As the NVDA update check URL is now configurable directly within NVDA, no replac
* 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.
To change sanitization rules, such as whitelisting tags or attributes, create a function that calls `nh3.clean` with the desired parameters. (#16985)
* `updateCheck.UpdateAskInstallDialog` no longer automatically performs an action when the update or postpone buttons are pressed.
Instead, a `callback` property has been added, which returns a function that performs the appropriate action when called with the return value from the dialog. (#17582)
* Dialogs opened with `gui.runScriptModalDialog` are now recognised as modal by NVDA. (#17582)
* Because SAPI5 voices now use `nvwave.WavePlayer` to output audio: (#17592, @gexgd0419)
* `synthDrivers.sapi5.SPAudioState` has been removed.
* `synthDrivers.sapi5.SynthDriver.ttsAudioStream` has been removed.

#### Deprecations

Expand Down
22 changes: 11 additions & 11 deletions user_docs/en/userGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -3094,24 +3094,24 @@ You can configure add-on update channels [individually for each add-on](#AddonSt
##### 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 an installed beta version.
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/Dev, Beta, Dev |
| 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 always automatically update to the latest version, regardless of channel |
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
| Do not update | Add-ons will not automatically update by default, you must enable them individually |
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
| Stable | Add-ons will only automatically update to stable versions |
| Beta/Dev | Add-ons will only automatically update to beta or dev versions |
| Beta | Add-ons will only automatically update to beta versions |
| Dev | Add-ons will only automatically update to dev versions |
| 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}

Expand Down Expand Up @@ -3758,7 +3758,7 @@ Instead, the purpose of this feature is to share feedback to help users decide i
#### 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 only update to the same [channel](#AddonStoreFilterChannel) by default.
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 |
Expand All @@ -3767,10 +3767,10 @@ From an add-on's actions menu, using the submenu "Update channel", you can modif
| Same | Add-on will remain on the same channel |
| Any | Add-on will always automatically update to the latest version, regardless of channel |
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
| Do not update | Add-on will not automatically update |
| Stable | Add-on will only automatically update to stable versions |
| Beta/Dev | Add-on will only automatically update to beta or dev versions |
| Beta | Add-on will only automatically update to beta versions |
| Dev | Add-on will only automatically update to dev versions |
| 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}

Expand Down