diff --git a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf index c9574b27af3..33d6d0b98c5 100644 --- a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf +++ b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf @@ -1,7 +1,7 @@ /* This file is a part of the NVDA project. URL: http://www.nvda-project.org/ -Copyright 2006-2010 NVDA contributers. +Copyright 2006-2025 NVDA contributers. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 2.0, as published by the Free Software Foundation. @@ -23,6 +23,7 @@ interface NvdaControllerInternal { [fault_status,comm_status] logMessage(); [fault_status,comm_status] vbufChangeNotify(); [fault_status,comm_status] installAddonPackageFromPath(); + [fault_status,comm_status] handleRemoteURL(); [fault_status,comm_status] drawFocusRectNotify(); [fault_status,comm_status] reportLiveRegion(); [fault_status,comm_status] openConfigDirectory(); diff --git a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl index 523cd9902c0..9a9a28feab1 100644 --- a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl +++ b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl @@ -1,7 +1,7 @@ /* This file is a part of the NVDA project. URL: http://www.nvda-project.org/ -Copyright 2006-2018 NV Access Limited, rui Batista, Google LLC. +Copyright 2006-2025 NV Access Limited, rui Batista, Google LLC, Christopher Toth. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 2.0, as published by the Free Software Foundation. @@ -102,4 +102,10 @@ interface NvdaControllerInternal { * Asks NVDA to open currently used configuration directory. */ error_status_t __stdcall openConfigDirectory(); + +/** + * Handles a remote URL request from the slave process. + * @param url The nvdaremote:// URL to process. + */ + error_status_t __stdcall handleRemoteURL([in,string] const wchar_t* url); }; diff --git a/nvdaHelper/local/nvdaControllerInternal.c b/nvdaHelper/local/nvdaControllerInternal.c index 8281a161d63..a2c6a241fa6 100644 --- a/nvdaHelper/local/nvdaControllerInternal.c +++ b/nvdaHelper/local/nvdaControllerInternal.c @@ -1,7 +1,7 @@ /* This file is a part of the NVDA project. URL: http://www.nvda-project.org/ -Copyright 2006-2018 NV Access Limited, rui Batista, Google LLC. +Copyright 2006-2025 NV Access Limited, rui Batista, Google LLC, Christopher Toth. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 2.0, as published by the Free Software Foundation. @@ -70,6 +70,11 @@ error_status_t __stdcall nvdaControllerInternal_installAddonPackageFromPath(cons return _nvdaControllerInternal_installAddonPackageFromPath(addonPath); } +error_status_t(__stdcall *_nvdaControllerInternal_handleRemoteURL)(const wchar_t*); +error_status_t __stdcall nvdaControllerInternal_handleRemoteURL(const wchar_t* url) { + return _nvdaControllerInternal_handleRemoteURL(url); +} + error_status_t(__stdcall *_nvdaControllerInternal_drawFocusRectNotify)(const long, const long, const long, const long, const long); error_status_t __stdcall nvdaControllerInternal_drawFocusRectNotify(const long hwnd, const long left, const long top, const long right, const long bottom) { return _nvdaControllerInternal_drawFocusRectNotify(hwnd,left,top,right,bottom); diff --git a/nvdaHelper/local/nvdaHelperLocal.def b/nvdaHelper/local/nvdaHelperLocal.def index 5f95d8cea88..cb7bf3e380f 100644 --- a/nvdaHelper/local/nvdaHelperLocal.def +++ b/nvdaHelper/local/nvdaHelperLocal.def @@ -46,6 +46,7 @@ EXPORTS _nvdaControllerInternal_logMessage _nvdaControllerInternal_typedCharacterNotify _nvdaControllerInternal_installAddonPackageFromPath + _nvdaControllerInternal_handleRemoteURL _nvdaControllerInternal_drawFocusRectNotify _nvdaController_brailleMessage _nvdaController_cancelSpeech diff --git a/nvdaHelper/remote/nvdaHelperRemote.def b/nvdaHelper/remote/nvdaHelperRemote.def index 55def5c88aa..a69c823c105 100644 --- a/nvdaHelper/remote/nvdaHelperRemote.def +++ b/nvdaHelper/remote/nvdaHelperRemote.def @@ -15,6 +15,7 @@ EXPORTS nvdaControllerInternal_logMessage nvdaControllerInternal_vbufChangeNotify nvdaControllerInternal_installAddonPackageFromPath + nvdaControllerInternal_handleRemoteURL nvdaController_testIfRunning nvdaController_speakText nvdaController_cancelSpeech diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index 7349a23f60a..3d52e2d5de7 100644 --- a/projectDocs/dev/developerGuide/developerGuide.md +++ b/projectDocs/dev/developerGuide/developerGuide.md @@ -1384,6 +1384,7 @@ For examples of how to define and use new extension points, please see the code |`Action` |`pre_speechCanceled` |Triggered before speech is canceled.| |`Action` |`pre_speech` |Triggered before NVDA handles prepared speech.| |`Action` |`post_speechPaused` |Triggered when speech is paused or resumed.| +|`Action` |`pre_speechQueued` |Triggered after speech is processed and normalized and directly before it is enqueued.| |`Filter` |`filter_speechSequence` |Allows components or add-ons to filter speech sequence before it passes to the synth driver.| ### synthDriverHandler {#synthDriverHandlerExtPts} diff --git a/requirements.txt b/requirements.txt index 983a7805984..b61940615e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ SCons==4.8.1 # NVDA's runtime dependencies comtypes==1.4.6 +cryptography==44.0.0 pyserial==3.5 wxPython==4.2.2 configobj @ git+https://github.com/DiffSK/configobj@8be54629ee7c26acb5c865b74c76284e80f3aa31#egg=configobj diff --git a/source/NVDAHelper.py b/source/NVDAHelper.py index 55923ef794a..6c5573133b4 100755 --- a/source/NVDAHelper.py +++ b/source/NVDAHelper.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2008-2023 NV Access Limited, Peter Vagner, Davy Kager, Mozilla Corporation, Google LLC, +# Copyright (C) 2008-2025 NV Access Limited, Peter Vagner, Davy Kager, Mozilla Corporation, Google LLC, # Leonard de Ruijter # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -687,6 +687,31 @@ def nvdaControllerInternal_openConfigDirectory(): return 0 +@WINFUNCTYPE(c_long, c_wchar_p) +def nvdaControllerInternal_handleRemoteURL(url): + """Handles a remote URL request from the slave process. + + :param url: The nvdaremote:// URL to process + :return: 0 on success, -1 on failure + """ + from remoteClient import connectionInfo, _remoteClient as client + + try: + if not client: + log.error("No RemoteClient instance available") + return -1 + # Queue the URL handling on the main thread + queueHandler.queueFunction( + queueHandler.eventQueue, + client.verifyAndConnect, + connectionInfo.ConnectionInfo.fromURL(url), + ) + return 0 + except Exception: + log.error("Error handling remote URL", exc_info=True) + return -1 + + class _RemoteLoader: def __init__(self, loaderDir: str): # Create a pipe so we can write to stdin of the loader process. @@ -776,6 +801,7 @@ def initialize() -> None: ), ("nvdaControllerInternal_drawFocusRectNotify", nvdaControllerInternal_drawFocusRectNotify), ("nvdaControllerInternal_openConfigDirectory", nvdaControllerInternal_openConfigDirectory), + ("nvdaControllerInternal_handleRemoteURL", nvdaControllerInternal_handleRemoteURL), ]: try: _setDllFuncPointer(localLib, "_%s" % name, func) diff --git a/source/config/__init__.py b/source/config/__init__.py index 62d36551979..00210ab45ce 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2024 NV Access Limited, Aleksey Sadovoy, Peter Vágner, Rui Batista, Zahari Yurukov, +# Copyright (C) 2006-2025 NV Access Limited, Aleksey Sadovoy, Peter Vágner, Rui Batista, Zahari Yurukov, # Joseph Lee, Babbage B.V., Łukasz Golonka, Julien Cochuyt, Cyrille Bougot # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -526,6 +526,7 @@ class ConfigManager(object): "update", "development", "addonStore", + "remote", } """ Sections that only apply to the base configuration; diff --git a/source/config/configSpec.py b/source/config/configSpec.py index ec121e060fe..37f9c79f312 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -13,7 +13,7 @@ #: provide an upgrade step (@see profileUpgradeSteps.py). An upgrade step does not need to be added when #: just adding a new element to (or removing from) the schema, only when old versions of the config #: (conforming to old schema versions) will not work correctly with the new schema. -latestSchemaVersion = 15 +latestSchemaVersion = 16 #: The configuration specification string #: @type: String @@ -344,6 +344,24 @@ # UpdateChannel values: # same channel (default), any channel, do not update, stable, beta & dev, beta, dev defaultUpdateChannel = integer(0, 6, default=0) + +# Remote Settings +[remote] + [[connections]] + last_connected = list(default=list()) + [[controlserver]] + autoconnect = boolean(default=False) + self_hosted = boolean(default=False) + connection_type = integer(default=0, min=0, max=1) # 0: follower, 1: leader + host = string(default="") + port = integer(default=6837) + key = string(default="") + [[seen_motds]] + __many__ = string(default="") + [[trusted_certs]] + __many__ = string(default="") + [[ui]] + play_sounds = boolean(default=True) """ #: The configuration specification diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index bc235e45e09..f267e3771a9 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -13,19 +13,22 @@ that no information is lost, while updating the ConfigObj to meet the requirements of the new schema. """ +import os + +import configobj.validate +from configobj import ConfigObj from logHandler import log + from config.configFlags import ( NVDAKey, - ShowMessages, - TetherTo, + OutputMode, + ReportCellBorders, ReportLineIndentation, ReportTableHeaders, - ReportCellBorders, - OutputMode, + ShowMessages, + TetherTo, TypingEcho, ) -import configobj.validate -from configobj import ConfigObj def upgradeConfigFrom_0_to_1(profile: ConfigObj) -> None: @@ -500,3 +503,38 @@ def _convertTypingEcho(profile: ConfigObj, key: str) -> None: newValue = TypingEcho.EDIT_CONTROLS.value if oldValue else TypingEcho.OFF.value profile["keyboard"][key] = newValue log.debug(f"Converted '{key}' from {oldValue!r} to {newValue} ({TypingEcho(newValue).name}).") + + +def upgradeConfigFrom_15_to_16(profile: ConfigObj) -> None: + """Migrate remote.ini settings into the main config.""" + remoteIniPath = os.path.join(os.path.dirname(profile.filename), "remote.ini") + if not os.path.isfile(remoteIniPath): + log.debug(f"No remote.ini found, no action taken. Checked {remoteIniPath}") + return + + try: + remoteConfig = ConfigObj(remoteIniPath, encoding="UTF-8") + log.debug(f"Loading remote config from {remoteIniPath}") + except Exception: + log.error("Error loading remote.ini", exc_info=True) + return + + # Create remote section if it doesn't exist + if "remote" not in profile: + profile["remote"] = {} + + # Copy all sections from remote.ini + for section in remoteConfig: + if section not in profile["remote"]: + profile["remote"][section] = {} + profile["remote"][section].update(remoteConfig[section]) + + try: + # Backup the old file just in case + backupPath = remoteIniPath + ".old" + if os.path.exists(backupPath): + os.unlink(backupPath) + os.rename(remoteIniPath, backupPath) + log.debug(f"Backed up remote.ini to {backupPath}") + except Exception: + log.error("Error backing up remote.ini after migration", exc_info=True) diff --git a/source/core.py b/source/core.py index d897b61d6ae..ee159213712 100644 --- a/source/core.py +++ b/source/core.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2024 NV Access Limited, Aleksey Sadovoy, Christopher Toth, Joseph Lee, Peter Vágner, +# Copyright (C) 2006-2025 NV Access Limited, Aleksey Sadovoy, Christopher Toth, Joseph Lee, Peter Vágner, # Derek Riemer, Babbage B.V., Zahari Yurukov, Łukasz Golonka, Cyrille Bougot, Julien Cochuyt # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -897,6 +897,12 @@ def main(): log.debug("Initializing global plugin handler") globalPluginHandler.initialize() + + log.debug("Initializing remote client") + import remoteClient + + remoteClient.initialize() + if globalVars.appArgs.install or globalVars.appArgs.installSilent: import gui.installerGui @@ -1049,6 +1055,7 @@ def _doPostNvdaStartupAction(): " This likely indicates NVDA is exiting due to WM_QUIT.", ) queueHandler.pumpAll() + _terminate(remoteClient) _terminate(gui) config.saveOnExit() diff --git a/source/globalCommands.py b/source/globalCommands.py index c88f4f527e4..244f75bb5e0 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -71,7 +71,7 @@ import audio from audio import appsVolume from utils.displayString import DisplayStringEnum - +import remoteClient #: Script category for text review commands. # Translators: The name of a category of NVDA commands. @@ -121,6 +121,9 @@ #: Script category for audio streaming commands. # Translators: The name of a category of NVDA commands. SCRCAT_AUDIO = _("Audio") +#: Script category for Remote commands. +# Translators: The name of a category of NVDA commands. +SCRCAT_REMOTE = _("Remote") # Translators: Reported when there are no settings to configure in synth settings ring # (example: when there is no setting for language). @@ -4914,6 +4917,71 @@ def script_toggleApplicationsVolumeAdjuster(self, gesture: "inputCore.InputGestu def script_toggleApplicationsMute(self, gesture: "inputCore.InputGesture") -> None: appsVolume._toggleAppsVolumeMute() + @script( + # Translators: Describes a command. + description=_("""Mute or unmute the speech coming from the remote computer"""), + category=SCRCAT_REMOTE, + ) + def script_toggleRemoteMute(self, gesture: "inputCore.InputGesture"): + remoteClient._remoteClient.toggleMute() + + @script( + gesture="kb:control+shift+NVDA+c", + category=SCRCAT_REMOTE, + # Translators: Documentation string for the script that sends the contents of the clipboard to the remote machine. + description=_("Sends the contents of the clipboard to the remote machine"), + ) + def script_pushClipboard(self, gesture: "inputCore.InputGesture"): + remoteClient._remoteClient.pushClipboard() + + @script( + # Translators: Documentation string for the script that copies a link to the remote session to the clipboard. + description=_("""Copies a link to the remote session to the clipboard"""), + category=SCRCAT_REMOTE, + ) + def script_copyRemoteLink(self, gesture: "inputCore.InputGesture"): + remoteClient._remoteClient.copyLink() + # Translators: A message indicating that a link has been copied to the clipboard. + ui.message(_("Copied link")) + + @script( + gesture="kb:alt+NVDA+pageDown", + category=SCRCAT_REMOTE, + # Translators: Documentation string for the script that disconnects a remote session. + description=_("Disconnect a remote session"), + ) + @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) + def script_disconnectFromRemote(self, gesture: "inputCore.InputGesture"): + if not remoteClient._remoteClient.isConnected: + # Translators: A message indicating that the remote client is not connected. + ui.message(_("Not connected")) + return + remoteClient._remoteClient.disconnect() + + @script( + gesture="kb:alt+NVDA+pageUp", + # Translators: Documentation string for the script that invokes the remote session. + description=_("""Connect to a remote computer"""), + category=SCRCAT_REMOTE, + ) + @gui.blockAction.when(gui.blockAction.Context.MODAL_DIALOG_OPEN) + @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) + def script_connectToRemote(self, gesture: "inputCore.InputGesture"): + if remoteClient._remoteClient.isConnected() or remoteClient._remoteClient.connecting: + # Translators: A message indicating that the remote client is already connected. + ui.message(_("Already connected")) + return + remoteClient._remoteClient.doConnect() + + @script( + # Translators: Documentation string for the script that toggles the control between guest and host machine. + description=_("Toggles the control between guest and host machine"), + category=SCRCAT_REMOTE, + gesture="kb:NVDA+f11", + ) + def script_sendKeys(self, gesture: "inputCore.InputGesture"): + remoteClient._remoteClient.toggleRemoteKeyControl(gesture) + #: The single global commands instance. #: @type: L{GlobalCommands} diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index 579466e5f07..a80bbe658a0 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2016-2024 NV Access Limited, Łukasz Golonka +# Copyright (C) 2016-2025 NV Access Limited, Łukasz Golonka # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -45,6 +45,7 @@ def __init__(self, parent): from collections.abc import Callable from contextlib import contextmanager +from functools import wraps import sys import threading import weakref @@ -529,3 +530,26 @@ def functionWrapper(): raise exception else: return result + + +# TODO: Rewrite to use type parameter lists when upgrading to python 3.12 or later. +_AlwaysCallAfterP = ParamSpec("_AlwaysCallAfterP") + + +def alwaysCallAfter(func: Callable[_AlwaysCallAfterP, Any]) -> Callable[_AlwaysCallAfterP, None]: + """Makes GUI updates thread-safe by running in the main thread. + + Example: + @alwaysCallAfter + def updateLabel(text): + label.SetLabel(text) # Safe GUI update from any thread + + .. note:: + The value returned by the decorated function will be discarded. + """ + + @wraps(func) + def wrapper(*args: _AlwaysCallAfterP.args, **kwargs: _AlwaysCallAfterP.kwargs) -> None: + wx.CallAfter(func, *args, **kwargs) + + return wrapper diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index dcf90189ca1..8f2b1695eca 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -49,6 +49,7 @@ import gui.contextHelp import globalVars from logHandler import log +from remoteClient import configuration import audio import audioDucking import queueHandler @@ -3365,6 +3366,158 @@ def onSave(self): config.conf["addonStore"]["defaultUpdateChannel"] = self.defaultUpdateChannelComboBox.GetSelection() +class RemoteSettingsPanel(SettingsPanel): + # Translators: This is the label for the remote settings category in NVDA Settings screen. + title = _("Remote") + autoconnect: wx.CheckBox + clientOrServer: wx.RadioBox + connectionType: wx.RadioBox + host: wx.TextCtrl + port: wx.SpinCtrl + key: wx.TextCtrl + playSounds: wx.CheckBox + deleteFingerprints: wx.Button + + def makeSettings(self, sizer): + self.config = configuration.getRemoteConfig() + sHelper = gui.guiHelper.BoxSizerHelper(self, sizer=sizer) + self.autoconnect = wx.CheckBox( + parent=self, + id=wx.ID_ANY, + # Translators: A checkbox in Remote settings to set whether NVDA should automatically connect to a control server on startup. + label=_("Automatically connect to control server on startup"), + ) + self.autoconnect.Bind(wx.EVT_CHECKBOX, self.onAutoconnect) + sHelper.addItem(self.autoconnect) + self.clientOrServer = wx.RadioBox( + self, + wx.ID_ANY, + choices=( + # Translators: Use a remote control server + _("Use Remote Control Server"), + # Translators: Host a control server + _("Host Control Server"), + ), + style=wx.RA_VERTICAL, + ) + self.clientOrServer.Bind(wx.EVT_RADIOBOX, self.onClientOrServer) + self.clientOrServer.SetSelection(0) + self.clientOrServer.Enable(False) + sHelper.addItem(self.clientOrServer) + choices = [ + # Translators: Radio button to allow this machine to be controlled + _("Allow this machine to be controlled"), + # Translators: Radio button to allow this machine to control another machine + _("Control another machine"), + ] + self.connectionType = wx.RadioBox(self, wx.ID_ANY, choices=choices, style=wx.RA_VERTICAL) + self.connectionType.SetSelection(0) + self.connectionType.Enable(False) + sHelper.addItem(self.connectionType) + sHelper.addItem(wx.StaticText(self, wx.ID_ANY, label=_("&Host:"))) + self.host = wx.TextCtrl(self, wx.ID_ANY) + self.host.Enable(False) + sHelper.addItem(self.host) + sHelper.addItem(wx.StaticText(self, wx.ID_ANY, label=_("&Port:"))) + self.port = wx.SpinCtrl(self, wx.ID_ANY, min=1, max=65535) + self.port.Enable(False) + sHelper.addItem(self.port) + sHelper.addItem(wx.StaticText(self, wx.ID_ANY, label=_("&Key:"))) + self.key = wx.TextCtrl(self, wx.ID_ANY) + self.key.Enable(False) + sHelper.addItem(self.key) + # Translators: A checkbox in Remote settings to set whether sounds play instead of beeps. + self.playSounds = wx.CheckBox(self, wx.ID_ANY, label=_("Play sounds instead of beeps")) + sHelper.addItem(self.playSounds) + # Translators: A button in Remote settings to delete all fingerprints of unauthorized certificates. + self.deleteFingerprints = wx.Button(self, wx.ID_ANY, label=_("Delete all trusted fingerprints")) + self.deleteFingerprints.Bind(wx.EVT_BUTTON, self.onDeleteFingerprints) + sHelper.addItem(self.deleteFingerprints) + self.setFromConfig() + + def onAutoconnect(self, evt: wx.CommandEvent) -> None: + self.setControls() + + def setControls(self) -> None: + state = bool(self.autoconnect.GetValue()) + self.clientOrServer.Enable(state) + self.connectionType.Enable(state) + self.key.Enable(state) + self.host.Enable(not bool(self.clientOrServer.GetSelection()) and state) + self.port.Enable(bool(self.clientOrServer.GetSelection()) and state) + + def onClientOrServer(self, evt: wx.CommandEvent) -> None: + evt.Skip() + self.setControls() + + def setFromConfig(self) -> None: + controlServer = self.config["controlserver"] + selfHosted = controlServer["self_hosted"] + connectionType = controlServer["connection_type"] + self.autoconnect.SetValue(controlServer["autoconnect"]) + self.clientOrServer.SetSelection(int(selfHosted)) + self.connectionType.SetSelection(connectionType) + self.host.SetValue(controlServer["host"]) + self.port.SetValue(str(controlServer["port"])) + self.key.SetValue(controlServer["key"]) + self.setControls() + self.playSounds.SetValue(self.config["ui"]["play_sounds"]) + + def onDeleteFingerprints(self, evt: wx.CommandEvent) -> None: + if ( + gui.messageBox( + _( + # Translators: This message is presented when the user tries to delete all stored trusted fingerprints. + "When connecting to an unauthorized server, you will again be prompted to accepts its certificate.", + ), + # Translators: This is the title of the dialog presented when the user tries to delete all stored trusted fingerprints. + _("Are you sure you want to delete all stored trusted fingerprints?"), + wx.YES | wx.NO | wx.NO_DEFAULT | wx.ICON_WARNING, + ) + == wx.YES + ): + self.config["trusted_certs"].clear() + evt.Skip() + + def isValid(self) -> bool: + if self.autoconnect.GetValue(): + if not self.clientOrServer.GetSelection() and ( + not self.host.GetValue() or not self.key.GetValue() + ): + gui.messageBox( + # Translators: This message is presented when the user tries to save the settings with the host or key field empty. + _("Both host and key must be set in the Remote section."), + # Translators: This is the title of the dialog presented when the user tries to save the settings with the host or key field empty. + _("Remote Error"), + wx.OK | wx.ICON_ERROR, + ) + return False + elif self.clientOrServer.GetSelection() and not self.port.GetValue() or not self.key.GetValue(): + gui.messageBox( + # Translators: This message is presented when the user tries to save the settings with the port or key field empty. + _("Both port and key must be set in the Remote section."), + # Translators: This is the title of the dialog presented when the user tries to save the settings with the port or key field empty. + _("Remote Error"), + wx.OK | wx.ICON_ERROR, + ) + return False + return True + + def onSave(self): + cs = self.config["controlserver"] + cs["autoconnect"] = self.autoconnect.GetValue() + selfHosted = bool(self.clientOrServer.GetSelection()) + connectionType = self.connectionType.GetSelection() + cs["self_hosted"] = selfHosted + cs["connection_type"] = connectionType + if not selfHosted: + cs["host"] = self.host.GetValue() + else: + cs["port"] = int(self.port.GetValue()) + cs["key"] = self.key.GetValue() + self.config["ui"]["play_sounds"] = self.playSounds.GetValue() + + class TouchInteractionPanel(SettingsPanel): # Translators: This is the label for the touch interaction settings panel. title = _("Touch Interaction") @@ -5246,6 +5399,8 @@ class NVDASettingsDialog(MultiCategorySettingsDialog): DocumentNavigationPanel, AddonStorePanel, ] + if not globalVars.appArgs.secure: + categoryClasses.append(RemoteSettingsPanel) if touchHandler.touchSupported(): categoryClasses.append(TouchInteractionPanel) if winVersion.isUwpOcrAvailable(): diff --git a/source/nvda_slave.pyw b/source/nvda_slave.pyw index 49625651f8c..83238566863 100755 --- a/source/nvda_slave.pyw +++ b/source/nvda_slave.pyw @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2009-2023 NV Access Limited, Cyrille Bougot +# Copyright (C) 2009-2025 NV Access Limited, Cyrille Bougot, Christopher Toth # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -118,6 +118,15 @@ def main(): 0, winUser.MB_ICONERROR, ) + elif action == "handleRemoteURL": + try: + url = args[0] + ret = getNvdaHelperRemote().nvdaControllerInternal_handleRemoteURL(url) + if ret != 0: + raise RuntimeError(f"URL handling failed with code {ret}") + except Exception: + logHandler.log.error("Error handling remote URL", exc_info=True) + sys.exit(1) elif action == "comGetActiveObject": import comHelper diff --git a/source/remoteClient/__init__.py b/source/remoteClient/__init__.py new file mode 100644 index 00000000000..9278c7cf02f --- /dev/null +++ b/source/remoteClient/__init__.py @@ -0,0 +1,24 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from .client import RemoteClient + +_remoteClient: RemoteClient = None + + +def initialize(): + """Initialise the remote client.""" + global _remoteClient + import globalCommands + + _remoteClient = RemoteClient() + _remoteClient.registerLocalScript(globalCommands.commands.script_sendKeys) + + +def terminate(): + """Terminate the remote client.""" + global _remoteClient + _remoteClient.terminate() + _remoteClient = None diff --git a/source/remoteClient/bridge.py b/source/remoteClient/bridge.py new file mode 100644 index 00000000000..cded9e66157 --- /dev/null +++ b/source/remoteClient/bridge.py @@ -0,0 +1,113 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Bridge Transport Module. + +Provides functionality to bridge two NVDA Remote transports together, +enabling bidirectional message passing with filtering and routing. + +:param transport1: First transport instance to bridge +:param transport2: Second transport instance to bridge + +The bridge acts as an intermediary layer that: +* Connects two transport instances +* Routes messages between them +* Filters out specific message types +* Manages message handler lifecycle + +Example:: + + transport1 = TCPTransport(serializer, addr1) + transport2 = TCPTransport(serializer, addr2) + bridge = BridgeTransport(transport1, transport2) + # Messages will now flow between transport1 and transport2 + bridge.disconnect() # Clean up when done +""" + +from collections.abc import Callable +from .protocol import RemoteMessageType +from .transport import Transport + +_CallbackT = Callable[..., None] + + +class BridgeTransport: + """A bridge between two NVDA Remote transport instances. + + Creates a bidirectional bridge between two Transport instances, + allowing them to exchange messages while providing message filtering capabilities. + Automatically sets up message handlers for all RemoteMessageTypes and manages + their lifecycle. + """ + + excluded: set[RemoteMessageType] = { + RemoteMessageType.CLIENT_JOINED, + RemoteMessageType.CLIENT_LEFT, + RemoteMessageType.CHANNEL_JOINED, + RemoteMessageType.SET_BRAILLE_INFO, + } + """Message types that should not be forwarded between transports + By default includes connection management messages that should remain local. + """ + + t1: Transport + """First transport instance to bridge""" + + t2: Transport + """Second transport instance to bridge""" + + t1Callbacks: dict[RemoteMessageType, _CallbackT] + """Storage for t1's message handlers""" + + t2Callbacks: dict[RemoteMessageType, _CallbackT] + """Storage for t2's message handlers""" + + def __init__(self, t1: Transport, t2: Transport) -> None: + """Initialize the bridge between two transports. + + Sets up message routing between the two provided transport instances + by registering handlers for all possible message types. + + :param t1: First transport instance to bridge + :param t2: Second transport instance to bridge + """ + self.t1 = t1 + self.t2 = t2 + # Store callbacks for each message type + self.t1Callbacks = {} + self.t2Callbacks = {} + + for messageType in RemoteMessageType: + # Create and store callbacks + self.t1Callbacks[messageType] = self.makeCallback(self.t1, messageType) + self.t2Callbacks[messageType] = self.makeCallback(self.t2, messageType) + # Register with stored references + t1.registerInbound(messageType, self.t2Callbacks[messageType]) + t2.registerInbound(messageType, self.t1Callbacks[messageType]) + + def makeCallback(self, targetTransport: Transport, messageType: RemoteMessageType) -> _CallbackT: + """Create a callback function for handling a specific message type. + + :param targetTransport: Transport instance to forward messages to + :param messageType: Type of message this callback will handle + :return: A callback function that forwards messages to the target transport + :note: Creates a closure that forwards messages unless the type is excluded + """ + + def callback(*args, **kwargs) -> None: + if messageType not in self.excluded: + targetTransport.send(messageType, *args, **kwargs) + + return callback + + def disconnect(self): + """Disconnect the bridge and clean up all message handlers. + + :note: Unregisters all message handlers from both transports. + Should be called before disposal to prevent memory leaks. + """ + for messageType in RemoteMessageType: + self.t1.unregisterInbound(messageType, self.t2Callbacks[messageType]) + self.t2.unregisterInbound(messageType, self.t1Callbacks[messageType]) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py new file mode 100644 index 00000000000..b9a2f0dc17d --- /dev/null +++ b/source/remoteClient/client.py @@ -0,0 +1,574 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import threading +from typing import Optional, Set, Tuple + +import api +import braille +import core +import gui +import inputCore +import ui +import wx +from config import isInstalledCopy +from keyboardHandler import KeyboardInputGesture +from logHandler import log +from gui.guiHelper import alwaysCallAfter +from utils.security import isRunningOnSecureDesktop +import scriptHandler + +from . import configuration, cues, dialogs, serializer, server, urlHandler +from .connectionInfo import ConnectionInfo, ConnectionMode +from .localMachine import LocalMachine +from .menu import RemoteMenu +from .protocol import RemoteMessageType, addressToHostPort +from .secureDesktop import SecureDesktopHandler +from .session import LeaderSession, FollowerSession +from .protocol import hostPortToAddress +from .transport import RelayTransport + +# Type aliases +KeyModifier = Tuple[int, bool] # (vk_code, extended) +Address = Tuple[str, int] # (hostname, port) + + +class RemoteClient: + localScripts: Set[scriptHandler._ScriptFunctionT] + localMachine: LocalMachine + leaderSession: Optional[LeaderSession] + followerSession: Optional[FollowerSession] + keyModifiers: Set[KeyModifier] + hostPendingModifiers: Set[KeyModifier] + connecting: bool + leaderTransport: Optional[RelayTransport] + followerTransport: Optional[RelayTransport] + localControlServer: Optional[server.LocalRelayServer] + sendingKeys: bool + + def __init__( + self, + ): + log.info("Initializing NVDA Remote client") + self.keyModifiers = set() + self.hostPendingModifiers = set() + self.localScripts = set() + self.localMachine = LocalMachine() + self.followerSession = None + self.leaderSession = None + self.menu: Optional[RemoteMenu] = None + if not isRunningOnSecureDesktop(): + self.menu: Optional[RemoteMenu] = RemoteMenu(self) + self.connecting = False + urlHandler.registerURLHandler() + self.leaderTransport = None + self.followerTransport = None + self.localControlServer = None + self.sendingKeys = False + self.sdHandler = SecureDesktopHandler() + if isRunningOnSecureDesktop(): + connection = self.sdHandler.initializeSecureDesktop() + if connection: + self.connectAsFollower(connection) + self.followerSession.transport.connectedEvent.wait( + self.sdHandler.SD_CONNECT_BLOCK_TIMEOUT, + ) + core.postNvdaStartup.register(self.performAutoconnect) + inputCore.decide_handleRawKey.register(self.processKeyInput) + + def performAutoconnect(self): + controlServerConfig = configuration.getRemoteConfig()["controlserver"] + if not controlServerConfig["autoconnect"] or self.leaderSession or self.followerSession: + log.debug("Autoconnect disabled or already connected") + return + key = controlServerConfig["key"] + insecure = False + if controlServerConfig["self_hosted"]: + port = controlServerConfig["port"] + hostname = "localhost" + insecure = True + self.startControlServer(port, key) + else: + hostname, port = addressToHostPort(controlServerConfig["host"]) + mode = ( + ConnectionMode.FOLLOWER if controlServerConfig["connection_type"] == 0 else ConnectionMode.LEADER + ) + conInfo = ConnectionInfo(mode=mode, hostname=hostname, port=port, key=key, insecure=insecure) + self.connect(conInfo) + + def terminate(self): + self.sdHandler.terminate() + self.disconnect() + self.localMachine.terminate() + self.localMachine = None + self.menu = None + self.localScripts.clear() + core.postNvdaStartup.unregister(self.performAutoconnect) + inputCore.decide_handleRawKey.unregister(self.processKeyInput) + if not isInstalledCopy(): + urlHandler.unregisterURLHandler() + + def toggleMute(self): + """Toggle muting of speech and sounds from the remote computer. + + :note: Updates menu item state and announces new mute status + """ + self.localMachine.isMuted = not self.localMachine.isMuted + self.menu.muteItem.Check(self.localMachine.isMuted) + # Translators: Displayed when muting speech and sounds from the remote computer + MUTE_MESSAGE = _("Muted remote") + # Translators: Displayed when unmuting speech and sounds from the remote computer + UNMUTE_MESSAGE = _("Unmuted remote") + status = MUTE_MESSAGE if self.localMachine.isMuted else UNMUTE_MESSAGE + ui.message(status) + + def pushClipboard(self): + """Send local clipboard content to the remote computer. + + :note: Requires an active connection + :raises TypeError: If clipboard content cannot be serialized + """ + connector = self.followerTransport or self.leaderTransport + if not getattr(connector, "connected", False): + # Translators: Message shown when trying to push the clipboard to the remote computer while not connected. + ui.message(_("Not connected.")) + return + try: + connector.send(RemoteMessageType.SET_CLIPBOARD_TEXT, text=api.getClipData()) + cues.clipboardPushed() + except TypeError: + log.exception("Unable to push clipboard") + # Translators: Message shown when clipboard content cannot be sent to the remote computer. + ui.message(_("Unable to push clipboard")) + + def copyLink(self): + """Copy connection URL to clipboard. + + :note: Requires an active session + """ + session = self.leaderSession or self.followerSession + if session is None: + # Translators: Message shown when trying to copy the link to connect to the remote computer while not connected. + ui.message(_("Not connected.")) + return + url = session.getConnectionInfo().getURLToConnect() + api.copyToClip(str(url)) + + def sendSAS(self): + """Send Secure Attention Sequence to remote computer. + + :note: Requires an active leader transport connection + """ + if self.leaderTransport is None: + log.error("No leader transport to send SAS") + return + self.leaderTransport.send(RemoteMessageType.SEND_SAS) + + def connect(self, connectionInfo: ConnectionInfo): + """Establish connection based on connection info. + + :param connectionInfo: Connection details including mode, host, port etc. + :note: Initiates either leader or follower connection based on mode + """ + log.info( + f"Initiating connection as {connectionInfo.mode} to {connectionInfo.hostname}:{connectionInfo.port}", + ) + if connectionInfo.mode == ConnectionMode.LEADER: + self.connectAsLeader(connectionInfo) + elif connectionInfo.mode == ConnectionMode.FOLLOWER: + self.connectAsFollower(connectionInfo) + + def disconnect(self): + """Close all active connections and clean up resources. + + :note: Closes local control server and both leader/follower sessions if active + """ + if self.leaderSession is None and self.followerSession is None: + log.debug("Disconnect called but no active sessions") + return + log.info("Disconnecting from remote session") + if self.localControlServer is not None: + self.localControlServer.close() + self.localControlServer = None + if self.leaderSession is not None: + self.disconnectAsLeader() + if self.followerSession is not None: + self.disconnectAsFollower() + cues.disconnected() + + def disconnectAsLeader(self): + """Close leader session and clean up related resources.""" + self.leaderSession.close() + self.leaderSession = None + self.leaderTransport = None + + def disconnectAsFollower(self): + """Close follower session and clean up related resources.""" + self.followerSession.close() + self.followerSession = None + self.followerTransport = None + self.sdHandler.followerSession = None + + @alwaysCallAfter + def onConnectAsLeaderFailed(self): + if self.leaderTransport.successfulConnects == 0: + log.error(f"Failed to connect to {self.leaderTransport.address}") + self.disconnectAsLeader() + # Translators: Title of the connection error dialog. + gui.messageBox( + parent=gui.mainFrame, + # Translators: Title of the connection error dialog. + caption=_("Error Connecting"), + # Translators: Message shown when cannot connect to the remote computer. + message=_("Unable to connect to the remote computer"), + style=wx.OK | wx.ICON_WARNING, + ) + + def doConnect(self, evt=None): + """Show connection dialog and handle connection initiation. + + :param evt: Optional wx event object + :note: Displays dialog with previous connections list + """ + if evt is not None: + evt.Skip() + previousConnections = configuration.getRemoteConfig()["connections"]["last_connected"] + hostnames = list(reversed(previousConnections)) + # Translators: Title of the connect dialog. + dlg = dialogs.DirectConnectDialog( + parent=gui.mainFrame, + id=wx.ID_ANY, + # Translators: Title of the connect dialog. + title=_("Connect"), + hostnames=hostnames, + ) + + def handleDialogCompletion(dlgResult): + if dlgResult != wx.ID_OK: + return + connectionInfo = dlg.getConnectionInfo() + if dlg.clientOrServer.GetSelection() == 1: # server + self.startControlServer(connectionInfo.port, connectionInfo.key) + self.connect(connectionInfo=connectionInfo) + + gui.runScriptModalDialog(dlg, callback=handleDialogCompletion) + + def connectAsLeader(self, connectionInfo: ConnectionInfo): + transport = RelayTransport.create( + connectionInfo=connectionInfo, + serializer=serializer.JSONSerializer(), + ) + self.leaderSession = LeaderSession( + transport=transport, + localMachine=self.localMachine, + ) + transport.transportCertificateAuthenticationFailed.register( + self.onLeaderCertificateFailed, + ) + transport.transportConnected.register(self.onConnectedAsLeader) + transport.transportConnectionFailed.register(self.onConnectAsLeaderFailed) + transport.transportClosing.register(self.onDisconnectingAsLeader) + transport.transportDisconnected.register(self.onDisconnectedAsLeader) + transport.reconnectorThread.start() + self.leaderTransport = transport + if self.menu: + self.menu.handleConnecting(connectionInfo.mode) + + @alwaysCallAfter + def onConnectedAsLeader(self): + log.info("Successfully connected as leader") + configuration.writeConnectionToConfig(self.leaderSession.getConnectionInfo()) + if self.menu: + self.menu.handleConnected(ConnectionMode.LEADER, True) + ui.message( + # Translators: Presented when connected to the remote computer. + _("Connected"), + ) + cues.connected() + + @alwaysCallAfter + def onDisconnectingAsLeader(self): + log.info("Leader session disconnecting") + if self.menu: + self.menu.handleConnected(ConnectionMode.LEADER, False) + if self.localMachine: + self.localMachine.isMuted = False + self.sendingKeys = False + self.keyModifiers = set() + + @alwaysCallAfter + def onDisconnectedAsLeader(self): + log.info("Leader session disconnected") + # Translators: Presented when connection to a remote computer was interupted. + ui.message(_("Disconnected")) + + def connectAsFollower(self, connectionInfo: ConnectionInfo): + transport = RelayTransport.create( + connectionInfo=connectionInfo, + serializer=serializer.JSONSerializer(), + ) + self.followerSession = FollowerSession( + transport=transport, + localMachine=self.localMachine, + ) + self.sdHandler.followerSession = self.followerSession + self.followerTransport = transport + transport.transportCertificateAuthenticationFailed.register( + self.onFollowerCertificateFailed, + ) + transport.transportConnected.register(self.onConnectedAsFollower) + transport.transportDisconnected.register(self.onDisconnectedAsFollower) + transport.reconnectorThread.start() + if self.menu: + self.menu.handleConnecting(connectionInfo.mode) + + @alwaysCallAfter + def onConnectedAsFollower(self): + log.info("Control connector connected") + cues.controlServerConnected() + if self.menu: + self.menu.handleConnected(ConnectionMode.FOLLOWER, True) + configuration.writeConnectionToConfig(self.followerSession.getConnectionInfo()) + + @alwaysCallAfter + def onDisconnectedAsFollower(self): + log.info("Control connector disconnected") + if self.menu: + self.menu.handleConnected(ConnectionMode.FOLLOWER, False) + + ### certificate handling + + def handleCertificateFailure(self, transport: RelayTransport): + log.warning(f"Certificate validation failed for {transport.address}") + self.lastFailAddress = transport.address + self.lastFailKey = transport.channel + self.disconnect() + try: + certHash = transport.lastFailFingerprint + + wnd = dialogs.CertificateUnauthorizedDialog(None, fingerprint=certHash) + a = wnd.ShowModal() + if a == wx.ID_YES: + config = configuration.getRemoteConfig() + config["trusted_certs"][hostPortToAddress(self.lastFailAddress)] = certHash + if a == wx.ID_YES or a == wx.ID_NO: + return True + except Exception as ex: + log.error(ex) + return False + + @alwaysCallAfter + def onLeaderCertificateFailed(self): + if self.handleCertificateFailure(self.leaderSession.transport): + connectionInfo = ConnectionInfo( + mode=ConnectionMode.LEADER, + hostname=self.lastFailAddress[0], + port=self.lastFailAddress[1], + key=self.lastFailKey, + insecure=True, + ) + self.connectAsLeader(connectionInfo=connectionInfo) + + @alwaysCallAfter + def onFollowerCertificateFailed(self): + if self.handleCertificateFailure(self.followerSession.transport): + connectionInfo = ConnectionInfo( + mode=ConnectionMode.FOLLOWER, + hostname=self.lastFailAddress[0], + port=self.lastFailAddress[1], + key=self.lastFailKey, + insecure=True, + ) + self.connectAsFollower(connectionInfo=connectionInfo) + + def startControlServer(self, serverPort, channel): + """Start local relay server for handling connections. + + :param serverPort: Port number to listen on + :param channel: Channel key for authentication + :note: Creates daemon thread to run server + """ + self.localControlServer = server.LocalRelayServer(serverPort, channel) + serverThread = threading.Thread(target=self.localControlServer.run) + serverThread.daemon = True + serverThread.start() + + def processKeyInput( + self, + vkCode: int | None = None, + scanCode: int | None = None, + extended: bool | None = None, + pressed: bool | None = None, + ) -> bool: + """Process keyboard input and forward to remote if sending keys. + + :param vkCode: Virtual key code + :param scanCode: Scan code + :param extended: Whether this is an extended key + :param pressed: True if key pressed, False if released + :return: ``True`` to allow local processing, ``False`` to block + """ + if not self.sendingKeys: + return True + keyCode = (vkCode, extended) + gesture = KeyboardInputGesture( + self.keyModifiers, + keyCode[0], + scanCode, + keyCode[1], + ) + if not pressed and keyCode in self.hostPendingModifiers: + self.hostPendingModifiers.discard(keyCode) + return True + gesture = KeyboardInputGesture( + self.keyModifiers, + keyCode[0], + scanCode, + keyCode[1], + ) + if gesture.isModifier: + if pressed: + self.keyModifiers.add(keyCode) + else: + self.keyModifiers.discard(keyCode) + elif pressed: + script = gesture.script + if script in self.localScripts: + wx.CallAfter(script, gesture) + return False + self.leaderTransport.send( + RemoteMessageType.KEY, + vk_code=vkCode, + extended=extended, + pressed=pressed, + scan_code=scanCode, + ) + return False # Don't pass it on + + def toggleRemoteKeyControl(self, gesture: KeyboardInputGesture): + """Toggle sending keyboard input to remote machine. + + :param gesture: The keyboard gesture that triggered this + :note: Also toggles braille input and mute state + """ + if not self.leaderTransport: + gesture.send() + return + self.sendingKeys = not self.sendingKeys + log.info(f"Remote key control {'enabled' if self.sendingKeys else 'disabled'}") + self.setReceivingBraille(self.sendingKeys) + if self.sendingKeys: + self.hostPendingModifiers = gesture.modifiers + # Translators: Presented when sending keyboard keys from the controlling computer to the controlled computer. + ui.message(_("Controlling remote machine.")) + if self.localMachine.isMuted: + self.toggleMute() + else: + self.releaseKeys() + # Translators: Presented when keyboard control is back to the controlling computer. + ui.message(_("Controlling local machine.")) + + def releaseKeys(self): + """Release all pressed keys on the remote machine. + + :note: Sends key-up events for all held modifiers + """ + # release all pressed keys in the guest. + for k in self.keyModifiers: + self.leaderTransport.send( + RemoteMessageType.KEY, + vk_code=k[0], + extended=k[1], + pressed=False, + ) + self.keyModifiers = set() + + def setReceivingBraille(self, state): + """Enable or disable receiving braille from remote. + + :param state: True to enable remote braille, False to disable + :note: Only enables if leader session and braille handler are ready + """ + if state and self.leaderSession.callbacksAdded and braille.handler.enabled: + self.leaderSession.registerBrailleInput() + self.localMachine.receivingBraille = True + elif not state: + self.leaderSession.unregisterBrailleInput() + self.localMachine.receivingBraille = False + + @alwaysCallAfter + def verifyAndConnect(self, conInfo: ConnectionInfo): + """Verify connection details and establish connection if approved by user. + + :param conInfo: Connection information to verify and use + :note: Shows confirmation dialog before connecting + :raises: Displays error if already connected + """ + if self.isConnected() or self.connecting: + gui.messageBox( + # Translators: Message shown when trying to connect while already connected. + _("NVDA Remote is already connected. Disconnect before opening a new connection."), + # Translators: Title of the connection error dialog. + _("NVDA Remote Already Connected"), + wx.OK | wx.ICON_WARNING, + ) + return + + self.connecting = True + try: + serverAddr = conInfo.getAddress() + key = conInfo.key + + # Prepare connection request message based on mode + if conInfo.mode == ConnectionMode.LEADER: + # Translators: Ask the user if they want to control the remote computer. + question = _("Do you wish to control the machine on server {server} with key {key}?") + else: + question = _( + # Translators: Ask the user if they want to allow the remote computer to control this computer. + "Do you wish to allow this machine to be controlled on server {server} with key {key}?", + ) + + question = question.format(server=serverAddr, key=key) + + # Translators: Title of the connection request dialog. + dialogTitle = _("NVDA Remote Connection Request") + + # Show confirmation dialog + if ( + gui.messageBox( + question, + dialogTitle, + wx.YES | wx.NO | wx.NO_DEFAULT | wx.ICON_WARNING, + ) + == wx.YES + ): + self.connect(conInfo) + finally: + self.connecting = False + + def isConnected(self) -> bool: + """Check if there is an active connection. + + :return: True if either follower or leader transport is connected + """ + connector = self.followerTransport or self.leaderTransport + if connector is not None: + return connector.connected + return False + + def registerLocalScript(self, script: scriptHandler._ScriptFunctionT): + """Add a script to be handled locally instead of sent to remote. + + :param script: Script function to register + """ + self.localScripts.add(script) + + def unregisterLocalScript(self, script: scriptHandler._ScriptFunctionT): + """Remove a script from local handling. + + :param script: Script function to unregister + """ + self.localScripts.discard(script) diff --git a/source/remoteClient/configuration.py b/source/remoteClient/configuration.py new file mode 100644 index 00000000000..a03f27b63c9 --- /dev/null +++ b/source/remoteClient/configuration.py @@ -0,0 +1,28 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + + +import config + +from .connectionInfo import ConnectionInfo + + +def getRemoteConfig(): + return config.conf["remote"] + + +def writeConnectionToConfig(connectionInfo: ConnectionInfo): + """ + Writes a connection to the last connected section of the config. + If the connection is already in the config, move it to the end. + + :param connectionInfo: The :class:`ConnectionInfo` object containing connection details + """ + conf = getRemoteConfig() + lastConnections = conf["connections"]["last_connected"] + address = connectionInfo.getAddress() + if address in lastConnections: + conf["connections"]["last_connected"].remove(address) + conf["connections"]["last_connected"].append(address) diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py new file mode 100644 index 00000000000..fffd537ac42 --- /dev/null +++ b/source/remoteClient/connectionInfo.py @@ -0,0 +1,154 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from dataclasses import dataclass +from enum import StrEnum +from typing import Self +from urllib.parse import ParseResult, parse_qs, urlencode, urlparse + +from . import protocol +from .protocol import SERVER_PORT, URL_PREFIX + + +class URLParsingError(Exception): + """Exception raised when URL parsing fails. + + Raised when the URL cannot be parsed due to missing or invalid components + such as hostname, key, or mode. + """ + + +class ConnectionMode(StrEnum): + """Defines the connection mode for remote connections. + + :cvar LEADER: Controller mode for controlling the remote system + :cvar FOLLOWER: Controlled mode for being controlled by remote system + """ + + LEADER = "master" + FOLLOWER = "slave" + + +class ConnectionState(StrEnum): + """Defines possible states of a remote connection. + + :cvar CONNECTED: Connection is established and active + :cvar DISCONNECTED: No active connection exists + :cvar CONNECTING: Connection attempt is currently in progress + :cvar DISCONNECTING: Connection termination is in progress + """ + + CONNECTED = "connected" + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + DISCONNECTING = "disconnecting" + + +@dataclass +class ConnectionInfo: + """Stores and manages remote connection information. + + Handles connection details including hostname, mode, authentication key, + port number and security settings. Provides methods for URL generation and parsing. + + :raises URLParsingError: When URL components are missing or invalid + """ + + hostname: str + """Remote host address to connect to""" + + mode: ConnectionMode + """Connection mode (leader/follower)""" + + key: str + """Authentication key for securing the connection""" + + port: int = SERVER_PORT + """Port number to use for connection, defaults to :const:`SERVER_PORT`""" + + insecure: bool = False + """Allow insecure connections without SSL/TLS, defaults to False""" + + def __post_init__(self) -> None: + self.port = self.port or SERVER_PORT + self.mode = ConnectionMode(self.mode) + + @classmethod + def fromURL(cls, url: str) -> Self: + """Creates a ConnectionInfo instance from a URL string. + + :param url: The URL to parse in nvdaremote:// format + :raises URLParsingError: If URL cannot be parsed or contains invalid data + :return: A new ConnectionInfo instance configured from the URL + """ + parsedUrl = urlparse(url) + parsedQuery = parse_qs(parsedUrl.query) + hostname = parsedUrl.hostname + port = parsedUrl.port + key = parsedQuery.get("key", [""])[0] + mode = parsedQuery.get("mode", [""])[0].lower() + insecure = parsedQuery.get("insecure", ["false"])[0].lower() == "true" + if not hostname: + raise URLParsingError("No hostname provided") + if not key: + raise URLParsingError("No key provided") + if not mode: + raise URLParsingError("No mode provided") + try: + ConnectionMode(mode) + except ValueError: + raise URLParsingError("Invalid mode provided: %r" % mode) + return cls(hostname=hostname, mode=mode, key=key, port=port, insecure=insecure) + + def getAddress(self) -> str: + """Gets the formatted address string. + + :return: Address string in format hostname:port, with IPv6 brackets if needed + """ + # Handle IPv6 addresses by adding brackets if needed + hostname = f"[{self.hostname}]" if ":" in self.hostname else self.hostname + return f"{hostname}:{self.port}" + + def _buildURL(self, mode: ConnectionMode) -> str: + """Builds a URL string for the given mode. + + :param mode: The connection mode to use in the URL + :return: Complete URL string + """ + # Build URL components + netloc = protocol.hostPortToAddress((self.hostname, self.port)) + params = { + "key": self.key, + "mode": mode, + } + if self.insecure: + params["insecure"] = "true" + query = urlencode(params) + + # Create our own ParseResult then get it to build the URL for us to make sure it's done properly + return ParseResult( + scheme=URL_PREFIX.removesuffix("://"), + netloc=netloc, + path="", + params="", + query=query, + fragment="", + ).geturl() + + def getURLToConnect(self) -> str: + """Gets a URL for connecting with reversed mode. + + :return: URL string with opposite connection mode + """ + # Flip leader/follower for connection URL + connectMode = ConnectionMode.FOLLOWER if self.mode == ConnectionMode.LEADER else ConnectionMode.LEADER + return self._buildURL(connectMode) + + def getURL(self) -> str: + """Gets the URL representation of the current connection info. + + :return: URL string with current connection mode + """ + return self._buildURL(self.mode) diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py new file mode 100644 index 00000000000..6adfae46ecb --- /dev/null +++ b/source/remoteClient/cues.py @@ -0,0 +1,101 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import os +from typing import Dict, Optional, TypedDict + +import globalVars +import nvwave +import ui +from tones import BeepSequence, beepSequenceAsync + +from . import configuration + + +class Cue(TypedDict, total=False): + wave: Optional[str] + beeps: Optional[BeepSequence] + message: Optional[str] + + +# Declarative dictionary of all possible cues +CUES: Dict[str, Cue] = { + "connected": {"wave": "connected", "beeps": [(440, 60), (660, 60)]}, + "disconnected": { + "wave": "disconnected", + "beeps": [(660, 60), (440, 60)], + # Translators: Message shown when the connection to the remote computer is lost. + "message": _("Disconnected"), + }, + "controlServerConnected": { + "wave": "controlled", + "beeps": [(720, 100), (None, 50), (720, 100), (None, 50), (720, 100)], + # Translators: Presented in direct (client to server) remote connection when the controlled computer is ready. + "message": _("Connected to control server"), + }, + "clientConnected": {"wave": "controlling", "beeps": [(1000, 300)]}, + "clientDisconnected": {"wave": "disconnected", "beeps": [(108, 300)]}, + "clipboardPushed": { + "wave": "clipboardPush", + "beeps": [(500, 100), (600, 100)], + # Translators: Message shown when the clipboard is successfully pushed to the remote computer. + "message": _("Clipboard pushed"), + }, + "clipboardReceived": { + "wave": "clipboardReceive", + "beeps": [(600, 100), (500, 100)], + # Translators: Message shown when the clipboard is successfully received from the remote computer. + "message": _("Clipboard received"), + }, +} + + +def _playCue(cueName: str) -> None: + """Helper function to play a cue by name""" + if not shouldPlaySounds(): + # Play beep sequence + if beeps := CUES[cueName].get("beeps"): + filteredBeeps = [(freq, dur) for freq, dur in beeps if freq is not None] + beepSequenceAsync(*filteredBeeps) + + # Play wave file + if wave := CUES[cueName].get("wave"): + nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", wave + ".wav")) + + # Show message if specified + if message := CUES[cueName].get("message"): + ui.message(message) + + +def connected(): + _playCue("connected") + + +def disconnected(): + _playCue("disconnected") + + +def controlServerConnected(): + _playCue("controlServerConnected") + + +def clientConnected(): + _playCue("clientConnected") + + +def clientDisconnected(): + _playCue("clientDisconnected") + + +def clipboardPushed(): + _playCue("clipboardPushed") + + +def clipboardReceived(): + _playCue("clipboardReceived") + + +def shouldPlaySounds() -> bool: + return configuration.getRemoteConfig()["ui"]["play_sounds"] diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py new file mode 100644 index 00000000000..7dd9fd78c47 --- /dev/null +++ b/source/remoteClient/dialogs.py @@ -0,0 +1,361 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import json +import random +import threading +from typing import List, Optional, TypedDict, Union +from urllib import request + +import gui +import wx +from logHandler import log +from gui.guiHelper import alwaysCallAfter + +from . import configuration, serializer, server, protocol, transport +from .connectionInfo import ConnectionInfo, ConnectionMode +from .protocol import SERVER_PORT, RemoteMessageType + + +class ClientPanel(wx.Panel): + host: wx.ComboBox + key: wx.TextCtrl + generateKey: wx.Button + keyConnector: Optional["transport.RelayTransport"] + + def __init__(self, parent: Optional[wx.Window] = None, id: int = wx.ID_ANY): + super().__init__(parent, id) + sizer = wx.BoxSizer(wx.HORIZONTAL) + # Translators: The label of an edit field in connect dialog to enter name or address of the remote computer. + sizer.Add(wx.StaticText(self, wx.ID_ANY, label=_("&Host:"))) + self.host = wx.ComboBox(self, wx.ID_ANY) + sizer.Add(self.host) + # Translators: Label of the edit field to enter key (password) to secure the remote connection. + sizer.Add(wx.StaticText(self, wx.ID_ANY, label=_("&Key:"))) + self.key = wx.TextCtrl(self, wx.ID_ANY) + sizer.Add(self.key) + # Translators: The button used to generate a random key/password. + self.generateKey = wx.Button(parent=self, label=_("&Generate Key")) + self.generateKey.Bind(wx.EVT_BUTTON, self.onGenerateKey) + sizer.Add(self.generateKey) + self.SetSizerAndFit(sizer) + + def onGenerateKey(self, evt: wx.CommandEvent) -> None: + if not self.host.GetValue(): + gui.messageBox( + # Translators: A message box displayed when the host field is empty and the user tries to generate a key. + _("Host must be set."), + # Translators: A title of a message box displayed when the host field is empty and the user tries to generate a key. + _("Error"), + wx.OK | wx.ICON_ERROR, + ) + self.host.SetFocus() + else: + evt.Skip() + self.generateKeyCommand() + + def generateKeyCommand(self, insecure: bool = False) -> None: + address = protocol.addressToHostPort(self.host.GetValue()) + self.keyConnector = transport.RelayTransport( + address=address, + serializer=serializer.JSONSerializer(), + insecure=insecure, + ) + self.keyConnector.registerInbound(RemoteMessageType.GENERATE_KEY, self.handleKeyGenerated) + self.keyConnector.transportCertificateAuthenticationFailed.register(self.handleCertificateFailed) + t = threading.Thread(target=self.keyConnector.run) + t.start() + + @alwaysCallAfter + def handleKeyGenerated(self, key: Optional[str] = None) -> None: + self.key.SetValue(key) + self.key.SetFocus() + self.keyConnector.close() + self.keyConnector = None + + @alwaysCallAfter + def handleCertificateFailed(self) -> None: + """ + Handles the event when a certificate validation fails. + + This method attempts to retrieve the last failed certificate fingerprint + and displays a dialog to the user to decide whether to trust the certificate. + If the user chooses to trust the certificate, it is added to the trusted certificates + configuration. If an exception occurs during this process, it is logged. + + Steps: + 1. Retrieve the last failed certificate fingerprint. + 2. Display a dialog to the user with the certificate fingerprint. + 3. If the user chooses to trust the certificate, add it to the trusted certificates configuration. + 4. Handle any exceptions by logging the error. + 5. Close the key connector and reset it. + 6. Generate a new key from the server. + """ + try: + certHash = self.keyConnector.lastFailFingerprint + + wnd = CertificateUnauthorizedDialog(None, fingerprint=certHash) + a = wnd.ShowModal() + if a == wx.ID_YES: + config = configuration.getRemoteConfig() + config["trusted_certs"][self.host.GetValue()] = certHash + if a != wx.ID_YES and a != wx.ID_NO: + return + except Exception: + log.exception("Error handling certificate failure") + return + finally: + self.keyConnector.close() + self.keyConnector = None + self.generateKeyCommand(True) + + +class PortCheckResponse(TypedDict): + host: str + port: int + open: bool + + +class ServerPanel(wx.Panel): + getIP: wx.Button + externalIP: wx.TextCtrl + port: wx.TextCtrl + key: wx.TextCtrl + generateKey: wx.Button + + def __init__(self, parent: Optional[wx.Window] = None, id: int = wx.ID_ANY): + super().__init__(parent, id) + sizer = wx.BoxSizer(wx.HORIZONTAL) + # Translators: Used in server mode to obtain the external IP address for the server (controlled computer) for direct connection. + self.getIP = wx.Button(parent=self, label=_("Get External &IP")) + self.getIP.Bind(wx.EVT_BUTTON, self.onGetIP) + sizer.Add(self.getIP) + # Translators: Label of the field displaying the external IP address if using direct (client to server) connection. + sizer.Add(wx.StaticText(self, wx.ID_ANY, label=_("&External IP:"))) + self.externalIP = wx.TextCtrl(self, wx.ID_ANY, style=wx.TE_READONLY | wx.TE_MULTILINE) + sizer.Add(self.externalIP) + # Translators: The label of an edit field in connect dialog to enter the port the server will listen on. + sizer.Add(wx.StaticText(self, wx.ID_ANY, label=_("&Port:"))) + self.port = wx.TextCtrl(self, wx.ID_ANY, value=str(SERVER_PORT)) + sizer.Add(self.port) + sizer.Add(wx.StaticText(self, wx.ID_ANY, label=_("&Key:"))) + self.key = wx.TextCtrl(self, wx.ID_ANY) + sizer.Add(self.key) + self.generateKey = wx.Button(parent=self, label=_("&Generate Key")) + self.generateKey.Bind(wx.EVT_BUTTON, self.onGenerateKey) + sizer.Add(self.generateKey) + self.SetSizerAndFit(sizer) + + def onGenerateKey(self, evt: wx.CommandEvent) -> None: + evt.Skip() + res = str(random.randrange(1, 9)) + for n in range(6): + res += str(random.randrange(0, 9)) + self.key.SetValue(res) + self.key.SetFocus() + + def onGetIP(self, evt: wx.CommandEvent) -> None: + evt.Skip() + self.getIP.Enable(False) + t = threading.Thread(target=self.doPortcheck, args=[int(self.port.GetValue())]) + t.daemon = True + t.start() + + def doPortcheck(self, port: int) -> None: + tempServer = server.LocalRelayServer(port=port, password=None) + try: + req = request.urlopen("https://portcheck.nvdaremote.com/port/%s" % port) + data = req.read() + result = json.loads(data) + wx.CallAfter(self.onGetIPSucceeded, result) + except Exception as e: + wx.CallAfter(self.onGetIPFail, e) + raise + finally: + tempServer.close() + wx.CallAfter(self.getIP.Enable, True) + + def onGetIPSucceeded(self, data: PortCheckResponse) -> None: + ip = data["host"] + port = data["port"] + isOpen = data["open"] + + if isOpen: + # Translators: Message shown when successfully getting external IP and the specified port is open + successMsg = _("Successfully retrieved IP address. Port {port} is open.") + # Translators: Title of success dialog + successTitle = _("Success") + wx.MessageBox( + message=successMsg.format(port=port), + caption=successTitle, + style=wx.OK, + ) + else: + # Translators: Message shown when IP was retrieved but the specified port is not forwarded + # {port} will be replaced with the actual port number + warningMsg = _("Retrieved external IP, but port {port} is most likely not currently forwarded.") + # Translators: Title of warning dialog + warningTitle = _("Warning") + wx.MessageBox( + message=warningMsg.format(port=port), + caption=warningTitle, + style=wx.ICON_WARNING | wx.OK, + ) + + self.externalIP.SetValue(ip) + self.externalIP.SelectAll() + self.externalIP.SetFocus() + + def onGetIPFail(self, exc: Exception) -> None: + # Translators: Error message when unable to get IP address from portcheck server + errorMsg = _("Unable to contact portcheck server, please manually retrieve your IP address") + # Translators: Title of error dialog + errorTitle = _("Error") + wx.MessageBox( + message=errorMsg, + caption=errorTitle, + style=wx.ICON_ERROR | wx.OK, + ) + + +class DirectConnectDialog(wx.Dialog): + clientOrServer: wx.RadioBox + connectionType: wx.RadioBox + container: wx.Panel + panel: Union[ClientPanel, ServerPanel] + mainSizer: wx.BoxSizer + + def __init__(self, parent: wx.Window, id: int, title: str, hostnames: Optional[List[str]] = None): + super().__init__(parent, id, title=title) + mainSizer = self.mainSizer = wx.BoxSizer(wx.VERTICAL) + self.clientOrServer = wx.RadioBox( + self, + wx.ID_ANY, + choices=( + # Translators: A choice to connect to another machine. + _("Client"), + # Translators: A choice to allow another machine to connect to this machine. + _("Server"), + ), + style=wx.RA_VERTICAL, + ) + self.clientOrServer.Bind(wx.EVT_RADIOBOX, self.onClientOrServer) + self.clientOrServer.SetSelection(0) + mainSizer.Add(self.clientOrServer) + choices = [ + # Translators: A choice to control another machine. + _("Control another machine"), + # Translators: A choice to allow this machine to be controlled. + _("Allow this machine to be controlled"), + ] + self.connectionType = wx.RadioBox(self, wx.ID_ANY, choices=choices, style=wx.RA_VERTICAL) + self.connectionType.SetSelection(0) + mainSizer.Add(self.connectionType) + self.container = wx.Panel(parent=self) + self.panel = ClientPanel(parent=self.container) + mainSizer.Add(self.container) + buttons = self.CreateButtonSizer(wx.OK | wx.CANCEL) + mainSizer.Add(buttons, flag=wx.BOTTOM) + mainSizer.Fit(self) + self.SetSizer(mainSizer) + self.Center(wx.BOTH | wx.CENTER) + ok = wx.FindWindowById(wx.ID_OK, self) + ok.Bind(wx.EVT_BUTTON, self.onOk) + self.clientOrServer.SetFocus() + if hostnames: + self.panel.host.AppendItems(hostnames) + self.panel.host.SetSelection(0) + + def onClientOrServer(self, evt: wx.CommandEvent) -> None: + evt.Skip() + self.panel.Destroy() + if self.clientOrServer.GetSelection() == 0: + self.panel = ClientPanel(parent=self.container) + else: + self.panel = ServerPanel(parent=self.container) + self.mainSizer.Fit(self) + + def onOk(self, evt: wx.CommandEvent) -> None: + if self.clientOrServer.GetSelection() == 0 and ( + not self.panel.host.GetValue() or not self.panel.key.GetValue() + ): + gui.messageBox( + # Translators: A message box displayed when the host or key field is empty and the user tries to connect. + _("Both host and key must be set."), + # Translators: A title of a message box displayed when the host or key field is empty and the user tries to connect. + _("Error"), + wx.OK | wx.ICON_ERROR, + ) + self.panel.host.SetFocus() + elif self.clientOrServer.GetSelection() == 1 and ( + not self.panel.port.GetValue() or not self.panel.key.GetValue() + ): + gui.messageBox( + # Translators: A message box displayed when the port or key field is empty and the user tries to connect. + _("Both port and key must be set."), + # Translators: A title of a message box displayed when the port or key field is empty and the user tries to connect. + _("Error"), + wx.OK | wx.ICON_ERROR, + ) + self.panel.port.SetFocus() + else: + evt.Skip() + + def getKey(self) -> str: + return self.panel.key.GetValue() + + def getConnectionInfo(self) -> ConnectionInfo: + if self.clientOrServer.GetSelection() == 0: # client + host = self.panel.host.GetValue() + serverAddr, port = protocol.addressToHostPort(host) + mode = ( + ConnectionMode.LEADER if self.connectionType.GetSelection() == 0 else ConnectionMode.FOLLOWER + ) + return ConnectionInfo( + hostname=serverAddr, + mode=mode, + key=self.getKey(), + port=port, + insecure=False, + ) + else: # server + port = int(self.panel.port.GetValue()) + mode = ( + ConnectionMode.LEADER if self.connectionType.GetSelection() == 0 else ConnectionMode.FOLLOWER + ) + return ConnectionInfo( + hostname="127.0.0.1", + mode=mode, + key=self.getKey(), + port=port, + insecure=True, + ) + + +class CertificateUnauthorizedDialog(wx.MessageDialog): + def __init__(self, parent: Optional[wx.Window], fingerprint: Optional[str] = None): + # Translators: A title bar of a window presented when an attempt has been made to connect with a server with unauthorized certificate. + title = _("NVDA Remote Connection Security Warning") + message = _( + # Translators: {fingerprint} is a SHA256 fingerprint of the server certificate. + "The certificate of this server could not be verified. Using the wrong fingerprint may allow a third party to access the remote session..\n" + "\n" + "Before continuing, please make sure that the following server certificate fingerprint is correct.\n" + "Server SHA256 fingerprint: {fingerprint}\n" + "\n" + "Continue connecting anyway?", + ).format(fingerprint=fingerprint) + super().__init__( + parent, + caption=title, + message=message, + style=wx.YES_NO | wx.CANCEL | wx.CANCEL_DEFAULT | wx.CENTRE, + ) + self.SetYesNoLabels( + # Translators: A button to connect and remember the server with unauthorized certificate. + _("Connect and do not ask again for this server"), + # Translators: A button to connect and ask again for the server with unauthorized certificate. + _("Connect"), + ) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py new file mode 100644 index 00000000000..e3caca524cb --- /dev/null +++ b/source/remoteClient/input.py @@ -0,0 +1,230 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import ctypes +from ctypes import POINTER, Structure, Union, c_long, c_ulong, wintypes +from enum import IntEnum, IntFlag + +import api +import baseObject +import braille +import brailleInput +import globalPluginHandler +import scriptHandler +import vision + + +class InputType(IntEnum): + """Values permissible as the `type` field in an `INPUT` struct. + + .. seealso:: + https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-input + """ + + MOUSE = 0 + """The event is a mouse event. Use the mi structure of the union.""" + + KEYBOARD = 1 + """The event is a keyboard event. Use the ki structure of the union.""" + + HARDWARE = 2 + """The event is a hardware event. Use the hi structure of the union.""" + + +class VKMapType(IntEnum): + """Type of mapping to be performed between virtual key code and virtual scan code. + + .. seealso:: + https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-mapvirtualkeyw + """ + + VK_TO_VSC = 0 + """Maps a virtual key code to a scan code.""" + + +class KeyEventFlag(IntFlag): + """Specifies various aspects of a keystroke in a KEYBDINPUT struct. + + .. seealso:: + https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-keybdinput + """ + + EXTENDED_KEY = 0x0001 + """If specified, the wScan scan code consists of a sequence of two bytes, where the first byte has a value of 0xE0.""" + + KEY_UP = 0x0002 + """If specified, the key is being released. If not specified, the key is being pressed. """ + + SCAN_CODE = 0x0008 + """If specified, wScan identifies the key and wVk is ignored. """ + + UNICODE = 0x0004 + """If specified, the system synthesizes a VK_PACKET keystroke. + + .. warning:: + Must only be combined with :const:`KEY_UP`. + """ + + +class MOUSEINPUT(Structure): + _fields_ = ( + ("dx", c_long), + ("dy", c_long), + ("mouseData", wintypes.DWORD), + ("dwFlags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", POINTER(c_ulong)), + ) + + +class KEYBDINPUT(Structure): + _fields_ = ( + ("wVk", wintypes.WORD), + ("wScan", wintypes.WORD), + ("dwFlags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", POINTER(c_ulong)), + ) + + +class HARDWAREINPUT(Structure): + _fields_ = ( + ("uMsg", wintypes.DWORD), + ("wParamL", wintypes.WORD), + ("wParamH", wintypes.WORD), + ) + + +class INPUTUnion(Union): + _fields_ = ( + ("mi", MOUSEINPUT), + ("ki", KEYBDINPUT), + ("hi", HARDWAREINPUT), + ) + + +class INPUT(Structure): + """Stores information for synthesizing input events. + + .. seealso:: + https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-input + """ + + _fields_ = ( + ("type", wintypes.DWORD), + ("union", INPUTUnion), + ) + + +class BrailleInputGesture(braille.BrailleDisplayGesture, brailleInput.BrailleInputGesture): + def __init__(self, **kwargs): + super().__init__() + for key, value in kwargs.items(): + setattr(self, key, value) + self.source = f"remote{self.source.capitalize()}" + self.scriptPath = getattr(self, "scriptPath", None) + self.script = self.findScript() if self.scriptPath else None + + def findScript(self) -> scriptHandler._ScriptFunctionT | None: + """Find and return a script function based on the script path. + + The script path must be a list containing three elements: + module name, class name, and script name. Searches through multiple levels + for the script. + + Search order: + * Global plugins + * App modules + * Vision enhancement providers + * Tree interceptors + * NVDA objects + * Global commands + + Returns: + Callable: The script function if found + None: If no matching script is found + + Note: + If scriptName starts with "kb:", returns a keyboard emulation script + """ + if not (isinstance(self.scriptPath, list) and len(self.scriptPath) == 3): + return None + module, cls, scriptName = self.scriptPath + focus = api.getFocusObject() + if not focus: + return None + if scriptName.startswith("kb:"): + # Emulate a key press. + return scriptHandler._makeKbEmulateScript(scriptName) + + import globalCommands + + # Global plugin level. + if cls == "GlobalPlugin": + for plugin in globalPluginHandler.runningPlugins: + if module == plugin.__module__: + func = getattr(plugin, f"script_{scriptName}", None) + if func: + return func + + # App module level. + app = focus.appModule + if app and cls == "AppModule" and module == app.__module__: + func = getattr(app, f"script_{scriptName}", None) + if func: + return func + + # Vision enhancement provider level + for provider in vision.handler.getActiveProviderInstances(): + if isinstance(provider, baseObject.ScriptableObject): + if cls == "VisionEnhancementProvider" and module == provider.__module__: + func = getattr(app, "script_{scriptName}", None) + if func: + return func + + # Tree interceptor level. + treeInterceptor = focus.treeInterceptor + if treeInterceptor and treeInterceptor.isReady: + func = getattr(treeInterceptor, f"script_{scriptName}", None) + if func: + return func + + # NVDAObject level. + func = getattr(focus, f"script_{scriptName}", None) + if func: + return func + for obj in reversed(api.getFocusAncestors()): + func = getattr(obj, "script_%s" % scriptName, None) + if func and getattr(func, "canPropagate", False): + return func + + # Global commands. + func = getattr(globalCommands.commands, f"script_{scriptName}", None) + if func: + return func + + return None + + +def sendKey(vk: int | None = None, scan: int | None = None, extended: bool = False, pressed: bool = True): + """Execute remote keyboard input locally. + + :param vk: Virtual key code, defaults to None + :param scan: Scan code, defaults to None + :param extended: Whether this is an extended key, defaults to False + :param pressed: ``True`` if key pressed; ``False`` if released, defaults to True + """ + i = INPUT() + i.union.ki.wVk = vk + if scan: + i.union.ki.wScan = scan + else: # No scancode provided, try to get one + i.union.ki.wScan = ctypes.windll.user32.MapVirtualKeyW(vk, VKMapType.VK_TO_VSC) + if not pressed: + i.union.ki.dwFlags |= KeyEventFlag.KEY_UP + if extended: + i.union.ki.dwFlags |= KeyEventFlag.EXTENDED_KEY + i.type = InputType.KEYBOARD + ctypes.windll.user32.SendInput(1, ctypes.byref(i), ctypes.sizeof(INPUT)) diff --git a/source/remoteClient/localMachine.py b/source/remoteClient/localMachine.py new file mode 100644 index 00000000000..dd8595c0e40 --- /dev/null +++ b/source/remoteClient/localMachine.py @@ -0,0 +1,261 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Local machine interface for NVDA Remote. + +This module provides functionality for controlling the local NVDA instance +in response to commands received from remote connections. + +The main class :class:`LocalMachine` implements all local control operations +that can be triggered by remote NVDA instances. It includes safety features like +muting and uses wxPython's CallAfter for thread synchronization. + +.. note:: + + This module is part of the NVDA Remote protocol implementation and should + not be used directly outside of the remote connection infrastructure. +""" + +import ctypes +import logging +import os +from typing import Any, Dict, List, Optional + +import api +import braille +import inputCore +import nvwave +import speech +import tones +import wx +from speech.priorities import Spri +from speech.types import SpeechSequence +from systemUtils import hasUiAccess +import ui + +from . import cues, input + +logger = logging.getLogger("local_machine") + + +def setSpeechCancelledToFalse() -> None: + """Reset the speech cancellation flag to allow new speech. + + :note: Updates NVDA's internal speech state to ensure future speech will not be cancelled. + Required when receiving remote speech commands. + :warning: This is a temporary workaround that modifies internal NVDA state. + May break in future NVDA versions if the speech subsystem changes. + :seealso: :meth:`LocalMachine.speak` + """ + # workaround as beenCanceled is readonly as of NVDA#12395 + speech.speech._speechState.beenCanceled = False + + +class LocalMachine: + """Controls the local NVDA instance based on remote commands. + + This class implements the local side of remote control functionality, + serving as the bridge between network commands and local NVDA operations. + It ensures thread-safe execution and proper state management. + + :note: This class is instantiated by the remote session manager and should not + be created directly. All methods are called in response to remote messages. + + :seealso: + - :class:`session.FollowerSession` - Manages remote connections + - :mod:`transport` - Network transport layer + """ + + def __init__(self) -> None: + """Initialize the local machine controller. + + :note: The local machine starts unmuted with local braille enabled. + """ + self.isMuted: bool = False + """When True, most remote commands will be ignored""" + + self.receivingBraille: bool = False + """When True, braille output comes from remote""" + + self._cachedSizes: Optional[List[int]] = None + """Cached braille display sizes from remote machines""" + + braille.decide_enabled.register(self.handleDecideEnabled) + + def terminate(self) -> None: + """Clean up resources when the local machine controller is terminated. + + :note: Unregisters the braille display handler to prevent memory leaks and + ensure proper cleanup when the remote connection ends. + """ + braille.decide_enabled.unregister(self.handleDecideEnabled) + + def playWave(self, fileName: str) -> None: + """Play a wave file on the local machine. + + :param fileName: Path to the wave file to play + :note: Sound playback is ignored if the local machine is muted. + The file must exist on the local system. + """ + if self.isMuted: + return + if os.path.exists(fileName): + nvwave.playWaveFile(fileName=fileName, asynchronous=True) + + def beep(self, hz: float, length: int, left: int = 50, right: int = 50) -> None: + """Play a beep sound on the local machine. + + :param hz: Frequency of the beep in Hertz + :param length: Duration of the beep in milliseconds + :param left: Left channel volume (0-100) + :param right: Right channel volume (0-100) + :note: Beeps are ignored if the local machine is muted. + """ + if self.isMuted: + return + tones.beep(hz, length, left, right) + + def cancelSpeech(self) -> None: + """Cancel any ongoing speech on the local machine. + + :note: Speech cancellation is ignored if the local machine is muted. + Uses wx.CallAfter to ensure thread-safe execution. + """ + if self.isMuted: + return + wx.CallAfter(speech._manager.cancel) + + def pauseSpeech(self, switch: bool) -> None: + """Pause or resume speech on the local machine. + + :param switch: True to pause speech, False to resume + :note: Speech control is ignored if the local machine is muted. + Uses wx.CallAfter to ensure thread-safe execution. + """ + if self.isMuted: + return + wx.CallAfter(speech.pauseSpeech, switch) + + def speak( + self, + sequence: SpeechSequence, + priority: Spri = Spri.NORMAL, + ) -> None: + """Process a speech sequence from a remote machine. + + Safely queues speech from remote NVDA instances into the local speech + subsystem, handling priority and ensuring proper cancellation state. + + :param sequence: List of speech sequences (text and commands) to speak + :param priority: Speech priority level + :note: Speech is always queued asynchronously via wx.CallAfter to ensure + thread safety, as this may be called from network threads. + """ + if self.isMuted: + return + setSpeechCancelledToFalse() + wx.CallAfter(speech._manager.speak, sequence, priority) + + def display(self, cells: List[int]) -> None: + """Update the local braille display with cells from remote. + + Safely writes braille cells from a remote machine to the local braille + display, handling display size differences and padding. + + :param cells: List of braille cells as integers (0-255) + :note: Only processes cells when: + - receivingBraille is True (display sharing is enabled) + - Local display is connected (displaySize > 0) + - Remote cells fit on local display + + Cells are padded with zeros if remote data is shorter than local display. + Uses thread-safe _writeCells method for compatibility with all displays. + """ + if ( + self.receivingBraille + and braille.handler.displaySize > 0 + and len(cells) <= braille.handler.displaySize + ): + cells = cells + [0] * (braille.handler.displaySize - len(cells)) + wx.CallAfter(braille.handler._writeCells, cells) + + def brailleInput(self, **kwargs: Dict[str, Any]) -> None: + """Process braille input gestures from a remote machine. + + Executes braille input commands locally using NVDA's input gesture system. + Handles both display routing and braille keyboard input. + + :param kwargs: Gesture parameters passed to BrailleInputGesture + :note: Silently ignores gestures that have no associated action. + """ + try: + inputCore.manager.executeGesture(input.BrailleInputGesture(**kwargs)) + except inputCore.NoInputGestureAction: + pass + + def setBrailleDisplaySize(self, sizes: List[int]) -> None: + """Cache remote braille display sizes for size negotiation. + + :param sizes: List of display sizes (cells) from remote machines + """ + self._cachedSizes = sizes + + def handleFilterDisplaySize(self, value: int) -> int: + """Filter the local display size based on remote display sizes. + + Determines the optimal display size when sharing braille output by + finding the smallest positive size among local and remote displays. + + :param value: Local display size in cells + :return: The negotiated display size to use + """ + if not self._cachedSizes: + return value + sizes = self._cachedSizes + [value] + try: + return min(i for i in sizes if i > 0) + except ValueError: + return value + + def handleDecideEnabled(self) -> bool: + """Determine if the local braille display should be enabled. + + :return: False if receiving remote braille, True otherwise + """ + return not self.receivingBraille + + def sendKey( + self, + vk_code: Optional[int] = None, + extended: Optional[bool] = None, + pressed: Optional[bool] = None, + ) -> None: + """Simulate a keyboard event on the local machine. + + :param vk_code: Virtual key code to simulate + :param extended: Whether this is an extended key + :param pressed: True for key press, False for key release + """ + wx.CallAfter(input.sendKey, vk_code, None, extended, pressed) + + def setClipboardText(self, text: str) -> None: + """Set the local clipboard text from a remote machine. + + :param text: Text to copy to the clipboard + """ + cues.clipboardReceived() + api.copyToClip(text=text) + + def sendSAS(self) -> None: + """Simulate a secure attention sequence (e.g. CTRL+ALT+DEL). + + :note: SendSAS requires UI Access. If this fails, a warning is displayed. + """ + if hasUiAccess(): + ctypes.windll.sas.SendSAS(0) + else: + # Translators: Message displayed when a remote machine tries to send a SAS but UI Access is disabled. + ui.message(_("Unable to trigger Alt Control Delete from remote")) + logger.warning("UI Access is disabled on this machine so cannot trigger CTRL+ALT+DEL") diff --git a/source/remoteClient/menu.py b/source/remoteClient/menu.py new file mode 100644 index 00000000000..de16096fe5a --- /dev/null +++ b/source/remoteClient/menu.py @@ -0,0 +1,169 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from typing import TYPE_CHECKING + +import wx + +if TYPE_CHECKING: + from .client import RemoteClient + +import gui + +from .connectionInfo import ConnectionMode + + +class RemoteMenu(wx.Menu): + """Menu for the NVDA Remote functionality that appears in the NVDA Tools menu""" + + def __init__(self, client: "RemoteClient") -> None: + super().__init__() + self.client = client + sysTrayIcon = gui.mainFrame.sysTrayIcon + toolsMenu = sysTrayIcon.toolsMenu + self.connectItem: wx.MenuItem = self.Append( + wx.ID_ANY, + # Translators: Item in NVDA Remote submenu to connect to a remote computer. + _("Connect..."), + # Translators: Tooltip for the Connect menu item in the NVDA Remote submenu. + _("Remotely connect to another computer running NVDA Remote Access"), + ) + sysTrayIcon.Bind( + wx.EVT_MENU, + self.client.doConnect, + self.connectItem, + ) + # Translators: Item in NVDA Remote submenu to disconnect from a remote computer. + self.disconnectItem: wx.MenuItem = self.Append( + wx.ID_ANY, + # Translators: Menu item in NVDA Remote submenu to disconnect from another computer running NVDA Remote Access. + _("Disconnect"), + # Translators: Tooltip for the Disconnect menu item in the NVDA Remote submenu. + _("Disconnect from another computer running NVDA Remote Access"), + ) + self.disconnectItem.Enable(False) + sysTrayIcon.Bind( + wx.EVT_MENU, + self.onDisconnectItem, + self.disconnectItem, + ) + self.muteItem: wx.MenuItem = self.Append( + wx.ID_ANY, + # Translators: Menu item in NvDA Remote submenu to mute speech and sounds from the remote computer. + _("Mute remote"), + # Translators: Tooltip for the Mute Remote menu item in the NVDA Remote submenu. + _("Mute speech and sounds from the remote computer"), + kind=wx.ITEM_CHECK, + ) + self.muteItem.Enable(False) + sysTrayIcon.Bind(wx.EVT_MENU, self.onMuteItem, self.muteItem) + self.pushClipboardItem: wx.MenuItem = self.Append( + wx.ID_ANY, + # Translators: Menu item in NVDA Remote submenu to push clipboard content to the remote computer. + _("&Push clipboard"), + # Translators: Tooltip for the Push Clipboard menu item in the NVDA Remote submenu. + _("Push the clipboard to the other machine"), + ) + self.pushClipboardItem.Enable(False) + sysTrayIcon.Bind( + wx.EVT_MENU, + self.onPushClipboardItem, + self.pushClipboardItem, + ) + self.copyLinkItem: wx.MenuItem = self.Append( + wx.ID_ANY, + # Translators: Menu item in NVDA Remote submenu to copy a link to the current session. + _("Copy &link"), + # Translators: Tooltip for the Copy Link menu item in the NVDA Remote submenu. + _("Copy a link to the remote session"), + ) + self.copyLinkItem.Enable(False) + sysTrayIcon.Bind( + wx.EVT_MENU, + self.onCopyLinkItem, + self.copyLinkItem, + ) + self.sendCtrlAltDelItem: wx.MenuItem = self.Append( + wx.ID_ANY, + # Translators: Menu item in NVDA Remote submenu to send Control+Alt+Delete to the remote computer. + _("Send Ctrl+Alt+Del"), + # Translators: Tooltip for the Send Ctrl+Alt+Del menu item in the NVDA Remote submenu. + _("Send Ctrl+Alt+Del"), + ) + sysTrayIcon.Bind( + wx.EVT_MENU, + self.onSendCtrlAltDel, + self.sendCtrlAltDelItem, + ) + self.sendCtrlAltDelItem.Enable(False) + self.remoteItem = toolsMenu.AppendSubMenu( + self, + # Translators: Label of menu in NVDA tools menu. + _("R&emote"), + # Translators: Tooltip for the Remote menu in the NVDA Tools menu. + _("NVDA Remote Access"), + ) + + def terminate(self) -> None: + self.Remove(self.connectItem.Id) + self.connectItem.Destroy() + self.connectItem = None + self.Remove(self.disconnectItem.Id) + self.disconnectItem.Destroy() + self.disconnectItem = None + self.Remove(self.muteItem.Id) + self.muteItem.Destroy() + self.muteItem = None + self.Remove(self.pushClipboardItem.Id) + self.pushClipboardItem.Destroy() + self.pushClipboardItem = None + self.Remove(self.copyLinkItem.Id) + self.copyLinkItem.Destroy() + self.copyLinkItem = None + self.Remove(self.sendCtrlAltDelItem.Id) + self.sendCtrlAltDelItem.Destroy() + self.sendCtrlAltDelItem = None + toolsMenu = gui.mainFrame.sysTrayIcon.toolsMenu + toolsMenu.Remove(self.remoteItem.Id) + self.remoteItem.Destroy() + self.remoteItem = None + try: + self.Destroy() + except (RuntimeError, AttributeError): + pass + + def onDisconnectItem(self, evt: wx.CommandEvent) -> None: + evt.Skip() + self.client.disconnect() + + def onMuteItem(self, evt: wx.CommandEvent) -> None: + evt.Skip() + self.client.toggleMute() + + def onPushClipboardItem(self, evt: wx.CommandEvent) -> None: + evt.Skip() + self.client.pushClipboard() + + def onCopyLinkItem(self, evt: wx.CommandEvent) -> None: + evt.Skip() + self.client.copyLink() + + def onSendCtrlAltDel(self, evt: wx.CommandEvent) -> None: + evt.Skip() + self.client.sendSAS() + + def handleConnected(self, mode: ConnectionMode, connected: bool) -> None: + self.connectItem.Enable(not connected) + self.disconnectItem.Enable(connected) + self.muteItem.Enable(connected) + if not connected: + self.muteItem.Check(False) + self.pushClipboardItem.Enable(connected) + self.copyLinkItem.Enable(connected) + self.sendCtrlAltDelItem.Enable(connected) + + def handleConnecting(self, mode: ConnectionMode) -> None: + self.disconnectItem.Enable(True) + self.connectItem.Enable(False) diff --git a/source/remoteClient/protocol.py b/source/remoteClient/protocol.py new file mode 100644 index 00000000000..f3d434da9bf --- /dev/null +++ b/source/remoteClient/protocol.py @@ -0,0 +1,72 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import urllib.parse +from enum import StrEnum + +PROTOCOL_VERSION: int = 2 + + +class RemoteMessageType(StrEnum): + # Connection and Protocol Messages + PROTOCOL_VERSION = "protocol_version" + JOIN = "join" + CHANNEL_JOINED = "channel_joined" + CLIENT_JOINED = "client_joined" + CLIENT_LEFT = "client_left" + GENERATE_KEY = "generate_key" + + # Control Messages + KEY = "key" + SPEAK = "speak" + CANCEL = "cancel" + PAUSE_SPEECH = "pause_speech" + TONE = "tone" + WAVE = "wave" + SEND_SAS = "send_SAS" # Send Secure Attention Sequence + INDEX = "index" + + # Display and Braille Messages + DISPLAY = "display" + BRAILLE_INPUT = "braille_input" + SET_BRAILLE_INFO = "set_braille_info" + SET_DISPLAY_SIZE = "set_display_size" + + # Clipboard Operations + SET_CLIPBOARD_TEXT = "set_clipboard_text" + + # System Messages + MOTD = "motd" + VERSION_MISMATCH = "version_mismatch" + PING = "ping" + ERROR = "error" + NVDA_NOT_CONNECTED = ( + "nvda_not_connected" # This was added in version 2 but never implemented on the server + ) + + +SERVER_PORT = 6837 +URL_PREFIX = "nvdaremote://" + + +def addressToHostPort(addr) -> tuple: + """Converts an address such as google.com:80 into a tuple of (address, port). + If no port is given, use SERVER_PORT. + """ + addr = urllib.parse.urlparse("//" + addr) + port = addr.port or SERVER_PORT + return (addr.hostname, port) + + +def hostPortToAddress(hostPort: tuple) -> str: + """Converts a tuple of (address, port) into a string such as google.com:80. + If the port is SERVER_PORT, it is omitted + """ + host, port = hostPort + if ":" in host: + host = f"[{host}]" + if port != SERVER_PORT: + return f"{host}:{port}" + return host diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py new file mode 100644 index 00000000000..ac20be43878 --- /dev/null +++ b/source/remoteClient/secureDesktop.py @@ -0,0 +1,239 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Secure desktop support for NVDA Remote. + +Handles the transition between regular and secure desktop sessions in Windows, +maintaining remote connections across these transitions. Manages the creation of local +relay servers, connection bridging, and IPC (Inter-Process Communication) between the +regular and secure desktop instances of NVDA. + +The secure desktop is a special Windows session used for UAC prompts and login screens +that runs in an isolated environment for security. This module ensures NVDA Remote +connections persist when entering and leaving this secure environment. + +Note: + All IPC operations use a temporary file in the system's ProgramData directory + to exchange connection information between sessions. +""" + +import json +import socket +import threading +import uuid +from pathlib import Path +from typing import Any, Optional + +import shlobj +from logHandler import log +from winAPI.secureDesktop import post_secureDesktopStateChange + +from . import bridge, server +from .connectionInfo import ConnectionInfo, ConnectionMode +from .protocol import RemoteMessageType +from .serializer import JSONSerializer +from .session import FollowerSession +from .transport import RelayTransport + + +def getProgramDataTempPath() -> Path: + """Get the system's program data temp directory path. + + :return: Path to the ProgramData temp directory + """ + return Path(shlobj.SHGetKnownFolderPath(shlobj.FolderId.PROGRAM_DATA)) / "temp" + + +class SecureDesktopHandler: + """Maintains remote connections during secure desktop transitions. + + Handles relay servers, IPC, and connection bridging between + regular and secure desktop sessions. + + :cvar SD_CONNECT_BLOCK_TIMEOUT: Timeout in seconds for secure desktop connection attempts + """ + + SD_CONNECT_BLOCK_TIMEOUT: int = 1 + + def __init__(self, tempPath: Path = getProgramDataTempPath()) -> None: + """Initialize secure desktop handler. + + :param tempPath: Directory for IPC file storage + """ + self.tempPath = tempPath + self.IPCPath: Path = self.tempPath / "NVDA" + self.IPCPath.mkdir(parents=True, exist_ok=True) + self.IPCFile = self.IPCPath / "remote.ipc" + log.debug("Initialized SecureDesktopHandler with IPC file: %s", self.IPCFile) + + self._followerSession: Optional[FollowerSession] = None + self.sdServer: Optional[server.LocalRelayServer] = None + self.sdRelay: Optional[RelayTransport] = None + self.sdBridge: Optional[bridge.BridgeTransport] = None + + post_secureDesktopStateChange.register(self._onSecureDesktopChange) + + def terminate(self) -> None: + """Clean up handler resources.""" + log.debug("Terminating SecureDesktopHandler") + post_secureDesktopStateChange.unregister(self._onSecureDesktopChange) + self.leaveSecureDesktop() + try: + log.debug("Removing IPC file: %s", self.IPCFile) + self.IPCFile.unlink() + except FileNotFoundError: + log.debug("IPC file already removed") + log.info("Secure desktop cleanup completed") + + @property + def followerSession(self) -> Optional[FollowerSession]: + return self._followerSession + + @followerSession.setter + def followerSession(self, session: Optional[FollowerSession]) -> None: + """Update follower session reference and handle necessary cleanup/setup.""" + if self._followerSession == session: + log.debug("Follower session unchanged, skipping update") + return + + log.info("Updating follower session reference") + if self.sdServer is not None: + self.leaveSecureDesktop() + + if self._followerSession is not None and self._followerSession.transport is not None: + transport = self._followerSession.transport + transport.unregisterInbound(RemoteMessageType.SET_BRAILLE_INFO, self._onLeaderDisplayChange) + self._followerSession = session + session.transport.registerInbound( + RemoteMessageType.SET_BRAILLE_INFO, + self._onLeaderDisplayChange, + ) + + def _onSecureDesktopChange(self, isSecureDesktop: Optional[bool] = None) -> None: + """Internal callback for secure desktop state changes. + + :param isSecureDesktop: True if transitioning to secure desktop, False otherwise + """ + log.info(f"Secure desktop state changed: {'entering' if isSecureDesktop else 'leaving'}") + if isSecureDesktop: + self.enterSecureDesktop() + else: + self.leaveSecureDesktop() + + def enterSecureDesktop(self) -> None: + """Set up necessary components when entering secure desktop.""" + log.debug("Attempting to enter secure desktop") + if self.followerSession is None or self.followerSession.transport is None: + log.warning("No follower session connected, not entering secure desktop.") + return + if not self.tempPath.exists(): + log.debug(f"Creating temp directory: {self.tempPath}") + self.tempPath.mkdir(parents=True, exist_ok=True) + + channel = str(uuid.uuid4()) + log.debug("Starting local relay server") + self.sdServer = server.LocalRelayServer(port=0, password=channel, bindHost="127.0.0.1") + port = self.sdServer.serverSocket.getsockname()[1] + log.info("Local relay server started on port %d", port) + + serverThread = threading.Thread(target=self.sdServer.run) + serverThread.daemon = True + serverThread.start() + + self.sdRelay = RelayTransport( + address=("127.0.0.1", port), + serializer=JSONSerializer(), + channel=channel, + insecure=True, + connectionType=ConnectionMode.LEADER, + ) + self.sdRelay.registerInbound(RemoteMessageType.CLIENT_JOINED, self._onLeaderDisplayChange) + self.followerSession.transport.registerInbound( + RemoteMessageType.SET_BRAILLE_INFO, + self._onLeaderDisplayChange, + ) + + self.sdBridge = bridge.BridgeTransport(self.followerSession.transport, self.sdRelay) + + relayThread = threading.Thread(target=self.sdRelay.run) + relayThread.daemon = True + relayThread.start() + + data = [port, channel] + log.debug(f"Writing connection data to IPC file: {self.IPCFile}") + self.IPCFile.write_text(json.dumps(data)) + log.info("Secure desktop setup completed successfully") + + def leaveSecureDesktop(self) -> None: + """Clean up when leaving secure desktop.""" + log.debug("Attempting to leave secure desktop") + if self.sdServer is None: + log.debug("No secure desktop server running, nothing to clean up") + return + + if self.sdBridge is not None: + self.sdBridge.disconnect() + self.sdBridge = None + + if self.sdServer is not None: + self.sdServer.close() + self.sdServer = None + + if self.sdRelay is not None: + self.sdRelay.close() + self.sdRelay = None + + if self.followerSession is not None and self.followerSession.transport is not None: + self.followerSession.transport.unregisterInbound( + RemoteMessageType.SET_BRAILLE_INFO, + self._onLeaderDisplayChange, + ) + self.followerSession.setDisplaySize() + + try: + self.IPCFile.unlink() + except FileNotFoundError: + pass + + def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: + """Initialize connection when starting in secure desktop. + + :return: Connection information if successful, None on failure + """ + log.info("Initializing secure desktop connection") + try: + log.debug(f"Reading connection data from IPC file: {self.IPCFile}") + data = json.loads(self.IPCFile.read_text()) + self.IPCFile.unlink() + port, channel = data + + # Try opening a socket to make sure we have the appropriate permissions + testSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + testSocket.close() + + log.info(f"Successfully established secure desktop connection on port {port}") + return ConnectionInfo( + hostname="127.0.0.1", + mode=ConnectionMode.FOLLOWER, + key=channel, + port=port, + insecure=True, + ) + + except Exception: + log.exception("Failed to initialize secure desktop connection.") + return None + + def _onLeaderDisplayChange(self, **kwargs: Any) -> None: + """Handle display size changes.""" + log.debug("Leader display change detected") + if self.sdRelay is not None and self.followerSession is not None: + log.debug("Propagating display size change to secure desktop relay") + self.sdRelay.send( + type=RemoteMessageType.SET_DISPLAY_SIZE, + sizes=self.followerSession.leaderDisplaySizes, + ) + else: + log.warning("No secure desktop relay or follower session available, skipping display change") diff --git a/source/remoteClient/serializer.py b/source/remoteClient/serializer.py new file mode 100644 index 00000000000..27cecf5e561 --- /dev/null +++ b/source/remoteClient/serializer.py @@ -0,0 +1,196 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Message serialization for remote NVDA communication. + +This module handles serializing and deserializing messages between NVDA instances. +It provides special handling for speech commands and other NVDA-specific data types. + +Module Features +------------- +* A generic :class:`.Serializer` interface +* A concrete :class:`.JSONSerializer` implementation for NVDA Remote messages + +Supported Data Types +------------------ +* Basic JSON data types +* Speech command objects +* Custom message types via the 'type' field +""" + +import json +from abc import ABCMeta, abstractmethod +from enum import Enum +from logging import getLogger +from typing import Any, Dict, Optional, Type, TypeVar, Union + +import speech.commands + +log = getLogger("serializer") + +T = TypeVar("T") +JSONDict = Dict[str, Any] + + +class Serializer(metaclass=ABCMeta): + """Base class for message serialization. + + Defines the interface for serializing messages between NVDA instances. + Concrete implementations should handle converting Python objects to/from + a wire format suitable for network transmission. + + Note + ---- + This is an abstract base class. Subclasses must implement + :meth:`serialize` and :meth:`deserialize`. + """ + + @abstractmethod + def serialize(self, type: Optional[str] = None, **obj: Any) -> bytes: + """Convert a message to bytes for transmission. + + :param type: Message type identifier, used for routing + :param obj: Message payload as keyword arguments + :return: Serialized message as bytes + :raises NotImplementedError: Must be implemented by subclasses + """ + raise NotImplementedError + + @abstractmethod + def deserialize(self, data: bytes) -> JSONDict: + """Convert received bytes back into a message dict. + + :param data: Raw message bytes to deserialize + :return: Dict containing the deserialized message + :raises NotImplementedError: Must be implemented by subclasses + """ + raise NotImplementedError + + +class JSONSerializer(Serializer): + """JSON-based message serializer with NVDA-specific type handling. + + Implements message serialization using JSON encoding with special handling for + NVDA speech commands and other custom types. Messages are encoded as UTF-8 + with newline separation. + """ + + SEP: bytes = b"\n" + """Message separator for streaming protocols""" + + def serialize(self, type: Optional[str] = None, **obj: Any) -> bytes: + """Serialize a message to JSON bytes. + + Converts message type and payload to JSON format, handling Enum types + and using CustomEncoder for NVDA-specific objects. + + :param type: Message type identifier (string or Enum) + :param obj: Message payload to serialize + :return: UTF-8 encoded JSON with newline separator + :rtype: bytes + """ + if type is not None: + if isinstance(type, Enum) and not isinstance(type, str): + type = type.value + obj["type"] = type + data = json.dumps(obj, cls=SpeechCommandJSONEncoder).encode("UTF-8") + self.SEP + return data + + def deserialize(self, data: bytes) -> JSONDict: + """Deserialize JSON message bytes. + + Converts JSON bytes back to a dict, using as_sequence hook to + reconstruct NVDA speech commands. + + :param data: UTF-8 encoded JSON bytes + :return: Dict containing the deserialized message + """ + obj = json.loads(data, object_hook=asSequence) + return obj + + +SEQUENCE_CLASSES = ( + speech.commands.SynthCommand, + speech.commands.EndUtteranceCommand, +) + + +class SpeechCommandJSONEncoder(json.JSONEncoder): + """Custom JSON encoder for NVDA speech commands. + + Handles serialization of speech command objects by converting them + to a list containing their class name and instance variables. + + :note: Inherits from :class:`json.JSONEncoder` + """ + + def default(self, obj: Any) -> Any: + """Convert speech commands to serializable format. + + :param obj: Object to serialize + :return: For speech commands, returns a list containing [class_name, instance_vars]. + For other types, returns the default JSON encoding. + """ + if isSubclassOrInstance(obj, SEQUENCE_CLASSES): + return [obj.__class__.__name__, obj.__dict__] + return super().default(obj) + + +def isSubclassOrInstance(unknown: Any, possible: Union[Type[T], tuple[Type[T], ...]]) -> bool: + """Check if an object is a subclass or instance of given type(s). + + Safely handles both types and instances, useful for type checking + during serialization. + + :param unknown: Object or type to check + :param possible: Type or tuple of types to check against + :return: True if unknown is a subclass or instance of possible + + Example:: + + >>> isSubclassOrInstance(str, (int, str)) + True + >>> isSubclassOrInstance("hello", (int, str)) + True + """ + try: + return issubclass(unknown, possible) + except TypeError: + return isinstance(unknown, possible) + + +def asSequence(dct: JSONDict) -> JSONDict: + """Reconstruct speech command objects from deserialized JSON. + + Handles the 'speak' message type by converting serialized speech + commands back into their original object form. + + :param dct: Dict containing potentially serialized speech commands + :return: Dict with reconstructed speech command objects if applicable, + otherwise returns the input unchanged + :warning: Logs a warning if an unknown sequence type is encountered + + .. warning:: + + This function modifies the input dictionary in place. + Copy the dictionary first if you need access to the unmodified data. + """ + if not ("type" in dct and dct["type"] == "speak" and "sequence" in dct): + return dct + sequence = [] + for item in dct["sequence"]: + if not isinstance(item, list): + sequence.append(item) + continue + name, values = item + cls = getattr(speech.commands, name, None) + if cls is None or not issubclass(cls, SEQUENCE_CLASSES): + log.warning("Unknown sequence type received: %r" % name) + continue + cls = cls.__new__(cls) + cls.__dict__.update(values) + sequence.append(cls) + dct["sequence"] = sequence + return dct diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py new file mode 100644 index 00000000000..e0ecffbca42 --- /dev/null +++ b/source/remoteClient/server.py @@ -0,0 +1,538 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Server implementation for NVDA Remote relay functionality. + +This module implements a relay server that enables NVDA Remote connections between +multiple clients. It provides: + +- A secure SSL/TLS encrypted relay server +- Client authentication via channel password matching +- Message routing between connected clients +- Protocol version recording (clients declare their version) +- Connection monitoring with periodic one-way pings +- Separate IPv4 and IPv6 socket handling +- Dynamic certificate generation and management + +The server creates separate IPv4 and IPv6 sockets but routes messages between all +connected clients regardless of IP version. Messages use JSON format and must be +newline-delimited. Invalid messages will cause client disconnection. + +When clients disconnect or lose connection, the server automatically removes them and +notifies other connected clients of the departure. +""" + +import os +import socket +import ssl +import time +from datetime import datetime, timedelta +from pathlib import Path +from select import select +from itertools import count +from typing import Any, Final + +import cffi # noqa # required for cryptography +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from logHandler import log + +from . import configuration +from .protocol import RemoteMessageType +from .secureDesktop import getProgramDataTempPath +from .serializer import JSONSerializer + + +class RemoteCertificateManager: + """Manages SSL certificates for the NVDA Remote relay server. + + :ivar certDir: Directory where certificates and keys are stored + :ivar certPath: Path to the certificate file + :ivar keyPath: Path to the private key file + :ivar fingerprintPath: Path to the fingerprint file + """ + + CERT_FILE = "NvdaRemoteRelay.pem" + KEY_FILE = "NvdaRemoteRelay.key" + FINGERPRINT_FILE = "NvdaRemoteRelay.fingerprint" + CERT_DURATION_DAYS = 365 + CERT_RENEWAL_THRESHOLD_DAYS = 30 + + def __init__(self, certDir: Path | None = None): + """Initialize the certificate manager. + + :param certDir: Directory to store certificate files, defaults to program data temp path + """ + self.certDir: Path = certDir or getProgramDataTempPath() + self.certPath: Path = self.certDir / self.CERT_FILE + self.keyPath: Path = self.certDir / self.KEY_FILE + self.fingerprintPath: Path = self.certDir / self.FINGERPRINT_FILE + + def ensureValidCertExists(self) -> None: + """Ensures a valid certificate and key exist, regenerating if needed.""" + log.info("Checking certificate validity") + os.makedirs(self.certDir, exist_ok=True) + + if self._filesExist(): + try: + self._validateCertificate() + return + except Exception as e: + log.warning(f"Certificate validation failed: {e}", exc_info=True) + + self._generateSelfSignedCert() + + def _filesExist(self) -> bool: + """Check if both certificate and key files exist.""" + return self.certPath.is_file() and self.keyPath.is_file() + + def _validateCertificate(self) -> None: + """Validates the existing certificate and key. + + :raises ValueError: If the current date/time is outside the certificate's validity period, or if the certificate is approaching expiration. + :raises OSError: If the certificate or private key files cannot be opened. + :raises ValueError: If the private key data cannot be decoded. + :raises TypeError: If the private key is encrypted. + """ + # Load and validate certificate + with open(self.certPath, "rb") as f: + certData = f.read() + cert = x509.load_pem_x509_certificate(certData) + + # Check validity period + now = datetime.utcnow() + if not (cert.not_valid_before_utc < now <= cert.not_valid_after_utc): + raise ValueError("Certificate is not within its validity period") + + # Check renewal threshold + timeRemaining = cert.not_valid_after - now + if timeRemaining.days <= self.CERT_RENEWAL_THRESHOLD_DAYS: + raise ValueError("Certificate is approaching expiration") + + # Verify private key can be loaded + with open(self.keyPath, "rb") as f: + serialization.load_pem_private_key(f.read(), password=None) + + def _generateSelfSignedCert(self) -> None: + """Generates a self-signed certificate and private key.""" + privateKey = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "NVDA Remote Access Service"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "NV Access"), + ], + ) + + cert = ( + x509.CertificateBuilder() + .subject_name( + subject, + ) + .issuer_name( + issuer, + ) + .public_key( + privateKey.public_key(), + ) + .serial_number( + x509.random_serial_number(), + ) + .not_valid_before( + datetime.utcnow(), + ) + .not_valid_after( + datetime.utcnow() + timedelta(days=self.CERT_DURATION_DAYS), + ) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), + critical=True, + ) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("localhost"), + ], + ), + critical=False, + ) + .sign(privateKey, hashes.SHA256()) + ) + + # Calculate fingerprint + fingerprint = cert.fingerprint(hashes.SHA256()).hex() + # Write private key + with open(self.keyPath, "wb") as f: + f.write( + privateKey.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ), + ) + + # Write certificate + with open(self.certPath, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + + # Save fingerprint + with open(self.fingerprintPath, "w") as f: + f.write(fingerprint) + + # Add to trusted certificates in config + config = configuration.getRemoteConfig() + if "trusted_certs" not in config: + config["trusted_certs"] = {} + config["trusted_certs"]["localhost"] = fingerprint + config["trusted_certs"]["127.0.0.1"] = fingerprint + + log.info("Generated new self-signed certificate for NVDA Remote. " f"Fingerprint: {fingerprint}") + + def getCurrentFingerprint(self) -> str | None: + """Get the fingerprint of the current certificate.""" + try: + if self.fingerprintPath.is_file(): + with open(self.fingerprintPath, "r") as f: + return f.read().strip() + except Exception as e: + log.warning(f"Error reading fingerprint: {e}", exc_info=True) + return None + + def createSSLContext(self) -> ssl.SSLContext: + """Creates an SSL context using the certificate and key.""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + # Load our certificate and private key + context.load_cert_chain( + certfile=str(self.certPath), + keyfile=str(self.keyPath), + ) + # Trust our own CA for server verification + context.load_verify_locations(cafile=str(self.certPath)) + # Require client cert verification + context.verify_mode = ssl.CERT_NONE # Don't require client certificates + context.check_hostname = False # Don't verify hostname since we're using self-signed certs + return context + + +class LocalRelayServer: + """Secure relay server for NVDA Remote connections. + + Accepts encrypted connections from NVDA Remote clients and routes messages between them. + Creates IPv4 and IPv6 listening sockets using SSL/TLS encryption. + Uses select() for non-blocking I/O and monitors connection health with periodic pings. + + Clients must authenticate by providing the correct channel password in their join message + before they can exchange messages. Both IPv4 and IPv6 clients share the same channel + and can interact with each other transparently. + + :ivar port: Port number to listen on + :ivar password: Channel password for client authentication + :ivar clients: Dictionary mapping sockets to Client objects + :ivar clientSockets: List of client sockets + :ivar PING_TIME: Seconds between ping messages + """ + + PING_TIME_SECONDS: int = 300 + SELECT_TIMEOUT_SECONDS: Final[int] = 60 + + def __init__( + self, + port: int, + password: str, + bindHost: str = "", + bindHost6: str = "[::]:", + certDir: Path | None = None, + ): + """Initialize the relay server. + + :param port: Port number to listen on + :param password: Channel password for client authentication + :param bindHost: IPv4 address to bind to, defaults to all interfaces + :param bindHost6: IPv6 address to bind to, defaults to all interfaces + :param certDir: Directory to store certificate files, defaults to None + """ + self.port = port + self.password = password + self.certManager = RemoteCertificateManager(certDir) + self.certManager.ensureValidCertExists() + + # Initialize other server components + self.serializer = JSONSerializer() + self.clients: dict[socket.socket, Client] = {} + self.clientSockets: list[socket.socket] = [] + self._running = False + self.lastPingTime = 0 + + # Create server sockets + self.serverSocket = self.createServerSocket( + socket.AF_INET, + socket.SOCK_STREAM, + bindAddress=(bindHost, self.port), + ) + self.serverSocket6 = self.createServerSocket( + socket.AF_INET6, + socket.SOCK_STREAM, + bindAddress=(bindHost6, self.port), + ) + + def createServerSocket(self, family: int, type: int, bindAddress: tuple[str, int]) -> ssl.SSLSocket: + """Creates an SSL wrapped socket using the certificate. + + :param family: Socket address family (AF_INET or AF_INET6) + :param type: Socket type (typically SOCK_STREAM) + :param bindAddress: Tuple of (host, port) to bind to + :return: SSL wrapped server socket + :raises socket.error: If socket creation or binding fails + """ + serverSocket = socket.socket(family, type) + sslContext = self.certManager.createSSLContext() + serverSocket = sslContext.wrap_socket(serverSocket, server_side=True) + serverSocket.bind(bindAddress) + serverSocket.listen(backlog=5) # Set the maximum number of queued connections + return serverSocket + + def run(self) -> None: + """Main server loop that handles client connections and message routing. + + Continuously accepts new connections and processes messages from connected clients. + Sends periodic ping messages to maintain connection health. + + :raises socket.error: If there are socket communication errors + """ + log.info(f"Starting NVDA Remote relay server on port {self.port}") + self._running = True + self.lastPingTime = time.time() + while self._running: + read, write, error = select( + self.clientSockets + [self.serverSocket, self.serverSocket6], + [], + self.clientSockets, + self.SELECT_TIMEOUT_SECONDS, + ) + if not self._running: + break + for sock in read: + if sock is self.serverSocket or sock is self.serverSocket6: + self.acceptNewConnection(sock) + continue + self.clients[sock].handleData() + if time.time() - self.lastPingTime >= self.PING_TIME: + for client in self.clients.values(): + if client.authenticated: + client.send(type=RemoteMessageType.PING) + self.lastPingTime = time.time() + + def acceptNewConnection(self, sock: ssl.SSLSocket) -> None: + """Accept and set up a new client connection.""" + try: + clientSock, addr = sock.accept() + log.info(f"New client connection from {addr}") + except (ssl.SSLError, socket.error, OSError): + log.error("Error accepting connection", exc_info=True) + return + # Disable Nagle's algorithm so that packets are always sent immediately. + clientSock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + client = Client(server=self, socket=clientSock) + self.addClient(client) + + def addClient(self, client: "Client") -> None: + """Add a new client to the server.""" + self.clients[client.socket] = client + self.clientSockets.append(client.socket) + + def removeClient(self, client: "Client") -> None: + """Remove a client from the server.""" + del self.clients[client.socket] + self.clientSockets.remove(client.socket) + + def clientDisconnected(self, client: "Client") -> None: + """Handle client disconnection and notify other clients.""" + log.info(f"Client {client.id} disconnected") + self.removeClient(client) + if client.authenticated: + client.sendToOthers( + type=RemoteMessageType.CLIENT_LEFT, + user_id=client.id, + client=client.asDict(), + ) + + def close(self) -> None: + """Shut down the server and close all connections.""" + log.info("Shutting down NVDA Remote relay server") + self._running = False + self.serverSocket.close() + self.serverSocket6.close() + log.info("Server shutdown complete") + + +class Client: + """Handles a single connected NVDA Remote client. + + Processes incoming messages, handles authentication via channel password, + records client protocol version, and routes messages to other connected clients. + Maintains a buffer of received data and processes complete messages delimited + by newlines. + + :ivar id: Unique client identifier + :ivar socket: SSL socket for this client connection + :ivar buffer: Buffer for incomplete received data + :ivar authenticated: Whether client has authenticated successfully + :ivar connectionType: Type of client connection + :ivar protocolVersion: Client protocol version number + """ + + _idCounter = count(1) + + def __init__(self, server: LocalRelayServer, socket: ssl.SSLSocket) -> None: + """Initialize a client connection. + + :param server: The relay server instance this client belongs to + :param socket: The SSL socket for this client connection + """ + self.server: LocalRelayServer = server + self.socket: ssl.SSLSocket = socket + self.buffer: bytes = b"" + self.serializer: JSONSerializer = server.serializer + self.authenticated: bool = False + self.id: int = next(self._idCounter) + self.connectionType: str | None = None + self.protocolVersion: int = 1 + + def handleData(self) -> None: + """Process incoming data from the client socket.""" + sockData = b"" + try: + sockData = self.socket.recv(16384) + except Exception: + self.close() + return + if not sockData: # Disconnect + self.close() + return + data = self.buffer + sockData + if b"\n" not in data: + self.buffer = data + return + self.buffer = b"" + while b"\n" in data: + line, sep, data = data.partition(b"\n") + try: + self.parse(line) + except ValueError: + log.error(f"Error parsing message from client {self.id}", exc_info=True) + self.close() + return + self.buffer += data + + def parse(self, line: bytes) -> None: + """Parse and handle an incoming message line.""" + parsed = self.serializer.deserialize(line) + if "type" not in parsed: + return + if self.authenticated: + self.sendToOthers(**parsed) + return + fn = "do_" + parsed["type"] + if hasattr(self, fn): + getattr(self, fn)(parsed) + + def asDict(self) -> dict[str, Any]: + """Get client information as a dictionary.""" + return dict(id=self.id, connection_type=self.connectionType) + + def do_join(self, obj: dict[str, Any]) -> None: + """Handle client join request and authentication.""" + password = obj.get("channel", None) + if password != self.server.password: + log.warning("Client %s sent incorrect password", self.id) + self.send( + type=RemoteMessageType.ERROR, + message="incorrect_password", + ) + self.close() + return + self.connectionType = obj.get("connection_type") + self.authenticated = True + log.info(f"Client {self.id} authenticated successfully " f"(connection type: {self.connectionType})") + clients = [] + clientIds = [] + for client in list(self.server.clients.values()): + if client is self or not client.authenticated: + continue + clients.append(client.asDict()) + clientIds.append(client.id) + self.send( + type=RemoteMessageType.CHANNEL_JOINED, + channel=self.server.password, + user_ids=clientIds, + clients=clients, + ) + self.sendToOthers( + type=RemoteMessageType.CLIENT_JOINED, + user_id=self.id, + client=self.asDict(), + ) + + def do_protocol_version(self, obj: dict[str, Any]) -> None: + """Record client's protocol version.""" + version = obj.get("version") + if not version: + return + self.protocolVersion = version + + def close(self) -> None: + """Close the client connection.""" + self.socket.close() + self.server.clientDisconnected(self) + + def send( + self, + type: str | RemoteMessageType, + origin: int | None = None, + clients: list[dict[str, Any]] | None = None, + client: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Send a message to this client. + + :param type: Message type + :param origin: Originating client ID + :param clients: List of connected clients + :param client: Client information + + :note: Additional keyword arguments are included in the message data. + """ + msg = kwargs + if self.protocolVersion > 1: + if origin: + msg["origin"] = origin + if clients: + msg["clients"] = clients + if client: + msg["client"] = client + try: + data = self.serializer.serialize(type=type, **msg) + self.socket.sendall(data) + except Exception: + log.error(f"Error sending message to client {self.id}", exc_info=True) + self.close() + + def sendToOthers(self, origin: int | None = None, **payload: Any) -> None: + """Send a message to all other authenticated clients. + + :param origin: Originating client ID + :param payload: Message data + """ + + if origin is None: + origin = self.id + for c in self.server.clients.values(): + if c is not self and c.authenticated: + c.send(origin=origin, **payload) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py new file mode 100644 index 00000000000..d5c2124b0e9 --- /dev/null +++ b/source/remoteClient/session.py @@ -0,0 +1,641 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""NVDA Remote session management and message routing. + +Implements the session layer for NVDA Remote, handling message routing, +connection roles, and NVDA feature coordination between instances. + +Core Operation: +------------- +1. Transport layer delivers typed messages (RemoteMessageType) +2. Session routes messages to registered handlers +3. Handlers execute on wx main thread via CallAfter +4. Results flow back through transport layer + +Connection Roles: +-------------- +Leader (Controlling) + - Captures and forwards input + - Receives remote output (speech/braille) + - Manages connection state + - Patches input handling + +Follower (Controlled) + - Executes received commands + - Forwards output to leader(s) + - Tracks connected leaders + - Patches output handling + +Key Components: +------------ +:class:`RemoteSession` + Base session managing shared functionality: + - Message handler registration + - Connection validation + - Version compatibility + - MOTD handling + +:class:`LeaderSession` + Controls remote instance: + - Input capture/forwarding + - Remote output reception + - Connection management + - Leader-specific patches + +:class:`FollowerSession` + Controlled by remote instance: + - Command execution + - Output forwarding + - Multi-leader support + - Follower-specific patches + +Thread Safety: +------------ +All message handlers execute on wx main thread via CallAfter +to ensure thread-safe NVDA operations. + +See Also: + transport.py: Network communication + local_machine.py: NVDA interface +""" + +import hashlib +from collections import defaultdict +from typing import Any, Final + +import braille +import brailleInput +import gui +import inputCore +import scriptHandler +import speech +import speech.commands +import tones +import ui +from logHandler import log +from nvwave import decide_playWaveFile +from speech.extensions import post_speechPaused, pre_speechQueued, speechCanceled + +from . import configuration, connectionInfo, cues +from .localMachine import LocalMachine +from .protocol import RemoteMessageType +from .transport import RelayTransport + +EXCLUDED_SPEECH_COMMANDS = ( + speech.commands.BaseCallbackCommand, + # _CancellableSpeechCommands are not designed to be reported and are used internally by NVDA. (#230) + speech.commands._CancellableSpeechCommand, +) + + +class RemoteSession: + """Base class for a session that runs on either the leader or follower machine. + + :note: Handles core session tasks: + - Version compatibility checks + - Message of the day handling + - Connection management + - Transport registration + """ + + transport: RelayTransport + """The transport layer handling network communication""" + + localMachine: LocalMachine + """Interface to control the local NVDA instance""" + + mode: connectionInfo.ConnectionMode | None = None + """Session mode - either 'leader' or 'follower'""" + + callbacksAdded: bool = False + """Whether callbacks are currently registered""" + + def __init__( + self, + localMachine: LocalMachine, + transport: RelayTransport, + ) -> None: + """Initialise the remote session. + + :param localMachine: Interface to control local NVDA instance + :param transport: Network transport layer instance + """ + log.info("Initializing Remote Session") + self.localMachine = localMachine + self.callbacksAdded = False + self.transport = transport + self.transport.registerInbound( + RemoteMessageType.VERSION_MISMATCH, + self.handleVersionMismatch, + ) + self.transport.registerInbound(RemoteMessageType.MOTD, self.handleMOTD) + self.transport.registerInbound( + RemoteMessageType.SET_CLIPBOARD_TEXT, + self.localMachine.setClipboardText, + ) + self.transport.registerInbound( + RemoteMessageType.CLIENT_JOINED, + self.handleClientConnected, + ) + self.transport.registerInbound( + RemoteMessageType.CLIENT_LEFT, + self.handleClientDisconnected, + ) + + def handleVersionMismatch(self) -> None: + """Handle protocol version mismatch between client and server. + + :note: Called when transport detects incompatible protocol versions. + - Displays localized error message + - Closes transport connection + - Prevents further communication + """ + log.error("Protocol version mismatch detected with relay server") + ui.message( + # Translators: Message for version mismatch + _("""The version of the relay server which you have connected to is not compatible with this version of the Remote Client. +Please use a different server."""), + ) + self.transport.close() + + def handleMOTD(self, motd: str, force_display: bool = False) -> None: + """Handle Message of the Day from relay server. + + :param motd: Message text to display + :param force_display: If True, always show message even if seen before + :note: Shows message if: + - Not shown before (tracked by hash) + - force_display is True + Used for service announcements, maintenance notices, etc. + Message hashes stored per-server in config. + """ + if force_display or self.shouldDisplayMotd(motd): + gui.messageBox( + parent=gui.mainFrame, + # Translators: Caption for message of the day dialog + caption=_("Message of the Day"), + message=motd, + ) + + def shouldDisplayMotd(self, motd: str) -> bool: + """Check if MOTD should be displayed. + + :param motd: Message to check + :return: True if message should be shown + :note: Compares message hash against previously shown messages + stored in config file per server + + .. warning:: + Calling this method will cause the MoTD to be registered as shown if it has not been already. + """ + conf = configuration.getRemoteConfig() + connection = self.getConnectionInfo() + address = "{host}:{port}".format( + host=connection.hostname, + port=connection.port, + ) + motdBytes = motd.encode("utf-8", errors="surrogatepass") + hashed = hashlib.sha1(motdBytes).hexdigest() + current = conf["seen_motds"].get(address, "") + if current == hashed: + return False + conf["seen_motds"][address] = hashed + return True + + def handleClientConnected(self, client: dict[str, Any] | None) -> None: + """Handle new client connection. + + :param client: Dictionary containing client connection details + :note: Logs connection info and plays connection sound + """ + log.info(f"Client connected: {client!r}") + cues.clientConnected() + + def handleClientDisconnected(self, client: dict[str, Any] | None = None) -> None: + """Handle client disconnection. + + :param client: Optional client info dictionary + :note: Plays disconnection sound when remote client disconnects + """ + cues.clientDisconnected() + + def getConnectionInfo(self) -> connectionInfo.ConnectionInfo: + """Get information about the current connection. + + :return: ConnectionInfo object with server details and session mode + :note: Contains hostname, port, channel key and session mode + """ + hostname, port = self.transport.address + key = self.transport.channel + return connectionInfo.ConnectionInfo( + hostname=hostname, + port=port, + key=key, + mode=self.mode, + ) + + def close(self) -> None: + """Close the transport connection. + + Terminates the network connection and cleans up resources. + """ + self.transport.close() + + def __del__(self) -> None: + """Ensure transport is closed when object is deleted.""" + self.close() + + +class FollowerSession(RemoteSession): + """Session that runs on the controlled (follower) NVDA instance. + + :ivar leaders: Information about connected leader clients + :ivar leaderDisplaySizes: Braille display sizes of connected leaders + :note: Handles: + - Command execution from leaders + - Output forwarding to leaders + - Multi-leader connections + - Braille display coordination + """ + + # Connection mode - always follower + mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.FOLLOWER + # Information about connected leader clients + leaders: dict[int, dict[str, Any]] + leaderDisplaySizes: list[int] # Braille display sizes of connected leaders + + def __init__( + self, + localMachine: LocalMachine, + transport: RelayTransport, + ) -> None: + super().__init__(localMachine, transport) + self.transport.registerInbound( + RemoteMessageType.KEY, + self.localMachine.sendKey, + ) + self.leaders = defaultdict(dict) + self.leaderDisplaySizes = [] + self.transport.transportClosing.register(self.handleTransportClosing) + self.transport.registerInbound( + RemoteMessageType.CHANNEL_JOINED, + self.handleChannelJoined, + ) + self.transport.registerInbound( + RemoteMessageType.SET_BRAILLE_INFO, + self.handleBrailleInfo, + ) + self.transport.registerInbound( + RemoteMessageType.SET_DISPLAY_SIZE, + self.setDisplaySize, + ) + braille.filter_displaySize.register( + self.localMachine.handleFilterDisplaySize, + ) + self.transport.registerInbound( + RemoteMessageType.BRAILLE_INPUT, + self.localMachine.brailleInput, + ) + self.transport.registerInbound( + RemoteMessageType.SEND_SAS, + self.localMachine.sendSAS, + ) + + def registerCallbacks(self) -> None: + if self.callbacksAdded: + return + self.transport.registerOutbound( + tones.decide_beep, + RemoteMessageType.TONE, + ) + self.transport.registerOutbound( + speechCanceled, + RemoteMessageType.CANCEL, + ) + self.transport.registerOutbound(decide_playWaveFile, RemoteMessageType.WAVE) + self.transport.registerOutbound(post_speechPaused, RemoteMessageType.PAUSE_SPEECH) + braille.pre_writeCells.register(self.display) + pre_speechQueued.register(self.sendSpeech) + self.callbacksAdded = True + + def unregisterCallbacks(self) -> None: + if not self.callbacksAdded: + return + self.transport.unregisterOutbound(RemoteMessageType.TONE) + self.transport.unregisterOutbound(RemoteMessageType.CANCEL) + self.transport.unregisterOutbound(RemoteMessageType.WAVE) + self.transport.unregisterOutbound(RemoteMessageType.PAUSE_SPEECH) + braille.pre_writeCells.unregister(self.display) + pre_speechQueued.unregister(self.sendSpeech) + self.callbacksAdded = False + + def handleClientConnected(self, client: dict[str, Any]) -> None: + super().handleClientConnected(client) + if client["connection_type"] == connectionInfo.ConnectionMode.LEADER.value: + self.leaders[client["id"]]["active"] = True + if self.leaders: + self.registerCallbacks() + + def handleChannelJoined( + self, + channel: str, + clients: list[dict[str, Any]], + origin: int | None = None, + ) -> None: + if clients is None: + clients = [] + for client in clients: + self.handleClientConnected(client) + + def handleTransportClosing(self) -> None: + """Handle cleanup when transport connection is closing. + + Removes any registered callbacks + to ensure clean shutdown of remote features. + """ + self.unregisterCallbacks() + + def handleTransportDisconnected(self) -> None: + """Handle disconnection from the transport layer. + + Called when the transport connection is lost. This method: + 1. Plays a connection sound cue + 2. Removes any NVDA patches + """ + log.info("Transport disconnected from follower session") + cues.clientDisconnected() + + def handleClientDisconnected(self, client: dict[str, Any]) -> None: + super().handleClientDisconnected(client) + if client["connection_type"] == connectionInfo.ConnectionMode.LEADER.value: + log.info("Leader client disconnected: %r", client) + del self.leaders[client["id"]] + if not self.leaders: + self.unregisterCallbacks() + + def setDisplaySize(self, sizes: list[int] | None = None) -> None: + self.leaderDisplaySizes = ( + sizes if sizes else [info.get("braille_numCells", 0) for info in self.leaders.values()] + ) + log.debug("Setting follower display size to: %r", self.leaderDisplaySizes) + self.localMachine.setBrailleDisplaySize(self.leaderDisplaySizes) + + def handleBrailleInfo( + self, + name: str | None = None, + numCells: int = 0, + origin: int | None = None, + ) -> None: + if not self.leaders.get(origin): + return + self.leaders[origin]["braille_name"] = name + self.leaders[origin]["braille_numCells"] = numCells + self.setDisplaySize() + + def _filterUnsupportedSpeechCommands(self, speechSequence: list[Any]) -> list[Any]: + """Remove unsupported speech commands from a sequence. + + Filters out commands that cannot be properly serialized or executed remotely, + like callback commands and cancellable commands. + + Returns: + Filtered sequence containing only supported speech commands + """ + return list([item for item in speechSequence if not isinstance(item, EXCLUDED_SPEECH_COMMANDS)]) + + def sendSpeech(self, speechSequence: list[Any], priority: str | None) -> None: + """Forward speech output to connected leader instances. + + Filters the speech sequence for supported commands and sends it + to leader instances for speaking. + """ + self.transport.send( + RemoteMessageType.SPEAK, + sequence=self._filterUnsupportedSpeechCommands( + speechSequence, + ), + priority=priority, + ) + + def pauseSpeech(self, switch: bool) -> None: + """Toggle speech pause state on leader instances.""" + self.transport.send(type=RemoteMessageType.PAUSE_SPEECH, switch=switch) + + def display(self, cells: list[int]) -> None: + """Forward braille display content to leader instances. + + Only sends braille data if there are connected leaders with braille displays. + """ + # Only send braille data when there are controlling machines with a braille display + if self.hasBrailleLeaders(): + self.transport.send(type=RemoteMessageType.DISPLAY, cells=cells) + + def hasBrailleLeaders(self) -> bool: + """Check if any connected leaders have braille displays. + + Returns: + True if at least one leader has a braille display with cells > 0 + """ + return bool([i for i in self.leaderDisplaySizes if i > 0]) + + +class LeaderSession(RemoteSession): + """Session that runs on the controlling (leader) NVDA instance. + + :ivar followers: Information about connected follower clients + :note: Handles: + - Control command sending + - Remote output reception + - Sound notification playback + - Client connection management + - Braille display sync + - Input handling patches + """ + + mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.LEADER + followers: dict[int, dict[str, Any]] # Information about connected follower + + def __init__( + self, + localMachine: LocalMachine, + transport: RelayTransport, + ) -> None: + super().__init__(localMachine, transport) + self.followers = defaultdict(dict) + self.transport.registerInbound( + RemoteMessageType.SPEAK, + self.localMachine.speak, + ) + self.transport.registerInbound( + RemoteMessageType.CANCEL, + self.localMachine.cancelSpeech, + ) + self.transport.registerInbound( + RemoteMessageType.PAUSE_SPEECH, + self.localMachine.pauseSpeech, + ) + self.transport.registerInbound( + RemoteMessageType.TONE, + self.localMachine.beep, + ) + self.transport.registerInbound( + RemoteMessageType.WAVE, + self.localMachine.playWave, + ) + self.transport.registerInbound( + RemoteMessageType.DISPLAY, + self.localMachine.display, + ) + self.transport.registerInbound( + RemoteMessageType.NVDA_NOT_CONNECTED, + self.handleNVDANotConnected, + ) + self.transport.registerInbound( + RemoteMessageType.CHANNEL_JOINED, + self.handleChannelJoined, + ) + self.transport.registerInbound( + RemoteMessageType.SET_BRAILLE_INFO, + self.sendBrailleInfo, + ) + + def registerCallbacks(self) -> None: + if self.callbacksAdded: + return + braille.displayChanged.register(self.sendBrailleInfo) + braille.displaySizeChanged.register(self.sendBrailleInfo) + self.callbacksAdded = True + + def unregisterCallbacks(self) -> None: + if not self.callbacksAdded: + return + braille.displayChanged.unregister(self.sendBrailleInfo) + braille.displaySizeChanged.unregister(self.sendBrailleInfo) + self.callbacksAdded = False + + def handleNVDANotConnected(self) -> None: + log.warning("Attempted to connect to remote NVDA that is not available") + speech.cancelSpeech() + ui.message( + # Translators: Message for when the remote NVDA is not connected + _("Remote NVDA not connected."), + ) + + def handleChannelJoined( + self, + channel: str, + clients: list[dict[str, Any]] | None = None, + origin: int | None = None, + ) -> None: + if clients is None: + clients = [] + for client in clients: + self.handleClientConnected(client) + + def handleClientConnected(self, client: dict[str, Any] | None = None): + hasFollowers = bool(self.followers) + super().handleClientConnected(client) + self.sendBrailleInfo() + if not hasFollowers: + self.registerCallbacks() + + def handleClientDisconnected(self, client: dict[str, Any] | None = None): + """Handle client disconnection. + Also calls parent class disconnection handler. + """ + super().handleClientDisconnected(client) + if self.callbacksAdded and not self.followers: + self.unregisterCallbacks() + + def sendBrailleInfo( + self, + display: braille.BrailleDisplayDriver | None = None, + displaySize: int | None = None, + ) -> None: + if display is None: + display = braille.handler.display + if displaySize is None: + displaySize = braille.handler.displaySize + log.debug( + "Sending braille info to follower - display: %s, size: %d", + display.name if display else "None", + displaySize if displaySize else 0, + ) + self.transport.send( + type=RemoteMessageType.SET_BRAILLE_INFO, + name=display.name, + numCells=displaySize, + ) + + def handleDecideExecuteGesture( + self, + gesture: braille.BrailleDisplayGesture | brailleInput.BrailleInputGesture, + ) -> bool: + """Handle and forward braille gestures to remote client. + + :param gesture: Braille display or input gesture to process + :return: False if gesture was processed and sent, True otherwise + :note: Extracts gesture details and script info before sending + """ + if isinstance(gesture, (braille.BrailleDisplayGesture, brailleInput.BrailleInputGesture)): + dict = { + key: gesture.__dict__[key] + for key in gesture.__dict__ + if isinstance(gesture.__dict__[key], (int, str, bool)) + } + if gesture.script: + name = scriptHandler.getScriptName(gesture.script) + if name.startswith("kb"): + location = ["globalCommands", "GlobalCommands"] + else: + location = scriptHandler.getScriptLocation(gesture.script).rsplit(".", 1) + dict["scriptPath"] = location + [name] + else: + scriptData = None + maps = [inputCore.manager.userGestureMap, inputCore.manager.localeGestureMap] + if braille.handler.display.gestureMap: + maps.append(braille.handler.display.gestureMap) + for map in maps: + for identifier in gesture.identifiers: + try: + scriptData = next(map.getScriptsForGesture(identifier)) + break + except StopIteration: + continue + if scriptData: + dict["scriptPath"] = [scriptData[0].__module__, scriptData[0].__name__, scriptData[1]] + if hasattr(gesture, "source") and "source" not in dict: + dict["source"] = gesture.source + if hasattr(gesture, "model") and "model" not in dict: + dict["model"] = gesture.model + if hasattr(gesture, "id") and "id" not in dict: + dict["id"] = gesture.id + elif hasattr(gesture, "identifiers") and "identifiers" not in dict: + dict["identifiers"] = gesture.identifiers + if hasattr(gesture, "dots") and "dots" not in dict: + dict["dots"] = gesture.dots + if hasattr(gesture, "space") and "space" not in dict: + dict["space"] = gesture.space + if hasattr(gesture, "routingIndex") and "routingIndex" not in dict: + dict["routingIndex"] = gesture.routingIndex + self.transport.send(type=RemoteMessageType.BRAILLE_INPUT, **dict) + return False + else: + return True + + def registerBrailleInput(self) -> None: + """Register handler for braille input gestures. + + :note: Connects to inputCore's gesture execution decision point + """ + inputCore.decide_executeGesture.register(self.handleDecideExecuteGesture) + + def unregisterBrailleInput(self) -> None: + """Unregister handler for braille input gestures. + + :note: Disconnects from inputCore's gesture execution decision point + """ + inputCore.decide_executeGesture.unregister(self.handleDecideExecuteGesture) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py new file mode 100644 index 00000000000..7a58e89308d --- /dev/null +++ b/source/remoteClient/transport.py @@ -0,0 +1,724 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Network transport layer for NVDA Remote. + +This module provides the core networking functionality for NVDA Remote. + +Classes: + Transport: Base class defining the transport interface + TCPTransport: Implementation of secure TCP socket transport + RelayTransport: Extended TCP transport for relay server connections + ConnectorThread: Helper class for connection management + +The transport layer handles: + * Secure socket connections with SSL/TLS + * Message serialization and deserialization + * Connection management and reconnection + * Event notifications for connection state changes + * Message routing based on RemoteMessageType enum + +All network operations run in background threads, while message handlers +are called on the main wxPython thread for thread-safety. +""" + +from abc import ABC, abstractmethod +import hashlib +import select +import socket +import ssl +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass +from logging import getLogger +from queue import Queue +from typing import Any, Literal, Optional, Self + +import wx +from extensionPoints import Action, HandlerRegistrar + +from . import configuration +from .connectionInfo import ConnectionInfo +from .protocol import PROTOCOL_VERSION, RemoteMessageType, hostPortToAddress +from .serializer import Serializer + +log = getLogger("transport") + + +@dataclass +class RemoteExtensionPoint: + """Bridges local extension points to remote message sending. + + This class connects local NVDA extension points to the remote transport layer, + allowing local events to trigger remote messages with optional argument transformation. + + :note: The filter function, if provided, should take (*args, **kwargs) and return + a new kwargs dict to be sent in the message. + """ + + extensionPoint: HandlerRegistrar + """The NVDA extension point to bridge""" + + messageType: RemoteMessageType + """The remote message type to send""" + + filter: Optional[Callable[..., dict[str, Any]]] = None + """Optional function to transform arguments before sending""" + + transport: Optional["Transport"] = None + """The transport instance (set on registration)""" + + def remoteBridge(self, *args: Any, **kwargs: Any) -> Literal[True]: + """Bridge function that gets registered to the extension point. + + Handles calling the filter if present and sending the message. + + :param args: Positional arguments from the extension point + :param kwargs: Keyword arguments from the extension point + :return: Always returns True to allow other handlers to process the event + """ + if self.filter is not None: + # Filter should transform args/kwargs into just the kwargs needed for the message + kwargs = self.filter(*args, **kwargs) + if self.transport is not None: + self.transport.send(self.messageType, **kwargs) + return True + + def register(self, transport: "Transport") -> None: + """Register this bridge with a transport and the extension point.""" + self.transport = transport + self.extensionPoint.register(self.remoteBridge) + + def unregister(self) -> None: + """Unregister this bridge from the extension point.""" + self.extensionPoint.unregister(self.remoteBridge) + + +class Transport(ABC): + """Base class defining the network transport interface for NVDA Remote. + + This abstract base class defines the interface that all network transports must implement. + It provides core functionality for secure message passing, connection management, + and event handling between NVDA instances. + + The Transport class handles: + + * Message serialization and routing using a pluggable serializer + * Connection state management and event notifications + * Registration of message type handlers + * Thread-safe connection events + + To implement a new transport: + + 1. Subclass Transport + 2. Implement connection logic in run() + 3. Call onTransportConnected() when connected + 4. Use send() to transmit messages + 5. Call appropriate event notifications + + Example: + >>> serializer = JSONSerializer() + >>> transport = TCPTransport(serializer, ("localhost", 8090)) + >>> transport.registerInbound(RemoteMessageType.key, handle_key) + >>> transport.run() + """ + + def __init__(self, serializer: Serializer) -> None: + """Initialize the transport. + + :param serializer: The serializer instance to use for message encoding/decoding + """ + self.serializer: Serializer = serializer + """The message serializer instance""" + + self.connected: bool = False + """ True if transport has an active connection """ + + self.successfulConnects: int = 0 + """ Counter of successful connection attempts """ + + self.connectedEvent: threading.Event = threading.Event() + """ Event that is set when connected """ + + self.inboundHandlers: dict[RemoteMessageType, Action] = {} + """ Registered message handlers """ + + self.outboundHandlers: dict[RemoteMessageType, RemoteExtensionPoint] = {} + """ Registered message handlers for outgoing messages""" + + self.transportConnected: Action = Action() + """Notifies when the transport is connected""" + + self.transportDisconnected: Action = Action() + """Notifies when the transport is disconnected""" + + self.transportCertificateAuthenticationFailed: Action = Action() + """Notifies when the transport fails to authenticate the certificate""" + + self.transportConnectionFailed: Action = Action() + """ Notifies when the transport fails to connect """ + + self.transportClosing: Action = Action() + """ Notifies when the transport is closing """ + + @abstractmethod + def run(self) -> None: + """Connection logic for this transport.""" + ... + + def onTransportConnected(self) -> None: + """Handle successful transport connection. + + :note: Called internally when connection established: + - Increments successful connection counter + - Sets connected flag to True + - Sets connected event + - Notifies transportConnected listeners + """ + self.successfulConnects += 1 + self.connected = True + self.connectedEvent.set() + self.transportConnected.notify() + + def registerInbound(self, type: RemoteMessageType, handler: Callable[..., None]) -> None: + """Register a handler for incoming messages of a specific type. + + :param type: The message type to handle + :param handler: Callback function to process messages of this type + :note: Multiple handlers can be registered for the same type. + Handlers are called asynchronously on wx main thread via CallAfter. + Handler will receive message payload as kwargs. + :example: + >>> def handleKeypress(keyCode, pressed): + ... print(f"Key {keyCode} {'pressed' if pressed else 'released'}") + >>> transport.registerInbound(RemoteMessageType.KEY, handleKeypress) + """ + if type not in self.inboundHandlers: + log.debug(f"Creating new handler for {type}") + self.inboundHandlers[type] = Action() + log.debug(f"Registering handler for {type}") + self.inboundHandlers[type].register(handler) + + def unregisterInbound(self, type: RemoteMessageType, handler: Callable) -> None: + """Remove a previously registered message handler. + + :param type: The message type to unregister from + :param handler: The handler function to remove + :note: If handler was not registered, this is a no-op + """ + self.inboundHandlers[type].unregister(handler) + log.debug(f"Unregistered handler for {type}") + + def registerOutbound( + self, + extensionPoint: HandlerRegistrar, + messageType: RemoteMessageType, + filter: Optional[Callable[..., dict[str, Any]]] = None, + ) -> None: + """Register an extension point to a message type. + + :param extensionPoint: The extension point to register + :param messageType: The message type to register the extension point to + :param filter: Optional function to transform message before sending + :note: Filter function should take (*args, **kwargs) and return new kwargs dict + """ + remoteExtension = RemoteExtensionPoint( + extensionPoint=extensionPoint, + messageType=messageType, + filter=filter, + ) + remoteExtension.register(self) + self.outboundHandlers[messageType] = remoteExtension + + def unregisterOutbound(self, messageType: RemoteMessageType) -> None: + """Unregister an extension point from a message type. + + Args: + messageType (RemoteMessageType): The message type to unregister the extension point from + """ + self.outboundHandlers[messageType].unregister() + del self.outboundHandlers[messageType] + + @abstractmethod + def send(self, type: RemoteMessageType, **kwargs: Any) -> None: + """Send a message through this transport. + + :param type: Message type, typically a :class:`~remoteClient.protocol.RemoteMessageType` enum value. + :param kwargs: Message payload data to serialize. + """ + ... + + +class TCPTransport(Transport): + """Secure TCP socket transport implementation. + + This class implements the Transport interface using TCP sockets with SSL/TLS + encryption. It handles connection establishment, data transfer, and connection + lifecycle management. + """ + + def __init__( + self, + serializer: Serializer, + address: tuple[str, int], + timeout: int = 0, + insecure: bool = False, + ) -> None: + """Initialize the TCP transport. + + :param serializer: Message serializer instance + :param address: Remote address to connect to, as (host, port) tuple + :param timeout: Connection timeout in seconds, defaults to 0 + :param insecure: Skip certificate verification, defaults to False + """ + super().__init__(serializer=serializer) + self.closed: bool = False + """Whether transport is closed""" + + self.buffer = b"" + """ Buffer to hold partially received data """ + + self.queue = Queue() + """ Queue of outbound messages """ + + self.address = address + """ Remote address to connect to """ + + self.serverSock = None + """ The SSL socket connection """ + + # Reading/writing from an SSL socket is not thread safe, so guard access to the socket with a lock. + # See https://bugs.python.org/issue41597#msg375692 + self.serverSockLock = threading.Lock() + """ Lock for thread-safe socket access """ + + self.queueThread: threading.Thread | None = None + """ Thread handling outbound messages """ + + self.timeout: int = timeout + """ Connection timeout in seconds """ + + self.reconnectorThread: ConnectorThread = ConnectorThread(self) + """ Thread managing reconnection """ + + self.insecure: bool = insecure + """Whether to skip certificate verification""" + + def run(self) -> None: + """ + Establishes a connection to the server and manages the transport lifecycle. + + This method attempts to create and connect an outbound socket to the server + using the provided address. If SSL certificate verification fails, it checks + if the host fingerprint is trusted. If trusted, it retries the connection + with insecure mode enabled. If not, it notifies about the certificate + authentication failure and raises the exception. For other exceptions, it + notifies about the connection failure and raises the exception. + + Once connected, it triggers the transport connected event, starts the queue + thread, and enters the read loop. Upon disconnection, it clears the connected + event, notifies about the transport disconnection, and performs cleanup. + + Raises: + ssl.SSLCertVerificationError: If SSL certificate verification fails and + the fingerprint is not trusted. + Exception: For any other exceptions during the connection process. + """ + self.closed = False + try: + self.serverSock = self.createOutboundSocket( + *self.address, + insecure=self.insecure, + ) + self.serverSock.connect(self.address) + except ssl.SSLCertVerificationError: + fingerprint = None + try: + fingerprint = self.getHostFingerprint() + except Exception: + pass + if self.isFingerprintTrusted(fingerprint): + self.insecure = True + return self.run() + self.lastFailFingerprint = fingerprint + self.transportCertificateAuthenticationFailed.notify() + raise + except Exception: + self.transportConnectionFailed.notify() + raise + self.onTransportConnected() + self.startQueueThread() + self._readLoop() + self.connected = False + self.connectedEvent.clear() + self.transportDisconnected.notify() + self._disconnect() + + def isFingerprintTrusted(self, fingerprint: str) -> bool: + """Check if the fingerprint is trusted. + + :param fingerprint: The fingerprint to check + :return: True if the fingerprint is trusted, False otherwise + """ + config = configuration.getRemoteConfig() + return ( + hostPortToAddress(self.address) in config["trusted_certs"] + and config["trusted_certs"][hostPortToAddress(self.address)] == fingerprint + ) + + def getHostFingerprint(self) -> str: + tempConnection = self.createOutboundSocket(*self.address, insecure=True) + tempConnection.connect(self.address) + certBin = tempConnection.getpeercert(True) + tempConnection.close() + fingerprint = hashlib.sha256(certBin).hexdigest().lower() + return fingerprint + + def startQueueThread(self) -> None: + """Start the outbound message queue thread.""" + if self.queueThread and self.queueThread.is_alive(): + return + self.queueThread = threading.Thread(target=self.sendQueue, name="queue_thread") + self.queueThread.daemon = True + self.queueThread.start() + + def _readLoop(self) -> None: + """Main loop for reading data from the server socket.""" + while self.serverSock is not None: + try: + readers, writers, error = select.select( + [self.serverSock], + [], + [self.serverSock], + ) + except socket.error: + self.buffer = b"" + break + if self.serverSock in error: + self.buffer = b"" + break + if self.serverSock in readers: + try: + self.processIncomingSocketData() + except socket.error: + self.buffer = b"" + break + + def createOutboundSocket( + self, + host: str, + port: int, + insecure: bool = False, + ) -> ssl.SSLSocket: + """Create and configure an SSL socket for outbound connections. + + Creates a TCP socket with appropriate timeout and keep-alive settings, + then wraps it with SSL/TLS encryption. + + :param host: Remote hostname to connect to + :param port: Remote port number + :param insecure: Skip certificate verification, defaults to False + :return: Configured SSL socket ready for connection + :note: The socket is created but not yet connected. Call connect() separately. + :raises socket.error: If socket creation fails + :raises ssl.SSLError: If SSL/TLS setup fails + """ + if host.lower().endswith(".onion"): + serverSock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + else: + address = socket.getaddrinfo(host, port)[0] + serverSock = socket.socket(*address[:3]) + if self.timeout: + serverSock.settimeout(self.timeout) + serverSock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + serverSock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, 60000, 2000)) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + if insecure: + ctx.verify_mode = ssl.CERT_NONE + log.warn(f"Skipping certificate verification for {host}:{port}") + ctx.check_hostname = not insecure + ctx.load_default_certs() + + serverSock = ctx.wrap_socket(sock=serverSock, server_hostname=host) + return serverSock + + def getpeercert( + self, + binaryForm: bool = False, + ) -> dict[str, Any] | bytes | None: + """Get the certificate from the peer. + + Retrieves the certificate presented by the remote peer during SSL handshake. + + :param binaryForm: If True, return the raw certificate bytes, if False return a parsed dictionary, defaults to False + :return: The peer's certificate, or None if not connected + :raises ssl.SSLError: If certificate retrieval fails + """ + if self.serverSock is None: + return None + return self.serverSock.getpeercert(binaryForm) + + def processIncomingSocketData(self) -> None: + """Process incoming data from the server socket. + + Reads data from the socket in chunks, handling partial messages and SSL behavior. + Complete messages are passed to parse() for processing. + + :note: Uses non-blocking reads with SSL and 16KB buffer size + :raises socket.error: If socket read fails + :raises ssl.SSLWantReadError: If no SSL data is available + """ + # This approach may be problematic: + # See also server.py handle_data in class Client. + buffSize = 16384 + with self.serverSockLock: + # select operates on the raw socket. Even though it said there was data to + # read, that might be SSL data which might not result in actual data for + # us. Therefore, do a non-blocking read so SSL doesn't try to wait for + # more data for us. + # We don't make the socket non-blocking earlier because then we'd have to + # handle retries during the SSL handshake. + # See https://stackoverflow.com/questions/3187565/select-and-ssl-in-python + # and https://docs.python.org/3/library/ssl.html#notes-on-non-blocking-sockets + self.serverSock.setblocking(False) + try: + data = self.buffer + self.serverSock.recv(buffSize) + except ssl.SSLWantReadError: + # There's no data for us. + return + finally: + self.serverSock.setblocking(True) + self.buffer = b"" + if not data: + self._disconnect() + return + if b"\n" not in data: + self.buffer += data + return + while b"\n" in data: + line, sep, data = data.partition(b"\n") + self.parse(line) + self.buffer += data + + def parse(self, line: bytes) -> None: + """Parse and handle a complete message line. + + Deserializes message and routes to appropriate handler based on type. + + :param line: Complete message line to parse + :raises ValueError: If message type is invalid + :note: Messages require 'type' field matching RemoteMessageType + :note: Handlers execute asynchronously on wx main thread + """ + obj = self.serializer.deserialize(line) + if "type" not in obj: + log.warn(f"Received message without type: {obj!r}") + return + try: + messageType = RemoteMessageType(obj["type"]) + except ValueError: + log.warn(f"Received message with invalid type: {obj!r}") + return + del obj["type"] + extensionPoint = self.inboundHandlers.get(messageType) + if not extensionPoint: + log.warn(f"Received message with unhandled type: {messageType} {obj!r}") + return + wx.CallAfter(extensionPoint.notify, **obj) + + def sendQueue(self) -> None: + """Background thread that processes the outbound message queue. + + :note: Runs in separate thread with thread-safe socket access via serverSockLock + :note: Exits on receiving None or socket error + :raises socket.error: If sending data fails + """ + while True: + item = self.queue.get() + if item is None: + return + try: + with self.serverSockLock: + self.serverSock.sendall(item) + except socket.error: + return + + def send(self, type: RemoteMessageType, **kwargs: Any) -> None: + """Send a message through the transport. + + :param type: Message type, typically a RemoteMessageType enum value + :param kwargs: Message payload data to serialize + :note: Thread-safe and can be called from any thread + :note: Messages are dropped if transport is not connected + """ + if self.connected: + obj = self.serializer.serialize(type=type, **kwargs) + self.queue.put(obj) + else: + log.error(f"Attempted to send message {type} while not connected") + + def _disconnect(self) -> None: + """Internal method to disconnect the transport. + + :note: Called internally on errors, unlike close() which is called explicitly + :note: Cleans up queue thread and socket without stopping connector thread + """ + if self.queueThread is not None: + self.queue.put(None) + self.queueThread.join() + self.queueThread = None + clearQueue(self.queue) + if self.serverSock: + self.serverSock.close() + self.serverSock = None + + def close(self): + """Close the transport and stop all threads. + + :note: Stops reconnector thread and cleans up all resources + """ + self.transportClosing.notify() + self.reconnectorThread.running = False + self._disconnect() + self.closed = True + self.reconnectorThread = ConnectorThread(self) + + +class RelayTransport(TCPTransport): + """Transport for connecting through a relay server. + + Extends TCPTransport with relay-specific protocol handling for channels + and connection types. Manages protocol versioning and channel joining. + """ + + def __init__( + self, + serializer: Serializer, + address: tuple[str, int], + timeout: int = 0, + channel: str | None = None, + connectionType: str | None = None, + protocolVersion: int = PROTOCOL_VERSION, + insecure: bool = False, + ) -> None: + """Initialize a new RelayTransport instance. + + :param serializer: Serializer for encoding/decoding messages + :param address: Tuple of (host, port) to connect to + :param timeout: Connection timeout in seconds, defaults to 0 + :param channel: Channel name to join, defaults to ``None`` + :param connectionType: Connection type identifier, defaults to ``None`` + :param protocolVersion: Protocol version to use, defaults to :const:`PROTOCOL_VERSION` + :param insecure: Whether to skip certificate verification, defaults to ``False`` + """ + super().__init__( + address=address, + serializer=serializer, + timeout=timeout, + insecure=insecure, + ) + log.info(f"Connecting to {address} channel {channel}") + self.channel: str | None = channel + """Relay channel name""" + + self.connectionType: str | None = connectionType + """ Type of relay connection """ + + self.protocolVersion: int = protocolVersion + """ Protocol version in use """ + + self.transportConnected.register(self.onConnected) + + @classmethod + def create(cls, connectionInfo: ConnectionInfo, serializer: Serializer) -> Self: + """Create a RelayTransport from a ConnectionInfo object. + + :param connectionInfo: ConnectionInfo instance containing connection details + :param serializer: Serializer instance for message encoding/decoding + :return: Configured RelayTransport instance ready for connection + """ + return cls( + serializer=serializer, + address=(connectionInfo.hostname, connectionInfo.port), + channel=connectionInfo.key, + connectionType=connectionInfo.mode, + insecure=connectionInfo.insecure, + ) + + def onConnected(self) -> None: + """Handle successful connection to relay server. + + Called automatically when transport connects. Sends protocol version and + either joins channel or requests key generation. + + :raises ValueError: If protocol version is invalid + """ + self.send(RemoteMessageType.PROTOCOL_VERSION, version=self.protocolVersion) + if self.channel is not None: + self.send( + RemoteMessageType.JOIN, + channel=self.channel, + connection_type=self.connectionType, + ) + else: + self.send(RemoteMessageType.GENERATE_KEY) + + +class ConnectorThread(threading.Thread): + """Background thread that manages connection attempts. + + Handles automatic reconnection with configurable delay between attempts. + Runs until explicitly stopped. + + To stop, set :attr:`running` to ``False``. + """ + + def __init__(self, connector: Transport, reconnectDelay: int = 5) -> None: + """Initialize the connector thread. + + :param connector: Transport instance to manage connections for + :param reconnectDelay: Seconds between attempts, defaults to 5 + """ + super().__init__() + self.reconnectDelay: int = reconnectDelay + """Seconds to wait between connection attempts""" + + self.running: bool = True + """Whether thread should continue running""" + + self.connector: Transport = connector + """Transport to manage connections for""" + + self.name = self.name + "_connector_loop" + self.daemon = True + + def run(self): + while self.running: + try: + self.connector.run() + except socket.error: + time.sleep(self.reconnectDelay) + continue + else: + time.sleep(self.reconnectDelay) + log.info(f"Ending control connector thread {self.name}") + + +def clearQueue(queue: Queue[bytes | None]) -> None: + """Empty all items from a queue without blocking. + + Removes all items from the queue in a non-blocking way, + useful for cleaning up before disconnection. + + :param queue: Queue instance to clear + :note: This function catches and ignores any exceptions that occur + while trying to get items from an empty queue. + """ + try: + while True: + queue.get_nowait() + except Exception: + pass diff --git a/source/remoteClient/urlHandler.py b/source/remoteClient/urlHandler.py new file mode 100644 index 00000000000..52a415d57c7 --- /dev/null +++ b/source/remoteClient/urlHandler.py @@ -0,0 +1,129 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +""" +URL Handler Module for NVDARemote +This module provides functionality for launching NVDARemote connections via custom 'nvdaremote://' URLs. + +Key Components: +- URL registration and unregistration utilities for Windows registry +- Parsing and handling of NVDARemote connection URLs + +Main Functions: + +-:func:`registerURLHandler`: + Registers the NVDARemote URL protocol in the Windows Registry +:func:`unregisterURLHandler`: + Removes the NVDARemote URL protocol registration +:func:`URLHandlerPath`: + Returns the path to the URL handler executable +""" + +import os +import sys +import winreg + + +_REGISTRY_KEY_PATH: str = r"SOFTWARE\Classes\nvdaremote" + + +def _createRegistryStructure(keyHandle: winreg.HKEYType, data: dict): + """Creates a nested registry structure from a dictionary. + + :param keyHandle: A handle to an open registry key + :param data: Dictionary containing the registry structure to create + :raises OSError: If creating registry keys or setting values fails + """ + for name, value in data.items(): + if isinstance(value, dict): + # Create and recursively populate subkey + try: + subkey = winreg.CreateKey(keyHandle, name) + try: + _createRegistryStructure(subkey, value) + finally: + winreg.CloseKey(subkey) + except WindowsError as e: + raise OSError(f"Failed to create registry subkey {name}: {e}") + else: + # Set value + try: + winreg.SetValueEx(keyHandle, name, 0, winreg.REG_SZ, str(value)) + except WindowsError as e: + raise OSError(f"Failed to set registry value {name}: {e}") + + +def _deleteRegistryKeyRecursive(baseKey: int, subkeyPath: str): + """Recursively deletes a registry key and all its subkeys. + + :param baseKey: One of the HKEY_* constants from winreg + :param subkeyPath: Full registry path to the key to delete + :raises OSError: If deletion fails for reasons other than key not found + """ + try: + # Try to delete directly first + winreg.DeleteKey(baseKey, subkeyPath) + except WindowsError: + # If that fails, need to do recursive deletion + try: + with winreg.OpenKey(baseKey, subkeyPath, 0, winreg.KEY_READ | winreg.KEY_WRITE) as key: + # Enumerate and delete all subkeys + while True: + try: + subkeyName = winreg.EnumKey(key, 0) + fullPath = f"{subkeyPath}\\{subkeyName}" + _deleteRegistryKeyRecursive(baseKey, fullPath) + except WindowsError: + break + # Now delete the key itself + winreg.DeleteKey(baseKey, subkeyPath) + except WindowsError as e: + if e.winerror != 2: # ERROR_FILE_NOT_FOUND + raise OSError(f"Failed to delete registry key {subkeyPath}: {e}") + + +def registerURLHandler(): + """Registers the nvdaremote:// URL protocol handler in the Windows Registry. + + :raises OSError: If registration in the registry fails + """ + try: + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, _REGISTRY_KEY_PATH) as key: + _createRegistryStructure(key, URL_HANDLER_REGISTRY) + except OSError as e: + raise OSError(f"Failed to register URL handler: {e}") + + +def unregisterURLHandler(): + """Unregisters the nvdaremote:// URL protocol handler from the Windows Registry. + + :raises OSError: If unregistration from the registry fails + """ + try: + _deleteRegistryKeyRecursive(winreg.HKEY_CURRENT_USER, _REGISTRY_KEY_PATH) + except OSError as e: + raise OSError(f"Failed to unregister URL handler: {e}") + + +def URLHandlerPath(): + """Returns the absolute path to the URL handler executable. + + :return: Full path to url_handler.exe + :rtype: str + """ + return os.path.join(os.path.split(os.path.abspath(__file__))[0], "url_handler.exe") + + +# Registry structure definition +URL_HANDLER_REGISTRY = { + "URL Protocol": "", + "shell": { + "open": { + "command": { + "": '"{path}" handleRemoteURL %1'.format(path=os.path.join(sys.prefix, "nvda_slave.exe")), + }, + }, + }, +} diff --git a/source/setup.py b/source/setup.py index af21ba463ec..97b16e8644a 100755 --- a/source/setup.py +++ b/source/setup.py @@ -232,6 +232,8 @@ def _genManifestTemplate(shouldHaveUIAccess: bool) -> tuple[int, int, bytes]: # multiprocessing isn't going to work in a frozen environment "multiprocessing", "concurrent.futures.process", + # Tomli is part of Python 3.11 as Tomlib, but is imported as tomli by cryptography, which causes an infinite loop in py2exe + "tomli", ], "packages": [ "NVDAObjects", diff --git a/source/speech/__init__.py b/source/speech/__init__.py index 7090bd8ab50..90655ac9c60 100644 --- a/source/speech/__init__.py +++ b/source/speech/__init__.py @@ -1,7 +1,7 @@ # 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) 2006-2023 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Babbage B.V., Bill Dengler, +# Copyright (C) 2006-2025 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Babbage B.V., Bill Dengler, # Julien Cochuyt, Leonard de Ruijter from .speech import ( @@ -63,7 +63,7 @@ spellTextInfo, splitTextIndentation, ) -from .extensions import speechCanceled, post_speechPaused +from .extensions import speechCanceled, post_speechPaused, pre_speechQueued from .priorities import Spri from .types import ( @@ -143,6 +143,7 @@ "splitTextIndentation", "speechCanceled", "post_speechPaused", + "pre_speechQueued", ] import synthDriverHandler diff --git a/source/speech/extensions.py b/source/speech/extensions.py index ae74f3115a1..240c5094350 100644 --- a/source/speech/extensions.py +++ b/source/speech/extensions.py @@ -1,7 +1,7 @@ # 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) 2023 NV Access Limited, Leonard de Ruijter +# Copyright (C) 2023-2025 NV Access Limited, Leonard de Ruijter """ Extension points for speech. @@ -51,3 +51,14 @@ :param value: the speech sequence to be filtered. :type value: SpeechSequence """ + +pre_speechQueued = Action() +""" +Notifies when a speech sequence is about to be queued for synthesis. + +@param speechSequence: The fully processed sequence of text and speech commands ready for synthesis +@type speechSequence: SpeechSequence + +@param priority: The priority level for this speech sequence +@type priority: priorities.Spri +""" diff --git a/source/speech/manager.py b/source/speech/manager.py index d5b374ccafb..bfe4d0961e8 100644 --- a/source/speech/manager.py +++ b/source/speech/manager.py @@ -2,7 +2,7 @@ # 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) 2006-2021 NV Access Limited +# Copyright (C) 2006-2025 NV Access Limited import typing import queueHandler @@ -19,7 +19,7 @@ IndexCommand, _CancellableSpeechCommand, ) - +from .extensions import pre_speechQueued from .priorities import Spri, SPEECH_PRIORITIES from logHandler import log from synthDriverHandler import getSynth, pre_synthSpeak @@ -243,6 +243,7 @@ def _hasNoMoreSpeech(self): def speak(self, speechSequence: SpeechSequence, priority: Spri): log._speechManagerUnitTest("speak (priority %r): %r", priority, speechSequence) + pre_speechQueued.notify(speechSequence=speechSequence, priority=priority) interrupt = self._queueSpeechSequence(speechSequence, priority) self._doRemoveCancelledSpeechCommands() # If speech isn't already in progress, we need to push the first speech. diff --git a/source/tones.py b/source/tones.py index 1e9286a56ae..f2aaafe4ffa 100644 --- a/source/tones.py +++ b/source/tones.py @@ -1,16 +1,22 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2007-2024 NV Access Limited, Aleksey Sadovoy, Leonard de Ruijter, Babbage B.V. +# Copyright (C) 2007-2025 NV Access Limited, Aleksey Sadovoy, Leonard de Ruijter, Babbage B.V. # This file is covered by the GNU General Public License. # See the file COPYING for more details. """Utilities to generate and play tones""" import atexit -import nvwave -import config -from logHandler import log +import collections +import threading +import time from ctypes import create_string_buffer +from typing import TypeAlias +import collections.abc + +import config import extensionPoints +import nvwave +from logHandler import log SAMPLE_RATE = 44100 @@ -87,3 +93,31 @@ def beep( generateBeep(buf, hz, length, left, right) player.stop() player.feed(buf.raw) + + +BeepSequenceElement: TypeAlias = int | tuple[int, int] # Either delay_ms or (frequency_hz, duration_ms) +BeepSequence: TypeAlias = collections.abc.Iterable[BeepSequenceElement] + + +def beepSequence(*sequence: BeepSequenceElement) -> None: + """Play a simple synchronous monophonic beep sequence + A beep sequence is an iterable containing one of two kinds of elements. + An element consisting of a tuple of two items is interpreted as a frequency and duration. Note, this function plays beeps synchronously, unlike tones.beep + A single integer is assumed to be a delay in ms. + """ + for element in sequence: + if not isinstance(element, collections.abc.Sequence): + time.sleep(float(element) / 1000) + else: + freq, duration = element + time.sleep(float(duration) / 1000) + beep(freq, duration) + + +def beepSequenceAsync(*sequence: BeepSequenceElement) -> threading.Thread: + """Play an asynchronous beep sequence. + This is the same as `beepSequence`, except it runs in a thread.""" + thread = threading.Thread(target=beepSequence, args=sequence) + thread.daemon = True + thread.start() + return thread diff --git a/source/waves/clipboardPush.wav b/source/waves/clipboardPush.wav new file mode 100644 index 00000000000..6274d987e56 Binary files /dev/null and b/source/waves/clipboardPush.wav differ diff --git a/source/waves/clipboardReceive.wav b/source/waves/clipboardReceive.wav new file mode 100644 index 00000000000..3fa72478202 Binary files /dev/null and b/source/waves/clipboardReceive.wav differ diff --git a/source/waves/connected.wav b/source/waves/connected.wav new file mode 100644 index 00000000000..d613e352df1 Binary files /dev/null and b/source/waves/connected.wav differ diff --git a/source/waves/controlled.wav b/source/waves/controlled.wav new file mode 100644 index 00000000000..99838e94f77 Binary files /dev/null and b/source/waves/controlled.wav differ diff --git a/source/waves/controlling.wav b/source/waves/controlling.wav new file mode 100644 index 00000000000..a0999ecb99d Binary files /dev/null and b/source/waves/controlling.wav differ diff --git a/source/waves/disconnected.wav b/source/waves/disconnected.wav new file mode 100644 index 00000000000..1b7ba96cde6 Binary files /dev/null and b/source/waves/disconnected.wav differ diff --git a/tests/manual/remote.md b/tests/manual/remote.md new file mode 100644 index 00000000000..eb57acae9a6 --- /dev/null +++ b/tests/manual/remote.md @@ -0,0 +1,176 @@ +# NVDA Remote Manual Test Suite + +## Overview + +Remote enables remote assistance functionality between two computers running NVDA. It allows a user to connect to another computer running NVDA and either control the remote system or have their system controlled. This enables remote support, training, and collaboration between screen reader users. The add-on provides features such as speech relay, keyboard control, clipboard sharing, and braille support over remote connections. + +## Environment Setup + +### Host Configuration + +- Windows 11 Pro +- Memory: at least 16GB +- Processor: at least 4 core +- NVDA Version: latest +- NVDA Remote Version: 2.6.4 (installed via addon store) + +### Guest Configuration + +- Another computer similar to the host or VMware Windows 11 Home running on the host with similar specs to the host computer +- Storage: 64GB disk +- Memory: 16GB +- Processor: 8 core +- NVDA Version: Custom build from (remote branch) +- Base Position: latest + +## Pre-Test Setup + +1. Build signed launcher +2. Host: Run installed stock NVDA +3. Guest: Install signed launcher + +## Connection Tests + +### Direct Connection + +1. Open NVDA Remote on the host +2. Press NVDA+alt+page-up to open the "Connect" dialog +3. Choose "Host" option +4. Set a password and wait for incoming connection +5. Open NVDA Remote on the guest +6. Press NVDA+alt+page-up to open the "Connect" dialog +7. Choose "Client" option +8. Enter the host's IP address and password +9. Verify connection status announcements or sounds +10. Test reversing roles (host becomes client, client becomes host) + +### Control Server Connection + +1. Open NVDA Remote on both systems +2. On both systems, press NVDA+alt+page-up to open the "Connect" dialog +3. Choose "Connect to Control Server" (nvdaremote.com) +4. Enter the same key on both systems +5. Set appropriate control permissions (Host controls guest, guest controls host) +6. Verify connection is established +7. Disconnect and retry with a different key +8. Verify behavior when server is unavailable +9. Test reconnection after temporary network interruption (by disabling networking or turning wifi off then back on again) +10. Test reversing roles (host becomes client, client becomes host) + +## Version Compatibility Tests + +### New Remote to New Remote + +1. Install the new remote implementation on two test machines +2. Establish connection between the two instances +3. Test all control modes: +4. Verify all features work correctly compared to old plugin: + 1. Speech relay + 2. Remote keyboard input + 3. Clipboard transfer + 4. Braille routing +5. If possible, monitor CPU and memory usage during an extended session +6. Test connection stability during intensive screen reader usage + +### New Remote Controlling Old Plugin + +1. Install the new remote implementation on one machine +2. Install the 2.6.4 plugin on another machine +3. Test connecting from new remote to old plugin system +4. Verify backward compatibility of control features: + 1. Keyboard commands + 2. Speech relay + 3. Clipboard sharing + 4. Braille support +5. Test automatic reconnection behavior +6. Switch control directions and verify functionality + +## Remote Control Features + +### Keyboard Input + +1. Connect two machines with remote control enabled +2. Test basic typing in a text editor +3. Test system shortcuts: + 1. Alt+Tab to switch applications + 2. Windows key to open start menu + 3. Alt+F4 to close applications +4. Test NVDA-specific shortcuts: + 1. NVDA+T to read title + 2. NVDA+F to read formatting + 3. NVDA+Tab for focus reporting +5. Verify modifier key combinations work properly: + 1. Shift+arrows for selection + 2. Ctrl+C and Ctrl+V for copy/paste + 3. Alt key combinations for menu navigation + +## Speech and Braille + +### Speech Relay + +1. Connect two machines with remote control enabled +2. Navigate through various UI elements on controlled machine +3. Verify speech output on controlling machine +4. Test with different speech synthesizers: + 1. eSpeak NG + 2. Windows OneCore + 3. Any third-party synthesizer +5. Measure speech latency and note any issues +6. Test speech interruption behavior (press ctrl when speaking) +7. Verify speech settings respect on host/guest machines + +### Braille Support + +1. Connect a braille display to the controlling machine +2. Establish remote connection between machines +3. Verify braille output appears correctly +4. Test braille cursor routing functions +5. Test braille input commands +6. Verify braille display settings are respected +7. Test with different braille display models if available + +## Special Features + +### Clipboard Sharing + +1. Connect two machines +2. Copy text on the controlling machine (Ctrl+C) +3. Push the clipboard text (NVDA+SHIFT+CTRL+C) +4. Paste text on the controlled machine (Ctrl+V) +5. Repeat in reverse direction +6. Test with various content types: + 1. Plain text + 2. Formatted text + 3. Large text (multiple paragraphs) +7. Verify handling of special characters +8. Test copying and pasting with keyboard shortcuts and context menus + +## Error Handling + +### Connection Issues + +1. Establish remote connection between two machines +2. Temporarily disable network adapter on one machine +3. Verify appropriate error messages are displayed +4. Verify reconnection attempts occur automatically +5. Test behavior when connection times out +6. Verify recovery when network is restored +7. Test disconnection handling when one machine crashes + +### Resource Usage + +1. Establish remote connection between two machines +2. Monitor CPU usage during an extended session (30+ minutes) +3. Monitor memory consumption over time +4. Run resource-intensive applications during connection +5. Verify system stability under load +6. Document any performance degradation +7. Test with different NVDA logging levels + +## Security Tests + +### Authentication + +1. Test connection with valid password +2. Attempt connection with invalid password +3. Test empty password behavior diff --git a/tests/unit/test_remote/__init__.py b/tests/unit/test_remote/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/test_remote/test_bridge.py b/tests/unit/test_remote/test_bridge.py new file mode 100644 index 00000000000..d44a43299db --- /dev/null +++ b/tests/unit/test_remote/test_bridge.py @@ -0,0 +1,122 @@ +# 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. + +import unittest +from remoteClient.bridge import BridgeTransport +from remoteClient.transport import Transport, RemoteMessageType + + +# A fake transport that implements minimal Transport interface for testing BridgeTransport. +class FakeTransport(Transport): + def __init__(self): + class DummySerializer: + def serialize(self, *, type, **kwargs): + return b"dummy" + + def deserialize(self, data): + return {} + + super().__init__(DummySerializer()) + # Override inboundHandlers to be a dict mapping RemoteMessageType -> list of handlers. + self.inboundHandlers = {} + # List to collect sent messages. + self.sentMessages = [] + + def registerInbound(self, messageType, handler): + if messageType in self.inboundHandlers: + self.inboundHandlers[messageType].append(handler) + else: + self.inboundHandlers[messageType] = [handler] + + def unregisterInbound(self, messageType, handler): + if messageType in self.inboundHandlers: + self.inboundHandlers[messageType].remove(handler) + if not self.inboundHandlers[messageType]: + del self.inboundHandlers[messageType] + + def send(self, type, **kwargs): + self.sentMessages.append((type, kwargs)) + + def parse(self, line: bytes): + pass # Not used in these tests. + + def run(self): + pass + + +# Tests for BridgeTransport. +class TestBridgeTransport(unittest.TestCase): + def setUp(self): + self.transport1 = FakeTransport() + self.transport2 = FakeTransport() + # Create a bridge between the two fake transports. + self.bridge = BridgeTransport(self.transport1, self.transport2) + + def test_inboundRegistrationOnInit(self): + # On initialization, both transports should have inbound handlers registered for every RemoteMessageType. + for messageType in list(RemoteMessageType): + self.assertIn( + messageType, + self.transport1.inboundHandlers, + f"{messageType} not registered in transport1", + ) + self.assertIn( + messageType, + self.transport2.inboundHandlers, + f"{messageType} not registered in transport2", + ) + + def test_forwardingMessage(self): + # Choose a message type that is not excluded. + nonExcluded = None + for m in list(RemoteMessageType): + if m not in BridgeTransport.excluded: + nonExcluded = m + break + self.assertIsNotNone(nonExcluded, "There must be at least one non-excluded message type") + # Simulate an inbound message on transport1. + callbacks = self.transport1.inboundHandlers[nonExcluded] + for callback in callbacks: + callback(a=10, b=20) + # Expect that transport2's send() was called with the same message type and payload. + self.assertTrue(len(self.transport2.sentMessages) > 0, "No message was forwarded to transport2") + for typeSent, payload in self.transport2.sentMessages: + self.assertEqual(typeSent, nonExcluded) + self.assertEqual(payload, {"a": 10, "b": 20}) + + def test_excludedMessageNotForwarded(self): + # Choose a message type that is excluded. + excludedMessage = None + for m in list(RemoteMessageType): + if m in BridgeTransport.excluded: + excludedMessage = m + break + self.assertIsNotNone(excludedMessage, "There must be at least one excluded message type") + # Clear any previous sent messages. + self.transport2.sentMessages.clear() + # Simulate an inbound message on transport1 for the excluded type. + callbacks = self.transport1.inboundHandlers[excludedMessage] + for callback in callbacks: + callback(a=99) + # Expect that transport2's send() is not called. + self.assertEqual(len(self.transport2.sentMessages), 0, "Excluded message was forwarded") + + def test_disconnectUnregistersHandlers(self): + # Count initial number of registered handlers. + countT1 = sum(len(handlers) for handlers in self.transport1.inboundHandlers.values()) + countT2 = sum(len(handlers) for handlers in self.transport2.inboundHandlers.values()) + self.assertGreater(countT1, 0) + self.assertGreater(countT2, 0) + # Disconnect the bridge. + self.bridge.disconnect() + # After disconnection, there should be no inbound handlers remaining. + totalT1 = sum(len(handlers) for handlers in self.transport1.inboundHandlers.values()) + totalT2 = sum(len(handlers) for handlers in self.transport2.inboundHandlers.values()) + self.assertEqual(totalT1, 0, "Still registered handlers in transport1 after disconnect") + self.assertEqual(totalT2, 0, "Still registered handlers in transport2 after disconnect") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_remote/test_remote_client.py b/tests/unit/test_remote/test_remote_client.py new file mode 100644 index 00000000000..cd276526146 --- /dev/null +++ b/tests/unit/test_remote/test_remote_client.py @@ -0,0 +1,212 @@ +# 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. + +import unittest +from unittest.mock import MagicMock, patch +import remoteClient.client as rcClient +from remoteClient.connectionInfo import ConnectionInfo, ConnectionMode +from remoteClient.protocol import RemoteMessageType + + +# Fake implementations for testing +class FakeLocalMachine: + def __init__(self): + self.isMuted = False + + def terminate(self): + pass + + +class FakeMenu: + def __init__(self): + self.muteItem = self.FakeMuteItem() + + class FakeMuteItem: + def __init__(self): + self.checked = None + + def Check(self, value): + self.checked = value + + +class FakeTransport: + def __init__(self): + self.sent = [] + + @property + def connected(self): + return True + + def send(self, messageType, **kwargs): + self.sent.append((messageType, kwargs)) + + +class FakeSession: + def __init__(self, url): + self.url = url + + def getConnectionInfo(self): + class FakeConnectionInfo: + def getURLToConnect(_): # type: ignore + return self.url + + return FakeConnectionInfo() + + +class FakeAPI: + clipData = "Fake clipboard text" + copied = None + + @staticmethod + def getClipData(): + return FakeAPI.clipData + + @staticmethod + def copyToClip(text): + FakeAPI.copied = text + + +class TestRemoteClient(unittest.TestCase): + def setUp(self): + import wx + + if not wx.GetApp(): + self.app = wx.App() + # Patch gui.mainFrame to a fake object so RemoteMenu can access sysTrayIcon.toolsMenu. + patcherMainFrame = patch("remoteClient.client.gui.mainFrame") + self.addCleanup(patcherMainFrame.stop) + mockMainFrame = patcherMainFrame.start() + mockMainFrame.sysTrayIcon = MagicMock() + mockMainFrame.sysTrayIcon.toolsMenu = MagicMock() + self.client = rcClient.RemoteClient() + # Override localMachine and menu with fake implementations. + self.client.localMachine = FakeLocalMachine() + self.client.menu = FakeMenu() + # Patch ui.message to capture calls. + patcher = patch("remoteClient.client.ui.message") + self.addCleanup(patcher.stop) + self.uiMessage = patcher.start() + # Patch the API module to use our fake API. + patcherAPI = patch("remoteClient.client.api", new=FakeAPI) + self.addCleanup(patcherAPI.stop) + patcherAPI.start() + FakeAPI.copied = None + patcherNvwave = patch("remoteClient.cues.nvwave.playWaveFile", return_value=None) + + self.addCleanup(patcherNvwave.stop) + patcherNvwave.start() + + def tearDown(self): + self.client = None + + def test_toggleMute(self): + # Initially, local machine should not be muted. + self.assertFalse(self.client.localMachine.isMuted) + # Toggle mute: should mute the local machine. + self.client.toggleMute() + self.assertTrue(self.client.localMachine.isMuted) + self.assertTrue(self.client.menu.muteItem.checked) + self.uiMessage.assert_called_once() + # Now toggle again: should unmute. + self.uiMessage.reset_mock() + self.client.toggleMute() + self.assertFalse(self.client.localMachine.isMuted) + self.assertFalse(self.client.menu.muteItem.checked) + self.uiMessage.assert_called_once() + + def test_pushClipboardNoConnection(self): + # Without any transport (neither follower nor leader), pushClipboard should warn. + self.client.followerTransport = None + self.client.leaderTransport = None + self.client.pushClipboard() + self.uiMessage.assert_called_with("Not connected.") + + def test_pushClipboardWithTransport(self): + # With a fake transport, pushClipboard should send the clipboard text. + fakeTransport = FakeTransport() + self.client.leaderTransport = fakeTransport + FakeAPI.clipData = "TestClipboard" + self.client.pushClipboard() + self.assertTrue(len(fakeTransport.sent) > 0) + messageType, kwargs = fakeTransport.sent[0] + self.assertEqual(messageType, RemoteMessageType.SET_CLIPBOARD_TEXT) + self.assertEqual(kwargs.get("text"), "TestClipboard") + + def test_copyLinkNoSession(self): + # If there is no session, copyLink should warn the user. + self.client.leaderSession = None + self.client.followerSession = None + self.uiMessage.reset_mock() + self.client.copyLink() + self.uiMessage.assert_called_with("Not connected.") + + def test_copyLinkWithSession(self): + # With a fake session, copyLink should call api.copyToClip with the proper URL. + fakeSession = FakeSession("http://fake.url/connect") + self.client.leaderSession = fakeSession + FakeAPI.copied = None + self.client.copyLink() + self.assertEqual(FakeAPI.copied, "http://fake.url/connect") + + def test_sendSasNoLeaderTransport(self): + # Without a leaderTransport, sendSAS should log an error. + self.client.leaderTransport = None + with patch("remoteClient.client.log.error") as mockLogError: + self.client.sendSAS() + mockLogError.assert_called_once_with("No leader transport to send SAS") + + def test_sendSasWithLeaderTransport(self): + # With a fake leaderTransport, sendSAS should forward the SEND_SAS message. + fakeTransport = FakeTransport() + self.client.leaderTransport = fakeTransport + self.client.sendSAS() + self.assertTrue(len(fakeTransport.sent) > 0) + messageType, _ = fakeTransport.sent[0] + self.assertEqual(messageType, RemoteMessageType.SEND_SAS) + + def test_connectDispatch(self): + # Ensure that connect() dispatches to connectAsLeader or connectAsFollower based on connection mode. + fakeConnectAsLeader = MagicMock() + fakeConnectAsFollower = MagicMock() + self.client.connectAsLeader = fakeConnectAsLeader + self.client.connectAsFollower = fakeConnectAsFollower + connInfoLeader = ConnectionInfo( + hostname="localhost", + mode=ConnectionMode.LEADER, + key="abc", + port=1000, + insecure=False, + ) + self.client.connect(connInfoLeader) + fakeConnectAsLeader.assert_called_once_with(connInfoLeader) + fakeConnectAsLeader.reset_mock() + connInfoFollower = ConnectionInfo( + hostname="localhost", + mode=ConnectionMode.FOLLOWER, + key="abc", + port=1000, + insecure=False, + ) + self.client.connect(connInfoFollower) + fakeConnectAsFollower.assert_called_once_with(connInfoFollower) + + def test_disconnect(self): + # Test disconnect with no active sessions. + self.client.leaderSession = None + self.client.followerSession = None + with patch("remoteClient.client.log.debug") as mockLogDebug: + self.client.disconnect() + mockLogDebug.assert_called() + # Test disconnect with an active localControlServer. + fakeControl = MagicMock() + self.client.localControlServer = fakeControl + self.client.leaderSession = MagicMock() + self.client.followerSession = MagicMock() + self.client.disconnect() + fakeControl.close.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_remote/test_serializer.py b/tests/unit/test_remote/test_serializer.py new file mode 100644 index 00000000000..5fb61b88ded --- /dev/null +++ b/tests/unit/test_remote/test_serializer.py @@ -0,0 +1,76 @@ +# 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. + +import json +import unittest +from enum import Enum +from remoteClient.serializer import JSONSerializer, SpeechCommandJSONEncoder, asSequence + + +# Create a dummy Enum for test purposes. +class DummyEnum(Enum): + VALUE1 = "value1" + VALUE2 = "value2" + + +# Dummy command for testing CustomEncoder fallback. +class DummyCommand: + def __init__(self, data): + self.data = data + + +class TestJSONSerializer(unittest.TestCase): + def setUp(self): + self.serializer = JSONSerializer() + + def test_serializeBasic(self): + # Test basic serialization with a string type and payload. + messageBytes = self.serializer.serialize(type="test_message", key=123) + self.assertTrue(messageBytes.endswith(b"\n")) + messageStr = messageBytes.rstrip(b"\n").decode("UTF-8") + data = json.loads(messageStr) + self.assertEqual(data["type"], "test_message") + self.assertEqual(data["key"], 123) + + def test_serializeEnum(self): + # Test that passing an Enum type is serialized to its value. + messageBytes = self.serializer.serialize(type=DummyEnum.VALUE1, key="abc") + messageStr = messageBytes.rstrip(b"\n").decode("UTF-8") + data = json.loads(messageStr) + self.assertEqual(data["type"], "value1") + self.assertEqual(data["key"], "abc") + + def test_roundTrip(self): + # Test that serializing and then deserializing returns the same message data. + original = {"type": "round_trip", "value": 999} + messageBytes = self.serializer.serialize(**original) + # Remove the separator for deserialization. + data = self.serializer.deserialize(messageBytes.rstrip(JSONSerializer.SEP)) + self.assertEqual(data["type"], "round_trip") + self.assertEqual(data["value"], 999) + + def test_customEncoder(self): + # Test that CustomEncoder falls back to default behavior for non-special objects. + dummy = DummyCommand("test") + # Set __dict__ to a non-serializable object (set is not serializable by default) + dummy.__dict__ = {"data": {1, 2, 3}} + with self.assertRaises(TypeError) as cm: + json.dumps(dummy, cls=SpeechCommandJSONEncoder) + self.assertRegex(str(cm.exception), "not JSON serializable") + # Even if __dict__ is set to a serializable value, it should still raise error. + dummy.__dict__ = {"data": "testdata"} + with self.assertRaises(TypeError) as cm: + json.dumps(dummy, cls=SpeechCommandJSONEncoder) + self.assertRegex(str(cm.exception), "not JSON serializable") + + def test_asSequenceNoChange(self): + # Test that as_sequence returns the dictionary unchanged when no special keys exist. + inputDict = {"type": "other", "foo": "bar"} + result = asSequence(inputDict) + self.assertEqual(result, inputDict) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_remote/test_transport.py b/tests/unit/test_remote/test_transport.py new file mode 100644 index 00000000000..ac4bb3727d0 --- /dev/null +++ b/tests/unit/test_remote/test_transport.py @@ -0,0 +1,484 @@ +# 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. + +""" +Unit tests for the remoteClient.transport module. +This test suite covers: + - RemoteExtensionPoint bridging of extension points + - Basic Transport functionality (send, inbound/outbound handler registration) + - Processing of incoming socket data and message parsing + - sendQueue functionality + - Creating outbound sockets in TCPTransport (with both .onion and regular hosts) + - RelayTransport onConnected logic + - ConnectorThread basic reconnection behavior + - clearQueue utility +""" + +import ast +import socket +import ssl +import threading +import unittest +from queue import Queue +from unittest import mock + +import wx + +# Import classes from the transport module. +from remoteClient.transport import ( + PROTOCOL_VERSION, + ConnectorThread, + RelayTransport, + RemoteExtensionPoint, + RemoteMessageType, + TCPTransport, + Transport, + clearQueue, +) + + +# --------------------------------------------------------------------------- +# Fake Serializer used for testing +class FakeSerializer: + def serialize(self, *, type, **kwargs): + # Return a simple string representation ending with newline. + import enum + + if isinstance(type, enum.Enum): + type = type.value + return f"type={type}|{kwargs}\n".encode("utf-8") + + def deserialize(self, line: bytes): + s = line.decode("utf-8").strip() + if not s.startswith("type="): + return {} + rest = s[len("type=") :] + typePart, sep, kwargPart = rest.partition("|") + kwargs = ast.literal_eval(kwargPart) if kwargPart else {} + kwargs["type"] = typePart + return kwargs + + +# --------------------------------------------------------------------------- +# Fake HandlerRegistrar (for outbound tests) +class FakeHandlerRegistrar: + def __init__(self): + self.handlers = [] + + def register(self, handler): + self.handlers.append(handler) + + def unregister(self, handler): + self.handlers.remove(handler) + + def notify(self, **kwargs): + for h in self.handlers: + h(**kwargs) + + +# --------------------------------------------------------------------------- +# Tests for RemoteExtensionPoint +class TestRemoteExtensionPoint(unittest.TestCase): + def test_remoteBridgeWithFilter(self): + # Create a fake extension point and a filter function + registrar = FakeHandlerRegistrar() + + def myFilter(*args, **kwargs): + return {"filtered": True} + + rep = RemoteExtensionPoint(extensionPoint=registrar, messageType="TEST", filter=myFilter) + + # Create a fake transport that records calls to send + class FakeTransport: + def __init__(self): + self.sent = [] + + def send(self, messageType, **kwargs): + self.sent.append((messageType, kwargs)) + + fakeTransport = FakeTransport() + rep.register(fakeTransport) + # Trigger the extension point + registrar.notify(unused="value") + self.assertEqual(fakeTransport.sent, [("TEST", {"filtered": True})]) + rep.unregister() + + def test_remoteBridgeWithoutFilter(self): + registrar = FakeHandlerRegistrar() + rep = RemoteExtensionPoint(extensionPoint=registrar, messageType="TEST", filter=None) + + class FakeTransport: + def __init__(self): + self.sent = [] + + def send(self, messageType, **kwargs): + self.sent.append((messageType, kwargs)) + + fakeTransport = FakeTransport() + rep.register(fakeTransport) + registrar.notify(a=1, b=2) + self.assertEqual(fakeTransport.sent, [("TEST", {"a": 1, "b": 2})]) + rep.unregister() + + +# --------------------------------------------------------------------------- +# Dummy subclass of TCPTransport for testing (without real network IO) +class DummyTransport(TCPTransport): + def __init__(self, serializer): + super().__init__(serializer, address=("localhost", 0), timeout=0, insecure=True) + + def run(self): + # Do nothing (for testing send, etc.) + pass + + +# --------------------------------------------------------------------------- +# Tests for send() and queue functionality in Transport +class TestTransportSendAndQueue(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + self.transport.connected = True + self.transport.queue = Queue() + + def test_sendEnqueuesSerializedMessage(self): + self.transport.send("TEST_TYPE", key=123) + item = self.transport.queue.get_nowait() + result = self.serializer.deserialize(item) + self.assertEqual(result["type"], "TEST_TYPE") + self.assertEqual(result["key"], 123) + + def test_sendWhenNotConnectedLogsError(self): + self.transport.connected = False + with mock.patch("remoteClient.transport.log.error") as mockError: + self.transport.send("TEST", a=1) + mockError.assert_called_once() + + +# --------------------------------------------------------------------------- +# Fake socket for testing processIncomingSocketData +class FakeSocket: + def __init__(self, recv_data: bytes): + self.recv_data = recv_data + self.blocking = True + self.closed = False + + def setblocking(self, flag: bool): + self.blocking = flag + + def recv(self, buffSize: int) -> bytes: + data = self.recv_data[:buffSize] + self.recv_data = self.recv_data[buffSize:] + return data + + def close(self): + self.closed = True + + +# --------------------------------------------------------------------------- +# Tests for processIncomingSocketData and parse() +class TestProcessAndParse(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + # Use a fake socket that returns two full lines and a partial + self.transport.serverSock = FakeSocket(b"line1\nline2\npartial") + self.transport.serverSockLock = threading.Lock() + self.transport.buffer = b"" + + def test_processIncomingSocketData(self): + parsedLines = [] + + def fakeParse(line): + parsedLines.append(line) + + self.transport.parse = fakeParse + self.transport.processIncomingSocketData() + self.assertEqual(self.transport.buffer, b"partial") + self.assertEqual(parsedLines, [b"line1", b"line2"]) + + def test_parseCallsInboundHandler(self): + # Set up an inbound handler for type RemoteMessageType.PROTOCOL_VERSION + dummyInbound = mock.MagicMock() + self.transport.inboundHandlers = {RemoteMessageType.PROTOCOL_VERSION: dummyInbound} + # Patch wx.CallAfter to simply call immediately + original_CallAfter = wx.CallAfter + wx.CallAfter = lambda func, *args, **kwargs: func(*args, **kwargs) + # Prepare a message + message = self.serializer.serialize(type=RemoteMessageType.PROTOCOL_VERSION, a=1) + self.transport.parse(message) + dummyInbound.notify.assert_called_once_with(a=1) + wx.CallAfter = original_CallAfter + + +# --------------------------------------------------------------------------- +# Tests for sendQueue +class DummyTransportSocket: + def __init__(self): + self.sent = [] + + def sendall(self, data): + self.sent.append(data) + + def close(self): + self.closed = True + + +class TestSendQueue(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + self.transport.serverSock = DummyTransportSocket() + self.transport.serverSockLock = threading.Lock() + self.transport.queue = Queue() + + def test_sendQueueSendsMessages(self): + item1 = b"msg1" + item2 = b"msg2" + self.transport.queue.put(item1) + self.transport.queue.put(item2) + self.transport.queue.put(None) # Signal to stop + self.transport.sendQueue() + self.assertEqual(self.transport.serverSock.sent, [item1, item2]) + + def test_sendQueueStopsOnSocketError(self): + item1 = b"msg1" + self.transport.queue.put(item1) + + def fakeSendall(data): + raise socket.error("Test error") + + self.transport.serverSock.sendall = fakeSendall + # Should complete without raising further exception + self.transport.sendQueue() + + +# --------------------------------------------------------------------------- +# Tests for TCPTransport.createOutboundSocket +class DummyTCPSocket: + def __init__(self): + self.options = {} + self.blocking = True + self.connected = False + self.timeout = None + self.family = socket.AF_INET + self.type = socket.SOCK_STREAM + self.proto = 0 + + def setsockopt(self, level, optname, value): + self.options[(level, optname)] = value + + def settimeout(self, timeout): + self.timeout = timeout + + def gettimeout(self): + return self.timeout + + def connect(self, address): + self.connected = True + + def close(self): + self.connected = False + + def ioctl(self, *args, **kwargs): + return + + def getsockopt(self, level, optname): + return socket.SOCK_STREAM + + def fileno(self): + return 0 + + def recv(self, buffSize): + return b"" + + +class TestTCPTransportCreateOutboundSocket(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.host = "localhost" + self.port = 8090 + + @mock.patch("test.mock_socket.socket", autospec=True) + def test_createOutboundSocketOnion(self, mockSocket): + t = TCPTransport(self.serializer, (self.host + ".onion", self.port)) + fakeSocket = DummyTCPSocket() + mockSocket.return_value = fakeSocket + sock = t.createOutboundSocket(self.host + ".onion", self.port, insecure=False) + self.assertFalse(fakeSocket.connected) + self.assertTrue(isinstance(sock, ssl.SSLSocket)) + + @mock.patch("test.mock_socket.socket", autospec=True) + def test_createOutboundSocketRegularInsecure(self, mockSocket): + t = TCPTransport(self.serializer, (self.host, self.port)) + fakeSocket = DummyTCPSocket() + mockSocket.return_value = fakeSocket + sock = t.createOutboundSocket(self.host, self.port, insecure=True) + self.assertFalse(fakeSocket.connected) + self.assertTrue(isinstance(sock, ssl.SSLSocket)) + + +# --------------------------------------------------------------------------- +# Tests for RelayTransport.onConnected +class TestRelayTransportOnConnected(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + + def test_onConnectedWithChannel(self): + # Create a RelayTransport with a channel set. + rt = RelayTransport( + serializer=self.serializer, + address=("localhost", 8090), + channel="mychannel", + connectionType="relayMode", + protocolVersion=PROTOCOL_VERSION, + insecure=False, + ) + # Override send() to record calls. + rt.send = mock.MagicMock() + rt.onConnected() + # It should send protocol version message. + rt.send.assert_any_call(RemoteMessageType.PROTOCOL_VERSION, version=PROTOCOL_VERSION) + # And since channel is set, should send JOIN message. + rt.send.assert_any_call(RemoteMessageType.JOIN, channel="mychannel", connection_type="relayMode") + + def test_onConnectedWithoutChannel(self): + # Create a RelayTransport with no channel. + rt = RelayTransport( + serializer=self.serializer, + address=("localhost", 8090), + channel=None, + connectionType="relayMode", + protocolVersion=PROTOCOL_VERSION, + insecure=False, + ) + rt.send = mock.MagicMock() + rt.onConnected() + rt.send.assert_any_call(RemoteMessageType.PROTOCOL_VERSION, version=PROTOCOL_VERSION) + rt.send.assert_any_call(RemoteMessageType.GENERATE_KEY) + + +# --------------------------------------------------------------------------- +# Tests for ConnectorThread +class DummyConnectorTransport(Transport): + def __init__(self, serializer): + super().__init__(serializer) + self.runCalled = 0 + + def run(self): + self.runCalled += 1 + raise socket.error("Simulated socket error") + + def processIncomingSocketData(self): + pass + + def send(self, type, **kwargs): + pass + + +class TestConnectorThread(unittest.TestCase): + def testConnectorThreadRunsAndReconnects(self): + serializer = FakeSerializer() + fakeTransport = DummyConnectorTransport(serializer) + connector = ConnectorThread(fakeTransport, reconnectDelay=0.01) + connector.running = True + # Run connector.run() in a loop for a few iterations manually. + iterations = 3 + for _ in range(iterations): + try: + fakeTransport.run() + except socket.error: + pass + connector.running = False + self.assertEqual(fakeTransport.runCalled, iterations) + + +# --------------------------------------------------------------------------- +# Tests for clearQueue function +class TestClearQueue(unittest.TestCase): + def test_clearQueue(self): + q = Queue() + for i in range(5): + q.put(i) + clearQueue(q) + self.assertTrue(q.empty()) + + +class TestRemoteExtensionPointIntegration(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + self.transport.connected = True + self.transport.queue = Queue() + self.fakeRegistrar = FakeHandlerRegistrar() + + def test_registerOutboundAndTrigger(self): + self.transport.registerOutbound(self.fakeRegistrar, RemoteMessageType.GENERATE_KEY) + for handler in self.fakeRegistrar.handlers: + handler(a=42) + item = self.transport.queue.get_nowait() + payload = self.serializer.deserialize(item) + self.assertEqual(payload["type"], RemoteMessageType.GENERATE_KEY.value) + self.assertEqual(payload["a"], 42) + + def test_unregisterOutbound(self): + self.transport.registerOutbound(self.fakeRegistrar, RemoteMessageType.GENERATE_KEY) + self.assertIn(RemoteMessageType.GENERATE_KEY, self.transport.outboundHandlers) + self.transport.unregisterOutbound(RemoteMessageType.GENERATE_KEY) + self.assertNotIn(RemoteMessageType.GENERATE_KEY, self.transport.outboundHandlers) + + +class TestInboundRegistration(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + self.called = False + + def handler(**kwargs): + self.called = True + + self.handler = handler + + def test_registerInbound(self): + self.transport.registerInbound(RemoteMessageType.PROTOCOL_VERSION, self.handler) + self.assertIn(RemoteMessageType.PROTOCOL_VERSION, self.transport.inboundHandlers) + self.transport.inboundHandlers[RemoteMessageType.PROTOCOL_VERSION].notify(a=123) + self.assertTrue(self.called) + + def test_unregisterInbound(self): + self.transport.registerInbound(RemoteMessageType.PROTOCOL_VERSION, self.handler) + self.transport.unregisterInbound(RemoteMessageType.PROTOCOL_VERSION, self.handler) + self.called = False + if RemoteMessageType.PROTOCOL_VERSION in self.transport.inboundHandlers: + self.transport.inboundHandlers[RemoteMessageType.PROTOCOL_VERSION].notify(a=456) + self.assertFalse(self.called) + + +class TestParseErrorHandling(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + self.transport.inboundHandlers = {} + + def test_parseNoType(self): + with self.assertLogs(level="WARN") as cm: + self.transport.parse(b"invalid message\n") + self.assertTrue(any("Received message without type" in log for log in cm.output)) + + def test_parseInvalidType(self): + with self.assertLogs(level="WARN") as cm: + message = self.serializer.serialize(type="NONEXISTENT", a=10) + self.transport.parse(message) + self.assertTrue(any("Received message with invalid type" in log for log in cm.output)) + + def test_parseUnhandledType(self): + with self.assertLogs(level="WARN") as cm: + message = self.serializer.serialize(type=RemoteMessageType.GENERATE_KEY, b=10) + self.transport.parse(message) + self.assertTrue(any("Received message with unhandled type" in log for log in cm.output)) + + +if __name__ == "__main__": + unittest.main() diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index 64cae6ad7e5..090a49c7470 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -24,6 +24,7 @@ Currently this is only supported in Foxit Reader & Foxit Editor. (#9288, @NSoiff * The ability to adjust the volume of other applications besides NVDA has been added. To use this feature, "allow NVDA to control the volume of other applications" must be enabled in the audio settings panel. (#16052, #17634, @mltony, @codeofdusk) * Added new unassigned gestures to increase, decrease and mute the volume of all other applications. +* Remote access functionality, based on the NVDA Remote add-on, has been integrated into NVDA. (#4390, @ctoth, @tspivey, @daiverd, NVDA Remote Contributors and funders) * When editing in Microsoft PowerPoint text boxes, you can now move per sentence with `alt+upArrow`/`alt+downArrow`. (#17015, @LeonarddeR) * In Mozilla Firefox, NVDA will report the highlighted text when a URL containing a text fragment is visited. (#16910, @jcsteh) * NVDA can now report when a link destination points to the current page. (#141, @LeonarddeR, @nvdaes) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index dff984431a2..3ba6cab1380 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -27,6 +27,7 @@ Major highlights include: * Reporting of textual formatting where available such as font name and size, style and spelling errors * Automatic announcement of text under the mouse and optional audible indication of the mouse position * Support for many refreshable braille displays, including the ability to detect many of them automatically as well as braille input on braille displays with a braille keyboard +* Remote Access: Connect to and control another computer running NVDA for remote assistance or collaboration. * Ability to run entirely from a USB flash drive or other portable media without the need for installation * Easy to use talking installer * Translated into 54 languages @@ -3631,6 +3632,63 @@ Settings for NVDA when running during sign-in or on UAC screens are stored in th Usually, this configuration should not be touched. To change NVDA's configuration during sign-in or on UAC screens, configure NVDA as you wish while signed into Windows, save the configuration, and then press the "use currently saved settings during sign-in and on secure screens" button in the General category of the [NVDA Settings](#NVDASettings) dialog. +## Remote Access {#remoteAccess} + +With NVDA's built-in remote access feature, you can control another computer running NVDA or allow someone to control your computer. This makes it easy to provide or receive assistance, collaborate, or access your own computer remotely. + +### Getting Started {#RemoteAccessGettingStarted} + +Before you begin, ensure NVDA is installed and running on both computers. The remote access feature is available from the Tools menu in NVDA—there’s no need for additional downloads or installations. + +### Setting Up a Remote Session {#RemoteAccessSetup} + +You’ll need to decide which computer will be controlled (the controlled computer) and which will be controlling (the controlling computer). + +#### Steps for the Controlled Computer {#RemoteAccessSetupControlled} + +1. Open the NVDA menu and select Tools, then Remote, then Connect. +1. Choose Allow this computer to be controlled. +1. Enter the connection details provided by the person controlling your computer: + * Relay Server: If using a server, enter the hostname (e.g., `nvdaremote.com`). + * Direct Connection: If connecting directly, share your external IP address and port (default: 6837). Ensure your network is set up for direct connections. +1. Press OK. Share the connection key with the other person. + +#### Steps for the Controlling Computer {#RemoteAccessSetupControlling} + +1. Open the NVDA menu and select Tools, then Remote, then Connect. +1. Choose Control another computer. +1. Enter the connection details and key provided by the controlled computer. +1. Press OK to connect. + +Once connected, you can control the other computer, including typing and navigating applications, just as if you were sitting in front of it. + +### Remote Connection Options {#RemoteAccessConnectionOptions} + +You can choose between two connection types depending on your setup: + +* Relay Server (easier): Uses a public or private server to mediate the connection. Only the server hostname and key are needed. +* Direct Connection (advanced): Connects directly without a server. Requires network setup, such as port forwarding. + +### Using Remote Access {#RemoteAccessUsage} + +Once the session is active, you can switch between controlling the remote computer and your own, share your clipboard, and mute the remote session: + +* Press `NVDA+f11` to toggle between controlling and returning to your own computer. +* Push text from your clipboard to the other computer by opening the NVDA menu, then selecting Tools, then Remote, then Push Clipboard. +* Mute the remote computer's speech output on your local computer by opening the NVDA menu, then selecting Tools, then Remote, then Mute Remote. + +### Remote Access Key Commands Summary {#RemoteAccessGestures} + + +| Action | Key Command | Description | +|--------------------------|----------------------|-------------------------------------------| +| Toggle Control | `NVDA+f11` | Switch between controlling and local. | +| Push Clipboard | `NVDA+ctrl+shift+c` | Send clipboard text to the other machine. | +| Disconnect | `NVDA+alt+pageDown`| End the remote session. | + + +You can assign further commands in the Remote section of the [Input Gestures dialog](#InputGestures). + ## Add-ons and the Add-on Store {#AddonsManager} Add-ons are software packages which provide new or altered functionality for NVDA.