Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions source/addonHandler/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from typing import (
Callable,
Dict,
IO,
Literal,
Optional,
Set,
Expand Down Expand Up @@ -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
Expand Down
31 changes: 21 additions & 10 deletions source/addonStore/dataManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions source/addonStore/models/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
98 changes: 71 additions & 27 deletions source/addonStore/models/status.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, 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.

Expand All @@ -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


Expand Down Expand Up @@ -243,47 +243,91 @@ 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.
# Ideally a numeric version would be compared,
# 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]:
Expand Down
6 changes: 3 additions & 3 deletions source/config/configFlags.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,7 @@ def _displayStringLabels(self):

class AddonsAutomaticUpdate(DisplayStringStrEnum):
NOTIFY = "notify"
# TODO: uncomment when implementing #3208
# UPDATE = "update"
UPDATE = "update"
DISABLED = "disabled"

@property
Expand All @@ -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"),
}
Expand Down
3 changes: 2 additions & 1 deletion source/config/configSpec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions source/gui/addonStoreGui/controls/details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading