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
30 changes: 22 additions & 8 deletions source/addonStore/dataManager.py
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
# 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 Expand Up @@ -44,6 +44,8 @@
_getCacheHashURL,
_LATEST_API_VER,
)
from .settings import _AddonStoreSettings


if TYPE_CHECKING:
from addonHandler import Addon as AddonHandlerModel # noqa: F401
Expand Down Expand Up @@ -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()
Expand All @@ -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():
Expand Down Expand Up @@ -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

Expand Down
96 changes: 94 additions & 2 deletions source/addonStore/models/channel.py
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 All @@ -9,7 +9,8 @@
Set,
)

from utils.displayString import DisplayStringStrEnum
import config
from utils.displayString import DisplayStringIntEnum, DisplayStringStrEnum


class Channel(DisplayStringStrEnum):
Expand Down Expand Up @@ -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}")
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
140 changes: 140 additions & 0 deletions source/addonStore/settings.py
Original file line number Diff line number Diff line change
@@ -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."""
seanbudd marked this conversation as resolved.
Show resolved Hide resolved


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)
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
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)
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
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
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
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)
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved

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()
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved

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()
SaschaCowley marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 3 additions & 1 deletion source/config/configSpec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading