Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
90b51c3
fix version comparisons
seanbudd Feb 17, 2026
3ef6cb2
import fileUtils
seanbudd Feb 17, 2026
cfa8ef7
fix up parsing logic
seanbudd Feb 17, 2026
a0b01d5
fix up parsing logic
seanbudd Feb 17, 2026
b1b487d
minor fixes
seanbudd Feb 17, 2026
49bc979
Update source/fileUtils.py
seanbudd Feb 17, 2026
ab82529
change default arg for startOnLogon param of doInstall
seanbudd Feb 17, 2026
3b677a6
add better back compat handling
seanbudd Feb 17, 2026
9aec218
don't show warning dialog on reinstall, it should be safe
seanbudd Feb 17, 2026
40a9f8d
add unit tests
seanbudd Feb 17, 2026
6655f7f
Merge branch 'beta' into fixCompareVersion
seanbudd Feb 17, 2026
6795c7c
fixup unit tests
seanbudd Feb 17, 2026
cd3a790
Update source/installer.py
seanbudd Feb 17, 2026
ec89563
Update source/installer.py
seanbudd Feb 17, 2026
d35fd73
polish up
seanbudd Feb 18, 2026
8f77aff
Pre-commit auto-fix
pre-commit-ci[bot] Feb 18, 2026
760a19c
Add warning text
seanbudd Feb 18, 2026
8fd79ff
Merge remote-tracking branch 'origin/fixCompareVersion' into fixCompa…
seanbudd Feb 18, 2026
a994392
fix import
seanbudd Feb 18, 2026
4d21e2b
fix up issues
seanbudd Feb 18, 2026
a09370a
use lambda
seanbudd Feb 18, 2026
f809b0d
fix portable copies
seanbudd Feb 18, 2026
8145d15
use diff messages
seanbudd Feb 18, 2026
c814204
Pre-commit auto-fix
pre-commit-ci[bot] Feb 18, 2026
f585227
fix prevcopyexists
seanbudd Feb 19, 2026
90daa69
Merge remote-tracking branch 'origin/fixCompareVersion' into fixCompa…
seanbudd Feb 19, 2026
4202fa8
pass in state
seanbudd Feb 19, 2026
b25022b
Update user_docs/en/changes.md
seanbudd Feb 19, 2026
26c95a0
Apply suggestions from code review
seanbudd Feb 19, 2026
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
16 changes: 11 additions & 5 deletions source/fileUtils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2017-2025 NV Access Limited, Bram Duvigneau, Łukasz Golonka
# Copyright (C) 2017-2026 NV Access Limited, Bram Duvigneau, Łukasz Golonka
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

Expand Down Expand Up @@ -70,10 +70,16 @@ def funcWrapper(filePath, *attributes):


@_suspendWow64RedirectionForFileInfoRetrieval
def getFileVersionInfo(name, *attributes):
"""Gets the specified file version info attributes from the provided file."""
if not isinstance(name, str):
raise TypeError("name must be an unicode string")
def getFileVersionInfo(name: str, *attributes: str) -> dict[str, str | None]:
"""
Gets the specified file version info attributes from the provided file.
:param name: The path to the file to get version info from.
:param attributes: The list of attributes to get. E.g. "FileVersion", "ProductVersion"
:return: A dictionary mapping the provided attributes to their values.
If an attribute is not found or invalid, its value will be None.

:raises RuntimeError: If the file does not exist, has no version information, or has no codepage.
"""
if not os.path.exists(name):
raise RuntimeError("The file %s does not exist" % name)
fileVersionInfo = {}
Expand Down
68 changes: 44 additions & 24 deletions source/gui/installerGui.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from winBindings import shell32
import globalVars
import installer
from installer import ComparisonState
from logHandler import log
import gui
from gui import guiHelper
Expand Down Expand Up @@ -55,12 +56,12 @@ def _canPortableConfigBeCopied() -> bool:


