Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
110 changes: 82 additions & 28 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,23 +350,36 @@ class InstallingOverNewerVersionDialog(
):
helpId = "InstallingNVDA"

def __init__(self):
_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.
"An existing NVDA installation has been detected, "
"but its version cannot be determined. "
"If you are attempting to install an earlier version of NVDA, "
"you should first cancel this installation "
"and completely uninstall NVDA before installing the earlier version.",
)

def __init__(self, installState: ComparisonState = ComparisonState.DOWNGRADE):
# Translators: The title of a warning dialog.
super().__init__(gui.mainFrame, title=_("Warning"))
self.installState = installState

mainSizer = wx.BoxSizer(wx.VERTICAL)
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 +405,48 @@ def __init__(self):
mainSizer.Fit(self)
self.CentreOnScreen()

@property
def _warningText(self) -> str:
match self.installState:
case ComparisonState.DOWNGRADE:
return self._DOWNGRADE_WARNING
case ComparisonState.UNKNOWN:
return self._UNKNOWN_WARNING
case _:
raise ValueError(f"Invalid install state for warning dialog {self.installState}")


class PortableCopyOverNewerVersionDialog(InstallingOverNewerVersionDialog):
helpId = "CreatingAPortableCopy"

_DOWNGRADE_WARNING = _(
# Translators: A warning presented when the user attempts to downgrade NVDA
# to an older version.
"You are attempting to replace an existing portable copy of NVDA with an earlier version. "
"Downgrading NVDA is not recommended. "
"You should cancel this operation and create a new portable copy instead. ",
)

_UNKNOWN_WARNING = _(
# Translators: A warning presented when the installer is unable to determine
# the state of the current NVDA installation.
"An existing copy of NVDA has been detected in the chosen directory, "
"but its version cannot be determined. "
"If you are attempting to downgrade to an earlier version of NVDA, "
"you should cancel this operation and create a new portable copy. ",
)


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.
d = InstallingOverNewerVersionDialog()
installState = installer._comparePreviousInstall()
if installState in (ComparisonState.DOWNGRADE, ComparisonState.UNKNOWN):
d = InstallingOverNewerVersionDialog(installState)
with d:
Comment thread
seanbudd marked this conversation as 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 All @@ -423,7 +467,7 @@ def _warnAndConfirmForNonEmptyDirectory(portableDirectory: str) -> bool:
# The directory is empty, so we can proceed.
return True
if _nvdaExistsInDir(portableDirectory):
return wx.YES == gui.messageBox(
if wx.NO == gui.messageBox(
_(
# Translators: The message displayed when the user has specified a destination directory
# that already has a portable copy in the Create Portable NVDA dialog.
Expand All @@ -434,7 +478,17 @@ def _warnAndConfirmForNonEmptyDirectory(portableDirectory: str) -> bool:
# that already has a portable copy in the Create Portable NVDA dialog.
_("Portable Copy Exists"),
wx.YES_NO | wx.ICON_QUESTION,
)
):
# The user does not want to update the existing portable copy, so we cancel.
return False
installState = installer._comparePreviousCopy(portableDirectory)
if installState in (ComparisonState.DOWNGRADE, ComparisonState.UNKNOWN):
d = PortableCopyOverNewerVersionDialog(installState)
with d:
if d.ShowModal() == wx.ID_CANCEL:
gui.mainFrame.postPopup()
return False
return True
return wx.YES == gui.messageBox(
_(
# Translators: The message displayed when the user has specified a destination directory
Expand Down
110 changes: 82 additions & 28 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",
lambda: _comparePreviousInstall()._legacyValue,
),
)


Expand Down Expand Up @@ -93,37 +99,85 @@ 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 _comparePreviousInstall() -> ComparisonState:
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
if pathExists:
try:
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
pathX64 = WritePaths.installDir
pathX64Exists = pathX64 and os.path.isdir(pathX64)

installPath = None
if pathX64Exists:
installPath = pathX64
elif pathX86Exists:
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
installPath = pathX86

return _comparePreviousCopy(installPath)


def _comparePreviousCopy(previousCopyPath: str | None) -> 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
"""
previousCopyPathExists = previousCopyPath and os.path.isdir(previousCopyPath)
if not previousCopyPathExists:
return ComparisonState.FRESH_INSTALL

oldSlavePath = os.path.join(previousCopyPath, "nvda_slave.exe")
try:
newTime = os.path.getmtime("nvda_slave.exe")
except OSError:
oldVersion = fileUtils.getFileVersionInfo(oldSlavePath, "FileVersion")
except (OSError, RuntimeError):
log.debug("Unable to get file version of nvda_slave.exe in previous copy.")
return ComparisonState.UNKNOWN

try:
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:
return ComparisonState.REINSTALL


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