diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index be23ea20c4f..47e993cdcf9 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 f1e6c221d53..a78bbcd550b 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/core.py b/source/core.py index d897b61d6ae..360fbaf6845 100644 --- a/source/core.py +++ b/source/core.py @@ -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 9c22287a3c5..271102cc065 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -69,7 +69,7 @@ from utils.security import objectBelowLockScreenAndWindowsIsLocked import audio from audio import appsVolume - +import remoteClient #: Script category for text review commands. # Translators: The name of a category of NVDA commands. @@ -119,6 +119,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). @@ -4888,6 +4891,69 @@ 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_toggle_remote_mute(self, gesture): + remoteClient.client.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_push_clipboard(self, gesture): + remoteClient.client.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_copy_link(self, gesture): + remoteClient.client.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): + if not remoteClient.client.isConnected: + # Translators: A message indicating that the remote client is not connected. + ui.message(_("Not connected.")) + return + remoteClient.client.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): + if remoteClient.client.isConnected() or remoteClient.client.connecting: + return + remoteClient.client.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:f11", + ) + def script_sendKeys(self, gesture): + remoteClient.client.toggleRemoteKeyControl(gesture) + #: The single global commands instance. #: @type: L{GlobalCommands} diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 793f6e04f32..e845ba6c89d 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -47,6 +47,7 @@ import gui.contextHelp import globalVars from logHandler import log +from remoteClient import configuration import audio import audioDucking import queueHandler @@ -3340,6 +3341,159 @@ def onSave(self): config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AddonsAutomaticUpdate][index] +class RemoteSettingsPanel(SettingsPanel): + # Translators: This is the label for the remote settings category in NVDA Settings screen. + title = _("Remote") + autoconnect: wx.CheckBox + client_or_server: wx.RadioBox + connection_type: wx.RadioBox + host: wx.TextCtrl + port: wx.SpinCtrl + key: wx.TextCtrl + play_sounds: wx.CheckBox + delete_fingerprints: wx.Button + + def makeSettings(self, settingsSizer): + self.config = configuration.get_config() + sHelper = gui.guiHelper.BoxSizerHelper(self, sizer=settingsSizer) + self.autoconnect = wx.CheckBox( + parent=self, + id=wx.ID_ANY, + # Translators: A checkbox in add-on options dialog to set whether NVDA should automatically connect to a control server on startup. + label=_("Auto-connect to control server on startup"), + ) + self.autoconnect.Bind(wx.EVT_CHECKBOX, self.on_autoconnect) + sHelper.addItem(self.autoconnect) + # Translators: Whether or not to use a relay server when autoconnecting + self.client_or_server = 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.client_or_server.Bind(wx.EVT_RADIOBOX, self.on_client_or_server) + self.client_or_server.SetSelection(0) + self.client_or_server.Enable(False) + sHelper.addItem(self.client_or_server) + 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.connection_type = wx.RadioBox(self, wx.ID_ANY, choices=choices, style=wx.RA_VERTICAL) + self.connection_type.SetSelection(0) + self.connection_type.Enable(False) + sHelper.addItem(self.connection_type) + 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 add-on options dialog to set whether sounds play instead of beeps. + self.play_sounds = wx.CheckBox(self, wx.ID_ANY, label=_("Play sounds instead of beeps")) + sHelper.addItem(self.play_sounds) + # Translators: A button in add-on options dialog to delete all fingerprints of unauthorized certificates. + self.delete_fingerprints = wx.Button(self, wx.ID_ANY, label=_("Delete all trusted fingerprints")) + self.delete_fingerprints.Bind(wx.EVT_BUTTON, self.on_delete_fingerprints) + sHelper.addItem(self.delete_fingerprints) + self.set_from_config() + + def on_autoconnect(self, evt: wx.CommandEvent) -> None: + self.set_controls() + + def set_controls(self) -> None: + state = bool(self.autoconnect.GetValue()) + self.client_or_server.Enable(state) + self.connection_type.Enable(state) + self.key.Enable(state) + self.host.Enable(not bool(self.client_or_server.GetSelection()) and state) + self.port.Enable(bool(self.client_or_server.GetSelection()) and state) + + def on_client_or_server(self, evt: wx.CommandEvent) -> None: + evt.Skip() + self.set_controls() + + def set_from_config(self) -> None: + cs = self.config["controlserver"] + self_hosted = cs["self_hosted"] + connection_type = cs["connection_type"] + self.autoconnect.SetValue(cs["autoconnect"]) + self.client_or_server.SetSelection(int(self_hosted)) + self.connection_type.SetSelection(connection_type) + self.host.SetValue(cs["host"]) + self.port.SetValue(str(cs["port"])) + self.key.SetValue(cs["key"]) + self.set_controls() + self.play_sounds.SetValue(self.config["ui"]["play_sounds"]) + + def on_delete_fingerprints(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.client_or_server.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.client_or_server.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() + self_hosted = bool(self.client_or_server.GetSelection()) + connection_type = self.connection_type.GetSelection() + cs["self_hosted"] = self_hosted + cs["connection_type"] = connection_type + if not self_hosted: + cs["host"] = self.host.GetValue() + else: + cs["port"] = int(self.port.GetValue()) + cs["key"] = self.key.GetValue() + self.config["ui"]["play_sounds"] = self.play_sounds.GetValue() + + class TouchInteractionPanel(SettingsPanel): # Translators: This is the label for the touch interaction settings panel. title = _("Touch Interaction") @@ -5207,6 +5361,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/remoteClient/__init__.py b/source/remoteClient/__init__.py new file mode 100644 index 00000000000..57e90895530 --- /dev/null +++ b/source/remoteClient/__init__.py @@ -0,0 +1,22 @@ +# 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 + +client: RemoteClient = None + + +def initialize(): + """Initialise the remote client.""" + global client + import globalCommands + + client = RemoteClient() + client.registerLocalScript(globalCommands.commands.script_sendKeys) + + +def terminate(): + """Terminate the remote client.""" + client.terminate() diff --git a/source/remoteClient/beepSequence.py b/source/remoteClient/beepSequence.py new file mode 100644 index 00000000000..91c13280c47 --- /dev/null +++ b/source/remoteClient/beepSequence.py @@ -0,0 +1,35 @@ +import collections.abc +import threading +import time +from typing import Tuple, Union + +import tones + +local_beep = tones.beep + +BeepElement = Union[int, Tuple[int, int]] # Either delay_ms or (frequency_hz, duration_ms) +BeepSequence = collections.abc.Iterable[BeepElement] + + +def beepSequence(*sequence: BeepElement) -> 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: + tone, duration = element + time.sleep(float(duration) / 1000) + local_beep(tone, duration) + + +def beepSequenceAsync(*sequence: BeepElement) -> 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/remoteClient/bridge.py b/source/remoteClient/bridge.py new file mode 100644 index 00000000000..a86b446853f --- /dev/null +++ b/source/remoteClient/bridge.py @@ -0,0 +1,106 @@ +# 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 +====================== + +This module provides functionality to bridge two NVDA Remote transports together, +enabling bidirectional message passing between two transport instances while +handling message filtering and routing. + +The bridge acts as an intermediary layer that: +- Connects two transport instances +- Routes messages between them +- Filters out specific message types that shouldn't be forwarded +- Manages the lifecycle of message handlers + +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 typing import Dict, Set + +from .protocol import RemoteMessageType +from .transport import Transport + + +class BridgeTransport: + """A bridge between two NVDA Remote transport instances. + + This class creates a bidirectional bridge between two Transport instances, + allowing them to exchange messages while providing message filtering capabilities. + It automatically sets up message handlers for all RemoteMessageTypes and manages + their lifecycle. + + Attributes: + excluded (Set[str]): 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 + t1_callbacks (Dict[RemoteMessageType, callable]): Storage for t1's message handlers + t2_callbacks (Dict[RemoteMessageType, callable]): Storage for t2's message handlers + """ + + excluded: Set[str] = {"client_joined", "client_left", "channel_joined", "set_braille_info"} + + 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. + + Args: + t1 (Transport): First transport instance to bridge + t2 (Transport): Second transport instance to bridge + """ + self.t1 = t1 + self.t2 = t2 + # Store callbacks for each message type + self.t1Callbacks: Dict[RemoteMessageType, callable] = {} + self.t2Callbacks: Dict[RemoteMessageType, callable] = {} + + 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): + """Create a callback function for handling a specific message type. + + Creates a closure that will forward messages of the specified type + to the target transport, unless the message type is in the excluded set. + + Args: + targetTransport (Transport): Transport instance to forward messages to + messageType (RemoteMessageType): Type of message this callback will handle + + Returns: + callable: A callback function that forwards messages to the target transport + """ + + def callback(*args, **kwargs): + if messageType.value not in self.excluded: + targetTransport.send(messageType, *args, **kwargs) + + return callback + + def disconnect(self): + """Disconnect the bridge and clean up all message handlers. + + Unregisters all message handlers from both transports that were set up + during bridge initialization. This should be called before disposing of + the bridge to prevent memory leaks and ensure proper cleanup. + """ + 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..b8b55fc9da4 --- /dev/null +++ b/source/remoteClient/client.py @@ -0,0 +1,483 @@ +# 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 Callable, 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 utils.alwaysCallAfter import alwaysCallAfter +from utils.security import isRunningOnSecureDesktop + +from . import configuration, cues, dialogs, serializer, server, url_handler +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 MasterSession, SlaveSession +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[Callable] + localMachine: LocalMachine + masterSession: Optional[MasterSession] + slaveSession: Optional[SlaveSession] + keyModifiers: Set[KeyModifier] + hostPendingModifiers: Set[KeyModifier] + connecting: bool + masterTransport: Optional[RelayTransport] + slaveTransport: 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.slaveSession = None + self.masterSession = None + self.menu: Optional[RemoteMenu] = None + if not isRunningOnSecureDesktop(): + self.menu: Optional[RemoteMenu] = RemoteMenu(self) + self.connecting = False + self.URLHandlerWindow = url_handler.URLHandlerWindow( + callback=self.verifyAndConnect, + ) + url_handler.register_url_handler() + self.masterTransport = None + self.slaveTransport = None + self.localControlServer = None + self.sendingKeys = False + self.sdHandler = SecureDesktopHandler() + if isRunningOnSecureDesktop(): + connection = self.sdHandler.initializeSecureDesktop() + if connection: + self.connectAsSlave(connection) + self.slaveSession.transport.connectedEvent.wait( + self.sdHandler.SD_CONNECT_BLOCK_TIMEOUT, + ) + core.postNvdaStartup.register(self.performAutoconnect) + inputCore.decide_handleRawKey.register(self.process_key_input) + + def performAutoconnect(self): + controlServerConfig = configuration.get_config()["controlserver"] + if not controlServerConfig["autoconnect"] or self.masterSession or self.slaveSession: + 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: + address = addressToHostPort(controlServerConfig["host"]) + hostname, port = address + mode = ConnectionMode.SLAVE if controlServerConfig["connection_type"] == 0 else ConnectionMode.MASTER + 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.process_key_input) + if not isInstalledCopy(): + url_handler.unregister_url_handler() + self.URLHandlerWindow.destroy() + self.URLHandlerWindow = None + + def toggleMute(self): + 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_msg = _("Mute speech and sounds from the remote computer") + # Translators: Displayed when unmuting speech and sounds from the remote computer + unmute_msg = _("Unmute speech and sounds from the remote computer") + status = mute_msg if self.localMachine.isMuted else unmute_msg + ui.message(status) + + def pushClipboard(self): + connector = self.slaveTransport or self.masterTransport + 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.clipboard_pushed() + except TypeError: + log.exception("Unable to push clipboard") + + def copyLink(self): + session = self.masterSession or self.slaveSession + url = session.getConnectionInfo().getURLToConnect() + api.copyToClip(str(url)) + + def sendSAS(self): + self.masterTransport.send(RemoteMessageType.send_SAS) + + def connect(self, connectionInfo: ConnectionInfo): + log.info( + f"Initiating connection as {connectionInfo.mode.name} to {connectionInfo.hostname}:{connectionInfo.port}", + ) + if connectionInfo.mode == ConnectionMode.MASTER: + self.connectAsMaster(connectionInfo) + elif connectionInfo.mode == ConnectionMode.SLAVE: + self.connectAsSlave(connectionInfo) + + def disconnect(self): + if self.masterSession is None and self.slaveSession 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.masterSession is not None: + self.disconnectAsMaster() + if self.slaveSession is not None: + self.disconnectAsSlave() + cues.disconnected() + + def disconnectAsMaster(self): + self.masterSession.close() + self.masterSession = None + self.masterTransport = None + + def disconnectAsSlave(self): + self.slaveSession.close() + self.slaveSession = None + self.slaveTransport = None + self.sdHandler.slaveSession = None + + @alwaysCallAfter + def onConnectAsMasterFailed(self): + if self.masterTransport.successfulConnects == 0: + log.error(f"Failed to connect to {self.masterTransport.address}") + self.disconnectAsMaster() + # 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): + if evt is not None: + evt.Skip() + previousConnections = configuration.get_config()["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 connectAsMaster(self, connectionInfo: ConnectionInfo): + transport = RelayTransport.create( + connection_info=connectionInfo, + serializer=serializer.JSONSerializer(), + ) + self.masterSession = MasterSession( + transport=transport, + localMachine=self.localMachine, + ) + transport.transportCertificateAuthenticationFailed.register( + self.onMasterCertificateFailed, + ) + transport.transportConnected.register(self.onConnectedAsMaster) + transport.transportConnectionFailed.register(self.onConnectAsMasterFailed) + transport.transportClosing.register(self.onDisconnectingAsMaster) + transport.transportDisconnected.register(self.onDisconnectedAsMaster) + transport.reconnectorThread.start() + self.masterTransport = transport + if self.menu: + self.menu.handleConnecting(connectionInfo.mode) + + @alwaysCallAfter + def onConnectedAsMaster(self): + log.info("Successfully connected as master") + configuration.write_connection_to_config(self.masterSession.getConnectionInfo()) + if self.menu: + self.menu.handleConnected(ConnectionMode.MASTER, True) + ui.message( + # Translators: Presented when connected to the remote computer. + _("Connected!"), + ) + cues.connected() + + @alwaysCallAfter + def onDisconnectingAsMaster(self): + log.info("Master session disconnecting") + if self.menu: + self.menu.handleConnected(ConnectionMode.MASTER, False) + if self.localMachine: + self.localMachine.isMuted = False + self.sendingKeys = False + self.keyModifiers = set() + + @alwaysCallAfter + def onDisconnectedAsMaster(self): + log.info("Master session disconnected") + # Translators: Presented when connection to a remote computer was interupted. + ui.message(_("Connection interrupted")) + + def connectAsSlave(self, connectionInfo: ConnectionInfo): + transport = RelayTransport.create( + connection_info=connectionInfo, + serializer=serializer.JSONSerializer(), + ) + self.slaveSession = SlaveSession( + transport=transport, + localMachine=self.localMachine, + ) + self.sdHandler.slaveSession = self.slaveSession + self.slaveTransport = transport + transport.transportCertificateAuthenticationFailed.register( + self.onSlaveCertificateFailed, + ) + transport.transportConnected.register(self.onConnectedAsSlave) + transport.transportDisconnected.register(self.onDisconnectedAsSlave) + transport.reconnectorThread.start() + if self.menu: + self.menu.handleConnecting(connectionInfo.mode) + + @alwaysCallAfter + def onConnectedAsSlave(self): + log.info("Control connector connected") + cues.control_server_connected() + if self.menu: + self.menu.handleConnected(ConnectionMode.SLAVE, True) + configuration.write_connection_to_config(self.slaveSession.getConnectionInfo()) + + @alwaysCallAfter + def onDisconnectedAsSlave(self): + log.info("Control connector disconnected") + # cues.control_server_disconnected() + if self.menu: + self.menu.handleConnected(ConnectionMode.SLAVE, 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.get_config() + 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 onMasterCertificateFailed(self): + if self.handleCertificateFailure(self.masterSession.transport): + connectionInfo = ConnectionInfo( + mode=ConnectionMode.MASTER, + hostname=self.lastFailAddress[0], + port=self.lastFailAddress[1], + key=self.lastFailKey, + insecure=True, + ) + self.connectAsMaster(connectionInfo=connectionInfo) + + @alwaysCallAfter + def onSlaveCertificateFailed(self): + if self.handleCertificateFailure(self.slaveSession.transport): + connectionInfo = ConnectionInfo( + mode=ConnectionMode.SLAVE, + hostname=self.lastFailAddress[0], + port=self.lastFailAddress[1], + key=self.lastFailKey, + insecure=True, + ) + self.connectAsSlave(connectionInfo=connectionInfo) + + def startControlServer(self, serverPort, channel): + self.localControlServer = server.LocalRelayServer(serverPort, channel) + serverThread = threading.Thread(target=self.localControlServer.run) + serverThread.daemon = True + serverThread.start() + + def process_key_input(self, vkCode=None, scanCode=None, extended=None, pressed=None): + 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.masterTransport.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): + if not self.masterTransport: + 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 in the guest. + for k in self.keyModifiers: + self.masterTransport.send( + RemoteMessageType.key, + vk_code=k[0], + extended=k[1], + pressed=False, + ) + self.keyModifiers = set() + + def setReceivingBraille(self, state): + if state and self.masterSession.callbacksAdded and braille.handler.enabled: + self.masterSession.registerBrailleInput() + self.localMachine.receivingBraille = True + elif not state: + self.masterSession.unregisterBrailleInput() + self.localMachine.receivingBraille = False + + @alwaysCallAfter + def verifyAndConnect(self, conInfo: ConnectionInfo): + """Verify connection details and establish connection if approved by user.""" + if self.isConnected() or self.connecting: + # Translators: Message shown when trying to connect while already connected. + error_msg = _("NVDA Remote is already connected. Disconnect before opening a new connection.") + # Translators: Title of the connection error dialog. + error_title = _("NVDA Remote Already Connected") + gui.messageBox(error_msg, error_title, 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.MASTER: + # 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): + connector = self.slaveTransport or self.masterTransport + if connector is not None: + return connector.connected + return False + + def registerLocalScript(self, script): + self.localScripts.add(script) + + def unregisterLocalScript(self, script): + self.localScripts.discard(script) diff --git a/source/remoteClient/configuration.py b/source/remoteClient/configuration.py new file mode 100644 index 00000000000..f4d47114281 --- /dev/null +++ b/source/remoteClient/configuration.py @@ -0,0 +1,87 @@ +# 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 io import StringIO + +import config +import configobj +import globalVars +from configobj import validate + +from .connectionInfo import ConnectionInfo + +CONFIG_FILE_NAME = "remote.ini" +configRoot = "Remote" + +_config = None +configspec = StringIO(""" +[connections] + last_connected = list(default=list()) +[controlserver] + autoconnect = boolean(default=False) + self_hosted = boolean(default=False) + connection_type = integer(default=0) + 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) +""") + + +def get_config(): + global _config + if not _config: + path = os.path.abspath(os.path.join(globalVars.appArgs.configPath, CONFIG_FILE_NAME)) + if os.path.isfile(path): + _config = configobj.ConfigObj(infile=path, configspec=configspec) + validator = validate.Validator() + _config.validate(validator) + config.conf[configRoot] = _config.dict() + config.post_configSave.register(onSave) + config.post_configReset.register(onReset) + else: + _config = configobj.ConfigObj(configspec=configspec) + config.conf.spec[configRoot] = _config.configspec.dict() + _config = config.conf[configRoot] + return _config + + +def write_connection_to_config(connection_info: 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. + + Args: + connection_info: The ConnectionInfo object containing connection details + """ + conf = get_config() + last_cons = conf["connections"]["last_connected"] + address = connection_info.getAddress() + if address in last_cons: + conf["connections"]["last_connected"].remove(address) + conf["connections"]["last_connected"].append(address) + + +def onSave(): + path = os.path.abspath(os.path.join(globalVars.appArgs.configPath, CONFIG_FILE_NAME)) + if os.path.isfile(path): # We have already merged the config, so we can just delete the file + os.remove(path) + config.post_configSave.unregister(onSave) + config.post_configReset.unregister(onReset) + + +def onReset(): + config.post_configSave.unregister( + onSave, + ) # We don't want to delete the file if we reset the config after merging + config.post_configReset.unregister(onReset) diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py new file mode 100644 index 00000000000..a9a5db311a8 --- /dev/null +++ b/source/remoteClient/connectionInfo.py @@ -0,0 +1,97 @@ +# 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 Enum +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +from . import protocol +from .protocol import SERVER_PORT, URL_PREFIX + + +class URLParsingError(Exception): + """Raised if it's impossible to parse out the URL""" + + +class ConnectionMode(Enum): + MASTER = "master" + SLAVE = "slave" + + +class ConnectionState(Enum): + CONNECTED = "connected" + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + DISCONNECTING = "disconnecting" + + +@dataclass +class ConnectionInfo: + hostname: str + mode: ConnectionMode + key: str + port: int = SERVER_PORT + insecure: bool = False + + def __post_init__(self): + self.port = self.port or SERVER_PORT + self.mode = ConnectionMode(self.mode) + + @classmethod + def fromURL(cls, 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): + # 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 _build_url(self, mode: ConnectionMode): + # Build URL components + netloc = protocol.hostPortToAddress((self.hostname, self.port)) + params = { + "key": self.key, + "mode": mode if isinstance(mode, str) else mode.value, + } + if self.insecure: + params["insecure"] = "true" + query = urlencode(params) + + # Use urlunparse for proper URL construction + return urlunparse( + ( + URL_PREFIX.split("://")[0], # scheme from URL_PREFIX + netloc, # network location + "", # path + "", # params + query, # query string + "", # fragment + ), + ) + + def getURLToConnect(self): + # Flip master/slave for connection URL + connect_mode = ConnectionMode.SLAVE if self.mode == ConnectionMode.MASTER else ConnectionMode.MASTER + return self._build_url(connect_mode.value) + + def getURL(self): + return self._build_url(self.mode) diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py new file mode 100644 index 00000000000..8860e46ee52 --- /dev/null +++ b/source/remoteClient/cues.py @@ -0,0 +1,104 @@ +# 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 tones +import ui +from . import configuration +from .beepSequence import beepSequenceAsync, BeepSequence + +local_beep = tones.beep + + +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"), + }, + "control_server_connected": { + "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"), + }, + "client_connected": {"wave": "controlling", "beeps": [(1000, 300)]}, + "client_disconnected": {"wave": "disconnected", "beeps": [(108, 300)]}, + "clipboard_pushed": { + "wave": "push_clipboard", + "beeps": [(500, 100), (600, 100)], + # Translators: Message shown when the clipboard is successfully pushed to the remote computer. + "message": _("Clipboard pushed"), + }, + "clipboard_received": { + "wave": "receive_clipboard", + "beeps": [(600, 100), (500, 100)], + # Translators: Message shown when the clipboard is successfully received from the remote computer. + "message": _("Clipboard received"), + }, +} + + +def _play_cue(cue_name: str) -> None: + """Helper function to play a cue by name""" + if not should_play_sounds(): + # Play beep sequence + if beeps := CUES[cue_name].get("beeps"): + filtered_beeps = [(freq, dur) for freq, dur in beeps if freq is not None] + beepSequenceAsync(*filtered_beeps) + return + + # Play wave file + if wave := CUES[cue_name].get("wave"): + nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", wave + ".wav")) + + # Show message if specified + if message := CUES[cue_name].get("message"): + ui.message(message) + + +def connected(): + _play_cue("connected") + + +def disconnected(): + _play_cue("disconnected") + + +def control_server_connected(): + _play_cue("control_server_connected") + + +def client_connected(): + _play_cue("client_connected") + + +def client_disconnected(): + _play_cue("client_disconnected") + + +def clipboard_pushed(): + _play_cue("clipboard_pushed") + + +def clipboard_received(): + _play_cue("clipboard_received") + + +def should_play_sounds(): + return configuration.get_config()["ui"]["play_sounds"] diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py new file mode 100644 index 00000000000..0b50f729ea9 --- /dev/null +++ b/source/remoteClient/dialogs.py @@ -0,0 +1,336 @@ +# 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 utils.alwaysCallAfter 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: + try: + certHash = self.keyConnector.lastFailFingerprint + + wnd = CertificateUnauthorizedDialog(None, fingerprint=certHash) + a = wnd.ShowModal() + if a == wx.ID_YES: + config = configuration.get_config() + config["trusted_certs"][self.host.GetValue()] = certHash + if a != wx.ID_YES and a != wx.ID_NO: + return + except Exception as ex: + log.error(ex) + return + 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 + warningMsg = _("Retrieved external IP, but port {port} is 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.SetSelection(0, len(ip)) + 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.MASTER if self.connectionType.GetSelection() == 0 else ConnectionMode.SLAVE + return ConnectionInfo( + hostname=serverAddr, + mode=mode, + key=self.getKey(), + port=port, + insecure=False, + ) + else: # server + port = int(self.panel.port.GetValue()) + mode = "master" if self.connectionType.GetSelection() == 0 else "slave" + 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. + "Warning! The certificate of this server could not be verified.\nThis connection may not be secure. It is possible that someone is trying to overhear your communication.\nBefore continuing please make sure that the following server certificate fingerprint is a proper one.\nIf you have any questions, please contact the server administrator.\n\nServer SHA256 fingerprint: {fingerprint}\n\nDo you want to continue connecting?", + ).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..d9bb4914205 --- /dev/null +++ b/source/remoteClient/input.py @@ -0,0 +1,152 @@ +# 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 + +import api +import baseObject +import braille +import brailleInput +import globalPluginHandler +import scriptHandler +import vision + +INPUT_MOUSE = 0 +INPUT_KEYBOARD = 1 +INPUT_HARDWARE = 2 +MAPVK_VK_TO_VSC = 0 +KEYEVENTF_EXTENDEDKEY = 0x0001 +KEYEVENTF_KEYUP = 0x0002 +KEYEVENT_SCANCODE = 0x0008 +KEYEVENTF_UNICODE = 0x0004 + + +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): + _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 = "remote{}{}".format(self.source[0].upper(), self.source[1:]) + self.scriptPath = getattr(self, "scriptPath", None) + self.script = self.findScript() if self.scriptPath else None + + def findScript(self): + 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, "script_%s" % scriptName, None) + if func: + return func + + # App module level. + app = focus.appModule + if app and cls == "AppModule" and module == app.__module__: + func = getattr(app, "script_%s" % 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_%s" % scriptName, None) + if func: + return func + + # Tree interceptor level. + treeInterceptor = focus.treeInterceptor + if treeInterceptor and treeInterceptor.isReady: + func = getattr(treeInterceptor, "script_%s" % scriptName, None) + if func: + return func + + # NVDAObject level. + func = getattr(focus, "script_%s" % 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, "script_%s" % scriptName, None) + if func: + return func + + return None + + +def sendKey(vk=None, scan=None, extended=False, pressed=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, MAPVK_VK_TO_VSC) + if not pressed: + i.union.ki.dwFlags |= KEYEVENTF_KEYUP + if extended: + i.union.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY + i.type = INPUT_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..d240a92a9a2 --- /dev/null +++ b/source/remoteClient/localMachine.py @@ -0,0 +1,314 @@ +# 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. It serves as the +execution endpoint for remote control operations, translating network commands +into local NVDA actions. + +Key Features: + * Speech output and cancellation with priority handling + * Braille display sharing and input routing with size negotiation + * Audio feedback through wave files and tones + * Keyboard and system input simulation + * One-way clipboard text transfer from remote to local + * System functions like Secure Attention Sequence (SAS) + +The main class :class:`LocalMachine` implements all the local control operations +that can be triggered by remote NVDA instances. It includes safety features like +muting and uses wxPython's CallAfter for most (but not all) 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 . import cues, input + +try: + from systemUtils import hasUiAccess +except ModuleNotFoundError: + from config import hasUiAccess + +import ui + +logger = logging.getLogger("local_machine") + + +def setSpeechCancelledToFalse() -> None: + """Reset the speech cancellation flag to allow new speech. + + This function updates NVDA's internal speech state to ensure future + speech will not be cancelled. This is necessary when receiving remote + speech commands to ensure they are properly processed. + + Warning: + This is a temporary workaround that modifies internal NVDA state. + It may break in future NVDA versions if the speech subsystem changes. + + See Also: + :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 of commands and proper state management + for features like speech queuing and braille display sharing. + + The class provides safety mechanisms like muting to temporarily disable + remote control, and handles coordination of braille display sharing between + local and remote instances, including automatic display size negotiation. + + All methods that interact with NVDA are wrapped with wx.CallAfter to ensure + thread-safe execution, as remote commands arrive on network threads. + + Attributes: + isMuted (bool): When True, most remote commands will be ignored, providing + a way to temporarily disable remote control while maintaining the connection + receivingBraille (bool): When True, braille output comes from the remote + machine instead of local NVDA. This affects both display output and input routing + _cachedSizes (Optional[List[int]]): Cached braille display sizes from remote + machines, used to negotiate the optimal display size for sharing + + Note: + This class is instantiated by the remote session manager and should not + be created directly. All its methods are called in response to remote + protocol messages. + + See Also: + :class:`session.SlaveSession`: The session class that manages remote connections + :mod:`transport`: The network transport layer that delivers remote commands + """ + + def __init__(self) -> None: + """Initialize the local machine controller. + + Sets up initial state and registers braille display handlers. + The local machine starts unmuted with local braille enabled. + """ + self.isMuted: bool = False + self.receivingBraille: bool = False + self._cachedSizes: Optional[List[int]] = None + braille.decide_enabled.register(self.handleDecideEnabled) + + def terminate(self) -> None: + """Clean up resources when the local machine controller is terminated. + + 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: + """Instructed by remote machine to play a wave file.""" + 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. + + Args: + hz: Frequency of the beep in Hertz + length: Duration of the beep in milliseconds + left: Left channel volume (0-100), defaults to 50% + right: Right channel volume (0-100), defaults to 50% + + 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. + + Args: + 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. + + Args: + sequence: List of speech sequences (text and commands) to speak + priority: Speech priority level, defaults to NORMAL + + 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. + + Args: + 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. + + Args: + **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 setBrailleDisplay_size(self, sizes: List[int]) -> None: + """Cache remote braille display sizes for size negotiation. + + Args: + 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. + + Args: + value: Local display size in cells + + Returns: + int: 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. + + Returns: + bool: 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. + + Args: + vk_code: Virtual key code to simulate + extended: Whether this is an extended key + 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. + + Args: + text: Text to copy to the clipboard + **kwargs: Additional parameters (ignored for compatibility) + """ + cues.clipboard_received() + api.copyToClip(text=text) + + def sendSAS(self) -> None: + """ + Simulate a secure attention sequence (e.g. CTRL+ALT+DEL). + + 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(_("No permission on device to trigger CTRL+ALT+DEL 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..f407f87d391 --- /dev/null +++ b/source/remoteClient/menu.py @@ -0,0 +1,176 @@ +# 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 addon that appears in the NVDA Tools menu""" + + connectItem: wx.MenuItem + disconnectItem: wx.MenuItem + muteItem: wx.MenuItem + pushClipboardItem: wx.MenuItem + copyLinkItem: wx.MenuItem + sendCtrlAltDelItem: wx.MenuItem + remoteItem: wx.MenuItem + + def __init__(self, client: "RemoteClient") -> None: + super().__init__() + self.client = client + toolsMenu = gui.mainFrame.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"), + ) + gui.mainFrame.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) + gui.mainFrame.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) + gui.mainFrame.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) + gui.mainFrame.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) + gui.mainFrame.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"), + ) + gui.mainFrame.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 + tools_menu = gui.mainFrame.sysTrayIcon.toolsMenu + tools_menu.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..9b8b44aaafa --- /dev/null +++ b/source/remoteClient/protocol.py @@ -0,0 +1,68 @@ +# 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 +from enum import Enum + +PROTOCOL_VERSION: int = 2 + + +class RemoteMessageType(Enum): + # 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): + """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): + host, port = hostPort + if ":" in host: + host = "[" + host + "]" + if port != SERVER_PORT: + return host + ":" + str(port) + return host diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py new file mode 100644 index 00000000000..3b6a36db27a --- /dev/null +++ b/source/remoteClient/secureDesktop.py @@ -0,0 +1,240 @@ +# 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. + +This module handles the transition between regular and secure desktop sessions in Windows, +maintaining remote connections across these transitions. It 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. +""" + +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 SlaveSession +from .transport import RelayTransport + + +def getProgramDataTempPath() -> Path: + """Get the system's program data temp directory path.""" + if hasattr(shlobj, "SHGetKnownFolderPath"): + return Path(shlobj.SHGetKnownFolderPath(shlobj.FolderId.PROGRAM_DATA)) / "temp" + return Path(shlobj.SHGetFolderPath(0, shlobj.CSIDL_COMMON_APPDATA)) / "temp" + + +class SecureDesktopHandler: + """Maintains remote connections during secure desktop transitions. + + Handles relay servers, IPC, and connection bridging between + regular and secure desktop sessions. + """ + + SD_CONNECT_BLOCK_TIMEOUT: int = 1 + + def __init__(self, temp_path: Path = getProgramDataTempPath()) -> None: + """ + Initialize secure desktop handler. + + Args: + temp_path: Path to temporary directory for IPC file. Defaults to program data temp path. + """ + self.tempPath = temp_path + self.IPCFile = temp_path / "remote.ipc" + log.debug(f"Initialized SecureDesktopHandler with IPC file: {self.IPCFile}") + + self._slaveSession: Optional[SlaveSession] = 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(f"Removing IPC file: {self.IPCFile}") + self.IPCFile.unlink() + except FileNotFoundError: + log.debug("IPC file already removed") + log.info("Secure desktop cleanup completed") + + @property + def slaveSession(self) -> Optional[SlaveSession]: + return self._slaveSession + + @slaveSession.setter + def slaveSession(self, session: Optional[SlaveSession]) -> None: + """ + Update slave session reference and handle necessary cleanup/setup. + + Args: + session: New SlaveSession instance or None to clear + """ + if self._slaveSession == session: + log.debug("Slave session unchanged, skipping update") + return + + log.info("Updating slave session reference") + if self.sdServer is not None: + self.leaveSecureDesktop() + + if self._slaveSession is not None and self._slaveSession.transport is not None: + transport = self._slaveSession.transport + transport.unregisterInbound(RemoteMessageType.set_braille_info, self._onMasterDisplayChange) + self._slaveSession = session + session.transport.registerInbound( + RemoteMessageType.set_braille_info, + self._onMasterDisplayChange, + ) + + def _onSecureDesktopChange(self, isSecureDesktop: Optional[bool] = None) -> None: + """ + Internal callback for secure desktop state changes. + + Args: + 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.slaveSession is None or self.slaveSession.transport is None: + log.warning("No slave 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, bind_host="127.0.0.1") + port = self.sdServer.serverSocket.getsockname()[1] + log.info(f"Local relay server started on port {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.MASTER.value, + ) + self.sdRelay.registerInbound(RemoteMessageType.client_joined, self._onMasterDisplayChange) + self.slaveSession.transport.registerInbound( + RemoteMessageType.set_braille_info, + self._onMasterDisplayChange, + ) + + self.sdBridge = bridge.BridgeTransport(self.slaveSession.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.slaveSession is not None and self.slaveSession.transport is not None: + self.slaveSession.transport.unregisterInbound( + RemoteMessageType.set_braille_info, + self._onMasterDisplayChange, + ) + self.slaveSession.setDisplaySize() + + try: + self.IPCFile.unlink() + except FileNotFoundError: + pass + + def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: + """ + Initialize connection when starting in secure desktop. + + Returns: + ConnectionInfo instance if successful, None otherwise + """ + 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 + + 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.SLAVE, + key=channel, + port=port, + insecure=True, + ) + + except Exception: + log.exception("Failed to initialize secure desktop connection.") + return None + + def _onMasterDisplayChange(self, **kwargs: Any) -> None: + """Handle display size changes.""" + log.debug("Master display change detected") + if self.sdRelay is not None and self.slaveSession is not None: + log.debug("Propagating display size change to secure desktop relay") + self.sdRelay.send( + type=RemoteMessageType.set_display_size, + sizes=self.slaveSession.masterDisplaySizes, + ) + else: + log.warning("No secure desktop relay or slave session available, skipping display change") diff --git a/source/remoteClient/serializer.py b/source/remoteClient/serializer.py new file mode 100644 index 00000000000..0a254f1bfda --- /dev/null +++ b/source/remoteClient/serializer.py @@ -0,0 +1,189 @@ +# 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, +with special handling for speech commands and other NVDA-specific data types. +It provides both a generic Serializer interface and a concrete JSONSerializer +implementation that handles the specific message format used by NVDA Remote. + +The serialization format supports: +- Basic JSON data types +- Speech command objects +- Custom message types via the 'type' field +""" + +from abc import ABCMeta, abstractmethod +from enum import Enum +from logging import getLogger +from typing import Any, Dict, Optional, Type, Union, TypeVar +import json + +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. + """ + + @abstractmethod + def serialize(self, type: Optional[str] = None, **obj: Any) -> bytes: + """Convert a message to bytes for transmission. + + Args: + type: Message type identifier, used for routing + **obj: Message payload as keyword arguments + + Returns: + Serialized message as bytes + """ + raise NotImplementedError + + @abstractmethod + def deserialize(self, data: bytes) -> JSONDict: + """Convert received bytes back into a message dict. + + Args: + data: Raw message bytes to deserialize + + Returns: + Dict containing the deserialized message + """ + 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. + + Args: + type: Message type identifier (string or Enum) + **obj: Message payload to serialize + + Returns: + UTF-8 encoded JSON with newline separator + """ + if type is not None: + if isinstance(type, Enum): + type = type.value + obj["type"] = type + data = json.dumps(obj, cls=CustomEncoder).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. + + Args: + data: UTF-8 encoded JSON bytes + + Returns: + Dict containing the deserialized message + """ + obj = json.loads(data, object_hook=as_sequence) + return obj + + +SEQUENCE_CLASSES = ( + speech.commands.SynthCommand, + speech.commands.EndUtteranceCommand, +) + + +class CustomEncoder(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. + """ + + def default(self, obj: Any) -> Any: + """Convert speech commands to serializable format. + + Args: + obj: Object to serialize + + Returns: + List containing [class_name, instance_vars] for speech commands, + or default JSON encoding for other types + """ + if is_subclass_or_instance(obj, SEQUENCE_CLASSES): + return [obj.__class__.__name__, obj.__dict__] + return super().default(obj) + + +def is_subclass_or_instance(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. + + Args: + unknown: Object or type to check + possible: Type or tuple of types to check against + + Returns: + True if unknown is a subclass or instance of possible + """ + try: + return issubclass(unknown, possible) + except TypeError: + return isinstance(unknown, possible) + + +def as_sequence(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. + + Args: + dct: Dict containing potentially serialized speech commands + + Returns: + Dict with reconstructed speech command objects if applicable, + otherwise returns the input unchanged + """ + 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..733d7ae09da --- /dev/null +++ b/source/remoteClient/server.py @@ -0,0 +1,474 @@ +# 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. +""" + +from logHandler import log +import os +import socket +import ssl +import time +import cffi # noqa # required for cryptography +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from datetime import datetime, timedelta +from enum import Enum +from pathlib import Path +from select import select +from typing import Any, Dict, List, Optional, Tuple + +from .protocol import RemoteMessageType +from .serializer import JSONSerializer +from .secureDesktop import getProgramDataTempPath +from . import configuration + + +class RemoteCertificateManager: + """Manages SSL certificates for the NVDA Remote relay server.""" + + CERT_FILE = "NvdaRemoteRelay.pem" + KEY_FILE = "NvdaRemoteRelay.key" + FINGERPRINT_FILE = "NvdaRemoteRelay.fingerprint" + CERT_DURATION_DAYS = 365 + CERT_RENEWAL_THRESHOLD_DAYS = 30 + + def __init__(self, cert_dir: Optional[Path] = None): + self.cert_dir = cert_dir or getProgramDataTempPath() + self.cert_path = self.cert_dir / self.CERT_FILE + self.key_path = self.cert_dir / self.KEY_FILE + self.fingerprint_path = self.cert_dir / 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.cert_dir, exist_ok=True) + + should_generate = False + if not self._filesExist(): + should_generate = True + else: + try: + self._validateCertificate() + except Exception as e: + log.warning(f"Certificate validation failed: {e}", exc_info=True) + should_generate = True + + if should_generate: + self._generateSelfSignedCert() + + def _filesExist(self) -> bool: + """Check if both certificate and key files exist.""" + return self.cert_path.exists() and self.key_path.exists() + + def _validateCertificate(self) -> None: + """Validates the existing certificate and key.""" + # Load and validate certificate + with open(self.cert_path, "rb") as f: + cert_data = f.read() + cert = x509.load_pem_x509_certificate(cert_data) + + # Check validity period + now = datetime.utcnow() + if now >= cert.not_valid_after or now < cert.not_valid_before: + raise ValueError("Certificate is not within its validity period") + + # Check renewal threshold + time_remaining = cert.not_valid_after - now + if time_remaining.days <= self.CERT_RENEWAL_THRESHOLD_DAYS: + raise ValueError("Certificate is approaching expiration") + + # Verify private key can be loaded + with open(self.key_path, "rb") as f: + serialization.load_pem_private_key(f.read(), password=None) + + def _generateSelfSignedCert(self) -> None: + """Generates a self-signed certificate and private key.""" + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "NVDARemote Relay"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "NVDARemote"), + ], + ) + + cert = ( + x509.CertificateBuilder() + .subject_name( + subject, + ) + .issuer_name( + issuer, + ) + .public_key( + private_key.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(private_key, hashes.SHA256()) + ) + + # Calculate fingerprint + fingerprint = cert.fingerprint(hashes.SHA256()).hex() + # Write private key + with open(self.key_path, "wb") as f: + f.write( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ), + ) + + # Write certificate + with open(self.cert_path, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + + # Save fingerprint + with open(self.fingerprint_path, "w") as f: + f.write(fingerprint) + + # Add to trusted certificates in config + config = configuration.get_config() + 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 get_current_fingerprint(self) -> Optional[str]: + """Get the fingerprint of the current certificate.""" + try: + if self.fingerprint_path.exists(): + with open(self.fingerprint_path, "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.cert_path), + keyfile=str(self.key_path), + ) + # Trust our own CA for server verification + context.load_verify_locations(cafile=str(self.cert_path)) + # 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 + (sent every PING_TIME seconds, no response expected). + + 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. + """ + + PING_TIME: int = 300 + + def __init__( + self, + port: int, + password: str, + bind_host: str = "", + bind_host6: str = "[::]:", + cert_dir: Optional[Path] = None, + ): + self.port = port + self.password = password + self.cert_manager = RemoteCertificateManager(cert_dir) + self.cert_manager.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, + bind_addr=(bind_host, self.port), + ) + self.serverSocket6 = self.createServerSocket( + socket.AF_INET6, + socket.SOCK_STREAM, + bind_addr=(bind_host6, self.port), + ) + + def createServerSocket(self, family: int, type: int, bind_addr: Tuple[str, int]) -> ssl.SSLSocket: + """Creates an SSL wrapped socket using the certificate.""" + serverSocket = socket.socket(family, type) + ssl_context = self.cert_manager.createSSLContext() + serverSocket = ssl_context.wrap_socket(serverSocket, server_side=True) + serverSocket.bind(bind_addr) + serverSocket.listen(5) + return serverSocket + + def run(self) -> None: + """Main server loop that handles client connections and message routing.""" + log.info(f"Starting NVDA Remote relay server on ports {self.port} (IPv4) " f"and {self.port} (IPv6)") + self._running = True + self.lastPingTime = time.time() + while self._running: + r, w, e = select( + self.clientSockets + [self.serverSocket, self.serverSocket6], + [], + self.clientSockets, + 60, + ) + if not self._running: + break + for sock in r: + 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 + 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.send_to_others( + type="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. + """ + + id: int = 0 + + def __init__(self, server: LocalRelayServer, socket: ssl.SSLSocket): + self.server = server + self.socket = socket + self.buffer = b"" + self.serializer = server.serializer + self.authenticated = False + self.id = Client.id + 1 + self.connectionType = None + self.protocolVersion = 1 + Client.id += 1 + + def handleData(self) -> None: + """Process incoming data from the client socket.""" + sock_data = b"" + try: + sock_data = self.socket.recv(16384) + except Exception: + self.close() + return + if not sock_data: # Disconnect + self.close() + return + data = self.buffer + sock_data + 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.send_to_others(**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(f"Failed authentication attempt from client {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 = [] + client_ids = [] + for c in list(self.server.clients.values()): + if c is self or not c.authenticated: + continue + clients.append(c.asDict()) + client_ids.append(c.id) + self.send( + type=RemoteMessageType.channel_joined, + channel=self.server.password, + user_ids=client_ids, + clients=clients, + ) + self.send_to_others( + type="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 | Enum, + origin: Optional[int] = None, + clients: Optional[List[Dict[str, Any]]] = None, + client: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> None: + """Send a message to this client.""" + 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 send_to_others(self, origin: Optional[int] = None, **obj: Any) -> None: + """Send a message to all other authenticated clients.""" + 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, **obj) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py new file mode 100644 index 00000000000..cb99f9d27b3 --- /dev/null +++ b/source/remoteClient/session.py @@ -0,0 +1,623 @@ +# 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: +-------------- +Master (Controlling) + - Captures and forwards input + - Receives remote output (speech/braille) + - Manages connection state + - Patches input handling + +Slave (Controlled) + - Executes received commands + - Forwards output to master(s) + - Tracks connected masters + - Patches output handling + +Key Components: +------------ +RemoteSession + Base session managing shared functionality: + - Message handler registration + - Connection validation + - Version compatibility + - MOTD handling + +MasterSession + Controls remote instance: + - Input capture/forwarding + - Remote output reception + - Connection management + - Master-specific patches + +SlaveSession + Controlled by remote instance: + - Command execution + - Output forwarding + - Multi-master support + - Slave-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 Dict, List, Optional, Any, Union + +import brailleInput +import inputCore +from logHandler import log + + +import braille +import gui +from nvwave import decide_playWaveFile +import scriptHandler +import speech +import tones +import ui +from speech.extensions import speechCanceled, post_speechPaused, pre_speechQueued + +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 master or slave machine. + + This abstract base class defines the core functionality shared between master and slave + sessions. It handles basic session management tasks like: + + - Handling version mismatch notifications + - Message of the day handling + - Connection info management + - Transport registration + + """ + + transport: RelayTransport # The transport layer handling network communication + localMachine: LocalMachine # Interface to control the local NVDA instance + # Session mode - either 'master' or 'slave' + mode: Optional[connectionInfo.ConnectionMode] = None + callbacksAdded: bool # Whether callbacks are currently registered + + def __init__( + self, + localMachine: LocalMachine, + transport: RelayTransport, + ) -> None: + 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. + + log.error("Protocol version mismatch detected with relay server") + + This method is called when the transport layer detects that the client's + protocol version is not compatible. It: + 1. Displays a localized error message to the user + 2. Closes the transport connection + 3. Prevents further communication attempts + """ + 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 either use a different server or upgrade your version of the addon."""), + ) + self.transport.close() + + def handleMOTD(self, motd: str, force_display=False): + """Handle Message of the Day from relay server. + + log.info("Received MOTD from server (force_display=%s)", force_display) + + Displays server MOTD to user if: + 1. It hasn't been shown before (tracked by message hash), or + 2. force_display is True (for important announcements) + + The MOTD system allows server operators to communicate important + information to users like: + - Service announcements + - Maintenance windows + - Version update notifications + - Security advisories + Note: + Message hashes are stored per-server in the config file to track + which messages have already been shown to the user. + """ + 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: + conf = configuration.get_config() + 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: Optional[Dict[str, Any]] = None) -> None: + """Handle new client connection.""" + log.info("Client connected: %r", client) + cues.client_connected() + + def handleClientDisconnected(self, client=None): + """Handle client disconnection. + Plays disconnection sound when remote client disconnects. + """ + cues.client_disconnected() + + def getConnectionInfo(self) -> connectionInfo.ConnectionInfo: + """Get information about the current connection. + + Returns a ConnectionInfo object containing: + - Hostname and port of the relay server + - Channel key for the connection + - Session mode (master/slave) + """ + 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 SlaveSession(RemoteSession): + """Session that runs on the controlled (slave) NVDA instance. + + This class implements the slave side of an NVDA Remote connection. It handles: + + - Receiving and executing commands from master(s) + - Forwarding speech/braille/tones/NVWave output to master(s) + - Managing connected master clients and their braille display sizes + - Coordinating braille display functionality + + The slave session allows multiple master connections simultaneously and manages + state for each connected master separately. + """ + + # Connection mode - always 'slave' + mode: connectionInfo.ConnectionMode = connectionInfo.ConnectionMode.SLAVE + # Information about connected master clients + masters: Dict[int, Dict[str, Any]] + masterDisplaySizes: List[int] # Braille display sizes of connected masters + + def __init__( + self, + localMachine: LocalMachine, + transport: RelayTransport, + ) -> None: + super().__init__(localMachine, transport) + self.transport.registerInbound( + RemoteMessageType.key, + self.localMachine.sendKey, + ) + self.masters = defaultdict(dict) + self.masterDisplaySizes = [] + 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"] == "master": + self.masters[client["id"]]["active"] = True + if self.masters: + self.registerCallbacks() + + def handleChannelJoined( + self, + channel: Optional[str] = None, + clients: Optional[List[Dict[str, Any]]] = None, + origin: Optional[int] = 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 slave session") + cues.client_connected() + + def handleClientDisconnected(self, client: Optional[Dict[str, Any]] = None) -> None: + super().handleClientDisconnected(client) + if client["connection_type"] == "master": + log.info("Master client disconnected: %r", client) + del self.masters[client["id"]] + if not self.masters: + self.unregisterCallbacks() + + def setDisplaySize(self, sizes=None): + self.masterDisplaySizes = ( + sizes if sizes else [info.get("braille_numCells", 0) for info in self.masters.values()] + ) + log.debug("Setting slave display size to: %r", self.masterDisplaySizes) + self.localMachine.setBrailleDisplay_size(self.masterDisplaySizes) + + def handleBrailleInfo( + self, + name: Optional[str] = None, + numCells: int = 0, + origin: Optional[int] = None, + ) -> None: + if not self.masters.get(origin): + return + self.masters[origin]["braille_name"] = name + self.masters[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: Optional[str]) -> None: + """Forward speech output to connected master instances. + + Filters the speech sequence for supported commands and sends it + to master 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 master instances.""" + self.transport.send(type=RemoteMessageType.pause_speech, switch=switch) + + def display(self, cells: List[int]) -> None: + """Forward braille display content to master instances. + + Only sends braille data if there are connected masters with braille displays. + """ + # Only send braille data when there are controlling machines with a braille display + if self.hasBrailleMasters(): + self.transport.send(type=RemoteMessageType.display, cells=cells) + + def hasBrailleMasters(self) -> bool: + """Check if any connected masters have braille displays. + + Returns: + True if at least one master has a braille display with cells > 0 + """ + return bool([i for i in self.masterDisplaySizes if i > 0]) + + +class MasterSession(RemoteSession): + """Session that runs on the controlling (master) NVDA instance. + + This class implements the master side of an NVDA Remote connection. It handles: + + - Sending control commands to slaves + - Receiving and playing speech/braille from slaves + - Playing basic notification sounds from slaves + - Managing connected slave clients + - Synchronizing braille display information + - Patching NVDA for remote input handling + + The master session takes input from the local NVDA instance and forwards + appropriate commands to control the remote slave instance. + """ + + mode: connectionInfo.ConnectionMode = connectionInfo.ConnectionMode.MASTER + slaves: Dict[int, Dict[str, Any]] # Information about connected slave + + def __init__( + self, + localMachine: LocalMachine, + transport: RelayTransport, + ) -> None: + super().__init__(localMachine, transport) + self.slaves = 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.handleChannel_joined, + ) + 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 handleChannel_joined( + self, + channel: Optional[str] = None, + clients: Optional[List[Dict[str, Any]]] = None, + origin: Optional[int] = None, + ) -> None: + if clients is None: + clients = [] + for client in clients: + self.handleClientConnected(client) + + def handleClientConnected(self, client=None): + hasSlaves = bool(self.slaves) + super().handleClientConnected(client) + self.sendBrailleInfo() + if not hasSlaves: + self.registerCallbacks() + + def handleClientDisconnected(self, client=None): + """Handle client disconnection. + Also calls parent class disconnection handler. + """ + super().handleClientDisconnected(client) + if self.callbacksAdded and not self.slaves: + self.unregisterCallbacks() + + def sendBrailleInfo( + self, + display: Optional[Any] = None, + displaySize: Optional[int] = None, + ) -> None: + if display is None: + display = braille.handler.display + if displaySize is None: + displaySize = braille.handler.displaySize + log.debug( + "Sending braille info to slave - 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 handle_decide_executeGesture( + self, + gesture: Union[braille.BrailleDisplayGesture, brailleInput.BrailleInputGesture, Any], + ) -> bool: + 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: + inputCore.decide_executeGesture.register(self.handle_decide_executeGesture) + + def unregisterBrailleInput(self) -> None: + inputCore.decide_executeGesture.unregister(self.handle_decide_executeGesture) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py new file mode 100644 index 00000000000..b30e41f36eb --- /dev/null +++ b/source/remoteClient/transport.py @@ -0,0 +1,732 @@ +# 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. +""" + +import hashlib +import select +import socket +import ssl +import threading +import time +from enum import Enum +from logging import getLogger +from queue import Queue +from typing import Any, Callable, Dict, Optional, Tuple, Union + +from dataclasses import dataclass +import wx +from extensionPoints import Action, HandlerRegistrar + +from . import configuration +from .connectionInfo import ConnectionInfo +from .protocol import PROTOCOL_VERSION, RemoteMessageType +from .serializer import Serializer +from .protocol import hostPortToAddress + +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. + + Args: + extensionPoint: The NVDA extension point to bridge + messageType: The remote message type to send + filter: Optional function to transform arguments before sending + transport: The transport instance (set on registration) + + The filter function, if provided, should take (*args, **kwargs) and return + a new kwargs dict to be sent in the message. + """ + + extensionPoint: HandlerRegistrar + messageType: RemoteMessageType + filter: Optional[Callable[..., Dict[str, Any]]] = None + transport: Optional["Transport"] = None + + def remoteBridge(self, *args: Any, **kwargs: Any) -> bool: + """Bridge function that gets registered to the extension point. + + Handles calling the filter if present and sending the message. + Always returns True to allow other handlers to process the event. + """ + if self.filter: + # Filter should transform args/kwargs into just the kwargs needed for the message + kwargs = self.filter(*args, **kwargs) + if self.transport: + 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: + """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() + + Args: + serializer: The serializer instance to use for message encoding/decoding + + Attributes: + connected (bool): True if transport has an active connection + successful_connects (int): Counter of successful connection attempts + connected_event (threading.Event): Event that is set when connected + serializer (Serializer): The message serializer instance + inboundHandlers (Dict[RemoteMessageType, Callable]): Registered message handlers + + Events: + transportConnected: Fired after connection is established and ready + transportDisconnected: Fired when existing connection is lost + transportCertificateAuthenticationFailed: Fired when SSL certificate validation fails + transportConnectionFailed: Fired when a connection attempt fails + transportClosing: Fired before transport is shut down + """ + + connected: bool + successfulConnects: int + connectedEvent: threading.Event + serializer: Serializer + + def __init__(self, serializer: Serializer) -> None: + self.serializer = serializer + self.connected = False + self.successfulConnects = 0 + self.connectedEvent = threading.Event() + self.inboundHandlers: Dict[RemoteMessageType, Action] = {} + self.outboundHandlers: Dict[RemoteMessageType, RemoteExtensionPoint] = {} + self.transportConnected = Action() + """ + Notifies when the transport is connected + """ + self.transportDisconnected = Action() + """ + Notifies when the transport is disconnected + """ + self.transportCertificateAuthenticationFailed = Action() + """ + Notifies when the transport fails to authenticate the certificate + """ + self.transportConnectionFailed = Action() + """ + Notifies when the transport fails to connect + """ + self.transportClosing = Action() + """ + Notifies when the transport is closing + """ + + def onTransportConnected(self) -> None: + """Handle successful transport connection. + + Called internally when a connection is established. Updates connection state, + increments successful connection counter, and notifies listeners. + + This method: + 1. Increments successful connection counter + 2. Sets connected flag to True + 3. Sets the connected event + 4. Notifies transportConnected listeners + """ + self.successfulConnects += 1 + self.connected = True + self.connectedEvent.set() + self.transportConnected.notify() + + def registerInbound(self, type: RemoteMessageType, handler: Callable) -> None: + """Register a handler for incoming messages of a specific type. + + Adds a callback function to handle messages of the specified RemoteMessageType. + Multiple handlers can be registered for the same message type. + + Args: + type (RemoteMessageType): The message type to handle + handler (Callable): Callback function to process messages of this type. + Will be called with the message payload as kwargs. + + Example: + >>> def handle_keypress(key_code, pressed): + ... print(f"Key {key_code} {'pressed' if pressed else 'released'}") + >>> transport.registerInbound(RemoteMessageType.key_press, handle_keypress) + + Note: + Handlers are called asynchronously on the wx main thread via wx.CallAfter + """ + if type not in self.inboundHandlers: + log.debug("Creating new handler for %s", type) + self.inboundHandlers[type] = Action() + log.debug("Registering handler for %s", type) + self.inboundHandlers[type].register(handler) + + def unregisterInbound(self, type: RemoteMessageType, handler: Callable) -> None: + """Remove a previously registered message handler. + + Removes a specific handler function from the list of handlers for a message type. + If the handler was not previously registered, this is a no-op. + + Args: + type (RemoteMessageType): The message type to unregister from + handler (Callable): The handler function to remove + """ + self.inboundHandlers[type].unregister(handler) + log.debug("Unregistered handler for %s", type) + + def registerOutbound( + self, + extensionPoint: HandlerRegistrar, + messageType: RemoteMessageType, + filter: Optional[Callable] = None, + ): + """Register an extension point to a message type. + + Args: + extensionPoint (HandlerRegistrar): The extension point to register + messageType (RemoteMessageType): The message type to register the extension point to + filter (Optional[Callable], optional): A filter function to apply to the message before sending. Defaults to None. + """ + remoteExtension = RemoteExtensionPoint( + extensionPoint=extensionPoint, + messageType=messageType, + filter=filter, + ) + remoteExtension.register(self) + self.outboundHandlers[messageType] = remoteExtension + + def unregisterOutbound(self, messageType: RemoteMessageType): + """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] + + +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. + + Args: + serializer (Serializer): Message serializer instance + address (Tuple[str, int]): Remote address to connect to + timeout (int, optional): Connection timeout in seconds. Defaults to 0. + insecure (bool, optional): Skip certificate verification. Defaults to False. + + Attributes: + buffer (bytes): Buffer for incomplete received data + closed (bool): Whether transport is closed + queue (Queue[Optional[bytes]]): Queue of outbound messages + insecure (bool): Whether to skip certificate verification + address (Tuple[str, int]): Remote address to connect to + timeout (int): Connection timeout in seconds + serverSock (Optional[ssl.SSLSocket]): The SSL socket connection + serverSockLock (threading.Lock): Lock for thread-safe socket access + queueThread (Optional[threading.Thread]): Thread handling outbound messages + reconnectorThread (ConnectorThread): Thread managing reconnection + """ + + buffer: bytes + closed: bool + queue: Queue[Optional[bytes]] + insecure: bool + serverSockLock: threading.Lock + address: Tuple[str, int] + serverSock: Optional[ssl.SSLSocket] + queueThread: Optional[threading.Thread] + timeout: int + reconnectorThread: "ConnectorThread" + lastFailFingerprint: Optional[str] + + def __init__( + self, + serializer: Serializer, + address: Tuple[str, int], + timeout: int = 0, + insecure: bool = False, + ) -> None: + super().__init__(serializer=serializer) + self.closed = False + # Buffer to hold partially received data + self.buffer = b"" + self.queue = Queue() + self.address = address + self.serverSock = None + # Reading/writing from an SSL socket is not thread safe. + # See https://bugs.python.org/issue41597#msg375692 + # Guard access to the socket with a lock. + self.serverSockLock = threading.Lock() + self.queueThread = None + self.timeout = timeout + self.reconnectorThread = ConnectorThread(self) + self.insecure = insecure + + def run(self) -> None: + self.closed = False + try: + self.serverSock = self.createOutboundSocket( + *self.address, + insecure=self.insecure, + ) + self.serverSock.connect(self.address) + except ssl.SSLCertVerificationError: + fingerprint = None + try: + tmp_con = self.createOutboundSocket(*self.address, insecure=True) + tmp_con.connect(self.address) + certBin = tmp_con.getpeercert(True) + tmp_con.close() + fingerprint = hashlib.sha256(certBin).hexdigest().lower() + except Exception: + pass + config = configuration.get_config() + if ( + hostPortToAddress(self.address) in config["trusted_certs"] + and config["trusted_certs"][hostPortToAddress(self.address)] == fingerprint + ): + self.insecure = True + return self.run() + self.lastFailFingerprint = fingerprint + self.transportCertificateAuthenticationFailed.notify() + raise + except Exception: + self.transportConnectionFailed.notify() + raise + self.onTransportConnected() + self.queueThread = threading.Thread(target=self.sendQueue) + self.queueThread.daemon = True + self.queueThread.start() + 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 + self.connected = False + self.connectedEvent.clear() + self.transportDisconnected.notify() + self._disconnect() + + 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. + + Args: + host (str): Remote hostname to connect to + port (int): Remote port number + insecure (bool, optional): Skip certificate verification. Defaults to False. + + Returns: + ssl.SSLSocket: Configured SSL socket ready for connection + + Note: + The socket is created but not yet connected. Call connect() separately. + """ + 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("Skipping certificate verification for %s:%d", 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, + binary_form: bool = False, + ) -> Optional[Union[Dict[str, Any], bytes]]: + """Get the certificate from the peer. + + Retrieves the certificate presented by the remote peer during SSL handshake. + + Args: + binary_form (bool, optional): If True, return the raw certificate bytes. + If False, return a parsed dictionary. Defaults to False. + + Returns: + Optional[Union[Dict[str, Any], bytes]]: The peer's certificate, or None if not connected. + Format depends on binary_form parameter. + """ + if self.serverSock is None: + return None + return self.serverSock.getpeercert(binary_form) + + def processIncomingSocketData(self) -> None: + """Process incoming data from the server socket. + + Reads available data from the socket, buffers partial messages, + and processes complete messages by passing them to parse(). + + Messages are expected to be newline-delimited. + Partial messages are stored in self.buffer until complete. + + Note: + This method handles SSL-specific socket behavior and non-blocking reads. + It is called when select() indicates data is available. + Uses a fixed 16384 byte buffer which may need tuning for performance. + """ + # 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 a message and routes it to the appropriate handler based on type. + + Args: + line (bytes): Complete message line to parse + + Note: + Messages must include a 'type' field matching a RemoteMessageType enum value. + Handler callbacks are executed asynchronously on the wx main thread. + Invalid or unhandled message types are logged as errors. + """ + obj = self.serializer.deserialize(line) + if "type" not in obj: + log.warn("Received message without type: %r" % obj) + return + try: + messageType = RemoteMessageType(obj["type"]) + except ValueError: + log.warn("Received message with invalid type: %r" % obj) + return + del obj["type"] + extensionPoint = self.inboundHandlers.get(messageType) + if not extensionPoint: + log.warn("Received message with unhandled type: %r %r", messageType, obj) + return + wx.CallAfter(extensionPoint.notify, **obj) + + def sendQueue(self) -> None: + """Background thread that processes the outbound message queue. + + Continuously pulls messages from the queue and sends them over the socket. + Thread exits when None is received from the queue or a socket error occurs. + + Note: + This method runs in a separate thread and handles thread-safe socket access + using the serverSockLock. + """ + 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: str | Enum, **kwargs: Any) -> None: + """Send a message through the transport. + + Serializes and queues a message for transmission. Messages are sent + asynchronously by the queue thread. + + Args: + type (str|Enum): Message type, typically a RemoteMessageType enum value + **kwargs: Message payload data to serialize + + Note: + This method is thread-safe and can be called from any thread. + If the transport is not connected, the message will be silently dropped. + """ + if self.connected: + obj = self.serializer.serialize(type=type, **kwargs) + self.queue.put(obj) + else: + log.error("Attempted to send message %r while not connected", type) + + def _disconnect(self) -> None: + """Internal method to disconnect the transport. + + Cleans up the send queue thread, empties queued messages, + and closes the socket connection. + + Note: + This is called internally on errors, unlike close() which is called + explicitly to shut down the transport. + """ + """Disconnect the transport due to an error, without closing the 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.""" + 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. + + Args: + serializer (Serializer): Message serializer instance + address (Tuple[str, int]): Relay server address + timeout (int, optional): Connection timeout. Defaults to 0. + channel (Optional[str], optional): Channel to join. Defaults to None. + connectionType (Optional[str], optional): Connection type. Defaults to None. + protocol_version (int, optional): Protocol version. Defaults to PROTOCOL_VERSION. + insecure (bool, optional): Skip certificate verification. Defaults to False. + + Attributes: + channel (Optional[str]): Relay channel name + connectionType (Optional[str]): Type of relay connection + protocol_version (int): Protocol version to use + """ + + channel: Optional[str] + connectionType: Optional[str] + protocol_version: int + + def __init__( + self, + serializer: Serializer, + address: Tuple[str, int], + timeout: int = 0, + channel: Optional[str] = None, + connectionType: Optional[str] = None, + protocol_version: int = PROTOCOL_VERSION, + insecure: bool = False, + ) -> None: + """Initialize a new RelayTransport instance. + + Args: + serializer: Serializer for encoding/decoding messages + address: Tuple of (host, port) to connect to + timeout: Connection timeout in seconds + channel: Optional channel name to join + connectionType: Optional connection type identifier + protocol_version: Protocol version to use + insecure: Whether to skip certificate verification + """ + super().__init__( + address=address, + serializer=serializer, + timeout=timeout, + insecure=insecure, + ) + log.info("Connecting to %s channel %s" % (address, channel)) + self.channel = channel + self.connectionType = connectionType + self.protocol_version = protocol_version + self.transportConnected.register(self.onConnected) + + @classmethod + def create(cls, connection_info: ConnectionInfo, serializer: Serializer) -> "RelayTransport": + """Create a RelayTransport from a ConnectionInfo object. + + Args: + connection_info: ConnectionInfo instance containing connection details + serializer: Serializer instance for message encoding/decoding + + Returns: + Configured RelayTransport instance ready for connection + """ + return cls( + serializer=serializer, + address=(connection_info.hostname, connection_info.port), + channel=connection_info.key, + connectionType=connection_info.mode.value, + insecure=connection_info.insecure, + ) + + def onConnected(self) -> None: + self.send(RemoteMessageType.protocol_version, version=self.protocol_version) + 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. + + Args: + connector (Transport): Transport instance to manage connections for + reconnectDelay (int, optional): Seconds between attempts. Defaults to 5. + + Attributes: + running (bool): Whether thread should continue running + connector (Transport): Transport to manage connections for + reconnectDelay (int): Seconds to wait between connection attempts + """ + + running: bool + connector: Transport + reconnectDelay: int + + def __init__(self, connector: Transport, reconnectDelay: int = 5) -> None: + super().__init__() + self.reconnectDelay = reconnectDelay + self.running = True + self.connector = connector + 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("Ending control connector thread %s" % self.name) + + +def clearQueue(queue: Queue[Optional[bytes]]) -> 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. + + Args: + queue (Queue[Optional[bytes]]): 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/url_handler.py b/source/remoteClient/url_handler.py new file mode 100644 index 00000000000..f75267a19f5 --- /dev/null +++ b/source/remoteClient/url_handler.py @@ -0,0 +1,229 @@ +# 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: +- URLHandlerWindow: A custom window class that intercepts and processes NVDARemote URLs +- URL registration and unregistration utilities for Windows registry +- Parsing and handling of NVDARemote connection URLs + +Main Functions: +- register_url_handler(): Registers the NVDARemote URL protocol in the Windows Registry +- unregister_url_handler(): Removes the NVDARemote URL protocol registration +- url_handler_path(): Returns the path to the URL handler executable +""" + +import os +import winreg + +try: + from logHandler import log +except ImportError: + from logging import getLogger + + log = getLogger("url_handler") + +import ctypes +import ctypes.wintypes + +import gui # provided by NVDA +import windowUtils +import wx +from winUser import WM_COPYDATA # provided by NVDA + +from . import connectionInfo + + +class COPYDATASTRUCT(ctypes.Structure): + """Windows COPYDATASTRUCT for inter-process communication. + + This structure is used by Windows to pass data between processes using + the WM_COPYDATA message. It contains fields for: + - Custom data value (dwData) + - Size of data being passed (cbData) + - Pointer to the actual data (lpData) + """ + + _fields_ = [ + ("dwData", ctypes.wintypes.LPARAM), + ("cbData", ctypes.wintypes.DWORD), + ("lpData", ctypes.c_void_p), + ] + + +PCOPYDATASTRUCT = ctypes.POINTER(COPYDATASTRUCT) + +MSGFLT_ALLOW = 1 + + +class URLHandlerWindow(windowUtils.CustomWindow): + """Window class that receives and processes nvdaremote:// URLs. + + This window registers itself to receive WM_COPYDATA messages containing + URLs. When a URL is received, it: + 1. Parses the URL into connection parameters + 2. Validates the URL format + 3. Calls the provided callback with the connection info + + The window automatically handles UAC elevation by allowing messages + from lower privilege processes. + """ + + className = "NVDARemoteURLHandler" + + def __init__(self, callback=None, *args, **kwargs): + """Initialize URL handler window. + + Args: + callback (callable, optional): Function to call with parsed ConnectionInfo + when a valid URL is received. Defaults to None. + *args: Additional arguments passed to CustomWindow + **kwargs: Additional keyword arguments passed to CustomWindow + """ + super().__init__(*args, **kwargs) + self.callback = callback + try: + ctypes.windll.user32.ChangeWindowMessageFilterEx( + self.handle, + WM_COPYDATA, + MSGFLT_ALLOW, + None, + ) + except AttributeError: + pass + + def windowProc(self, hwnd, msg, wParam, lParam): + """Windows message procedure for handling received URLs. + + Processes WM_COPYDATA messages containing nvdaremote:// URLs. + Parses the URL and calls the callback if one was provided. + + Args: + hwnd: Window handle + msg: Message type + wParam: Source window handle + lParam: Pointer to COPYDATASTRUCT containing the URL + + Raises: + URLParsingError: If the received URL is malformed or invalid + """ + if msg != WM_COPYDATA: + return + struct_pointer = lParam + message_data = ctypes.cast(struct_pointer, PCOPYDATASTRUCT) + url = ctypes.wstring_at(message_data.contents.lpData) + log.info("Received url: %s" % url) + try: + con_info = connectionInfo.ConnectionInfo.fromURL(url) + except connectionInfo.URLParsingError: + wx.CallLater( + 50, + gui.messageBox, + parent=gui.mainFrame, + # Translators: Title of a message box shown when an invalid URL has been provided. + caption=_("Invalid URL"), + # Translators: Message shown when an invalid URL has been provided. + message=_('Unable to parse url "%s"') % url, + style=wx.OK | wx.ICON_ERROR, + ) + log.exception("unable to parse nvdaremote:// url %s" % url) + raise + log.info("Connection info: %r" % con_info) + if callable(self.callback): + wx.CallLater(50, self.callback, con_info) + + +def _create_registry_structure(key_handle, data): + """Creates a nested registry structure from a dictionary. + + Args: + key_handle: A handle to an open registry key + data: Dictionary containing the registry structure to create + """ + for name, value in data.items(): + if isinstance(value, dict): + # Create and recursively populate subkey + try: + subkey = winreg.CreateKey(key_handle, name) + try: + _create_registry_structure(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(key_handle, name, 0, winreg.REG_SZ, str(value)) + except WindowsError as e: + raise OSError(f"Failed to set registry value {name}: {e}") + + +def _delete_registry_key_recursive(base_key, subkey_path): + """Recursively deletes a registry key and all its subkeys. + + Args: + base_key: One of the HKEY_* constants + subkey_path: Path to the key to delete + """ + try: + # Try to delete directly first + winreg.DeleteKey(base_key, subkey_path) + except WindowsError: + # If that fails, need to do recursive deletion + try: + with winreg.OpenKey(base_key, subkey_path, 0, winreg.KEY_READ | winreg.KEY_WRITE) as key: + # Enumerate and delete all subkeys + while True: + try: + subkey_name = winreg.EnumKey(key, 0) + full_path = f"{subkey_path}\\{subkey_name}" + _delete_registry_key_recursive(base_key, full_path) + except WindowsError: + break + # Now delete the key itself + winreg.DeleteKey(base_key, subkey_path) + except WindowsError as e: + if e.winerror != 2: # ERROR_FILE_NOT_FOUND + raise OSError(f"Failed to delete registry key {subkey_path}: {e}") + + +def register_url_handler(): + """Registers the URL handler in the Windows Registry.""" + try: + key_path = r"SOFTWARE\Classes\nvdaremote" + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, key_path) as key: + _create_registry_structure(key, URL_HANDLER_REGISTRY) + except OSError as e: + raise OSError(f"Failed to register URL handler: {e}") + + +def unregister_url_handler(): + """Unregisters the URL handler from the Windows Registry.""" + try: + _delete_registry_key_recursive(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Classes\nvdaremote") + except OSError as e: + raise OSError(f"Failed to unregister URL handler: {e}") + + +def url_handler_path(): + """Returns the path to the URL handler executable.""" + 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}" %1'.format(path=url_handler_path()), + }, + }, + }, +} diff --git a/source/setup.py b/source/setup.py index b31ecec65fa..7a8b8d4c8be 100755 --- a/source/setup.py +++ b/source/setup.py @@ -221,6 +221,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 and causes an infinite loop now. + "tomli", ], "packages": [ "NVDAObjects", diff --git a/source/speech/__init__.py b/source/speech/__init__.py index 7090bd8ab50..7e41ca6a31d 100644 --- a/source/speech/__init__.py +++ b/source/speech/__init__.py @@ -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..e12a27a8d5d 100644 --- a/source/speech/extensions.py +++ b/source/speech/extensions.py @@ -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 b642bfdf83f..8dccf44804a 100644 --- a/source/speech/manager.py +++ b/source/speech/manager.py @@ -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 @@ -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/utils/alwaysCallAfter.py b/source/utils/alwaysCallAfter.py new file mode 100644 index 00000000000..2d3ca7f0493 --- /dev/null +++ b/source/utils/alwaysCallAfter.py @@ -0,0 +1,25 @@ +"""Thread-safe GUI updates for wxPython. + +Provides a decorator that ensures functions execute in the main GUI thread +using wx.CallAfter, required for safe interface updates from background threads. +""" + +from functools import wraps + +import wx + + +def alwaysCallAfter(func): + """Makes GUI updates thread-safe by running in the main thread. + + Example: + @alwaysCallAfter + def update_label(text): + label.SetLabel(text) # Safe GUI update from any thread + """ + + @wraps(func) + def wrapper(*args, **kwargs): + wx.CallAfter(func, *args, **kwargs) + + return wrapper diff --git a/source/waves/Push_Clipboard.wav b/source/waves/Push_Clipboard.wav new file mode 100644 index 00000000000..6274d987e56 Binary files /dev/null and b/source/waves/Push_Clipboard.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/source/waves/receive_clipboard.wav b/source/waves/receive_clipboard.wav new file mode 100644 index 00000000000..3fa72478202 Binary files /dev/null and b/source/waves/receive_clipboard.wav differ diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index a1230c6b729..78d838a5122 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -9,6 +9,7 @@ * Support for math in PDFs has been added. This works for formulas with associated MathML, such as some files generated by newer versions of TeX/LaTeX. Currently this is only supported in Foxit Reader & Foxit Editor. (#9288, @NSoiffer) +* Full remote access functionality based on the NVDA Remote add-on has been integrated into core, allowing users to control another computer running NVDA or allow their computer to be controlled remotely for assistance and collaboration. Previously available only as an add-on, this functionality is now built into NVDA with improved security, better integration with NVDA's systems, and enhanced maintainability. (#4390, #17580, @ctoth, @tspivey, @daiverd, NVDA Remote Contributors and funders) * Commands to adjust the volume of other applications besides NVDA have been added. To use this feature, "allow NVDA to control the volume of other applications" must be enabled in the audio settings panel. (#16052, @mltony, @codeofdusk) * `NVDA+alt+pageUp`: Increase the volume of all other applications. diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 8b5ef0d340b..5c4545d97c7 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 @@ -3593,6 +3594,62 @@ 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 {#NvdaRemote} + +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 + +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 + +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 + +1. Open the NVDA menu and select **Tools > Remote > 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 + +1. Open the NVDA menu and select **Tools > Remote > 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 + +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 + +Once the session is active, you can switch between controlling the remote computer and your own: + +* **Start/Stop Controlling:** Press `F11` (default) to toggle between controlling and returning to your own computer. +* **Share Clipboard:** Push text from your clipboard to the other computer by selecting **Tools > Remote > Push Clipboard**. +* **Mute Remote Speech:** Mute the remote computer's speech output by selecting **Tools > Remote > Mute Remote**. + +### Remote Access Key Commands Summary + + +| Action | Key Command | Description | +|--------------------------|----------------------|-------------------------------------------| +| Toggle Control | `F11` | Switch between controlling and local. | +| Push Clipboard | `NVDA+Alt+C` | Send clipboard text to the other machine. | +| Disconnect | `NVDA+Alt+Page Down`| End the remote session. | +| Mute Remote Speech | `NVDA+Alt+M` | Mute speech on the remote computer. | + + ## Add-ons and the Add-on Store {#AddonsManager} Add-ons are software packages which provide new or altered functionality for NVDA.