def doInstall(
createDesktopShortcut=True,
startOnLogon=True,
isUpdate=False,
copyPortableConfig=False,
silent=False,
startAfterInstall=True,
createDesktopShortcut: bool = True,
startOnLogon: bool = False,
isUpdate: bool = False,
copyPortableConfig: bool = False,
silent: bool = False,
startAfterInstall: bool = True,
):
progressDialog = gui.IndeterminateProgressDialog(
gui.mainFrame,
Expand Down Expand Up @@ -180,14 +181,14 @@ def doSilentInstall(
copyPortableConfig=False,
startAfterInstall=True,
):
prevInstall = installer.comparePreviousInstall() is not None
freshInstall = installer._comparePreviousInstall() is ComparisonState.FRESH_INSTALL
startOnLogon = globalVars.appArgs.enableStartOnLogon
if startOnLogon is None:
startOnLogon = config.getStartOnLogonScreen() if prevInstall else True
startOnLogon = config.getStartOnLogonScreen() if not freshInstall else False
doInstall(
createDesktopShortcut=installer.isDesktopShortcutInstalled() if prevInstall else True,
createDesktopShortcut=installer.isDesktopShortcutInstalled() if not freshInstall else True,
startOnLogon=startOnLogon,
isUpdate=prevInstall,
isUpdate=not freshInstall,
copyPortableConfig=copyPortableConfig,
silent=True,
startAfterInstall=startAfterInstall,
Expand Down Expand Up @@ -262,7 +263,7 @@ def __init__(self, parent, isUpdate):
if globalVars.appArgs.enableStartOnLogon is not None:
self.startOnLogonCheckbox.Value = globalVars.appArgs.enableStartOnLogon
else:
self.startOnLogonCheckbox.Value = config.getStartOnLogonScreen() if self.isUpdate else True
self.startOnLogonCheckbox.Value = config.getStartOnLogonScreen() if self.isUpdate else False

shortcutIsPrevInstalled = installer.isDesktopShortcutInstalled()
if self.isUpdate and shortcutIsPrevInstalled:
Expand Down Expand Up @@ -349,6 +350,26 @@ class InstallingOverNewerVersionDialog(
):
helpId = "InstallingNVDA"

_DOWNGRADE_WARNING = _(
# Translators: A warning presented when the user attempts to downgrade NVDA
# to an older version.
"You are attempting to install an earlier version of NVDA "
"than the version currently installed. "
"If you really wish to revert to an earlier version, "
"you should first cancel this installation "
"and completely uninstall NVDA before installing the earlier version.",
)

_UNKNOWN_WARNING = _(
# Translators: A warning presented when the installer is unable to determine
# the state of the current NVDA installation.
"NVDA has detected that you have NVDA already installed, "
"but it is unable to determine the version. "
Comment thread
seanbudd marked this conversation as resolved.
Resolved
"If you are attempting to install an earlier version of NVDA "
Comment thread
seanbudd marked this conversation as resolved.
Resolved
"you should first cancel this installation "
"and completely uninstall NVDA before installing the earlier version.",
)

def __init__(self):
# Translators: The title of a warning dialog.
super().__init__(gui.mainFrame, title=_("Warning"))
Expand All @@ -357,15 +378,7 @@ def __init__(self):
contentSizer = guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL)
text = wx.StaticText(
self,
label=_(
# Translators: A warning presented when the user attempts to downgrade NVDA
# to an older version.
"You are attempting to install an earlier version of NVDA "
"than the version currently installed. "
"If you really wish to revert to an earlier version, "
"you should first cancel this installation "
"and completely uninstall NVDA before installing the earlier version.",
),
label=self._warningText,
)
text.Wrap(self.scaleSize(600))
contentSizer.addItem(text)
Expand All @@ -391,18 +404,25 @@ def __init__(self):
mainSizer.Fit(self)
self.CentreOnScreen()

@property
def _warningText(self) -> str:
if self._installState == ComparisonState.DOWNGRADE:
return self._DOWNGRADE_WARNING
if self._installState == ComparisonState.UNKNOWN:
return self._UNKNOWN_WARNING
raise ValueError("Invalid install state for warning dialog")


def showInstallGui():
gui.mainFrame.prePopup()
previous = installer.comparePreviousInstall()
if previous is not None and previous > 0:
# The existing installation is newer, which means this will be a downgrade.
installState = installer._comparePreviousInstall()
if installState in (ComparisonState.DOWNGRADE, ComparisonState.UNKNOWN):
d = InstallingOverNewerVersionDialog()
with d:
Comment thread
seanbudd marked this conversation as resolved.
Resolved
if d.ShowModal() == wx.ID_CANCEL:
gui.mainFrame.postPopup()
return
InstallerDialog(gui.mainFrame, previous is not None).Show()
InstallerDialog(gui.mainFrame, installState is not ComparisonState.FRESH_INSTALL).Show()
gui.mainFrame.postPopup()


Expand Down
98 changes: 77 additions & 21 deletions source/installer.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# 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) 2011-2025 NV Access Limited, Joseph Lee, Babbage B.V., Łukasz Golonka, Cyrille Bougot
# Copyright (C) 2011-2026 NV Access Limited, Joseph Lee, Babbage B.V., Łukasz Golonka, Cyrille Bougot

from collections.abc import Iterable
import comtypes.client
import ctypes
from enum import auto, Enum
import pathlib
import winreg
import time
Expand All @@ -18,6 +19,7 @@
import languageHandler
import config
from config.registry import NVDA_ADDON_PROG_ID, RegistryKey, _deleteKeyAndSubkeys
import fileUtils
import versionInfo
import buildVersion
from logHandler import log
Expand All @@ -28,7 +30,7 @@
import NVDAState
from NVDAState import WritePaths
from utils.tempFile import _createEmptyTempFileForDeletingFile
from utils._deprecate import handleDeprecations, MovedSymbol
from utils._deprecate import handleDeprecations, MovedSymbol, RemovedSymbol

_wsh = None

Expand All @@ -45,6 +47,10 @@
"WritePaths",
"defaultInstallDir",
),
RemovedSymbol(
"comparePreviousInstall",
"_legacyComparePreviousInstall",
Comment thread
seanbudd marked this conversation as resolved.
Resolved
),
)


Expand Down Expand Up @@ -93,37 +99,87 @@ def createShortcut(
short.Save()


def comparePreviousInstall() -> int | None:
"""Returns 1 if the existing installation is newer than this running version,
0 if it is the same, -1 if it is older,
None if there is no existing installation.
class ComparisonState(Enum):
FRESH_INSTALL = auto()
DOWNGRADE = auto()
REINSTALL = auto()
UPGRADE = auto()
UNKNOWN = auto()

@property
def _legacyValue(self) -> int | None:
"""Legacy value for comparison state."""
match self:
case ComparisonState.FRESH_INSTALL:
return None
case ComparisonState.DOWNGRADE:
return 1
case ComparisonState.REINSTALL:
return 0
case ComparisonState.UPGRADE:
return -1
case ComparisonState.UNKNOWN:
return None


def _legacyComparePreviousInstall() -> int | None:
Comment thread
seanbudd marked this conversation as resolved.
Resolved
"""Legacy wrapper for _comparePreviousInstall"""
return _comparePreviousInstall()._legacyValue


def _comparePreviousInstall() -> ComparisonState:
"""
Compares the version of the currently running NVDA with the version of a previous installation of NVDA on this system, if any.
:return:
- ComparisonState.FRESH_INSTALL if no previous installation is found
- ComparisonState.DOWNGRADE if the previous installation is newer than the current one
- ComparisonState.REINSTALL if they are the same version
- ComparisonState.UPGRADE if the previous installation is older than the current one
- ComparisonState.UNKNOWN if there was an error determining the version of either the current or previous installation
"""
pathX86 = WritePaths._installDirX86
pathX86Exists = pathX86 and os.path.isdir(pathX86)
path = WritePaths.installDir
pathExists = path and os.path.isdir(path)
oldTime = None

if not (pathExists or pathX86Exists):
return None
return ComparisonState.FRESH_INSTALL

if pathExists:
oldSlavePath = os.path.join(path, "nvda_slave.exe")
try:
Comment thread
seanbudd marked this conversation as resolved.
Resolved
oldTime = os.path.getmtime(os.path.join(path, "nvda_slave.exe"))
except OSError:
log.debug("Unable to get modification time of nvda_slave.exe in previous installation.")
return None
oldVersion = fileUtils.getFileVersionInfo(oldSlavePath, "FileVersion")
except (OSError, RuntimeError):
log.debug("Unable to get file version of nvda_slave.exe in previous installation.")
return ComparisonState.UNKNOWN
elif pathX86Exists:
oldSlavePath = os.path.join(pathX86, "nvda_slave.exe")
try:
oldTime = os.path.getmtime(os.path.join(pathX86, "nvda_slave.exe"))
except OSError:
log.debug("Unable to get modification time of nvda_slave.exe in previous installation (x86).")
return None
oldVersion = fileUtils.getFileVersionInfo(oldSlavePath, "FileVersion")
except (OSError, RuntimeError):
log.debug("Unable to get file version of nvda_slave.exe in previous installation (x86).")
return ComparisonState.UNKNOWN

try:
newTime = os.path.getmtime("nvda_slave.exe")
except OSError:
newVersion = fileUtils.getFileVersionInfo("nvda_slave.exe", "FileVersion")
except (OSError, RuntimeError):
# This should never happen.
log.error("Unable to get modification time of nvda_slave.exe in current process.")
return None
return (oldTime > newTime) - (oldTime < newTime)
log.exception("Unable to get file version of nvda_slave.exe in current process.")
return ComparisonState.UNKNOWN

try:
oldVersion = [int(x) for x in oldVersion["FileVersion"].split(".")]
newVersion = [int(x) for x in newVersion["FileVersion"].split(".")]
except (KeyError, AttributeError, ValueError, TypeError):
log.exception("Error parsing version information.")
return ComparisonState.UNKNOWN

if oldVersion > newVersion:
return ComparisonState.DOWNGRADE
elif oldVersion < newVersion:
return ComparisonState.UPGRADE
else:
Comment thread
seanbudd marked this conversation as resolved.
Resolved
return ComparisonState.REINSTALL


def getDocFilePath(fileName: str, installDir: str):
Expand Down
Loading
Loading