From e483ed1ac0e715f18f3d62286516c0206787e460 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 24 Dec 2024 13:12:05 -0700 Subject: [PATCH 001/203] Add NVDA Remote functionality to core NVDA This commit integrates the NVDA Remote functionality directly into NVDA core, allowing users to remotely control or be controlled by other NVDA instances without requiring an addon. The integration provides secure, encrypted remote access capabilities with features including: Key Features: - Secure SSL/TLS encrypted connections between NVDA instances - Remote speech, braille, and input control sharing - Support for both direct connections and relay server configurations - Clipboard sharing between connected machines - Persistent remote sessions across UAC/secure desktop transitions - Configurable auto-connect settings - Certificate verification and management for secure connections Technical Implementation: - Adds comprehensive client/server architecture for remote connections - Implements secure transport layer with SSL/TLS encryption - Provides message serialization and protocol handling - Uses wx.CallAfter for thread-safe UI operations - Handles braille display sizing negotiation between instances - Integrates with NVDA's core input/output systems The implementation follows NVDA's coding standards and includes: - Full type hinting for improved maintainability - Comprehensive error handling and logging - Thread-safe operations for UI updates - Clear separation of concerns between components - Detailed documentation throughout the codebase This integration enables a remote access experience for users by providing these capabilities out-of-the-box, while maintaining the security and reliability that NVDA Remote users expect. The code was adapted from the standalone NVDA Remote addon, with modifications to align with NVDA core architecture and coding standards. --- source/core.py | 7 + source/globalCommands.py | 63 +++ source/gui/settingsDialogs.py | 157 ++++++ source/remoteClient/__init__.py | 18 + source/remoteClient/beep_sequence.py | 34 ++ source/remoteClient/bridge.py | 101 ++++ source/remoteClient/callback_manager.py | 35 ++ source/remoteClient/client.py | 470 ++++++++++++++++ source/remoteClient/configuration.py | 58 ++ source/remoteClient/connection_info.py | 92 +++ source/remoteClient/cues.py | 67 +++ source/remoteClient/dialogs.py | 326 +++++++++++ source/remoteClient/input.py | 147 +++++ source/remoteClient/localMachine.py | 319 +++++++++++ source/remoteClient/menu.py | 171 ++++++ source/remoteClient/nvda_patcher.py | 132 +++++ source/remoteClient/protocol.py | 45 ++ source/remoteClient/secureDesktop.py | 211 +++++++ source/remoteClient/serializer.py | 184 ++++++ source/remoteClient/server.pem | 84 +++ source/remoteClient/server.py | 305 ++++++++++ source/remoteClient/session.py | 614 ++++++++++++++++++++ source/remoteClient/socket_utils.py | 20 + source/remoteClient/transport.py | 719 ++++++++++++++++++++++++ source/remoteClient/url_handler.py | 224 ++++++++ source/utils/alwaysCallAfter.py | 25 + source/waves/Push_Clipboard.wav | Bin 0 -> 87533 bytes source/waves/connected.wav | Bin 0 -> 114394 bytes source/waves/controlled.wav | Bin 0 -> 144120 bytes source/waves/controlling.wav | Bin 0 -> 291028 bytes source/waves/disconnected.wav | Bin 0 -> 114394 bytes source/waves/receive_clipboard.wav | Bin 0 -> 86568 bytes 32 files changed, 4628 insertions(+) create mode 100644 source/remoteClient/__init__.py create mode 100644 source/remoteClient/beep_sequence.py create mode 100644 source/remoteClient/bridge.py create mode 100644 source/remoteClient/callback_manager.py create mode 100644 source/remoteClient/client.py create mode 100644 source/remoteClient/configuration.py create mode 100644 source/remoteClient/connection_info.py create mode 100644 source/remoteClient/cues.py create mode 100644 source/remoteClient/dialogs.py create mode 100644 source/remoteClient/input.py create mode 100644 source/remoteClient/localMachine.py create mode 100644 source/remoteClient/menu.py create mode 100644 source/remoteClient/nvda_patcher.py create mode 100644 source/remoteClient/protocol.py create mode 100644 source/remoteClient/secureDesktop.py create mode 100644 source/remoteClient/serializer.py create mode 100644 source/remoteClient/server.pem create mode 100644 source/remoteClient/server.py create mode 100644 source/remoteClient/session.py create mode 100644 source/remoteClient/socket_utils.py create mode 100644 source/remoteClient/transport.py create mode 100644 source/remoteClient/url_handler.py create mode 100644 source/utils/alwaysCallAfter.py create mode 100644 source/waves/Push_Clipboard.wav create mode 100644 source/waves/connected.wav create mode 100644 source/waves/controlled.wav create mode 100644 source/waves/controlling.wav create mode 100644 source/waves/disconnected.wav create mode 100644 source/waves/receive_clipboard.wav 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 b89b60e5fcd..4a11e03e014 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -121,6 +121,9 @@ #: Script category for audio streaming commands. # Translators: The name of a category of NVDA commands. SCRCAT_AUDIO = _("Audio") +#: Script category for Remote commands. +# Translators: The name of a category of NVDA commands. +SCRCAT_REMOTE = _("Remote") # Translators: Reported when there are no settings to configure in synth settings ring # (example: when there is no setting for language). @@ -4891,6 +4894,66 @@ 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): + globalVars.remoteClient.toggleMute() + + @script( + gesture="kb:control+shift+NVDA+c", + category=SCRCAT_REMOTE, + # Translators: Documentation string for the script that sends the contents of the clipboard to the remote machine. + description=_("Sends the contents of the clipboard to the remote machine"), + ) + def script_push_clipboard(self, gesture): + globalVars.remoteClient.pushClipboard() + + @script( + # Translators: Documentation string for the script that copies a link to the remote session to the clipboard. + description=_("""Copies a link to the remote session to the clipboard"""), + category=SCRCAT_REMOTE, + ) + def script_copy_link(self, gesture): + globalVars.remoteClient.copyLink() + # Translators: A message indicating that a link has been copied to the clipboard. + ui.message(_("Copied link")) + + @script( + gesture="kb:alt+NVDA+pageDown", + category=SCRCAT_REMOTE, + # Translators: Documentation string for the script that disconnects a remote session. + description=_("""Disconnect a remote session"""), + ) + def script_disconnect(self, gesture): + if not globalVars.remoteClient.isConnected: + # Translators: A message indicating that the remote client is not connected. + ui.message(_("Not connected.")) + return + globalVars.remoteClient.disconnect() + + @script( + gesture="kb:alt+NVDA+pageUp", + # Translators: Documentation string for the script that invokes the remote session. + description=_("""Connect to a remote computer"""), + category=SCRCAT_REMOTE, + ) + def script_connect(self, gesture): + if globalVars.remoteClient.isConnected() or globalVars.remoteClient.connecting: + return + globalVars.remoteClient.doConnect() + + @script( + # Translators: Documentation string for the script that toggles the control between guest and host machine. + description=_("Toggles the control between guest and host machine"), + category=SCRCAT_REMOTE, + gesture="kb:f11", + ) + def script_sendKeys(self, gesture): + globalVars.remoteClient.toggleRemoteKeyControl(gesture) + #: The single global commands instance. #: @type: L{GlobalCommands} diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 6278c2440b8..0c7b5db549a 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -48,6 +48,7 @@ import gui.contextHelp import globalVars from logHandler import log +from remoteClient import configuration import audio import audioDucking import queueHandler @@ -3332,6 +3333,160 @@ 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() + self.config.write() + 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") @@ -5199,6 +5354,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..c680cefaae1 --- /dev/null +++ b/source/remoteClient/__init__.py @@ -0,0 +1,18 @@ +from .client import RemoteClient + + +def initialize(): + """Initialise the remote client.""" + import globalVars + import globalCommands + + globalVars.remoteClient = RemoteClient() + globalVars.remoteClient.registerLocalScript(globalCommands.commands.script_sendKeys) + + +def terminate(): + """Terminate the remote client.""" + import globalVars + + globalVars.remoteClient.terminate() + globalVars.remoteClient = None diff --git a/source/remoteClient/beep_sequence.py b/source/remoteClient/beep_sequence.py new file mode 100644 index 00000000000..67f7c8b9cbc --- /dev/null +++ b/source/remoteClient/beep_sequence.py @@ -0,0 +1,34 @@ +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) + + +def beep_sequence(*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 beep_sequence_async(*sequence: BeepElement) -> threading.Thread: + """Play an asynchronous beep sequence. + This is the same as beep_sequence, except it runs in a thread.""" + thread = threading.Thread(target=beep_sequence, 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..d6f65cc0677 --- /dev/null +++ b/source/remoteClient/bridge.py @@ -0,0 +1,101 @@ +""" +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/callback_manager.py b/source/remoteClient/callback_manager.py new file mode 100644 index 00000000000..0fdcc5dd4d5 --- /dev/null +++ b/source/remoteClient/callback_manager.py @@ -0,0 +1,35 @@ +from logging import getLogger +from typing import Any, Callable, Dict, List +from collections import defaultdict + +import wx + +logger = getLogger("callback_manager") + + +class CallbackManager: + """A simple way of associating multiple callbacks to events and calling them all when that event happens""" + + def __init__(self) -> None: + self.callbacks: Dict[str, List[Callable[..., Any]]] = defaultdict(list) + + def registerCallback(self, event_type: str, callback: Callable[..., Any]) -> None: + """Registers a callback as a callable to an event type, which can be anything hashable""" + self.callbacks[event_type].append(callback) + + def unregisterCallback(self, event_type: str, callback: Callable[..., Any]) -> None: + """Unregisters a callback from an event type""" + self.callbacks[event_type].remove(callback) + + def callCallbacks(self, type: str, *args: Any, **kwargs: Any) -> None: + """Calls all callbacks for a given event type with the provided args and kwargs""" + for callback in self.callbacks[type]: + try: + wx.CallAfter(callback, *args, **kwargs) + except Exception: + logger.exception("Error calling callback %r" % callback) + for callback in self.callbacks["*"]: + try: + wx.CallAfter(callback, type, *args, **kwargs) + except Exception: + logger.exception("Error calling callback %r" % callback) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py new file mode 100644 index 00000000000..20ad8d16076 --- /dev/null +++ b/source/remoteClient/client.py @@ -0,0 +1,470 @@ +import threading +from typing import Callable, Optional, Set, Tuple + +import api +import braille +import core +import gui +import inputCore +import speech +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 .connection_info import ConnectionInfo, ConnectionMode +from .localMachine import LocalMachine +from .menu import RemoteMenu +from .protocol import RemoteMessageType +from .secureDesktop import SecureDesktopHandler +from .session import MasterSession, SlaveSession +from .socket_utils import addressToHostPort, 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: 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"] + if controlServerConfig["self_hosted"]: + port = controlServerConfig["port"] + hostname = "localhost" + 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) + 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() + # Translators: Message shown when the clipboard is successfully pushed to the remote computer. + ui.message(_("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.client_or_server.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 + self.menu.handleConnecting(connectionInfo.mode) + + def onConnectedAsMaster(self): + log.info("Successfully connected as master") + configuration.write_connection_to_config(self.masterSession.getConnectionInfo()) + self.menu.handleConnected(ConnectionMode.MASTER, True) + ui.message( + # Translators: Presented when connected to the remote computer. + _("Connected!"), + ) + cues.connected() + + 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() + + 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() + self.menu.handleConnecting(connectionInfo.mode) + + @alwaysCallAfter + def onConnectedAsSlave(self): + log.info("Control connector connected") + cues.control_server_connected() + # Translators: Presented in direct (client to server) remote connection when the controlled computer is ready. + speech.speakMessage(_("Connected to control server")) + 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() + 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 + config.write() + 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.")) + 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.patchCallbacksAdded and braille.handler.enabled: + self.masterSession.patcher.registerBrailleInput() + self.localMachine.receivingBraille = True + elif not state: + self.masterSession.patcher.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..2a7994838f2 --- /dev/null +++ b/source/remoteClient/configuration.py @@ -0,0 +1,58 @@ +import os +from io import StringIO + +import configobj +import globalVars +from configobj import validate + +from .connection_info import ConnectionInfo + +CONFIG_FILE_NAME = "remote.ini" + +_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)) + _config = configobj.ConfigObj(infile=path, configspec=configspec, create_empty=True) + val = validate.Validator() + _config.validate(val, copy=True) + 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) + conf.write() diff --git a/source/remoteClient/connection_info.py b/source/remoteClient/connection_info.py new file mode 100644 index 00000000000..100daeacd58 --- /dev/null +++ b/source/remoteClient/connection_info.py @@ -0,0 +1,92 @@ +from dataclasses import dataclass +from enum import Enum +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +from . import socket_utils +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 = socket_utils.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..a289feb9ede --- /dev/null +++ b/source/remoteClient/cues.py @@ -0,0 +1,67 @@ +import os + +import nvwave +import tones + +from . import beep_sequence, configuration + +local_beep = tones.beep +local_playWaveFile = nvwave.playWaveFile + + +def connected(): + if should_play_sounds(): + playSound("connected") + else: + beep_sequence.beep_sequence_async((440, 60), (660, 60)) + + +def disconnected(): + if should_play_sounds(): + playSound("disconnected") + else: + beep_sequence.beep_sequence_async((660, 60), (440, 60)) + + +def control_server_connected(): + if should_play_sounds(): + playSound("controlled") + else: + beep_sequence.beep_sequence_async((720, 100), 50, (720, 100), 50, (720, 100)) + + +def client_connected(): + if should_play_sounds(): + playSound("controlling") + else: + local_beep(1000, 300) + + +def client_disconnected(): + if should_play_sounds(): + playSound("disconnected") + else: + local_beep(108, 300) + + +def clipboard_pushed(): + if should_play_sounds(): + playSound("push_clipboard") + else: + beep_sequence.beep_sequence_async((500, 100), (600, 100)) + + +def clipboard_received(): + if should_play_sounds(): + playSound("receive_clipboard") + else: + beep_sequence.beep_sequence_async((600, 100), (500, 100)) + + +def should_play_sounds(): + return configuration.get_config()["ui"]["play_sounds"] + + +def playSound(filename): + path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "waves", filename)) + return local_playWaveFile(path + ".wav") diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py new file mode 100644 index 00000000000..af38d9e376c --- /dev/null +++ b/source/remoteClient/dialogs.py @@ -0,0 +1,326 @@ +import json +import random +import threading +from typing import Any, Dict, List, Optional, Union +from urllib import request + +import gui +import wx +from logHandler import log +from utils.alwaysCallAfter import alwaysCallAfter + +from . import configuration, serializer, server, socket_utils, transport +from .connection_info import ConnectionInfo, ConnectionMode +from .protocol import SERVER_PORT, RemoteMessageType + + +class ClientPanel(wx.Panel): + host: wx.ComboBox + key: wx.TextCtrl + generate_key: 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.generate_key = wx.Button(parent=self, label=_("&Generate Key")) + self.generate_key.Bind(wx.EVT_BUTTON, self.on_generate_key) + sizer.Add(self.generate_key) + self.SetSizerAndFit(sizer) + + def on_generate_key(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.generate_key_command() + + def generate_key_command(self, insecure: bool = False) -> None: + address = socket_utils.addressToHostPort(self.host.GetValue()) + self.keyConnector = transport.RelayTransport( + address=address, + serializer=serializer.JSONSerializer(), + insecure=insecure, + ) + self.keyConnector.registerInbound(RemoteMessageType.generate_key, self.handle_key_generated) + self.keyConnector.transportCertificateAuthenticationFailed.register(self.handle_certificate_failed) + t = threading.Thread(target=self.keyConnector.run) + t.start() + + @alwaysCallAfter + def handle_key_generated(self, key: Optional[str] = None) -> None: + self.key.SetValue(key) + self.key.SetFocus() + self.keyConnector.close() + self.keyConnector = None + + @alwaysCallAfter + def handle_certificate_failed(self) -> None: + try: + cert_hash = self.keyConnector.lastFailFingerprint + + wnd = CertificateUnauthorizedDialog(None, fingerprint=cert_hash) + a = wnd.ShowModal() + if a == wx.ID_YES: + config = configuration.get_config() + config["trusted_certs"][self.host.GetValue()] = cert_hash + config.write() + 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.generate_key_command(True) + + +class ServerPanel(wx.Panel): + get_IP: wx.Button + external_IP: wx.TextCtrl + port: wx.TextCtrl + key: wx.TextCtrl + generate_key: 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.get_IP = wx.Button(parent=self, label=_("Get External &IP")) + self.get_IP.Bind(wx.EVT_BUTTON, self.on_get_IP) + sizer.Add(self.get_IP) + # 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.external_IP = wx.TextCtrl(self, wx.ID_ANY, style=wx.TE_READONLY | wx.TE_MULTILINE) + sizer.Add(self.external_IP) + # 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.generate_key = wx.Button(parent=self, label=_("&Generate Key")) + self.generate_key.Bind(wx.EVT_BUTTON, self.on_generate_key) + sizer.Add(self.generate_key) + self.SetSizerAndFit(sizer) + + def on_generate_key(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 on_get_IP(self, evt: wx.CommandEvent) -> None: + evt.Skip() + self.get_IP.Enable(False) + t = threading.Thread(target=self.do_portcheck, args=[int(self.port.GetValue())]) + t.daemon = True + t.start() + + def do_portcheck(self, port: int) -> None: + temp_server = 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: + temp_server.close() + wx.CallAfter(self.get_IP.Enable, True) + + def onGetIPSucceeded(self, data: Dict[str, Any]) -> None: + ip = data["host"] + port = data["port"] + is_open = data["open"] + + if is_open: + # Translators: Message shown when successfully getting external IP and the specified port is open + success_msg = _("Successfully retrieved IP address. Port {port} is open.") + # Translators: Title of success dialog + success_title = _("Success") + wx.MessageBox( + message=success_msg.format(port=port), + caption=success_title, + style=wx.OK, + ) + else: + # Translators: Message shown when IP was retrieved but the specified port is not forwarded + warning_msg = _("Retrieved external IP, but port {port} is not currently forwarded.") + # Translators: Title of warning dialog + warning_title = _("Warning") + wx.MessageBox( + message=warning_msg.format(port=port), + caption=warning_title, + style=wx.ICON_WARNING | wx.OK, + ) + + self.external_IP.SetValue(ip) + self.external_IP.SetSelection(0, len(ip)) + self.external_IP.SetFocus() + + def onGetIPFail(self, exc: Exception) -> None: + # Translators: Error message when unable to get IP address from portcheck server + error_msg = _("Unable to contact portcheck server, please manually retrieve your IP address") + # Translators: Title of error dialog + error_title = _("Error") + wx.MessageBox( + message=error_msg, + caption=error_title, + style=wx.ICON_ERROR | wx.OK, + ) + + +class DirectConnectDialog(wx.Dialog): + client_or_server: wx.RadioBox + connection_type: wx.RadioBox + container: wx.Panel + panel: Union[ClientPanel, ServerPanel] + main_sizer: wx.BoxSizer + + def __init__(self, parent: wx.Window, id: int, title: str, hostnames: Optional[List[str]] = None): + super().__init__(parent, id, title=title) + main_sizer = self.main_sizer = wx.BoxSizer(wx.VERTICAL) + self.client_or_server = 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.client_or_server.Bind(wx.EVT_RADIOBOX, self.onClientOrServer) + self.client_or_server.SetSelection(0) + main_sizer.Add(self.client_or_server) + 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.connection_type = wx.RadioBox(self, wx.ID_ANY, choices=choices, style=wx.RA_VERTICAL) + self.connection_type.SetSelection(0) + main_sizer.Add(self.connection_type) + self.container = wx.Panel(parent=self) + self.panel = ClientPanel(parent=self.container) + main_sizer.Add(self.container) + buttons = self.CreateButtonSizer(wx.OK | wx.CANCEL) + main_sizer.Add(buttons, flag=wx.BOTTOM) + main_sizer.Fit(self) + self.SetSizer(main_sizer) + self.Center(wx.BOTH | wx.CENTER) + ok = wx.FindWindowById(wx.ID_OK, self) + ok.Bind(wx.EVT_BUTTON, self.onOk) + self.client_or_server.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.client_or_server.GetSelection() == 0: + self.panel = ClientPanel(parent=self.container) + else: + self.panel = ServerPanel(parent=self.container) + self.main_sizer.Fit(self) + + def onOk(self, evt: wx.CommandEvent) -> None: + if self.client_or_server.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.client_or_server.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.client_or_server.GetSelection() == 0: # client + host = self.panel.host.GetValue() + serverAddr, port = socket_utils.addressToHostPort(host) + mode = ConnectionMode.MASTER if self.connection_type.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.connection_type.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..cc435ec34a4 --- /dev/null +++ b/source/remoteClient/input.py @@ -0,0 +1,147 @@ +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 send_key(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..c808e85610e --- /dev/null +++ b/source/remoteClient/localMachine.py @@ -0,0 +1,319 @@ +"""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. + +Example: + A typical usage from the remote connection handler:: + + local = LocalMachine() + # Handle incoming remote speech + local.speak(["Hello from remote"], priority=Spri.NORMAL) + # Share braille display + local.receivingBraille = True + local.display([0x28, 0x28]) # Show dots 1,2,3,4 in cell + +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.send_key, 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..a358886e005 --- /dev/null +++ b/source/remoteClient/menu.py @@ -0,0 +1,171 @@ +from typing import TYPE_CHECKING + +import wx + +if TYPE_CHECKING: + from .client import RemoteClient + +import gui + +from .connection_info 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/nvda_patcher.py b/source/remoteClient/nvda_patcher.py new file mode 100644 index 00000000000..8b03f51a8c9 --- /dev/null +++ b/source/remoteClient/nvda_patcher.py @@ -0,0 +1,132 @@ +from typing import Any, Optional, Union + +import braille +import brailleInput +import inputCore +import scriptHandler +import speech + +from . import callback_manager + + +class NVDAPatcher(callback_manager.CallbackManager): + """Base class to manage patching of braille display changes.""" + + def registerSetDisplay(self) -> None: + braille.displayChanged.register(self.handle_displayChanged) + braille.displaySizeChanged.register(self.handle_displaySizeChanged) + + def unregisterSetDisplay(self) -> None: + braille.displaySizeChanged.unregister(self.handle_displaySizeChanged) + braille.displayChanged.unregister(self.handle_displayChanged) + + def register(self) -> None: + self.registerSetDisplay() + + def unregister(self) -> None: + self.unregisterSetDisplay() + + def handle_displayChanged(self, display: Any) -> None: + self.callCallbacks("set_display", display=display) + + def handle_displaySizeChanged(self, displaySize: Any) -> None: + self.callCallbacks("set_display", displaySize=displaySize) + + +class NVDASlavePatcher(NVDAPatcher): + """Class to manage patching of synth and braille.""" + + def __init__(self) -> None: + super().__init__() + self.origSpeak: Optional[Any] = None + + def registerSpeech(self) -> None: + if self.origSpeak is not None: + return + self.origSpeak = speech._manager.speak + speech._manager.speak = self.speak + + def unregisterSpeech(self): + if self.origSpeak is None: + return + speech._manager.speak = self.origSpeak + self.origSpeak = None + + def register(self): + self.registerSpeech() + + def unregister(self): + self.unregisterSpeech() + + def speak(self, speechSequence: Any, priority: Any) -> None: + self.callCallbacks("speak", speechSequence=speechSequence, priority=priority) + self.origSpeak(speechSequence, priority) + + +class NVDAMasterPatcher(NVDAPatcher): + """Class to manage patching of braille input.""" + + def registerBrailleInput(self) -> None: + inputCore.decide_executeGesture.register(self.handle_decide_executeGesture) + + def unregisterBrailleInput(self) -> None: + inputCore.decide_executeGesture.unregister(self.handle_decide_executeGesture) + + def register(self): + super().register() + # We do not patch braille input by default + + def unregister(self): + super().unregister() + # To be sure, unpatch braille input + self.unregisterBrailleInput() + + 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.callCallbacks("braille_input", **dict) + return False + else: + return True diff --git a/source/remoteClient/protocol.py b/source/remoteClient/protocol.py new file mode 100644 index 00000000000..d0d8a9f3977 --- /dev/null +++ b/source/remoteClient/protocol.py @@ -0,0 +1,45 @@ +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://" diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py new file mode 100644 index 00000000000..e9f1de2e0d7 --- /dev/null +++ b/source/remoteClient/secureDesktop.py @@ -0,0 +1,211 @@ +"""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 ssl +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 .connection_info 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" + + 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.""" + post_secureDesktopStateChange.unregister(self._onSecureDesktopChange) + self.leaveSecureDesktop() + try: + self.IPCFile.unlink() + except FileNotFoundError: + pass + + @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: + return + + 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 + + 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 + """ + if isSecureDesktop: + self.enterSecureDesktop() + else: + self.leaveSecureDesktop() + + def enterSecureDesktop(self) -> None: + """Set up necessary components when entering 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(): + self.tempPath.mkdir(parents=True, exist_ok=True) + + channel = str(uuid.uuid4()) + self.sdServer = server.LocalRelayServer(port=0, password=channel, bind_host="127.0.0.1") + port = self.sdServer.serverSocket.getsockname()[1] + + 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, + ) + 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] + self.IPCFile.write_text(json.dumps(data)) + + def leaveSecureDesktop(self) -> None: + """Clean up when leaving secure desktop.""" + if self.sdServer is None: + 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 + """ + try: + data = json.loads(self.IPCFile.read_text()) + self.IPCFile.unlink() + port, channel = data + + testSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + testSocket = ssl.wrap_socket(testSocket, ssl_version=ssl.PROTOCOL_TLS) + testSocket.connect(("127.0.0.1", port)) + testSocket.close() + + 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.""" + if self.sdRelay is not None and self.slaveSession is not None: + self.sdRelay.send( + type=RemoteMessageType.set_display_size, + sizes=self.slaveSession.masterDisplaySizes, + ) diff --git a/source/remoteClient/serializer.py b/source/remoteClient/serializer.py new file mode 100644 index 00000000000..065075d4669 --- /dev/null +++ b/source/remoteClient/serializer.py @@ -0,0 +1,184 @@ +"""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 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: + """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.pem b/source/remoteClient/server.pem new file mode 100644 index 00000000000..692f651c393 --- /dev/null +++ b/source/remoteClient/server.pem @@ -0,0 +1,84 @@ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIFrzCCA5egAwIBAgIJAM10H3WD3490MA0GCSqGSIb3DQEBBQUAMG4xCzAJBgNV +BAYTAlVTMREwDwYDVQQIDAhDb2xvcmFkbzESMBAGA1UEBwwJTGl0dGxldG9uMRsw +GQYDVQQKDBJOVkRBIFJlbW90ZSBBY2Nlc3MxGzAZBgNVBAMMEk5WREEgUmVtb3Rl +IEFjY2VzczAeFw0xNDExMjUyMTM5MTJaFw0yNDExMjIyMTM5MTJaMG4xCzAJBgNV +BAYTAlVTMREwDwYDVQQIDAhDb2xvcmFkbzESMBAGA1UEBwwJTGl0dGxldG9uMRsw +GQYDVQQKDBJOVkRBIFJlbW90ZSBBY2Nlc3MxGzAZBgNVBAMMEk5WREEgUmVtb3Rl +IEFjY2VzczCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANPAqnE+vtgS +Bv4cy+1OwrXMD6PtLXDTrzKt9U0xdGV8QF8QgOWJrvBLYS4MYiYG/Gv0x5GybMoD +1vKmx22kPlxe3XicvzpZwuAOKJ1Eic6ruI/DsT/7UjEfmUDSUKwV9ufaPi3R/PpL +RU4ctfWCC0Oo/gxgyc1/tXvIz46tUeDXGq4iMv+xyS8U+VmWBQM9x+2xyM0CYF+o +uC009QQCkgWpcJlKEhaw7qEqcYtsRowNV0+ZqgtsZ1i9lPcmbqIm2cRc33EhfOam +NBEW2bMohpc8FW5j9h0Xv9xwv1doI/mPg41jP5+6ZQ9pP8dtZ6f9fYsAIzLKVYJK +sfYMQrLCINVnvCfMVme/XJv8Rsto6S+EK8qpBMqKjjq4NryumTy3j8O75WjwUV1a +nGm3nAOpQ0uSbeD4KKOOcH6SCcqc6RnrrKQTnmcRdGCCEACryTCM/oKahsxBjNW2 ++rFoLIsO0lpZrxMRm72uHwevtqthyRD0/06iXgWY44CQqaIEijoUW5rQaFZ1UvmC +2+TNgDL19ooZuCU+VeKOmplMpr/FlJY1YNNLGCPz5Up08Woo/CaAW+wv7iBNH9Dm +NiGtJwth0G8GdJTi8VDtQyiTDgXJhcq8KdxHOUPzgRCm2iprbdTbGg+ANP9mWWvH +cbJNriT5O2vpPi9bp34cSmkM5BRdmdQLAgMBAAGjUDBOMB0GA1UdDgQWBBTGQYxO +U5m4jzifDpt04NkSJfUXPDAfBgNVHSMEGDAWgBTGQYxOU5m4jzifDpt04NkSJfUX +PDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQB/giqhMXP1hGWYs2cW +gs/8gsbKrHwl3D7oRb3hsQqV0dUUH6z7FPAVc3LdjGSnpVlDPN3M4WNlv6kgZNCB +XFtwL7dNjQaCijP+PAemtyY557yGIj2cU3IKPWwKViaVb3jO8JhJG2zsVjMJT0po +H3T5CkIeIb58S3gt1r968WLWtWhLn+miOWq2K1FeMk5bQgNS6MIwXqQZlwVnPac3 +uX8hFjnt3QqyiCEejKLUDwkkfNz8KDE7dlqhDlQeUS0ILRAc79tmoJl8UsKcWqON +V0OqrBoMjtvQO4oNezoZRjNwmyWVXwKMsgVCHZNmnw91OGZ/6jGLWgASSD2EtuuR +K/1nkeqLSMkpYYURidECRW3CJqwD8u3TJlD5rQsV51dCdSqljO5dexh7ERnmmalh +cOaM/qxqZYzvS1+6jtDDFzuiC/wqAPWnL0SWYNE9AeTLG1BicQVhGRMhIdOUPE7d +VI8ZhRRrlgt7oWgvSC68x5b7z5yCX0MkXpooESoiB7FrCMkpnT1kl1k27DDY6umu +eESZo6mT4Gi3KEaiusTk1hHA0lK70uGtYzoEN343uMO2Gk941wX9iaZEAi77LV23 +8CVTpk/uAQXRXVSA6lrJ/RT+BuuFrl8dzk6jSaccWvD+Z9UnP0iGE2H6q82Y7KZc +rLW6fuzwKmvZuVcOFDXigHXJwQ== +-----END CERTIFICATE----- diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py new file mode 100644 index 00000000000..ee0a2abf431 --- /dev/null +++ b/source/remoteClient/server.py @@ -0,0 +1,305 @@ +"""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 + +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. + +Key Classes: + LocalRelayServer: The main relay server that accepts connections and routes messages + Client: Represents a connected remote client and handles its message processing + +Example: + server = LocalRelayServer(port=6837, password="secret") + server.run() +""" + +import logging +import os +import socket +import ssl +import time +from enum import Enum +from select import select +from typing import Any, Dict, List, Optional, Tuple + +from .protocol import RemoteMessageType +from .serializer import JSONSerializer + +logger = logging.getLogger(__name__) + + +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 + _running: bool = False + port: int + password: str + clients: Dict[socket.socket, "Client"] + clientSockets: List[socket.socket] + serverSocket: ssl.SSLSocket + serverSocket6: ssl.SSLSocket + lastPingTime: float + + def __init__(self, port: int, password: str, bind_host: str = "", bind_host6: str = "[::]:"): + self.port = port + self.password = password + self.serializer = JSONSerializer() + # Maps client sockets to clients + self.clients = {} + self.clientSockets = [] + self._running = False + 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: + serverSocket = socket.socket(family, type) + certfile = os.path.join( + os.path.abspath( + os.path.dirname(__file__), + ), + "server.pem", + ) + serverSocket = ssl.wrap_socket(serverSocket, certfile=certfile) + serverSocket.bind(bind_addr) + serverSocket.listen(5) + return serverSocket + + def run(self) -> None: + 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: + try: + clientSock, addr = sock.accept() + except (ssl.SSLError, socket.error, OSError): + logger.exception("Error accepting connection") + 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: + self.clients[client.socket] = client + self.clientSockets.append(client.socket) + + def removeClient(self, client: "Client") -> None: + del self.clients[client.socket] + self.clientSockets.remove(client.socket) + + def clientDisconnected(self, client: "Client") -> None: + self.removeClient(client) + if client.authenticated: + client.send_to_others( + type="client_left", + user_id=client.id, + client=client.asDict(), + ) + + def close(self) -> None: + self._running = False + self.serverSocket.close() + self.serverSocket6.close() + + +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. Invalid or unparseable messages will cause client disconnection. + + Unauthenticated clients can only send join and protocol_version messages. + The join message must include the correct channel password in its 'channel' field. + Once authenticated, all valid messages are forwarded to other connected clients. + When this client disconnects, all other clients are notified via client_left message. + """ + + id: int = 0 + server: LocalRelayServer + socket: ssl.SSLSocket + buffer: bytes + authenticated: bool + connectionType: Optional[str] + protocolVersion: int + + 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: + sock_Data: bytes = b"" + try: + # 16384 is 2^14 self.socket is a ssl wrapped socket. + # Perhaps this value was chosen as the largest value that could be received [1] to avoid having to loop + # until a new line is reached. + # However, the Python docs [2] say: + # "For best match with hardware and network realities, the value of bufsize should be a relatively + # small power of 2, for example, 4096." + # This should probably be changed in the future. + # See also transport.py handle_server_data in class TCPTransport. + # [1] https://stackoverflow.com/a/24870153/ + # [2] https://docs.python.org/3.7/library/socket.html#socket.socket.recv + buffSize = 16384 + sock_Data = self.socket.recv(buffSize) + 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: + logger.exception("Error parsing line") + self.close() + return + self.buffer += data + + def parse(self, line: bytes) -> None: + 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]: + return dict(id=self.id, connection_type=self.connectionType) + + def do_join(self, obj: Dict[str, Any]) -> None: + password = obj.get("channel", None) + if password != self.server.password: + self.send( + type=RemoteMessageType.error, + message="incorrect_password", + ) + self.close() + return + self.connectionType = obj.get("connection_type") + self.authenticated = True + 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: + version = obj.get("version") + if not version: + return + self.protocolVersion = version + + def close(self) -> None: + 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: + 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: + self.close() + + def send_to_others(self, origin: Optional[int] = None, **obj: Any) -> None: + 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..ed91c438ba1 --- /dev/null +++ b/source/remoteClient/session.py @@ -0,0 +1,614 @@ +"""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 + nvda_patcher.py: Feature patches +""" + +import hashlib +from collections import defaultdict +from typing import Dict, List, Optional, Tuple, Any, Callable + +from logHandler import log + + +import braille +import gui +import nvwave +import speech +import tones +import ui +from speech.extensions import speechCanceled, post_speechPaused + +from . import configuration, connection_info, cues, nvda_patcher + +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[connection_info.ConnectionMode] = None + # Patcher instance for NVDA modifications + patcher: Optional[nvda_patcher.NVDAPatcher] + patchCallbacksAdded: bool # Whether callbacks are currently registered + + def __init__( + self, + localMachine: LocalMachine, + transport: RelayTransport, + ) -> None: + log.info("Initializing Remote Session") + self.localMachine = localMachine + self.patcher = None + self.patchCallbacksAdded = 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 registerCallbacks(self) -> None: + """Register all callback handlers for this session. + + Registers the callbacks returned by _getPatcherCallbacks() with the patcher. + Sets patchCallbacksAdded flag when complete. + """ + patcher_callbacks = self._getPatcherCallbacks() + for event, callback in patcher_callbacks: + self.patcher.registerCallback(event, callback) + self.patchCallbacksAdded = True + + def unregisterCallbacks(self): + """Unregister all callback handlers for this session. + + Unregisters the callbacks returned by _getPatcherCallbacks() from the patcher. + Clears patchCallbacksAdded flag when complete. + """ + patcher_callbacks = self._getPatcherCallbacks() + for event, callback in patcher_callbacks: + self.patcher.unregisterCallback(event, callback) + self.patchCallbacksAdded = False + + 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 + conf.write() + return True + + def handleClientConnected(self, client: Optional[Dict[str, Any]] = None) -> None: + """Handle new client connection. + + log.info("Client connected: %r", client) + + Registers the patcher and callbacks if needed, then plays connection sound. + Called when a new remote client establishes connection. + """ + self.patcher.register() + if not self.patchCallbacksAdded: + self.registerCallbacks() + cues.client_connected() + + def handleClientDisconnected(self, client=None): + """Handle client disconnection. + + Plays disconnection sound when remote client disconnects. + """ + cues.client_disconnected() + + def getConnectionInfo(self) -> connection_info.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 connection_info.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: connection_info.ConnectionMode = connection_info.ConnectionMode.SLAVE + # Patcher instance for NVDA modifications + patcher: nvda_patcher.NVDASlavePatcher + # 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.patcher = nvda_patcher.NVDASlavePatcher() + 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: + super().registerCallbacks() + self.transport.registerOutbound( + tones.decide_beep, + RemoteMessageType.tone, + ) + self.transport.registerOutbound( + speechCanceled, + RemoteMessageType.cancel, + ) + self.transport.registerOutbound( + nvwave.decide_playWaveFile, + RemoteMessageType.wave, + ) + braille.pre_writeCells.register(self.display) + post_speechPaused.register(self.pauseSpeech) + + def unregisterCallbacks(self) -> None: + super().unregisterCallbacks() + self.transport.unregisterOutbound(RemoteMessageType.tone) + self.transport.unregisterOutbound(RemoteMessageType.cancel) + self.transport.unregisterOutbound(RemoteMessageType.wave) + braille.pre_writeCells.unregister(self.display) + post_speechPaused.unregister(self.pauseSpeech) + + def handleClientConnected(self, client: Dict[str, Any]) -> None: + super().handleClientConnected(client) + if client["connection_type"] == "master": + self.masters[client["id"]]["active"] = True + + 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. + + Unregisters the patcher and removes any registered callbacks + to ensure clean shutdown of remote features. + """ + log.info("Transport closing, unregistering slave session patcher") + self.patcher.unregister() + if self.patchCallbacksAdded: + 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() + self.patcher.unregister() + + 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.patcher.unregister() + + 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 _getPatcherCallbacks(self) -> List[Tuple[str, Callable[..., Any]]]: + """Get callbacks to register with the patcher. + + Returns: + Sequence of (event_name, callback_function) pairs for: + - Speech output + - Display size updates + """ + return ( + ("speak", self.speak), + ("set_display", 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 speak(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: connection_info.ConnectionMode = connection_info.ConnectionMode.MASTER + patcher: nvda_patcher.NVDAMasterPatcher + 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.patcher = nvda_patcher.NVDAMasterPatcher() + 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 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): + super().handleClientConnected(client) + self.sendBrailleInfo() + + def handleClientDisconnected(self, client=None): + """Handle client disconnection. + + Unregisters the patcher and removes any registered callbacks. + Also calls parent class disconnection handler. + """ + super().handleClientDisconnected(client) + self.patcher.unregister() + if self.patchCallbacksAdded: + 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="set_braille_info", + name=display.name, + numCells=displaySize, + ) + + def brailleInput(self) -> None: + self.transport.send(type=RemoteMessageType.braille_input) + + def _getPatcherCallbacks(self) -> List[Tuple[str, Callable[..., Any]]]: + """Get callbacks to register with the patcher. + + Returns: + Sequence of (event_name, callback_function) pairs for: + - Braille input handling + - Display info updates + """ + return ( + ("braille_input", self.brailleInput), + ("set_display", self.sendBrailleInfo), + ) diff --git a/source/remoteClient/socket_utils.py b/source/remoteClient/socket_utils.py new file mode 100644 index 00000000000..2f84134d35c --- /dev/null +++ b/source/remoteClient/socket_utils.py @@ -0,0 +1,20 @@ +import urllib.parse + +from .protocol import SERVER_PORT + + +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/transport.py b/source/remoteClient/transport.py new file mode 100644 index 00000000000..16d93373682 --- /dev/null +++ b/source/remoteClient/transport.py @@ -0,0 +1,719 @@ +"""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 .connection_info import ConnectionInfo +from .protocol import PROTOCOL_VERSION, RemoteMessageType +from .serializer import Serializer +from .socket_utils 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: + self.inboundHandlers[type] = Action() + 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) + + 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. + """ + 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() + if insecure: + ctx.verify_mode = ssl.CERT_NONE + 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.error("Received message without type: %r" % obj) + return + try: + messageType = RemoteMessageType(obj["type"]) + except ValueError: + log.error("Received message with invalid type: %r" % obj) + return + del obj["type"] + extensionPoint = self.inboundHandlers.get(messageType) + if not extensionPoint: + log.error("Received message with unhandled type: %r" % 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..9dd03b044a9 --- /dev/null +++ b/source/remoteClient/url_handler.py @@ -0,0 +1,224 @@ +""" +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 connection_info + + +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 = connection_info.ConnectionInfo.fromURL(url) + except connection_info.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/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 0000000000000000000000000000000000000000..6274d987e56c82a40233f3c364c50eda97e898a1 GIT binary patch literal 87533 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 K|304RKI+!EOs}t~sF62oAZc>b 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ŷ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 literal 0 HcmV?d00001 diff --git a/source/waves/controlled.wav b/source/waves/controlled.wav new file mode 100644 index 0000000000000000000000000000000000000000..99838e94f77b0ff0609dae2af7fb3bb34afe86e0 GIT binary patch literal 144120 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 N4^M4=O;_EF{{zO7F1-K% literal 0 HcmV?d00001 diff --git a/source/waves/controlling.wav b/source/waves/controlling.wav new file mode 100644 index 0000000000000000000000000000000000000000..a0999ecb99dc6744977e71a6f9c7e86b8dbf1f5c GIT binary patch literal 291028 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

95?d`2J?hU-JtUs_4G0xT-|vBt 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

Qu}_%ndA1}iGe1wLUPWJMFKh(pHBWf) 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ÃhaG%XegJ+gV64j)ax&NKU+F61|8&)Lo5o4 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

>w^!neUz7WsE5u1Uk4S~F9T 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 T(f;p$*?Il%|MveAzxm$)$T8&} literal 0 HcmV?d00001 diff --git a/source/waves/disconnected.wav b/source/waves/disconnected.wav new file mode 100644 index 0000000000000000000000000000000000000000..1b7ba96cde6d7f7e9fdb94e3aafc51e0b9687f5b GIT binary patch literal 114394 zcmWKXX;e*J7{|{!_YC{od%JaSsK`vn7%3r1BqS20lDRTO2@#oxWGW$J$yCOqL^4mI zyh@a@WXfFK;mp(P%U*l!wLk2&p66M6@89$PPa5OvTXh?TLcD`VMJ-y^!3KgL7($@+ zH~vi`Q3!|Zp}Dh`&6@jf&uj>?f}j>Kq*0WU05YqffcB8771YTM!h1qHouEDKpkL3F zfLbN7onnquQr+Z`D%p0t#QV#3`$UIkNpumVaw%Z2xVb^va7Fy(CfAopnP=q3J(V*( zmFOVo4x_a114rxOhp%AD8>!SH74uc4uVGJHG?dh32^;)jrCxol43C zc|{3-GDFVjMvZ?Z*OxZ8CFEANjbDijWw-#c~5gq3wFX{^VdAO;1Xjf zr1tNX*8ZY|nP{CWbEZPG=`nlQ!_eac-)3s7julcaWRFaNC(gASGZ-tl++vgy8MRAK ztxlx`@IXX@eUbpR^7Qsj78zy`MBRAdG8$Ddl2 zlXf6ul4L_JEUgl~dqA)63V&ndVb=vfi9>NgRD|r-e3wyzyFVAC7CI!c-`)uIzZtg{ z65-FT@sO)}@YMlIZ(r&7A4Ss&TI>uF&T#MosL>J5ItiUP1SLL)QjRIv8dy~(rRZS% zg0Rd5TIR;zI4}Qqg}X2lDr>lB|>T6QN;wWut!Z5 zNL{Q-ZKb+kr;WIIgTK*=vv?|5IA1+yCg4oqBafpk2)`8U)jPCrEA-DHB>x5Cvk|)72w&-=EcSxk zbjst$(DDPy_w$g=3MjA+I{Xs;yb(@tR_(ulY#WX?>d-%Sm_vK)>MCqt9u~=>+!rk3 zw5p^5TZ|%0%CJofp&7wg7e*dB06pv{Wkw*4@A)!!i2ul}x074AQ&YN&cV?KU9^%Wx zOk^VWnlVm2&EE1e`L<%=b>>wp^E`)oww!f$WdCg7vemr*4L)L`P2m|Z3!%zF z1>c~gPf&jMQles&8RwPe8_InKI^P;{{e|?n4n1?n=9Iy&wh|uwRrzN~)fM#UD$Tw= z_~z<=Q6skITO~gRP7%7zx5?FQt#?mQUq5AidzJdTm-VdCYWobUXZuOKzh*!vsC)#* zJi;$+!%IhEJ0_zmT+l9l$ka@g9}K(AQh8a!or6`mU687Issnzi<~^!+jjGJIDtrKX zB@kU1i^?RH_8hJGgE{oaTyEk{ld#fpgtjGSNhY@MMJLT8{2r+KHR9`nkc+>uhn~`d;D%SokH=+mo-G=-5n~pcI zPz#T6Xwp&#;gkiluK?eU#Mxl7?*-z*S@O_S5V3=FUkL8WVA*tH zi#_PG4%@3Gj)$m79xF7!+P7$@Ou5%?)u=|Hy8-bS%GD)7QLky#A`fe@phqNewt49i zF>kWzX0*VT8S4-7ckdaU2JGh{sC! z<=fSq2#SYP#hkAN!210Ii|)eXH{0Y_y!uiwiCpS!%KImi;fY0 z}l1zp7`a>+|E)feALF&XPy%tUYc5 zr?pm=*Tn3zn!QfMyLaTeYJB!OAUEUeC1UdgqQIFrS3vZ?f_p23W-zWE2@aQHpAG?Z z751G6cJr|Tp5)}CSepT);|HwjD+nsVvX_9NztLg;5#{Suk6z-YQh3#B>_y$b>q9lH zL~_(1I|GHy-caubmd%$>zooV7rIz8;{sOUPyrp!Y*lWER`6z6aP3&%=qs5dOAPkyl z?ma;8Xfme{5jxzkJoXhbN~oX1h5bM1C7pyYky&5EzdXW*j^P^*vR-RAZwGey7PkE) zCVB~TZzer(96d0Os_07HIc^DewzTBTw&~`1iRPKh%+_A!?nUM{F=nQP+zA_=*};+mBt1MCv=LXmniOldR!KrlHulGV&C3~ zCnZvu$lxF1shi0A2cozh@yQTp*dZ;piyv;lOtm=nAUtHfG`bva8zP^ZfLtA_tnH>+ zIu=TfKm$*}n|@&12dZKV3HP^XUJXbDG zK3zS#%y1$|U7BogN7Y%ydM1UOKi?+q9jGeTshoihv5LAzOx9@r+fIycLmGDwxm$>C zImE_k_|A_+&{|CA4k})wlXJkLIq3A>V5LzLUj8ub>c(RCq{jOM$4Vl=C?3s?-dex)5RPif3*4X2jOIsdA6(g{G~;IKol#e0k6eDHFT>x z;(|{M?;);!&hCj43Z`<##r*Fm&SNge>p9@X;!D}-8s_x@rt}N-ERA-0V{wn6Ix%MZ z*%se$v;L|1Q#bRpeDmFzX6l={@~3&rcuUKR7I%|HcahrvgDU$$rw(J3VeHHw?7JAw z`T_4eoUhImJs&`;2crC~&wn8IezQqluI`5$B1+Yb>kXMz>gs5N?Gg3L5Bjw3>elaV+NF`6 zbF6Lu0|89*)<-G)PaFxRS!z>=z18Qghh9O zT&}B*p|TH&bh#jeE`;J1afMt)Hd1R?Mw%pV%(H#B2bUnsAh%me5m=~m&Z9Oco zrzM~@W!;rp?L`Or(Di4Tuj`nZ;oQY5tY?5gKje0dk^UU#dtl0eVS*(X@;f3Bt5|9Z-EB zl|DDomvf*&o$;=X@aUU_{ZUoKTCztFRvDu4gb32tYU5^b)X5rDkc%GK>?&2eH0g&; z){NO_Xt!3=?TR5hOye}k@Cwmn#_B^htGP}#d(33QF5UO#WZgGyCl^wgs4474!k@_K zv84Jas9r=iZX}R{q{lv7`-yz^40D*QF7U)c->JqD>^jxXh56#ltP|P!d)XyA z!RZx84wkt4eA@v^_sPQY%g}=Y;eHl88xv!?BK2d$jWI~87||;odA?X&dmI6C#7oDK zadX6qOr&bJ=yeS#byB8Kw@_6m+p2uwC+0G)WYucck4AeULxU-@lAsePZgz3V8~i|p6^RHtYLO{cDP zp||d%xWATP>!_k<=2e}kepRMFt1Za})8{Pn^DN_oE2d*9MqANX^TGHc&$u|v^!1PN zQ;OODmg&-EOOlmkt%cGQP{D4@!VQcR%4%kE#iKa4@xs1u+}u7=@?c*2EDw+1m&HMc z=konuzyVP_&LS_D@R6merR({~R%mt#Z~U%G$>a4aRShb^)Iw!CA?*Bx{3;gbwp9g< zly^Q>t?vNkzC!o5LC~H!2~g*9V#fzeNh5ckBFY0a@HBF_wbkZ&wWeA(RHGdhVsqey zHg}soaGg$xCy7jG~NvMC|5J-4(5`tv3-Co zo2S`Yf{l8l?z;f%caz*(j2;gF509(<+l(*hinL5c14ly57ZB7=E31=@~ckJL!WZwJ^H{R_CyNZ{RLg;NLM>hMJuSBmFDL=EUvbud)v+4 zO0)62DL$b2!%t%l+B{>Z@ws*L{722oA%9t|E-1nq$ZM~)A zSyTK5I?>yrYt05Pq!+H`;>+1D>3rjFe%ik;@ENhbK*+L@-LDAG^@__<;qwJ$Y%ige z7V@+fK1V@I(tF2NvpgqM&a#HDvIa!Z(?lpht(ZE>HgJYnUz(Wed?s4n$J1vF~`X% zRcg=CU}!JRtMSCmTbj<*c-%BC^9>*VSeqX~{QggS-a?d)*De_kPK?&vat3r2`D87z z#1SkH!G{ImZ9kwDN725Xs${(i+YcvRg+pr8bZyfA~0{3bun=11*Qx*g)(te{Q@`0K-< zWmoxpZ|HDq;e7yf@1yWN13KDS+Uo+Z`6X9+BiR@nRj*2QQhBz*6K$}hTcGX~9=t(w zzMAmFbmdn-Z_?)XB(l$1z4JYC&sxLsmTG@@+ty*~!FO%n?obc2ZE@ z%oW6%^Hwu3aHE6O$Ri+TkoLt-FtM$st{UY3Av?r@ap%FyZ$#__qGKR&y#{OEiSPD9 zCq-d13lZkFs<|s%xE@}4TWNeGFFGZAHVR`{X>ugDp;USo%S4-`^!9YBm;84YmAFE# zX-BPCA#WW_{Ubkl2Px>3RAfV^GGg9h`ayT`c{Y8{Q@C46bW zW<1Q)SB|bdUq8NKSRDeeM;hFaWqvTZc<$Y}0u9y7HgpFNV>ae#02jPH7t zw|5awZRedrq&36&IZkpq%6D5UYbnk!L#}Ge|3^vR)A{)sQb3dtm@a)x7N0`0?!6TD zQa(ObaYv!kk09TJaMcjRJ6TnALUp|WTVzJV5s=p#-#bU`7eZv-)+S8_Js6z@CnK9| zJpIX_Ee2!+IU>t;a4QmT-(qkR_@&sMssl@%Y;QII<4Jv*6=}L-{UwY%U2Nr4N)D*h z3?8nwIH@f+)n4UbX-`eZ|A;9Gnymu}k6oH}G(L5?=9dj|a+s!S6%kXb<{|LiO}%L$ zSl~qx`#};*bXo}pti-!%K#L#fr#{5Vg{rtG*jzn=HmJsahMe=^Z-14#1AT#KF~DbHD&v_>GgTKX;X9A4rX$_aYQVuzGc4a z$=!ZJdA;ERuQ11)`Kxod;(@&SJ^vK&HEqOMPq?=Z(t%@KS&7u^JXeFsMv;qcAL5JWo>;FY?Fq1Clji9z@a4D_Tm;%hS$94Ns-M`jp9$XH*L(B>*TW4=YmjxyaHc(I zTw`e46Lh+uf7c7#Xlv8h7i?>%I~)L(wbC~21LsDo-wR;OJ@9)K8Gn-asF1I8glefe zR!c0tq+XCf3_YZt#1N;P)m0FLf(m{ytCZ)9F{CUx^h#6%s>4(xa)1;@j zDCZ;6u=kWlk#sVYK6XrUqUeqR()CTumVEK0mc3OjJJ2!5a(t%JL+`GP19E)Nu=4X{6LtVMFtS8tV0- zW~cA;47oW?$5xw-{d#cDL(K+9KJF)(O&+vjkxKV zu)VXm0F|D2h#lL>r8C7JpXA+p#p6Ac4gZM-mEzt~@;xIrCrJgf<<(BIAzB{LU1@qD zk6Q(8yrYZ};mkqs@0F@mEmbLx(1DIv%U76oATc!u-#LN&<4Mp{HN$g>Po7o{4B>Cq zxx0hFcpHy7fHKllAcfLBQi0Re+6x-+wMhNK z16V$TkK2H1D-$fKF~Q8!rE7yD>Ypsgz18#!SkeB^_a>#O*}(l%H+Hcf2v zldnZZzxG@yC(O!cygG@~vS_~uF|~=ho+8e;NZn2si{4Yamx$9N>0>>_zb%-_cZCU8 znYn`mdrx-RF8;t7cHtdv-#hlvXEr^b?WkiNT-o3LjByjA&7uQN(|!a^rBEAAQBOTB zUDBy|#Wb{vvgl0jf@!B+#+h2?;Ywrvd**AUQB%b3Ibpi6i+jzR0|NQS*Qwnsza@vc zv|H%ChPz}ZerPKsq=}a|i4)CY<`HS|5b4b{IW1Od{Ym!Akt#fuAX;*mtC*tXJN`;7 zBd=2{htDY2YUCNKp)+6Q_$0W>NAb=>b_vSzCRN|g@MlNNr52$(;#^1Uq=36$CJtXA zW_hT;1c8{{TH+b7J*|t;kkt!qS{lgXcl5jef)P6n9WH_|9}R65g6XdekOy#`X=rZ? zj)&|2Scq3S*5Yqs%LXfpNEAhAE<1pCYsv1eV3R^*i~%kT-hU!ky9tjR3Q`W?r7AE` z!apA&S}rHLa(LuhqRJj$sSq)G40=lZq*Yy9i2CP<>qz|aF}PhP?87o>(i>H~>B{1X z$k9dekyxlQSMu|eUqMpdU%@3o-1LZ}9mIlPj7AU=6X<@nqHYV7=q<8?sGx8$J(X%6 zD=zV+x2we&R!rF`!MTtb+fDH7!H&q_V^^@6Kirg)Y-Cq1DTm!Um91FKPT0@1>cI~C zOc$MCh|%=8J9PgyRM=8#@fphQkGc0ls(YeIaiWVtjD7CX%WgJ-T? zp;qv=P3XLr$h<^sods=}g!_9Fz1tG+c9DfIiMNr}wNOhsSPI-}p~zHM zKzGVA9*t*O62^|xS;W%JjN)>_jcq&ghX5SG^RcYuR^> zlIL{}3z81_3O=b)uf5{?7t;MB(xw*j?U`~(xU7F8U$`tM$#Sy267)@uOjS}($pNjQ zn7MNKU8p!*-mwiHk|SS=NBT`v(iW?@1yJw;w5I^;!?3hZs{Ssxa}##%8(s;3uz{G^ zQ(dV8AtSZeHqh2xSMU{-ezI2DkPrIk1Ki0Uq#QO;pjZQ(j}cG-@oEUsE|6H-j=1vyU(=W9 zJR0vbo7l1)`*VmWK8_A8BJ%gDwj3h*u0#smh;g&v;LW%`80xbD3!I`vI-?h6$y3)N z*jCAX2ehe7{1`81x`@U|@k+5!yM|AkE#y|R+xiRX>loBYICPFK?;;GGNM4J&BZ zk9^EG8s5(bEoVYT@_nt@tOoA<5q9DtZau_peajkqaE~}OHxAQ|5#|$Sx*0O%tz-Id&5k|TxFyYV-mpr?Tot>F<&R0?^OZu)FllzIcwn>SF-Y27 zC3UTqPP@zNyyY9WWyd%jv+fP7)4QzukzALRNi zXyrtR3d9VaNE*Oz&PR7&#=l;~r%WNn{{riO5j3Oro&ho*X%C$RjUl>s|AA2#txMj3 z(>H9A>cNQB`XCcn_*nlFBgv)u6PkacO)CK;ebXI!4XS{ZO#ztZqM4TlDlU`DqrroG zu*3yK4FWg55uf^l9vg{mQ6K^%JhMSVG(PYF*nb2gp8)HV=$H#&eVVH85-?*KvdtPC z4TBfO6QKdn%^i535T(s{EFwWx=c=O5OJ9${9<;b{ni4QWY^RlG{}!Bo@UIRD+5Na* z3xu5{`)Z;9#WR$Pz)fK;$o$$(%*jjqkB3b12%aF>;SadsQ`r|oxb*XE$2@kqzuHthdNiH6ZaslY1pzT^n?#Sd=y^&7oX<}$W-F& zP_?xqs2#7JngY%Z)7icT?zPr=I`TZK_Z>)%KB#{%ihR0J|I(8zdu)@~fjpmLy@LW< z1gq=!LC|FF;T>Sm1NFSgz-|HQ=Kw~`0(bu-JW7bCi-?jKB3i^ROeY+ZaCQx``7gHh zDUs@f1r7ln;?Pr9LATYadm3`WD&&zBX`2WSJqOa)Kuewz_jV{7qj0xencae-HPYQ^ z#NAO!9ReLn68k&IL9}qjUTl>rI8Nk?#|hi3*x)Y0_#2EXDP*@`q`!RAQ@ZI1Klv3M zxPy;%W7<0N9<|KJlbm)x`(4H9`f~|k?50fa-96^qf1LGL#{B{pVWyXL;Kp2~OM9?6 zX*69=k6%ltETlH9ryVWktLJEN%JjgFDLrD;lrbhtbI)^Z#Dix416+!&@kSD#RcCY$ z68u}3Lv>{H8C|#aj+bhiskXL~D4twOm;YZ?~3HlaZ#8@`ib;2etCav1p2{lsaHi zCY)c6#T`;5N8*pmv2DNc%$DF`1o3c^y7V4VI78dNJt%IkD_jZ!wpyEt0I}Vs?hBaP zO7A6rNp|{~ET~DgdHoiYC0TDj4G^{N!$Pp!S6k-=W<{#2I)U0#04Rj-5aQNtqWeU= zI+S3)VV^7TyasGWSNv*Uy!RYzQa1i+2U?{g%*R#csl=L#$o-|nKk^ekgYdl#6&Z+^ z50$0_{8xpXmxS@3rHP%;_qa4Z5%HNQmS;f6s)eLfx#JPxLW&r?TX=nozrR)wxFHJZD=i=X?1 zo%xja8OQ#t;^)0(kd6HQIL51(TiAvfwT?|N(}P$#OU?8SpoT{>-dD}}B6E7N>E$){ zT8{D03U0|V)d{lY>D>oL)r2$GxoxJ&^67WMdki>3JriMCqg!ME-Ri7I^agit*^KrBD@`^*)4+!~n|qOYnno!{B(cDZW6!f z1oM>OHE!&WGhD)7*6hOl&gGiUvgBC4YiD-g1HSA6Bk{cB1m^xDzQ~%%khw&G9(k3; z?3orFn5J1w-(}RIR}A&jJY)fDxNP#U=c<1h$GqS!WE+#W@;;TuQ3HhPrKaxh1v_^3I zBvi{Qq4m(myUMop@cXF>?Tq9#$(_F==L+N*S5>_p%Xz!dVI!4aVVGMlsJ$NdjfIaC z;wO1T@*z%^qaN1@`V+oF3p#Zny@rF>MH;V}U}J`r*GkZCt99H?kkU#YeGII!G9+IH zZ-yC?uLJLk`XQIWtj#t%kAk6;ZsvOMx2M&`*^exco!OF~tjUTCjA5HAIW@yLMeuEQ zGOrKvrh$w$j9`sn+P1pFd?_bg@%jlOp9qdZ22x6eC=DPPx{c_XxO>D*>wA#mY{=k1WPzc7YjW-$gTFgDtV+K(fUMnTV~!xZC_1}Pvar7u zGLU>6s=4|Lq~(xFN#KhI_}oa?_aL&y5i^o-ISrrJ6R*66r8?s~AEAK}_^#Wk@*}vE zi@@c0Rtg;U9zPlb1>C~-OjE{A!)MNt&+o&gZj?MGqbu)=vrZvGXR&)W)a9b^XptNp zE@1AWv4fD&jR$6a(Hyqw34ch%49MpH+S4x*_zS0~G#{QjNU;j%^qJaFzzvV5M^57^ z`Z6ahY&U^m(=a@T3xLuJ<_e3sg5Hq`+J=dODb(C>;WN>SGuq(ra zS*9;!T3<7rd&ukwF|P1pr_65tUBgEHZkn^6vl5zOd+;-MHOD;RkL@)M4Hwb`(~ElH zU6Cbqs#uy!=ROj5=Ck&_rM_2q-C=3NAyKlEuZ@!HbL83klxxG3*tt;M8|C{ssP8gp z^d?AW3lHu9X|Kb(t|-w_$jJrD!M3V*)0Hn@R4%#7ssglKFmz%$b~73t-yZK=j_k<8 zOO~N%S0Xx*4d7Zg)td-V*AsDE1QPqh+uMQRu29Tq z;`so@<0TgFDZhNJlBY{Hsqo3;BGgwgkfOsEF?f$~=`mmEA}q16!)p1&rA(h4{M^y> zyZ(I36;xggH_SlIU&U2)qW-q!8c$M?+UKU%w z*Q0U0l-Iw}fK;XF9F{#6nlv77xeAVYj(6*>q6ZSeBe0cw2>&xg;};^qLw&Co=v<+V ziv*9{tm{)ie^34BBjDnia)#~MOmHQ_Hti@FTWLUYz?nDtb2q_`{Wg25!TQeD zl??#Rwi@>b%;~K4`vA0$)uDNyBT8QSq?8vEp$)S8H|&l^K6gQtK3z)phTqhS zuiwbd$>RH6Vw{h-auM&U7B_BZPrVQ-BznndQ#sXqV;kzwJibK|HKvJ^ z2h-wwF1;sHUCoBIXT#muUSC+`Ad@hhn*x}bR@|!nG&+QBHI}w(pq+cr)5cRa{pnpt z%>!rCW6Z`@SLtDK%_Y5=BmJAsJYxDxY)pz{Prm=#%Z~f?{qK~sT=T|8x(gq5xyfZe zkHi_DB0|4|=GsNV(%;mSSHeeE_N#|DC5Rt?LQLu|-qlNYk4O`CNIUk)ZCc8oZIqfq zc}92Tz&vHTMOL{&HTz_`1>Aa|JX?cgb5c5?+RsXzn^ik}<=us-4J9jqn6nhzQ9BbHL5Y85TRjuk^dvu=z-sQ0pB|yv?&>>bD#vIwT7)eACn?+myKW{2 zuY@`UgOs&O_8I)ZB{^dUny8VRIwDmaF3f9WnSMoE0YO>u;pdo)i>h-BJG z!s%YLaf{G4i5g@p%nPQ1&hS_BsEb2*$Hla7Dd#+&N%rO%7qI?k+09;D49b4L#KCiz z9ecTTwRDRwY~6Nx<0WQv9NpwVk4mPC$5@6xq2DhsRd-=7oNfM`#tiS*Gy`SL4vlLQ zS%<{GKbl#W(7#_}xcWzbPyXdR?>GKj#%~O4{>AZgBTP=qgzO^Ah7W>oJI1lMST&z3 zT`Z<75#+1l9y@7?gLHI+{NjYPsX^{OK~@`-TwCS+M>+MYQn5}3*PwD|xywfQGa}b| zBMmM8kKAuALkas9f@D_yg0L`ab6` z+;868QE)nA+7c^}KP+SK3Y#V}-Tz7MPjg%2#YLBdOO;~fWXW@=G&5C>Dv`E@DnkO~ z*_V}}qD-AoY#%7b>B`Z=kb9SZb{M#$m0}4*CbU)l2~LtDl$Rx{fp3+QW6V<=Gj@MFAc(k4M;Z69gi#FB!@Dthk{keG9 zcm36;_>Xjb6ioP)+3fWv+TXEmdyGiir?1Fcr2a0xtaT$z%?PrCL0tM}W?VbgZyi%!M9)lO_IIQ_av7^+v%Q%q(U`KPu%)HVP$`>oqbYDW z=iSu!>K5mn*r>Sk4O4+R@uUypO%J?!sjVWAA502K9?0B zX51Gx?iOdwmmYo=ZBED!1EupTm4Hvua#}I$m0dbRH)km!dZ^q7viqhibcSC%P(~t1 z;X`HkJ!If-<=bM_&QZ{s7U*38T3>|5xFQ?;uuxhx^d9!u3BPWK3x`2i08X^i%!|QK zCs;jNgD;M@UcMVIePXlt1kUf*d)~ztz19Dq@D``^$)kvSicQ3EB1g3TW+Lw1))~8k z;W1X5`hmAP?N@t1<*HMgi9-{}uIBn{H72->++GyQnX?nbE4KWPG*NL9hyM}R)Jm4=Qbao?=c^R?T{*E#Zt{Uz zYL$RVP|8ImWgs+b8k9mp4R-LW-%9vQbZ&$fjkd9SI`}-tskM!?D&YA(k z2Ebl~rf$TGNoe13bmS6c!d9gIr+nELy1iNsgk`v!?E75Mf0p2@oa%rSd7tqbAZ@j! zpO%Q1-dn;4iMo*%U7;{$ip96D(225G=ks5isko~UMD6siy>O3!6gHw{bV=4hG{ z-f{Yj#z(Gv|1*v6cJj|6n$o}U+_mOigN3ihOrQ4)QH_=(j4)|2b0tFj`HA~-M|`Xo zkK0Kn-%6JjNz?l%=nJV$8|avyyzU|t@=Ctn3~5#XDCjpN(C9ivPWfz4Ib9t?`B$O?WhZ+)KCc zIKI5UjeH+}kfNXU9Ix>;I8@*d))@Z9)Z%6E|AuF0>Yr$cb(d`(jv~$;u%5q@csxtj zy^1KawbHA=zDt@lI`DdwTJwh}R=|>@gs&|a){XE!MyL+rF((Ks9*e|4??5a&8!XO5 z<2=YyZ&glv$-c0vXd~Hdhg_t<;?(!!?OseJO|<~eF% z%z4w@HNvsK7N>8*$uMT?P_g|Dt|nQmcr7$niDwV|JHO=4%OA6(NLg|3C%?*uZhesL z8lZcJl#yScYhh4j3AD);KD`slz7BU>0`>Ms!q-7LFA=H|x*e<~Z3A`b}zsya|cn{SHGl&&^p_+u?A{cm)Zv`EVi;80G`d%cIym=qnZsW@Zcet_M8}4 z0qB*)Z*LGL<7h`PZx)^!0tTGHRCj@Y4f?nX8ER40OeHg{R4<2a_Txbc^vS{UY<@;yEiBwi^g;ICO7j&|tL0+FDxEz-b#c=jslJyei`%iJeQ>r{e zOxr-TSL1(wSawkJRzJ=^vB{Xv zEhL(DIq=iarfZ4(o$XE5|M5LeHP=`ROGcUsM+q$^S>`7SbI;H5BDMl^wZ_V z5*w|o)?6gsR%!PAN9 zE^2=i>@lk>Hsl5`Rhxb!n}VGC3ZiS^bv?kuF0d~|v|I`q;<3y!C1AJ8V~FAs56`NU zD~2c^H_HbVv2Ltv|AMb+As0MhZQn>yDkeKw3e2YR9HcdOE#vo#W0EYhHR72n%bsN6 zMmDusBeXqDciPJr?qI4N_}%ST%Mnf=$cA?2dh}rrUuCndF@LABQ_Zx0E4HtR`k^ql zo?1G$W$C?UG?Mj@wEV8iP4}ko?TtNQ87?5N#4D|(kPcF-L-O4ysjxr^yCVIX4|QxK zQ!k*sr{z=u8aPEk8=#BXk{^eZaj2sHWpi}TrE+tDN0!xJZFRgPAF-jJTF`-FO--5vq(>qm$v4TnxwpW zOwuyRqdOhKi;i*Br`_TnU&^nKI5nG!EE5h-quWdo4tX-m%lTx8{V|Z=oy7Jz#c{dp zuAbbVR5qZFjqzd~9JOXlRY^q+0rMGdp644wt%Z~GCis1a9^X0 z^DhdU8~XEiw>4M9@?jsFU)Gpv zk9YXj4)o1`{M31DXB%PET7LWlLH(aNZLi>|mx}%hHDiIaa51qD7&27KO#-u9q{@fj z_^a}}qu|G>K!zt+S`WxAz?SV`?px_pdOT16`kq;Qq+l zTx8@#%vFs>Z6UnJq6ZTciF48JJ5;LWXgsTK+=c2i+CI0@E7{sZ26P6kZ5oG#H=&LsR^pX4wk7r0DSF1f>&bUl`q2wTs=V?RJnM{)OVP<)8zxK>!mN$DO#WJ#ae#_nm`~+~a67Nir$4Yx0ZilyHb0v=IEa1ri|MXl8#9^f zI_82Sv-%e^btyfvg6X%Kx?0U3bB*Hwwq}%J`dGGYh(0xy<)`c3*>Sb6+DnqTg%{ed z61@5E_Vzh^(H)&3f%jJ%-j?$YO-7=#Fue!!ZjsPsH5dL=V9yIh!$sQ!>2{{*>UA1*J==9H;<19L2jOJfg>|T)it1kwPRSi3UEnpQhbFl3f$oXHf zI1eJ@6?U-^tBk>@eDqmgY*#Z~7RVkE(H+O40 z9n{vr+A~W}u%(lknADB-?@s9#b)7NSqx$0qrqVRdWXe#$JK|h6@#%;E?9j8N`6BFtx0$)17QRFg=b~S=yq#-3vs4CHa^Ab#x z^@acV8+yIxE`H)Q1BmdJ$Bbk4aXZ4P>jbBrMjuULeGn$imp!_jY3jtfoo9-9W=jy0 zsAF87)6tEL^EYa34O5wKEUjhC;tdxiCM#Os#f=^FLU;QJyCO(8x}MFQt8<#j#Xixk zy}|W4t=B1eu)(lv1wSr>D$M6gUo!)`3O*LRXOvJoUmVW~6T{@Jy<(Yb2frx|I|WgX zrN?1#WRN_*9=^=UvuXIqA;9}7e9jW=84P#12;PEV&K?SR0#)Th+(pP?E^J=}H8sQ3 z7&v7Oa>5RISc2@`gzmFIZF}Jxrl0{NIdB(R(X7~c51p2*`q^Qhkkk*%vA&$<=wu8# zru`9vH9XXge}$!lYeQ(P?>EgNEq<&~y|goa=c%fsJ1&MQ*Z0SlXcW>Q{OJSYyA6IU z5}((CJ=lp2O~S$-pf?<`qhryCJE+zk_3eqKZ9(&+kWH(9vbIY2@Wx#E^olPPW7B*X+rdIxTMyZG3G z35ye3CQ;8uijV&qCVm$H%rJI?@Z!F{2g|>2)d%h1|2xw$7Tle6M!S97PFJdpW^6w|!Wu)L0oV8{GwR~N9_sk$^t*qCBgYqk7jZ23E_a11;9N&BM=D_Ns;SL6Ii&5DtD zP=Gpd4(|J2=^Tnb8?RWl3;+I;DA|oK&BaYu;%>jO`_A~{X&79G`75v+5!g=*n{7b< zIAiux(K}nPai@?sY1pY!IHmwQU<&WMhQ$X%NC39r321Q_?b!`1e1y!11hN;vgBxY! z9Eb(U$(vxyx`{P#gaSB<3emyewJ>ql(>(vE~nMP1E(90HzZbwmVrm|B95@M@Xf>`n_&5L1Je;;l8 zB5X~S_Qy%=6=-tfIritO_N5Mc^;Pq$EADh#y=FWfFjZx;0{>j2n0g2gOe8-g;}x-l z-8tOv4Zd#`zQ+L{*$MCd0()}>t2~AA-7vpPSeFB6=x^*#EkcaMYuyp=aC}ZI92bL6 zZiICHxHuk~kb^Bs0ULABX9#%17g@RiSiBiJ*dRxE1KfIfP@OneA+J5fPf3@yN3!Tt zDXyH(ZV{7CQLj&m11}pNj}Tvh#%+}Xnq@e#UdYTfbfftPN@Li1UL9pr{o)>w)NUWn z|2VZHi%plQKZDs%qv#=znXm!$>0oAHAvL-m)76SH!qWxm_!<3=+& zC*7N9X2bDzsgC*X(f)fX+uOh0?;(4*xP5h3&i$FrYX|pZm|@mu&QeLm4dN^97{^`w zF<)+QDW5Y}cyBB8=__e>3*%DcE{(#&=fLw7;H6=D`pl8RWHEvMyP&uwE)a|4E z?-S^`1DJjad@=}>!@-go@aiS-#5t(U44OX?PWuY2tA#6J#CaK#bOOEqrXv-A-!esq zc#!eq(Fm2YZ5evvj;dP}`bn$VoQ&SH*4}-Fh81Zo+R*|Flhb{%mw?H@Aj~ORyC@dx zeOvRs2+Ka8e!*fR9aN+}KDSIE&d0axC%f##Jwu2y`*ENBxMm}MjKa>2#Vwy;(>pvf zud&zLuo*bcSEDzkJXl|7I8U@*It1gy>PqV(DMga;c#d~1G*v}{P_{_K*1?X z;mP5^!_(jwoxEq0JZ+=wGf;fMNk^JE?~{^cEi=qja&e$NTEuznM(Z15v$JvDY%zl} z+-nf%PR0!jgn^rk10L{!zdMjU4xK`6^5P0lQ|fbU%5y4%WX;Q|uuIIGw^ZU{29Bjd z+?n52)CW7JVw$n5IpgGGC^Tb+cGXYl&$uz|!Sfh@O}pzY#yYy~cvm+0KwIuvcD_aX zY!j|{pl;6wPI{ove#nhDZ#4hMIo_t5t@y*w*`NS^es|_G*DfxLp*RDttheCcX^457LvTVE40J`B2-IN@()A5ynvWPx5oel^gfWWH&M5R)={ynrC8$nrMMW#k{&T2@ zTHBV2f(2SU8C_~(qCSbv{i#(PMM+!jpNlBktgifm;#*YlgR%LnB02#}x1 z@d7;2ni#wXr=Q>x*Wij++}{?ze+n;8#=73Zc?#|H4&UaEdjG{k_9J2q9#H|eJj8E1 z!QE%!^+%vBSFz8(!51gch3;Sgj38%#od8rT$emKwd&cU# z)H<1dwOqP0fim=zqV5~ZpNR9rjNe9xYt|dLofR668HYCWhYF2#lX!)Of*){8mQs(0 zaSQUPb&ps}f;M`wSvK^;Zwy*ZZH{CVlc^2vOw%l*S7+u;7lWfMGwY@9P$0u6woBQ} z%+j{wL)hjWZR;Mmlv znT%;c#zM*-ozX6ls=@ZC$0v2@D0J61jjJ~*?9^`dLl9&>OVP zU)^&6_Cl@ddj#{WRd`in*-d18H+-f&`N<96aFO`#g8vvzbnb-D=5gODto93@9FKW^ z#Jjs;vF-S>XJ|lQf*6Ey{}DU3BVInl{RePkGk&TGDjtB_*+R{F)NL8Kaxyac4RC2R zR5TR;8F|egIrXkswp+d$$1mw3%a2*>Y>9Saz649hG<2&%dOeXEd{69fRt%mamYguQ z@xsMuW85hrDcfjiCiwTD{_fyo&QdL3xxo;9csO@=3{5Anhg|9NU0Jb;y8n_n?L>hW znI0a-OBa~OHinGbOb(;lSjuFH?J~neO==Gx%>HO;3tz_;No|R**!=MJEDz3So^JU| z?)WKv{uKVGt?|okK9ZwGHS(~`oKgsm0H4rH*!@))<}KK6ltQ)$6Y}K8c|uGE!1ocS zj|9zciss9~G7qUF0Q~bsn%foJvPw4o13+HB`VF{o9PlH-00%HX8pLWrRS-1lD%21T z-(Cf0mLcipi03RU>Il-n^Wo3P{W$vqb^-EcCe z6!UsRJTqXKM~QSDR=kY>9$@dH2+k85mQ18xN3%1DQXWy?B)a$_4J!!n8ch7g6(;bQ zPWYclsD?thHgM7|Bx)AeEeksQ2-tHEi0lnSpOykI%fq6C;*s*4OI%R1P4c^cQUoUj(-_Ot#aqlBUHCroM{AA z#60l#8^T?Sry0xn$+eLM|W=fZ7c;k3~x@d#e>92<%u^E`>uZpe>^WL5~0-do8W zN0#kWeR+qWuC`Ay@f-)Y?4VU$A`F6D=D>```wi?OZ~duW^?>lL%r zTwk8f>?&_hC}8#%w#}BA>FT!q0c_)o))kl8ldY|N8rfyxZ8wK-Q~cXEY~LXcx?feWk#AeDGc71Izb1$u&849I}LpS;Dzek=Q60o1}NKLi|P9SS-9S z$fx>={s7>eDtffYIisbB7J2<|X>1>0^fCGGC7{o6pkg`r;|p+Z6@+dFH=l#+t)aKS zkn(%b8YgV_7`XThj%340R;17kNx!e?w-jlcqWXOlx${WfH3=#HqM308>3m8%?GCc& zmv&GwGWLa5%OI~NYo9uxH;!rEuS3yH^~zi5p(<6c8gy)bm5ByRJFC>VU`0;KJ&UpO zcE$Qr*th~k%wtTBQ$)SNGC~z=v$50wMets1kE_DFJ2pnEaE?P~<&j-=NVlQn;)zJl z*~H3Z`05ZG0buiVRJ9sfR{>wB2A6&WOM}3n&t&vFF!-eyItP$Be)D%3UeC=7lSjC+ z!Gq=cY0M`^x}HXdzK}-Q(+hV=CGROiSE)IHD!Cw zpv(OI-(2B0{;iF$ZIBRcC63-J+%6O6e-ds^loEP~_Ayf6GEvNs_GXCR$|VCJJ?J4n zSuItj$kwbhvBP|FL+%JuIjsQ#8lgk|z*Rwr?F;Z~IU48&eVv9QC!oMaueU_{P0@5&j{J+$1f?R)ks7ZO1ni|LuSTXER}X7K!fR9> zA~I{V>TSnOY_4)&C@O?2{}rS3CZ&E5wjf^Fz7CuHwnLbJ%@CC(%drbRRY^Uth#{&i z1!$g)s(J~!|GV;vCE9nd?C-?W!u z{9Y%$sfM4IXP98iznV$yp2V;EO?ThN`_E;M7W03ua$n4a$mje_AA!3nSS%4vO%ksz z6*^xL2Q3rU=8GA7g$a*DzY^i@U9n|?$d-wQUt-sh()(y>wjkvVmW|dxS)ROQ2Wa66 z)Io6Y1)wY**(?B$2V(w9!J@~w@Esg7h71aVUS%i(e?Xl?<-E!8ZfkYb8F*NWx-=iY zuvn8^3h!U3>HG!GQfTHh!s!duge7w0r0V=MWKpToF$pPfSB`5!rnf6%?9t{{g?27V z_E!#Ei5j;l+m@o;a+OjL8da-&8iXEdP>u^h7k*NPhoHHam9M9x)d9-l-YD@=alRB8 zXs$31Kp2)-_!yqQ4L9one~L$Q&O)=p;BHP($r$kDKk##Z8O#E|1d8Rm!IOG^hBG+A z$f*i}=l^jh9D)4btj|&T<|$UuCUpv6|E`u!^=9`XQl*M@%@SWrOu;7cZ5>lJTy(k5 z#54*^Con%^g&T+I*Um!Ut<>Hs{;Y-3F@blD)^A_V=MuWbo_x^jHpq@|X>PSe_yf_c zrGGfZ+193~T&&#MFP1xarY&g$*WI`M>vFE2oi24-$5Y>$#2pUkP)Kn_*NpcGKG~dh z_2K77G2f2xM}D#szw#T+`N)C7c8!o5EtC`qT2VN*L`>T+Hke3wcgen13V$TU*2_Em zWY!wo_(8Va3~AhfwMFpMXu$J5^58vi`7RpQ1$=rGE1nH5Ux@SJAU%*s+XJ3%Bk0{7 z_oZak3UH&D;?flG;Y`I2FR;#2@yi`tT|@5n1_ciiUI*IGBDy4j1~0rW2Mm;*=BM1w%c>jLKc4}x9sm{_>SY3y1KoEd@MYl4dUqu=7d&U&P54zT?; z@@Ks4dl8xGBh`&S0t-cH6?|u;cugS`=87 zCb0{lcbkA-i{Z(8A@83^{8wbib#0V4^p^(GF6UVJ9yG_JgsF2c=~u==rz&#B*@lH)hRO z&?JOXSOY&(4BzfbcY^gcvEtJWy2O1#bXj})Nq%u-`>T8-pig&22P^YiZ+)oI)ixP#5-8irDz7qh@t>S+n{u0P!z$IR=)493|UJ@erw z>(-mymBK+d+cJ{(vtvC9__UMk<@rLnGxtg>+Ty(GfVj4=Kv#+u&%`&b604I_4@-M@ zK~{jg-xe*emV;98r)FTmJF;dh)FnYV)DE6|N7ZW^{JBomeIYz;ugai;*Zffq*am5X zl`}qr`zZy!3RJCEtQ!rUeouxD0^Np@7rTI(YeXFe`b;ANEWm-D#O)Ap;83FOd56=8 zxWa-t?f7Z|OxF-I^`P5uVq_E8X(oYJf>(|ZJ>G)}KZr9e;I&r5-~@U56aE_@#|CV{ zCTLq{G-o(;vE!A#f-?tzaVtQjgZ%g>U}-Oo9tj+o#xGhazyHOaTq8Z^nWEL=mBY-D zXkqng##`h^UuN#K^Ru+c)x){Hl`~6$o)19AU zBjqIXx8_KL6Zwrr()K#edz!rI4(s+=&huuvi~{DCQ=?AQf3hq1Xo21#2^haxpVc7uYt?7;mfy4)xG*VVB$Zw#I)~BQH;G|OnSLtqoi*!m zQJDFLJ>o3vxy@NO@YFN@%L{(d1L43!e&A{me93!jqyvDk?!A<}L1?=nFR&E%UI#vY z7YQ4vx8$A@ftAQ>r6Q0n0)C<6j_+OYr`dbt@8&w)J=1-0Ox|#{)zaPIMs*V z`0pZR-V|(8y<)R7nocS%s*quu$PExYZW+0C356?TuIRf`lAuc01y$d5%(@-0HR4_QygmC?}aSTu4RbZaG?#Dcy6_@fB? zmLu)W13&y0>}tW*-TYZI$Re1VNJ7@5*}-X`FHeI;AfuLA!^pA2sB>SXS$~YS+2W82 zW9Snh^Pln8H~vaUtbL3n&u?Y0}(J&pEOJvuj06X=GH`llXs7g^|} zj+~2}Iih+o6xm*=)b&D;427vZvTQ1O#19!>iO<=K%t*#=79t~Gp}o7Jg+8c!1%0bP z$Ax02jYxeEzWgt;;w+}OsH zE@VTWv-Lf=sjoPT7A`rE_rJ!^*~FU-6fiqpSTDq$<%S#;enZ@VL}A?-cKBN%CW<{Z zOpGsQlOBlq;heFLG-(3wzFgWdQuwq-l2(hKqa?4761`dKGZ(n$AwBs3)|HC~RdDhY z@uLVIz9$4cL?#Us_D(?OJmdRjp+BSe{VaNOH@^eN-sJFizM{)J37@y3@0SXbRcK7A zu(JR;Z4mnBBC}SD(@C@kAz2?myXQ&m-LUis+42HbJPH_q;Nx2X$6!1w7L3ZnciTdF zFfsHHbeqHLo>GkOSE%gN5Ng7?%RxC7oe+^5-5i--Gy}EzIH#e1RwH z`jS6+leO>{+`DsoY2ok*j(Z|@Y2@P1Nif7`?2xOHxa^UD<8}7X1E9&9In)mH%%eOv!&z|jP41Ehc-ct^d<^VQ5t|4g^`k^ik&7$kNjIbipMdYd?I?(2JS)=Qz0kIO6Md zmW@rbQD24=U_ok4kPTIu+ zv8qk;$e*xH)BH@sQ&wwUTH=$3X@(xbR*32?C^qnw+G`uy@2Glj6*9?RJ$f>7*+!jH z42P0xmyPho9_k-s;F|&J7)SVIl-hMTJo}9Lr7L_tOuf(@-hrw|`oPYERF@aPMIRN{ zm%^z^vTYo^y&cPMgQnj?wnRaei=h}bWYZlePX_zxMGtS#(84?EfleB(JPA1I$Ltsn zSmaS2-{iGXMtr4wKHp$VOJzF^R(qrw2MuA2h+Hw8@)JvL8Z6fe^9~psmhjf23=gJo zFMsJbk7k3m=^FzWD^|Dh0zG1`ZboN%e^PsF7FAW=W)VTHFl+m=j_McO`uqs>t)yjm z3FWf5CAuGd+O8!xp3c;?*jxE6%%$jo@u`M=aV(XZ{to87=;*A{W)^1tOt?HvQ zm${NJx|V8=pQTqvbAGvc6D9Yrw_(v9_Vii9T|I-_7(1+wi?11Xv-G^NRN6VZcO~UL zfgZPwekoBsMlgduQOn%er8QK=S+3ZL?&~A0c}mwfO8Yl5``rM-m)$-Uav#s>W+LnT z`E=I~35KxYH@@SbSQ|hVT#;h8DxT-d6;qVSUjXwGCG{7qm6aVQ!N-8g-W1tGDcky? z8#rbE0a(0=>MVd)yQ}2exaC1rfG05*QIE+dRu-vOdy=LvG>LD>@n5x5y%jItm{^`v z4Bch=;)H^3FkRxQ2(d8x_>hc+%wP?<{;BD{`9xcw>BoG0+IN%P6LGKpCR-aZ&l9vSP{(n+{vPr!mtB1Z zR*YoeFz9n3WfK76dyMvuKz5j6Q7?I0rrzizT?o*R-zx52tp6?u$Ex(iQ(<$uL6ITs z&oExj6#_p|`d@;DmbpcU&K_*9e&YSfT)c~Ttp^W`72WRgrBlV@I{s3iSXRM*4iwu$ zdCpIKQo{9i6Yq55lmo=w3)yK_V%1Y-D=h>~X2M?xc{sB@TKIr6hkS(?f2OyUuuIRF zQ+yo5Zv4Za@a2b9@@T0*zvQpYk?1pg&vH3u0{_Yl{8r0-+XBto%?*r%*AC&vPDhRl zY(g8NZ|SgqpbC(ieFz=t#Qlmx7wzVPW6>UexE04yZ~_0{0W|Luzhf&}?ImCqn3$$R3oLz(*|0WOdL7aL3b!TC22(Ts(D%1d3 z#o&W7+4>zYZMW?3LSAPfXWo(?o{&DriwgkB>A28ivlw-)1ML*nRdI_C317!^C&miL zI(E7J1czMK#asCJl~tY+)Pp!1R&Yw?o`;GyLwU;<@&09g)fy?{7hhc|(PjL02btQ! zw}i?^D>yb$u7|jOFXZ(w>((NNw$l^Lftl4*wmq<@-gs*qaM;Rt)E~(1@Z8P^ly-&> z>w&MfhSrlnL9C&?5SY5t=xqju6;sdGf~iXx+vgnx9Gp%Bw~KtC7CQbyybnQjE9J5E zpz0go{|*f445bx-WmMqkH@4?{!T@heTx z>;nANa`@a>g5cn&B4Szs;<|`@G!{*_QGBXJQ+P#0IOf-)^yjfNKU7XjaOVs)d=syj zsA+wLZ@i+JT7bK})p#f3nXfb}7U6zZG&)nfDNs|Lfh}oQFLT9utylNHk6!twy3r4{ z+oWo`j2!Kzf=41fIprS)wlG)yYKQwTSFN-~T&q+a)f)%qt9#@l^mBEXj9g{ZPi#@? zmwK%Yx^R^`wGr_?q)JLg3cZw#UP$hBGNl5}E5O}m!?(|&t+`OZL|AJH#n%9@mw^W( zrRc|i+EaLg10(u#ef;Hh9?Yt%h4U-x^!@j9JM#4FAuevI{?=Z0OpDH0#cF5hZro(%oNfOe#5{l1 zHl<4k_|V$$op$kTeQ}NMf2l>-N5|T=WJS`)UN(P7q=zRqd4}*V zOqX8aX=Y~^byGaK)^OTJxCtTh4h3(3|aKxCi-kpZh) zinKN0{6fX}FW{+13UUnOc}cM?4Z3()akLBks50&U1B=(T1${JmM zO8ub_?NO+)-i!4&HM!}DmrOSeq;M(JY(YFx?rWZCMdIJgUv49BsVt5^CiyyZ{|0i_ zI`gX=(Ga66-$^Y#WD_ zg#qJEp*g$ctDg|l4N}j}NajpY9|)%n6y|p@J`G$-8943&`v(IP7cobDfl+>R=M1@F z59K~w2Gvw_qjZlouKX;mA5VF;N@3rrogr9|31b{dnhNYXIH(EUF0Gv9J&tH9xU?(}7-+KSH~2ybxZ%@)D)0(f~GynHEtpcNXpiO=2%9Sh?j5xl;Z zj|&H91@TG(TsD(`o(F_26JmP>@ z{aqp=#2HV-h^6A|(}Krj(PJOKf28;{mFw&+zO>-Bgo^{~*pXkwpPe{1KuV9{F1(kb zt@yd4KNYAwa`z&L3B!O#e z_~P!+Dx*+w5<2ol+Sm$Z-TVqH*P@ZODvr@{bMDFq0sz zzW(L<5AXC>vv0osh5VZLYn45rKK1;SU0iO~fGC?l<%Jr_2B0I4+`bvwF>5MJn zsNK~4kHVoWWAr2;!P&U3oFCO}=&^~v@Y)cs;?pw?o@cq#V}?GRIGZtseJ9vO+4_}c zte33wN@8xPb$i^Hl7DRvYG~^lt*7H?r)4b}Q|JJ<<~|rbXlT=fSCrDb@#A^Qc;sJ~ zqf|=2e+Mp5Lq`6a{+3eY|MOMT7S|gif;!rcn$AC`M@?^u8Ob;{v@X5In1#33w=x;E zx-%$i*~`$ojp4kF`wN-(UR2>(=DRs<6UJDl)9s6yWNXG{A#*E~d9aAd&1QB6F=tHK z4|ADG2iWvQjKPjGJ<9B8;hY+npB$gQr2`ig0|s*E>!i^f_vR6Bt%~MfHe4~>1rB4G3+BxLaEoNbnB)!h`a5`z}VbVQ^tgX_# zOCtRP)F*<-8|RfTlq6nDT09~SP9`GO6Cn)tVGQ9YqYDNRUzeb<4#d9hXqp$X#T0D} zBqR%zTTFyws9_S3QHm@^iRQ&fVIp2t2|!Y^u`%%MFev= zjP1LgwZF(_#Bfe;*!FDx^)GfptB~`9y$VSi-m!6?rSf-d;6iyo6YG3Mp4z=*9>~oz zx$V(17Qt;BCtr@?{Hvvyz1)+P(yo=@qerQ#g5% z8QCa=xidR$#GhWwZZC2A4W?qX*zC*3o)dRL+@6o3*Le=^D*3^D%|>a#NS=5nz3|~z z*vUWL`QeM@;Z}Un3EAowmz^Ppq;QL0%a@jL9l-47Vcg|jKyy!S^%fv$0{5#5Aa8Si z!64ZYn&H8~5rXqg=(e9YCgijVrq1~09{^)3QkD)b+oHJs?edk zn$aC@SWGy!ZwFTK9XphPF+K49FEC*eo^}s=?2jAvVCntvDo?Du0_zAnA3T8#D@W}j zu{-zC6|bfYmKK{4tNgJZdpTJB@jdqBv}U6XK0RFP;kC zGekLXEPC8p@l1hkA4LLH$mbN|VttzodV0M~&%!8>coJ z#f8o0v#DndEeG-``xR|AUFh2-?W+UnDQ5a3`|0id4Yet>rP5gQnC?|*j4Gqq+0@8d zn#iLv>*+F<^7%{OwW7N=(_5YB87gMpN_x^%X38hJEtC200F&s>K96BfRI%_+&fy%l zV3%+^n7*V!T6HOv%IZ3;GD>-1b`qOw)*sa{5CA&Q&ZT}K~T!}Nq z#FZX+&u4_|W^BbPVu&Nwvy!;%iOpyxh$yU(A&A$QKSgY7!kWJl4?kiV=Ly|}#Wy`eB0A&Gj=^`mv3O@_;B@qF9y zd5j%520$M(#x{A7CzJL}zUIKxrOGyYnd%d=dpGvpF8O68du*kwiQ__jWT#&I)^4(I z7(YNKt&Zj=ev&>0@;!>AX&owx9I0s)x9Pl8f0sQyNBUFFOlS}-b@Z+oVwnw{a9vCWd|s@E17Jg4L>-X^->9) z$Fomg3*EGAu&=nWiurq9EPBW|J{NTfOw0>0JBoR6UfdVTO!XA!1T(#F3T{i8x@vyg zE+$XK=Ur!F=5xJj7*~OvID}O|T*6VdC6rqzvFlv;7n?cDvBK!T+^fZ+>r}pKgLL>h zA3jCiY2Z8klEIYv4_83%VG7 zQ!+ng7{4Q)>$ovHdY6rnI9r6}?RhGeG2PGGuI~84eC%NcSt7KzF@wtl>mzJmCvo;T zuH}G8>p6Xy=zV~{&_g2s@d4pdNjG82FKPG?VT`w2(^sfYmNhCtYLmsU{G}1Vqj>(x zW?<}m{>)9_FX;&mXyd=G1rO&6S#QCxAL3XyNarDkWz{vlZ_5u`Xv7+kaxZ;|hks zDn=<-2zN*$(_uVVAZQ%FGM%_!gD{VQliEB4DJ0z^kiZM%^nu84e{%Ip*zXPTbPlZIag#b|!*}fDcE~OqJ!}g3 z*1~&kf_yi~bvU@PKk(^1a9AaI{gjJp`9+vq{DF1sEiLEhUlYal%~aYh!QI#Ry@b!( zZa6TUk80C*zRf*J(U&@N*EZ|VK4X8((I1$}K6BHTw=vt4`ka%@*N3|M&P?)fon<-g zwx|7R80~VcZK5f?;dX1=bt>RY%h*xW_qokCON^m-Q`u@`=EHxxx*DU8G)(wouz6I! z>6JkYs`n}~ygyt&_NM`MX(-eh=LP)xG|lK*+Gv|%ycE+sj55BS(W;t5jUn4tJfjYt z()rudzy9jAaWo7VkF?WkzZ*4POwk_dd?Yj2K%rNeGh^tzkC~(}x~zm5oI$tgnCm!m z(}BHvmU(uVtqWygj1%{AcmlV%i0|UTKX4XD*YlZgCFY3Ge>d>GpV-|4TAL%@Z-(9d zrEhD|s3vJu2KM5D{N@h+I~YihAiM{IbF9c0I~ENo4{-sm-?1 zMA-myW(uh=vv_)jd~nU8O9k2R)go1;D86p7v9AK@Zn3<(V&4e!+7>dd)wJ7rlAC0r za3a?SX^M-8xARq(R}wy93ilyI>_;M`2XXi;u684;i!q1g#HLl)&69-vEUfY-QMv&; zkWQqX!`{ae>36W$^~BgqShxq_wiqjw@u)v&-5osF5#2BuFY`w}KgW9b!WTPZ?*>4< zC!=fXfu$=E?6kZg8vf-cg*}1>4Hg3c2=x>yfZt?ud`Sddw3nai z&6L>i#u4n5Z0_Gg?#^-!U&8ks!YS4WpoYUHi?$e7+9I|qx&LNKdo8%_dnA=P7qLp( z0dYsnrQjNN#164IhrM+}`0pIs?+(9aKRYXpdv$Nt0_gx}}O{jK9ZSaW+jag#W9&{Fo6j=lVqd5UtzZA^|6 z*Ts)X+|CuvV0wJ!4jyL4dGeNj8M7z+uX*eu55e*qd%Zw7Hl6!9M*Nz=>Fyf(1C}Qi)Ubt7nMclu?x$TTi#+FC1UxtSXOUEcN%kFNTzqj z<8q0CQ*f`v#OIUvl@P+c1+OY2W(N@#*GSth#Q0K0jyLHwK(!!=?0!x?;3FAf)P(&a zovlsUUy{|0CcY;-1mLE9$C1BdOvlv_0rO0)!ibE2CRsG@?rRdh65o1QyRjV0eW-~H zz~~zFfLioq7q#;#R0>fYoQuwStBi6&Z_iM6wnimU@eoB{Jyn#^$ob=n^nb|crHXUq zNMk=m&j-k!Y%*pal0uLZ#v^}KM64cmE5!O9gF$C>j~2c@AAYzVs+|nNH^G4xa%CVMWfXtW7L}lXmoP?qaGy)pk%tdpv2knnG(w>Am;TW!nvd@6pj=#^GP-d4s57 zdfM?4H3wkeI_f9JP+EGC71QKOPng8~+)XE3W}0aF?_hS+EoRhD_FW2Vd!4I%$Mx98 zk8l(M#tAnbi(3&%GgFI{Qw+?6O>U{Rk;_^=ioUCgfbmfBNf z6>X95gB(8BVunDXGt7Iok#m|%f8>(|gH8HxBNf9n&ArJa2i2vIgy$5++GE7Y3xxM9 z0`tV(2K-OanfS%@y>a}`-DhfMS|qZSQi&|_&5|vlq9kPrDQnqfO~_h8ge)Q1itJ@c zWQh=Ei>xJEXkTaU+mXKT9Lhe5~G#1i%Qy!Y=yQ^b`)oW?3?ouN$S=c& zIbi-*UKj*CMBzkd!1je#+ktD-#HTKxX(~E25u{p5A-jNKi&XU-d^;zdL14xv>39ct zTO=47Mx;kCpJ_Cc(iDZurc<7y+m4?(h4JAC0R zd`*YF>V(Du*y}gwS_v;exRDSGx}vBKqRBAnzN5IHr@Y5jT=7P}$ch1$*r%`1m;nEN z6qe7IlYHRvy%OIHj;u$G>7aI^_+|w#41`w}gI#{0>L{o@DcH1t-v0@s=D}J@2>1!x z(Zab9@rkow3_1>)C6x9;4=xF}4H@D0(e3z55tI$2D_6U^&q(#(g=u%(s`Q156~&Uo}kHopkJ)gLJh&+Z0O+dCaU;^nh7R-9|blkm-AZ zo|?jRD56igvlm6W%uZ?DlL_@xJquwfvP`bVGe14lm$R4)Z#2p8n4M$IP!01j!Tb-$ zbX#m;*}&YduvlNrm{nNJ-OHR>WpU4g>5^woeWA-Ao4Kx{RJLQ9QWJMke zT*%U=QYw^<85rv7f-{&4dP1&?x}Gd9HQU27EUbF&7|PI!@A2Sz@c?IA1~0nOm{{Q z8fWPa<_m56=;{s#&k9>-M+jaHtxJ0ebA4M@ed5u;=9nkcIr$Vs8wpih~;fmC(aT<{ZtQ|i1hiY9bRO|XR5&|BoStUHIYyHn_l&&w)?7Q z+@wAwX{?>-F}ybU5^8*UcUTje zX}66{Sx9?&vSGIL86D$nO?N!WTo^{%c{52n=pJ=+*%x|S5`DZQv#bw&auBoZDRs$) z>E}z`yh4A!L+-4i)E&t;ugE>C38%$GX$kIHrdT)v@9lwIc&(UwO-hVaI88%k-ipZ+ zMZU8l?HIfzVtu#%xUBnwi+XF>qIJ?ASi|0K>kP!_?Puwv8B@ zET<0;-}=Y}cA{C06j21d*GNP!xV;rE4*?VAqlpp1%#R|ritjW|tUYD`FX15__azj5 z-p>&j6xMUEuY*UoxSP?ycZk6?7;L2Y0w3T=3Fec*kUrqy2Jj^sZoLH}o`@<4%$t$> z7})Yf0z2V=+49+&|3Bo1-h*Kl^4+u0F+lpe2A=RkHAZ_~U(vQRtn30iurR$hcu2v6 zD}+-+_xhrbKlxFoQD~sho|Hnq2+`A}r7MA5hIC4699c+H zPr%+hRv*P|{x$KzYQMe+ZCq8>7 zv91iCGKDhsr)r&;wSMHyEOz8C@?xp7*I4RbtV$hAP0mtfB~VjiRo<~wI};TXO{K*s zXT?#6YT5Y9RLxq}s)}+O$U5|<>xZ#lqv`P}tiuC((g-Dm=%OB~h3<^Ize)1~ru`w) zTZfo`wdzMVnGqh^+(ITS(rjD_qnlwK^P2fXS-^*kTZl#aS!Up53(XqFTVZj*gLyaK z_#x1asb)9t)BWyfZIkGirJ7T#X$I4rkEHL7RL=;dU#FYa_|V3e^IYTYP+~Hdq_-Y2 zN&iWiEi&o%kZM2NWXlnX#7zokQEO9Gq_HCmlp$Bh&8)J?mIRNPM^QwkGrjo&zF{=^ z>#bs5FC5olZGmjC!A?F!=f}&RufaFTQp-ER_{JPEj8~390|ahErkFu<8{3G%v-Ml{ z!QGPXU{`2TtV?(e9_H#E9{{WKbgv@7{d`@%Cm4N2huVT)b9H~pgu3F^l-+{shn5-k zf>~U%n8`bEjZ^ITuD%VvsRrk?I{!|FLz>z?S=`45)s8bb<*Tahw%p)RRU3GHi*41S zpZa~XszOWk|H)N5Ug~euS5wdQhW51%5A>H8*7eNQuPm&;pR2EL-{@4JKOWq)`jehq z(45kwKkVI-s^ql)S_&OGVnXXkqjDv=_2L1})u;poTqg_N_ML`_2|9%bzkP&$DJ4XE za=prhpW6+Cu7h*9P`}YgF#?B&h;z4z$6G~{CDP;zC}%pB9V&Gif?Hb1VFEGbnLKnG zwP`i>s+e|Yuh6_`8~{v* zVQrGmuvr7FG#<*e1y(87l#WGKJM~IyFRKDZHPgL~eZ4aGp2fOcr5ZE$j8UHGuKn$) z96w#1W2M}fWpbQj2`|;~3igb>a%mwO9>g|WW}{y+n-ke1@r>O(cK=GosUN#^E7Ol- z%1$!wSDCV#%*jw@#Z{&Y&iHR&Zl=;@=FC4=`q(Tw_6{|30cG4H!2igUXJlOkUKmUk zY*4goA#OTjFHR74nCucrL|I8;IPu?7RC5u3N}+5YJfu{7{6q2egE%2ZQA(hqPKtvQ z(74ywi%evafH^|cegsAikb>%Em+_L<4mr5Dw2hG~UZCJblGX}MGD=dc#SXHV_Zwj2 zMLI>Wyam?=@#J8**TeAZBRIZ<>#-FWujXrg!GKDA`fuT~J$Ltn5Szt4Um(njHFOFV zE^aa|5rp*_Lf^xJ?i;B7CR{cZRlUI$b987Oc>4-@+yHxeO84J^ANJCmub^EXGQ0z) z8C3KXh>oIo7mRd=rME#?Fc_H&l2e2!xggkDaJ>bNe&q`~)4%dy#Bw=$k3(^_-a<>TP1ENI;+c4MTpRPPYub z`b+x;@qgl^KkxbQ&(i2k!gnW`>IJeF%XuZ>;VJpT6d3+k&dxASX5_fbP%M)FTMU=q zmoo&g{;JFw&(d@9XlXec#z zsOoAW_0CUaca9p`UiCbiO3GJ8-=^$BmFMy(N0qYjAyx97z4DkcV)iWxsQUwz*#*>+ z0_B@R>QaJg$p>oYW)tr^%Jz)u8HV0kp+3@;-e#{|?Lm*7YSu1@-Wq0}w3vR<#ln3D zUB25Q?+m@~w8f2w^s7jVrQhgt&E{H>9yHPXlnwLyyjkX8=CGt)V61>)PcSvs*@j5gZHZ& z{HVRJOx5>E?@W_<=H#aKs@3C(i4N?g^?3CcD&dsEc{1Vp6dQC0vuKv>oh93zvf(K- z+aUQ|6i!v3K|c(5Aet4$Z7dN>X6h$|i9gTkH~|*+(^;Q~wU|z^0KU<;ej5h!46RE$ z!EUw2(+vE*(dz!(7-??hV!_tU##Aj>zP35?fY7OLQ$LUtjwAYhz)z?}b)?EGUm9DZ~ zZI{QoGgIrtLfz$k^&5ZaE}UstVxoT=)A-#-?`qz({D^+b!ls~B{grIuV zoc;5rs#%89txYGDJR8(J`7z%QH9Kq&>QT#ON6>AOuH*sOiS*NbU}%k@TM_(7gG663 z(L;1D7B?=JrUasr{g~?)6!leCHs#2Ivzm&TdGEVi%eq)${oN~D%v%FfFLo%bXm4Cm{tA{G5 zWzoOm*rJW}v|Y@;QFOxu+D1h?RFj)asEbR9k;kb9SUsuF zL4NBlJElqNPfFe$CDm;tq#!+x*er=!uG->7^y8zg|xsMNttPdl#+I80M8Bldp!Jg!K6V@6cEJ@2U_sQwll= zGPg-3Q((6=sjw1eT#?FM#2dFIA3O2seMwUYZ68VFy2Cq0{ZJVA^h_ESFIc~m-skgI zs-#bS_(!I4U5lZ`L+;Uuf4WAVoy)&@EDwDkoN>msc7Z*vVq~${eWGIIRjH#27j9#g z&v4UMxM3Bsvxuyr$()_^?bW1jE%WOcIqExm6Q{Iuls8?e>aEIuW2ke1O7B4GX9Ig< zEH!T}`^1gPQnHtn)V@My-Fq^om^qz7?zdw{hm$7@**9+FsO!qd?aBS6s<9nOO&8Og zp=8x&^~)G?(qD~vF}ZoD*=`4F+A{NqZB%@?g-Z<;jV!FE(sPGd#u#V9&XzrgGxmiR z{jV`2ds>8Av#XYvZ;oQ4&zK##&e~UM-Kuody} zlk&|9JUW9}R;0jPDBsSC!#i>NwOD+e{IpC~#Gq#Z@`3=EUm~5@Bn+P;#aZ#&l+r*8 z!}=HK^&+nQS>$iQdG13{r=J~%9560u4FcBOykPXV6F1ii4b$uY-V)<>>UR$j*T4Ng zV|-&_Yn`3(MsCU44X$!cZySW|6C3~e2~1}Fii^CVece2ofB3Yf^DM)r{OYQE+})1V zl07%^N7dWY`sZ@hgi-nh3#tpX`stQ6UljVj^);fIe(|5$lWzJE)pd_&>Fq1)za7_) z&TokNsYlBjo!q&Umd1rAIfrpgLG28Ow={h_W=PL$dS=Jxd}@lxUVT9Ey4IqLpvQAvcvt8j!nHpF6YTi@O0i50Uat~=vZBX(aj1lHyP}_Ea#aM1 z*sBZOd>h&7wRJe$T|sHH()_SSm(^KkZp z4ehal-S(2&eTy|YKt0m4lNV4ux+wbuQ9JFF@5WQ7tJ&%hN6>oV`(F?A8T^K5(DMC9kG&=(xR90S{cRkl0(;`t-~cPgAOQ> z?-bp6Py8J^LlvoKjeB_l!Fla0kxfo3UZ~0{%EA1e3(SyR(axUvFBv@%R%w} z3u#`6=u?lhHsZ4iG3y`ObqNB4s0jGzXf1H4M0Po z`!FylAO4&z_zn@^luO+8CMB4dMg3F|E|yPF8ylH`2k*0;xY4n zPSnN?7F$xNA`_zlhwd@bQe8j~bGGDXGWUut&Q>wxNDE_+?CCM{--&EkjTx23^25v` z6Ikr5c4;8{!&du>W;YaS9%nN)X`0BXOz}w#%hPGMG&gqA35A+4jLyBQX$_$&*J%8X zk&FJRJxhtXnA*7=5q#IA%PPE28Kl;_qxj7D?r#A$*hXMx?b|WXRz)kZPIa;I@EV3*Y_?Ow}7+Tgxaj*Vum1ig!^4D zwiwCb`^0+*TzNO~xzUAm0~YMiZ})_Aj_4ZR1FzYwyB31Ay5?F8L{4v7n=Ihh8YXlV z`kB^$zr^$VYcKTSyM@$@ykm%tsh%~~5F%FjRdH@*Riy{H(4) zT5-M|YZH{*B&#}s;JTRA*D1MPuzs8qr!H!UkL0dJHEwv%O>1b34mZ>dYqF6H@lj3E zasK%6rop|0=O3EJX9?ne%~}Vrs-*emULfVRnDXE_-#TfQQJ<}U{u0JV83qm(bK?Z* zoLG_ty-d)9FK9$4QvQ=a9z!P|Da?zJ^;9B8L?h3T%|oP^!<6ozbiX&flagJd=pG63 z_Z9TNHdvP_bjTU3Xe4dss_>pbkI7Yx-baV|;I%5|_78kmA>$NJ{4;vT29YN_s(J`y z>LnBIA>}q#y=ohs<*4o9#!N3UBkwV{hFh$$VJ}axygQLy)nZAkV(q%MA$PN1>MZS3 z*$_WV?^7)8YcV~E4d=`bhOxhwXe}++RUg&euQDFfO+AJ)^)@Dom-Kjh6*i4twN5#( zhH`GF+_Z(-(!}0!px!pKceCbp8Ws%2{ezE0r5UHevKZ6C&>)b?A+v%N45i4i-3ximi|b z{wCeW${}H7xNAXA&KV2&> zapO@N@vUgM-BaA$mM;wwyKUy3c8C-D2|@Yd@J`^YUaaf}8>~@DFEOMu8fJ$^P-s~t zvMm)Y7fU}5itR2+!AA1-A<5oc6uV2kOQ6Ye)ISNj$B3PM;qkd}Dgvhi!0anP4i^4} zg5;gN(i)r-43El%ix&;b>%#0*gVzzE=(!<2NyzHX`=kh|H~4dxge`$ahJ%3Wgti}r z+l#^bX5sWhknI50eghdBfx`^x3LIx2RH;X867f5uQf5Iz0I zZCHrxhH)l`P{~JqyL>eGw*Fxgs`;xw(M{6M;pR<|@;e&pW26npFm$_=Q_nA2B3<|_ zv>7ago(J3YsB#P}$wsR);KnT|FBf{QKv}V{T^yQ=LzC+$cm}XBqEs&ljXkAj7Q)dL z(ua%u^#WNTV8qiO5QjC0YXoS~nz(*%B^Mt)b%Y$7K`sL#tphx4YLZHULmOXX^OW8 z*}<%0SsMLh3wCb_S*gItF8Jm+dGtHkXp5eH4js>xHr|9&Hb`+6;MQ#E{7b&~T*>i| zA#|m5GSF~;qqJM*5>`k(K5^ehNe}b6l`W{}dG1XtTECvFZxy3lIhQceG)o`&3SJj= zabuu~R@bf&jHqn!8U^;pHfLoCFY6m;vBF5dhK+%IWL{mn6hqUL+P5{__+d49L0sqc z)vLbf`&m}s+^la$SDTL0{|l_%)L&oxuX>2PKITSE=_vh;wA%XFdfSvbs{{JuyX(7t z(HBKFn0az$-5XcF;oP1#uHInS;N8?XfafkW?QP@-2~GKjg=nv4Zx=B7X!FA>V7Im< z!wGKAZJB=p7N)m`X~n}|bn`ce!Be=jU*cqI-e&{~V8HBs$kSZ>^b%=1N^A?7X^utH zQkx%&v+X5j9npEPwDl!x|nG`6dp9{ve~Kk02N z6b~7Ou*1o@jJ5$Ehp{eY#G~&lUO_gA$~M0!daTKTceFCcbapB;u&2hWFT3oJwqiHy z`Pr=Z4ffP`^QXD&my;GH*I3rlvhXB3q?hILy=*|EMer&%X_7_u7`Dv<^K`)MA859I zFEjp|#?*wl9c^?;&_OPys|{2DY4Ua>6>FhtY)dJp8#5B*pr33*9H}_VqUq$xW9*I5 zWbi%Kbr|W!vwKF8kKC2nfn-RiGBAqlGD(?{L8fYz?VHJu3G8z}D&YxpF_X$Iq7xb@ zvrH=6jCPD7rK0jBLkwIkM@=Lw z56OP#@M|ySF25AX4ECN-y!a0rYJm;+!5(*%tNLP21Ed4LyxA7jAK5Z2J z6VbY%;*3?|(vISU5V*ujoE-#wyNh>M31=sZV@mk+6JqUZUdM|`i};SSQTPr1SRwN8 z5$;$?ybZWIOkyoz&=6^Wm3ZG+AEVKlSLnt|ba^du3YJJ)~(V|`=r$if@VM{l0^cmQzHw+jJS9<`TYS4R@kb4}!YrM-m5Z8})9}Pz745ov@ zOwMp?I9N20f9wzXXY=~$;NLi5Uj&G37XGXNofAOZ4qz-N2HXH2`a&6l{=J~hO8B@5 z=tP*Z0VKwY7G=T?K}@$2w)+@QSKc=o{Wssx`7kGMnIb=#v+0l+Vqo%r6H6+XE6<7Ka>o89F`Z`b zTqf4dV=ZqIM*m*UOXApfrDrRlG^l*KkuJkbkA#vp&#K3!lDkZ`!z#(!;by0YQkN6W z&tIbgH(PujLU-+D`SKS%eZOUg1C0L#OI=^~QHXi5uEeEK zdGt@YhrL`@ie8g)v;h4{dCwdW-9h%W73_w{rSAOr0NM4V!7yAlA_X=mHFNU0CX>k%Q+&h5%g#NzzzQT$dS-{i7xyqm^HQaN*81Gvc-Lm5OTWdLL(n! z)0jAx?~q*YdeP9+q0X$cLHJkWcbSW9tY(6^O=GHaOt@lQRhy6cws>{u75&kO>RjWq zvrSF(2K`Q5&AN5^S1q-R67`=9b<{omk>>jA8ZNb@p)8)eu)opY)?nGINh~(B&uEgO z`DJ30Yde7)(mXvwC|K2;*$v2do0~JhuI??Ru5kH>7L$8$)azE`W=wq&wYuQvFQESp65(%(nz6b-M|fic&(VIg!pM%kQK9HYGeb-8XN#E$pNR++8&pY_RkDM=QwS)F zv+1G{Chz^3J?Bh=vY41Qnqd`8=t6DEPe!xfthkW*yViW{Y37x+1-+hWUSM%*IwLH! z*ffIq+|GitWj?Mn|J+3Xx5=#g6?)M)ZNxPCVYAwTraSF5eSVLcIK*V`Hmb}@1tw8> zu1e3IlywsOP(ghi$i{sjbGxyg7s)qc*?-$f<_KH1fPB@$jt?W#yp;M#a?J{5v>w|b~IT?LG zVRe?YEyCu{Al=4bN{sAHV0+_;BQ5gYzxXU9d)ec&HQ20?if~ITWHM%lYAiN^$Z#F@}LhUWxm)VAeM=yq~}j=y(EO>Vd4g z@l!XWY#aV<0s6CqH{sF#_QEW4$y5oxsHBgb;lV0&c95vaLcW6#w+Jnyr4`)}u}`}3 zSDgD=nwlftd@MB_7Dug+{%jFP{z1$#ajgT23J|*u6?bxRIEU!eCOP?rcFj2G0S zVeLVlG=aT3@CSbbo4*F9SHP7wcs>Lk<9NSpz`W=Co(3yogy@6dn+uqi43548R+(V6 zKh%E(vrfW@J}~J%oSp%NbMSCaabYObXNyrkz!e8%+Ygu+O+cZ-?HkBpIj{SMT;dFO zap}iB?zNL~8o(X*lO`DSla@#`G#s;4nzn>X+b+2&3^7Zjyllhkp;Fl*{uGZgI|zF- zQQ~!B?o^b~6?j^sA9I1CN$j=;46YP4(?Cs==w}1=Xpz?oVY~-AyiSN;g-nMDk&hAS zEa1JQ`(p&fU1|O~p*mc44FCr$v4#=w_C2g%xwy?ok@yn%T~r(!E@vCNlUdjZUV$|D za!FA;mY6}~`=*m0I^s_TQ0si~*00p2I2?GzT^-ChyNv2MZ ziMns<@3j1TM=8Yr!x|8N` z9OGT1UY|$Do>qVUK>Zt_-t>DrrEU8Txdi<{&q=YCOq@n>(2SFF5E zJH0om%gI4=uzmj#sTfwYP$6Z>@2|>j*U7W3rRJ$}yBzVKx7=z}D-Mv$a=`nxavyWh zr%W1pSZLiW!T!Qyt+8vyAKZ%yBl%ngDLxrCZxeS~8y2u)Xm_r|QRuDF&vt`7i(BK1 z!G94gFK2*u4Ncc7gvdpWkrRYD_4V#~eC_tSI2XRFf9=W(hL$Nc-v=9d-m3mv!F^d$ zz4ZWhG`@Oq0LKW`$IQ9GIW_g)^t;d3_Q}_ezg_q8u|B=1KBq>%zo;R15I5~|Be=vR zEo^!|$Z&~n=06*(w>1kJ`6+*zk8~2qJ}vui2#(8ICXWOkuD2X21)^nZ;!^k|qcv6| z`mWObz9bHe)0YlIKR$449wKvNnb%1oO~BEW5-5VYbcx-Gu057a`pb7eOP;ydmL_Qq zg|9J_mst>g9&%D8;k8Q6+d;0lE4Qkswn}V44Am5h$-k&J53z#*birW7wioo;FN%#b zn2ck1aa;D*QloiCi4P-h*{C90NWV!YTQ*ZnnyJYjYHgYNffGHeRg)YHZ+5O5)9ZnDw;j{}tL7l0cNeIi-J#d)HPwdF zJ`+rCG*W9FRhgS82Pb8|CG}u7d-gJE^_{uxOE#QfUe^+<(wOc?iB+E%aVl}RA1n7J zxTCDxk!WmYyW0{vMccms&=p*#RTz(QA1+0mbq?A&Q;TQJV@_a za_o50D~|{cAv+z!dxVke5*0(HljC!-F{4Pq0n@3;6L00@Yee8xS!Yi;Uyz5d!1rI2 z*JmgWpOx!gVHvyR5Gc2uBA*^C143SwB$e!!3PiMqk|u3KgXf?wT~T6|==fR8)5GL+ zaoGr1w@Yk31rn0PFWo@mbuqhK*jz1|ycTktQ8E&&=AlIkK+a*bkb<9Yp)-$QdcN@o z5-;STb`wz7Z1h}2g_&r}W=V4nt$ig)7Z6o0&Ay4gU6MSXpch_JehI3(gv{Texeem9 zTvTr*p52DN^@i~tC~yL}QX>x8C$v8*8YrR6S2TaX*T``01O9g|Jc|iyqu|MCp~x5* zHV0`9;8ZpsUw}Yw$Q6Kb&)~7&;EA(%(iwJ-5Pf&S=j+9-2zECvpjV0eG@?nhDD8x! zf|314(2#>>NWuV1i7*26j5C4Zd`h8Iy3cT?vpl`Cp&?QZXy!6fm%i z7XBC#R>;X)d20tbxRX%uNV<1gSQ#QsBESoPMh*eiSJ1!!Ff$7M@C1KGqnM6hgbQk{ z5s1!6pDXxvL-!5}7k!ZZZXqWbg=Pq^8R=V-08^zk>wwJ&xy2LKmCFl8iuJQFzs1O; z5X-$L{n09-pxo70F@2a~>}G{}A%6UkqDK_5K#OPllIJ4ut_|e)$2gctjcQATouD$N z5;>QtJDUlQBb3K30$)OXUq{RvN%>4C7P(Lz2N8zBRD644YCLs8!fV>npa0>b>*3!EOzkv8%W3uHdBhP+yY~~3*W0YyG;*4)`KCXlS+V(! z<V;-xB-%h{(j;&&;Y5tgNG{F|jnco!0!sXFE*avet@`d!ROrn+{ z_NG+lDKdK{D_=Ngri9|acv~q>Cmb$C*lxjcGin|vTyjE}|MBhKi;uVRO*_Rdf+23A z_`%cQ(^I@1#N}wk?S1vvn&8Pltw&kY^&F(q;zC$ftf`mM?)~p6WwW5{Z4YuUBHur?~jBedq zcw&mK^GZ?uL^sO}eVeHdKZ}05aXTC(otMEQS=zpvpYT^Q_Aa~H$!iY7L|=KE8}gek z%Xg)pv2uPVEI&oAoS<;elPiL7@t>UOLd@%c?fFjJ55hXFC%YzNMpAgj5A6F6O7EpO zYDyo;SL{!r}%KNAS>m|CXbG%@Ts8$FEt`cjEKAp@FK-+E9}>`kkpsng@tmu^vK z!!`cRRNXjj#((q{%Ix_Ty5D9q=a=-=+h(*WQ+3&_Y9KRkp4rZ^%;6te)`KzWqP1k0 zhut&@*XT#M`oUoO;0u$on-rIyAHrj5i~E7Cm@Pp~D|&rw|OL@Ksp#WL~gvpnk_@n()3K8}!V z<%-Mr7)(B1t!UpMm0KwOev+aFW5GA1&dcPsF;Y~X6y9B`v6J51L(kJtd4J^G6P-9L z4tge5)WN1GvGWKhyNmBrfwsLkT@8SZn2{!A4-j*v3%Up~-%nU_QhaU1Abb`#d=Lzz z5l;<598hLQnCpSEYv9UB2o zih4?OT%|0PRGxxXzeoMc#IK2HE+H-+j&MgPHHxJ(z|T`+;Ui(>L@_o(Xd=Y(LBi%7 z*f&-PTME~I6^OnNg@ADySlIyjTR~zQ{2Ty*LVS@68)l04yhNuX;_WYD>t%860+ez_ z-2E3_UL#5YQgRP*WTuq!1RmB&-F@JP0rI~GfLJAe?gzp$WxG2<|F<$9E)@NhZw?l^ zSINe@OP3P)+YUiKgFBo2dZ+k4U8Ex8Ura?As5Od_RfW9Dx|Z*n%{4sT7I?| zdwx(JEla0o$p^2Y`Ofl~o8y$qU{P28?^m#HDR{*(NkA}BRhb&=>X8YB9X zb9Z9q7s;fb*rc{p-ylWTZIq%yVK7joF?f$nboT1eHFNOaJ3?a^+`_5)g# zBeP+sc9}%~zM*NlMbG2Z36tnV6ZOkK)MLz)T}*YXRe9HuXMZUj7LY>`d*~1GY7DEI zM>IcWb~oZNG0c}Fd~`VD5Qra*V3;BJmaR zBQCO7)jVR^1-aiRd`Fc0!xirwB*(5%Bn+36?qLm1@*EYGgvp6><@1lEr>`WdMbe4U zQjCP$i&6Jj6f_gbwc>mNdHRWGABxx0;ji#7|f<>FI+ z;R`3GO%z%wbY#C!twdvK;IBlDXF;v!j<=EZKeYEB z+SONjPD+V>(id6^A0W-GM-O#~yNd=VpmVDbGKibHpbr6J_+!!XEac~il_V@-Mf(Jh znho;?02m5q*nk8r^mhdZUxF_SLCh&Y6@jrSVBt`hbPwFR1&?XqEeFwR2h_)k`gUUG zeKF;RSoux7;ETrp5FOv5QgsWST%Y9h!7d_q#=T@QX1L0i>b*lrN`bhgSz|mOAVkJoVE$t5jAW(*D zz|Xs~%OkMW9Q)}7UrfOMgV1+9=KfQ>nTmmGqV zj%geU8GV}CzJggYLTz}(_%xX+l9==?({*-CYpkjLUV40xY2TleU02g~&Q#qG6Jzf_ zV28<&`@~|c$#ZLhjZ!6S#-C*>UvY}YTdd_;#kLd7RfQsFF1_j)w%U?9=7UYVL7X?k zoF?FjO>$lf_Vk;)?u>k+NbV6KeYhk?jWybWWOlL`>?YSI!FT1-91iS@lkB&GaCgbl z2{h}`kdMOT0(54J5Ox^ZcNB`|p#M(r-@2dx(O~vS9OG^X9V7nr<*wa;6Fu~=%wdLF z=N1W;6t|cgHE;>dc8%#nBxPoo3Ems9W|v~F(;mS9gE9)s=|)wzn*4th*_N(t6rr1 ziz$gGA3bI9LNe=t5qq`5lE%NCJ)A7yR=AC%hXng}j}l zcqI^4HQ44x?U`Es!WWP{+(kc1ZK1Eottj@;*Oy%!_Y*i@v`O0s$N=;X#1s+no zvlLZ}3bIhh9#rmyUUWjgeu>qoqR&AwMJ>J#7ynxYp}#ofGZ+~v9vul>cZg~E!kT=s z?M#6{Vvdt=(+#;f3-UA+GDq0G8VxEH%y*(SO94EAE_Q{}@1fM65dK9+PlzKrNXy5e z-~h?#Cz>5Ci-NEX>!Ce{ElY-jGUYwD;hV{_`EQt^ zmCu`r=Sn2|0U~=ux*sU63zZzEh~Z9B&M5IHK({-Jx!;j(C1k#%;#2UmggmCh4#T9u zUEr8Q(n}t!H%J~jpqeVzc7#{1$UE0SYrWhmLzmvzpKQ^8HWs-Z^*D?zSt?z7htRqTpqq%j`xTs)JhwryaW?54r8rzgPLEN9pQ65;R*cy~ z=hY}OH!~Y1;w}f+^Ch@%rm}h_u{2xNsfkEFZ89g8+_%KED@i%1)xojUuxRzCI?DO7 z+Ik`_UR7_pM*HtouOyf$Q`HI|rdvmK{R+nXx9N~o40qYo-jj)2ZJPa=_8wq*ddckwK>hCppMKNY4XI2yi%@udY&*}s2bUhX!le3@&Nv1kn)@*-mr+hepK;pE%TqV z!Zn-@e}*OZq^j3pH!6v*UKrSihjhZ04puA{<@NP)(YoSFjcWec#hbcOH2jFYl%3Kl3Ky&9og0rEC&*#%SHCcG^4bhD@ zPYwH3jZ-HX%s)1qqYO3`4cfn)VBfeKaQjX*t{rL^J*vq+-GEC?o9y`$51OsB`Lvjp zm@&do=T@H|!jZ?V*Ea%ej85zV@oZh^7chv?w~rBL1?l~Jqkd=f-F_eq$rZ;+iSgVr zQS$rGFg8&3*7998%e%~kJqP5--r)CPIe#v!Jt_~$6wA})mn~@MUAf(0`CF;Hb_KSg zLB6?9VNYR=d-2m6Lv`$sdt}@bC84GAPN;GhQsdT|^eLvEdz>cTL0uzUXbY)pkO||;{jYT`+OO+&1J1H^gYd!mcf9nK=T0Jodw+u&`UFv;D}CL zM_$uWTx%RQ4c(uLw+}-`3vg5i)OQ*VX@J5I-0LyC(-Zq1hb0aed%`|T@Z0{-<0j5A zgl&2W-Rr>kY++t4FxVvaH-_uSNCD1pXIDi{EL_tspz%R zuy~%r-`4PON5za<)v8n}JykWatF+cvm9|rKvs3kXBlP@Zu+LVwb;BSe1bg@zRDVai zx*6C^Ky9;?&91>)LzHJMpnb67Y&5tNCk=A|@1n&~KlpopVd8ZjI}C^I;x_?mHJ=YG z0v`tQ>UbVw%)3Uh(&^s>dv}L zwKVIscI8uAw^Iuv=s7cO`f%DUSabaaX?USFo=Nm+-=qThAZ|+0uZc*5U!v~0y8dN5 z-FHR3(E;tKp>@l&8rM^`!@M*rtZLtutJlB(*Kvb-{>^_wt<-bw{F6j=)%Smit<*oA zYP-)+TimL(OI2SSTW76Td_UJY+|~F5)SsEAy~paE>a-jD8q)Q6lahu7)8(Pv8qXLI zr*(}lvxs!Ranm~5)~xBW5&OEc>1{Zhp;RxZVUv>7ZF}*QjhgHUywP2|YZ^D1t+SiK zU$2%s&*tvONaGUzDvKIz=4my|B8u0TfmL^S7aO?n6R)yFF-q{a9uP~fjRLZF>MhgWt7+dN>erec*ha@^5T8!;-V)N3N7gHeVGwzCOWr$)wA&ze z?@oNj$=NMQz+kzT5n1Rew>KwXzkJ7u950ifZX&tPqkV`IEQtxrp-Jj+Z(D`@i zHdprPt6q?0zkkyb4}SVB&3(taCeglAK$k!|E(I8kqJV*I#?-1eEdE8tI>GW1l3)$D zJR`m9f$*B7TmpB#k!>!(fs>`Re5DmNjp5=fI(a_#3ZrJdc<*fLuHvm5C~smzTC*=T z>|#e&+Q=S=Y>$dBD5U#s`S0~~k_Yc(K<&b~Qw$k%k7te}?!S1If{Zr-PpjoQL%?E< zY`O&eu_E&i0RQ!*R8P(=P;tSY!H?QB%d$&zkYsa(>e6Q5O9 z+ob1ts`U2K!3(M}(c;>*s!f8}qqpkmGU3usgR75l$MXgw>~YFmgZj&8wwZxhZ*=XU zK1U4u%u)Jy!n02m|Czu#3&q8!;NuACUKBVmN=)?zt!#vTW5HK|H@63~-obzxuD%D- z?(>A3yiYJMc*NRG=ez&X#Ev}6mPY+!UJ83?-)y;XR`S+x*c)IrRoot8E&ub_jqO`k@xj0m zagP=75fqjb!9O#F#WrYUj1agLy{i^t3efF|VxQJHKS?~}jkBzz8HHFAFS!pCCORnY zCJ5(?6``HQMS;pG$Hfcw26nROTx}ptk(S4*3U*7c91UMaNxQBY+J#EDstws(X(lz) zwvp`13{O24r=B*x zHJ?$3pNbB5(9|%6)fLoikV2D)PW_YKWujlVrO?mFF<5FN;=yjxdVBodLh7~X5Oa> zrnHkAUG9($<|P zmoIAbACa8f+BgmA`de!{fX*MRlQ+@KSe@}*THINl^^XQ5$)n8JoM9x#l6AUIkO?!g zq(%AzzldH=r`g-+k1e#r7OLt`56q|W-^p@Y`uG@etR*d-h-E4n|3hvYK%!2|TgH&2 zWpY>tQaVxoE0W<;yX{r{C;uAwBEGn?Ipr%lOlDI&cE`cb>+b1%39>W1qmiwe%x`ofgrlhH#`i zHPC^>ZZzx_nC4DPW5JBMRJjUxEu;aq;Oq*zt&Vr!q_243yN=NBM|i&!+Gjm4FQyrb zc}+R(sfVJM)6U*}ZV{cojhn^LgNJ$P5(;DZ4Ga463QsQ~AFp$(NMe%8kFFp??(%W2 zWMd)UK8qCo=Fhi~DeXaEE|C|2kM?v(0{E9f8+BmbDmL2%$~OGnaoDYaYo6(aV-Qde zN3DaaAqtU@yAD=vL9_yXe5OAV&|bpsXW>~Je8CSE4#RO1;nHb%vL%f0!pAA-eFPt= z1htQGx8Gn^bAcmh?+Nm-^{yK{{57_KPg|j z!0g7$>zA`#e{}y$S;0=-?;A8&ryb%*JIvEQyhDD*X>QmN*UxI#)AAQ%^>ZNSjA(kl zM0eD`apXg-`K^YvHriORVcG@FtTpw*5KZ*Iy5_a&?4xx*iqr{ib)6oocMPxFTdICN zwGJC-()ZMDo1*FRp>F9FjX17;N*nF#JN1K4YF&CZBzM=L$cAqjI@87mqd9VmDUE)$ z^6-m|s(nNRO%+Bo$g63|F52~LQ(PIfTd8*K&MKJt$2?YgN#h*MOc!c*B{E+t-LhwF zi&kg zOaUx3#Eui-#vG+xLN5lGK zVq8zd-X3C#Mx|*k`dw8;rVI6BRbZh|Ut%x?37Z!iRNTN`HOe?2ykVcxdjw9gQueXN zfln3M4!GqG#iI_m|6oOWXS|h5&wJqH63KiZzL6rmamF2wNO|6P`wFRN6y9Mcr4``e z6(R=uC5d>ipU}FqsGB1ky)H~xB~;rBjkAS-1nk~X@UXxOGx6%(C}Six`33jhK(A-O ztEMQV0Ic2&S4{`%GGJZBcT5J6k=*zmcbdYz?Rhg}{w#?_KWD#&GZfDDmC-pP*xsXb zMfv}&Sw;c0rVaI=)9P7*+QP?OO|Kr+6NM!-8#2ZpPh|8^?NHCy_(gCk^@2 zo^|g@8*Ayy5p?TaTJ1#l`O#M0XnZGHPe@W0`I$#Fv&sE1^5Bmg;7qc^<+07lj>+(g`8eTTkLh+g;z-u0%s*3hs8{bdUeXh?Ne z*td$@6X2!a#N{nOO=RqOuv|soE&)rr(a{~j=JB-EJ3eX+?RSz>y^OMg-_D>;llY|% z)O0jI_Jh81);A4NV-N29knUc_>mq2HC;z*UM$hA0dr(ssK2u9FhVd0I$mSv3`VN^n zfm^4McptvKkT@mqMo7I0UooFfnhRpz&_n59iw_HH1=CycRi1E2K6i) zq%z$SudPttzlw*fG*~lC7*S-<{e`gERMo;mY#6K36o|){s3O}+wX0NLhDm?sss0R> z&N!;7jikX!)!bY$u*l%Dm)LN?zy^!@XhVLKP-J0nxtoyqTiHAXFU?SP9Et-EDR<T7=R)2-m^#kkce~N{6Zo`OWM+4697_5& zu+Ia@w`8VzBX3^F3>M05Ii*i^MN#xhciqBnwA@DvuafWan%13&)qAySznrg9U;Ux8 z8rI}LQuk|pysiF)rzW&bz3Wg7FRQz4r}_T6&Udh;qM>e` zhsJ+EeZW!8u<-iJ<(i@O^;Ir<(_lldVr`4`26Io{cGJdpSU#|#@pim?_f})dNMa~9 z>FfM@&1w3rZ=X+U8hn|?7^#<6)5`VgaUGd+g?iRVX1-E0-GfC#ZNwUO6{wEeO26XGiM>PNx#_#TdsD>vr!DCZF z?M#$^5tN)qs%r4C9IfsRgFE68>)@NY`0Z_&c^)?zqdXmU-i(I&2{URDYbIuG#9Q;k z94$6EA^8Ug(t5@3FGAB;Wd{fGq-gMcjaYQaAo{QDvN2vC$7q5q_ zd`FAzR;k7!@o#61q^ow%Ll-#Z9rNi*igkQ8~O7*zIq;ay1?yIeu}5ds zmj0)ACoZL`RNeO!dJO3uUR1MIjvUO+{*jL^VO`gf=>bgrog@Y_tBJIH9b0)w4|8K{ z)9B=3tUjHtH(_TkQS&$S+!i`Fn8rF%J1e^GH%a=Bm>(uLCz4!y^5%zJ^g*5+E%!Ys zpYWFF`pUI#a;KFt@sNGC$uWL%aiZMeyga`~_W2@5jUeX+lIfA8JdS+(MQVFcU4QDA zO#|1{;Vaqjv(&9Q&%a3xlKGeasCFcna+%uQ1=~XC4ik8GF5Tn|H(1kzB``=$S}cP9 zy&}yg!Et%Sw<~;DLcH{iy&Cd65zOmNPtF1RSJQPeKXaL$Kg;vp(+5-d4ZYyiihG)} zg&OA7iaoAn@dE3oW?#QjE5^!`=@`!L`RUgzY)c>NSI5eKk($4(^<|Q%VX6Mazcnvk zL|VD@mvxR~>CQje#JgmEckS&CX!;26tp(-7w@hRY-Y-wrK_I zgX`~#_tn^TpLAi3(9Bt}y+ByS6$XZ)U$nAxfOy2*pwDD6ccZ~W7xCORgIY&%UbaEo zHsXyt25w&j;fR6xDWRu}!MAaOT&HYRgX0b=EA<|LuF9$Ic*;Y?s?IoUouW>MemN*^ zeL>w!6pNlAOHt8~h3q6f+ZII{DqQZOr-q8?eB=xjTdL8OO39)JKAbIiZo+d8O7os! z^?0dACt*&VxNM;ibU-ZLCNvm}Z8r%Aw+db^LfcpPD-zaP;%%|G+8agK<6e)TzF@1T zBdq9)q++n|GIZMw8e79y7f`edmv^;C+_{^S)C~C zD1To^tL)`VxwL4x+`J{TPLmH$W*eK44(r%!Z(_BBZFxu>x3jq_8sW>7qiE(lHr|uI z9>yN)_o${U(4Ts~r_sykrUSHS81?E!uOhlRiyXQ|TDg(v1;o5bp06OPYqC|QY`0a8 zKO)yIm#^)S1#h`n4+q;Tw<(e}$+D#hvHm9~EhU>rlcoQWZV9A^L?>9&i&JUH9U6Lo z%Cp&yblRY0Pu|dLk-Yd5U1eU0?=B51>o>z+5%)90V&KkwAOs zb(t*f0EJi*Oo81^vbPvSej+7D!Hw3`d?L8zM!mKC<1w0*%zNe0djWjMUux>gQze!? zh)*(R+k0|ZU@4Y-L=_#^lHbarA%txXrSHG9?D2H$YgUS>!)xa8ge3iCyAz35H@zvE zSO)Oy*JSNGUSv%xM}nZUbax!cwPpKc(7b?ojfEyFxZy77qT^j}!r+}?{U>;~2^j03 z_k7q+K+jU)MKx@ugZVFDrahW?99GOmug1Z_zGy-nn7s>GUjmCG&_N%NdmY`I4Ay)= z%g2G5PWaqxV6+E2Zvmwo$0UMV$AoLYK;HpkQeT+zNwf}wN&BSUO>j(iMQ{N6kgh0K z;yuHawek4s4Q0tFVVcrl$1`Elc!U1K#j2$S*89cr3k<@pi;o5yG+qOqVONb}wwbW5Lh&wH59U?0nuxnRQ(XRp4&79Q2cV!Z zMY~qW$wl$+F3f0>ESJG9A(DG@_?(Cd8Q_GwIA|@%J|RSR1&SQ}@+;SVMamo8hQsHF zxQjLXv6r`625OFSkGp(rGT&*--QIAIdrYO~qZhNz3eebsongE~4n0%KP1e#K7x-I4 z8aanMpCpShPd6hSISJBHzet7CpDE#l|zp-71zqU3!9!DAcNISIVN;fTea~{ zx~QMJ<|RGvtbS+Ae7x0XhOlkX>bN;9^SgS3FYD^6$=J1^T|_V^o{JDbP0=eHZVdOn{V2=1KW1HOSxuerGs zv=zbO5a`<<{Conpdx20>bUGCzEl0~*UW)e4cwm!M6pshpkX|Z;mTr>mQX#rZ zOt~n$T`S&xD0KfMxZM^q1_?pIf>#K(8Yu8LNcjvWI3Pne+$<5!evVF!f*xa#p9Ty` zhU!A_yDiMS0haCpF(-hchA-U$YF2WW>7Y*y8)ObDg4wE<{9G@Vw~c3((#;n9cmxf( z&MwWM!w0bgZS~Jpy5$%7kFlZ~Vr54kijw3O zcc}SCc>}3n8_u_XrG=}w;~%P;%7^`;N1gbw zcXWz9zm!Q|cIA=h=u%cO;xk21~lG~w-M;=L41P+&DqL>XV85w zcu@k)>I3RJGS`FPLp*CN1JfONO?TMlA%DIYzSO_?!ePj5PtY$R5KZE3{*TSN3)w0r+4GL z`O2P_LP~;i_En)@sj}KkjH^;w&KE;Vm9K(C-(-FKRpC?r^b*CI|7?o9WWQ-0>F~ z@r|9CK!)vRs*`fR7VPW?-PeQEtffw;A>K~f0dq*=5{*r&JUdKX(_H2cn`of!hiQ}0 zq-_jo9KT=dY1L>xP}}uSLn75|`qW_jMe|>6L-Ws?=Up48>NGul8p8)@E3zBM9nn^G zY4ogWWNsT#J6(gT(zYSITfmYTtzxvR)3s9U*A@DzCatZ)V<26 z_Y?JUW7ht=`fY!fZ>k|P*tW$QcORDXMB}lOWzNw)IK+-I?Z)FwPaM4v#b$4jU!7p> zMv$IoSVu-aUuLdpv^s-T%wt{4*a0O4}|cnUc-3bk!7nyrT| zH%ZgS3Qu+_9D{{_^@^mMLb{96_?gh`fSxQT*d-|=vW3yNmEfF^bV2!ZrO?bzxxSlF zVy6uJh!?+CjE%q}b}44M;%WAZm#y%eI_df^bf!oOC_r}~NFQ#a-T9Jt8mg+0_T5Js zNn!E|dD$rX5j1mzLOlTIj#u0Y!kvdI7QMn9+9}F=3L}0<5B-G?r=(w(gnNCZxBm%_ z*G2C*A-lQQb)%q1jLx+cyc6)I51*Vu!ttN2913#!?;Ozyse`EKHU+OdsRyf&AHK4Sh`X#RX==tTdk zqG2k!E`a|1MbwBk`#>I^Cdpq&@i?OYh8mKuz3AJ&@|Kl!+dui}CAvXHjK9*W1BkT= ztMDR|2eF~&iTQNa{3Qum#3FP=xr|w~r4K!rq6e)S#pc-3`nIfRCwlQC4T1FVA=<8( z9PLAg?Y(g9sqpS!e8KHFVM4=mv?A@lgI zPZH5CQ^4=9^w>!-<^~;G2p+GY><1Xti*Bj|&+15ME%@?;O#Tk)GDyjDkoB1Cx(P;o zBdJHhcVqf%6;O|&(;dNCe>zhFm*QwiF<(+ho9Ury-)WT(f7?J0OyE~Fba7Aqu7*N0 zUQ$ZGA$~W5cGobA)6`VWK5nK51>W70+F0=`p7gytj}D^VXY^iB`nZZ8kyyj>`oH)1X6N`0OeCaRg3$2mhAAr7xg!J2dAm z+%_5oAB88~ko7`XF$d*$g$HJ!+)5DOf=1o~4%5-i1E9zY`TK${XVI?}z@i#$S_j^{ zW3nIIdyh}u0VzI$LIxEE;^>*MWuo}+ zw+d62D4)I;9Q>62twodN%KQDrPQ#UP1H|T%@>(ZxZ@wZzBm50l_+|-qixo?@3cZFY z-rEW}R*IePa8EOZY8S?mVw^P|qL$V_LD$NqUA}1KBk71K+I~t3zYR~#lLjt?8

W9V4OkKR*2?0FU^B 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 literal 0 HcmV?d00001 From 3d38ad212158747b747cd33eb7d02cb81cb93cc9 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 27 Dec 2024 14:04:44 -0700 Subject: [PATCH 002/203] Fix failed certificate callback --- source/remoteClient/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 20ad8d16076..c181e88d576 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -312,7 +312,7 @@ def handleCertificateFailure(self, transport: RelayTransport): @alwaysCallAfter def onMasterCertificateFailed(self): - if self.handleCertificateFailure(self.masterSession.Transport): + if self.handleCertificateFailure(self.masterSession.transport): connectionInfo = ConnectionInfo( mode=ConnectionMode.MASTER, hostname=self.lastFailAddress[0], From e0a894631504f0d9c056983caa8acfae7c872e43 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 27 Dec 2024 18:28:15 -0700 Subject: [PATCH 003/203] refactor: simplify remote speech handling using extension points Replace speech patching with direct event handling for remote speech. This removes the need for speech-specific patching code by introducing a new pre_speechQueued event that triggers before speech is synthesized. The change simplifies the slave session implementation by: - Removing NVDASlavePatcher speech-specific code - Using pre_speechQueued event instead of patching speech manager - Cleaning up redundant speech patching registration/unregistration - Renaming patchCallbacksAdded to callbacksAdded for clarity This makes the remote speech implementation more maintainable and less intrusive by leveraging NVDA's event system rather than monkey-patching. --- source/remoteClient/client.py | 2 +- source/remoteClient/nvda_patcher.py | 33 +------------------------ source/remoteClient/session.py | 38 +++++++++++------------------ source/speech/extensions.py | 11 +++++++++ source/speech/manager.py | 3 ++- 5 files changed, 29 insertions(+), 58 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index c181e88d576..b31d8fcbbf4 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -406,7 +406,7 @@ def releaseKeys(self): self.keyModifiers = set() def setReceivingBraille(self, state): - if state and self.masterSession.patchCallbacksAdded and braille.handler.enabled: + if state and self.masterSession.callbacksAdded and braille.handler.enabled: self.masterSession.patcher.registerBrailleInput() self.localMachine.receivingBraille = True elif not state: diff --git a/source/remoteClient/nvda_patcher.py b/source/remoteClient/nvda_patcher.py index 8b03f51a8c9..08e6e3c5fd9 100644 --- a/source/remoteClient/nvda_patcher.py +++ b/source/remoteClient/nvda_patcher.py @@ -1,10 +1,9 @@ -from typing import Any, Optional, Union +from typing import Any, Union import braille import brailleInput import inputCore import scriptHandler -import speech from . import callback_manager @@ -33,36 +32,6 @@ def handle_displaySizeChanged(self, displaySize: Any) -> None: self.callCallbacks("set_display", displaySize=displaySize) -class NVDASlavePatcher(NVDAPatcher): - """Class to manage patching of synth and braille.""" - - def __init__(self) -> None: - super().__init__() - self.origSpeak: Optional[Any] = None - - def registerSpeech(self) -> None: - if self.origSpeak is not None: - return - self.origSpeak = speech._manager.speak - speech._manager.speak = self.speak - - def unregisterSpeech(self): - if self.origSpeak is None: - return - speech._manager.speak = self.origSpeak - self.origSpeak = None - - def register(self): - self.registerSpeech() - - def unregister(self): - self.unregisterSpeech() - - def speak(self, speechSequence: Any, priority: Any) -> None: - self.callCallbacks("speak", speechSequence=speechSequence, priority=priority) - self.origSpeak(speechSequence, priority) - - class NVDAMasterPatcher(NVDAPatcher): """Class to manage patching of braille input.""" diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index ed91c438ba1..769313620d5 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -71,7 +71,7 @@ import speech import tones import ui -from speech.extensions import speechCanceled, post_speechPaused +from speech.extensions import speechCanceled, post_speechPaused, pre_speechQueued from . import configuration, connection_info, cues, nvda_patcher @@ -106,7 +106,7 @@ class RemoteSession: mode: Optional[connection_info.ConnectionMode] = None # Patcher instance for NVDA modifications patcher: Optional[nvda_patcher.NVDAPatcher] - patchCallbacksAdded: bool # Whether callbacks are currently registered + callbacksAdded: bool # Whether callbacks are currently registered def __init__( self, @@ -116,7 +116,7 @@ def __init__( log.info("Initializing Remote Session") self.localMachine = localMachine self.patcher = None - self.patchCallbacksAdded = False + self.callbacksAdded = False self.transport = transport self.transport.registerInbound( RemoteMessageType.version_mismatch, @@ -145,7 +145,7 @@ def registerCallbacks(self) -> None: patcher_callbacks = self._getPatcherCallbacks() for event, callback in patcher_callbacks: self.patcher.registerCallback(event, callback) - self.patchCallbacksAdded = True + self.callbacksAdded = True def unregisterCallbacks(self): """Unregister all callback handlers for this session. @@ -156,7 +156,7 @@ def unregisterCallbacks(self): patcher_callbacks = self._getPatcherCallbacks() for event, callback in patcher_callbacks: self.patcher.unregisterCallback(event, callback) - self.patchCallbacksAdded = False + self.callbacksAdded = False def handleVersionMismatch(self) -> None: """Handle protocol version mismatch between client and server. @@ -224,11 +224,10 @@ def handleClientConnected(self, client: Optional[Dict[str, Any]] = None) -> None log.info("Client connected: %r", client) - Registers the patcher and callbacks if needed, then plays connection sound. + Registers the callbacks if needed, then plays connection sound. Called when a new remote client establishes connection. """ - self.patcher.register() - if not self.patchCallbacksAdded: + if not self.callbacksAdded: self.registerCallbacks() cues.client_connected() @@ -284,8 +283,6 @@ class SlaveSession(RemoteSession): # Connection mode - always 'slave' mode: connection_info.ConnectionMode = connection_info.ConnectionMode.SLAVE - # Patcher instance for NVDA modifications - patcher: nvda_patcher.NVDASlavePatcher # Information about connected master clients masters: Dict[int, Dict[str, Any]] masterDisplaySizes: List[int] # Braille display sizes of connected masters @@ -303,7 +300,6 @@ def __init__( self.masters = defaultdict(dict) self.masterDisplaySizes = [] self.transport.transportClosing.register(self.handleTransportClosing) - self.patcher = nvda_patcher.NVDASlavePatcher() self.transport.registerInbound( RemoteMessageType.channel_joined, self.handleChannelJoined, @@ -344,6 +340,7 @@ def registerCallbacks(self) -> None: ) braille.pre_writeCells.register(self.display) post_speechPaused.register(self.pauseSpeech) + pre_speechQueued.register(self.sendSpeech) def unregisterCallbacks(self) -> None: super().unregisterCallbacks() @@ -352,6 +349,7 @@ def unregisterCallbacks(self) -> None: self.transport.unregisterOutbound(RemoteMessageType.wave) braille.pre_writeCells.unregister(self.display) post_speechPaused.unregister(self.pauseSpeech) + pre_speechQueued.unregister(self.sendSpeech) def handleClientConnected(self, client: Dict[str, Any]) -> None: super().handleClientConnected(client) @@ -372,12 +370,11 @@ def handleChannelJoined( def handleTransportClosing(self) -> None: """Handle cleanup when transport connection is closing. - Unregisters the patcher and removes any registered callbacks + Removes any registered callbacks to ensure clean shutdown of remote features. """ log.info("Transport closing, unregistering slave session patcher") - self.patcher.unregister() - if self.patchCallbacksAdded: + if self.callbacksAdded: self.unregisterCallbacks() def handleTransportDisconnected(self) -> None: @@ -389,15 +386,12 @@ def handleTransportDisconnected(self) -> None: """ log.info("Transport disconnected from slave session") cues.client_connected() - self.patcher.unregister() 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.patcher.unregister() def setDisplaySize(self, sizes=None): self.masterDisplaySizes = ( @@ -423,13 +417,9 @@ def _getPatcherCallbacks(self) -> List[Tuple[str, Callable[..., Any]]]: Returns: Sequence of (event_name, callback_function) pairs for: - - Speech output - Display size updates """ - return ( - ("speak", self.speak), - ("set_display", self.setDisplaySize), - ) + return (("set_display", self.setDisplaySize),) def _filterUnsupportedSpeechCommands(self, speechSequence: List[Any]) -> List[Any]: """Remove unsupported speech commands from a sequence. @@ -442,7 +432,7 @@ def _filterUnsupportedSpeechCommands(self, speechSequence: List[Any]) -> List[An """ return list([item for item in speechSequence if not isinstance(item, EXCLUDED_SPEECH_COMMANDS)]) - def speak(self, speechSequence: List[Any], priority: Optional[str]) -> None: + 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 @@ -574,7 +564,7 @@ def handleClientDisconnected(self, client=None): """ super().handleClientDisconnected(client) self.patcher.unregister() - if self.patchCallbacksAdded: + if self.callbacksAdded: self.unregisterCallbacks() def sendBrailleInfo( 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. From 4b7eac9b546e646be2ce943e469cc64fc36e696e Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 27 Dec 2024 21:05:01 -0700 Subject: [PATCH 004/203] Warn, not error for unknown message types --- source/remoteClient/transport.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 16d93373682..36f2c717e94 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -487,12 +487,12 @@ def parse(self, line: bytes) -> None: """ obj = self.serializer.deserialize(line) if "type" not in obj: - log.error("Received message without type: %r" % obj) + log.warn("Received message without type: %r" % obj) return try: messageType = RemoteMessageType(obj["type"]) except ValueError: - log.error("Received message with invalid type: %r" % obj) + log.warn("Received message with invalid type: %r" % obj) return del obj["type"] extensionPoint = self.inboundHandlers.get(messageType) From 927d4f1beabf22c85658e5f32844a7196e5c18b1 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 27 Dec 2024 23:06:58 -0700 Subject: [PATCH 005/203] refactor: remove NVDA patcher system and integrate braille handling directly Remove the NVDAPatcher and NVDAMasterPatcher classes and integrate their functionality directly into the remote session classes. This simplifies the code architecture by: - Moving braille input handling from NVDAMasterPatcher to MasterSession - Replacing the generic callback system with direct event registrations - Eliminating the intermediate patcher layer between sessions and NVDA - Removing unnecessary abstraction around callback management - Relocating display change handlers to use direct braille events This change reduces code complexity and makes the control flow more straightforward by having sessions interact with NVDA's event system directly rather than going through a patcher intermediary. No functional changes - all existing braille input and display functionality remains the same, just with a simpler implementation. --- source/remoteClient/client.py | 4 +- source/remoteClient/nvda_patcher.py | 74 +-------------- source/remoteClient/session.py | 137 +++++++++++++++------------- 3 files changed, 79 insertions(+), 136 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index b31d8fcbbf4..a8419a3c841 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -407,10 +407,10 @@ def releaseKeys(self): def setReceivingBraille(self, state): if state and self.masterSession.callbacksAdded and braille.handler.enabled: - self.masterSession.patcher.registerBrailleInput() + self.masterSession.registerBrailleInput() self.localMachine.receivingBraille = True elif not state: - self.masterSession.patcher.unregisterBrailleInput() + self.masterSession.unregisterBrailleInput() self.localMachine.receivingBraille = False @alwaysCallAfter diff --git a/source/remoteClient/nvda_patcher.py b/source/remoteClient/nvda_patcher.py index 08e6e3c5fd9..14075337bdc 100644 --- a/source/remoteClient/nvda_patcher.py +++ b/source/remoteClient/nvda_patcher.py @@ -1,9 +1,6 @@ -from typing import Any, Union +from typing import Any import braille -import brailleInput -import inputCore -import scriptHandler from . import callback_manager @@ -30,72 +27,3 @@ def handle_displayChanged(self, display: Any) -> None: def handle_displaySizeChanged(self, displaySize: Any) -> None: self.callCallbacks("set_display", displaySize=displaySize) - - -class NVDAMasterPatcher(NVDAPatcher): - """Class to manage patching of braille input.""" - - def registerBrailleInput(self) -> None: - inputCore.decide_executeGesture.register(self.handle_decide_executeGesture) - - def unregisterBrailleInput(self) -> None: - inputCore.decide_executeGesture.unregister(self.handle_decide_executeGesture) - - def register(self): - super().register() - # We do not patch braille input by default - - def unregister(self): - super().unregister() - # To be sure, unpatch braille input - self.unregisterBrailleInput() - - 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.callCallbacks("braille_input", **dict) - return False - else: - return True diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 769313620d5..fa2baa6e04e 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -55,25 +55,27 @@ See Also: transport.py: Network communication local_machine.py: NVDA interface - nvda_patcher.py: Feature patches """ import hashlib from collections import defaultdict -from typing import Dict, List, Optional, Tuple, Any, Callable +from typing import Dict, List, Optional, Any, Union +import brailleInput +import inputCore from logHandler import log import braille import gui -import nvwave +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, connection_info, cues, nvda_patcher +from . import configuration, connection_info, cues from .localMachine import LocalMachine from .protocol import RemoteMessageType @@ -104,8 +106,6 @@ class RemoteSession: localMachine: LocalMachine # Interface to control the local NVDA instance # Session mode - either 'master' or 'slave' mode: Optional[connection_info.ConnectionMode] = None - # Patcher instance for NVDA modifications - patcher: Optional[nvda_patcher.NVDAPatcher] callbacksAdded: bool # Whether callbacks are currently registered def __init__( @@ -115,7 +115,6 @@ def __init__( ) -> None: log.info("Initializing Remote Session") self.localMachine = localMachine - self.patcher = None self.callbacksAdded = False self.transport = transport self.transport.registerInbound( @@ -136,28 +135,6 @@ def __init__( self.handleClientDisconnected, ) - def registerCallbacks(self) -> None: - """Register all callback handlers for this session. - - Registers the callbacks returned by _getPatcherCallbacks() with the patcher. - Sets patchCallbacksAdded flag when complete. - """ - patcher_callbacks = self._getPatcherCallbacks() - for event, callback in patcher_callbacks: - self.patcher.registerCallback(event, callback) - self.callbacksAdded = True - - def unregisterCallbacks(self): - """Unregister all callback handlers for this session. - - Unregisters the callbacks returned by _getPatcherCallbacks() from the patcher. - Clears patchCallbacksAdded flag when complete. - """ - patcher_callbacks = self._getPatcherCallbacks() - for event, callback in patcher_callbacks: - self.patcher.unregisterCallback(event, callback) - self.callbacksAdded = False - def handleVersionMismatch(self) -> None: """Handle protocol version mismatch between client and server. @@ -334,12 +311,11 @@ def registerCallbacks(self) -> None: speechCanceled, RemoteMessageType.cancel, ) - self.transport.registerOutbound( - nvwave.decide_playWaveFile, - RemoteMessageType.wave, - ) + self.transport.registerOutbound(decide_playWaveFile, RemoteMessageType.wave) + self.transport.registerOutbound(post_speechPaused, RemoteMessageType.pause_speech) braille.pre_writeCells.register(self.display) - post_speechPaused.register(self.pauseSpeech) + braille.displayChanged.register(self.setDisplaySize) + braille.displaySizeChanged.register(self.setDisplaySize) pre_speechQueued.register(self.sendSpeech) def unregisterCallbacks(self) -> None: @@ -347,8 +323,10 @@ def unregisterCallbacks(self) -> None: 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) - post_speechPaused.unregister(self.pauseSpeech) + braille.displayChanged.unregister(self.setDisplaySize) + braille.displaySizeChanged.unregister(self.setDisplaySize) pre_speechQueued.unregister(self.sendSpeech) def handleClientConnected(self, client: Dict[str, Any]) -> None: @@ -373,7 +351,6 @@ def handleTransportClosing(self) -> None: Removes any registered callbacks to ensure clean shutdown of remote features. """ - log.info("Transport closing, unregistering slave session patcher") if self.callbacksAdded: self.unregisterCallbacks() @@ -412,15 +389,6 @@ def handleBrailleInfo( self.masters[origin]["braille_numCells"] = numCells self.setDisplaySize() - def _getPatcherCallbacks(self) -> List[Tuple[str, Callable[..., Any]]]: - """Get callbacks to register with the patcher. - - Returns: - Sequence of (event_name, callback_function) pairs for: - - Display size updates - """ - return (("set_display", self.setDisplaySize),) - def _filterUnsupportedSpeechCommands(self, speechSequence: List[Any]) -> List[Any]: """Remove unsupported speech commands from a sequence. @@ -485,7 +453,6 @@ class MasterSession(RemoteSession): """ mode: connection_info.ConnectionMode = connection_info.ConnectionMode.MASTER - patcher: nvda_patcher.NVDAMasterPatcher slaves: Dict[int, Dict[str, Any]] # Information about connected slave def __init__( @@ -495,7 +462,6 @@ def __init__( ) -> None: super().__init__(localMachine, transport) self.slaves = defaultdict(dict) - self.patcher = nvda_patcher.NVDAMasterPatcher() self.transport.registerInbound( RemoteMessageType.speak, self.localMachine.speak, @@ -533,6 +499,16 @@ def __init__( self.sendBrailleInfo, ) + def registerCallbacks(self) -> None: + super().registerCallbacks() + braille.displayChanged.register(self.sendBrailleInfo) + braille.displaySizeChanged.register(self.sendBrailleInfo) + + def unregisterCallbacks(self) -> None: + super().unregisterCallbacks() + braille.displayChanged.unregister(self.sendBrailleInfo) + braille.displaySizeChanged.unregister(self.sendBrailleInfo) + def handleNVDANotConnected(self) -> None: log.warning("Attempted to connect to remote NVDA that is not available") speech.cancelSpeech() @@ -563,7 +539,6 @@ def handleClientDisconnected(self, client=None): Also calls parent class disconnection handler. """ super().handleClientDisconnected(client) - self.patcher.unregister() if self.callbacksAdded: self.unregisterCallbacks() @@ -587,18 +562,58 @@ def sendBrailleInfo( numCells=displaySize, ) - def brailleInput(self) -> None: - self.transport.send(type=RemoteMessageType.braille_input) + 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 _getPatcherCallbacks(self) -> List[Tuple[str, Callable[..., Any]]]: - """Get callbacks to register with the patcher. + def registerBrailleInput(self) -> None: + inputCore.decide_executeGesture.register(self.handle_decide_executeGesture) - Returns: - Sequence of (event_name, callback_function) pairs for: - - Braille input handling - - Display info updates - """ - return ( - ("braille_input", self.brailleInput), - ("set_display", self.sendBrailleInfo), - ) + def unregisterBrailleInput(self) -> None: + inputCore.decide_executeGesture.unregister(self.handle_decide_executeGesture) From 77366a03d1d548e86976e4e1fd23a65714dddc7e Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 27 Dec 2024 23:08:33 -0700 Subject: [PATCH 006/203] Remove unused `CallbackManager` and patcher modules --- source/remoteClient/callback_manager.py | 35 ------------------------- source/remoteClient/nvda_patcher.py | 29 -------------------- 2 files changed, 64 deletions(-) delete mode 100644 source/remoteClient/callback_manager.py delete mode 100644 source/remoteClient/nvda_patcher.py diff --git a/source/remoteClient/callback_manager.py b/source/remoteClient/callback_manager.py deleted file mode 100644 index 0fdcc5dd4d5..00000000000 --- a/source/remoteClient/callback_manager.py +++ /dev/null @@ -1,35 +0,0 @@ -from logging import getLogger -from typing import Any, Callable, Dict, List -from collections import defaultdict - -import wx - -logger = getLogger("callback_manager") - - -class CallbackManager: - """A simple way of associating multiple callbacks to events and calling them all when that event happens""" - - def __init__(self) -> None: - self.callbacks: Dict[str, List[Callable[..., Any]]] = defaultdict(list) - - def registerCallback(self, event_type: str, callback: Callable[..., Any]) -> None: - """Registers a callback as a callable to an event type, which can be anything hashable""" - self.callbacks[event_type].append(callback) - - def unregisterCallback(self, event_type: str, callback: Callable[..., Any]) -> None: - """Unregisters a callback from an event type""" - self.callbacks[event_type].remove(callback) - - def callCallbacks(self, type: str, *args: Any, **kwargs: Any) -> None: - """Calls all callbacks for a given event type with the provided args and kwargs""" - for callback in self.callbacks[type]: - try: - wx.CallAfter(callback, *args, **kwargs) - except Exception: - logger.exception("Error calling callback %r" % callback) - for callback in self.callbacks["*"]: - try: - wx.CallAfter(callback, type, *args, **kwargs) - except Exception: - logger.exception("Error calling callback %r" % callback) diff --git a/source/remoteClient/nvda_patcher.py b/source/remoteClient/nvda_patcher.py deleted file mode 100644 index 14075337bdc..00000000000 --- a/source/remoteClient/nvda_patcher.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Any - -import braille - -from . import callback_manager - - -class NVDAPatcher(callback_manager.CallbackManager): - """Base class to manage patching of braille display changes.""" - - def registerSetDisplay(self) -> None: - braille.displayChanged.register(self.handle_displayChanged) - braille.displaySizeChanged.register(self.handle_displaySizeChanged) - - def unregisterSetDisplay(self) -> None: - braille.displaySizeChanged.unregister(self.handle_displaySizeChanged) - braille.displayChanged.unregister(self.handle_displayChanged) - - def register(self) -> None: - self.registerSetDisplay() - - def unregister(self) -> None: - self.unregisterSetDisplay() - - def handle_displayChanged(self, display: Any) -> None: - self.callCallbacks("set_display", display=display) - - def handle_displaySizeChanged(self, displaySize: Any) -> None: - self.callCallbacks("set_display", displaySize=displaySize) From 258106d1cda68d5afcaae13fb5ddc4e9a5d568dd Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 28 Dec 2024 09:19:37 -0700 Subject: [PATCH 007/203] Improve logic for callback registration --- source/remoteClient/session.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index fa2baa6e04e..e42a07f110b 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -197,20 +197,12 @@ def shouldDisplayMotd(self, motd: str) -> bool: return True def handleClientConnected(self, client: Optional[Dict[str, Any]] = None) -> None: - """Handle new client connection. - + """Handle new client connection.""" log.info("Client connected: %r", client) - - Registers the callbacks if needed, then plays connection sound. - Called when a new remote client establishes connection. - """ - if not self.callbacksAdded: - self.registerCallbacks() cues.client_connected() def handleClientDisconnected(self, client=None): """Handle client disconnection. - Plays disconnection sound when remote client disconnects. """ cues.client_disconnected() @@ -302,7 +294,6 @@ def __init__( ) def registerCallbacks(self) -> None: - super().registerCallbacks() self.transport.registerOutbound( tones.decide_beep, RemoteMessageType.tone, @@ -319,7 +310,6 @@ def registerCallbacks(self) -> None: pre_speechQueued.register(self.sendSpeech) def unregisterCallbacks(self) -> None: - super().unregisterCallbacks() self.transport.unregisterOutbound(RemoteMessageType.tone) self.transport.unregisterOutbound(RemoteMessageType.cancel) self.transport.unregisterOutbound(RemoteMessageType.wave) @@ -500,12 +490,10 @@ def __init__( ) def registerCallbacks(self) -> None: - super().registerCallbacks() braille.displayChanged.register(self.sendBrailleInfo) braille.displaySizeChanged.register(self.sendBrailleInfo) def unregisterCallbacks(self) -> None: - super().unregisterCallbacks() braille.displayChanged.unregister(self.sendBrailleInfo) braille.displaySizeChanged.unregister(self.sendBrailleInfo) @@ -529,17 +517,18 @@ def handleChannel_joined( 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. - - Unregisters the patcher and removes any registered callbacks. Also calls parent class disconnection handler. """ super().handleClientDisconnected(client) - if self.callbacksAdded: + if self.callbacksAdded and not self.slaves: self.unregisterCallbacks() def sendBrailleInfo( From ced73451d7ac17c088dec486cf89d89e1e495632 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 28 Dec 2024 10:55:00 -0700 Subject: [PATCH 008/203] fix: prevent duplicate callback registration and improve callback lifecycle This adds guards against duplicate callback registration/unregistration and improves the callback lifecycle management. Callbacks are now registered when the first master connects and unregistered when the last master disconnects. This prevents potential memory leaks and ensures proper cleanup. --- source/remoteClient/session.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index e42a07f110b..2c9dde141c2 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -294,6 +294,8 @@ def __init__( ) def registerCallbacks(self) -> None: + if self.callbacksAdded: + return self.transport.registerOutbound( tones.decide_beep, RemoteMessageType.tone, @@ -308,8 +310,11 @@ def registerCallbacks(self) -> None: braille.displayChanged.register(self.setDisplaySize) braille.displaySizeChanged.register(self.setDisplaySize) 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) @@ -318,11 +323,14 @@ def unregisterCallbacks(self) -> None: braille.displayChanged.unregister(self.setDisplaySize) braille.displaySizeChanged.unregister(self.setDisplaySize) 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, @@ -341,8 +349,7 @@ def handleTransportClosing(self) -> None: Removes any registered callbacks to ensure clean shutdown of remote features. """ - if self.callbacksAdded: - self.unregisterCallbacks() + self.unregisterCallbacks() def handleTransportDisconnected(self) -> None: """Handle disconnection from the transport layer. @@ -359,6 +366,8 @@ def handleClientDisconnected(self, client: Optional[Dict[str, Any]] = None) -> N 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 = ( From afa53b39626462277640231ff038902ee63398ab Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 28 Dec 2024 12:04:05 -0700 Subject: [PATCH 009/203] Improve logging --- source/remoteClient/transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 36f2c717e94..32326dba5ff 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -497,7 +497,7 @@ def parse(self, line: bytes) -> None: del obj["type"] extensionPoint = self.inboundHandlers.get(messageType) if not extensionPoint: - log.error("Received message with unhandled type: %r" % obj) + log.warn("Received message with unhandled type: %r %r", messageType, obj) return wx.CallAfter(extensionPoint.notify, **obj) From 13788150de07ba1da15f6fb0c227cb7f713e1974 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 28 Dec 2024 14:52:11 -0700 Subject: [PATCH 010/203] Add missing @alwaysCallAfter decorators --- source/remoteClient/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index a8419a3c841..1165e60f741 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -230,6 +230,7 @@ def connectAsMaster(self, connectionInfo: ConnectionInfo): self.masterTransport = transport self.menu.handleConnecting(connectionInfo.mode) + @alwaysCallAfter def onConnectedAsMaster(self): log.info("Successfully connected as master") configuration.write_connection_to_config(self.masterSession.getConnectionInfo()) @@ -240,6 +241,7 @@ def onConnectedAsMaster(self): ) cues.connected() + @alwaysCallAfter def onDisconnectingAsMaster(self): log.info("Master session disconnecting") if self.menu: @@ -249,6 +251,7 @@ def onDisconnectingAsMaster(self): 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. From 1820d9336ea533d10788a2026dfa124724e722bc Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 28 Dec 2024 20:52:10 -0700 Subject: [PATCH 011/203] Fix braille input --- source/remoteClient/session.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 2c9dde141c2..ca25a69a9e2 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -499,12 +499,18 @@ def __init__( ) 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") From 7ac87d3209725ffc599a4555a24c12a8af97a26e Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 28 Dec 2024 21:24:11 -0700 Subject: [PATCH 012/203] feat(remoteClient): add TypedDict for PortCheckResponse Adds a strongly typed `PortCheckResponse` TypedDict to properly type the response data from port check operations, replacing the generic Dict[str, Any] typing. Also removes unused imports. --- source/remoteClient/dialogs.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index af38d9e376c..2f8db48949d 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -1,7 +1,7 @@ import json import random import threading -from typing import Any, Dict, List, Optional, Union +from typing import List, Optional, TypedDict, Union from urllib import request import gui @@ -42,7 +42,7 @@ def on_generate_key(self, evt: wx.CommandEvent) -> None: 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. + # Trans rs: 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, ) @@ -91,6 +91,12 @@ def handle_certificate_failed(self) -> None: self.generate_key_command(True) +class PortCheckResponse(TypedDict): + host: str + port: int + open: bool + + class ServerPanel(wx.Panel): get_IP: wx.Button external_IP: wx.TextCtrl @@ -150,7 +156,7 @@ def do_portcheck(self, port: int) -> None: temp_server.close() wx.CallAfter(self.get_IP.Enable, True) - def onGetIPSucceeded(self, data: Dict[str, Any]) -> None: + def onGetIPSucceeded(self, data: PortCheckResponse) -> None: ip = data["host"] port = data["port"] is_open = data["open"] From 542c17407252ef206ee071bd55fcc461d1c9b7e1 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 28 Dec 2024 21:29:59 -0700 Subject: [PATCH 013/203] Fix translator note comment --- source/remoteClient/dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 2f8db48949d..4e79a2dddcc 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -42,7 +42,7 @@ def on_generate_key(self, evt: wx.CommandEvent) -> None: 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."), - # Trans rs: A title of a message box displayed when the host field is empty and the user tries to generate a key. + # 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, ) From aaa5428551b3f02ae0982493529fb5520291ae79 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 28 Dec 2024 21:55:49 -0700 Subject: [PATCH 014/203] refactor: migrate remote config to use main config system (#350) Consolidate remote connection configuration management by integrating it with the main application config system instead of maintaining a separate config file. This change: - Removes manual config.write() calls as saving is now handled by the main config system - Adds handlers for config save/reset events to clean up the old config file - Merges remote.ini settings into the main config under the "Remote" section Based on nvdaremote/nvdaremote#350 --- source/gui/settingsDialogs.py | 1 - source/remoteClient/client.py | 1 - source/remoteClient/configuration.py | 32 ++++++++++++++++++++++++---- source/remoteClient/dialogs.py | 1 - 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 0c7b5db549a..f745b381a7e 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -3445,7 +3445,6 @@ def on_delete_fingerprints(self, evt: wx.CommandEvent) -> None: == wx.YES ): self.config["trusted_certs"].clear() - self.config.write() evt.Skip() def isValid(self) -> bool: diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 1165e60f741..1b505702ddd 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -306,7 +306,6 @@ def handleCertificateFailure(self, transport: RelayTransport): if a == wx.ID_YES: config = configuration.get_config() config["trusted_certs"][hostPortToAddress(self.lastFailAddress)] = certHash - config.write() if a == wx.ID_YES or a == wx.ID_NO: return True except Exception as ex: diff --git a/source/remoteClient/configuration.py b/source/remoteClient/configuration.py index 2a7994838f2..ebc6d0c2fba 100644 --- a/source/remoteClient/configuration.py +++ b/source/remoteClient/configuration.py @@ -1,6 +1,7 @@ import os from io import StringIO +import config import configobj import globalVars from configobj import validate @@ -8,6 +9,7 @@ from .connection_info import ConnectionInfo CONFIG_FILE_NAME = "remote.ini" +configRoot = "Remote" _config = None configspec = StringIO(""" @@ -36,9 +38,17 @@ def get_config(): global _config if not _config: path = os.path.abspath(os.path.join(globalVars.appArgs.configPath, CONFIG_FILE_NAME)) - _config = configobj.ConfigObj(infile=path, configspec=configspec, create_empty=True) - val = validate.Validator() - _config.validate(val, copy=True) + 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 @@ -55,4 +65,18 @@ def write_connection_to_config(connection_info: ConnectionInfo): if address in last_cons: conf["connections"]["last_connected"].remove(address) conf["connections"]["last_connected"].append(address) - conf.write() + + +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/dialogs.py b/source/remoteClient/dialogs.py index 4e79a2dddcc..2fb8276bc6e 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -80,7 +80,6 @@ def handle_certificate_failed(self) -> None: if a == wx.ID_YES: config = configuration.get_config() config["trusted_certs"][self.host.GetValue()] = cert_hash - config.write() if a != wx.ID_YES and a != wx.ID_NO: return except Exception as ex: From d8cb51cbc08a84f6653a42a4418ad52c0e2752ea Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 28 Dec 2024 22:42:24 -0700 Subject: [PATCH 015/203] refactor: Remote Client: Ensure consistent feedback for deafblind users Restructure the remote client feedback system to guarantee all users receive appropriate cues. While improving this, also clean up the cues implementation: - Centralize all cues in a typed dictionary to prevent missing any feedback - Move speech messages from client.py into cues.py for better tracking - Add type hints to make feedback handling more maintainable - Consolidate duplicated sound/message logic into _play_cue helper This ensures deafblind users receive consistent feedback about connection status, clipboard operations, and other important events. --- source/remoteClient/client.py | 5 -- source/remoteClient/cues.py | 98 ++++++++++++++++++++++++----------- 2 files changed, 69 insertions(+), 34 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 1b505702ddd..47f1622d053 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -6,7 +6,6 @@ import core import gui import inputCore -import speech import ui import wx from config import isInstalledCopy @@ -124,8 +123,6 @@ def pushClipboard(self): try: connector.send(RemoteMessageType.set_clipboard_text, text=api.getClipData()) cues.clipboard_pushed() - # Translators: Message shown when the clipboard is successfully pushed to the remote computer. - ui.message(_("Clipboard pushed")) except TypeError: log.exception("Unable to push clipboard") @@ -280,8 +277,6 @@ def connectAsSlave(self, connectionInfo: ConnectionInfo): def onConnectedAsSlave(self): log.info("Control connector connected") cues.control_server_connected() - # Translators: Presented in direct (client to server) remote connection when the controlled computer is ready. - speech.speakMessage(_("Connected to control server")) self.menu.handleConnected(ConnectionMode.SLAVE, True) configuration.write_connection_to_config(self.slaveSession.getConnectionInfo()) diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index a289feb9ede..0f8577e58dc 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -1,61 +1,101 @@ import os +from typing import Dict, Optional, Tuple, List, TypedDict import nvwave import tones - +import ui from . import beep_sequence, configuration local_beep = tones.beep local_playWaveFile = nvwave.playWaveFile +# Define types for clarity +BeepTone = Tuple[Optional[int], int] # (frequency, duration) +BeepSequence = List[BeepTone] + + +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] + beep_sequence.beep_sequence_async(*filtered_beeps) + return + + # Play wave file + if wave := CUES[cue_name].get("wave"): + playSound(wave) + + # Show message if specified + if message := CUES[cue_name].get("message"): + ui.message(message) + def connected(): - if should_play_sounds(): - playSound("connected") - else: - beep_sequence.beep_sequence_async((440, 60), (660, 60)) + _play_cue("connected") def disconnected(): - if should_play_sounds(): - playSound("disconnected") - else: - beep_sequence.beep_sequence_async((660, 60), (440, 60)) + _play_cue("disconnected") def control_server_connected(): - if should_play_sounds(): - playSound("controlled") - else: - beep_sequence.beep_sequence_async((720, 100), 50, (720, 100), 50, (720, 100)) + _play_cue("control_server_connected") def client_connected(): - if should_play_sounds(): - playSound("controlling") - else: - local_beep(1000, 300) + _play_cue("client_connected") def client_disconnected(): - if should_play_sounds(): - playSound("disconnected") - else: - local_beep(108, 300) + _play_cue("client_disconnected") def clipboard_pushed(): - if should_play_sounds(): - playSound("push_clipboard") - else: - beep_sequence.beep_sequence_async((500, 100), (600, 100)) + _play_cue("clipboard_pushed") def clipboard_received(): - if should_play_sounds(): - playSound("receive_clipboard") - else: - beep_sequence.beep_sequence_async((600, 100), (500, 100)) + _play_cue("clipboard_received") def should_play_sounds(): From 8f6cf6f43b12a6ef5d2d2404fa3c9548bf7b4adc Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 28 Dec 2024 22:57:53 -0700 Subject: [PATCH 016/203] Naming and typing fixes --- .../{beep_sequence.py => beepSequence.py} | 9 +++++---- source/remoteClient/cues.py | 11 ++++------- 2 files changed, 9 insertions(+), 11 deletions(-) rename source/remoteClient/{beep_sequence.py => beepSequence.py} (74%) diff --git a/source/remoteClient/beep_sequence.py b/source/remoteClient/beepSequence.py similarity index 74% rename from source/remoteClient/beep_sequence.py rename to source/remoteClient/beepSequence.py index 67f7c8b9cbc..91c13280c47 100644 --- a/source/remoteClient/beep_sequence.py +++ b/source/remoteClient/beepSequence.py @@ -8,9 +8,10 @@ local_beep = tones.beep BeepElement = Union[int, Tuple[int, int]] # Either delay_ms or (frequency_hz, duration_ms) +BeepSequence = collections.abc.Iterable[BeepElement] -def beep_sequence(*sequence: BeepElement) -> None: +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 @@ -25,10 +26,10 @@ def beep_sequence(*sequence: BeepElement) -> None: local_beep(tone, duration) -def beep_sequence_async(*sequence: BeepElement) -> threading.Thread: +def beepSequenceAsync(*sequence: BeepElement) -> threading.Thread: """Play an asynchronous beep sequence. - This is the same as beep_sequence, except it runs in a thread.""" - thread = threading.Thread(target=beep_sequence, args=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/cues.py b/source/remoteClient/cues.py index 0f8577e58dc..a7614a86143 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -1,18 +1,15 @@ import os -from typing import Dict, Optional, Tuple, List, TypedDict +from typing import Dict, Optional, TypedDict import nvwave import tones import ui -from . import beep_sequence, configuration +from . import configuration +from .beepSequence import beepSequenceAsync, BeepSequence local_beep = tones.beep local_playWaveFile = nvwave.playWaveFile -# Define types for clarity -BeepTone = Tuple[Optional[int], int] # (frequency, duration) -BeepSequence = List[BeepTone] - class Cue(TypedDict, total=False): wave: Optional[str] @@ -58,7 +55,7 @@ def _play_cue(cue_name: str) -> None: # Play beep sequence if beeps := CUES[cue_name].get("beeps"): filtered_beeps = [(freq, dur) for freq, dur in beeps if freq is not None] - beep_sequence.beep_sequence_async(*filtered_beeps) + beepSequenceAsync(*filtered_beeps) return # Play wave file From 2aaa3ea007b6e15491c5e6ccc2f4816af65fe561 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 29 Dec 2024 10:37:23 -0700 Subject: [PATCH 017/203] Add `pre_speechQueued` to the table of extension points and export it from speech --- projectDocs/dev/developerGuide/developerGuide.md | 1 + source/speech/__init__.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/projectDocs/dev/developerGuide/developerGuide.md b/projectDocs/dev/developerGuide/developerGuide.md index d0b8c24712d..3320472ae7b 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/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 From 1d26a7e9b37fb9f24e779e121dd40da7b2d89531 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 29 Dec 2024 11:36:56 -0700 Subject: [PATCH 018/203] docs: add remote access documentation to user guide Add documentation for NVDA's new built-in remote access feature, including setup instructions, connection options, and keyboard shortcuts. Also add remote access to the list of major highlights. --- user_docs/en/userGuide.md | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index dfc66983465..8d790a2edf8 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 @@ -3583,6 +3584,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. From d7ed00e1233284cf9e59faa10cff859ef093defb Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 29 Dec 2024 13:14:15 -0700 Subject: [PATCH 019/203] feat(remoteClient): make RemoteMenu conditional on secure desktop status Modify the remote client to only initialize and interact with RemoteMenu when not running on the secure desktop. All menu-related calls are now guarded with null checks to handle cases where the menu is not created. The main changes: - Make RemoteMenu initialization conditional on isRunningOnSecureDesktop flag - Add null checks before all menu interactions - Make menu property type explicitly Optional[RemoteMenu] --- source/remoteClient/client.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 47f1622d053..44d39342ab3 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -52,7 +52,9 @@ def __init__( self.localMachine = LocalMachine() self.slaveSession = None self.masterSession = None - self.menu: RemoteMenu = RemoteMenu(self) + 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, @@ -225,13 +227,15 @@ def connectAsMaster(self, connectionInfo: ConnectionInfo): transport.transportDisconnected.register(self.onDisconnectedAsMaster) transport.reconnectorThread.start() self.masterTransport = transport - self.menu.handleConnecting(connectionInfo.mode) + 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()) - self.menu.handleConnected(ConnectionMode.MASTER, True) + if self.menu: + self.menu.handleConnected(ConnectionMode.MASTER, True) ui.message( # Translators: Presented when connected to the remote computer. _("Connected!"), @@ -271,20 +275,23 @@ def connectAsSlave(self, connectionInfo: ConnectionInfo): transport.transportConnected.register(self.onConnectedAsSlave) transport.transportDisconnected.register(self.onDisconnectedAsSlave) transport.reconnectorThread.start() - self.menu.handleConnecting(connectionInfo.mode) + if self.menu: + self.menu.handleConnecting(connectionInfo.mode) @alwaysCallAfter def onConnectedAsSlave(self): log.info("Control connector connected") cues.control_server_connected() - self.menu.handleConnected(ConnectionMode.SLAVE, True) + 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() - self.menu.handleConnected(ConnectionMode.SLAVE, False) + if self.menu: + self.menu.handleConnected(ConnectionMode.SLAVE, False) ### certificate handling From a230d8ac4db1beb3ee6859a5361b108aaabf7ad0 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 29 Dec 2024 13:42:31 -0700 Subject: [PATCH 020/203] feat: block remote connection actions in secure mode and rename for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commit renames the remote connection scripts for better clarity (`script_disconnect` → `script_disconnectFromRemote` and `script_connect` → `script_connectToRemote`) and adds security restrictions to prevent these actions when in secure mode. The connect action is also blocked when a modal dialog is open. --- source/globalCommands.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/source/globalCommands.py b/source/globalCommands.py index 4a11e03e014..46aaa6f6df2 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4927,7 +4927,8 @@ def script_copy_link(self, gesture): # Translators: Documentation string for the script that disconnects a remote session. description=_("""Disconnect a remote session"""), ) - def script_disconnect(self, gesture): + @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) + def script_disconnectFromRemote(self, gesture): if not globalVars.remoteClient.isConnected: # Translators: A message indicating that the remote client is not connected. ui.message(_("Not connected.")) @@ -4940,7 +4941,9 @@ def script_disconnect(self, gesture): description=_("""Connect to a remote computer"""), category=SCRCAT_REMOTE, ) - def script_connect(self, gesture): + @gui.blockAction.when(gui.blockAction.Context.MODAL_DIALOG_OPEN) + @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) + def script_connectToRemote(self, gesture): if globalVars.remoteClient.isConnected() or globalVars.remoteClient.connecting: return globalVars.remoteClient.doConnect() From 14c208f1d1ae565ed59f2d762e4a2ffb8504ecb1 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 29 Dec 2024 14:22:05 -0700 Subject: [PATCH 021/203] Use playWaveFile directly for remote sounds --- source/remoteClient/cues.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index a7614a86143..c7f3c9b898a 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -1,4 +1,3 @@ -import os from typing import Dict, Optional, TypedDict import nvwave @@ -8,7 +7,6 @@ from .beepSequence import beepSequenceAsync, BeepSequence local_beep = tones.beep -local_playWaveFile = nvwave.playWaveFile class Cue(TypedDict, total=False): @@ -60,7 +58,7 @@ def _play_cue(cue_name: str) -> None: # Play wave file if wave := CUES[cue_name].get("wave"): - playSound(wave) + nvwave.playWaveFile(wave) # Show message if specified if message := CUES[cue_name].get("message"): @@ -97,8 +95,3 @@ def clipboard_received(): def should_play_sounds(): return configuration.get_config()["ui"]["play_sounds"] - - -def playSound(filename): - path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "waves", filename)) - return local_playWaveFile(path + ".wav") From 3147cf8899d370bbc8a939fcf0c4067538554a86 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 29 Dec 2024 15:26:49 -0700 Subject: [PATCH 022/203] Fix secure desktop check --- source/remoteClient/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 44d39342ab3..e75f22f9055 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -53,7 +53,7 @@ def __init__( self.slaveSession = None self.masterSession = None self.menu: Optional[RemoteMenu] = None - if not isRunningOnSecureDesktop: + if not isRunningOnSecureDesktop(): self.menu: Optional[RemoteMenu] = RemoteMenu(self) self.connecting = False self.URLHandlerWindow = url_handler.URLHandlerWindow( From 2f474eff2ffc71ce686e8e216154dde93730c9bd Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 29 Dec 2024 15:29:06 -0700 Subject: [PATCH 023/203] Don't write config after receiving MOTD --- source/remoteClient/session.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index ca25a69a9e2..eed46a14489 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -193,7 +193,6 @@ def shouldDisplayMotd(self, motd: str) -> bool: if current == hashed: return False conf["seen_motds"][address] = hashed - conf.write() return True def handleClientConnected(self, client: Optional[Dict[str, Any]] = None) -> None: From 14468e337d89f4cd2f20b07e6b6f5855a96911f2 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 29 Dec 2024 15:51:28 -0700 Subject: [PATCH 024/203] Add .wav suffix to cues --- source/remoteClient/cues.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index c7f3c9b898a..ae3e5a9c8e4 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -58,7 +58,7 @@ def _play_cue(cue_name: str) -> None: # Play wave file if wave := CUES[cue_name].get("wave"): - nvwave.playWaveFile(wave) + nvwave.playWaveFile(wave + ".wav") # Show message if specified if message := CUES[cue_name].get("message"): From c9a0a3803cb80f46ed650af78452f21c56c8ce3d Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 29 Dec 2024 15:59:15 -0700 Subject: [PATCH 025/203] Better handle wave files --- source/remoteClient/cues.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index ae3e5a9c8e4..3fcc47c9a46 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -1,5 +1,7 @@ +import os from typing import Dict, Optional, TypedDict +import globalVars import nvwave import tones import ui @@ -58,7 +60,7 @@ def _play_cue(cue_name: str) -> None: # Play wave file if wave := CUES[cue_name].get("wave"): - nvwave.playWaveFile(wave + ".wav") + nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", wave + ".wav")) # Show message if specified if message := CUES[cue_name].get("message"): From 154172781112ea6733b3f9242e4d977e2f3d3efd Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 30 Dec 2024 20:32:37 -0700 Subject: [PATCH 026/203] Added Remote Certificate Manager to self-sign certificates for the built-in relay server --- source/remoteClient/server.py | 234 ++++++++++++++++++++++++++-------- 1 file changed, 179 insertions(+), 55 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index ee0a2abf431..1bb1dbe8223 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -9,6 +9,7 @@ - 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 @@ -16,14 +17,6 @@ When clients disconnect or lose connection, the server automatically removes them and notifies other connected clients of the departure. - -Key Classes: - LocalRelayServer: The main relay server that accepts connections and routes messages - Client: Represents a connected remote client and handles its message processing - -Example: - server = LocalRelayServer(port=6837, password="secret") - server.run() """ import logging @@ -31,16 +24,154 @@ import socket import ssl import time +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 logger = logging.getLogger(__name__) +class RemoteCertificateManager: + """Manages SSL certificates for the NVDA Remote relay server.""" + + CERT_FILE = "NvdaRemoteRelay.pem" + KEY_FILE = "NvdaRemoteRelay.key" + 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 + + def ensureValidCertExists(self) -> None: + """Ensures a valid certificate and key exist, regenerating if needed.""" + 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: + logging.warning(f"Certificate validation failed: {e}") + 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()) + ) + + # 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)) + + logging.info("Generated new self-signed certificate for NVDA Remote") + + def createSSLContext(self) -> ssl.SSLContext: + """Creates an SSL context using the certificate and key.""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain( + certfile=str(self.cert_path), + keyfile=str(self.key_path), + ) + return context + + class LocalRelayServer: """Secure relay server for NVDA Remote connections. @@ -55,23 +186,28 @@ class LocalRelayServer: """ PING_TIME: int = 300 - _running: bool = False - port: int - password: str - clients: Dict[socket.socket, "Client"] - clientSockets: List[socket.socket] - serverSocket: ssl.SSLSocket - serverSocket6: ssl.SSLSocket - lastPingTime: float - - def __init__(self, port: int, password: str, bind_host: str = "", bind_host6: str = "[::]:"): + + 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() - # Maps client sockets to clients - self.clients = {} - self.clientSockets = [] + 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, @@ -84,19 +220,16 @@ def __init__(self, port: int, password: str, bind_host: str = "", bind_host6: st ) 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) - certfile = os.path.join( - os.path.abspath( - os.path.dirname(__file__), - ), - "server.pem", - ) - serverSocket = ssl.wrap_socket(serverSocket, certfile=certfile) + ssl_context = self.cert_manager.createSSLContext() + serverSocket = ssl_context.wrap_socket(serverSocket) serverSocket.bind(bind_addr) serverSocket.listen(5) return serverSocket def run(self) -> None: + """Main server loop that handles client connections and message routing.""" self._running = True self.lastPingTime = time.time() while self._running: @@ -120,6 +253,7 @@ def run(self) -> None: self.lastPingTime = time.time() def acceptNewConnection(self, sock: ssl.SSLSocket) -> None: + """Accept and set up a new client connection.""" try: clientSock, addr = sock.accept() except (ssl.SSLError, socket.error, OSError): @@ -130,14 +264,17 @@ def acceptNewConnection(self, sock: ssl.SSLSocket) -> None: 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.""" self.removeClient(client) if client.authenticated: client.send_to_others( @@ -147,6 +284,7 @@ def clientDisconnected(self, client: "Client") -> None: ) def close(self) -> None: + """Shut down the server and close all connections.""" self._running = False self.serverSocket.close() self.serverSocket6.close() @@ -158,21 +296,10 @@ class 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. Invalid or unparseable messages will cause client disconnection. - - Unauthenticated clients can only send join and protocol_version messages. - The join message must include the correct channel password in its 'channel' field. - Once authenticated, all valid messages are forwarded to other connected clients. - When this client disconnects, all other clients are notified via client_left message. + by newlines. """ id: int = 0 - server: LocalRelayServer - socket: ssl.SSLSocket - buffer: bytes - authenticated: bool - connectionType: Optional[str] - protocolVersion: int def __init__(self, server: LocalRelayServer, socket: ssl.SSLSocket): self.server = server @@ -186,27 +313,17 @@ def __init__(self, server: LocalRelayServer, socket: ssl.SSLSocket): Client.id += 1 def handleData(self) -> None: - sock_Data: bytes = b"" + """Process incoming data from the client socket.""" + sock_data = b"" try: - # 16384 is 2^14 self.socket is a ssl wrapped socket. - # Perhaps this value was chosen as the largest value that could be received [1] to avoid having to loop - # until a new line is reached. - # However, the Python docs [2] say: - # "For best match with hardware and network realities, the value of bufsize should be a relatively - # small power of 2, for example, 4096." - # This should probably be changed in the future. - # See also transport.py handle_server_data in class TCPTransport. - # [1] https://stackoverflow.com/a/24870153/ - # [2] https://docs.python.org/3.7/library/socket.html#socket.socket.recv - buffSize = 16384 - sock_Data = self.socket.recv(buffSize) + sock_data = self.socket.recv(16384) except Exception: self.close() return - if not sock_Data: # Disconnect + if not sock_data: # Disconnect self.close() return - data = self.buffer + sock_Data + data = self.buffer + sock_data if b"\n" not in data: self.buffer = data return @@ -222,6 +339,7 @@ def handleData(self) -> None: 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 @@ -233,9 +351,11 @@ def parse(self, line: bytes) -> None: 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: self.send( @@ -266,12 +386,14 @@ def do_join(self, obj: Dict[str, Any]) -> None: ) 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) @@ -283,6 +405,7 @@ def send( client: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> None: + """Send a message to this client.""" msg = kwargs if self.protocolVersion > 1: if origin: @@ -298,6 +421,7 @@ def send( 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(): From 0a62d5de56f71aaaca47264e50f63bddcc800873 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 31 Dec 2024 11:41:15 -0700 Subject: [PATCH 027/203] Add cryptography dependency to requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) 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 From 3cc3c42861a414181f507935baa112789913c5f4 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 31 Dec 2024 12:51:25 -0700 Subject: [PATCH 028/203] Hidden import of CFFI to satisfy py2exe per pyca/cryptography#5122 --- source/remoteClient/server.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 1bb1dbe8223..7adc8a8f5f5 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -24,6 +24,7 @@ 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 @@ -108,7 +109,7 @@ def _generateSelfSignedCert(self) -> None: [ x509.NameAttribute(NameOID.COMMON_NAME, "NVDARemote Relay"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, "NVDARemote"), - ] + ], ) cert = ( @@ -139,7 +140,7 @@ def _generateSelfSignedCert(self) -> None: x509.SubjectAlternativeName( [ x509.DNSName("localhost"), - ] + ], ), critical=False, ) From d0ee2bb0dacbb179a5cf54526a0839328a57edfd Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 2 Jan 2025 21:30:41 -0700 Subject: [PATCH 029/203] security: Improve certificate handling for self-hosted remote connections - Add fingerprint tracking for self-signed certificates - Auto-trust localhost certificates for self-hosted servers - Add insecure connection flag for local connections - Modify SSL context configuration for self-signed certificates - Add warning log for insecure connections - Exclude tomli package to prevent infinite loop in Python 3.11 This change improves the security model for self-hosted remote connections while maintaining compatibility with existing setups. Self-hosted servers now automatically trust their own certificates while still allowing secure remote connections. --- source/remoteClient/client.py | 4 +++- source/remoteClient/server.py | 32 ++++++++++++++++++++++++++++++++ source/remoteClient/transport.py | 2 ++ source/setup.py | 2 ++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index e75f22f9055..9d269e7f619 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -81,15 +81,17 @@ def performAutoconnect(self): 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) + conInfo = ConnectionInfo(mode=mode, hostname=hostname, port=port, key=key, insecure=insecure) self.connect(conInfo) def terminate(self): diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 7adc8a8f5f5..0f1a13254e5 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -39,6 +39,7 @@ from .protocol import RemoteMessageType from .serializer import JSONSerializer from .secureDesktop import getProgramDataTempPath +from . import configuration logger = logging.getLogger(__name__) @@ -48,6 +49,7 @@ class RemoteCertificateManager: CERT_FILE = "NvdaRemoteRelay.pem" KEY_FILE = "NvdaRemoteRelay.key" + FINGERPRINT_FILE = "NvdaRemoteRelay.fingerprint" CERT_DURATION_DAYS = 365 CERT_RENEWAL_THRESHOLD_DAYS = 30 @@ -55,6 +57,7 @@ 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.""" @@ -147,6 +150,8 @@ def _generateSelfSignedCert(self) -> None: .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( @@ -161,15 +166,42 @@ def _generateSelfSignedCert(self) -> None: 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 + logging.info("Generated new self-signed certificate for NVDA Remote") + 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: + logging.warning(f"Error reading fingerprint: {e}") + 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 diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 32326dba5ff..f6a2147e370 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -404,6 +404,8 @@ def createOutboundSocket( ctx.check_hostname = not insecure ctx.load_default_certs() serverSock = ctx.wrap_socket(sock=serverSock, server_hostname=host) + if insecure: + log.warn("Skipping certificate verification for %s:%d", host, port) return serverSock def getpeercert( 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", From aa2f6e5406d7ec6ac266c2e591b16e60ee7f7d5a Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 3 Jan 2025 13:30:40 -0700 Subject: [PATCH 030/203] fix: Update SSL socket configuration and connection settings --- source/remoteClient/secureDesktop.py | 2 +- source/remoteClient/server.py | 2 +- source/remoteClient/transport.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index e9f1de2e0d7..3faa4d1be46 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -191,7 +191,7 @@ def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: testSocket.close() return ConnectionInfo( - hostname="127.0.0.1", + hostname="localhost", mode=ConnectionMode.SLAVE, key=channel, port=port, diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 0f1a13254e5..a8f309689b0 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -256,7 +256,7 @@ def createServerSocket(self, family: int, type: int, bind_addr: Tuple[str, int]) """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) + serverSocket = ssl_context.wrap_socket(serverSocket, server_side=True) serverSocket.bind(bind_addr) serverSocket.listen(5) return serverSocket diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index f6a2147e370..bf111098334 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -403,9 +403,9 @@ def createOutboundSocket( ctx.verify_mode = ssl.CERT_NONE ctx.check_hostname = not insecure ctx.load_default_certs() - serverSock = ctx.wrap_socket(sock=serverSock, server_hostname=host) if insecure: log.warn("Skipping certificate verification for %s:%d", host, port) + serverSock = ctx.wrap_socket(sock=serverSock, server_hostname=host) return serverSock def getpeercert( From ed1fba9ac2154814f321dbb6bc38704832c0fbd1 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 3 Jan 2025 18:00:02 -0700 Subject: [PATCH 031/203] fix: Change localhost binding from IP to hostname in secure desktop relay --- source/remoteClient/secureDesktop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 3faa4d1be46..22d12345a3c 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -124,7 +124,7 @@ def enterSecureDesktop(self) -> None: serverThread.start() self.sdRelay = RelayTransport( - address=("127.0.0.1", port), + address=("localhost", port), serializer=JSONSerializer(), channel=channel, insecure=True, From a1cc380335a6b15abab3b1b87241f16334b83903 Mon Sep 17 00:00:00 2001 From: "Christopher Toth (aider)" Date: Fri, 3 Jan 2025 18:00:03 -0700 Subject: [PATCH 032/203] feat: Add comprehensive logging to SecureDesktopHandler module --- source/remoteClient/secureDesktop.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 22d12345a3c..3661733a52a 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -55,6 +55,7 @@ def __init__(self, temp_path: Path = getProgramDataTempPath()) -> None: """ 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 @@ -65,12 +66,15 @@ def __init__(self, temp_path: Path = getProgramDataTempPath()) -> None: 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: - pass + log.debug("IPC file already removed") + log.info("Secure desktop cleanup completed") @property def slaveSession(self) -> Optional[SlaveSession]: @@ -85,8 +89,10 @@ def slaveSession(self, session: Optional[SlaveSession]) -> None: 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() @@ -102,6 +108,7 @@ def _onSecureDesktopChange(self, isSecureDesktop: Optional[bool] = None) -> None 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: @@ -109,15 +116,19 @@ def _onSecureDesktopChange(self, isSecureDesktop: Optional[bool] = None) -> None 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 @@ -142,11 +153,15 @@ def enterSecureDesktop(self) -> None: 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: @@ -180,7 +195,9 @@ def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: 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 @@ -190,6 +207,7 @@ def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: testSocket.connect(("127.0.0.1", port)) testSocket.close() + log.info(f"Successfully established secure desktop connection on port {port}") return ConnectionInfo( hostname="localhost", mode=ConnectionMode.SLAVE, @@ -204,7 +222,9 @@ def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: 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, From 94e12e4803542dd9697e3a6d8c237e12c74db8a3 Mon Sep 17 00:00:00 2001 From: "Christopher Toth (aider)" Date: Fri, 3 Jan 2025 18:01:41 -0700 Subject: [PATCH 033/203] feat: Add comprehensive logging to server module for improved monitoring and debugging --- source/remoteClient/server.py | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index a8f309689b0..6cf245cd06e 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -61,6 +61,7 @@ def __init__(self, cert_dir: Optional[Path] = None): def ensureValidCertExists(self) -> None: """Ensures a valid certificate and key exist, regenerating if needed.""" + logger.info("Checking certificate validity") os.makedirs(self.cert_dir, exist_ok=True) should_generate = False @@ -70,7 +71,7 @@ def ensureValidCertExists(self) -> None: try: self._validateCertificate() except Exception as e: - logging.warning(f"Certificate validation failed: {e}") + logger.warning(f"Certificate validation failed: {e}", exc_info=True) should_generate = True if should_generate: @@ -177,7 +178,10 @@ def _generateSelfSignedCert(self) -> None: config["trusted_certs"]["localhost"] = fingerprint config["trusted_certs"]["127.0.0.1"] = fingerprint - logging.info("Generated new self-signed certificate for NVDA Remote") + logger.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.""" @@ -186,7 +190,7 @@ def get_current_fingerprint(self) -> Optional[str]: with open(self.fingerprint_path, "r") as f: return f.read().strip() except Exception as e: - logging.warning(f"Error reading fingerprint: {e}") + logger.warning(f"Error reading fingerprint: {e}", exc_info=True) return None def createSSLContext(self) -> ssl.SSLContext: @@ -263,6 +267,10 @@ def createServerSocket(self, family: int, type: int, bind_addr: Tuple[str, int]) def run(self) -> None: """Main server loop that handles client connections and message routing.""" + logger.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: @@ -289,8 +297,9 @@ def acceptNewConnection(self, sock: ssl.SSLSocket) -> None: """Accept and set up a new client connection.""" try: clientSock, addr = sock.accept() + logger.info(f"New client connection from {addr}") except (ssl.SSLError, socket.error, OSError): - logger.exception("Error accepting connection") + logger.error("Error accepting connection", exc_info=True) return clientSock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) client = Client(server=self, socket=clientSock) @@ -308,6 +317,7 @@ def removeClient(self, client: "Client") -> None: def clientDisconnected(self, client: "Client") -> None: """Handle client disconnection and notify other clients.""" + logger.info(f"Client {client.id} disconnected") self.removeClient(client) if client.authenticated: client.send_to_others( @@ -318,9 +328,11 @@ def clientDisconnected(self, client: "Client") -> None: def close(self) -> None: """Shut down the server and close all connections.""" + logger.info("Shutting down NVDA Remote relay server") self._running = False self.serverSocket.close() self.serverSocket6.close() + logger.info("Server shutdown complete") class Client: @@ -366,7 +378,7 @@ def handleData(self) -> None: try: self.parse(line) except ValueError: - logger.exception("Error parsing line") + logger.error(f"Error parsing message from client {self.id}", exc_info=True) self.close() return self.buffer += data @@ -391,6 +403,7 @@ 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: + logger.warning(f"Failed authentication attempt from client {self.id}") self.send( type=RemoteMessageType.error, message="incorrect_password", @@ -399,6 +412,10 @@ def do_join(self, obj: Dict[str, Any]) -> None: return self.connectionType = obj.get("connection_type") self.authenticated = True + logger.info( + f"Client {self.id} authenticated successfully " + f"(connection type: {self.connectionType})" + ) clients = [] client_ids = [] for c in list(self.server.clients.values()): @@ -451,6 +468,7 @@ def send( data = self.serializer.serialize(type=type, **msg) self.socket.sendall(data) except Exception: + logger.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: From f40eade046d63472cfc3ed97d1fefebc49818450 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 3 Jan 2025 20:28:34 -0700 Subject: [PATCH 034/203] refactor: Replace localhost with 127.0.0.1 and remove SSL socket wrapping --- source/remoteClient/secureDesktop.py | 7 ++----- source/remoteClient/transport.py | 3 +++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 3661733a52a..a78a9cb509c 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -12,7 +12,6 @@ import json import socket -import ssl import threading import uuid from pathlib import Path @@ -135,7 +134,7 @@ def enterSecureDesktop(self) -> None: serverThread.start() self.sdRelay = RelayTransport( - address=("localhost", port), + address=("127.0.0.1", port), serializer=JSONSerializer(), channel=channel, insecure=True, @@ -203,13 +202,11 @@ def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: port, channel = data testSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - testSocket = ssl.wrap_socket(testSocket, ssl_version=ssl.PROTOCOL_TLS) - testSocket.connect(("127.0.0.1", port)) testSocket.close() log.info(f"Successfully established secure desktop connection on port {port}") return ConnectionInfo( - hostname="localhost", + hostname="127.0.0.1", mode=ConnectionMode.SLAVE, key=channel, port=port, diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index bf111098334..946211a3074 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -204,7 +204,9 @@ def registerInbound(self, type: RemoteMessageType, handler: Callable) -> None: 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: @@ -218,6 +220,7 @@ def unregisterInbound(self, type: RemoteMessageType, handler: Callable) -> None: handler (Callable): The handler function to remove """ self.inboundHandlers[type].unregister(handler) + log.debug("Unregistered handler for %s", type) def registerOutbound( self, From b2ee2d26a7535ce273e1d52dca264d4144e25a46 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 3 Jan 2025 20:35:23 -0700 Subject: [PATCH 035/203] refactor: Simplify logging and formatting in server and secureDesktop modules --- source/remoteClient/secureDesktop.py | 1 + source/remoteClient/server.py | 11 +++-------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index a78a9cb509c..fbbd8e55186 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -138,6 +138,7 @@ def enterSecureDesktop(self) -> None: serializer=JSONSerializer(), channel=channel, insecure=True, + connectionType=ConnectionMode.MASTER, ) self.sdRelay.registerInbound(RemoteMessageType.client_joined, self._onMasterDisplayChange) self.slaveSession.transport.registerInbound( diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 6cf245cd06e..df61b5d55a5 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -178,10 +178,7 @@ def _generateSelfSignedCert(self) -> None: config["trusted_certs"]["localhost"] = fingerprint config["trusted_certs"]["127.0.0.1"] = fingerprint - logger.info( - "Generated new self-signed certificate for NVDA Remote. " - f"Fingerprint: {fingerprint}" - ) + logger.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.""" @@ -268,8 +265,7 @@ def createServerSocket(self, family: int, type: int, bind_addr: Tuple[str, int]) def run(self) -> None: """Main server loop that handles client connections and message routing.""" logger.info( - f"Starting NVDA Remote relay server on ports {self.port} (IPv4) " - f"and {self.port} (IPv6)" + f"Starting NVDA Remote relay server on ports {self.port} (IPv4) " f"and {self.port} (IPv6)" ) self._running = True self.lastPingTime = time.time() @@ -413,8 +409,7 @@ def do_join(self, obj: Dict[str, Any]) -> None: self.connectionType = obj.get("connection_type") self.authenticated = True logger.info( - f"Client {self.id} authenticated successfully " - f"(connection type: {self.connectionType})" + f"Client {self.id} authenticated successfully " f"(connection type: {self.connectionType})" ) clients = [] client_ids = [] From f620309e6c0fcd6a4ac4430f6c388881cfaabd68 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 3 Jan 2025 20:42:54 -0700 Subject: [PATCH 036/203] Use the Enum's value --- source/remoteClient/secureDesktop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index fbbd8e55186..6ba39799c62 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -138,7 +138,7 @@ def enterSecureDesktop(self) -> None: serializer=JSONSerializer(), channel=channel, insecure=True, - connectionType=ConnectionMode.MASTER, + connectionType=ConnectionMode.MASTER.value, ) self.sdRelay.registerInbound(RemoteMessageType.client_joined, self._onMasterDisplayChange) self.slaveSession.transport.registerInbound( From 75190d5fcafac58c9dd320b199502ea90da4f582 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 3 Jan 2025 20:57:18 -0700 Subject: [PATCH 037/203] Remove old static certificate --- source/remoteClient/server.pem | 84 ---------------------------------- 1 file changed, 84 deletions(-) delete mode 100644 source/remoteClient/server.pem diff --git a/source/remoteClient/server.pem b/source/remoteClient/server.pem deleted file mode 100644 index 692f651c393..00000000000 --- a/source/remoteClient/server.pem +++ /dev/null @@ -1,84 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -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 ------END RSA PRIVATE KEY----- ------BEGIN CERTIFICATE----- -MIIFrzCCA5egAwIBAgIJAM10H3WD3490MA0GCSqGSIb3DQEBBQUAMG4xCzAJBgNV -BAYTAlVTMREwDwYDVQQIDAhDb2xvcmFkbzESMBAGA1UEBwwJTGl0dGxldG9uMRsw -GQYDVQQKDBJOVkRBIFJlbW90ZSBBY2Nlc3MxGzAZBgNVBAMMEk5WREEgUmVtb3Rl -IEFjY2VzczAeFw0xNDExMjUyMTM5MTJaFw0yNDExMjIyMTM5MTJaMG4xCzAJBgNV -BAYTAlVTMREwDwYDVQQIDAhDb2xvcmFkbzESMBAGA1UEBwwJTGl0dGxldG9uMRsw -GQYDVQQKDBJOVkRBIFJlbW90ZSBBY2Nlc3MxGzAZBgNVBAMMEk5WREEgUmVtb3Rl -IEFjY2VzczCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANPAqnE+vtgS -Bv4cy+1OwrXMD6PtLXDTrzKt9U0xdGV8QF8QgOWJrvBLYS4MYiYG/Gv0x5GybMoD -1vKmx22kPlxe3XicvzpZwuAOKJ1Eic6ruI/DsT/7UjEfmUDSUKwV9ufaPi3R/PpL -RU4ctfWCC0Oo/gxgyc1/tXvIz46tUeDXGq4iMv+xyS8U+VmWBQM9x+2xyM0CYF+o -uC009QQCkgWpcJlKEhaw7qEqcYtsRowNV0+ZqgtsZ1i9lPcmbqIm2cRc33EhfOam -NBEW2bMohpc8FW5j9h0Xv9xwv1doI/mPg41jP5+6ZQ9pP8dtZ6f9fYsAIzLKVYJK -sfYMQrLCINVnvCfMVme/XJv8Rsto6S+EK8qpBMqKjjq4NryumTy3j8O75WjwUV1a -nGm3nAOpQ0uSbeD4KKOOcH6SCcqc6RnrrKQTnmcRdGCCEACryTCM/oKahsxBjNW2 -+rFoLIsO0lpZrxMRm72uHwevtqthyRD0/06iXgWY44CQqaIEijoUW5rQaFZ1UvmC -2+TNgDL19ooZuCU+VeKOmplMpr/FlJY1YNNLGCPz5Up08Woo/CaAW+wv7iBNH9Dm -NiGtJwth0G8GdJTi8VDtQyiTDgXJhcq8KdxHOUPzgRCm2iprbdTbGg+ANP9mWWvH -cbJNriT5O2vpPi9bp34cSmkM5BRdmdQLAgMBAAGjUDBOMB0GA1UdDgQWBBTGQYxO -U5m4jzifDpt04NkSJfUXPDAfBgNVHSMEGDAWgBTGQYxOU5m4jzifDpt04NkSJfUX -PDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQB/giqhMXP1hGWYs2cW -gs/8gsbKrHwl3D7oRb3hsQqV0dUUH6z7FPAVc3LdjGSnpVlDPN3M4WNlv6kgZNCB -XFtwL7dNjQaCijP+PAemtyY557yGIj2cU3IKPWwKViaVb3jO8JhJG2zsVjMJT0po -H3T5CkIeIb58S3gt1r968WLWtWhLn+miOWq2K1FeMk5bQgNS6MIwXqQZlwVnPac3 -uX8hFjnt3QqyiCEejKLUDwkkfNz8KDE7dlqhDlQeUS0ILRAc79tmoJl8UsKcWqON -V0OqrBoMjtvQO4oNezoZRjNwmyWVXwKMsgVCHZNmnw91OGZ/6jGLWgASSD2EtuuR -K/1nkeqLSMkpYYURidECRW3CJqwD8u3TJlD5rQsV51dCdSqljO5dexh7ERnmmalh -cOaM/qxqZYzvS1+6jtDDFzuiC/wqAPWnL0SWYNE9AeTLG1BicQVhGRMhIdOUPE7d -VI8ZhRRrlgt7oWgvSC68x5b7z5yCX0MkXpooESoiB7FrCMkpnT1kl1k27DDY6umu -eESZo6mT4Gi3KEaiusTk1hHA0lK70uGtYzoEN343uMO2Gk941wX9iaZEAi77LV23 -8CVTpk/uAQXRXVSA6lrJ/RT+BuuFrl8dzk6jSaccWvD+Z9UnP0iGE2H6q82Y7KZc -rLW6fuzwKmvZuVcOFDXigHXJwQ== ------END CERTIFICATE----- From 9f6fbadc4dd08899460fb788190b58175775835f Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 4 Jan 2025 00:49:46 -0700 Subject: [PATCH 038/203] Ch-ch-ch-ch-changes --- user_docs/en/changes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index fe7c3b241df..d633ed6792e 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -15,6 +15,7 @@ Currently this is only supported in Foxit Reader & Foxit Editor. (#9288, @NSoiff * The ability to adjust the volume of other applications besides NVDA has been added. To use this feature, "allow NVDA to control the volume of other applications" must be enabled in the audio settings panel. (#16052, #17634, @mltony, @codeofdusk) * Added new unassigned gestures to increase, decrease and mute the volume of all other applications. +* 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, #xxxxx, @ctoth, @tspivey, @daiverd, NVDA Remote Contributors and funders) * When editing in Microsoft PowerPoint text boxes, you can now move per sentence with `alt+upArrow`/`alt+downArrow`. (#17015, @LeonarddeR) * In Mozilla Firefox, NVDA will report the highlighted text when a URL containing a text fragment is visited. (#16910, @jcsteh) * NVDA can now report when a link destination points to the current page. (#141, @LeonarddeR, @nvdaes) From ec5f2d304f80d3482c3366c31a131bc4a9dae11f Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 4 Jan 2025 10:55:12 -0700 Subject: [PATCH 039/203] Braille secure desktop fix --- source/remoteClient/secureDesktop.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 6ba39799c62..07ab525968d 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -99,6 +99,10 @@ def slaveSession(self, session: Optional[SlaveSession]) -> None: transport = self._slaveSession.transport transport.unregisterInbound(RemoteMessageType.set_braille_info, self._onMasterDisplayChange) self._slaveSession = session + self.slaveSession.transport.registerInbound( + RemoteMessageType.set_braille_info, + self._onMasterDisplayChange, + ) def _onSecureDesktopChange(self, isSecureDesktop: Optional[bool] = None) -> None: """ From 1c1511ec6d98d53600cd1bc93619fd976bbdf18d Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 4 Jan 2025 17:06:12 -0700 Subject: [PATCH 040/203] Better logging --- source/remoteClient/secureDesktop.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 07ab525968d..2fa2a74bf37 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -99,7 +99,7 @@ def slaveSession(self, session: Optional[SlaveSession]) -> None: transport = self._slaveSession.transport transport.unregisterInbound(RemoteMessageType.set_braille_info, self._onMasterDisplayChange) self._slaveSession = session - self.slaveSession.transport.registerInbound( + session.transport.registerInbound( RemoteMessageType.set_braille_info, self._onMasterDisplayChange, ) @@ -231,3 +231,5 @@ def _onMasterDisplayChange(self, **kwargs: Any) -> None: type=RemoteMessageType.set_display_size, sizes=self.slaveSession.masterDisplaySizes, ) + else: + log.warning("No secure desktop relay or slave session available, skipping display change") From 536f25061447d9244ab76e174f3397cb4d4c9b31 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 4 Jan 2025 18:49:51 -0700 Subject: [PATCH 041/203] Use enum --- source/remoteClient/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index eed46a14489..7c4d0406274 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -560,7 +560,7 @@ def sendBrailleInfo( displaySize if displaySize else 0, ) self.transport.send( - type="set_braille_info", + type=RemoteMessageType.set_braille_info, name=display.name, numCells=displaySize, ) From f89daf44130a093b7843c778559bdd85f3ad9704 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 4 Jan 2025 20:10:40 -0700 Subject: [PATCH 042/203] Fix logging in server module --- source/remoteClient/server.py | 36 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index df61b5d55a5..19cc4cff265 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -19,7 +19,7 @@ notifies other connected clients of the departure. """ -import logging +from logHandler import log import os import socket import ssl @@ -41,8 +41,6 @@ from .secureDesktop import getProgramDataTempPath from . import configuration -logger = logging.getLogger(__name__) - class RemoteCertificateManager: """Manages SSL certificates for the NVDA Remote relay server.""" @@ -61,7 +59,7 @@ def __init__(self, cert_dir: Optional[Path] = None): def ensureValidCertExists(self) -> None: """Ensures a valid certificate and key exist, regenerating if needed.""" - logger.info("Checking certificate validity") + log.info("Checking certificate validity") os.makedirs(self.cert_dir, exist_ok=True) should_generate = False @@ -71,7 +69,7 @@ def ensureValidCertExists(self) -> None: try: self._validateCertificate() except Exception as e: - logger.warning(f"Certificate validation failed: {e}", exc_info=True) + log.warning(f"Certificate validation failed: {e}", exc_info=True) should_generate = True if should_generate: @@ -178,7 +176,7 @@ def _generateSelfSignedCert(self) -> None: config["trusted_certs"]["localhost"] = fingerprint config["trusted_certs"]["127.0.0.1"] = fingerprint - logger.info("Generated new self-signed certificate for NVDA Remote. " f"Fingerprint: {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.""" @@ -187,7 +185,7 @@ def get_current_fingerprint(self) -> Optional[str]: with open(self.fingerprint_path, "r") as f: return f.read().strip() except Exception as e: - logger.warning(f"Error reading fingerprint: {e}", exc_info=True) + log.warning(f"Error reading fingerprint: {e}", exc_info=True) return None def createSSLContext(self) -> ssl.SSLContext: @@ -264,9 +262,7 @@ def createServerSocket(self, family: int, type: int, bind_addr: Tuple[str, int]) def run(self) -> None: """Main server loop that handles client connections and message routing.""" - logger.info( - f"Starting NVDA Remote relay server on ports {self.port} (IPv4) " f"and {self.port} (IPv6)" - ) + 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: @@ -293,9 +289,9 @@ def acceptNewConnection(self, sock: ssl.SSLSocket) -> None: """Accept and set up a new client connection.""" try: clientSock, addr = sock.accept() - logger.info(f"New client connection from {addr}") + log.info(f"New client connection from {addr}") except (ssl.SSLError, socket.error, OSError): - logger.error("Error accepting connection", exc_info=True) + log.error("Error accepting connection", exc_info=True) return clientSock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) client = Client(server=self, socket=clientSock) @@ -313,7 +309,7 @@ def removeClient(self, client: "Client") -> None: def clientDisconnected(self, client: "Client") -> None: """Handle client disconnection and notify other clients.""" - logger.info(f"Client {client.id} disconnected") + log.info(f"Client {client.id} disconnected") self.removeClient(client) if client.authenticated: client.send_to_others( @@ -324,11 +320,11 @@ def clientDisconnected(self, client: "Client") -> None: def close(self) -> None: """Shut down the server and close all connections.""" - logger.info("Shutting down NVDA Remote relay server") + log.info("Shutting down NVDA Remote relay server") self._running = False self.serverSocket.close() self.serverSocket6.close() - logger.info("Server shutdown complete") + log.info("Server shutdown complete") class Client: @@ -374,7 +370,7 @@ def handleData(self) -> None: try: self.parse(line) except ValueError: - logger.error(f"Error parsing message from client {self.id}", exc_info=True) + log.error(f"Error parsing message from client {self.id}", exc_info=True) self.close() return self.buffer += data @@ -399,7 +395,7 @@ 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: - logger.warning(f"Failed authentication attempt from client {self.id}") + log.warning(f"Failed authentication attempt from client {self.id}") self.send( type=RemoteMessageType.error, message="incorrect_password", @@ -408,9 +404,7 @@ def do_join(self, obj: Dict[str, Any]) -> None: return self.connectionType = obj.get("connection_type") self.authenticated = True - logger.info( - f"Client {self.id} authenticated successfully " f"(connection type: {self.connectionType})" - ) + log.info(f"Client {self.id} authenticated successfully " f"(connection type: {self.connectionType})") clients = [] client_ids = [] for c in list(self.server.clients.values()): @@ -463,7 +457,7 @@ def send( data = self.serializer.serialize(type=type, **msg) self.socket.sendall(data) except Exception: - logger.error(f"Error sending message to client {self.id}", exc_info=True) + 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: From 1ca2b3429f2e49a4ca004ec5d5408f4229803e1c Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 4 Jan 2025 22:05:05 -0700 Subject: [PATCH 043/203] We don't need to register these Braille callbacks for anything but the Master --- source/remoteClient/session.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 7c4d0406274..1e9e4cb0f5b 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -306,8 +306,6 @@ def registerCallbacks(self) -> None: self.transport.registerOutbound(decide_playWaveFile, RemoteMessageType.wave) self.transport.registerOutbound(post_speechPaused, RemoteMessageType.pause_speech) braille.pre_writeCells.register(self.display) - braille.displayChanged.register(self.setDisplaySize) - braille.displaySizeChanged.register(self.setDisplaySize) pre_speechQueued.register(self.sendSpeech) self.callbacksAdded = True @@ -319,8 +317,6 @@ def unregisterCallbacks(self) -> None: self.transport.unregisterOutbound(RemoteMessageType.wave) self.transport.unregisterOutbound(RemoteMessageType.pause_speech) braille.pre_writeCells.unregister(self.display) - braille.displayChanged.unregister(self.setDisplaySize) - braille.displaySizeChanged.unregister(self.setDisplaySize) pre_speechQueued.unregister(self.sendSpeech) self.callbacksAdded = False From f04591bf60ceb1bef2cedefc765d12789cd5ad33 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 5 Jan 2025 13:33:36 -0700 Subject: [PATCH 044/203] fix: Enforce TLS 1.2 minimum version for secure socket connections --- source/remoteClient/transport.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 946211a3074..ff38d969a0b 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -406,6 +406,7 @@ def createOutboundSocket( ctx.verify_mode = ssl.CERT_NONE ctx.check_hostname = not insecure ctx.load_default_certs() + ctx.minimum_version = ssl.PROTOCOL_TLSv1_2 if insecure: log.warn("Skipping certificate verification for %s:%d", host, port) serverSock = ctx.wrap_socket(sock=serverSock, server_hostname=host) From 75898386660c3b256ba014ab4837fe861baa6e87 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 5 Jan 2025 13:38:00 -0700 Subject: [PATCH 045/203] Include reference to PR in changelog. --- user_docs/en/changes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index d633ed6792e..b501ee5399d 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -15,7 +15,7 @@ Currently this is only supported in Foxit Reader & Foxit Editor. (#9288, @NSoiff * The ability to adjust the volume of other applications besides NVDA has been added. To use this feature, "allow NVDA to control the volume of other applications" must be enabled in the audio settings panel. (#16052, #17634, @mltony, @codeofdusk) * Added new unassigned gestures to increase, decrease and mute the volume of all other applications. -* 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, #xxxxx, @ctoth, @tspivey, @daiverd, NVDA Remote Contributors and funders) +* 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) * When editing in Microsoft PowerPoint text boxes, you can now move per sentence with `alt+upArrow`/`alt+downArrow`. (#17015, @LeonarddeR) * In Mozilla Firefox, NVDA will report the highlighted text when a URL containing a text fragment is visited. (#16910, @jcsteh) * NVDA can now report when a link destination points to the current page. (#141, @LeonarddeR, @nvdaes) From 78bd45382dd4510df9e689234eff9e046e2c6e9a Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 5 Jan 2025 15:19:25 -0700 Subject: [PATCH 046/203] fix: Update SSL context configuration for TLS 1.2 protocol --- source/remoteClient/transport.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index ff38d969a0b..7f2c1772b4f 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -401,12 +401,12 @@ def createOutboundSocket( 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() + ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) if insecure: ctx.verify_mode = ssl.CERT_NONE ctx.check_hostname = not insecure ctx.load_default_certs() - ctx.minimum_version = ssl.PROTOCOL_TLSv1_2 + if insecure: log.warn("Skipping certificate verification for %s:%d", host, port) serverSock = ctx.wrap_socket(sock=serverSock, server_hostname=host) From ccd466d444d5f571d6c8825b8179654e5afd74b4 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 11 Jan 2025 23:34:34 -0700 Subject: [PATCH 047/203] refactor: Rename variables and methods to follow camelCase convention --- source/remoteClient/client.py | 2 +- source/remoteClient/dialogs.py | 148 ++++++++++++++++----------------- 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 9d269e7f619..0a1222d878f 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -205,7 +205,7 @@ def handleDialogCompletion(dlgResult): if dlgResult != wx.ID_OK: return connectionInfo = dlg.getConnectionInfo() - if dlg.client_or_server.GetSelection() == 1: # server + if dlg.clientOrServer.GetSelection() == 1: # server self.startControlServer(connectionInfo.port, connectionInfo.key) self.connect(connectionInfo=connectionInfo) diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 2fb8276bc6e..660d4bc42fb 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -17,7 +17,7 @@ class ClientPanel(wx.Panel): host: wx.ComboBox key: wx.TextCtrl - generate_key: wx.Button + generateKey: wx.Button keyConnector: Optional["transport.RelayTransport"] def __init__(self, parent: Optional[wx.Window] = None, id: int = wx.ID_ANY): @@ -32,12 +32,12 @@ def __init__(self, parent: Optional[wx.Window] = None, id: int = wx.ID_ANY): self.key = wx.TextCtrl(self, wx.ID_ANY) sizer.Add(self.key) # Translators: The button used to generate a random key/password. - self.generate_key = wx.Button(parent=self, label=_("&Generate Key")) - self.generate_key.Bind(wx.EVT_BUTTON, self.on_generate_key) - sizer.Add(self.generate_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 on_generate_key(self, evt: wx.CommandEvent) -> None: + 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. @@ -49,37 +49,37 @@ def on_generate_key(self, evt: wx.CommandEvent) -> None: self.host.SetFocus() else: evt.Skip() - self.generate_key_command() + self.generateKeyCommand() - def generate_key_command(self, insecure: bool = False) -> None: + def generateKeyCommand(self, insecure: bool = False) -> None: address = socket_utils.addressToHostPort(self.host.GetValue()) self.keyConnector = transport.RelayTransport( address=address, serializer=serializer.JSONSerializer(), insecure=insecure, ) - self.keyConnector.registerInbound(RemoteMessageType.generate_key, self.handle_key_generated) - self.keyConnector.transportCertificateAuthenticationFailed.register(self.handle_certificate_failed) + 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 handle_key_generated(self, key: Optional[str] = None) -> None: + def handleKeyGenerated(self, key: Optional[str] = None) -> None: self.key.SetValue(key) self.key.SetFocus() self.keyConnector.close() self.keyConnector = None @alwaysCallAfter - def handle_certificate_failed(self) -> None: + def handleCertificateFailed(self) -> None: try: - cert_hash = self.keyConnector.lastFailFingerprint + certHash = self.keyConnector.lastFailFingerprint - wnd = CertificateUnauthorizedDialog(None, fingerprint=cert_hash) + wnd = CertificateUnauthorizedDialog(None, fingerprint=certHash) a = wnd.ShowModal() if a == wx.ID_YES: config = configuration.get_config() - config["trusted_certs"][self.host.GetValue()] = cert_hash + config["trusted_certs"][self.host.GetValue()] = certHash if a != wx.ID_YES and a != wx.ID_NO: return except Exception as ex: @@ -87,7 +87,7 @@ def handle_certificate_failed(self) -> None: return self.keyConnector.close() self.keyConnector = None - self.generate_key_command(True) + self.generateKeyCommand(True) class PortCheckResponse(TypedDict): @@ -97,23 +97,23 @@ class PortCheckResponse(TypedDict): class ServerPanel(wx.Panel): - get_IP: wx.Button - external_IP: wx.TextCtrl + getIP: wx.Button + externalIP: wx.TextCtrl port: wx.TextCtrl key: wx.TextCtrl - generate_key: wx.Button + 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.get_IP = wx.Button(parent=self, label=_("Get External &IP")) - self.get_IP.Bind(wx.EVT_BUTTON, self.on_get_IP) - sizer.Add(self.get_IP) + 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.external_IP = wx.TextCtrl(self, wx.ID_ANY, style=wx.TE_READONLY | wx.TE_MULTILINE) - sizer.Add(self.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)) @@ -121,12 +121,12 @@ def __init__(self, parent: Optional[wx.Window] = None, id: int = wx.ID_ANY): sizer.Add(wx.StaticText(self, wx.ID_ANY, label=_("&Key:"))) self.key = wx.TextCtrl(self, wx.ID_ANY) sizer.Add(self.key) - self.generate_key = wx.Button(parent=self, label=_("&Generate Key")) - self.generate_key.Bind(wx.EVT_BUTTON, self.on_generate_key) - sizer.Add(self.generate_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 on_generate_key(self, evt: wx.CommandEvent) -> None: + def onGenerateKey(self, evt: wx.CommandEvent) -> None: evt.Skip() res = str(random.randrange(1, 9)) for n in range(6): @@ -134,15 +134,15 @@ def on_generate_key(self, evt: wx.CommandEvent) -> None: self.key.SetValue(res) self.key.SetFocus() - def on_get_IP(self, evt: wx.CommandEvent) -> None: + def onGetIP(self, evt: wx.CommandEvent) -> None: evt.Skip() - self.get_IP.Enable(False) - t = threading.Thread(target=self.do_portcheck, args=[int(self.port.GetValue())]) + self.getIP.Enable(False) + t = threading.Thread(target=self.doPortcheck, args=[int(self.port.GetValue())]) t.daemon = True t.start() - def do_portcheck(self, port: int) -> None: - temp_server = server.LocalRelayServer(port=port, password=None) + 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() @@ -152,62 +152,62 @@ def do_portcheck(self, port: int) -> None: wx.CallAfter(self.onGetIPFail, e) raise finally: - temp_server.close() - wx.CallAfter(self.get_IP.Enable, True) + tempServer.close() + wx.CallAfter(self.getIP.Enable, True) def onGetIPSucceeded(self, data: PortCheckResponse) -> None: ip = data["host"] port = data["port"] - is_open = data["open"] + isOpen = data["open"] - if is_open: + if isOpen: # Translators: Message shown when successfully getting external IP and the specified port is open - success_msg = _("Successfully retrieved IP address. Port {port} is open.") + successMsg = _("Successfully retrieved IP address. Port {port} is open.") # Translators: Title of success dialog - success_title = _("Success") + successTitle = _("Success") wx.MessageBox( - message=success_msg.format(port=port), - caption=success_title, + 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 - warning_msg = _("Retrieved external IP, but port {port} is not currently forwarded.") + warningMsg = _("Retrieved external IP, but port {port} is not currently forwarded.") # Translators: Title of warning dialog - warning_title = _("Warning") + warningTitle = _("Warning") wx.MessageBox( - message=warning_msg.format(port=port), - caption=warning_title, + message=warningMsg.format(port=port), + caption=warningTitle, style=wx.ICON_WARNING | wx.OK, ) - self.external_IP.SetValue(ip) - self.external_IP.SetSelection(0, len(ip)) - self.external_IP.SetFocus() + 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 - error_msg = _("Unable to contact portcheck server, please manually retrieve your IP address") + errorMsg = _("Unable to contact portcheck server, please manually retrieve your IP address") # Translators: Title of error dialog - error_title = _("Error") + errorTitle = _("Error") wx.MessageBox( - message=error_msg, - caption=error_title, + message=errorMsg, + caption=errorTitle, style=wx.ICON_ERROR | wx.OK, ) class DirectConnectDialog(wx.Dialog): - client_or_server: wx.RadioBox - connection_type: wx.RadioBox + clientOrServer: wx.RadioBox + connectionType: wx.RadioBox container: wx.Panel panel: Union[ClientPanel, ServerPanel] - main_sizer: wx.BoxSizer + mainSizer: wx.BoxSizer def __init__(self, parent: wx.Window, id: int, title: str, hostnames: Optional[List[str]] = None): super().__init__(parent, id, title=title) - main_sizer = self.main_sizer = wx.BoxSizer(wx.VERTICAL) - self.client_or_server = wx.RadioBox( + mainSizer = self.mainSizer = wx.BoxSizer(wx.VERTICAL) + self.clientOrServer = wx.RadioBox( self, wx.ID_ANY, choices=( @@ -218,29 +218,29 @@ def __init__(self, parent: wx.Window, id: int, title: str, hostnames: Optional[L ), style=wx.RA_VERTICAL, ) - self.client_or_server.Bind(wx.EVT_RADIOBOX, self.onClientOrServer) - self.client_or_server.SetSelection(0) - main_sizer.Add(self.client_or_server) + 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.connection_type = wx.RadioBox(self, wx.ID_ANY, choices=choices, style=wx.RA_VERTICAL) - self.connection_type.SetSelection(0) - main_sizer.Add(self.connection_type) + 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) - main_sizer.Add(self.container) + mainSizer.Add(self.container) buttons = self.CreateButtonSizer(wx.OK | wx.CANCEL) - main_sizer.Add(buttons, flag=wx.BOTTOM) - main_sizer.Fit(self) - self.SetSizer(main_sizer) + 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.client_or_server.SetFocus() + self.clientOrServer.SetFocus() if hostnames: self.panel.host.AppendItems(hostnames) self.panel.host.SetSelection(0) @@ -248,14 +248,14 @@ def __init__(self, parent: wx.Window, id: int, title: str, hostnames: Optional[L def onClientOrServer(self, evt: wx.CommandEvent) -> None: evt.Skip() self.panel.Destroy() - if self.client_or_server.GetSelection() == 0: + if self.clientOrServer.GetSelection() == 0: self.panel = ClientPanel(parent=self.container) else: self.panel = ServerPanel(parent=self.container) - self.main_sizer.Fit(self) + self.mainSizer.Fit(self) def onOk(self, evt: wx.CommandEvent) -> None: - if self.client_or_server.GetSelection() == 0 and ( + if self.clientOrServer.GetSelection() == 0 and ( not self.panel.host.GetValue() or not self.panel.key.GetValue() ): gui.messageBox( @@ -267,7 +267,7 @@ def onOk(self, evt: wx.CommandEvent) -> None: ) self.panel.host.SetFocus() elif ( - self.client_or_server.GetSelection() == 1 + self.clientOrServer.GetSelection() == 1 and not self.panel.port.GetValue() or not self.panel.key.GetValue() ): @@ -286,10 +286,10 @@ def getKey(self) -> str: return self.panel.key.GetValue() def getConnectionInfo(self) -> ConnectionInfo: - if self.client_or_server.GetSelection() == 0: # client + if self.clientOrServer.GetSelection() == 0: # client host = self.panel.host.GetValue() serverAddr, port = socket_utils.addressToHostPort(host) - mode = ConnectionMode.MASTER if self.connection_type.GetSelection() == 0 else ConnectionMode.SLAVE + mode = ConnectionMode.MASTER if self.connectionType.GetSelection() == 0 else ConnectionMode.SLAVE return ConnectionInfo( hostname=serverAddr, mode=mode, @@ -299,7 +299,7 @@ def getConnectionInfo(self) -> ConnectionInfo: ) else: # server port = int(self.panel.port.GetValue()) - mode = "master" if self.connection_type.GetSelection() == 0 else "slave" + mode = "master" if self.connectionType.GetSelection() == 0 else "slave" return ConnectionInfo( hostname="127.0.0.1", mode=mode, From 49701378bc99528695a65f1a50c65e37f76dc2d0 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 11 Jan 2025 23:36:11 -0700 Subject: [PATCH 048/203] Unmute remote speech when controlling the remote machine --- source/remoteClient/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 0a1222d878f..a7642dc3190 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -395,6 +395,8 @@ def toggleRemoteKeyControl(self, gesture: KeyboardInputGesture): 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. From dc433430725dda0df0de763ded8ea280fb6c723f Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 11 Jan 2025 23:44:24 -0700 Subject: [PATCH 049/203] Added support for TOR hidden services (.onion addresses) per nvdaremote/nvdaremote#281 by @jmdaweb --- source/remoteClient/transport.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 7f2c1772b4f..05daba82d63 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -395,8 +395,11 @@ def createOutboundSocket( Note: The socket is created but not yet connected. Call connect() separately. """ - address = socket.getaddrinfo(host, port)[0] - serverSock = socket.socket(*address[:3]) + 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) @@ -404,11 +407,10 @@ def createOutboundSocket( 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() - if insecure: - log.warn("Skipping certificate verification for %s:%d", host, port) serverSock = ctx.wrap_socket(sock=serverSock, server_hostname=host) return serverSock From cffd9d83ff5ce06cae4468f82ce2651d85a448f9 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 12 Jan 2025 19:12:54 -0700 Subject: [PATCH 050/203] Update docs --- source/remoteClient/localMachine.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/source/remoteClient/localMachine.py b/source/remoteClient/localMachine.py index c808e85610e..c5800a0fb0e 100644 --- a/source/remoteClient/localMachine.py +++ b/source/remoteClient/localMachine.py @@ -17,16 +17,6 @@ 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. -Example: - A typical usage from the remote connection handler:: - - local = LocalMachine() - # Handle incoming remote speech - local.speak(["Hello from remote"], priority=Spri.NORMAL) - # Share braille display - local.receivingBraille = True - local.display([0x28, 0x28]) # Show dots 1,2,3,4 in cell - Note: This module is part of the NVDA Remote protocol implementation and should not be used directly outside of the remote connection infrastructure. From f8a7e116adfd2ba8abbcb330dc4aa0cb19ee4eeb Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 12 Jan 2025 20:49:53 -0700 Subject: [PATCH 051/203] Move contents of `socket_utils` into `protocol` module --- source/remoteClient/client.py | 4 ++-- source/remoteClient/connection_info.py | 4 ++-- source/remoteClient/dialogs.py | 6 +++--- source/remoteClient/protocol.py | 18 ++++++++++++++++++ source/remoteClient/socket_utils.py | 20 -------------------- source/remoteClient/transport.py | 2 +- 6 files changed, 26 insertions(+), 28 deletions(-) delete mode 100644 source/remoteClient/socket_utils.py diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index a7642dc3190..6f3ef82bb07 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -18,10 +18,10 @@ from .connection_info import ConnectionInfo, ConnectionMode from .localMachine import LocalMachine from .menu import RemoteMenu -from .protocol import RemoteMessageType +from .protocol import RemoteMessageType, addressToHostPort from .secureDesktop import SecureDesktopHandler from .session import MasterSession, SlaveSession -from .socket_utils import addressToHostPort, hostPortToAddress +from .protocol import hostPortToAddress from .transport import RelayTransport # Type aliases diff --git a/source/remoteClient/connection_info.py b/source/remoteClient/connection_info.py index 100daeacd58..f99423cebbb 100644 --- a/source/remoteClient/connection_info.py +++ b/source/remoteClient/connection_info.py @@ -2,7 +2,7 @@ from enum import Enum from urllib.parse import parse_qs, urlencode, urlparse, urlunparse -from . import socket_utils +from . import protocol from .protocol import SERVER_PORT, URL_PREFIX @@ -62,7 +62,7 @@ def getAddress(self): def _build_url(self, mode: ConnectionMode): # Build URL components - netloc = socket_utils.hostPortToAddress((self.hostname, self.port)) + netloc = protocol.hostPortToAddress((self.hostname, self.port)) params = { "key": self.key, "mode": mode if isinstance(mode, str) else mode.value, diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 660d4bc42fb..06acbe7c3f4 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -9,7 +9,7 @@ from logHandler import log from utils.alwaysCallAfter import alwaysCallAfter -from . import configuration, serializer, server, socket_utils, transport +from . import configuration, serializer, server, protocol, transport from .connection_info import ConnectionInfo, ConnectionMode from .protocol import SERVER_PORT, RemoteMessageType @@ -52,7 +52,7 @@ def onGenerateKey(self, evt: wx.CommandEvent) -> None: self.generateKeyCommand() def generateKeyCommand(self, insecure: bool = False) -> None: - address = socket_utils.addressToHostPort(self.host.GetValue()) + address = protocol.addressToHostPort(self.host.GetValue()) self.keyConnector = transport.RelayTransport( address=address, serializer=serializer.JSONSerializer(), @@ -288,7 +288,7 @@ def getKey(self) -> str: def getConnectionInfo(self) -> ConnectionInfo: if self.clientOrServer.GetSelection() == 0: # client host = self.panel.host.GetValue() - serverAddr, port = socket_utils.addressToHostPort(host) + serverAddr, port = protocol.addressToHostPort(host) mode = ConnectionMode.MASTER if self.connectionType.GetSelection() == 0 else ConnectionMode.SLAVE return ConnectionInfo( hostname=serverAddr, diff --git a/source/remoteClient/protocol.py b/source/remoteClient/protocol.py index d0d8a9f3977..52ca2049a6c 100644 --- a/source/remoteClient/protocol.py +++ b/source/remoteClient/protocol.py @@ -1,3 +1,4 @@ +import urllib from enum import Enum PROTOCOL_VERSION: int = 2 @@ -43,3 +44,20 @@ class RemoteMessageType(Enum): 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/socket_utils.py b/source/remoteClient/socket_utils.py deleted file mode 100644 index 2f84134d35c..00000000000 --- a/source/remoteClient/socket_utils.py +++ /dev/null @@ -1,20 +0,0 @@ -import urllib.parse - -from .protocol import SERVER_PORT - - -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/transport.py b/source/remoteClient/transport.py index 05daba82d63..7c8e52e4c31 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -38,7 +38,7 @@ from .connection_info import ConnectionInfo from .protocol import PROTOCOL_VERSION, RemoteMessageType from .serializer import Serializer -from .socket_utils import hostPortToAddress +from .protocol import hostPortToAddress log = getLogger("transport") From ff3be26a954b34c3be05a587445743e73814cdc7 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 12 Jan 2025 21:04:32 -0700 Subject: [PATCH 052/203] Camel-case input module --- source/remoteClient/input.py | 2 +- source/remoteClient/localMachine.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index cc435ec34a4..9bfcd48d8cd 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -132,7 +132,7 @@ def findScript(self): return None -def send_key(vk=None, scan=None, extended=False, pressed=True): +def sendKey(vk=None, scan=None, extended=False, pressed=True): i = INPUT() i.union.ki.wVk = vk if scan: diff --git a/source/remoteClient/localMachine.py b/source/remoteClient/localMachine.py index c5800a0fb0e..a11c6f93ca5 100644 --- a/source/remoteClient/localMachine.py +++ b/source/remoteClient/localMachine.py @@ -283,7 +283,7 @@ def sendKey( extended: Whether this is an extended key pressed: True for key press, False for key release """ - wx.CallAfter(input.send_key, vk_code, None, extended, pressed) + wx.CallAfter(input.sendKey, vk_code, None, extended, pressed) def setClipboardText(self, text: str) -> None: """Set the local clipboard text from a remote machine. From d41ac912e6b85e04a110f21965c3824c02501e4d Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 15 Jan 2025 21:38:20 -0700 Subject: [PATCH 053/203] Add copyright notices to all remote modules --- source/remoteClient/__init__.py | 5 +++++ source/remoteClient/bridge.py | 5 +++++ source/remoteClient/client.py | 5 +++++ source/remoteClient/configuration.py | 5 +++++ source/remoteClient/connection_info.py | 5 +++++ source/remoteClient/cues.py | 5 +++++ source/remoteClient/dialogs.py | 5 +++++ source/remoteClient/input.py | 5 +++++ source/remoteClient/localMachine.py | 5 +++++ source/remoteClient/menu.py | 5 +++++ source/remoteClient/protocol.py | 5 +++++ source/remoteClient/secureDesktop.py | 5 +++++ source/remoteClient/serializer.py | 5 +++++ source/remoteClient/server.py | 5 +++++ source/remoteClient/session.py | 5 +++++ source/remoteClient/transport.py | 5 +++++ source/remoteClient/url_handler.py | 5 +++++ 17 files changed, 85 insertions(+) diff --git a/source/remoteClient/__init__.py b/source/remoteClient/__init__.py index c680cefaae1..e1d8fe1dd75 100644 --- a/source/remoteClient/__init__.py +++ b/source/remoteClient/__init__.py @@ -1,3 +1,8 @@ +# 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 diff --git a/source/remoteClient/bridge.py b/source/remoteClient/bridge.py index d6f65cc0677..a86b446853f 100644 --- a/source/remoteClient/bridge.py +++ b/source/remoteClient/bridge.py @@ -1,3 +1,8 @@ +# 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 ====================== diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 6f3ef82bb07..c9da3b026d7 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -1,3 +1,8 @@ +# 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 diff --git a/source/remoteClient/configuration.py b/source/remoteClient/configuration.py index ebc6d0c2fba..c981255a9f6 100644 --- a/source/remoteClient/configuration.py +++ b/source/remoteClient/configuration.py @@ -1,3 +1,8 @@ +# 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 diff --git a/source/remoteClient/connection_info.py b/source/remoteClient/connection_info.py index f99423cebbb..a9a5db311a8 100644 --- a/source/remoteClient/connection_info.py +++ b/source/remoteClient/connection_info.py @@ -1,3 +1,8 @@ +# 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 diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index 3fcc47c9a46..8860e46ee52 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -1,3 +1,8 @@ +# 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 diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 06acbe7c3f4..44e14acd742 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -1,3 +1,8 @@ +# 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 diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index 9bfcd48d8cd..d9bb4914205 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -1,3 +1,8 @@ +# 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 diff --git a/source/remoteClient/localMachine.py b/source/remoteClient/localMachine.py index a11c6f93ca5..d240a92a9a2 100644 --- a/source/remoteClient/localMachine.py +++ b/source/remoteClient/localMachine.py @@ -1,3 +1,8 @@ +# 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 diff --git a/source/remoteClient/menu.py b/source/remoteClient/menu.py index a358886e005..f441234f110 100644 --- a/source/remoteClient/menu.py +++ b/source/remoteClient/menu.py @@ -1,3 +1,8 @@ +# 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 diff --git a/source/remoteClient/protocol.py b/source/remoteClient/protocol.py index 52ca2049a6c..9b8b44aaafa 100644 --- a/source/remoteClient/protocol.py +++ b/source/remoteClient/protocol.py @@ -1,3 +1,8 @@ +# 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 diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 2fa2a74bf37..b31b7bbf2c0 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -1,3 +1,8 @@ +# 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, diff --git a/source/remoteClient/serializer.py b/source/remoteClient/serializer.py index 065075d4669..a4f9bec36db 100644 --- a/source/remoteClient/serializer.py +++ b/source/remoteClient/serializer.py @@ -1,3 +1,8 @@ +# 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, diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 19cc4cff265..733d7ae09da 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -1,3 +1,8 @@ +# 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 diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 1e9e4cb0f5b..2cd38dc2619 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -1,3 +1,8 @@ +# 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, diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 7c8e52e4c31..e68c9776f8c 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -1,3 +1,8 @@ +# 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. diff --git a/source/remoteClient/url_handler.py b/source/remoteClient/url_handler.py index 9dd03b044a9..afa335a6e78 100644 --- a/source/remoteClient/url_handler.py +++ b/source/remoteClient/url_handler.py @@ -1,3 +1,8 @@ +# 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. From 0d0859f6b3a5fe32ef227c241edb770f37ab5af7 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 15 Jan 2025 21:41:32 -0700 Subject: [PATCH 054/203] connection_info -> connectionInfo --- source/remoteClient/client.py | 2 +- source/remoteClient/configuration.py | 2 +- .../{connection_info.py => connectionInfo.py} | 0 source/remoteClient/dialogs.py | 2 +- source/remoteClient/menu.py | 2 +- source/remoteClient/secureDesktop.py | 2 +- source/remoteClient/session.py | 12 ++++++------ source/remoteClient/transport.py | 2 +- source/remoteClient/url_handler.py | 6 +++--- 9 files changed, 15 insertions(+), 15 deletions(-) rename source/remoteClient/{connection_info.py => connectionInfo.py} (100%) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index c9da3b026d7..b8b55fc9da4 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -20,7 +20,7 @@ from utils.security import isRunningOnSecureDesktop from . import configuration, cues, dialogs, serializer, server, url_handler -from .connection_info import ConnectionInfo, ConnectionMode +from .connectionInfo import ConnectionInfo, ConnectionMode from .localMachine import LocalMachine from .menu import RemoteMenu from .protocol import RemoteMessageType, addressToHostPort diff --git a/source/remoteClient/configuration.py b/source/remoteClient/configuration.py index c981255a9f6..f4d47114281 100644 --- a/source/remoteClient/configuration.py +++ b/source/remoteClient/configuration.py @@ -11,7 +11,7 @@ import globalVars from configobj import validate -from .connection_info import ConnectionInfo +from .connectionInfo import ConnectionInfo CONFIG_FILE_NAME = "remote.ini" configRoot = "Remote" diff --git a/source/remoteClient/connection_info.py b/source/remoteClient/connectionInfo.py similarity index 100% rename from source/remoteClient/connection_info.py rename to source/remoteClient/connectionInfo.py diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 44e14acd742..0b50f729ea9 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -15,7 +15,7 @@ from utils.alwaysCallAfter import alwaysCallAfter from . import configuration, serializer, server, protocol, transport -from .connection_info import ConnectionInfo, ConnectionMode +from .connectionInfo import ConnectionInfo, ConnectionMode from .protocol import SERVER_PORT, RemoteMessageType diff --git a/source/remoteClient/menu.py b/source/remoteClient/menu.py index f441234f110..f407f87d391 100644 --- a/source/remoteClient/menu.py +++ b/source/remoteClient/menu.py @@ -12,7 +12,7 @@ import gui -from .connection_info import ConnectionMode +from .connectionInfo import ConnectionMode class RemoteMenu(wx.Menu): diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index b31b7bbf2c0..3b6a36db27a 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -27,7 +27,7 @@ from winAPI.secureDesktop import post_secureDesktopStateChange from . import bridge, server -from .connection_info import ConnectionInfo, ConnectionMode +from .connectionInfo import ConnectionInfo, ConnectionMode from .protocol import RemoteMessageType from .serializer import JSONSerializer from .session import SlaveSession diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 2cd38dc2619..cb99f9d27b3 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -80,7 +80,7 @@ import ui from speech.extensions import speechCanceled, post_speechPaused, pre_speechQueued -from . import configuration, connection_info, cues +from . import configuration, connectionInfo, cues from .localMachine import LocalMachine from .protocol import RemoteMessageType @@ -110,7 +110,7 @@ class RemoteSession: 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[connection_info.ConnectionMode] = None + mode: Optional[connectionInfo.ConnectionMode] = None callbacksAdded: bool # Whether callbacks are currently registered def __init__( @@ -211,7 +211,7 @@ def handleClientDisconnected(self, client=None): """ cues.client_disconnected() - def getConnectionInfo(self) -> connection_info.ConnectionInfo: + def getConnectionInfo(self) -> connectionInfo.ConnectionInfo: """Get information about the current connection. Returns a ConnectionInfo object containing: @@ -221,7 +221,7 @@ def getConnectionInfo(self) -> connection_info.ConnectionInfo: """ hostname, port = self.transport.address key = self.transport.channel - return connection_info.ConnectionInfo( + return connectionInfo.ConnectionInfo( hostname=hostname, port=port, key=key, @@ -255,7 +255,7 @@ class SlaveSession(RemoteSession): """ # Connection mode - always 'slave' - mode: connection_info.ConnectionMode = connection_info.ConnectionMode.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 @@ -451,7 +451,7 @@ class MasterSession(RemoteSession): appropriate commands to control the remote slave instance. """ - mode: connection_info.ConnectionMode = connection_info.ConnectionMode.MASTER + mode: connectionInfo.ConnectionMode = connectionInfo.ConnectionMode.MASTER slaves: Dict[int, Dict[str, Any]] # Information about connected slave def __init__( diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index e68c9776f8c..b30e41f36eb 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -40,7 +40,7 @@ from extensionPoints import Action, HandlerRegistrar from . import configuration -from .connection_info import ConnectionInfo +from .connectionInfo import ConnectionInfo from .protocol import PROTOCOL_VERSION, RemoteMessageType from .serializer import Serializer from .protocol import hostPortToAddress diff --git a/source/remoteClient/url_handler.py b/source/remoteClient/url_handler.py index afa335a6e78..f75267a19f5 100644 --- a/source/remoteClient/url_handler.py +++ b/source/remoteClient/url_handler.py @@ -36,7 +36,7 @@ import wx from winUser import WM_COPYDATA # provided by NVDA -from . import connection_info +from . import connectionInfo class COPYDATASTRUCT(ctypes.Structure): @@ -119,8 +119,8 @@ def windowProc(self, hwnd, msg, wParam, lParam): url = ctypes.wstring_at(message_data.contents.lpData) log.info("Received url: %s" % url) try: - con_info = connection_info.ConnectionInfo.fromURL(url) - except connection_info.URLParsingError: + con_info = connectionInfo.ConnectionInfo.fromURL(url) + except connectionInfo.URLParsingError: wx.CallLater( 50, gui.messageBox, From 742f1f8b925aa43cf07364c87ff1bfcb5a08611b Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 15 Jan 2025 23:35:09 -0700 Subject: [PATCH 055/203] refactor: Add ABCMeta to Serializer for abstract base class definition --- source/remoteClient/serializer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/serializer.py b/source/remoteClient/serializer.py index a4f9bec36db..0a254f1bfda 100644 --- a/source/remoteClient/serializer.py +++ b/source/remoteClient/serializer.py @@ -16,7 +16,7 @@ - Custom message types via the 'type' field """ -from abc import abstractmethod +from abc import ABCMeta, abstractmethod from enum import Enum from logging import getLogger from typing import Any, Dict, Optional, Type, Union, TypeVar @@ -30,7 +30,7 @@ JSONDict = Dict[str, Any] -class Serializer: +class Serializer(metaclass=ABCMeta): """Base class for message serialization. Defines the interface for serializing messages between NVDA instances. From fb58036d9f55144e4809427af4f8cb19986aac01 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 18 Jan 2025 15:36:03 -0700 Subject: [PATCH 056/203] globalVars.remoteClient -> remoteClient.client per review --- source/globalCommands.py | 18 +++++++++--------- source/remoteClient/__init__.py | 13 ++++++------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/source/globalCommands.py b/source/globalCommands.py index 46aaa6f6df2..e1b8ffff5f5 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -71,7 +71,7 @@ import audio from audio import appsVolume from utils.displayString import DisplayStringEnum - +import remoteClient #: Script category for text review commands. # Translators: The name of a category of NVDA commands. @@ -4900,7 +4900,7 @@ def script_toggleApplicationsMute(self, gesture: "inputCore.InputGesture") -> No category=SCRCAT_REMOTE, ) def script_toggle_remote_mute(self, gesture): - globalVars.remoteClient.toggleMute() + remoteClient.client.toggleMute() @script( gesture="kb:control+shift+NVDA+c", @@ -4909,7 +4909,7 @@ def script_toggle_remote_mute(self, gesture): description=_("Sends the contents of the clipboard to the remote machine"), ) def script_push_clipboard(self, gesture): - globalVars.remoteClient.pushClipboard() + remoteClient.client.pushClipboard() @script( # Translators: Documentation string for the script that copies a link to the remote session to the clipboard. @@ -4917,7 +4917,7 @@ def script_push_clipboard(self, gesture): category=SCRCAT_REMOTE, ) def script_copy_link(self, gesture): - globalVars.remoteClient.copyLink() + remoteClient.client.copyLink() # Translators: A message indicating that a link has been copied to the clipboard. ui.message(_("Copied link")) @@ -4929,11 +4929,11 @@ def script_copy_link(self, gesture): ) @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) def script_disconnectFromRemote(self, gesture): - if not globalVars.remoteClient.isConnected: + if not remoteClient.client.isConnected: # Translators: A message indicating that the remote client is not connected. ui.message(_("Not connected.")) return - globalVars.remoteClient.disconnect() + remoteClient.client.disconnect() @script( gesture="kb:alt+NVDA+pageUp", @@ -4944,9 +4944,9 @@ def script_disconnectFromRemote(self, gesture): @gui.blockAction.when(gui.blockAction.Context.MODAL_DIALOG_OPEN) @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) def script_connectToRemote(self, gesture): - if globalVars.remoteClient.isConnected() or globalVars.remoteClient.connecting: + if remoteClient.client.isConnected() or remoteClient.client.connecting: return - globalVars.remoteClient.doConnect() + remoteClient.client.doConnect() @script( # Translators: Documentation string for the script that toggles the control between guest and host machine. @@ -4955,7 +4955,7 @@ def script_connectToRemote(self, gesture): gesture="kb:f11", ) def script_sendKeys(self, gesture): - globalVars.remoteClient.toggleRemoteKeyControl(gesture) + remoteClient.client.toggleRemoteKeyControl(gesture) #: The single global commands instance. diff --git a/source/remoteClient/__init__.py b/source/remoteClient/__init__.py index e1d8fe1dd75..57e90895530 100644 --- a/source/remoteClient/__init__.py +++ b/source/remoteClient/__init__.py @@ -5,19 +5,18 @@ from .client import RemoteClient +client: RemoteClient = None + def initialize(): """Initialise the remote client.""" - import globalVars + global client import globalCommands - globalVars.remoteClient = RemoteClient() - globalVars.remoteClient.registerLocalScript(globalCommands.commands.script_sendKeys) + client = RemoteClient() + client.registerLocalScript(globalCommands.commands.script_sendKeys) def terminate(): """Terminate the remote client.""" - import globalVars - - globalVars.remoteClient.terminate() - globalVars.remoteClient = None + client.terminate() From 23317c15b144de95e93a6728c585573529619868 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 22 Jan 2025 18:03:11 -0700 Subject: [PATCH 057/203] remoteClient: Rename global 'client' variable to 'remoteClient' Improve code clarity by renaming the global client variable to a more descriptive name 'remoteClient' across the codebase. This change: - Renames the global variable in remoteClient/__init__.py from 'client' to 'remoteClient' - Updates all references in globalCommands.py to use the new variable name - Adds explicit cleanup in terminate() by setting remoteClient to None - Preserves type hints and existing functionality --- source/globalCommands.py | 16 ++++++++-------- source/remoteClient/__init__.py | 12 +++++++----- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/source/globalCommands.py b/source/globalCommands.py index e1b8ffff5f5..81c0da1c406 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4900,7 +4900,7 @@ def script_toggleApplicationsMute(self, gesture: "inputCore.InputGesture") -> No category=SCRCAT_REMOTE, ) def script_toggle_remote_mute(self, gesture): - remoteClient.client.toggleMute() + remoteClient.remoteClient.toggleMute() @script( gesture="kb:control+shift+NVDA+c", @@ -4909,7 +4909,7 @@ def script_toggle_remote_mute(self, gesture): description=_("Sends the contents of the clipboard to the remote machine"), ) def script_push_clipboard(self, gesture): - remoteClient.client.pushClipboard() + remoteClient.remoteClient.pushClipboard() @script( # Translators: Documentation string for the script that copies a link to the remote session to the clipboard. @@ -4917,7 +4917,7 @@ def script_push_clipboard(self, gesture): category=SCRCAT_REMOTE, ) def script_copy_link(self, gesture): - remoteClient.client.copyLink() + remoteClient.remoteClient.copyLink() # Translators: A message indicating that a link has been copied to the clipboard. ui.message(_("Copied link")) @@ -4929,11 +4929,11 @@ def script_copy_link(self, gesture): ) @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) def script_disconnectFromRemote(self, gesture): - if not remoteClient.client.isConnected: + if not remoteClient.remoteClient.isConnected: # Translators: A message indicating that the remote client is not connected. ui.message(_("Not connected.")) return - remoteClient.client.disconnect() + remoteClient.remoteClient.disconnect() @script( gesture="kb:alt+NVDA+pageUp", @@ -4944,9 +4944,9 @@ def script_disconnectFromRemote(self, gesture): @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: + if remoteClient.remoteClient.isConnected() or remoteClient.remoteClient.connecting: return - remoteClient.client.doConnect() + remoteClient.remoteClient.doConnect() @script( # Translators: Documentation string for the script that toggles the control between guest and host machine. @@ -4955,7 +4955,7 @@ def script_connectToRemote(self, gesture): gesture="kb:f11", ) def script_sendKeys(self, gesture): - remoteClient.client.toggleRemoteKeyControl(gesture) + remoteClient.remoteClient.toggleRemoteKeyControl(gesture) #: The single global commands instance. diff --git a/source/remoteClient/__init__.py b/source/remoteClient/__init__.py index 57e90895530..2e2fc550eb0 100644 --- a/source/remoteClient/__init__.py +++ b/source/remoteClient/__init__.py @@ -5,18 +5,20 @@ from .client import RemoteClient -client: RemoteClient = None +remoteClient: RemoteClient = None def initialize(): """Initialise the remote client.""" - global client + global remoteClient import globalCommands - client = RemoteClient() - client.registerLocalScript(globalCommands.commands.script_sendKeys) + remoteClient = RemoteClient() + remoteClient.registerLocalScript(globalCommands.commands.script_sendKeys) def terminate(): """Terminate the remote client.""" - client.terminate() + global remoteClient + remoteClient.terminate() + remoteClient = None From f6f7da8e36e69324455ba8c5472acd2c4710f27e Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 22 Jan 2025 21:57:54 -0700 Subject: [PATCH 058/203] refactor(bridge): use RemoteMessageType enum for excluded messages --- source/remoteClient/bridge.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/source/remoteClient/bridge.py b/source/remoteClient/bridge.py index a86b446853f..2f697961ee9 100644 --- a/source/remoteClient/bridge.py +++ b/source/remoteClient/bridge.py @@ -40,7 +40,7 @@ class BridgeTransport: their lifecycle. Attributes: - excluded (Set[str]): Message types that should not be forwarded between transports. + excluded (Set[RemoteMessageType]): 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 @@ -48,7 +48,12 @@ class BridgeTransport: t2_callbacks (Dict[RemoteMessageType, callable]): Storage for t2's message handlers """ - excluded: Set[str] = {"client_joined", "client_left", "channel_joined", "set_braille_info"} + excluded: Set[RemoteMessageType] = { + RemoteMessageType.client_joined, + RemoteMessageType.client_left, + RemoteMessageType.channel_joined, + RemoteMessageType.set_braille_info, + } def __init__(self, t1: Transport, t2: Transport) -> None: """Initialize the bridge between two transports. @@ -89,7 +94,7 @@ def makeCallback(self, targetTransport: Transport, messageType: RemoteMessageTyp """ def callback(*args, **kwargs): - if messageType.value not in self.excluded: + if messageType not in self.excluded: targetTransport.send(messageType, *args, **kwargs) return callback From a62a94f587e9fcb77f326a2638a234379b9f1471 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 22 Jan 2025 21:59:30 -0700 Subject: [PATCH 059/203] refactor: switch to StrEnum for connection enums Change ConnectionMode and ConnectionState from Enum to StrEnum for more straightforward string handling. This allows direct use of enum members as strings without accessing the .value property. --- source/remoteClient/connectionInfo.py | 10 +++++----- source/remoteClient/secureDesktop.py | 2 +- source/remoteClient/serializer.py | 2 +- source/remoteClient/transport.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py index a9a5db311a8..e6d959eeb85 100644 --- a/source/remoteClient/connectionInfo.py +++ b/source/remoteClient/connectionInfo.py @@ -4,7 +4,7 @@ # See the file COPYING for more details. from dataclasses import dataclass -from enum import Enum +from enum import StrEnum from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from . import protocol @@ -15,12 +15,12 @@ class URLParsingError(Exception): """Raised if it's impossible to parse out the URL""" -class ConnectionMode(Enum): +class ConnectionMode(StrEnum): MASTER = "master" SLAVE = "slave" -class ConnectionState(Enum): +class ConnectionState(StrEnum): CONNECTED = "connected" DISCONNECTED = "disconnected" CONNECTING = "connecting" @@ -70,7 +70,7 @@ def _build_url(self, mode: ConnectionMode): netloc = protocol.hostPortToAddress((self.hostname, self.port)) params = { "key": self.key, - "mode": mode if isinstance(mode, str) else mode.value, + "mode": mode, } if self.insecure: params["insecure"] = "true" @@ -91,7 +91,7 @@ def _build_url(self, mode: ConnectionMode): 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) + return self._build_url(connect_mode) def getURL(self): return self._build_url(self.mode) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 3b6a36db27a..32e4b723317 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -147,7 +147,7 @@ def enterSecureDesktop(self) -> None: serializer=JSONSerializer(), channel=channel, insecure=True, - connectionType=ConnectionMode.MASTER.value, + connectionType=ConnectionMode.MASTER, ) self.sdRelay.registerInbound(RemoteMessageType.client_joined, self._onMasterDisplayChange) self.slaveSession.transport.registerInbound( diff --git a/source/remoteClient/serializer.py b/source/remoteClient/serializer.py index 0a254f1bfda..e9340d24f80 100644 --- a/source/remoteClient/serializer.py +++ b/source/remoteClient/serializer.py @@ -88,7 +88,7 @@ def serialize(self, type: Optional[str] = None, **obj: Any) -> bytes: UTF-8 encoded JSON with newline separator """ if type is not None: - if isinstance(type, Enum): + if isinstance(type, Enum) and not isinstance(type, str): type = type.value obj["type"] = type data = json.dumps(obj, cls=CustomEncoder).encode("UTF-8") + self.SEP diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index b30e41f36eb..7a34f3a2ca9 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -656,7 +656,7 @@ def create(cls, connection_info: ConnectionInfo, serializer: Serializer) -> "Rel serializer=serializer, address=(connection_info.hostname, connection_info.port), channel=connection_info.key, - connectionType=connection_info.mode.value, + connectionType=connection_info.mode, insecure=connection_info.insecure, ) From bdad80a326e8505a689603d9b656d1228ff7a69d Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 22 Jan 2025 22:04:13 -0700 Subject: [PATCH 060/203] Standardize clipboard wave file naming per review --- source/remoteClient/cues.py | 4 ++-- .../waves/{Push_Clipboard.wav => clipboardPush.wav} | Bin .../{receive_clipboard.wav => clipboardReceive.wav} | Bin 3 files changed, 2 insertions(+), 2 deletions(-) rename source/waves/{Push_Clipboard.wav => clipboardPush.wav} (100%) rename source/waves/{receive_clipboard.wav => clipboardReceive.wav} (100%) diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index 8860e46ee52..739e057662c 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -40,13 +40,13 @@ class Cue(TypedDict, total=False): "client_connected": {"wave": "controlling", "beeps": [(1000, 300)]}, "client_disconnected": {"wave": "disconnected", "beeps": [(108, 300)]}, "clipboard_pushed": { - "wave": "push_clipboard", + "wave": "clipboardPush", "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", + "wave": "clipboardReceive", "beeps": [(600, 100), (500, 100)], # Translators: Message shown when the clipboard is successfully received from the remote computer. "message": _("Clipboard received"), diff --git a/source/waves/Push_Clipboard.wav b/source/waves/clipboardPush.wav similarity index 100% rename from source/waves/Push_Clipboard.wav rename to source/waves/clipboardPush.wav diff --git a/source/waves/receive_clipboard.wav b/source/waves/clipboardReceive.wav similarity index 100% rename from source/waves/receive_clipboard.wav rename to source/waves/clipboardReceive.wav From b12bfd410c5832e7cefbd591bdd805760f01354f Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 22 Jan 2025 22:14:26 -0700 Subject: [PATCH 061/203] Move alwaysCallAfter decorator to guiHelper module --- source/gui/guiHelper.py | 17 +++++++++++++++++ source/remoteClient/client.py | 2 +- source/remoteClient/dialogs.py | 2 +- source/utils/alwaysCallAfter.py | 25 ------------------------- 4 files changed, 19 insertions(+), 27 deletions(-) delete mode 100644 source/utils/alwaysCallAfter.py diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index 579466e5f07..8b7d8a75f78 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -45,6 +45,7 @@ def __init__(self, parent): from collections.abc import Callable from contextlib import contextmanager +from functools import wraps import sys import threading import weakref @@ -529,3 +530,19 @@ def functionWrapper(): raise exception else: return result + + +def alwaysCallAfter(func: Callable[..., None]) -> Callable[..., None]: + """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/remoteClient/client.py b/source/remoteClient/client.py index b8b55fc9da4..39cf9c58711 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -16,7 +16,7 @@ from config import isInstalledCopy from keyboardHandler import KeyboardInputGesture from logHandler import log -from utils.alwaysCallAfter import alwaysCallAfter +from gui.guiHelper import alwaysCallAfter from utils.security import isRunningOnSecureDesktop from . import configuration, cues, dialogs, serializer, server, url_handler diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 0b50f729ea9..7e24381f2eb 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -12,7 +12,7 @@ import gui import wx from logHandler import log -from utils.alwaysCallAfter import alwaysCallAfter +from gui.guiHelper import alwaysCallAfter from . import configuration, serializer, server, protocol, transport from .connectionInfo import ConnectionInfo, ConnectionMode diff --git a/source/utils/alwaysCallAfter.py b/source/utils/alwaysCallAfter.py deleted file mode 100644 index 2d3ca7f0493..00000000000 --- a/source/utils/alwaysCallAfter.py +++ /dev/null @@ -1,25 +0,0 @@ -"""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 From 39f9ff3bddb84c03179f0eb0b1dc4aeb56a5ec02 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 22 Jan 2025 22:19:33 -0700 Subject: [PATCH 062/203] Handle cases where the client is called (for instance by a gesture) and is disconnected. Also fix logging --- source/remoteClient/client.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 39cf9c58711..8b49d3c1b9a 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -137,15 +137,22 @@ def pushClipboard(self): def copyLink(self): session = self.masterSession or self.slaveSession + if session is None: + # Translators: Message shown when trying to copy the link to connect to the remote computer while not connected. + ui.message(_("Not connected.")) + return url = session.getConnectionInfo().getURLToConnect() api.copyToClip(str(url)) def sendSAS(self): + if self.masterTransport is None: + log.error("No master transport to send SAS") + return 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}", + f"Initiating connection as {connectionInfo.mode} to {connectionInfo.hostname}:{connectionInfo.port}", ) if connectionInfo.mode == ConnectionMode.MASTER: self.connectAsMaster(connectionInfo) From 68a1ebfd9c9daa0bbaeee75139a8c15d1d423adb Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 22 Jan 2025 22:31:04 -0700 Subject: [PATCH 063/203] Update anchor Co-authored-by: Cyrille Bougot --- user_docs/en/userGuide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 8d790a2edf8..4deda8c1647 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -3584,7 +3584,7 @@ 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} +## Remote Access {#remoteAccess} With NVDA's built-in remote access feature, you can control another computer running NVDA or allow someone to control your computer. This makes it easy to provide or receive assistance, collaborate, or access your own computer remotely. From 3244d0b19c1861803ed8246cb94d4cd35f01ebc3 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 24 Jan 2025 18:49:03 -0700 Subject: [PATCH 064/203] Update Remote menu documentation Co-authored-by: Leonard de Ruijter <3049216+LeonarddeR@users.noreply.github.com> --- source/remoteClient/menu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/menu.py b/source/remoteClient/menu.py index f407f87d391..a09bd876bea 100644 --- a/source/remoteClient/menu.py +++ b/source/remoteClient/menu.py @@ -16,7 +16,7 @@ class RemoteMenu(wx.Menu): - """Menu for the NVDA Remote addon that appears in the NVDA Tools menu""" + """Menu for the NVDA Remote functionality that appears in the NVDA Tools menu""" connectItem: wx.MenuItem disconnectItem: wx.MenuItem From cd2be77b9df071afbb0dcd2705c3880bbe6b297b Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Fri, 24 Jan 2025 18:50:19 -0700 Subject: [PATCH 065/203] Update version mismatch error Co-authored-by: Leonard de Ruijter <3049216+LeonarddeR@users.noreply.github.com> --- source/remoteClient/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index cb99f9d27b3..5774cc45638 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -154,7 +154,7 @@ def handleVersionMismatch(self) -> None: 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."""), +Please use a different server."""), ) self.transport.close() From 2a17049ee714a1fc2b328d432e45ef865f001e9f Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 26 Jan 2025 14:00:06 -0700 Subject: [PATCH 066/203] refactor: Simplify Remote URL handling via slave + NVDA helper Changes how nvdaremote:// URLs are processed by: - Adding a new nvdaControllerInternal RPC method handleRemoteURL - Updating URL handler registration to invoke nvda_slave.exe directly - Updating the slave to pass the URL to the running NVDA This simplifies the URL handling by: 1. Using existing NVDA helper infrastructure instead of custom window messaging 2. Eliminating need for WM_COPYDATA IPC and associated complexity 3. Providing more direct and reliable URL processing path sans url_handler.exe --- .../nvdaControllerInternal.idl | 6 + source/NVDAHelper.py | 26 ++++ source/nvda_slave.pyw | 9 ++ source/remoteClient/client.py | 5 - source/remoteClient/url_handler.py | 113 +----------------- 5 files changed, 43 insertions(+), 116 deletions(-) diff --git a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl index 523cd9902c0..49b8deb0862 100644 --- a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl +++ b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl @@ -102,4 +102,10 @@ interface NvdaControllerInternal { * Asks NVDA to open currently used configuration directory. */ error_status_t __stdcall openConfigDirectory(); + +/** + * Handles a remote URL request from the slave process. + * @param url The nvdaremote:// URL to process. + */ + error_status_t __stdcall handleRemoteURL([in,string] const wchar_t* url); }; diff --git a/source/NVDAHelper.py b/source/NVDAHelper.py index 55923ef794a..3da77a56e9f 100755 --- a/source/NVDAHelper.py +++ b/source/NVDAHelper.py @@ -687,6 +687,31 @@ def nvdaControllerInternal_openConfigDirectory(): return 0 +@WINFUNCTYPE(c_long, c_wchar_p) +def nvdaControllerInternal_handleRemoteURL(url): + """Handles a remote URL request from the slave process. + @param url: The nvdaremote:// URL to process + @return: 0 on success, -1 on failure + """ + import queueHandler + from remoteClient import connectionInfo, remoteClient as client + + try: + if not client: + log.error("No RemoteClient instance available") + return -1 + # Queue the URL handling on the main thread + queueHandler.queueFunction( + queueHandler.eventQueue, + client.verifyAndConnect, + connectionInfo.ConnectionInfo.fromURL(url), + ) + return 0 + except Exception: + log.error("Error handling remote URL", exc_info=True) + return -1 + + class _RemoteLoader: def __init__(self, loaderDir: str): # Create a pipe so we can write to stdin of the loader process. @@ -776,6 +801,7 @@ def initialize() -> None: ), ("nvdaControllerInternal_drawFocusRectNotify", nvdaControllerInternal_drawFocusRectNotify), ("nvdaControllerInternal_openConfigDirectory", nvdaControllerInternal_openConfigDirectory), + ("nvdaControllerInternal_handleRemoteURL", nvdaControllerInternal_handleRemoteURL), ]: try: _setDllFuncPointer(localLib, "_%s" % name, func) diff --git a/source/nvda_slave.pyw b/source/nvda_slave.pyw index 49625651f8c..e9adc89bb6a 100755 --- a/source/nvda_slave.pyw +++ b/source/nvda_slave.pyw @@ -118,6 +118,15 @@ def main(): 0, winUser.MB_ICONERROR, ) + elif action == "handleRemoteURL": + try: + url = args[0] + ret = getNvdaHelperRemote().nvdaControllerInternal_handleRemoteURL(url) + if ret != 0: + raise RuntimeError(f"URL handling failed with code {ret}") + except Exception: + logHandler.log.error("Error handling remote URL", exc_info=True) + sys.exit(1) elif action == "comGetActiveObject": import comHelper diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 8b49d3c1b9a..c762f5fe63d 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -61,9 +61,6 @@ def __init__( 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 @@ -110,8 +107,6 @@ def terminate(self): 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 diff --git a/source/remoteClient/url_handler.py b/source/remoteClient/url_handler.py index f75267a19f5..93fe1529ce0 100644 --- a/source/remoteClient/url_handler.py +++ b/source/remoteClient/url_handler.py @@ -8,7 +8,6 @@ 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 @@ -19,6 +18,7 @@ """ import os +import sys import winreg try: @@ -28,115 +28,6 @@ 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. @@ -222,7 +113,7 @@ def url_handler_path(): "shell": { "open": { "command": { - "": '"{path}" %1'.format(path=url_handler_path()), + "": '"{path}" handleRemoteURL %1'.format(path=os.path.join(sys.prefix, "nvda_slave.exe")), }, }, }, From ea4dd51d5ecaf99cde1ba815351f02671a8f522e Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 26 Jan 2025 20:57:35 -0700 Subject: [PATCH 067/203] Add handleRemoteURL interface to nvdaControllerInternal Added new interface function handleRemoteURL to enable remote URL handling capabilities in NVDA: - Added fault_status/comm_status interface definition in nvdaControllerInternal.acf - Added corresponding function exports in both local and remote helper DLLs --- .../interfaces/nvdaControllerInternal/nvdaControllerInternal.acf | 1 + nvdaHelper/local/nvdaHelperLocal.def | 1 + nvdaHelper/remote/nvdaHelperRemote.def | 1 + 3 files changed, 3 insertions(+) diff --git a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf index c9574b27af3..f7a17d76110 100644 --- a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf +++ b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf @@ -23,6 +23,7 @@ interface NvdaControllerInternal { [fault_status,comm_status] logMessage(); [fault_status,comm_status] vbufChangeNotify(); [fault_status,comm_status] installAddonPackageFromPath(); + [fault_status,comm_status] handleRemoteURL(); [fault_status,comm_status] drawFocusRectNotify(); [fault_status,comm_status] reportLiveRegion(); [fault_status,comm_status] openConfigDirectory(); diff --git a/nvdaHelper/local/nvdaHelperLocal.def b/nvdaHelper/local/nvdaHelperLocal.def index ed63733f45c..b2ee19ced9b 100644 --- a/nvdaHelper/local/nvdaHelperLocal.def +++ b/nvdaHelper/local/nvdaHelperLocal.def @@ -46,6 +46,7 @@ EXPORTS _nvdaControllerInternal_logMessage _nvdaControllerInternal_typedCharacterNotify _nvdaControllerInternal_installAddonPackageFromPath + _nvdaControllerInternal_handleRemoteURL _nvdaControllerInternal_drawFocusRectNotify _nvdaController_brailleMessage _nvdaController_cancelSpeech diff --git a/nvdaHelper/remote/nvdaHelperRemote.def b/nvdaHelper/remote/nvdaHelperRemote.def index 55def5c88aa..a69c823c105 100644 --- a/nvdaHelper/remote/nvdaHelperRemote.def +++ b/nvdaHelper/remote/nvdaHelperRemote.def @@ -15,6 +15,7 @@ EXPORTS nvdaControllerInternal_logMessage nvdaControllerInternal_vbufChangeNotify nvdaControllerInternal_installAddonPackageFromPath + nvdaControllerInternal_handleRemoteURL nvdaController_testIfRunning nvdaController_speakText nvdaController_cancelSpeech From 654ce8db76b22a202dcba52af0e13b8013ff9fac Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 26 Jan 2025 21:14:23 -0700 Subject: [PATCH 068/203] Add handleRemoteURL function to nvdaControllerInternal --- nvdaHelper/local/nvdaControllerInternal.c | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nvdaHelper/local/nvdaControllerInternal.c b/nvdaHelper/local/nvdaControllerInternal.c index 8281a161d63..c69d877af40 100644 --- a/nvdaHelper/local/nvdaControllerInternal.c +++ b/nvdaHelper/local/nvdaControllerInternal.c @@ -70,6 +70,11 @@ error_status_t __stdcall nvdaControllerInternal_installAddonPackageFromPath(cons return _nvdaControllerInternal_installAddonPackageFromPath(addonPath); } +error_status_t(__stdcall *_nvdaControllerInternal_handleRemoteURL)(const wchar_t*); +error_status_t __stdcall nvdaControllerInternal_handleRemoteURL(const wchar_t* url) { + return _nvdaControllerInternal_handleRemoteURL(url); +} + error_status_t(__stdcall *_nvdaControllerInternal_drawFocusRectNotify)(const long, const long, const long, const long, const long); error_status_t __stdcall nvdaControllerInternal_drawFocusRectNotify(const long hwnd, const long left, const long top, const long right, const long bottom) { return _nvdaControllerInternal_drawFocusRectNotify(hwnd,left,top,right,bottom); From 0e352a58c9ead749a5eeaf7b15206332fff9b4c4 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 26 Jan 2025 22:08:41 -0700 Subject: [PATCH 069/203] refactor: Move beepSequence functionality to tones.py --- source/remoteClient/beepSequence.py | 35 -------------------------- source/remoteClient/cues.py | 8 +++--- source/tones.py | 39 ++++++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 41 deletions(-) delete mode 100644 source/remoteClient/beepSequence.py diff --git a/source/remoteClient/beepSequence.py b/source/remoteClient/beepSequence.py deleted file mode 100644 index 91c13280c47..00000000000 --- a/source/remoteClient/beepSequence.py +++ /dev/null @@ -1,35 +0,0 @@ -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/cues.py b/source/remoteClient/cues.py index 739e057662c..b669e9a7659 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -8,12 +8,14 @@ import globalVars import nvwave -import tones + + +from tones import beep, BeepSequence, beepSequenceAsync import ui from . import configuration -from .beepSequence import beepSequenceAsync, BeepSequence -local_beep = tones.beep + +local_beep = beep class Cue(TypedDict, total=False): diff --git a/source/tones.py b/source/tones.py index 1e9286a56ae..b0555f5a511 100644 --- a/source/tones.py +++ b/source/tones.py @@ -6,11 +6,16 @@ """Utilities to generate and play tones""" import atexit -import nvwave -import config -from logHandler import log +import collections +import threading +import time from ctypes import create_string_buffer +from typing import Tuple, Union + +import config import extensionPoints +import nvwave +from logHandler import log SAMPLE_RATE = 44100 @@ -87,3 +92,31 @@ def beep( generateBeep(buf, hz, length, left, right) player.stop() player.feed(buf.raw) + + +BeepSequenceElement = Union[int, Tuple[int, int]] # Either delay_ms or (frequency_hz, duration_ms) +BeepSequence = collections.abc.Iterable[BeepSequenceElement] + + +def beepSequence(*sequence: BeepSequenceElement) -> None: + """Play a simple synchronous monophonic beep sequence + A beep sequence is an iterable containing one of two kinds of elements. + An element consisting of a tuple of two items is interpreted as a frequency and duration. Note, this function plays beeps synchronously, unlike tones.beep + A single integer is assumed to be a delay in ms. + """ + for element in sequence: + if not isinstance(element, collections.abc.Sequence): + time.sleep(float(element) / 1000) + else: + freq, duration = element + time.sleep(float(duration) / 1000) + beep(freq, duration) + + +def beepSequenceAsync(*sequence: BeepSequenceElement) -> threading.Thread: + """Play an asynchronous beep sequence. + This is the same as `beepSequence`, except it runs in a thread.""" + thread = threading.Thread(target=beepSequence, args=sequence) + thread.daemon = True + thread.start() + return thread From 2b6af90419e81e55daef51168eeca95c9447bbf2 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 26 Jan 2025 22:10:49 -0700 Subject: [PATCH 070/203] camel-case URL Handler --- source/remoteClient/client.py | 6 +++--- source/remoteClient/{url_handler.py => urlHandler.py} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename source/remoteClient/{url_handler.py => urlHandler.py} (100%) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index c762f5fe63d..1ebf9bf5e4e 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -19,7 +19,7 @@ from gui.guiHelper import alwaysCallAfter from utils.security import isRunningOnSecureDesktop -from . import configuration, cues, dialogs, serializer, server, url_handler +from . import configuration, cues, dialogs, serializer, server, urlHandler from .connectionInfo import ConnectionInfo, ConnectionMode from .localMachine import LocalMachine from .menu import RemoteMenu @@ -61,7 +61,7 @@ def __init__( if not isRunningOnSecureDesktop(): self.menu: Optional[RemoteMenu] = RemoteMenu(self) self.connecting = False - url_handler.register_url_handler() + urlHandler.register_url_handler() self.masterTransport = None self.slaveTransport = None self.localControlServer = None @@ -106,7 +106,7 @@ def terminate(self): core.postNvdaStartup.unregister(self.performAutoconnect) inputCore.decide_handleRawKey.unregister(self.process_key_input) if not isInstalledCopy(): - url_handler.unregister_url_handler() + urlHandler.unregister_url_handler() def toggleMute(self): self.localMachine.isMuted = not self.localMachine.isMuted diff --git a/source/remoteClient/url_handler.py b/source/remoteClient/urlHandler.py similarity index 100% rename from source/remoteClient/url_handler.py rename to source/remoteClient/urlHandler.py From e736fb04c2f413009c05f7d16cd89b3eb2a4a093 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 27 Jan 2025 18:29:22 -0700 Subject: [PATCH 071/203] Update remote toggle gesture to NVDA+F11 --- source/globalCommands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/globalCommands.py b/source/globalCommands.py index 81c0da1c406..7711837577b 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4952,7 +4952,7 @@ def script_connectToRemote(self, gesture): # 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", + gesture="kb:NVDA+f11", ) def script_sendKeys(self, gesture): remoteClient.remoteClient.toggleRemoteKeyControl(gesture) From 7102dda45bbe6414628cf7df53a981991ac42385 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 27 Jan 2025 20:50:25 -0700 Subject: [PATCH 072/203] Migrate remote settings into main config file Use config migration system to move remote control settings from separate remote.ini into NVDA's main configuration: - Add "remote" section to base configuration spec - Increase schema version from 15 to 16 - Add migration code to automatically move settings from remote.ini to main config - Update remote client to use main config instead of separate file - Create backup of old remote.ini after migration - Remove standalone remote config handling code --- source/config/__init__.py | 1 + source/config/configSpec.py | 20 ++++++++- source/config/profileUpgradeSteps.py | 51 +++++++++++++++++++---- source/remoteClient/configuration.py | 61 +--------------------------- 4 files changed, 65 insertions(+), 68 deletions(-) diff --git a/source/config/__init__.py b/source/config/__init__.py index 68bd111f401..3cf19ed51b9 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -526,6 +526,7 @@ class ConfigManager(object): "update", "development", "addonStore", + "remote", } """ Sections that only apply to the base configuration; diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 4653f5ebc92..207aefb062b 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -13,7 +13,7 @@ #: provide an upgrade step (@see profileUpgradeSteps.py). An upgrade step does not need to be added when #: just adding a new element to (or removing from) the schema, only when old versions of the config #: (conforming to old schema versions) will not work correctly with the new schema. -latestSchemaVersion = 15 +latestSchemaVersion = 16 #: The configuration specification string #: @type: String @@ -339,6 +339,24 @@ showWarning = boolean(default=true) automaticUpdates = option("notify", "disabled", default="notify") baseServerURL = string(default="") + +# Remote Settings +[remote] + [[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) """ #: The configuration specification diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index 6eefe42f496..a4da660ac88 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -13,19 +13,22 @@ that no information is lost, while updating the ConfigObj to meet the requirements of the new schema. """ +import os + +import configobj.validate +from configobj import ConfigObj from logHandler import log + from config.configFlags import ( NVDAKey, - ShowMessages, - TetherTo, + OutputMode, + ReportCellBorders, ReportLineIndentation, ReportTableHeaders, - ReportCellBorders, - OutputMode, + ShowMessages, + TetherTo, TypingEcho, ) -import configobj.validate -from configobj import ConfigObj def upgradeConfigFrom_0_to_1(profile: ConfigObj) -> None: @@ -459,8 +462,8 @@ def _friendlyNameToEndpointId(friendlyName: str) -> str | None: :param friendlyName: Friendly name of the device to search for. :return: Endpoint ID string of the best match device, or `None` if no device with a matching friendly name is available. """ - from utils.mmdevice import _getOutputDevices from pycaw.constants import DEVICE_STATE + from utils.mmdevice import _getOutputDevices states = (DEVICE_STATE.ACTIVE, DEVICE_STATE.UNPLUGGED, DEVICE_STATE.DISABLED, DEVICE_STATE.NOTPRESENT) for state in states: @@ -500,3 +503,37 @@ def _convertTypingEcho(profile: ConfigObj, key: str) -> None: newValue = TypingEcho.EDIT_CONTROLS.value if oldValue else TypingEcho.OFF.value profile["keyboard"][key] = newValue log.debug(f"Converted '{key}' from {oldValue!r} to {newValue} ({TypingEcho(newValue).name}).") + + +def upgradeConfigFrom_15_to_16(profile: ConfigObj) -> None: + """Migrate remote.ini settings into the main config.""" + remoteIniPath = os.path.join(os.path.dirname(profile.filename), "remote.ini") + if not os.path.isfile(remoteIniPath): + return + + try: + remoteConfig = ConfigObj(remoteIniPath, encoding="UTF-8") + log.debug(f"Loading remote config from {remoteIniPath}") + except Exception: + log.error("Error loading remote.ini", exc_info=True) + return + + # Create remote section if it doesn't exist + if "remote" not in profile: + profile["remote"] = {} + + # Copy all sections from remote.ini + for section in remoteConfig: + if section not in profile["remote"]: + profile["remote"][section] = {} + profile["remote"][section].update(remoteConfig[section]) + + try: + # Backup the old file just in case + backupPath = remoteIniPath + ".old" + if os.path.exists(backupPath): + os.unlink(backupPath) + os.rename(remoteIniPath, backupPath) + log.debug(f"Backed up remote.ini to {backupPath}") + except Exception: + log.error("Error backing up remote.ini after migration", exc_info=True) diff --git a/source/remoteClient/configuration.py b/source/remoteClient/configuration.py index f4d47114281..438b64c7e05 100644 --- a/source/remoteClient/configuration.py +++ b/source/remoteClient/configuration.py @@ -3,58 +3,14 @@ # 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 + return config.conf["remote"] def write_connection_to_config(connection_info: ConnectionInfo): @@ -70,18 +26,3 @@ def write_connection_to_config(connection_info: ConnectionInfo): 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) From ac92db41fab8f3206f0d531f28ee5e52d1c083f8 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 18:27:03 -0700 Subject: [PATCH 073/203] Update Docstring Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/NVDAHelper.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/NVDAHelper.py b/source/NVDAHelper.py index 3da77a56e9f..5269a76aa4c 100755 --- a/source/NVDAHelper.py +++ b/source/NVDAHelper.py @@ -690,8 +690,9 @@ def nvdaControllerInternal_openConfigDirectory(): @WINFUNCTYPE(c_long, c_wchar_p) def nvdaControllerInternal_handleRemoteURL(url): """Handles a remote URL request from the slave process. - @param url: The nvdaremote:// URL to process - @return: 0 on success, -1 on failure + + :param url: The nvdaremote:// URL to process + :return: 0 on success, -1 on failure """ import queueHandler from remoteClient import connectionInfo, remoteClient as client From 99a154cee780bf094638c4747b4db3bc9d2e3633 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 19:11:06 -0700 Subject: [PATCH 074/203] Remove unnecessary import Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/NVDAHelper.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/NVDAHelper.py b/source/NVDAHelper.py index 5269a76aa4c..1379a419ba9 100755 --- a/source/NVDAHelper.py +++ b/source/NVDAHelper.py @@ -694,7 +694,6 @@ def nvdaControllerInternal_handleRemoteURL(url): :param url: The nvdaremote:// URL to process :return: 0 on success, -1 on failure """ - import queueHandler from remoteClient import connectionInfo, remoteClient as client try: From 6a387f9e0ef7dee38fb82fde57b973715b3b1209 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 19:14:04 -0700 Subject: [PATCH 075/203] Log when no remote.ini is found Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/config/profileUpgradeSteps.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/config/profileUpgradeSteps.py b/source/config/profileUpgradeSteps.py index a4da660ac88..e19ddd49eb8 100644 --- a/source/config/profileUpgradeSteps.py +++ b/source/config/profileUpgradeSteps.py @@ -509,6 +509,7 @@ def upgradeConfigFrom_15_to_16(profile: ConfigObj) -> None: """Migrate remote.ini settings into the main config.""" remoteIniPath = os.path.join(os.path.dirname(profile.filename), "remote.ini") if not os.path.isfile(remoteIniPath): + log.debug(f"No remote.ini found, no action taken. Checked {remoteIniPath}") return try: From f0170dfef970890d89ee6c8ea91f6d00efe99a63 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 19:04:44 -0700 Subject: [PATCH 076/203] Document mode in confspec --- source/config/configSpec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 207aefb062b..a1abbf520f7 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -347,7 +347,7 @@ [[controlserver]] autoconnect = boolean(default=False) self_hosted = boolean(default=False) - connection_type = integer(default=0) + connection_type = integer(default=0) # 0: slave, 1: master host = string(default="") port = integer(default=6837) key = string(default="") From f564a085974195cb55f348013bd41cfde54d953a Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 19:45:06 -0700 Subject: [PATCH 077/203] Add min/max to mode --- source/config/configSpec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index a1abbf520f7..529b6422824 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -347,7 +347,7 @@ [[controlserver]] autoconnect = boolean(default=False) self_hosted = boolean(default=False) - connection_type = integer(default=0) # 0: slave, 1: master + connection_type = integer(default=0, min=0, max=1) # 0: slave, 1: master host = string(default="") port = integer(default=6837) key = string(default="") From 399975c23f33f09c309c808e54cd257cffded8cd Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 19:50:27 -0700 Subject: [PATCH 078/203] Already Connected message Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/globalCommands.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/globalCommands.py b/source/globalCommands.py index 7711837577b..6c89c6525b2 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4945,6 +4945,8 @@ def script_disconnectFromRemote(self, gesture): @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) def script_connectToRemote(self, gesture): if remoteClient.remoteClient.isConnected() or remoteClient.remoteClient.connecting: + # Translators: A message indicating that the remote client is already connected. + ui.message(_("Already connected")) return remoteClient.remoteClient.doConnect() From 07e9f92b452fae6ed2561eb16c76a93466814f4a Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 19:51:15 -0700 Subject: [PATCH 079/203] fix message Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/globalCommands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/globalCommands.py b/source/globalCommands.py index 6c89c6525b2..ea0e12abcbc 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4931,7 +4931,7 @@ def script_copy_link(self, gesture): def script_disconnectFromRemote(self, gesture): if not remoteClient.remoteClient.isConnected: # Translators: A message indicating that the remote client is not connected. - ui.message(_("Not connected.")) + ui.message(_("Not connected")) return remoteClient.remoteClient.disconnect() From 9058dc3fc01e6982fdd4ed2a04471669433db027 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 20:02:17 -0700 Subject: [PATCH 080/203] Add docs and types to remoteClient.connectionInfo --- source/remoteClient/connectionInfo.py | 67 ++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py index e6d959eeb85..70e2b15ea2f 100644 --- a/source/remoteClient/connectionInfo.py +++ b/source/remoteClient/connectionInfo.py @@ -12,15 +12,33 @@ class URLParsingError(Exception): - """Raised if it's impossible to parse out the URL""" + """Exception raised when URL parsing fails. + + This exception is raised when the URL cannot be parsed due to missing or invalid components + such as hostname, key, or mode. + """ class ConnectionMode(StrEnum): + """Enum defining the connection mode for remote connections. + + :cvar MASTER: Controller/master mode + :cvar SLAVE: Controlled/slave mode + """ + MASTER = "master" SLAVE = "slave" class ConnectionState(StrEnum): + """Enum defining possible states of a remote connection. + + :cvar CONNECTED: Connection is established + :cvar DISCONNECTED: No connection is active + :cvar CONNECTING: Connection attempt in progress + :cvar DISCONNECTING: Disconnection in progress + """ + CONNECTED = "connected" DISCONNECTED = "disconnected" CONNECTING = "connecting" @@ -29,18 +47,36 @@ class ConnectionState(StrEnum): @dataclass class ConnectionInfo: + """Stores and manages remote connection information. + + This class handles connection details including hostname, mode, authentication key, + port number and security settings. It provides methods for URL generation and parsing. + + :param hostname: The remote host to connect to + :param mode: The connection mode (master/slave) + :param key: Authentication key for the connection + :param port: Port number to use, defaults to SERVER_PORT + :param insecure: Whether to allow insecure connections, defaults to False + """ + hostname: str mode: ConnectionMode key: str port: int = SERVER_PORT insecure: bool = False - def __post_init__(self): + def __post_init__(self) -> None: self.port = self.port or SERVER_PORT self.mode = ConnectionMode(self.mode) @classmethod - def fromURL(cls, url): + def fromURL(cls, url: str) -> "ConnectionInfo": + """Creates a ConnectionInfo instance from a URL string. + + :param url: The URL to parse + :raises URLParsingError: If URL cannot be parsed or contains invalid data + :return: A new ConnectionInfo instance + """ parsedUrl = urlparse(url) parsedQuery = parse_qs(parsedUrl.query) hostname = parsedUrl.hostname @@ -60,12 +96,21 @@ def fromURL(cls, url): raise URLParsingError("Invalid mode provided: %r" % mode) return cls(hostname=hostname, mode=mode, key=key, port=port, insecure=insecure) - def getAddress(self): + def getAddress(self) -> str: + """Gets the formatted address string. + + :return: Address string in format hostname:port, with IPv6 brackets if needed + """ # Handle IPv6 addresses by adding brackets if needed hostname = f"[{self.hostname}]" if ":" in self.hostname else self.hostname return f"{hostname}:{self.port}" - def _build_url(self, mode: ConnectionMode): + def _build_url(self, mode: ConnectionMode) -> str: + """Builds a URL string for the given mode. + + :param mode: The connection mode to use in the URL + :return: Complete URL string + """ # Build URL components netloc = protocol.hostPortToAddress((self.hostname, self.port)) params = { @@ -88,10 +133,18 @@ def _build_url(self, mode: ConnectionMode): ), ) - def getURLToConnect(self): + def getURLToConnect(self) -> str: + """Gets a URL for connecting with reversed mode. + + :return: URL string with opposite connection mode + """ # Flip master/slave for connection URL connect_mode = ConnectionMode.SLAVE if self.mode == ConnectionMode.MASTER else ConnectionMode.MASTER return self._build_url(connect_mode) - def getURL(self): + def getURL(self) -> str: + """Gets the URL representation of the current connection info. + + :return: URL string with current connection mode + """ return self._build_url(self.mode) From 1a37d368d33264efac7b80c2ac6c538989dfebd8 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 20:08:48 -0700 Subject: [PATCH 081/203] Copyright headers --- .../nvdaControllerInternal/nvdaControllerInternal.idl | 2 +- nvdaHelper/local/nvdaControllerInternal.c | 2 +- source/nvda_slave.pyw | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl index 49b8deb0862..9a9a28feab1 100644 --- a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl +++ b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.idl @@ -1,7 +1,7 @@ /* This file is a part of the NVDA project. URL: http://www.nvda-project.org/ -Copyright 2006-2018 NV Access Limited, rui Batista, Google LLC. +Copyright 2006-2025 NV Access Limited, rui Batista, Google LLC, Christopher Toth. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 2.0, as published by the Free Software Foundation. diff --git a/nvdaHelper/local/nvdaControllerInternal.c b/nvdaHelper/local/nvdaControllerInternal.c index c69d877af40..a2c6a241fa6 100644 --- a/nvdaHelper/local/nvdaControllerInternal.c +++ b/nvdaHelper/local/nvdaControllerInternal.c @@ -1,7 +1,7 @@ /* This file is a part of the NVDA project. URL: http://www.nvda-project.org/ -Copyright 2006-2018 NV Access Limited, rui Batista, Google LLC. +Copyright 2006-2025 NV Access Limited, rui Batista, Google LLC, Christopher Toth. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 2.0, as published by the Free Software Foundation. diff --git a/source/nvda_slave.pyw b/source/nvda_slave.pyw index e9adc89bb6a..83238566863 100755 --- a/source/nvda_slave.pyw +++ b/source/nvda_slave.pyw @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2009-2023 NV Access Limited, Cyrille Bougot +# Copyright (C) 2009-2025 NV Access Limited, Cyrille Bougot, Christopher Toth # This file is covered by the GNU General Public License. # See the file COPYING for more details. From 12baa5be78f380bc7a24b4f10e34ae3171fb4c84 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 20:18:23 -0700 Subject: [PATCH 082/203] camelCase settings attributes --- source/gui/settingsDialogs.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index f745b381a7e..44933171df5 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -3342,8 +3342,8 @@ class RemoteSettingsPanel(SettingsPanel): host: wx.TextCtrl port: wx.SpinCtrl key: wx.TextCtrl - play_sounds: wx.CheckBox - delete_fingerprints: wx.Button + playSounds: wx.CheckBox + deleteFingerprints: wx.Button def makeSettings(self, settingsSizer): self.config = configuration.get_config() @@ -3356,7 +3356,6 @@ def makeSettings(self, settingsSizer): ) 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, @@ -3395,12 +3394,12 @@ def makeSettings(self, settingsSizer): 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) + self.playSounds = wx.CheckBox(self, wx.ID_ANY, label=_("Play sounds instead of beeps")) + sHelper.addItem(self.playSounds) # 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.deleteFingerprints = wx.Button(self, wx.ID_ANY, label=_("Delete all trusted fingerprints")) + self.deleteFingerprints.Bind(wx.EVT_BUTTON, self.on_delete_fingerprints) + sHelper.addItem(self.deleteFingerprints) self.set_from_config() def on_autoconnect(self, evt: wx.CommandEvent) -> None: @@ -3429,7 +3428,7 @@ def set_from_config(self) -> None: self.port.SetValue(str(cs["port"])) self.key.SetValue(cs["key"]) self.set_controls() - self.play_sounds.SetValue(self.config["ui"]["play_sounds"]) + self.playSounds.SetValue(self.config["ui"]["play_sounds"]) def on_delete_fingerprints(self, evt: wx.CommandEvent) -> None: if ( @@ -3483,7 +3482,7 @@ def onSave(self): else: cs["port"] = int(self.port.GetValue()) cs["key"] = self.key.GetValue() - self.config["ui"]["play_sounds"] = self.play_sounds.GetValue() + self.config["ui"]["play_sounds"] = self.playSounds.GetValue() class TouchInteractionPanel(SettingsPanel): From 8b5d913b42d392d63a928407de8b2791bea5c62e Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 20:22:05 -0700 Subject: [PATCH 083/203] Remove trailing whitespace from nvdaControllerInternal_handleRemoteURL docstring --- source/NVDAHelper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/NVDAHelper.py b/source/NVDAHelper.py index 1379a419ba9..61beac72ec9 100755 --- a/source/NVDAHelper.py +++ b/source/NVDAHelper.py @@ -690,7 +690,7 @@ def nvdaControllerInternal_openConfigDirectory(): @WINFUNCTYPE(c_long, c_wchar_p) def nvdaControllerInternal_handleRemoteURL(url): """Handles a remote URL request from the slave process. - + :param url: The nvdaremote:// URL to process :return: 0 on success, -1 on failure """ From 3fd0ce89cd72cfe0e66356fe354c6852f4daa092 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 20:28:55 -0700 Subject: [PATCH 084/203] Upcase `RemoteConnectionType` enum constants and add types and docstrings --- source/remoteClient/bridge.py | 8 ++-- source/remoteClient/client.py | 8 ++-- source/remoteClient/dialogs.py | 2 +- source/remoteClient/protocol.py | 55 ++++++++++++----------- source/remoteClient/secureDesktop.py | 12 ++--- source/remoteClient/server.py | 6 +-- source/remoteClient/session.py | 66 ++++++++++++++-------------- source/remoteClient/transport.py | 6 +-- 8 files changed, 83 insertions(+), 80 deletions(-) diff --git a/source/remoteClient/bridge.py b/source/remoteClient/bridge.py index 2f697961ee9..cac9cc9dc0d 100644 --- a/source/remoteClient/bridge.py +++ b/source/remoteClient/bridge.py @@ -49,10 +49,10 @@ class BridgeTransport: """ excluded: Set[RemoteMessageType] = { - RemoteMessageType.client_joined, - RemoteMessageType.client_left, - RemoteMessageType.channel_joined, - RemoteMessageType.set_braille_info, + RemoteMessageType.CLIENT_JOINED, + RemoteMessageType.CLIENT_LEFT, + RemoteMessageType.CHANNEL_JOINED, + RemoteMessageType.SET_BRAILLE_INFO, } def __init__(self, t1: Transport, t2: Transport) -> None: diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 1ebf9bf5e4e..645731e9527 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -125,7 +125,7 @@ def pushClipboard(self): ui.message(_("Not connected.")) return try: - connector.send(RemoteMessageType.set_clipboard_text, text=api.getClipData()) + connector.send(RemoteMessageType.SET_CLIPBOARD_TEXT, text=api.getClipData()) cues.clipboard_pushed() except TypeError: log.exception("Unable to push clipboard") @@ -143,7 +143,7 @@ def sendSAS(self): if self.masterTransport is None: log.error("No master transport to send SAS") return - self.masterTransport.send(RemoteMessageType.send_SAS) + self.masterTransport.send(RemoteMessageType.SEND_SAS) def connect(self, connectionInfo: ConnectionInfo): log.info( @@ -383,7 +383,7 @@ def process_key_input(self, vkCode=None, scanCode=None, extended=None, pressed=N wx.CallAfter(script, gesture) return False self.masterTransport.send( - RemoteMessageType.key, + RemoteMessageType.KEY, vk_code=vkCode, extended=extended, pressed=pressed, @@ -413,7 +413,7 @@ def releaseKeys(self): # release all pressed keys in the guest. for k in self.keyModifiers: self.masterTransport.send( - RemoteMessageType.key, + RemoteMessageType.KEY, vk_code=k[0], extended=k[1], pressed=False, diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 7e24381f2eb..254e90e947e 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -63,7 +63,7 @@ def generateKeyCommand(self, insecure: bool = False) -> None: serializer=serializer.JSONSerializer(), insecure=insecure, ) - self.keyConnector.registerInbound(RemoteMessageType.generate_key, self.handleKeyGenerated) + self.keyConnector.registerInbound(RemoteMessageType.GENERATE_KEY, self.handleKeyGenerated) self.keyConnector.transportCertificateAuthenticationFailed.register(self.handleCertificateFailed) t = threading.Thread(target=self.keyConnector.run) t.start() diff --git a/source/remoteClient/protocol.py b/source/remoteClient/protocol.py index 9b8b44aaafa..f16bba20d33 100644 --- a/source/remoteClient/protocol.py +++ b/source/remoteClient/protocol.py @@ -11,38 +11,38 @@ 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" + 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" + 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" + 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" + SET_CLIPBOARD_TEXT = "set_clipboard_text" # System Messages - motd = "motd" - version_mismatch = "version_mismatch" - ping = "ping" - error = "error" - nvda_not_connected = ( + MOTD = "motd" + VERSION_MISMATCH = "version_mismatch" + PINGping = "ping" + ERROR = "error" + NVDA_NOT_CONNECTED = ( "nvda_not_connected" # This was added in version 2 but never implemented on the server ) @@ -51,7 +51,7 @@ class RemoteMessageType(Enum): URL_PREFIX = "nvdaremote://" -def addressToHostPort(addr): +def addressToHostPort(addr) -> tuple: """Converts an address such as google.com:80 into a tuple of (address, port). If no port is given, use SERVER_PORT.""" addr = urllib.parse.urlparse("//" + addr) @@ -59,7 +59,10 @@ def addressToHostPort(addr): return (addr.hostname, port) -def hostPortToAddress(hostPort): +def hostPortToAddress(hostPort: tuple) -> str: + """Converts a tuple of (address, port) into a string such as google.com:80. + If the port is SERVER_PORT, it is omitted + """ host, port = hostPort if ":" in host: host = "[" + host + "]" diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 32e4b723317..5a86ddafa73 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -102,10 +102,10 @@ def slaveSession(self, session: Optional[SlaveSession]) -> None: 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) + transport.unregisterInbound(RemoteMessageType.SET_BRAILLE_INFO, self._onMasterDisplayChange) self._slaveSession = session session.transport.registerInbound( - RemoteMessageType.set_braille_info, + RemoteMessageType.SET_BRAILLE_INFO, self._onMasterDisplayChange, ) @@ -149,9 +149,9 @@ def enterSecureDesktop(self) -> None: insecure=True, connectionType=ConnectionMode.MASTER, ) - self.sdRelay.registerInbound(RemoteMessageType.client_joined, self._onMasterDisplayChange) + self.sdRelay.registerInbound(RemoteMessageType.CLIENT_JOINED, self._onMasterDisplayChange) self.slaveSession.transport.registerInbound( - RemoteMessageType.set_braille_info, + RemoteMessageType.SET_BRAILLE_INFO, self._onMasterDisplayChange, ) @@ -187,7 +187,7 @@ def leaveSecureDesktop(self) -> None: if self.slaveSession is not None and self.slaveSession.transport is not None: self.slaveSession.transport.unregisterInbound( - RemoteMessageType.set_braille_info, + RemoteMessageType.SET_BRAILLE_INFO, self._onMasterDisplayChange, ) self.slaveSession.setDisplaySize() @@ -233,7 +233,7 @@ def _onMasterDisplayChange(self, **kwargs: Any) -> None: 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, + type=RemoteMessageType.SET_DISPLAY_SIZE, sizes=self.slaveSession.masterDisplaySizes, ) else: diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 733d7ae09da..20144620862 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -287,7 +287,7 @@ def run(self) -> None: if time.time() - self.lastPingTime >= self.PING_TIME: for client in self.clients.values(): if client.authenticated: - client.send(type=RemoteMessageType.ping) + client.send(type=RemoteMessageType.PINGping) self.lastPingTime = time.time() def acceptNewConnection(self, sock: ssl.SSLSocket) -> None: @@ -402,7 +402,7 @@ def do_join(self, obj: Dict[str, Any]) -> None: if password != self.server.password: log.warning(f"Failed authentication attempt from client {self.id}") self.send( - type=RemoteMessageType.error, + type=RemoteMessageType.ERROR, message="incorrect_password", ) self.close() @@ -418,7 +418,7 @@ def do_join(self, obj: Dict[str, Any]) -> None: clients.append(c.asDict()) client_ids.append(c.id) self.send( - type=RemoteMessageType.channel_joined, + type=RemoteMessageType.CHANNEL_JOINED, channel=self.server.password, user_ids=client_ids, clients=clients, diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 5774cc45638..adb233ce608 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -123,20 +123,20 @@ def __init__( self.callbacksAdded = False self.transport = transport self.transport.registerInbound( - RemoteMessageType.version_mismatch, + RemoteMessageType.VERSION_MISMATCH, self.handleVersionMismatch, ) - self.transport.registerInbound(RemoteMessageType.motd, self.handleMOTD) + self.transport.registerInbound(RemoteMessageType.MOTD, self.handleMOTD) self.transport.registerInbound( - RemoteMessageType.set_clipboard_text, + RemoteMessageType.SET_CLIPBOARD_TEXT, self.localMachine.setClipboardText, ) self.transport.registerInbound( - RemoteMessageType.client_joined, + RemoteMessageType.CLIENT_JOINED, self.handleClientConnected, ) self.transport.registerInbound( - RemoteMessageType.client_left, + RemoteMessageType.CLIENT_LEFT, self.handleClientDisconnected, ) @@ -267,33 +267,33 @@ def __init__( ) -> None: super().__init__(localMachine, transport) self.transport.registerInbound( - RemoteMessageType.key, + RemoteMessageType.KEY, self.localMachine.sendKey, ) self.masters = defaultdict(dict) self.masterDisplaySizes = [] self.transport.transportClosing.register(self.handleTransportClosing) self.transport.registerInbound( - RemoteMessageType.channel_joined, + RemoteMessageType.CHANNEL_JOINED, self.handleChannelJoined, ) self.transport.registerInbound( - RemoteMessageType.set_braille_info, + RemoteMessageType.SET_BRAILLE_INFO, self.handleBrailleInfo, ) self.transport.registerInbound( - RemoteMessageType.set_display_size, + RemoteMessageType.SET_DISPLAY_SIZE, self.setDisplaySize, ) braille.filter_displaySize.register( self.localMachine.handleFilterDisplaySize, ) self.transport.registerInbound( - RemoteMessageType.braille_input, + RemoteMessageType.BRAILLE_INPUT, self.localMachine.brailleInput, ) self.transport.registerInbound( - RemoteMessageType.send_SAS, + RemoteMessageType.SEND_SAS, self.localMachine.sendSAS, ) @@ -302,14 +302,14 @@ def registerCallbacks(self) -> None: return self.transport.registerOutbound( tones.decide_beep, - RemoteMessageType.tone, + RemoteMessageType.TONE, ) self.transport.registerOutbound( speechCanceled, - RemoteMessageType.cancel, + RemoteMessageType.CANCEL, ) - self.transport.registerOutbound(decide_playWaveFile, RemoteMessageType.wave) - self.transport.registerOutbound(post_speechPaused, RemoteMessageType.pause_speech) + 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 @@ -317,10 +317,10 @@ def registerCallbacks(self) -> None: 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) + 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 @@ -406,7 +406,7 @@ def sendSpeech(self, speechSequence: List[Any], priority: Optional[str]) -> None to master instances for speaking. """ self.transport.send( - RemoteMessageType.speak, + RemoteMessageType.SPEAK, sequence=self._filterUnsupportedSpeechCommands( speechSequence, ), @@ -415,7 +415,7 @@ def sendSpeech(self, speechSequence: List[Any], priority: Optional[str]) -> None def pauseSpeech(self, switch: bool) -> None: """Toggle speech pause state on master instances.""" - self.transport.send(type=RemoteMessageType.pause_speech, switch=switch) + self.transport.send(type=RemoteMessageType.PAUSE_SPEECH, switch=switch) def display(self, cells: List[int]) -> None: """Forward braille display content to master instances. @@ -424,7 +424,7 @@ def display(self, cells: List[int]) -> None: """ # Only send braille data when there are controlling machines with a braille display if self.hasBrailleMasters(): - self.transport.send(type=RemoteMessageType.display, cells=cells) + self.transport.send(type=RemoteMessageType.DISPLAY, cells=cells) def hasBrailleMasters(self) -> bool: """Check if any connected masters have braille displays. @@ -462,39 +462,39 @@ def __init__( super().__init__(localMachine, transport) self.slaves = defaultdict(dict) self.transport.registerInbound( - RemoteMessageType.speak, + RemoteMessageType.SPEAK, self.localMachine.speak, ) self.transport.registerInbound( - RemoteMessageType.cancel, + RemoteMessageType.CANCEL, self.localMachine.cancelSpeech, ) self.transport.registerInbound( - RemoteMessageType.pause_speech, + RemoteMessageType.PAUSE_SPEECH, self.localMachine.pauseSpeech, ) self.transport.registerInbound( - RemoteMessageType.tone, + RemoteMessageType.TONE, self.localMachine.beep, ) self.transport.registerInbound( - RemoteMessageType.wave, + RemoteMessageType.WAVE, self.localMachine.playWave, ) self.transport.registerInbound( - RemoteMessageType.display, + RemoteMessageType.DISPLAY, self.localMachine.display, ) self.transport.registerInbound( - RemoteMessageType.nvda_not_connected, + RemoteMessageType.NVDA_NOT_CONNECTED, self.handleNVDANotConnected, ) self.transport.registerInbound( - RemoteMessageType.channel_joined, + RemoteMessageType.CHANNEL_JOINED, self.handleChannel_joined, ) self.transport.registerInbound( - RemoteMessageType.set_braille_info, + RemoteMessageType.SET_BRAILLE_INFO, self.sendBrailleInfo, ) @@ -561,7 +561,7 @@ def sendBrailleInfo( displaySize if displaySize else 0, ) self.transport.send( - type=RemoteMessageType.set_braille_info, + type=RemoteMessageType.SET_BRAILLE_INFO, name=display.name, numCells=displaySize, ) @@ -611,7 +611,7 @@ def handle_decide_executeGesture( 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) + self.transport.send(type=RemoteMessageType.BRAILLE_INPUT, **dict) return False else: return True diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 7a34f3a2ca9..9efb819981a 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -661,15 +661,15 @@ def create(cls, connection_info: ConnectionInfo, serializer: Serializer) -> "Rel ) def onConnected(self) -> None: - self.send(RemoteMessageType.protocol_version, version=self.protocol_version) + self.send(RemoteMessageType.PROTOCOL_VERSION, version=self.protocol_version) if self.channel is not None: self.send( - RemoteMessageType.join, + RemoteMessageType.JOIN, channel=self.channel, connection_type=self.connectionType, ) else: - self.send(RemoteMessageType.generate_key) + self.send(RemoteMessageType.GENERATE_KEY) class ConnectorThread(threading.Thread): From 97942f958169cc3d3a3f8833abfdd1bc4281840a Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 20:54:05 -0700 Subject: [PATCH 085/203] Use f-string for ipv6 Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/protocol.py b/source/remoteClient/protocol.py index f16bba20d33..f8c96fdc003 100644 --- a/source/remoteClient/protocol.py +++ b/source/remoteClient/protocol.py @@ -65,7 +65,7 @@ def hostPortToAddress(hostPort: tuple) -> str: """ host, port = hostPort if ":" in host: - host = "[" + host + "]" + host = f"[{host}]" if port != SERVER_PORT: return host + ":" + str(port) return host From 715dbe2b9682d71ceec56294e8972040ea721295 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 28 Jan 2025 20:56:03 -0700 Subject: [PATCH 086/203] f-string for host:port Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/protocol.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/protocol.py b/source/remoteClient/protocol.py index f8c96fdc003..31bdce6119e 100644 --- a/source/remoteClient/protocol.py +++ b/source/remoteClient/protocol.py @@ -67,5 +67,5 @@ def hostPortToAddress(hostPort: tuple) -> str: if ":" in host: host = f"[{host}]" if port != SERVER_PORT: - return host + ":" + str(port) + return f"{host}:{port}" return host From 530dfb9bbb019f4b8dc9e9d46dcf6757fafa7d6f Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 30 Jan 2025 18:34:18 -0700 Subject: [PATCH 087/203] Improve transport guard Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 9efb819981a..cd4f9f5badb 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -79,7 +79,7 @@ def remoteBridge(self, *args: Any, **kwargs: Any) -> bool: if self.filter: # Filter should transform args/kwargs into just the kwargs needed for the message kwargs = self.filter(*args, **kwargs) - if self.transport: + if self.transport is not None: self.transport.send(self.messageType, **kwargs) return True From b47c2585204dbaf7a29951f94db9b4805c324a4e Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 30 Jan 2025 18:35:34 -0700 Subject: [PATCH 088/203] Improve filter guard Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index cd4f9f5badb..b5c3e4329d6 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -76,7 +76,7 @@ def remoteBridge(self, *args: Any, **kwargs: Any) -> bool: Handles calling the filter if present and sending the message. Always returns True to allow other handlers to process the event. """ - if self.filter: + if self.filter is not None: # Filter should transform args/kwargs into just the kwargs needed for the message kwargs = self.filter(*args, **kwargs) if self.transport is not None: From fe7b4a446ad8ac335386731bde88ae0c75492c2d Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 30 Jan 2025 18:41:44 -0700 Subject: [PATCH 089/203] Update copy link script Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/globalCommands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/globalCommands.py b/source/globalCommands.py index ea0e12abcbc..aa9395bbd8a 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4916,7 +4916,7 @@ def script_push_clipboard(self, gesture): description=_("""Copies a link to the remote session to the clipboard"""), category=SCRCAT_REMOTE, ) - def script_copy_link(self, gesture): + def script_copyRemoteLink(self, gesture: "inputCore.InputGesture"): remoteClient.remoteClient.copyLink() # Translators: A message indicating that a link has been copied to the clipboard. ui.message(_("Copied link")) From 8307fca773dc19056251c294903bab7b661a3d85 Mon Sep 17 00:00:00 2001 From: "Christopher Toth (aider)" Date: Thu, 30 Jan 2025 18:38:01 -0700 Subject: [PATCH 090/203] docs: Update documentation to Sphinx-style docstrings --- source/remoteClient/serializer.py | 66 +++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/source/remoteClient/serializer.py b/source/remoteClient/serializer.py index e9340d24f80..630d33b25f0 100644 --- a/source/remoteClient/serializer.py +++ b/source/remoteClient/serializer.py @@ -5,15 +5,17 @@ """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. +This module handles serializing and deserializing messages between NVDA instances. +It provides special handling for speech commands and other NVDA-specific data types. + +The module provides: + * A generic :class:`Serializer` interface + * A concrete :class:`JSONSerializer` implementation for NVDA Remote messages The serialization format supports: -- Basic JSON data types -- Speech command objects -- Custom message types via the 'type' field + * Basic JSON data types + * Speech command objects + * Custom message types via the 'type' field """ from abc import ABCMeta, abstractmethod @@ -36,6 +38,10 @@ class Serializer(metaclass=ABCMeta): Defines the interface for serializing messages between NVDA instances. Concrete implementations should handle converting Python objects to/from a wire format suitable for network transmission. + + Note: + This is an abstract base class. Subclasses must implement + :meth:`serialize` and :meth:`deserialize`. """ @abstractmethod @@ -43,11 +49,14 @@ 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 + type (str, optional): Message type identifier, used for routing + **obj: Message payload as keyword arguments Returns: - Serialized message as bytes + bytes: Serialized message as bytes + + Raises: + NotImplementedError: Must be implemented by subclasses """ raise NotImplementedError @@ -56,10 +65,13 @@ def deserialize(self, data: bytes) -> JSONDict: """Convert received bytes back into a message dict. Args: - data: Raw message bytes to deserialize + data (bytes): Raw message bytes to deserialize Returns: - Dict containing the deserialized message + Dict[str, Any]: Dict containing the deserialized message + + Raises: + NotImplementedError: Must be implemented by subclasses """ raise NotImplementedError @@ -70,6 +82,9 @@ class JSONSerializer(Serializer): 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. + + Attributes: + SEP (bytes): Message separator for streaming protocols """ SEP: bytes = b"\n" # Message separator for streaming protocols @@ -127,11 +142,11 @@ def default(self, obj: Any) -> Any: """Convert speech commands to serializable format. Args: - obj: Object to serialize + obj (Any): Object to serialize Returns: - List containing [class_name, instance_vars] for speech commands, - or default JSON encoding for other types + Any: For speech commands, returns a list containing [class_name, instance_vars]. + For other types, returns the default JSON encoding. """ if is_subclass_or_instance(obj, SEQUENCE_CLASSES): return [obj.__class__.__name__, obj.__dict__] @@ -145,11 +160,17 @@ def is_subclass_or_instance(unknown: Any, possible: Union[Type[T], tuple[Type[T] during serialization. Args: - unknown: Object or type to check - possible: Type or tuple of types to check against + unknown (Any): Object or type to check + possible (Union[Type[T], tuple[Type[T], ...]]): Type or tuple of types to check against Returns: - True if unknown is a subclass or instance of possible + bool: True if unknown is a subclass or instance of possible + + Example: + >>> is_subclass_or_instance(str, (int, str)) + True + >>> is_subclass_or_instance("hello", (int, str)) + True """ try: return issubclass(unknown, possible) @@ -164,11 +185,14 @@ def as_sequence(dct: JSONDict) -> JSONDict: commands back into their original object form. Args: - dct: Dict containing potentially serialized speech commands + dct (JSONDict): Dict containing potentially serialized speech commands Returns: - Dict with reconstructed speech command objects if applicable, - otherwise returns the input unchanged + JSONDict: Dict with reconstructed speech command objects if applicable, + otherwise returns the input unchanged + + Warning: + Logs a warning if an unknown sequence type is encountered """ if not ("type" in dct and dct["type"] == "speak" and "sequence" in dct): return dct From 63543f2038e7871711eaaa49cb2e8ef9108585c5 Mon Sep 17 00:00:00 2001 From: "Christopher Toth (aider)" Date: Thu, 30 Jan 2025 19:15:56 -0700 Subject: [PATCH 091/203] docs: Update docstrings to use Sphinx-style formatting --- source/remoteClient/bridge.py | 68 +++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/source/remoteClient/bridge.py b/source/remoteClient/bridge.py index cac9cc9dc0d..7de15b2a4a1 100644 --- a/source/remoteClient/bridge.py +++ b/source/remoteClient/bridge.py @@ -3,26 +3,27 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -""" -Bridge Transport Module -====================== +"""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 + +* 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 + Create and use a bridge between two transports:: + + 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 @@ -34,18 +35,22 @@ class BridgeTransport: """A bridge between two NVDA Remote transport instances. - This class creates a bidirectional bridge between two Transport instances, + 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 + Automatically sets up message handlers for all RemoteMessageTypes and manages their lifecycle. - Attributes: - excluded (Set[RemoteMessageType]): 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 + :ivar excluded: Message types that should not be forwarded between transports. + By default includes connection management messages that should remain local. + :type excluded: Set[RemoteMessageType] + :ivar t1: First transport instance to bridge + :type t1: Transport + :ivar t2: Second transport instance to bridge + :type t2: Transport + :ivar t1_callbacks: Storage for t1's message handlers + :type t1_callbacks: Dict[RemoteMessageType, callable] + :ivar t2_callbacks: Storage for t2's message handlers + :type t2_callbacks: Dict[RemoteMessageType, callable] """ excluded: Set[RemoteMessageType] = { @@ -61,9 +66,10 @@ def __init__(self, t1: Transport, t2: Transport) -> None: 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 + :param t1: First transport instance to bridge + :type t1: Transport + :param t2: Second transport instance to bridge + :type t2: Transport """ self.t1 = t1 self.t2 = t2 @@ -85,12 +91,12 @@ def makeCallback(self, targetTransport: Transport, messageType: RemoteMessageTyp 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 + :param targetTransport: Transport instance to forward messages to + :type targetTransport: Transport + :param messageType: Type of message this callback will handle + :type messageType: RemoteMessageType + :return: A callback function that forwards messages to the target transport + :rtype: callable """ def callback(*args, **kwargs): @@ -105,6 +111,8 @@ def disconnect(self): 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. + + :return: None """ for messageType in RemoteMessageType: self.t1.unregisterInbound(messageType, self.t2Callbacks[messageType]) From 3fa9992b0beacc2595a1f3ac5e596e84fd70db99 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 30 Jan 2025 19:17:56 -0700 Subject: [PATCH 092/203] Use dict and set typing directly --- source/remoteClient/bridge.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/source/remoteClient/bridge.py b/source/remoteClient/bridge.py index 7de15b2a4a1..a41f4f58c37 100644 --- a/source/remoteClient/bridge.py +++ b/source/remoteClient/bridge.py @@ -12,7 +12,7 @@ The bridge acts as an intermediary layer that: * Connects two transport instances -* Routes messages between them +* Routes messages between them * Filters out specific message types that shouldn't be forwarded * Manages the lifecycle of message handlers @@ -20,14 +20,12 @@ Create and use a bridge between two transports:: transport1 = TCPTransport(serializer, addr1) - transport2 = TCPTransport(serializer, addr2) + 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 @@ -44,7 +42,7 @@ class BridgeTransport: By default includes connection management messages that should remain local. :type excluded: Set[RemoteMessageType] :ivar t1: First transport instance to bridge - :type t1: Transport + :type t1: Transport :ivar t2: Second transport instance to bridge :type t2: Transport :ivar t1_callbacks: Storage for t1's message handlers @@ -53,7 +51,7 @@ class BridgeTransport: :type t2_callbacks: Dict[RemoteMessageType, callable] """ - excluded: Set[RemoteMessageType] = { + excluded: set[RemoteMessageType] = { RemoteMessageType.CLIENT_JOINED, RemoteMessageType.CLIENT_LEFT, RemoteMessageType.CHANNEL_JOINED, @@ -68,14 +66,14 @@ def __init__(self, t1: Transport, t2: Transport) -> None: :param t1: First transport instance to bridge :type t1: Transport - :param t2: Second transport instance to bridge + :param t2: Second transport instance to bridge :type t2: Transport """ self.t1 = t1 self.t2 = t2 # Store callbacks for each message type - self.t1Callbacks: Dict[RemoteMessageType, callable] = {} - self.t2Callbacks: Dict[RemoteMessageType, callable] = {} + self.t1Callbacks: dict[RemoteMessageType, callable] = {} + self.t2Callbacks: dict[RemoteMessageType, callable] = {} for messageType in RemoteMessageType: # Create and store callbacks From 3f01593dfb663e77c5dfde7cc262639560808cdb Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 30 Jan 2025 22:14:02 -0700 Subject: [PATCH 093/203] f-string Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index d9bb4914205..2867c7f518e 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -94,7 +94,7 @@ def findScript(self): if cls == "GlobalPlugin": for plugin in globalPluginHandler.runningPlugins: if module == plugin.__module__: - func = getattr(plugin, "script_%s" % scriptName, None) + func = getattr(plugin, f"script_{scriptName}", None) if func: return func From c5f92986fc068eb198684907f6530ed08eb42876 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 30 Jan 2025 22:22:12 -0700 Subject: [PATCH 094/203] Sphinx-style docs for `LocalMachine` --- source/remoteClient/localMachine.py | 197 +++++++++++++++------------- 1 file changed, 107 insertions(+), 90 deletions(-) diff --git a/source/remoteClient/localMachine.py b/source/remoteClient/localMachine.py index d240a92a9a2..62f59839666 100644 --- a/source/remoteClient/localMachine.py +++ b/source/remoteClient/localMachine.py @@ -10,21 +10,21 @@ 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) +: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. +.. 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 @@ -61,12 +61,12 @@ def setSpeechCancelledToFalse() -> None: 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. + .. 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` + .. seealso:: + :meth:`LocalMachine.speak` """ # workaround as beenCanceled is readonly as of NVDA#12395 speech.speech._speechState.beenCanceled = False @@ -87,29 +87,33 @@ class LocalMachine: 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 + :ivar isMuted: When True, most remote commands will be ignored, providing + a way to temporarily disable remote control while maintaining the connection + :type isMuted: bool + :ivar receivingBraille: When True, braille output comes from the remote + machine instead of local NVDA. This affects both display output and input routing + :type receivingBraille: bool + :ivar _cachedSizes: Cached braille display sizes from remote + machines, used to negotiate the optimal display size for sharing + :type _cachedSizes: Optional[List[int]] + + .. 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. + + .. seealso:: + - :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. + + .. note:: + The local machine starts unmuted with local braille enabled. """ self.isMuted: bool = False self.receivingBraille: bool = False @@ -119,13 +123,22 @@ def __init__(self) -> None: 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. + .. note:: + Unregisters the braille display handler to prevent memory leaks and + ensure proper cleanup when the remote connection ends. """ braille.decide_enabled.unregister(self.handleDecideEnabled) def playWave(self, fileName: str) -> None: - """Instructed by remote machine to play a wave file.""" + """Play a wave file on the local machine. + + :param fileName: Path to the wave file to play + :type fileName: str + + .. note:: + Sound playback is ignored if the local machine is muted. + The file must exist on the local system. + """ if self.isMuted: return if os.path.exists(fileName): @@ -134,14 +147,17 @@ def playWave(self, fileName: str) -> None: 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. + :param hz: Frequency of the beep in Hertz + :type hz: float + :param length: Duration of the beep in milliseconds + :type length: int + :param left: Left channel volume (0-100), defaults to 50% + :type left: int + :param right: Right channel volume (0-100), defaults to 50% + :type right: int + + .. note:: + Beeps are ignored if the local machine is muted. """ if self.isMuted: return @@ -150,9 +166,9 @@ def beep(self, hz: float, length: int, left: int = 50, right: int = 50) -> None: 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. + .. note:: + Speech cancellation is ignored if the local machine is muted. + Uses wx.CallAfter to ensure thread-safe execution. """ if self.isMuted: return @@ -161,12 +177,12 @@ def cancelSpeech(self) -> None: def pauseSpeech(self, switch: bool) -> None: """Pause or resume speech on the local machine. - Args: - switch: True to pause speech, False to resume + :param switch: True to pause speech, False to resume + :type switch: bool - Note: - Speech control is ignored if the local machine is muted. - Uses wx.CallAfter to ensure thread-safe execution. + .. note:: + Speech control is ignored if the local machine is muted. + Uses wx.CallAfter to ensure thread-safe execution. """ if self.isMuted: return @@ -182,14 +198,14 @@ def speak( 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. + :param sequence: List of speech sequences (text and commands) to speak + :type sequence: SpeechSequence + :param priority: Speech priority level, defaults to NORMAL + :type priority: Spri + .. 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 @@ -202,17 +218,18 @@ def display(self, cells: List[int]) -> None: 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) + :param cells: List of braille cells as integers (0-255) + :type cells: List[int] + + .. note:: + Only processes cells when: - Note: - Only processes cells when: - - receivingBraille is True (display sharing is enabled) - - Local display is connected (displaySize > 0) - - Remote cells fit on local display + - 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. + 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 @@ -228,11 +245,11 @@ def brailleInput(self, **kwargs: Dict[str, Any]) -> None: 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 + :param kwargs: Gesture parameters passed to BrailleInputGesture + :type kwargs: Dict[str, Any] - Note: - Silently ignores gestures that have no associated action. + .. note:: + Silently ignores gestures that have no associated action. """ try: inputCore.manager.executeGesture(input.BrailleInputGesture(**kwargs)) @@ -242,8 +259,8 @@ def brailleInput(self, **kwargs: Dict[str, Any]) -> None: 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 + :param sizes: List of display sizes (cells) from remote machines + :type sizes: List[int] """ self._cachedSizes = sizes @@ -253,11 +270,10 @@ def handleFilterDisplaySize(self, value: int) -> int: 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 + :param value: Local display size in cells + :type value: int + :returns: The negotiated display size to use + :rtype: int """ if not self._cachedSizes: return value @@ -270,8 +286,8 @@ def handleFilterDisplaySize(self, value: int) -> int: def handleDecideEnabled(self) -> bool: """Determine if the local braille display should be enabled. - Returns: - bool: False if receiving remote braille, True otherwise + :returns: False if receiving remote braille, True otherwise + :rtype: bool """ return not self.receivingBraille @@ -283,28 +299,29 @@ def sendKey( ) -> 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 + :param vk_code: Virtual key code to simulate + :type vk_code: Optional[int] + :param extended: Whether this is an extended key + :type extended: Optional[bool] + :param pressed: True for key press, False for key release + :type pressed: Optional[bool] """ 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) + :param text: Text to copy to the clipboard + :type text: str """ cues.clipboard_received() api.copyToClip(text=text) def sendSAS(self) -> None: - """ - Simulate a secure attention sequence (e.g. CTRL+ALT+DEL). + """Simulate a secure attention sequence (e.g. CTRL+ALT+DEL). - SendSAS requires UI Access. If this fails, a warning is displayed. + .. note:: + SendSAS requires UI Access. If this fails, a warning is displayed. """ if hasUiAccess(): ctypes.windll.sas.SendSAS(0) From 2240854f2ea1295fa2c7018b9f1f828c228717bd Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 12:43:11 -0700 Subject: [PATCH 095/203] URL Handler casing --- source/remoteClient/client.py | 4 ++-- source/remoteClient/urlHandler.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 645731e9527..a033d36c60a 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -61,7 +61,7 @@ def __init__( if not isRunningOnSecureDesktop(): self.menu: Optional[RemoteMenu] = RemoteMenu(self) self.connecting = False - urlHandler.register_url_handler() + urlHandler.registerURLHandler() self.masterTransport = None self.slaveTransport = None self.localControlServer = None @@ -106,7 +106,7 @@ def terminate(self): core.postNvdaStartup.unregister(self.performAutoconnect) inputCore.decide_handleRawKey.unregister(self.process_key_input) if not isInstalledCopy(): - urlHandler.unregister_url_handler() + urlHandler.unregisterURLHandler() def toggleMute(self): self.localMachine.isMuted = not self.localMachine.isMuted diff --git a/source/remoteClient/urlHandler.py b/source/remoteClient/urlHandler.py index 93fe1529ce0..a8d54c51dcc 100644 --- a/source/remoteClient/urlHandler.py +++ b/source/remoteClient/urlHandler.py @@ -84,7 +84,7 @@ def _delete_registry_key_recursive(base_key, subkey_path): raise OSError(f"Failed to delete registry key {subkey_path}: {e}") -def register_url_handler(): +def registerURLHandler(): """Registers the URL handler in the Windows Registry.""" try: key_path = r"SOFTWARE\Classes\nvdaremote" @@ -94,7 +94,7 @@ def register_url_handler(): raise OSError(f"Failed to register URL handler: {e}") -def unregister_url_handler(): +def unregisterURLHandler(): """Unregisters the URL handler from the Windows Registry.""" try: _delete_registry_key_recursive(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Classes\nvdaremote") @@ -102,7 +102,7 @@ def unregister_url_handler(): raise OSError(f"Failed to unregister URL handler: {e}") -def url_handler_path(): +def URLHandlerPath(): """Returns the path to the URL handler executable.""" return os.path.join(os.path.split(os.path.abspath(__file__))[0], "url_handler.exe") From 02f6c5c403a5daa69a60b1a9922381f88046c8e2 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 12:50:55 -0700 Subject: [PATCH 096/203] Serializer Docstrings and import organization --- source/remoteClient/serializer.py | 117 ++++++++++++++---------------- 1 file changed, 55 insertions(+), 62 deletions(-) diff --git a/source/remoteClient/serializer.py b/source/remoteClient/serializer.py index 630d33b25f0..b5f551d81d5 100644 --- a/source/remoteClient/serializer.py +++ b/source/remoteClient/serializer.py @@ -8,21 +8,23 @@ This module handles serializing and deserializing messages between NVDA instances. It provides special handling for speech commands and other NVDA-specific data types. -The module provides: - * A generic :class:`Serializer` interface - * A concrete :class:`JSONSerializer` implementation for NVDA Remote messages - -The serialization format supports: - * Basic JSON data types - * Speech command objects - * Custom message types via the 'type' field +Module Features +------------- +* A generic :class:`.Serializer` interface +* A concrete :class:`.JSONSerializer` implementation for NVDA Remote messages + +Supported Data Types +------------------ +* Basic JSON data types +* Speech command objects +* Custom message types via the 'type' field """ +import json from abc import ABCMeta, abstractmethod from enum import Enum from logging import getLogger -from typing import Any, Dict, Optional, Type, Union, TypeVar -import json +from typing import Any, Dict, Optional, Type, TypeVar, Union import speech.commands @@ -39,24 +41,22 @@ class Serializer(metaclass=ABCMeta): Concrete implementations should handle converting Python objects to/from a wire format suitable for network transmission. - Note: - This is an abstract base class. Subclasses must implement - :meth:`serialize` and :meth:`deserialize`. + Note + ---- + This is an abstract base class. Subclasses must implement + :meth:`serialize` and :meth:`deserialize`. """ @abstractmethod def serialize(self, type: Optional[str] = None, **obj: Any) -> bytes: """Convert a message to bytes for transmission. - Args: - type (str, optional): Message type identifier, used for routing - **obj: Message payload as keyword arguments - - Returns: - bytes: Serialized message as bytes - - Raises: - NotImplementedError: Must be implemented by subclasses + :param type: Message type identifier, used for routing + :type type: str, optional + :param obj: Message payload as keyword arguments + :return: Serialized message as bytes + :rtype: bytes + :raises NotImplementedError: Must be implemented by subclasses """ raise NotImplementedError @@ -64,14 +64,11 @@ def serialize(self, type: Optional[str] = None, **obj: Any) -> bytes: def deserialize(self, data: bytes) -> JSONDict: """Convert received bytes back into a message dict. - Args: - data (bytes): Raw message bytes to deserialize - - Returns: - Dict[str, Any]: Dict containing the deserialized message - - Raises: - NotImplementedError: Must be implemented by subclasses + :param data: Raw message bytes to deserialize + :type data: bytes + :return: Dict containing the deserialized message + :rtype: Dict[str, Any] + :raises NotImplementedError: Must be implemented by subclasses """ raise NotImplementedError @@ -83,8 +80,8 @@ class JSONSerializer(Serializer): NVDA speech commands and other custom types. Messages are encoded as UTF-8 with newline separation. - Attributes: - SEP (bytes): Message separator for streaming protocols + :cvar SEP: Message separator for streaming protocols + :type SEP: bytes """ SEP: bytes = b"\n" # Message separator for streaming protocols @@ -95,12 +92,10 @@ def serialize(self, type: Optional[str] = None, **obj: Any) -> 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 + :param type: Message type identifier (string or Enum) + :param obj: Message payload to serialize + :return: UTF-8 encoded JSON with newline separator + :rtype: bytes """ if type is not None: if isinstance(type, Enum) and not isinstance(type, str): @@ -115,11 +110,10 @@ def deserialize(self, data: bytes) -> JSONDict: 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 + :param data: UTF-8 encoded JSON bytes + :type data: bytes + :return: Dict containing the deserialized message + :rtype: Dict[str, Any] """ obj = json.loads(data, object_hook=as_sequence) return obj @@ -136,17 +130,18 @@ class CustomEncoder(json.JSONEncoder): Handles serialization of speech command objects by converting them to a list containing their class name and instance variables. + + :note: Inherits from :class:`json.JSONEncoder` """ def default(self, obj: Any) -> Any: """Convert speech commands to serializable format. - Args: - obj (Any): Object to serialize - - Returns: - Any: For speech commands, returns a list containing [class_name, instance_vars]. + :param obj: Object to serialize + :type obj: Any + :return: For speech commands, returns a list containing [class_name, instance_vars]. For other types, returns the default JSON encoding. + :rtype: Any """ if is_subclass_or_instance(obj, SEQUENCE_CLASSES): return [obj.__class__.__name__, obj.__dict__] @@ -159,14 +154,15 @@ def is_subclass_or_instance(unknown: Any, possible: Union[Type[T], tuple[Type[T] Safely handles both types and instances, useful for type checking during serialization. - Args: - unknown (Any): Object or type to check - possible (Union[Type[T], tuple[Type[T], ...]]): Type or tuple of types to check against + :param unknown: Object or type to check + :type unknown: Any + :param possible: Type or tuple of types to check against + :type possible: Union[Type[T], tuple[Type[T], ...]] + :return: True if unknown is a subclass or instance of possible + :rtype: bool - Returns: - bool: True if unknown is a subclass or instance of possible + Example:: - Example: >>> is_subclass_or_instance(str, (int, str)) True >>> is_subclass_or_instance("hello", (int, str)) @@ -184,15 +180,12 @@ def as_sequence(dct: JSONDict) -> JSONDict: Handles the 'speak' message type by converting serialized speech commands back into their original object form. - Args: - dct (JSONDict): Dict containing potentially serialized speech commands - - Returns: - JSONDict: Dict with reconstructed speech command objects if applicable, + :param dct: Dict containing potentially serialized speech commands + :type dct: JSONDict + :return: Dict with reconstructed speech command objects if applicable, otherwise returns the input unchanged - - Warning: - Logs a warning if an unknown sequence type is encountered + :rtype: JSONDict + :warning: Logs a warning if an unknown sequence type is encountered """ if not ("type" in dct and dct["type"] == "speak" and "sequence" in dct): return dct From 6f06127e1549b09fc3d298863bed13655a451a4a Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 15:14:00 -0700 Subject: [PATCH 097/203] f-string Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index 2867c7f518e..0a6f3bcdb55 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -101,7 +101,7 @@ def findScript(self): # App module level. app = focus.appModule if app and cls == "AppModule" and module == app.__module__: - func = getattr(app, "script_%s" % scriptName, None) + func = getattr(app, f"script_{scriptName}", None) if func: return func From 730ed6df2af35ace61807db605490cc641bebc22 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 15:22:28 -0700 Subject: [PATCH 098/203] Camel-case cues --- source/remoteClient/client.py | 4 +-- source/remoteClient/cues.py | 52 ++++++++++++++--------------- source/remoteClient/localMachine.py | 2 +- source/remoteClient/session.py | 6 ++-- 4 files changed, 31 insertions(+), 33 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index a033d36c60a..b149ab48ea2 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -126,7 +126,7 @@ def pushClipboard(self): return try: connector.send(RemoteMessageType.SET_CLIPBOARD_TEXT, text=api.getClipData()) - cues.clipboard_pushed() + cues.clipboardPushed() except TypeError: log.exception("Unable to push clipboard") @@ -290,7 +290,7 @@ def connectAsSlave(self, connectionInfo: ConnectionInfo): @alwaysCallAfter def onConnectedAsSlave(self): log.info("Control connector connected") - cues.control_server_connected() + cues.controlServerConnected() if self.menu: self.menu.handleConnected(ConnectionMode.SLAVE, True) configuration.write_connection_to_config(self.slaveSession.getConnectionInfo()) diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index b669e9a7659..4e7667f10c7 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -8,12 +8,10 @@ import globalVars import nvwave - - -from tones import beep, BeepSequence, beepSequenceAsync import ui -from . import configuration +from tones import BeepSequence, beep, beepSequenceAsync +from . import configuration local_beep = beep @@ -33,21 +31,21 @@ class Cue(TypedDict, total=False): # Translators: Message shown when the connection to the remote computer is lost. "message": _("Disconnected"), }, - "control_server_connected": { + "controlServerConnected": { "wave": "controlled", "beeps": [(720, 100), (None, 50), (720, 100), (None, 50), (720, 100)], # Translators: Presented in direct (client to server) remote connection when the controlled computer is ready. "message": _("Connected to control server"), }, - "client_connected": {"wave": "controlling", "beeps": [(1000, 300)]}, - "client_disconnected": {"wave": "disconnected", "beeps": [(108, 300)]}, - "clipboard_pushed": { + "clientConnected": {"wave": "controlling", "beeps": [(1000, 300)]}, + "clientDisconnected": {"wave": "disconnected", "beeps": [(108, 300)]}, + "clipboardPushed": { "wave": "clipboardPush", "beeps": [(500, 100), (600, 100)], # Translators: Message shown when the clipboard is successfully pushed to the remote computer. "message": _("Clipboard pushed"), }, - "clipboard_received": { + "clipboardReceived": { "wave": "clipboardReceive", "beeps": [(600, 100), (500, 100)], # Translators: Message shown when the clipboard is successfully received from the remote computer. @@ -56,51 +54,51 @@ class Cue(TypedDict, total=False): } -def _play_cue(cue_name: str) -> None: +def _playCue(cueName: str) -> None: """Helper function to play a cue by name""" - if not should_play_sounds(): + if not shouldPlaySounds(): # Play beep sequence - if beeps := CUES[cue_name].get("beeps"): + if beeps := CUES[cueName].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"): + if wave := CUES[cueName].get("wave"): nvwave.playWaveFile(os.path.join(globalVars.appDir, "waves", wave + ".wav")) # Show message if specified - if message := CUES[cue_name].get("message"): + if message := CUES[cueName].get("message"): ui.message(message) def connected(): - _play_cue("connected") + _playCue("connected") def disconnected(): - _play_cue("disconnected") + _playCue("disconnected") -def control_server_connected(): - _play_cue("control_server_connected") +def controlServerConnected(): + _playCue("controlServerConnected") -def client_connected(): - _play_cue("client_connected") +def clientConnected(): + _playCue("clientConnected") -def client_disconnected(): - _play_cue("client_disconnected") +def clientDisconnected(): + _playCue("clientDisconnected") -def clipboard_pushed(): - _play_cue("clipboard_pushed") +def clipboardPushed(): + _playCue("clipboardPushed") -def clipboard_received(): - _play_cue("clipboard_received") +def clipboardReceived(): + _playCue("clipboardReceived") -def should_play_sounds(): +def shouldPlaySounds(): return configuration.get_config()["ui"]["play_sounds"] diff --git a/source/remoteClient/localMachine.py b/source/remoteClient/localMachine.py index 62f59839666..a11ec8b851a 100644 --- a/source/remoteClient/localMachine.py +++ b/source/remoteClient/localMachine.py @@ -314,7 +314,7 @@ def setClipboardText(self, text: str) -> None: :param text: Text to copy to the clipboard :type text: str """ - cues.clipboard_received() + cues.clipboardReceived() api.copyToClip(text=text) def sendSAS(self) -> None: diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index adb233ce608..5f9fb94fae5 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -203,13 +203,13 @@ def shouldDisplayMotd(self, motd: str) -> bool: def handleClientConnected(self, client: Optional[Dict[str, Any]] = None) -> None: """Handle new client connection.""" log.info("Client connected: %r", client) - cues.client_connected() + cues.clientConnected() def handleClientDisconnected(self, client=None): """Handle client disconnection. Plays disconnection sound when remote client disconnects. """ - cues.client_disconnected() + cues.clientDisconnected() def getConnectionInfo(self) -> connectionInfo.ConnectionInfo: """Get information about the current connection. @@ -359,7 +359,7 @@ def handleTransportDisconnected(self) -> None: 2. Removes any NVDA patches """ log.info("Transport disconnected from slave session") - cues.client_connected() + cues.clientConnected() def handleClientDisconnected(self, client: Optional[Dict[str, Any]] = None) -> None: super().handleClientDisconnected(client) From fcab4c221cf9f3b43291c7313c2e5ee7c5dba7ab Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 15:30:49 -0700 Subject: [PATCH 099/203] f-string Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index 0a6f3bcdb55..b142813253b 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -116,7 +116,7 @@ def findScript(self): # Tree interceptor level. treeInterceptor = focus.treeInterceptor if treeInterceptor and treeInterceptor.isReady: - func = getattr(treeInterceptor, "script_%s" % scriptName, None) + func = getattr(treeInterceptor, f"script_{scriptName}", None) if func: return func From c579ac1cc818fed6d62d38d713ccd81f636e68b2 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 15:31:26 -0700 Subject: [PATCH 100/203] f-string Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index b142813253b..2fe5a3eb66d 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -109,7 +109,7 @@ def findScript(self): 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) + func = getattr(app, "script_{scriptName}", None) if func: return func From 54389b0065eaaa86282b632192293a3f7abd0aa5 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 15:44:35 -0700 Subject: [PATCH 101/203] Improve docs Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/serializer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/source/remoteClient/serializer.py b/source/remoteClient/serializer.py index b5f551d81d5..fd7cc6e8d9d 100644 --- a/source/remoteClient/serializer.py +++ b/source/remoteClient/serializer.py @@ -84,7 +84,8 @@ class JSONSerializer(Serializer): :type SEP: bytes """ - SEP: bytes = b"\n" # Message separator for streaming protocols + 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. From a9e4e580f5249c7dcc878c90ef963997ec09c054 Mon Sep 17 00:00:00 2001 From: "Christopher Toth (aider)" Date: Sat, 1 Feb 2025 15:37:51 -0700 Subject: [PATCH 102/203] refactor: Update type hints to use modern Python syntax --- source/remoteClient/transport.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index b5c3e4329d6..b19e65f3f37 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -32,9 +32,9 @@ import time from enum import Enum from logging import getLogger +from collections.abc import Callable from queue import Queue -from typing import Any, Callable, Dict, Optional, Tuple, Union - +from typing import Any, Type, TypeVar, Generic from dataclasses import dataclass import wx from extensionPoints import Action, HandlerRegistrar @@ -604,8 +604,8 @@ class RelayTransport(TCPTransport): protocol_version (int): Protocol version to use """ - channel: Optional[str] - connectionType: Optional[str] + channel: str | None + connectionType: str | None protocol_version: int def __init__( @@ -613,8 +613,8 @@ def __init__( serializer: Serializer, address: Tuple[str, int], timeout: int = 0, - channel: Optional[str] = None, - connectionType: Optional[str] = None, + channel: str | None = None, + connectionType: str | None = None, protocol_version: int = PROTOCOL_VERSION, insecure: bool = False, ) -> None: @@ -712,7 +712,7 @@ def run(self): log.info("Ending control connector thread %s" % self.name) -def clearQueue(queue: Queue[Optional[bytes]]) -> None: +def clearQueue(queue: Queue[bytes | None]) -> None: """Empty all items from a queue without blocking. Removes all items from the queue in a non-blocking way, From 9aef15bf2f74429337202da53eeb740c06115515 Mon Sep 17 00:00:00 2001 From: "Christopher Toth (aider)" Date: Sat, 1 Feb 2025 15:38:08 -0700 Subject: [PATCH 103/203] refactor: Update type hints for TCPTransport methods --- source/remoteClient/transport.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index b19e65f3f37..70790fcde25 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -299,7 +299,7 @@ class TCPTransport(Transport): def __init__( self, serializer: Serializer, - address: Tuple[str, int], + address: tuple[str, int], timeout: int = 0, insecure: bool = False, ) -> None: @@ -383,7 +383,7 @@ def createOutboundSocket( host: str, port: int, insecure: bool = False, - ) -> ssl.SSLSocket: + ) -> ssl.SSLSocket | None: """Create and configure an SSL socket for outbound connections. Creates a TCP socket with appropriate timeout and keep-alive settings, @@ -422,7 +422,7 @@ def createOutboundSocket( def getpeercert( self, binary_form: bool = False, - ) -> Optional[Union[Dict[str, Any], bytes]]: + ) -> dict[str, Any] | bytes | None: """Get the certificate from the peer. Retrieves the certificate presented by the remote peer during SSL handshake. From bd442232d02d45e611aa56ac3856562da24ebdd8 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 15:50:46 -0700 Subject: [PATCH 104/203] Naming and casing --- source/remoteClient/transport.py | 22 +++++++++++----------- source/remoteClient/urlHandler.py | 16 ++++++++-------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 70790fcde25..130cca587f7 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -30,20 +30,20 @@ import ssl import threading import time +from collections.abc import Callable +from dataclasses import dataclass from enum import Enum from logging import getLogger -from collections.abc import Callable from queue import Queue -from typing import Any, Type, TypeVar, Generic -from dataclasses import dataclass +from typing import Any, Optional + import wx from extensionPoints import Action, HandlerRegistrar from . import configuration from .connectionInfo import ConnectionInfo -from .protocol import PROTOCOL_VERSION, RemoteMessageType +from .protocol import PROTOCOL_VERSION, RemoteMessageType, hostPortToAddress from .serializer import Serializer -from .protocol import hostPortToAddress log = getLogger("transport") @@ -67,7 +67,7 @@ class RemoteExtensionPoint: extensionPoint: HandlerRegistrar messageType: RemoteMessageType - filter: Optional[Callable[..., Dict[str, Any]]] = None + filter: Optional[Callable[..., dict[str, Any]]] = None transport: Optional["Transport"] = None def remoteBridge(self, *args: Any, **kwargs: Any) -> bool: @@ -149,8 +149,8 @@ def __init__(self, serializer: Serializer) -> None: self.connected = False self.successfulConnects = 0 self.connectedEvent = threading.Event() - self.inboundHandlers: Dict[RemoteMessageType, Action] = {} - self.outboundHandlers: Dict[RemoteMessageType, RemoteExtensionPoint] = {} + self.inboundHandlers: dict[RemoteMessageType, Action] = {} + self.outboundHandlers: dict[RemoteMessageType, RemoteExtensionPoint] = {} self.transportConnected = Action() """ Notifies when the transport is connected @@ -289,7 +289,7 @@ class TCPTransport(Transport): queue: Queue[Optional[bytes]] insecure: bool serverSockLock: threading.Lock - address: Tuple[str, int] + address: tuple[str, int] serverSock: Optional[ssl.SSLSocket] queueThread: Optional[threading.Thread] timeout: int @@ -604,14 +604,14 @@ class RelayTransport(TCPTransport): protocol_version (int): Protocol version to use """ - channel: str | None + channel: str | None connectionType: str | None protocol_version: int def __init__( self, serializer: Serializer, - address: Tuple[str, int], + address: tuple[str, int], timeout: int = 0, channel: str | None = None, connectionType: str | None = None, diff --git a/source/remoteClient/urlHandler.py b/source/remoteClient/urlHandler.py index a8d54c51dcc..ea18e544923 100644 --- a/source/remoteClient/urlHandler.py +++ b/source/remoteClient/urlHandler.py @@ -29,7 +29,7 @@ log = getLogger("url_handler") -def _create_registry_structure(key_handle, data): +def _createRegistryStructure(key_handle, data: dict): """Creates a nested registry structure from a dictionary. Args: @@ -42,7 +42,7 @@ def _create_registry_structure(key_handle, data): try: subkey = winreg.CreateKey(key_handle, name) try: - _create_registry_structure(subkey, value) + _createRegistryStructure(subkey, value) finally: winreg.CloseKey(subkey) except WindowsError as e: @@ -55,7 +55,7 @@ def _create_registry_structure(key_handle, data): raise OSError(f"Failed to set registry value {name}: {e}") -def _delete_registry_key_recursive(base_key, subkey_path): +def _deleteRegistryKeyRecursive(base_key, subkey_path: str): """Recursively deletes a registry key and all its subkeys. Args: @@ -74,7 +74,7 @@ def _delete_registry_key_recursive(base_key, subkey_path): try: subkey_name = winreg.EnumKey(key, 0) full_path = f"{subkey_path}\\{subkey_name}" - _delete_registry_key_recursive(base_key, full_path) + _deleteRegistryKeyRecursive(base_key, full_path) except WindowsError: break # Now delete the key itself @@ -87,9 +87,9 @@ def _delete_registry_key_recursive(base_key, subkey_path): def registerURLHandler(): """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) + keyPath = r"SOFTWARE\Classes\nvdaremote" + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, keyPath) as key: + _createRegistryStructure(key, URL_HANDLER_REGISTRY) except OSError as e: raise OSError(f"Failed to register URL handler: {e}") @@ -97,7 +97,7 @@ def registerURLHandler(): def unregisterURLHandler(): """Unregisters the URL handler from the Windows Registry.""" try: - _delete_registry_key_recursive(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Classes\nvdaremote") + _deleteRegistryKeyRecursive(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Classes\nvdaremote") except OSError as e: raise OSError(f"Failed to unregister URL handler: {e}") From 0c8e632568cfd9a142f2fba051a254f247a2258b Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 15:55:54 -0700 Subject: [PATCH 105/203] Script naming Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/globalCommands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/globalCommands.py b/source/globalCommands.py index aa9395bbd8a..e11346ebe12 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4899,7 +4899,7 @@ def script_toggleApplicationsMute(self, gesture: "inputCore.InputGesture") -> No description=_("""Mute or unmute the speech coming from the remote computer"""), category=SCRCAT_REMOTE, ) - def script_toggle_remote_mute(self, gesture): + def script_toggleRemoteMute(self, gesture: "inputCore.InputGesture"): remoteClient.remoteClient.toggleMute() @script( From ad9380485a91d1e8392178bb825e8fc622ed6058 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 15:56:33 -0700 Subject: [PATCH 106/203] Script naming Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/globalCommands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/globalCommands.py b/source/globalCommands.py index e11346ebe12..771370fe7cc 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4908,7 +4908,7 @@ def script_toggleRemoteMute(self, gesture: "inputCore.InputGesture"): # 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): + def script_pushClipboard(self, gesture: "inputCore.InputGesture"): remoteClient.remoteClient.pushClipboard() @script( From b41ddf2ceda2619818fc12368b877108c1a56eaa Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 16:00:10 -0700 Subject: [PATCH 107/203] f-string Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index 2fe5a3eb66d..d298f86d1c9 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -130,7 +130,7 @@ def findScript(self): return func # Global commands. - func = getattr(globalCommands.commands, "script_%s" % scriptName, None) + func = getattr(globalCommands.commands, f"script_{scriptName}", None) if func: return func From 4ca41818226c732da21ffaa1648d72afcd6ffffa Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 16:02:47 -0700 Subject: [PATCH 108/203] Fix session imports --- source/remoteClient/session.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 5f9fb94fae5..28ff3b64ead 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -64,29 +64,26 @@ import hashlib from collections import defaultdict -from typing import Dict, List, Optional, Any, Union - -import brailleInput -import inputCore -from logHandler import log - +from typing import Any, Dict, List, Optional, Union import braille +import brailleInput import gui -from nvwave import decide_playWaveFile +import inputCore import scriptHandler import speech +import speech.commands import tones import ui -from speech.extensions import speechCanceled, post_speechPaused, pre_speechQueued +from logHandler import log +from nvwave import decide_playWaveFile +from speech.extensions import post_speechPaused, pre_speechQueued, speechCanceled from . import configuration, connectionInfo, cues - from .localMachine import LocalMachine from .protocol import RemoteMessageType from .transport import RelayTransport - EXCLUDED_SPEECH_COMMANDS = ( speech.commands.BaseCallbackCommand, # _CancellableSpeechCommands are not designed to be reported and are used internally by NVDA. (#230) From 28f6e25aefbee373b115b7d18dd3232c8d1df256 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 16:04:23 -0700 Subject: [PATCH 109/203] Fix version mismatch log --- source/remoteClient/session.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 28ff3b64ead..bf69bdd0451 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -140,14 +140,13 @@ def __init__( 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 """ + log.error("Protocol version mismatch detected with relay server") ui.message( # Translators: Message for version mismatch _("""The version of the relay server which you have connected to is not compatible with this version of the Remote Client. From 9a03336766359befb73b5b348fd325ba459c406a Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 20:05:15 -0700 Subject: [PATCH 110/203] F-string Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index d298f86d1c9..9161dce0cb0 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -121,7 +121,7 @@ def findScript(self): return func # NVDAObject level. - func = getattr(focus, "script_%s" % scriptName, None) + func = getattr(focus, f"script_{scriptName}", None) if func: return func for obj in reversed(api.getFocusAncestors()): From ddcf880e7ba07b98901622f75573b5eb598a688c Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 20:21:46 -0700 Subject: [PATCH 111/203] refactor: improve type hints and documentation - Fix bug in handleTransportDisconnected() to correctly call clientDisconnected() instead of clientConnected() - Add comprehensive docstrings to findScript() and handleDecideExecuteGesture() methods explaining their behavior and parameters - Modernize type hints using Python 3.10+ notation (| for unions) - Make type hints more specific using Final and proper container types - Rename handle_decide_executeGesture to handleDecideExecuteGesture for consistent method naming convention - Update parameter type hints to be more precise across multiple methods This commit improves code quality, documentation, and type safety while fixing a disconnect handling bug. --- source/remoteClient/input.py | 21 ++++++++++ source/remoteClient/session.py | 71 ++++++++++++++++++++-------------- 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index 9161dce0cb0..da95867dc27 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -78,6 +78,27 @@ def __init__(self, **kwargs): self.script = self.findScript() if self.scriptPath else None def findScript(self): + """Find and return a script function based on the script path. + + The script path must be a list containing three elements: + module name, class name, and script name. Searches through multiple levels + for the script. + + Search order: + * Global plugins + * App modules + * Vision enhancement providers + * Tree interceptors + * NVDA objects + * Global commands + + Returns: + Callable: The script function if found + None: If no matching script is found + + Note: + If scriptName starts with "kb:", returns a keyboard emulation script + """ if not (isinstance(self.scriptPath, list) and len(self.scriptPath) == 3): return None module, cls, scriptName = self.scriptPath diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index bf69bdd0451..cfa8855a766 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -64,7 +64,7 @@ import hashlib from collections import defaultdict -from typing import Any, Dict, List, Optional, Union +from typing import Any, Final import braille import brailleInput @@ -107,7 +107,7 @@ class RemoteSession: 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 + mode: connectionInfo.ConnectionMode | None = None callbacksAdded: bool # Whether callbacks are currently registered def __init__( @@ -196,7 +196,7 @@ def shouldDisplayMotd(self, motd: str) -> bool: conf["seen_motds"][address] = hashed return True - def handleClientConnected(self, client: Optional[Dict[str, Any]] = None) -> None: + def handleClientConnected(self, client: dict[str, Any]) -> None: """Handle new client connection.""" log.info("Client connected: %r", client) cues.clientConnected() @@ -251,10 +251,10 @@ class SlaveSession(RemoteSession): """ # Connection mode - always 'slave' - mode: connectionInfo.ConnectionMode = connectionInfo.ConnectionMode.SLAVE + mode: Final[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 + masters: dict[int, dict[str, Any]] + masterDisplaySizes: list[int] # Braille display sizes of connected masters def __init__( self, @@ -321,7 +321,7 @@ def unregisterCallbacks(self) -> None: pre_speechQueued.unregister(self.sendSpeech) self.callbacksAdded = False - def handleClientConnected(self, client: Dict[str, Any]) -> None: + def handleClientConnected(self, client: dict[str, Any]) -> None: super().handleClientConnected(client) if client["connection_type"] == "master": self.masters[client["id"]]["active"] = True @@ -330,9 +330,9 @@ def handleClientConnected(self, client: Dict[str, Any]) -> None: def handleChannelJoined( self, - channel: Optional[str] = None, - clients: Optional[List[Dict[str, Any]]] = None, - origin: Optional[int] = None, + channel: str, + clients: list[dict[str, Any]], + origin: int | None = None, ) -> None: if clients is None: clients = [] @@ -355,9 +355,9 @@ def handleTransportDisconnected(self) -> None: 2. Removes any NVDA patches """ log.info("Transport disconnected from slave session") - cues.clientConnected() + cues.clientDisconnected() - def handleClientDisconnected(self, client: Optional[Dict[str, Any]] = None) -> None: + def handleClientDisconnected(self, client: dict[str, Any]) -> None: super().handleClientDisconnected(client) if client["connection_type"] == "master": log.info("Master client disconnected: %r", client) @@ -374,9 +374,9 @@ def setDisplaySize(self, sizes=None): def handleBrailleInfo( self, - name: Optional[str] = None, - numCells: int = 0, - origin: Optional[int] = None, + name: str | None = None, + numCells: int | None = 0, + origin: int | None = None, ) -> None: if not self.masters.get(origin): return @@ -384,7 +384,7 @@ def handleBrailleInfo( self.masters[origin]["braille_numCells"] = numCells self.setDisplaySize() - def _filterUnsupportedSpeechCommands(self, speechSequence: List[Any]) -> List[Any]: + 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, @@ -395,7 +395,7 @@ def _filterUnsupportedSpeechCommands(self, speechSequence: List[Any]) -> List[An """ return list([item for item in speechSequence if not isinstance(item, EXCLUDED_SPEECH_COMMANDS)]) - def sendSpeech(self, speechSequence: List[Any], priority: Optional[str]) -> None: + def sendSpeech(self, speechSequence: list[Any], priority: str | None) -> None: """Forward speech output to connected master instances. Filters the speech sequence for supported commands and sends it @@ -413,7 +413,7 @@ 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: + 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. @@ -447,8 +447,8 @@ class MasterSession(RemoteSession): appropriate commands to control the remote slave instance. """ - mode: connectionInfo.ConnectionMode = connectionInfo.ConnectionMode.MASTER - slaves: Dict[int, Dict[str, Any]] # Information about connected slave + mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.MASTER + slaves: dict[int, dict[str, Any]] # Information about connected slave def __init__( self, @@ -518,9 +518,9 @@ def handleNVDANotConnected(self) -> None: def handleChannel_joined( self, - channel: Optional[str] = None, - clients: Optional[List[Dict[str, Any]]] = None, - origin: Optional[int] = None, + channel: str, + clients: list[dict[str, Any]] | None = None, + origin: int | None = None, ) -> None: if clients is None: clients = [] @@ -544,8 +544,8 @@ def handleClientDisconnected(self, client=None): def sendBrailleInfo( self, - display: Optional[Any] = None, - displaySize: Optional[int] = None, + display: Any = None, + displaySize: int | None = None, ) -> None: if display is None: display = braille.handler.display @@ -562,10 +562,23 @@ def sendBrailleInfo( numCells=displaySize, ) - def handle_decide_executeGesture( + def handleDecideExecuteGesture( self, - gesture: Union[braille.BrailleDisplayGesture, brailleInput.BrailleInputGesture, Any], + gesture: braille.BrailleDisplayGesture | brailleInput.BrailleInputGesture, ) -> bool: + """ + Handles the decision to execute a gesture by processing the given gesture and sending + the relevant data to the remote client. + + Args: + gesture (braille.BrailleDisplayGesture | brailleInput.BrailleInputGesture): + The gesture to be processed, which can be either a Braille display gesture + or a Braille input gesture. + + Returns: + bool: Returns False if the gesture is successfully processed and sent to the + remote client, otherwise returns True. + """ if isinstance(gesture, (braille.BrailleDisplayGesture, brailleInput.BrailleInputGesture)): dict = { key: gesture.__dict__[key] @@ -613,7 +626,7 @@ def handle_decide_executeGesture( return True def registerBrailleInput(self) -> None: - inputCore.decide_executeGesture.register(self.handle_decide_executeGesture) + inputCore.decide_executeGesture.register(self.handleDecideExecuteGesture) def unregisterBrailleInput(self) -> None: - inputCore.decide_executeGesture.unregister(self.handle_decide_executeGesture) + inputCore.decide_executeGesture.unregister(self.handleDecideExecuteGesture) From bb1653dfa7716fbda41785617e9e113ff264c476 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 20:25:08 -0700 Subject: [PATCH 112/203] Rename handleChannel_joined to handleChannelJoined --- source/remoteClient/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index cfa8855a766..40869162ce1 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -487,7 +487,7 @@ def __init__( ) self.transport.registerInbound( RemoteMessageType.CHANNEL_JOINED, - self.handleChannel_joined, + self.handleChannelJoined, ) self.transport.registerInbound( RemoteMessageType.SET_BRAILLE_INFO, @@ -516,7 +516,7 @@ def handleNVDANotConnected(self) -> None: _("Remote NVDA not connected."), ) - def handleChannel_joined( + def handleChannelJoined( self, channel: str, clients: list[dict[str, Any]] | None = None, From 02d8fe417727042f239fcb617942cb17d1ea2961 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Feb 2025 22:42:37 -0700 Subject: [PATCH 113/203] Typing for script gesture Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/globalCommands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/globalCommands.py b/source/globalCommands.py index 771370fe7cc..e7c7feec5e4 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4928,7 +4928,7 @@ def script_copyRemoteLink(self, gesture: "inputCore.InputGesture"): description=_("""Disconnect a remote session"""), ) @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) - def script_disconnectFromRemote(self, gesture): + def script_disconnectFromRemote(self, gesture: "inputCore.InputGesture"): if not remoteClient.remoteClient.isConnected: # Translators: A message indicating that the remote client is not connected. ui.message(_("Not connected")) From cb188f1776649c9635a4b861feaf7c53a72b9c43 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 00:40:54 -0700 Subject: [PATCH 114/203] Standardizing sphinx-style docs --- source/remoteClient/bridge.py | 40 ++--- source/remoteClient/client.py | 82 ++++++++- source/remoteClient/connectionInfo.py | 45 ++--- source/remoteClient/localMachine.py | 163 ++++++------------ source/remoteClient/session.py | 127 +++++++------- source/remoteClient/transport.py | 234 ++++++++++++++------------ source/remoteClient/urlHandler.py | 28 ++- 7 files changed, 385 insertions(+), 334 deletions(-) diff --git a/source/remoteClient/bridge.py b/source/remoteClient/bridge.py index a41f4f58c37..e2476b429eb 100644 --- a/source/remoteClient/bridge.py +++ b/source/remoteClient/bridge.py @@ -5,25 +5,25 @@ """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. +Provides functionality to bridge two NVDA Remote transports together, +enabling bidirectional message passing with filtering and routing. -The bridge acts as an intermediary layer that: +:param transport1: First transport instance to bridge +:param transport2: Second transport instance to bridge +The bridge acts as an intermediary layer that: * Connects two transport instances * Routes messages between them -* Filters out specific message types that shouldn't be forwarded -* Manages the lifecycle of message handlers +* Filters out specific message types +* Manages message handler lifecycle -Example: - Create and use a bridge between two transports:: +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 + 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 .protocol import RemoteMessageType @@ -86,15 +86,10 @@ def __init__(self, t1: Transport, t2: Transport) -> None: 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. - :param targetTransport: Transport instance to forward messages to - :type targetTransport: Transport :param messageType: Type of message this callback will handle - :type messageType: RemoteMessageType :return: A callback function that forwards messages to the target transport - :rtype: callable + :note: Creates a closure that forwards messages unless the type is excluded """ def callback(*args, **kwargs): @@ -106,11 +101,8 @@ def callback(*args, **kwargs): 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. - - :return: None + :note: Unregisters all message handlers from both transports. + Should be called before disposal to prevent memory leaks. """ for messageType in RemoteMessageType: self.t1.unregisterInbound(messageType, self.t2Callbacks[messageType]) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index b149ab48ea2..34deb6edab9 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -109,6 +109,10 @@ def terminate(self): urlHandler.unregisterURLHandler() def toggleMute(self): + """Toggle muting of speech and sounds from the remote computer. + + :note: Updates menu item state and announces new mute status + """ self.localMachine.isMuted = not self.localMachine.isMuted self.menu.muteItem.Check(self.localMachine.isMuted) # Translators: Displayed when muting speech and sounds from the remote computer @@ -119,6 +123,11 @@ def toggleMute(self): ui.message(status) def pushClipboard(self): + """Send local clipboard content to the remote computer. + + :note: Requires an active connection + :raises TypeError: If clipboard content cannot be serialized + """ connector = self.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. @@ -131,6 +140,10 @@ def pushClipboard(self): log.exception("Unable to push clipboard") def copyLink(self): + """Copy connection URL to clipboard. + + :note: Requires an active session + """ session = self.masterSession or self.slaveSession if session is None: # Translators: Message shown when trying to copy the link to connect to the remote computer while not connected. @@ -140,12 +153,21 @@ def copyLink(self): api.copyToClip(str(url)) def sendSAS(self): + """Send Secure Attention Sequence to remote computer. + + :note: Requires an active master transport connection + """ if self.masterTransport is None: log.error("No master transport to send SAS") return self.masterTransport.send(RemoteMessageType.SEND_SAS) def connect(self, connectionInfo: ConnectionInfo): + """Establish connection based on connection info. + + :param connectionInfo: Connection details including mode, host, port etc. + :note: Initiates either master or slave connection based on mode + """ log.info( f"Initiating connection as {connectionInfo.mode} to {connectionInfo.hostname}:{connectionInfo.port}", ) @@ -155,6 +177,10 @@ def connect(self, connectionInfo: ConnectionInfo): self.connectAsSlave(connectionInfo) def disconnect(self): + """Close all active connections and clean up resources. + + :note: Closes local control server and both master/slave sessions if active + """ if self.masterSession is None and self.slaveSession is None: log.debug("Disconnect called but no active sessions") return @@ -169,11 +195,13 @@ def disconnect(self): cues.disconnected() def disconnectAsMaster(self): + """Close master session and clean up related resources.""" self.masterSession.close() self.masterSession = None self.masterTransport = None def disconnectAsSlave(self): + """Close slave session and clean up related resources.""" self.slaveSession.close() self.slaveSession = None self.slaveTransport = None @@ -195,6 +223,11 @@ def onConnectAsMasterFailed(self): ) def doConnect(self, evt=None): + """Show connection dialog and handle connection initiation. + + :param evt: Optional wx event object + :note: Displays dialog with previous connections list + """ if evt is not None: evt.Skip() previousConnections = configuration.get_config()["connections"]["last_connected"] @@ -348,12 +381,27 @@ def onSlaveCertificateFailed(self): self.connectAsSlave(connectionInfo=connectionInfo) def startControlServer(self, serverPort, channel): + """Start local relay server for handling connections. + + :param serverPort: Port number to listen on + :param channel: Channel key for authentication + :note: Creates daemon thread to run server + """ self.localControlServer = server.LocalRelayServer(serverPort, channel) serverThread = threading.Thread(target=self.localControlServer.run) serverThread.daemon = True serverThread.start() def process_key_input(self, vkCode=None, scanCode=None, extended=None, pressed=None): + """Process keyboard input and forward to remote if sending keys. + + :param vkCode: Virtual key code + :param scanCode: Scan code + :param extended: Whether this is an extended key + :param pressed: True if key pressed, False if released + :return: True to allow local processing, False to block + :rtype: bool + """ if not self.sendingKeys: return True keyCode = (vkCode, extended) @@ -392,6 +440,11 @@ def process_key_input(self, vkCode=None, scanCode=None, extended=None, pressed=N return False # Don't pass it on def toggleRemoteKeyControl(self, gesture: KeyboardInputGesture): + """Toggle sending keyboard input to remote machine. + + :param gesture: The keyboard gesture that triggered this + :note: Also toggles braille input and mute state + """ if not self.masterTransport: gesture.send() return @@ -410,6 +463,10 @@ def toggleRemoteKeyControl(self, gesture: KeyboardInputGesture): ui.message(_("Controlling local machine.")) def releaseKeys(self): + """Release all pressed keys on the remote machine. + + :note: Sends key-up events for all held modifiers + """ # release all pressed keys in the guest. for k in self.keyModifiers: self.masterTransport.send( @@ -421,6 +478,11 @@ def releaseKeys(self): self.keyModifiers = set() def setReceivingBraille(self, state): + """Enable or disable receiving braille from remote. + + :param state: True to enable remote braille, False to disable + :note: Only enables if master session and braille handler are ready + """ if state and self.masterSession.callbacksAdded and braille.handler.enabled: self.masterSession.registerBrailleInput() self.localMachine.receivingBraille = True @@ -430,7 +492,12 @@ def setReceivingBraille(self, state): @alwaysCallAfter def verifyAndConnect(self, conInfo: ConnectionInfo): - """Verify connection details and establish connection if approved by user.""" + """Verify connection details and establish connection if approved by user. + + :param conInfo: Connection information to verify and use + :note: Shows confirmation dialog before connecting + :raises: Displays error if already connected + """ if self.isConnected() or self.connecting: # Translators: Message shown when trying to connect while already connected. error_msg = _("NVDA Remote is already connected. Disconnect before opening a new connection.") @@ -473,13 +540,26 @@ def verifyAndConnect(self, conInfo: ConnectionInfo): self.connecting = False def isConnected(self): + """Check if there is an active connection. + + :return: True if either slave or master transport is connected + :rtype: bool + """ connector = self.slaveTransport or self.masterTransport if connector is not None: return connector.connected return False def registerLocalScript(self, script): + """Add a script to be handled locally instead of sent to remote. + + :param script: Script function to register + """ self.localScripts.add(script) def unregisterLocalScript(self, script): + """Remove a script from local handling. + + :param script: Script function to unregister + """ self.localScripts.discard(script) diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py index 70e2b15ea2f..99d86955fe7 100644 --- a/source/remoteClient/connectionInfo.py +++ b/source/remoteClient/connectionInfo.py @@ -14,16 +14,18 @@ class URLParsingError(Exception): """Exception raised when URL parsing fails. - This exception is raised when the URL cannot be parsed due to missing or invalid components + Raised when the URL cannot be parsed due to missing or invalid components such as hostname, key, or mode. + + :raises URLParsingError: When URL components are missing or invalid """ class ConnectionMode(StrEnum): - """Enum defining the connection mode for remote connections. + """Defines the connection mode for remote connections. - :cvar MASTER: Controller/master mode - :cvar SLAVE: Controlled/slave mode + :cvar MASTER: Controller mode for controlling the remote system + :cvar SLAVE: Controlled mode for being controlled by remote system """ MASTER = "master" @@ -31,12 +33,12 @@ class ConnectionMode(StrEnum): class ConnectionState(StrEnum): - """Enum defining possible states of a remote connection. + """Defines possible states of a remote connection. - :cvar CONNECTED: Connection is established - :cvar DISCONNECTED: No connection is active - :cvar CONNECTING: Connection attempt in progress - :cvar DISCONNECTING: Disconnection in progress + :cvar CONNECTED: Connection is established and active + :cvar DISCONNECTED: No active connection exists + :cvar CONNECTING: Connection attempt is currently in progress + :cvar DISCONNECTING: Connection termination is in progress """ CONNECTED = "connected" @@ -49,14 +51,17 @@ class ConnectionState(StrEnum): class ConnectionInfo: """Stores and manages remote connection information. - This class handles connection details including hostname, mode, authentication key, - port number and security settings. It provides methods for URL generation and parsing. - - :param hostname: The remote host to connect to - :param mode: The connection mode (master/slave) - :param key: Authentication key for the connection - :param port: Port number to use, defaults to SERVER_PORT - :param insecure: Whether to allow insecure connections, defaults to False + Handles connection details including hostname, mode, authentication key, + port number and security settings. Provides methods for URL generation and parsing. + + :param hostname: Remote host address to connect to + :param mode: Connection mode (master/slave) + :param key: Authentication key for securing the connection + :param port: Port number to use for connection, defaults to SERVER_PORT + :param insecure: Allow insecure connections without SSL/TLS, defaults to False + :raises URLParsingError: When URL components are missing or invalid + :return: A ConnectionInfo instance with the specified connection details + :rtype: ConnectionInfo """ hostname: str @@ -73,9 +78,10 @@ def __post_init__(self) -> None: def fromURL(cls, url: str) -> "ConnectionInfo": """Creates a ConnectionInfo instance from a URL string. - :param url: The URL to parse + :param url: The URL to parse in nvdaremote:// format :raises URLParsingError: If URL cannot be parsed or contains invalid data - :return: A new ConnectionInfo instance + :return: A new ConnectionInfo instance configured from the URL + :rtype: ConnectionInfo """ parsedUrl = urlparse(url) parsedQuery = parse_qs(parsedUrl.query) @@ -100,6 +106,7 @@ def getAddress(self) -> str: """Gets the formatted address string. :return: Address string in format hostname:port, with IPv6 brackets if needed + :rtype: str """ # Handle IPv6 addresses by adding brackets if needed hostname = f"[{self.hostname}]" if ":" in self.hostname else self.hostname diff --git a/source/remoteClient/localMachine.py b/source/remoteClient/localMachine.py index a11ec8b851a..e75c3862b60 100644 --- a/source/remoteClient/localMachine.py +++ b/source/remoteClient/localMachine.py @@ -6,25 +6,21 @@ """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. - -: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 +in response to commands received from remote connections. + +:param speech: Controls speech output and cancellation +:param braille: Handles braille display sharing and input routing +:param audio: Provides feedback through wave files and tones +:param input: Simulates keyboard and system input +:param clipboard: Enables one-way text transfer from remote +:param system: Provides functions like Secure Attention Sequence (SAS) + +The main class :class:`LocalMachine` implements all local control operations that can be triggered by remote NVDA instances. It includes safety features like -muting and uses wxPython's CallAfter for most (but not all) thread synchronization. +muting and uses wxPython's CallAfter for thread synchronization. -.. note:: - This module is part of the NVDA Remote protocol implementation and should - not be used directly outside of the remote connection infrastructure. +: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 @@ -57,16 +53,11 @@ 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. - - .. seealso:: - :meth:`LocalMachine.speak` + :note: Updates NVDA's internal speech state to ensure future speech will not be cancelled. + Required when receiving remote speech commands. + :warning: This is a temporary workaround that modifies internal NVDA state. + May break in future NVDA versions if the speech subsystem changes. + :seealso: :meth:`LocalMachine.speak` """ # workaround as beenCanceled is readonly as of NVDA#12395 speech.speech._speechState.beenCanceled = False @@ -77,43 +68,27 @@ class LocalMachine: 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. + It ensures thread-safe execution and proper state management. - 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. - - :ivar isMuted: When True, most remote commands will be ignored, providing - a way to temporarily disable remote control while maintaining the connection + :ivar isMuted: When True, most remote commands will be ignored :type isMuted: bool - :ivar receivingBraille: When True, braille output comes from the remote - machine instead of local NVDA. This affects both display output and input routing + :ivar receivingBraille: When True, braille output comes from remote :type receivingBraille: bool - :ivar _cachedSizes: Cached braille display sizes from remote - machines, used to negotiate the optimal display size for sharing + :ivar _cachedSizes: Cached braille display sizes from remote machines :type _cachedSizes: Optional[List[int]] - .. 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. + :note: This class is instantiated by the remote session manager and should not + be created directly. All methods are called in response to remote messages. - .. seealso:: - - :class:`session.SlaveSession`: The session class that manages remote connections - - :mod:`transport`: The network transport layer that delivers remote commands + :seealso: + - :class:`session.SlaveSession` - Manages remote connections + - :mod:`transport` - Network transport layer """ def __init__(self) -> None: """Initialize the local machine controller. - Sets up initial state and registers braille display handlers. - - .. note:: - The local machine starts unmuted with local braille enabled. + :note: The local machine starts unmuted with local braille enabled. """ self.isMuted: bool = False self.receivingBraille: bool = False @@ -123,9 +98,8 @@ def __init__(self) -> None: def terminate(self) -> None: """Clean up resources when the local machine controller is terminated. - .. note:: - Unregisters the braille display handler to prevent memory leaks and - ensure proper cleanup when the remote connection ends. + :note: Unregisters the braille display handler to prevent memory leaks and + ensure proper cleanup when the remote connection ends. """ braille.decide_enabled.unregister(self.handleDecideEnabled) @@ -133,11 +107,8 @@ def playWave(self, fileName: str) -> None: """Play a wave file on the local machine. :param fileName: Path to the wave file to play - :type fileName: str - - .. note:: - Sound playback is ignored if the local machine is muted. - The file must exist on the local system. + :note: Sound playback is ignored if the local machine is muted. + The file must exist on the local system. """ if self.isMuted: return @@ -148,16 +119,10 @@ def beep(self, hz: float, length: int, left: int = 50, right: int = 50) -> None: """Play a beep sound on the local machine. :param hz: Frequency of the beep in Hertz - :type hz: float :param length: Duration of the beep in milliseconds - :type length: int - :param left: Left channel volume (0-100), defaults to 50% - :type left: int - :param right: Right channel volume (0-100), defaults to 50% - :type right: int - - .. note:: - Beeps are ignored if the local machine is muted. + :param left: Left channel volume (0-100) + :param right: Right channel volume (0-100) + :note: Beeps are ignored if the local machine is muted. """ if self.isMuted: return @@ -166,9 +131,8 @@ def beep(self, hz: float, length: int, left: int = 50, right: int = 50) -> None: 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. + :note: Speech cancellation is ignored if the local machine is muted. + Uses wx.CallAfter to ensure thread-safe execution. """ if self.isMuted: return @@ -178,11 +142,8 @@ def pauseSpeech(self, switch: bool) -> None: """Pause or resume speech on the local machine. :param switch: True to pause speech, False to resume - :type switch: bool - - .. note:: - Speech control is ignored if the local machine is muted. - Uses wx.CallAfter to ensure thread-safe execution. + :note: Speech control is ignored if the local machine is muted. + Uses wx.CallAfter to ensure thread-safe execution. """ if self.isMuted: return @@ -199,13 +160,9 @@ def speak( subsystem, handling priority and ensuring proper cancellation state. :param sequence: List of speech sequences (text and commands) to speak - :type sequence: SpeechSequence - :param priority: Speech priority level, defaults to NORMAL - :type priority: Spri - - .. note:: - Speech is always queued asynchronously via wx.CallAfter to ensure - thread safety, as this may be called from network threads. + :param priority: Speech priority level + :note: Speech is always queued asynchronously via wx.CallAfter to ensure + thread safety, as this may be called from network threads. """ if self.isMuted: return @@ -219,17 +176,13 @@ def display(self, cells: List[int]) -> None: display, handling display size differences and padding. :param cells: List of braille cells as integers (0-255) - :type cells: List[int] + :note: Only processes cells when: + - receivingBraille is True (display sharing is enabled) + - Local display is connected (displaySize > 0) + - Remote cells fit on local display - .. 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. + 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 @@ -246,10 +199,7 @@ def brailleInput(self, **kwargs: Dict[str, Any]) -> None: Handles both display routing and braille keyboard input. :param kwargs: Gesture parameters passed to BrailleInputGesture - :type kwargs: Dict[str, Any] - - .. note:: - Silently ignores gestures that have no associated action. + :note: Silently ignores gestures that have no associated action. """ try: inputCore.manager.executeGesture(input.BrailleInputGesture(**kwargs)) @@ -260,7 +210,6 @@ def setBrailleDisplay_size(self, sizes: List[int]) -> None: """Cache remote braille display sizes for size negotiation. :param sizes: List of display sizes (cells) from remote machines - :type sizes: List[int] """ self._cachedSizes = sizes @@ -271,9 +220,7 @@ def handleFilterDisplaySize(self, value: int) -> int: finding the smallest positive size among local and remote displays. :param value: Local display size in cells - :type value: int - :returns: The negotiated display size to use - :rtype: int + :return: The negotiated display size to use """ if not self._cachedSizes: return value @@ -286,8 +233,7 @@ def handleFilterDisplaySize(self, value: int) -> int: def handleDecideEnabled(self) -> bool: """Determine if the local braille display should be enabled. - :returns: False if receiving remote braille, True otherwise - :rtype: bool + :return: False if receiving remote braille, True otherwise """ return not self.receivingBraille @@ -300,11 +246,8 @@ def sendKey( """Simulate a keyboard event on the local machine. :param vk_code: Virtual key code to simulate - :type vk_code: Optional[int] :param extended: Whether this is an extended key - :type extended: Optional[bool] :param pressed: True for key press, False for key release - :type pressed: Optional[bool] """ wx.CallAfter(input.sendKey, vk_code, None, extended, pressed) @@ -312,7 +255,6 @@ def setClipboardText(self, text: str) -> None: """Set the local clipboard text from a remote machine. :param text: Text to copy to the clipboard - :type text: str """ cues.clipboardReceived() api.copyToClip(text=text) @@ -320,8 +262,7 @@ def setClipboardText(self, text: str) -> None: def sendSAS(self) -> None: """Simulate a secure attention sequence (e.g. CTRL+ALT+DEL). - .. note:: - SendSAS requires UI Access. If this fails, a warning is displayed. + :note: SendSAS requires UI Access. If this fails, a warning is displayed. """ if hasUiAccess(): ctypes.windll.sas.SendSAS(0) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 40869162ce1..be644dd5581 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -94,14 +94,13 @@ 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 - + :param localMachine: Interface to control local NVDA instance + :param transport: Network transport layer instance + :note: Handles core session tasks: + - Version compatibility checks + - Message of the day handling + - Connection management + - Transport registration """ transport: RelayTransport # The transport layer handling network communication @@ -140,11 +139,10 @@ def __init__( def handleVersionMismatch(self) -> None: """Handle protocol version mismatch between client and 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 + :note: Called when transport detects incompatible protocol versions. + - Displays localized error message + - Closes transport connection + - Prevents further communication """ log.error("Protocol version mismatch detected with relay server") ui.message( @@ -157,21 +155,13 @@ def handleVersionMismatch(self) -> None: 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. + :param motd: Message text to display + :param force_display: If True, always show message even if seen before + :note: Shows message if: + - Not shown before (tracked by hash) + - force_display is True + Used for service announcements, maintenance notices, etc. + Message hashes stored per-server in config. """ if force_display or self.shouldDisplayMotd(motd): gui.messageBox( @@ -182,6 +172,13 @@ def handleMOTD(self, motd: str, force_display=False): ) def shouldDisplayMotd(self, motd: str) -> bool: + """Check if MOTD should be displayed. + + :param motd: Message to check + :return: True if message should be shown + :note: Compares message hash against previously shown messages + stored in config file per server + """ conf = configuration.get_config() connection = self.getConnectionInfo() address = "{host}:{port}".format( @@ -197,23 +194,27 @@ def shouldDisplayMotd(self, motd: str) -> bool: return True def handleClientConnected(self, client: dict[str, Any]) -> None: - """Handle new client connection.""" + """Handle new client connection. + + :param client: Dictionary containing client connection details + :note: Logs connection info and plays connection sound + """ log.info("Client connected: %r", client) cues.clientConnected() def handleClientDisconnected(self, client=None): """Handle client disconnection. - Plays disconnection sound when remote client disconnects. + + :param client: Optional client info dictionary + :note: Plays disconnection sound when remote client disconnects """ cues.clientDisconnected() def getConnectionInfo(self) -> connectionInfo.ConnectionInfo: """Get information about the current connection. - Returns a ConnectionInfo object containing: - - Hostname and port of the relay server - - Channel key for the connection - - Session mode (master/slave) + :return: ConnectionInfo object with server details and session mode + :note: Contains hostname, port, channel key and session mode """ hostname, port = self.transport.address key = self.transport.channel @@ -239,15 +240,13 @@ def __del__(self) -> None: 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. + :ivar masters: Information about connected master clients + :ivar masterDisplaySizes: Braille display sizes of connected masters + :note: Handles: + - Command execution from masters + - Output forwarding to masters + - Multi-master connections + - Braille display coordination """ # Connection mode - always 'slave' @@ -434,17 +433,14 @@ def hasBrailleMasters(self) -> bool: 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. + :ivar slaves: Information about connected slave clients + :note: Handles: + - Control command sending + - Remote output reception + - Sound notification playback + - Client connection management + - Braille display sync + - Input handling patches """ mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.MASTER @@ -566,18 +562,11 @@ def handleDecideExecuteGesture( self, gesture: braille.BrailleDisplayGesture | brailleInput.BrailleInputGesture, ) -> bool: - """ - Handles the decision to execute a gesture by processing the given gesture and sending - the relevant data to the remote client. + """Handle and forward braille gestures to remote client. - Args: - gesture (braille.BrailleDisplayGesture | brailleInput.BrailleInputGesture): - The gesture to be processed, which can be either a Braille display gesture - or a Braille input gesture. - - Returns: - bool: Returns False if the gesture is successfully processed and sent to the - remote client, otherwise returns True. + :param gesture: Braille display or input gesture to process + :return: False if gesture was processed and sent, True otherwise + :note: Extracts gesture details and script info before sending """ if isinstance(gesture, (braille.BrailleDisplayGesture, brailleInput.BrailleInputGesture)): dict = { @@ -626,7 +615,15 @@ def handleDecideExecuteGesture( return True def registerBrailleInput(self) -> None: + """Register handler for braille input gestures. + + :note: Connects to inputCore's gesture execution decision point + """ inputCore.decide_executeGesture.register(self.handleDecideExecuteGesture) def unregisterBrailleInput(self) -> None: + """Unregister handler for braille input gestures. + + :note: Disconnects from inputCore's gesture execution decision point + """ inputCore.decide_executeGesture.unregister(self.handleDecideExecuteGesture) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 130cca587f7..3481c4f924a 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -55,14 +55,17 @@ class RemoteExtensionPoint: 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. + :param extensionPoint: The NVDA extension point to bridge + :type extensionPoint: HandlerRegistrar + :param messageType: The remote message type to send + :type messageType: RemoteMessageType + :param filter: Optional function to transform arguments before sending + :type filter: Optional[Callable[..., dict[str, Any]]] + :param transport: The transport instance (set on registration) + :type transport: Optional[Transport] + + :note: The filter function, if provided, should take (*args, **kwargs) and return + a new kwargs dict to be sent in the message. """ extensionPoint: HandlerRegistrar @@ -74,7 +77,13 @@ 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. + + :param args: Positional arguments from the extension point + :type args: Any + :param kwargs: Keyword arguments from the extension point + :type kwargs: Any + :return: Always returns True to allow other handlers to process the event + :rtype: bool """ if self.filter is not None: # Filter should transform args/kwargs into just the kwargs needed for the message @@ -121,22 +130,30 @@ class Transport: >>> 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 + :param serializer: The serializer instance to use for message encoding/decoding + :type serializer: Serializer + + :ivar connected: True if transport has an active connection + :vartype connected: bool + :ivar successfulConnects: Counter of successful connection attempts + :vartype successfulConnects: int + :ivar connectedEvent: Event that is set when connected + :vartype connectedEvent: threading.Event + :ivar serializer: The message serializer instance + :vartype serializer: Serializer + :ivar inboundHandlers: Registered message handlers + :vartype inboundHandlers: Dict[RemoteMessageType, Callable] + + :cvar transportConnected: Fired after connection is established and ready + :vartype transportConnected: Action + :cvar transportDisconnected: Fired when existing connection is lost + :vartype transportDisconnected: Action + :cvar transportCertificateAuthenticationFailed: Fired when SSL certificate validation fails + :vartype transportCertificateAuthenticationFailed: Action + :cvar transportConnectionFailed: Fired when a connection attempt fails + :vartype transportConnectionFailed: Action + :cvar transportClosing: Fired before transport is shut down + :vartype transportClosing: Action """ connected: bool @@ -175,14 +192,11 @@ def __init__(self, serializer: Serializer) -> None: 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 + :note: Called internally when connection established: + - Increments successful connection counter + - Sets connected flag to True + - Sets connected event + - Notifies transportConnected listeners """ self.successfulConnects += 1 self.connected = True @@ -192,21 +206,15 @@ def onTransportConnected(self) -> None: 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 + :param type: The message type to handle + :param handler: Callback function to process messages of this type + :note: Multiple handlers can be registered for the same type. + Handlers are called asynchronously on wx main thread via CallAfter. + Handler will receive message payload as kwargs. + :example: + >>> def handle_keypress(key_code, pressed): + ... print(f"Key {key_code} {'pressed' if pressed else 'released'}") + >>> transport.registerInbound(RemoteMessageType.key_press, handle_keypress) """ if type not in self.inboundHandlers: log.debug("Creating new handler for %s", type) @@ -217,12 +225,9 @@ def registerInbound(self, type: RemoteMessageType, handler: Callable) -> None: 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 + :param type: The message type to unregister from + :param handler: The handler function to remove + :note: If handler was not registered, this is a no-op """ self.inboundHandlers[type].unregister(handler) log.debug("Unregistered handler for %s", type) @@ -235,10 +240,10 @@ def registerOutbound( ): """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. + :param extensionPoint: The extension point to register + :param messageType: The message type to register the extension point to + :param filter: Optional function to transform message before sending + :note: Filter function should take (*args, **kwargs) and return new kwargs dict """ remoteExtension = RemoteExtensionPoint( extensionPoint=extensionPoint, @@ -265,23 +270,35 @@ class TCPTransport(Transport): 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 + :param serializer: Message serializer instance + :type serializer: Serializer + :param address: Remote address to connect to as (host, port) tuple + :type address: tuple[str, int] + :param timeout: Connection timeout in seconds, defaults to 0 + :type timeout: int, optional + :param insecure: Skip certificate verification, defaults to False + :type insecure: bool, optional + + :ivar buffer: Buffer for incomplete received data + :vartype buffer: bytes + :ivar closed: Whether transport is closed + :vartype closed: bool + :ivar queue: Queue of outbound messages + :vartype queue: Queue[Optional[bytes]] + :ivar insecure: Whether to skip certificate verification + :vartype insecure: bool + :ivar address: Remote address to connect to + :vartype address: tuple[str, int] + :ivar timeout: Connection timeout in seconds + :vartype timeout: int + :ivar serverSock: The SSL socket connection + :vartype serverSock: Optional[ssl.SSLSocket] + :ivar serverSockLock: Lock for thread-safe socket access + :vartype serverSockLock: threading.Lock + :ivar queueThread: Thread handling outbound messages + :vartype queueThread: Optional[threading.Thread] + :ivar reconnectorThread: Thread managing reconnection + :vartype reconnectorThread: ConnectorThread """ buffer: bytes @@ -389,16 +406,15 @@ def createOutboundSocket( 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. + :param host: Remote hostname to connect to + :type host: str + :param port: Remote port number + :type port: int + :param insecure: Skip certificate verification, defaults to False + :type insecure: bool, optional + :return: Configured SSL socket ready for connection + :rtype: ssl.SSLSocket | None + :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) @@ -645,12 +661,12 @@ def __init__( 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 + :param connection_info: ConnectionInfo instance containing connection details + :type connection_info: ConnectionInfo + :param serializer: Serializer instance for message encoding/decoding + :type serializer: Serializer + :return: Configured RelayTransport instance ready for connection + :rtype: RelayTransport """ return cls( serializer=serializer, @@ -661,6 +677,13 @@ def create(cls, connection_info: ConnectionInfo, serializer: Serializer) -> "Rel ) def onConnected(self) -> None: + """Handle successful connection to relay server. + + :note: Called automatically when transport connects: + - Sends protocol version + - Joins channel if specified + - Otherwise requests key generation + """ self.send(RemoteMessageType.PROTOCOL_VERSION, version=self.protocol_version) if self.channel is not None: self.send( @@ -678,14 +701,17 @@ class ConnectorThread(threading.Thread): 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 + :param connector: Transport instance to manage connections for + :type connector: Transport + :param reconnectDelay: Seconds between attempts, defaults to 5 + :type reconnectDelay: int, optional + + :ivar running: Whether thread should continue running + :vartype running: bool + :ivar connector: Transport to manage connections for + :vartype connector: Transport + :ivar reconnectDelay: Seconds to wait between connection attempts + :vartype reconnectDelay: int """ running: bool @@ -718,12 +744,10 @@ def clearQueue(queue: Queue[bytes | None]) -> None: 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. + :param queue: Queue instance to clear + :type queue: Queue[Optional[bytes]] + :note: This function catches and ignores any exceptions that occur + while trying to get items from an empty queue. """ try: while True: diff --git a/source/remoteClient/urlHandler.py b/source/remoteClient/urlHandler.py index ea18e544923..80b75137517 100644 --- a/source/remoteClient/urlHandler.py +++ b/source/remoteClient/urlHandler.py @@ -32,9 +32,9 @@ def _createRegistryStructure(key_handle, data: dict): """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 + :param key_handle: A handle to an open registry key + :param data: Dictionary containing the registry structure to create + :raises OSError: If creating registry keys or setting values fails """ for name, value in data.items(): if isinstance(value, dict): @@ -58,9 +58,9 @@ def _createRegistryStructure(key_handle, data: dict): def _deleteRegistryKeyRecursive(base_key, subkey_path: str): """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 + :param base_key: One of the HKEY_* constants from winreg + :param subkey_path: Full registry path to the key to delete + :raises OSError: If deletion fails for reasons other than key not found """ try: # Try to delete directly first @@ -85,7 +85,10 @@ def _deleteRegistryKeyRecursive(base_key, subkey_path: str): def registerURLHandler(): - """Registers the URL handler in the Windows Registry.""" + """Registers the nvdaremote:// URL protocol handler in the Windows Registry. + + :raises OSError: If registration in the registry fails + """ try: keyPath = r"SOFTWARE\Classes\nvdaremote" with winreg.CreateKey(winreg.HKEY_CURRENT_USER, keyPath) as key: @@ -95,7 +98,10 @@ def registerURLHandler(): def unregisterURLHandler(): - """Unregisters the URL handler from the Windows Registry.""" + """Unregisters the nvdaremote:// URL protocol handler from the Windows Registry. + + :raises OSError: If unregistration from the registry fails + """ try: _deleteRegistryKeyRecursive(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Classes\nvdaremote") except OSError as e: @@ -103,7 +109,11 @@ def unregisterURLHandler(): def URLHandlerPath(): - """Returns the path to the URL handler executable.""" + """Returns the absolute path to the URL handler executable. + + :return: Full path to url_handler.exe + :rtype: str + """ return os.path.join(os.path.split(os.path.abspath(__file__))[0], "url_handler.exe") From 92ff5b0812bda3bd01d2b1a2100544ca32ae870d Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 11:08:46 -0700 Subject: [PATCH 115/203] camel-case process_key_input --- source/remoteClient/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 34deb6edab9..ef35e914dd1 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -75,7 +75,7 @@ def __init__( self.sdHandler.SD_CONNECT_BLOCK_TIMEOUT, ) core.postNvdaStartup.register(self.performAutoconnect) - inputCore.decide_handleRawKey.register(self.process_key_input) + inputCore.decide_handleRawKey.register(self.processKeyInput) def performAutoconnect(self): controlServerConfig = configuration.get_config()["controlserver"] @@ -104,7 +104,7 @@ def terminate(self): self.menu = None self.localScripts.clear() core.postNvdaStartup.unregister(self.performAutoconnect) - inputCore.decide_handleRawKey.unregister(self.process_key_input) + inputCore.decide_handleRawKey.unregister(self.processKeyInput) if not isInstalledCopy(): urlHandler.unregisterURLHandler() @@ -392,7 +392,7 @@ def startControlServer(self, serverPort, channel): serverThread.daemon = True serverThread.start() - def process_key_input(self, vkCode=None, scanCode=None, extended=None, pressed=None): + def processKeyInput(self, vkCode=None, scanCode=None, extended=None, pressed=None): """Process keyboard input and forward to remote if sending keys. :param vkCode: Virtual key code From d7b75d5be664dac1e78b260355cdb246c54eca49 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 13:11:26 -0700 Subject: [PATCH 116/203] Docs and typing for transport and session --- source/remoteClient/session.py | 14 ++--- source/remoteClient/transport.py | 103 +++++++++++-------------------- 2 files changed, 44 insertions(+), 73 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index be644dd5581..b56d3a7bca4 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -107,7 +107,7 @@ class RemoteSession: localMachine: LocalMachine # Interface to control the local NVDA instance # Session mode - either 'master' or 'slave' mode: connectionInfo.ConnectionMode | None = None - callbacksAdded: bool # Whether callbacks are currently registered + callbacksAdded: bool = False # Whether callbacks are currently registered def __init__( self, @@ -152,7 +152,7 @@ def handleVersionMismatch(self) -> None: ) self.transport.close() - def handleMOTD(self, motd: str, force_display=False): + def handleMOTD(self, motd: str, force_display: bool = False) -> None: """Handle Message of the Day from relay server. :param motd: Message text to display @@ -193,7 +193,7 @@ def shouldDisplayMotd(self, motd: str) -> bool: conf["seen_motds"][address] = hashed return True - def handleClientConnected(self, client: dict[str, Any]) -> None: + def handleClientConnected(self, client: dict[str, Any] | None) -> None: """Handle new client connection. :param client: Dictionary containing client connection details @@ -202,7 +202,7 @@ def handleClientConnected(self, client: dict[str, Any]) -> None: log.info("Client connected: %r", client) cues.clientConnected() - def handleClientDisconnected(self, client=None): + def handleClientDisconnected(self, client: dict[str, Any] | None = None) -> None: """Handle client disconnection. :param client: Optional client info dictionary @@ -364,7 +364,7 @@ def handleClientDisconnected(self, client: dict[str, Any]) -> None: if not self.masters: self.unregisterCallbacks() - def setDisplaySize(self, sizes=None): + def setDisplaySize(self, sizes: list[int] | None = None) -> None: self.masterDisplaySizes = ( sizes if sizes else [info.get("braille_numCells", 0) for info in self.masters.values()] ) @@ -374,7 +374,7 @@ def setDisplaySize(self, sizes=None): def handleBrailleInfo( self, name: str | None = None, - numCells: int | None = 0, + numCells: int = 0, origin: int | None = None, ) -> None: if not self.masters.get(origin): @@ -540,7 +540,7 @@ def handleClientDisconnected(self, client=None): def sendBrailleInfo( self, - display: Any = None, + display: braille.BrailleDisplayDriver | None = None, displaySize: int | None = None, ) -> None: if display is None: diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 3481c4f924a..4a900fa6ebc 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -32,7 +32,6 @@ import time from collections.abc import Callable from dataclasses import dataclass -from enum import Enum from logging import getLogger from queue import Queue from typing import Any, Optional @@ -203,7 +202,7 @@ def onTransportConnected(self) -> None: self.connectedEvent.set() self.transportConnected.notify() - def registerInbound(self, type: RemoteMessageType, handler: Callable) -> None: + def registerInbound(self, type: RemoteMessageType, handler: Callable[..., None]) -> None: """Register a handler for incoming messages of a specific type. :param type: The message type to handle @@ -236,8 +235,8 @@ def registerOutbound( self, extensionPoint: HandlerRegistrar, messageType: RemoteMessageType, - filter: Optional[Callable] = None, - ): + filter: Optional[Callable[..., dict[str, Any]]] = None, + ) -> None: """Register an extension point to a message type. :param extensionPoint: The extension point to register @@ -253,7 +252,7 @@ def registerOutbound( remoteExtension.register(self) self.outboundHandlers[messageType] = remoteExtension - def unregisterOutbound(self, messageType: RemoteMessageType): + def unregisterOutbound(self, messageType: RemoteMessageType) -> None: """Unregister an extension point from a message type. Args: @@ -400,21 +399,19 @@ def createOutboundSocket( host: str, port: int, insecure: bool = False, - ) -> ssl.SSLSocket | None: + ) -> ssl.SSLSocket: """Create and configure an SSL socket for outbound connections. Creates a TCP socket with appropriate timeout and keep-alive settings, then wraps it with SSL/TLS encryption. :param host: Remote hostname to connect to - :type host: str :param port: Remote port number - :type port: int :param insecure: Skip certificate verification, defaults to False - :type insecure: bool, optional :return: Configured SSL socket ready for connection - :rtype: ssl.SSLSocket | None :note: The socket is created but not yet connected. Call connect() separately. + :raises socket.error: If socket creation fails + :raises ssl.SSLError: If SSL/TLS setup fails """ if host.lower().endswith(".onion"): serverSock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -443,13 +440,9 @@ def getpeercert( 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. + :param binary_form: If True, return the raw certificate bytes, if False return a parsed dictionary, defaults to False + :return: The peer's certificate, or None if not connected + :raises ssl.SSLError: If certificate retrieval fails """ if self.serverSock is None: return None @@ -458,16 +451,12 @@ def getpeercert( 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. + Reads data from the socket in chunks, handling partial messages and SSL behavior. + Complete messages are passed to parse() for processing. - 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. + :note: Uses non-blocking reads with SSL and 16KB buffer size + :raises socket.error: If socket read fails + :raises ssl.SSLWantReadError: If no SSL data is available """ # This approach may be problematic: # See also server.py handle_data in class Client. @@ -504,15 +493,12 @@ def processIncomingSocketData(self) -> None: 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. + Deserializes message and routes to 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. + :param line: Complete message line to parse + :raises ValueError: If message type is invalid + :note: Messages require 'type' field matching RemoteMessageType + :note: Handlers execute asynchronously on wx main thread """ obj = self.serializer.deserialize(line) if "type" not in obj: @@ -533,12 +519,9 @@ def parse(self, line: bytes) -> None: 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. + :note: Runs in separate thread with thread-safe socket access via serverSockLock + :note: Exits on receiving None or socket error + :raises socket.error: If sending data fails """ while True: item = self.queue.get() @@ -550,19 +533,13 @@ def sendQueue(self) -> None: except socket.error: return - def send(self, type: str | Enum, **kwargs: Any) -> None: + def send(self, type: RemoteMessageType | str, **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. + :param type: Message type, typically a RemoteMessageType enum value + :param kwargs: Message payload data to serialize + :note: Thread-safe and can be called from any thread + :note: Messages are dropped if transport is not connected """ if self.connected: obj = self.serializer.serialize(type=type, **kwargs) @@ -573,14 +550,9 @@ def send(self, type: str | Enum, **kwargs: Any) -> None: 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. + :note: Called internally on errors, unlike close() which is called explicitly + :note: Cleans up queue thread and socket without stopping connector thread """ - """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() @@ -591,7 +563,10 @@ def _disconnect(self) -> None: self.serverSock = None def close(self): - """Close the transport.""" + """Close the transport and stop all threads. + + :note: Stops reconnector thread and cleans up all resources + """ self.transportClosing.notify() self.reconnectorThread.running = False self._disconnect() @@ -662,11 +637,8 @@ def create(cls, connection_info: ConnectionInfo, serializer: Serializer) -> "Rel """Create a RelayTransport from a ConnectionInfo object. :param connection_info: ConnectionInfo instance containing connection details - :type connection_info: ConnectionInfo :param serializer: Serializer instance for message encoding/decoding - :type serializer: Serializer :return: Configured RelayTransport instance ready for connection - :rtype: RelayTransport """ return cls( serializer=serializer, @@ -679,10 +651,9 @@ def create(cls, connection_info: ConnectionInfo, serializer: Serializer) -> "Rel def onConnected(self) -> None: """Handle successful connection to relay server. - :note: Called automatically when transport connects: - - Sends protocol version - - Joins channel if specified - - Otherwise requests key generation + :note: Called automatically when transport connects + :note: Sends protocol version and either joins channel or requests key generation + :raises ValueError: If protocol version is invalid """ self.send(RemoteMessageType.PROTOCOL_VERSION, version=self.protocol_version) if self.channel is not None: From 0c0df36cb7cf0e6ab1f3673bcc9d4202a66dfdb2 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 13:16:33 -0700 Subject: [PATCH 117/203] Sphinx-style docs for `RelayTransport` --- source/remoteClient/transport.py | 64 ++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 4a900fa6ebc..83797d62578 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -580,19 +580,27 @@ class RelayTransport(TCPTransport): 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 + :param serializer: Message serializer instance + :type serializer: Serializer + :param address: Relay server address as (host, port) tuple + :type address: tuple[str, int] + :param timeout: Connection timeout in seconds, defaults to 0 + :type timeout: int, optional + :param channel: Channel name to join, defaults to None + :type channel: str, optional + :param connectionType: Type of relay connection, defaults to None + :type connectionType: str, optional + :param protocol_version: Protocol version to use, defaults to PROTOCOL_VERSION + :type protocol_version: int, optional + :param insecure: Skip certificate verification, defaults to False + :type insecure: bool, optional + + :ivar channel: Relay channel name + :vartype channel: str or None + :ivar connectionType: Type of relay connection + :vartype connectionType: str or None + :ivar protocol_version: Protocol version in use + :vartype protocol_version: int """ channel: str | None @@ -611,14 +619,20 @@ def __init__( ) -> 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 + :param serializer: Serializer for encoding/decoding messages + :type serializer: Serializer + :param address: Tuple of (host, port) to connect to + :type address: tuple[str, int] + :param timeout: Connection timeout in seconds, defaults to 0 + :type timeout: int, optional + :param channel: Channel name to join, defaults to None + :type channel: str, optional + :param connectionType: Connection type identifier, defaults to None + :type connectionType: str, optional + :param protocol_version: Protocol version to use, defaults to PROTOCOL_VERSION + :type protocol_version: int, optional + :param insecure: Whether to skip certificate verification, defaults to False + :type insecure: bool, optional """ super().__init__( address=address, @@ -637,8 +651,11 @@ def create(cls, connection_info: ConnectionInfo, serializer: Serializer) -> "Rel """Create a RelayTransport from a ConnectionInfo object. :param connection_info: ConnectionInfo instance containing connection details + :type connection_info: ConnectionInfo :param serializer: Serializer instance for message encoding/decoding + :type serializer: Serializer :return: Configured RelayTransport instance ready for connection + :rtype: RelayTransport """ return cls( serializer=serializer, @@ -651,8 +668,9 @@ def create(cls, connection_info: ConnectionInfo, serializer: Serializer) -> "Rel def onConnected(self) -> None: """Handle successful connection to relay server. - :note: Called automatically when transport connects - :note: Sends protocol version and either joins channel or requests key generation + Called automatically when transport connects. Sends protocol version and + either joins channel or requests key generation. + :raises ValueError: If protocol version is invalid """ self.send(RemoteMessageType.PROTOCOL_VERSION, version=self.protocol_version) From 79ad5d98d347b407a1c3308cff5bdbfc140cec1e Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 17:21:08 -0700 Subject: [PATCH 118/203] Docstring update Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/globalCommands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/globalCommands.py b/source/globalCommands.py index e7c7feec5e4..8dbe9b78dc3 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4925,7 +4925,7 @@ def script_copyRemoteLink(self, gesture: "inputCore.InputGesture"): gesture="kb:alt+NVDA+pageDown", category=SCRCAT_REMOTE, # Translators: Documentation string for the script that disconnects a remote session. - description=_("""Disconnect a remote session"""), + description=_("Disconnect a remote session"), ) @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) def script_disconnectFromRemote(self, gesture: "inputCore.InputGesture"): From 387a91d351ec16ec7989d1bd251f9c695a612bef Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 17:21:41 -0700 Subject: [PATCH 119/203] Better typing for URL Handler Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/urlHandler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/urlHandler.py b/source/remoteClient/urlHandler.py index 80b75137517..c6d6f773ec6 100644 --- a/source/remoteClient/urlHandler.py +++ b/source/remoteClient/urlHandler.py @@ -29,7 +29,7 @@ log = getLogger("url_handler") -def _createRegistryStructure(key_handle, data: dict): +def _createRegistryStructure(keyHandle: HKEYType, data: dict): """Creates a nested registry structure from a dictionary. :param key_handle: A handle to an open registry key From a51609061a04345e2f59898fca6148212f97fc6f Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 20:15:39 -0700 Subject: [PATCH 120/203] Fix registry handler type annotations and variable naming in url handler --- source/remoteClient/urlHandler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/remoteClient/urlHandler.py b/source/remoteClient/urlHandler.py index c6d6f773ec6..2b076ff729c 100644 --- a/source/remoteClient/urlHandler.py +++ b/source/remoteClient/urlHandler.py @@ -29,7 +29,7 @@ log = getLogger("url_handler") -def _createRegistryStructure(keyHandle: HKEYType, data: dict): +def _createRegistryStructure(keyHandle: winreg.HKEYType, data: dict): """Creates a nested registry structure from a dictionary. :param key_handle: A handle to an open registry key @@ -40,7 +40,7 @@ def _createRegistryStructure(keyHandle: HKEYType, data: dict): if isinstance(value, dict): # Create and recursively populate subkey try: - subkey = winreg.CreateKey(key_handle, name) + subkey = winreg.CreateKey(keyHandle, name) try: _createRegistryStructure(subkey, value) finally: @@ -50,7 +50,7 @@ def _createRegistryStructure(keyHandle: HKEYType, data: dict): else: # Set value try: - winreg.SetValueEx(key_handle, name, 0, winreg.REG_SZ, str(value)) + winreg.SetValueEx(keyHandle, name, 0, winreg.REG_SZ, str(value)) except WindowsError as e: raise OSError(f"Failed to set registry value {name}: {e}") From 79ef0f939157fea59e85604f8a585bdcb2d6d79a Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 20:17:09 -0700 Subject: [PATCH 121/203] refactor: standardize naming conventions and type hints in RemoteClient server - Rename variables and methods to follow camelCase convention consistently - Update type hints to use simplified dict/list syntax - Replace exists() with is_file() for more accurate file checks - Improve overall code style consistency in RemoteCertificateManager and LocalRelayServer classes This is a style-only change with no functional modifications. --- source/remoteClient/server.py | 78 +++++++++++++++++------------------ 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 20144620862..f9b8d0d98d1 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -57,15 +57,15 @@ class RemoteCertificateManager: 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 + self.certDir = cert_dir or getProgramDataTempPath() + self.certPath = self.certDir / self.CERT_FILE + self.keyPath = self.certDir / self.KEY_FILE + self.fingerprintPath = self.certDir / self.FINGERPRINT_FILE def ensureValidCertExists(self) -> None: """Ensures a valid certificate and key exist, regenerating if needed.""" log.info("Checking certificate validity") - os.makedirs(self.cert_dir, exist_ok=True) + os.makedirs(self.certDir, exist_ok=True) should_generate = False if not self._filesExist(): @@ -82,14 +82,14 @@ def ensureValidCertExists(self) -> None: def _filesExist(self) -> bool: """Check if both certificate and key files exist.""" - return self.cert_path.exists() and self.key_path.exists() + return self.certPath.is_file() and self.keyPath.is_file() 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) + with open(self.certPath, "rb") as f: + certData = f.read() + cert = x509.load_pem_x509_certificate(certData) # Check validity period now = datetime.utcnow() @@ -97,17 +97,17 @@ def _validateCertificate(self) -> None: 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: + timeRemaining = cert.not_valid_after - now + if timeRemaining.days <= self.CERT_RENEWAL_THRESHOLD_DAYS: raise ValueError("Certificate is approaching expiration") # Verify private key can be loaded - with open(self.key_path, "rb") as f: + with open(self.keyPath, "rb") as f: serialization.load_pem_private_key(f.read(), password=None) def _generateSelfSignedCert(self) -> None: """Generates a self-signed certificate and private key.""" - private_key = rsa.generate_private_key( + privateKey = rsa.generate_private_key( public_exponent=65537, key_size=2048, ) @@ -128,7 +128,7 @@ def _generateSelfSignedCert(self) -> None: issuer, ) .public_key( - private_key.public_key(), + privateKey.public_key(), ) .serial_number( x509.random_serial_number(), @@ -151,15 +151,15 @@ def _generateSelfSignedCert(self) -> None: ), critical=False, ) - .sign(private_key, hashes.SHA256()) + .sign(privateKey, hashes.SHA256()) ) # Calculate fingerprint fingerprint = cert.fingerprint(hashes.SHA256()).hex() # Write private key - with open(self.key_path, "wb") as f: + with open(self.keyPath, "wb") as f: f.write( - private_key.private_bytes( + privateKey.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), @@ -167,11 +167,11 @@ def _generateSelfSignedCert(self) -> None: ) # Write certificate - with open(self.cert_path, "wb") as f: + with open(self.certPath, "wb") as f: f.write(cert.public_bytes(serialization.Encoding.PEM)) # Save fingerprint - with open(self.fingerprint_path, "w") as f: + with open(self.fingerprintPath, "w") as f: f.write(fingerprint) # Add to trusted certificates in config @@ -183,11 +183,11 @@ def _generateSelfSignedCert(self) -> None: log.info("Generated new self-signed certificate for NVDA Remote. " f"Fingerprint: {fingerprint}") - def get_current_fingerprint(self) -> Optional[str]: + def getCurrentFingerprint(self) -> Optional[str]: """Get the fingerprint of the current certificate.""" try: - if self.fingerprint_path.exists(): - with open(self.fingerprint_path, "r") as f: + if self.fingerprintPath.exists(): + with open(self.fingerprintPath, "r") as f: return f.read().strip() except Exception as e: log.warning(f"Error reading fingerprint: {e}", exc_info=True) @@ -198,11 +198,11 @@ def createSSLContext(self) -> ssl.SSLContext: 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), + certfile=str(self.certPath), + keyfile=str(self.keyPath), ) # Trust our own CA for server verification - context.load_verify_locations(cafile=str(self.cert_path)) + context.load_verify_locations(cafile=str(self.certPath)) # Require client cert verification context.verify_mode = ssl.CERT_NONE # Don't require client certificates context.check_hostname = False # Don't verify hostname since we're using self-signed certs @@ -234,13 +234,13 @@ def __init__( ): self.port = port self.password = password - self.cert_manager = RemoteCertificateManager(cert_dir) - self.cert_manager.ensureValidCertExists() + self.certManager = RemoteCertificateManager(cert_dir) + self.certManager.ensureValidCertExists() # Initialize other server components self.serializer = JSONSerializer() - self.clients: Dict[socket.socket, Client] = {} - self.clientSockets: List[socket.socket] = [] + self.clients: dict[socket.socket, Client] = {} + self.clientSockets: list[socket.socket] = [] self._running = False self.lastPingTime = 0 @@ -259,8 +259,8 @@ def __init__( 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) + sslContext = self.certManager.createSSLContext() + serverSocket = sslContext.wrap_socket(serverSocket, server_side=True) serverSocket.bind(bind_addr) serverSocket.listen(5) return serverSocket @@ -317,7 +317,7 @@ def clientDisconnected(self, client: "Client") -> None: log.info(f"Client {client.id} disconnected") self.removeClient(client) if client.authenticated: - client.send_to_others( + client.sendToOthers( type="client_left", user_id=client.id, client=client.asDict(), @@ -356,16 +356,16 @@ def __init__(self, server: LocalRelayServer, socket: ssl.SSLSocket): def handleData(self) -> None: """Process incoming data from the client socket.""" - sock_data = b"" + sockData = b"" try: - sock_data = self.socket.recv(16384) + sockData = self.socket.recv(16384) except Exception: self.close() return - if not sock_data: # Disconnect + if not sockData: # Disconnect self.close() return - data = self.buffer + sock_data + data = self.buffer + sockData if b"\n" not in data: self.buffer = data return @@ -386,7 +386,7 @@ def parse(self, line: bytes) -> None: if "type" not in parsed: return if self.authenticated: - self.send_to_others(**parsed) + self.sendToOthers(**parsed) return fn = "do_" + parsed["type"] if hasattr(self, fn): @@ -423,7 +423,7 @@ def do_join(self, obj: Dict[str, Any]) -> None: user_ids=client_ids, clients=clients, ) - self.send_to_others( + self.sendToOthers( type="client_joined", user_id=self.id, client=self.asDict(), @@ -465,7 +465,7 @@ def send( 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: + def sendToOthers(self, origin: Optional[int] = None, **obj: Any) -> None: """Send a message to all other authenticated clients.""" if origin is None: origin = self.id From 5410b993e12aa32b55dcf42b83a6f149a39bf4b6 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 20:18:42 -0700 Subject: [PATCH 122/203] secureDesktop: Create dedicated NVDA directory for IPC files --- source/remoteClient/secureDesktop.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 5a86ddafa73..ce2543490f8 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -50,16 +50,18 @@ class SecureDesktopHandler: SD_CONNECT_BLOCK_TIMEOUT: int = 1 - def __init__(self, temp_path: Path = getProgramDataTempPath()) -> None: + def __init__(self, tempPath: Path = getProgramDataTempPath()) -> None: """ Initialize secure desktop handler. Args: - temp_path: Path to temporary directory for IPC file. Defaults to program data temp path. + tempPath: 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.tempPath = tempPath + self.IPCPath: Path = self.tempPath / "NVDA" + self.IPCPath.mkdir(parents=True, exist_ok=True) + self.IPCFile = self.IPCPath / "remote.ipc" + log.debug("Initialized SecureDesktopHandler with IPC file: %s", self.IPCFile) self._slaveSession: Optional[SlaveSession] = None self.sdServer: Optional[server.LocalRelayServer] = None @@ -74,7 +76,7 @@ def terminate(self) -> None: post_secureDesktopStateChange.unregister(self._onSecureDesktopChange) self.leaveSecureDesktop() try: - log.debug(f"Removing IPC file: {self.IPCFile}") + log.debug("Removing IPC file: %s", self.IPCFile) self.IPCFile.unlink() except FileNotFoundError: log.debug("IPC file already removed") @@ -136,7 +138,7 @@ def enterSecureDesktop(self) -> None: 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}") + log.info("Local relay server started on port %d", port) serverThread = threading.Thread(target=self.sdServer.run) serverThread.daemon = True From 5714783e3a28fee0ef0c3df5f8b88cd0cf0d4118 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 20:59:11 -0700 Subject: [PATCH 123/203] Sphinx-style docs for server --- source/remoteClient/server.py | 41 ++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index f9b8d0d98d1..7b9d55172f9 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -48,7 +48,13 @@ class RemoteCertificateManager: - """Manages SSL certificates for the NVDA Remote relay server.""" + """Manages SSL certificates for the NVDA Remote relay server. + + :ivar certDir: Directory where certificates and keys are stored + :ivar certPath: Path to the certificate file + :ivar keyPath: Path to the private key file + :ivar fingerprintPath: Path to the fingerprint file + """ CERT_FILE = "NvdaRemoteRelay.pem" KEY_FILE = "NvdaRemoteRelay.key" @@ -214,12 +220,17 @@ class LocalRelayServer: 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). + Uses select() for non-blocking I/O and monitors connection health with periodic pings. Clients must authenticate by providing the correct channel password in their join message before they can exchange messages. Both IPv4 and IPv6 clients share the same channel and can interact with each other transparently. + + :ivar port: Port number to listen on + :ivar password: Channel password for client authentication + :ivar clients: Dictionary mapping sockets to Client objects + :ivar clientSockets: List of client sockets + :ivar PING_TIME: Seconds between ping messages """ PING_TIME: int = 300 @@ -257,7 +268,14 @@ def __init__( ) def createServerSocket(self, family: int, type: int, bind_addr: Tuple[str, int]) -> ssl.SSLSocket: - """Creates an SSL wrapped socket using the certificate.""" + """Creates an SSL wrapped socket using the certificate. + + :param family: Socket address family (AF_INET or AF_INET6) + :param type: Socket type (typically SOCK_STREAM) + :param bind_addr: Tuple of (host, port) to bind to + :return: SSL wrapped server socket + :raises socket.error: If socket creation or binding fails + """ serverSocket = socket.socket(family, type) sslContext = self.certManager.createSSLContext() serverSocket = sslContext.wrap_socket(serverSocket, server_side=True) @@ -266,7 +284,13 @@ def createServerSocket(self, family: int, type: int, bind_addr: Tuple[str, int]) return serverSocket def run(self) -> None: - """Main server loop that handles client connections and message routing.""" + """Main server loop that handles client connections and message routing. + + Continuously accepts new connections and processes messages from connected clients. + Sends periodic ping messages to maintain connection health. + + :raises socket.error: If there are socket communication errors + """ log.info(f"Starting NVDA Remote relay server on ports {self.port} (IPv4) " f"and {self.port} (IPv6)") self._running = True self.lastPingTime = time.time() @@ -339,6 +363,13 @@ class Client: records client protocol version, and routes messages to other connected clients. Maintains a buffer of received data and processes complete messages delimited by newlines. + + :ivar id: Unique client identifier + :ivar socket: SSL socket for this client connection + :ivar buffer: Buffer for incomplete received data + :ivar authenticated: Whether client has authenticated successfully + :ivar connectionType: Type of client connection + :ivar protocolVersion: Client protocol version number """ id: int = 0 From e489eef147a22496c3d5d36624ad16e34066c9e5 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 21:09:09 -0700 Subject: [PATCH 124/203] Apply feedback from server review --- source/remoteClient/server.py | 62 +++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 7b9d55172f9..8a695d25364 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -24,27 +24,26 @@ 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 +import cffi # noqa # required for cryptography +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from logHandler import log + +from . import configuration from .protocol import RemoteMessageType -from .serializer import JSONSerializer from .secureDesktop import getProgramDataTempPath -from . import configuration +from .serializer import JSONSerializer class RemoteCertificateManager: @@ -342,7 +341,7 @@ def clientDisconnected(self, client: "Client") -> None: self.removeClient(client) if client.authenticated: client.sendToOthers( - type="client_left", + type=RemoteMessageType.CLIENT_LEFT, user_id=client.id, client=client.asDict(), ) @@ -431,7 +430,7 @@ 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}") + log.warning("Client %s sent incorrect password", self.id) self.send( type=RemoteMessageType.ERROR, message="incorrect_password", @@ -443,11 +442,11 @@ def do_join(self, obj: Dict[str, Any]) -> None: 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: + for client in list(self.server.clients.values()): + if client is self or not client.authenticated: continue - clients.append(c.asDict()) - client_ids.append(c.id) + clients.append(client.asDict()) + client_ids.append(client.id) self.send( type=RemoteMessageType.CHANNEL_JOINED, channel=self.server.password, @@ -455,7 +454,7 @@ def do_join(self, obj: Dict[str, Any]) -> None: clients=clients, ) self.sendToOthers( - type="client_joined", + type=RemoteMessageType.CLIENT_JOINED, user_id=self.id, client=self.asDict(), ) @@ -474,13 +473,21 @@ def close(self) -> None: def send( self, - type: str | Enum, - origin: Optional[int] = None, - clients: Optional[List[Dict[str, Any]]] = None, - client: Optional[Dict[str, Any]] = None, + type: str | RemoteMessageType, + origin: int | None = None, + clients: List[Dict[str, Any]] | None = None, + client: dict[str, Any] | None = None, **kwargs: Any, ) -> None: - """Send a message to this client.""" + """Send a message to this client. + + :param type: Message type + :param origin: Originating client ID + :param clients: List of connected clients + :param client: Client information + + :note: Additional keyword arguments are included in the message data. + """ msg = kwargs if self.protocolVersion > 1: if origin: @@ -496,10 +503,15 @@ def send( log.error(f"Error sending message to client {self.id}", exc_info=True) self.close() - def sendToOthers(self, origin: Optional[int] = None, **obj: Any) -> None: - """Send a message to all other authenticated clients.""" + def sendToOthers(self, origin: int | None = None, **payload: Any) -> None: + """Send a message to all other authenticated clients. + + :param origin: Originating client ID + :param payload: Message data + """ + if origin is None: origin = self.id for c in self.server.clients.values(): if c is not self and c.authenticated: - c.send(origin=origin, **obj) + c.send(origin=origin, **payload) From 45886f80be981fad892cc04a3ac531e5c2d47c23 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 21:19:04 -0700 Subject: [PATCH 125/203] refactor: Improve type hints and client ID generation --- source/remoteClient/server.py | 36 +++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 8a695d25364..f2972afdda7 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -31,7 +31,8 @@ from datetime import datetime, timedelta from pathlib import Path from select import select -from typing import Any, Dict, List, Optional, Tuple +from itertools import count +from typing import Any, Dict import cffi # noqa # required for cryptography from cryptography import x509 @@ -61,8 +62,8 @@ class RemoteCertificateManager: CERT_DURATION_DAYS = 365 CERT_RENEWAL_THRESHOLD_DAYS = 30 - def __init__(self, cert_dir: Optional[Path] = None): - self.certDir = cert_dir or getProgramDataTempPath() + def __init__(self, certDir: Path | None = None): + self.certDir = certDir or getProgramDataTempPath() self.certPath = self.certDir / self.CERT_FILE self.keyPath = self.certDir / self.KEY_FILE self.fingerprintPath = self.certDir / self.FINGERPRINT_FILE @@ -188,7 +189,7 @@ def _generateSelfSignedCert(self) -> None: log.info("Generated new self-signed certificate for NVDA Remote. " f"Fingerprint: {fingerprint}") - def getCurrentFingerprint(self) -> Optional[str]: + def getCurrentFingerprint(self) -> str | None: """Get the fingerprint of the current certificate.""" try: if self.fingerprintPath.exists(): @@ -240,7 +241,7 @@ def __init__( password: str, bind_host: str = "", bind_host6: str = "[::]:", - cert_dir: Optional[Path] = None, + cert_dir: Path | None = None, ): self.port = port self.password = password @@ -266,7 +267,7 @@ def __init__( bind_addr=(bind_host6, self.port), ) - def createServerSocket(self, family: int, type: int, bind_addr: Tuple[str, int]) -> ssl.SSLSocket: + def createServerSocket(self, family: int, type: int, bind_addr: tuple[str, int]) -> ssl.SSLSocket: """Creates an SSL wrapped socket using the certificate. :param family: Socket address family (AF_INET or AF_INET6) @@ -371,18 +372,17 @@ class Client: :ivar protocolVersion: Client protocol version number """ - id: int = 0 + _id_counter = count(1) - 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 __init__(self, server: LocalRelayServer, socket: ssl.SSLSocket) -> None: + self.server: LocalRelayServer = server + self.socket: ssl.SSLSocket = socket + self.buffer: bytes = b"" + self.serializer: JSONSerializer = server.serializer + self.authenticated: bool = False + self.id: int = next(self._id_counter) + self.connectionType: str | None = None + self.protocolVersion: int = 1 def handleData(self) -> None: """Process incoming data from the client socket.""" @@ -475,7 +475,7 @@ def send( self, type: str | RemoteMessageType, origin: int | None = None, - clients: List[Dict[str, Any]] | None = None, + clients: list[dict[str, Any]] | None = None, client: dict[str, Any] | None = None, **kwargs: Any, ) -> None: From 31f02921d97d2ee81905153dc9cd1c84cd8e5a78 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 21:24:38 -0700 Subject: [PATCH 126/203] Add explanatory comment Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index f2972afdda7..6d38e95e9d0 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -322,6 +322,7 @@ def acceptNewConnection(self, sock: ssl.SSLSocket) -> None: except (ssl.SSLError, socket.error, OSError): log.error("Error accepting connection", exc_info=True) return + # Disable Nagle's algorithm so that packets are always sent immediately. clientSock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) client = Client(server=self, socket=clientSock) self.addClient(client) From d7a28495b4403d81778931a49e2de71eb87cc7ba Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Feb 2025 21:35:35 -0700 Subject: [PATCH 127/203] refactor: modernize type hints in remoteClient server --- source/remoteClient/server.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 6d38e95e9d0..ebd79479045 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -32,7 +32,7 @@ from pathlib import Path from select import select from itertools import count -from typing import Any, Dict +from typing import Any import cffi # noqa # required for cryptography from cryptography import x509 @@ -63,10 +63,10 @@ class RemoteCertificateManager: CERT_RENEWAL_THRESHOLD_DAYS = 30 def __init__(self, certDir: Path | None = None): - self.certDir = certDir or getProgramDataTempPath() - self.certPath = self.certDir / self.CERT_FILE - self.keyPath = self.certDir / self.KEY_FILE - self.fingerprintPath = self.certDir / self.FINGERPRINT_FILE + self.certDir: Path = certDir or getProgramDataTempPath() + self.certPath: Path = self.certDir / self.CERT_FILE + self.keyPath: Path = self.certDir / self.KEY_FILE + self.fingerprintPath: Path = self.certDir / self.FINGERPRINT_FILE def ensureValidCertExists(self) -> None: """Ensures a valid certificate and key exist, regenerating if needed.""" @@ -423,11 +423,11 @@ def parse(self, line: bytes) -> None: if hasattr(self, fn): getattr(self, fn)(parsed) - def asDict(self) -> Dict[str, Any]: + 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: + 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: @@ -460,7 +460,7 @@ def do_join(self, obj: Dict[str, Any]) -> None: client=self.asDict(), ) - def do_protocol_version(self, obj: Dict[str, Any]) -> None: + def do_protocol_version(self, obj: dict[str, Any]) -> None: """Record client's protocol version.""" version = obj.get("version") if not version: From d24fca534b76b8eb586f1147d8b7c9fd441e63b9 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 3 Feb 2025 23:07:16 -0700 Subject: [PATCH 128/203] Add message if sending clipboard fails --- source/remoteClient/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index ef35e914dd1..5a79a3adaac 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -138,6 +138,8 @@ def pushClipboard(self): cues.clipboardPushed() except TypeError: log.exception("Unable to push clipboard") + # Translators: Message shown when clipboard content cannot be sent to the remote computer. + ui.message(_("Unable to push clipboard")) def copyLink(self): """Copy connection URL to clipboard. From dfa9d7e97f787866be0df70a7ed5f5cc9e5d180c Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 3 Feb 2025 23:10:54 -0700 Subject: [PATCH 129/203] Proper method to get temp path Co-authored-by: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> --- source/remoteClient/secureDesktop.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index ce2543490f8..be10d1bb483 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -36,9 +36,7 @@ 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" + return Path(shlobj.SHGetKnownFolderPath(shlobj.FolderId.PROGRAM_DATA)) / "temp" class SecureDesktopHandler: From 0467889f0591471b8ea45aa7b75204e937636334 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 3 Feb 2025 23:20:10 -0700 Subject: [PATCH 130/203] docs: improve secure desktop module documentation --- source/remoteClient/secureDesktop.py | 40 +++++++++++++--------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index be10d1bb483..da556c4ea7c 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -5,14 +5,18 @@ """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 +Handles the transition between regular and secure desktop sessions in Windows, +maintaining remote connections across these transitions. Manages the creation of local relay servers, connection bridging, and IPC (Inter-Process Communication) between the regular and secure desktop instances of NVDA. The secure desktop is a special Windows session used for UAC prompts and login screens that runs in an isolated environment for security. This module ensures NVDA Remote connections persist when entering and leaving this secure environment. + +Note: + All IPC operations use a temporary file in the system's ProgramData directory + to exchange connection information between sessions. """ import json @@ -35,7 +39,10 @@ def getProgramDataTempPath() -> Path: - """Get the system's program data temp directory path.""" + """Get the system's program data temp directory path. + + :return: Path to the ProgramData temp directory + """ return Path(shlobj.SHGetKnownFolderPath(shlobj.FolderId.PROGRAM_DATA)) / "temp" @@ -44,16 +51,16 @@ class SecureDesktopHandler: Handles relay servers, IPC, and connection bridging between regular and secure desktop sessions. + + :cvar SD_CONNECT_BLOCK_TIMEOUT: Timeout in seconds for secure desktop connection attempts """ SD_CONNECT_BLOCK_TIMEOUT: int = 1 def __init__(self, tempPath: Path = getProgramDataTempPath()) -> None: - """ - Initialize secure desktop handler. + """Initialize secure desktop handler. - Args: - tempPath: Path to temporary directory for IPC file. Defaults to program data temp path. + :param tempPath: Directory for IPC file storage """ self.tempPath = tempPath self.IPCPath: Path = self.tempPath / "NVDA" @@ -86,12 +93,7 @@ def slaveSession(self) -> Optional[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 - """ + """Update slave session reference and handle necessary cleanup/setup.""" if self._slaveSession == session: log.debug("Slave session unchanged, skipping update") return @@ -110,11 +112,9 @@ def slaveSession(self, session: Optional[SlaveSession]) -> None: ) def _onSecureDesktopChange(self, isSecureDesktop: Optional[bool] = None) -> None: - """ - Internal callback for secure desktop state changes. + """Internal callback for secure desktop state changes. - Args: - isSecureDesktop: True if transitioning to secure desktop, False otherwise + :param isSecureDesktop: True if transitioning to secure desktop, False otherwise """ log.info(f"Secure desktop state changed: {'entering' if isSecureDesktop else 'leaving'}") if isSecureDesktop: @@ -198,11 +198,9 @@ def leaveSecureDesktop(self) -> None: pass def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: - """ - Initialize connection when starting in secure desktop. + """Initialize connection when starting in secure desktop. - Returns: - ConnectionInfo instance if successful, None otherwise + :return: Connection information if successful, None on failure """ log.info("Initializing secure desktop connection") try: From 0880a78a9e15dde0fbc69c2a1a0f2b45ef9d277c Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Wed, 5 Feb 2025 22:02:47 -0700 Subject: [PATCH 131/203] Mark the global `RemoteClient` reference as private --- source/NVDAHelper.py | 2 +- source/globalCommands.py | 16 ++++++++-------- source/remoteClient/__init__.py | 14 +++++++------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/source/NVDAHelper.py b/source/NVDAHelper.py index 61beac72ec9..8f1c93fa060 100755 --- a/source/NVDAHelper.py +++ b/source/NVDAHelper.py @@ -694,7 +694,7 @@ def nvdaControllerInternal_handleRemoteURL(url): :param url: The nvdaremote:// URL to process :return: 0 on success, -1 on failure """ - from remoteClient import connectionInfo, remoteClient as client + from remoteClient import connectionInfo, _remoteClient as client try: if not client: diff --git a/source/globalCommands.py b/source/globalCommands.py index 8dbe9b78dc3..d72990e422c 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4900,7 +4900,7 @@ def script_toggleApplicationsMute(self, gesture: "inputCore.InputGesture") -> No category=SCRCAT_REMOTE, ) def script_toggleRemoteMute(self, gesture: "inputCore.InputGesture"): - remoteClient.remoteClient.toggleMute() + remoteClient._remoteClient.toggleMute() @script( gesture="kb:control+shift+NVDA+c", @@ -4909,7 +4909,7 @@ def script_toggleRemoteMute(self, gesture: "inputCore.InputGesture"): description=_("Sends the contents of the clipboard to the remote machine"), ) def script_pushClipboard(self, gesture: "inputCore.InputGesture"): - remoteClient.remoteClient.pushClipboard() + remoteClient._remoteClient.pushClipboard() @script( # Translators: Documentation string for the script that copies a link to the remote session to the clipboard. @@ -4917,7 +4917,7 @@ def script_pushClipboard(self, gesture: "inputCore.InputGesture"): category=SCRCAT_REMOTE, ) def script_copyRemoteLink(self, gesture: "inputCore.InputGesture"): - remoteClient.remoteClient.copyLink() + remoteClient._remoteClient.copyLink() # Translators: A message indicating that a link has been copied to the clipboard. ui.message(_("Copied link")) @@ -4929,11 +4929,11 @@ def script_copyRemoteLink(self, gesture: "inputCore.InputGesture"): ) @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) def script_disconnectFromRemote(self, gesture: "inputCore.InputGesture"): - if not remoteClient.remoteClient.isConnected: + if not remoteClient._remoteClient.isConnected: # Translators: A message indicating that the remote client is not connected. ui.message(_("Not connected")) return - remoteClient.remoteClient.disconnect() + remoteClient._remoteClient.disconnect() @script( gesture="kb:alt+NVDA+pageUp", @@ -4944,11 +4944,11 @@ def script_disconnectFromRemote(self, gesture: "inputCore.InputGesture"): @gui.blockAction.when(gui.blockAction.Context.MODAL_DIALOG_OPEN) @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) def script_connectToRemote(self, gesture): - if remoteClient.remoteClient.isConnected() or remoteClient.remoteClient.connecting: + if remoteClient._remoteClient.isConnected() or remoteClient._remoteClient.connecting: # Translators: A message indicating that the remote client is already connected. ui.message(_("Already connected")) return - remoteClient.remoteClient.doConnect() + remoteClient._remoteClient.doConnect() @script( # Translators: Documentation string for the script that toggles the control between guest and host machine. @@ -4957,7 +4957,7 @@ def script_connectToRemote(self, gesture): gesture="kb:NVDA+f11", ) def script_sendKeys(self, gesture): - remoteClient.remoteClient.toggleRemoteKeyControl(gesture) + remoteClient._remoteClient.toggleRemoteKeyControl(gesture) #: The single global commands instance. diff --git a/source/remoteClient/__init__.py b/source/remoteClient/__init__.py index 2e2fc550eb0..9278c7cf02f 100644 --- a/source/remoteClient/__init__.py +++ b/source/remoteClient/__init__.py @@ -5,20 +5,20 @@ from .client import RemoteClient -remoteClient: RemoteClient = None +_remoteClient: RemoteClient = None def initialize(): """Initialise the remote client.""" - global remoteClient + global _remoteClient import globalCommands - remoteClient = RemoteClient() - remoteClient.registerLocalScript(globalCommands.commands.script_sendKeys) + _remoteClient = RemoteClient() + _remoteClient.registerLocalScript(globalCommands.commands.script_sendKeys) def terminate(): """Terminate the remote client.""" - global remoteClient - remoteClient.terminate() - remoteClient = None + global _remoteClient + _remoteClient.terminate() + _remoteClient = None From 0c74ae350dcb0e79faf0bf66b431d094e2647f50 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 10 Feb 2025 10:18:03 -0700 Subject: [PATCH 132/203] tests: Add comprehensive unit tests for remote client functionality Add unit test coverage for the remote client subsystem, including: - Bridge transport testing for message routing between transports - Remote client core functionality (connection handling, muting, clipboard) - JSON serialization and protocol message handling - Transport layer with connection management and queuing - Error handling and edge cases The tests ensure proper behavior of: - Extension point bridging - Message routing and handlers - Connection lifecycle - Protocol serialization - Transport queues - Error handling --- tests/unit/test_bridge.py | 114 ++++++++ tests/unit/test_remote_client.py | 207 ++++++++++++++ tests/unit/test_serializer.py | 71 +++++ tests/unit/test_transport.py | 476 +++++++++++++++++++++++++++++++ 4 files changed, 868 insertions(+) create mode 100644 tests/unit/test_bridge.py create mode 100644 tests/unit/test_remote_client.py create mode 100644 tests/unit/test_serializer.py create mode 100644 tests/unit/test_transport.py diff --git a/tests/unit/test_bridge.py b/tests/unit/test_bridge.py new file mode 100644 index 00000000000..c9cc961ec50 --- /dev/null +++ b/tests/unit/test_bridge.py @@ -0,0 +1,114 @@ +import unittest +from remoteClient.bridge import BridgeTransport +from remoteClient.transport import Transport, RemoteMessageType + + +# A fake transport that implements minimal Transport interface for testing BridgeTransport. +class FakeTransport(Transport): + def __init__(self): + class DummySerializer: + def serialize(self, *, type, **kwargs): + return b"dummy" + + def deserialize(self, data): + return {} + + super().__init__(DummySerializer()) + # Override inboundHandlers to be a dict mapping RemoteMessageType -> list of handlers. + self.inboundHandlers = {} + # List to collect sent messages. + self.sent_messages = [] + + def registerInbound(self, messageType, handler): + if messageType in self.inboundHandlers: + self.inboundHandlers[messageType].append(handler) + else: + self.inboundHandlers[messageType] = [handler] + + def unregisterInbound(self, messageType, handler): + if messageType in self.inboundHandlers: + self.inboundHandlers[messageType].remove(handler) + if not self.inboundHandlers[messageType]: + del self.inboundHandlers[messageType] + + def send(self, type, **kwargs): + self.sent_messages.append((type, kwargs)) + + def parse(self, line: bytes): + pass # Not used in these tests. + + +# Tests for BridgeTransport. +class TestBridgeTransport(unittest.TestCase): + def setUp(self): + self.transport1 = FakeTransport() + self.transport2 = FakeTransport() + # Create a bridge between the two fake transports. + self.bridge = BridgeTransport(self.transport1, self.transport2) + + def test_inbound_registration_on_init(self): + # On initialization, both transports should have inbound handlers registered for every RemoteMessageType. + for messageType in list(RemoteMessageType): + self.assertIn( + messageType, + self.transport1.inboundHandlers, + f"{messageType} not registered in transport1", + ) + self.assertIn( + messageType, + self.transport2.inboundHandlers, + f"{messageType} not registered in transport2", + ) + + def test_forwarding_message(self): + # Choose a message type that is not excluded. + non_excluded = None + for m in list(RemoteMessageType): + if m not in BridgeTransport.excluded: + non_excluded = m + break + self.assertIsNotNone(non_excluded, "There must be at least one non-excluded message type") + # Simulate an inbound message on transport1. + callbacks = self.transport1.inboundHandlers[non_excluded] + for callback in callbacks: + callback(a=10, b=20) + # Expect that transport2's send() was called with the same message type and payload. + self.assertTrue(len(self.transport2.sent_messages) > 0, "No message was forwarded to transport2") + for type_sent, payload in self.transport2.sent_messages: + self.assertEqual(type_sent, non_excluded) + self.assertEqual(payload, {"a": 10, "b": 20}) + + def test_excluded_message_not_forwarded(self): + # Choose a message type that is excluded. + excluded_message = None + for m in list(RemoteMessageType): + if m in BridgeTransport.excluded: + excluded_message = m + break + self.assertIsNotNone(excluded_message, "There must be at least one excluded message type") + # Clear any previous sent messages. + self.transport2.sent_messages.clear() + # Simulate an inbound message on transport1 for the excluded type. + callbacks = self.transport1.inboundHandlers[excluded_message] + for callback in callbacks: + callback(a=99) + # Expect that transport2's send() is not called. + self.assertEqual(len(self.transport2.sent_messages), 0, "Excluded message was forwarded") + + def test_disconnect_unregisters_handlers(self): + # Count initial number of registered handlers. + count_t1 = sum(len(handlers) for handlers in self.transport1.inboundHandlers.values()) + count_t2 = sum(len(handlers) for handlers in self.transport2.inboundHandlers.values()) + self.assertGreater(count_t1, 0) + self.assertGreater(count_t2, 0) + # Disconnect the bridge. + self.bridge.disconnect() + # After disconnection, there should be no inbound handlers remaining. + total_t1 = sum(len(handlers) for handlers in self.transport1.inboundHandlers.values()) + total_t2 = sum(len(handlers) for handlers in self.transport2.inboundHandlers.values()) + self.assertEqual(total_t1, 0, "Still registered handlers in transport1 after disconnect") + self.assertEqual(total_t2, 0, "Still registered handlers in transport2 after disconnect") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_remote_client.py b/tests/unit/test_remote_client.py new file mode 100644 index 00000000000..5aaf89eba1b --- /dev/null +++ b/tests/unit/test_remote_client.py @@ -0,0 +1,207 @@ +import unittest +from unittest.mock import MagicMock, patch +import remoteClient.client as rc_client +from remoteClient.connectionInfo import ConnectionInfo, ConnectionMode +from remoteClient.protocol import RemoteMessageType + + +# Fake implementations for testing +class FakeLocalMachine: + def __init__(self): + self.isMuted = False + + def terminate(self): + pass + + +class FakeMenu: + def __init__(self): + self.muteItem = self.FakeMuteItem() + + class FakeMuteItem: + def __init__(self): + self.checked = None + + def Check(self, value): + self.checked = value + + +class FakeTransport: + def __init__(self): + self.sent = [] + + @property + def connected(self): + return True + + def send(self, messageType, **kwargs): + self.sent.append((messageType, kwargs)) + + +class FakeSession: + def __init__(self, url): + self.url = url + + def getConnectionInfo(self): + class FakeConnectionInfo: + def getURLToConnect(_): + return self.url + + return FakeConnectionInfo() + + +class FakeAPI: + clip_data = "Fake clipboard text" + copied = None + + @staticmethod + def getClipData(): + return FakeAPI.clip_data + + @staticmethod + def copyToClip(text): + FakeAPI.copied = text + + +class TestRemoteClient(unittest.TestCase): + def setUp(self): + import wx + + if not wx.GetApp(): + self.app = wx.App() + # Patch gui.mainFrame to a fake object so RemoteMenu can access sysTrayIcon.toolsMenu. + patcher_mainFrame = patch("remoteClient.client.gui.mainFrame") + self.addCleanup(patcher_mainFrame.stop) + mock_mainFrame = patcher_mainFrame.start() + mock_mainFrame.sysTrayIcon = MagicMock() + mock_mainFrame.sysTrayIcon.toolsMenu = MagicMock() + self.client = rc_client.RemoteClient() + # Override localMachine and menu with fake implementations. + self.client.localMachine = FakeLocalMachine() + self.client.menu = FakeMenu() + # Patch ui.message to capture calls. + patcher = patch("remoteClient.client.ui.message") + self.addCleanup(patcher.stop) + self.ui_message = patcher.start() + # Patch the API module to use our fake API. + patcher_api = patch("remoteClient.client.api", new=FakeAPI) + self.addCleanup(patcher_api.stop) + patcher_api.start() + FakeAPI.copied = None + patcher_nvwave = patch("remoteClient.cues.nvwave.playWaveFile", return_value=None) + + self.addCleanup(patcher_nvwave.stop) + patcher_nvwave.start() + + def tearDown(self): + self.client = None + + def test_toggle_mute(self): + # Initially, local machine should not be muted. + self.assertFalse(self.client.localMachine.isMuted) + # Toggle mute: should mute the local machine. + self.client.toggleMute() + self.assertTrue(self.client.localMachine.isMuted) + self.assertTrue(self.client.menu.muteItem.checked) + self.ui_message.assert_called_once() + # Now toggle again: should unmute. + self.ui_message.reset_mock() + self.client.toggleMute() + self.assertFalse(self.client.localMachine.isMuted) + self.assertFalse(self.client.menu.muteItem.checked) + self.ui_message.assert_called_once() + + def test_push_clipboard_no_connection(self): + # Without any transport (neither slave nor master), pushClipboard should warn. + self.client.slaveTransport = None + self.client.masterTransport = None + self.client.pushClipboard() + self.ui_message.assert_called_with("Not connected.") + + def test_push_clipboard_with_transport(self): + # With a fake transport, pushClipboard should send the clipboard text. + fake_transport = FakeTransport() + self.client.masterTransport = fake_transport + FakeAPI.clip_data = "TestClipboard" + self.client.pushClipboard() + self.assertTrue(len(fake_transport.sent) > 0) + messageType, kwargs = fake_transport.sent[0] + self.assertEqual(messageType, RemoteMessageType.SET_CLIPBOARD_TEXT) + self.assertEqual(kwargs.get("text"), "TestClipboard") + + def test_copy_link_no_session(self): + # If there is no session, copyLink should warn the user. + self.client.masterSession = None + self.client.slaveSession = None + self.ui_message.reset_mock() + self.client.copyLink() + self.ui_message.assert_called_with("Not connected.") + + def test_copy_link_with_session(self): + # With a fake session, copyLink should call api.copyToClip with the proper URL. + fake_session = FakeSession("http://fake.url/connect") + self.client.masterSession = fake_session + FakeAPI.copied = None + self.client.copyLink() + self.assertEqual(FakeAPI.copied, "http://fake.url/connect") + + def test_send_sas_no_master_transport(self): + # Without a masterTransport, sendSAS should log an error. + self.client.masterTransport = None + with patch("remoteClient.client.log.error") as mock_log_error: + self.client.sendSAS() + mock_log_error.assert_called_once_with("No master transport to send SAS") + + def test_send_sas_with_master_transport(self): + # With a fake masterTransport, sendSAS should forward the SEND_SAS message. + fake_transport = FakeTransport() + self.client.masterTransport = fake_transport + self.client.sendSAS() + self.assertTrue(len(fake_transport.sent) > 0) + messageType, _ = fake_transport.sent[0] + self.assertEqual(messageType, RemoteMessageType.SEND_SAS) + + def test_connect_dispatch(self): + # Ensure that connect() dispatches to connectAsMaster or connectAsSlave based on connection mode. + fake_connect_as_master = MagicMock() + fake_connect_as_slave = MagicMock() + self.client.connectAsMaster = fake_connect_as_master + self.client.connectAsSlave = fake_connect_as_slave + conn_info_master = ConnectionInfo( + hostname="localhost", + mode=ConnectionMode.MASTER, + key="abc", + port=1000, + insecure=False, + ) + self.client.connect(conn_info_master) + fake_connect_as_master.assert_called_once_with(conn_info_master) + fake_connect_as_master.reset_mock() + conn_info_slave = ConnectionInfo( + hostname="localhost", + mode=ConnectionMode.SLAVE, + key="abc", + port=1000, + insecure=False, + ) + self.client.connect(conn_info_slave) + fake_connect_as_slave.assert_called_once_with(conn_info_slave) + + def test_disconnect(self): + # Test disconnect with no active sessions. + self.client.masterSession = None + self.client.slaveSession = None + with patch("remoteClient.client.log.debug") as mock_log_debug: + self.client.disconnect() + mock_log_debug.assert_called() + # Test disconnect with an active localControlServer. + fake_control = MagicMock() + self.client.localControlServer = fake_control + self.client.masterSession = MagicMock() + self.client.slaveSession = MagicMock() + self.client.disconnect() + fake_control.close.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_serializer.py b/tests/unit/test_serializer.py new file mode 100644 index 00000000000..f9ffb74df15 --- /dev/null +++ b/tests/unit/test_serializer.py @@ -0,0 +1,71 @@ +import json +import unittest +from enum import Enum +from remoteClient.serializer import JSONSerializer, CustomEncoder, as_sequence + + +# Create a dummy Enum for test purposes. +class DummyEnum(Enum): + VALUE1 = "value1" + VALUE2 = "value2" + + +# Dummy command for testing CustomEncoder fallback. +class DummyCommand: + def __init__(self, data): + self.data = data + + +class TestJSONSerializer(unittest.TestCase): + def setUp(self): + self.serializer = JSONSerializer() + + def test_serialize_basic(self): + # Test basic serialization with a string type and payload. + message_bytes = self.serializer.serialize(type="test_message", key=123) + self.assertTrue(message_bytes.endswith(b"\n")) + message_str = message_bytes.rstrip(b"\n").decode("UTF-8") + data = json.loads(message_str) + self.assertEqual(data["type"], "test_message") + self.assertEqual(data["key"], 123) + + def test_serialize_enum(self): + # Test that passing an Enum type is serialized to its value. + message_bytes = self.serializer.serialize(type=DummyEnum.VALUE1, key="abc") + message_str = message_bytes.rstrip(b"\n").decode("UTF-8") + data = json.loads(message_str) + self.assertEqual(data["type"], "value1") + self.assertEqual(data["key"], "abc") + + def test_round_trip(self): + # Test that serializing and then deserializing returns the same message data. + original = {"type": "round_trip", "value": 999} + message_bytes = self.serializer.serialize(**original) + # Remove the separator for deserialization. + data = self.serializer.deserialize(message_bytes.rstrip(JSONSerializer.SEP)) + self.assertEqual(data["type"], "round_trip") + self.assertEqual(data["value"], 999) + + def test_custom_encoder(self): + # Test that CustomEncoder falls back to default behavior for non-special objects. + dummy = DummyCommand("test") + # Set __dict__ to a non-serializable object (set is not serializable by default) + dummy.__dict__ = {"data": {1, 2, 3}} + with self.assertRaises(TypeError) as cm: + json.dumps(dummy, cls=CustomEncoder) + self.assertRegex(str(cm.exception), "not JSON serializable") + # Even if __dict__ is set to a serializable value, it should still raise error. + dummy.__dict__ = {"data": "testdata"} + with self.assertRaises(TypeError) as cm: + json.dumps(dummy, cls=CustomEncoder) + self.assertRegex(str(cm.exception), "not JSON serializable") + + def test_as_sequence_no_change(self): + # Test that as_sequence returns the dictionary unchanged when no special keys exist. + input_dict = {"type": "other", "foo": "bar"} + result = as_sequence(input_dict) + self.assertEqual(result, input_dict) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_transport.py b/tests/unit/test_transport.py new file mode 100644 index 00000000000..a8b48e61dbc --- /dev/null +++ b/tests/unit/test_transport.py @@ -0,0 +1,476 @@ +""" +Unit tests for the remoteClient.transport module. +This test suite covers: + - RemoteExtensionPoint bridging of extension points + - Basic Transport functionality (send, inbound/outbound handler registration) + - Processing of incoming socket data and message parsing + - sendQueue functionality + - Creating outbound sockets in TCPTransport (with both .onion and regular hosts) + - RelayTransport onConnected logic + - ConnectorThread basic reconnection behavior + - clearQueue utility +""" + +import ast +import socket +import ssl +import threading +import unittest +from queue import Queue +from unittest import mock + +import wx + +# Import classes from the transport module. +from remoteClient.transport import ( + PROTOCOL_VERSION, + ConnectorThread, + RelayTransport, + RemoteExtensionPoint, + RemoteMessageType, + TCPTransport, + Transport, + clearQueue, +) + + +# --------------------------------------------------------------------------- +# Fake Serializer used for testing +class FakeSerializer: + def serialize(self, *, type, **kwargs): + # Return a simple string representation ending with newline. + import enum + + if isinstance(type, enum.Enum): + type = type.value + return f"type={type}|{kwargs}\n".encode("utf-8") + + def deserialize(self, line: bytes): + s = line.decode("utf-8").strip() + if not s.startswith("type="): + return {} + rest = s[len("type=") :] + typePart, sep, kwargPart = rest.partition("|") + kwargs = ast.literal_eval(kwargPart) if kwargPart else {} + kwargs["type"] = typePart + return kwargs + + +# --------------------------------------------------------------------------- +# Fake HandlerRegistrar (for outbound tests) +class FakeHandlerRegistrar: + def __init__(self): + self.handlers = [] + + def register(self, handler): + self.handlers.append(handler) + + def unregister(self, handler): + self.handlers.remove(handler) + + def notify(self, **kwargs): + for h in self.handlers: + h(**kwargs) + + +# --------------------------------------------------------------------------- +# Tests for RemoteExtensionPoint +class TestRemoteExtensionPoint(unittest.TestCase): + def test_remoteBridge_with_filter(self): + # Create a fake extension point and a filter function + registrar = FakeHandlerRegistrar() + + def my_filter(*args, **kwargs): + return {"filtered": True} + + rep = RemoteExtensionPoint(extensionPoint=registrar, messageType="TEST", filter=my_filter) + + # Create a fake transport that records calls to send + class FakeTransport: + def __init__(self): + self.sent = [] + + def send(self, messageType, **kwargs): + self.sent.append((messageType, kwargs)) + + fakeTransport = FakeTransport() + rep.register(fakeTransport) + # Trigger the extension point + registrar.notify(unused="value") + self.assertEqual(fakeTransport.sent, [("TEST", {"filtered": True})]) + rep.unregister() + + def test_remoteBridge_without_filter(self): + registrar = FakeHandlerRegistrar() + rep = RemoteExtensionPoint(extensionPoint=registrar, messageType="TEST", filter=None) + + class FakeTransport: + def __init__(self): + self.sent = [] + + def send(self, messageType, **kwargs): + self.sent.append((messageType, kwargs)) + + fakeTransport = FakeTransport() + rep.register(fakeTransport) + registrar.notify(a=1, b=2) + self.assertEqual(fakeTransport.sent, [("TEST", {"a": 1, "b": 2})]) + rep.unregister() + + +# --------------------------------------------------------------------------- +# Dummy subclass of TCPTransport for testing (without real network IO) +class DummyTransport(TCPTransport): + def __init__(self, serializer): + super().__init__(serializer, address=("localhost", 0), timeout=0, insecure=True) + + def run(self): + # Do nothing (for testing send, etc.) + pass + + +# --------------------------------------------------------------------------- +# Tests for send() and queue functionality in Transport +class TestTransportSendAndQueue(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + self.transport.connected = True + self.transport.queue = Queue() + + def test_send_enqueues_serialized_message(self): + self.transport.send("TEST_TYPE", key=123) + item = self.transport.queue.get_nowait() + result = self.serializer.deserialize(item) + self.assertEqual(result["type"], "TEST_TYPE") + self.assertEqual(result["key"], 123) + + def test_send_when_not_connected_logs_error(self): + self.transport.connected = False + with mock.patch("remoteClient.transport.log.error") as mock_error: + self.transport.send("TEST", a=1) + mock_error.assert_called_once() + + +# --------------------------------------------------------------------------- +# Fake socket for testing processIncomingSocketData +class FakeSocket: + def __init__(self, recv_data: bytes): + self.recv_data = recv_data + self.blocking = True + self.closed = False + + def setblocking(self, flag: bool): + self.blocking = flag + + def recv(self, buffSize: int) -> bytes: + data = self.recv_data[:buffSize] + self.recv_data = self.recv_data[buffSize:] + return data + + def close(self): + self.closed = True + + +# --------------------------------------------------------------------------- +# Tests for processIncomingSocketData and parse() +class TestProcessAndParse(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + # Use a fake socket that returns two full lines and a partial + self.transport.serverSock = FakeSocket(b"line1\nline2\npartial") + self.transport.serverSockLock = threading.Lock() + self.transport.buffer = b"" + + def test_processIncomingSocketData(self): + parsed_lines = [] + + def fake_parse(line): + parsed_lines.append(line) + + self.transport.parse = fake_parse + self.transport.processIncomingSocketData() + self.assertEqual(self.transport.buffer, b"partial") + self.assertEqual(parsed_lines, [b"line1", b"line2"]) + + def test_parse_calls_inboundHandler(self): + # Set up an inbound handler for type RemoteMessageType.PROTOCOL_VERSION + dummy_inbound = mock.MagicMock() + self.transport.inboundHandlers = {RemoteMessageType.PROTOCOL_VERSION: dummy_inbound} + # Patch wx.CallAfter to simply call immediately + original_CallAfter = wx.CallAfter + wx.CallAfter = lambda func, *args, **kwargs: func(*args, **kwargs) + # Prepare a message + message = self.serializer.serialize(type=RemoteMessageType.PROTOCOL_VERSION, a=1) + self.transport.parse(message) + dummy_inbound.notify.assert_called_once_with(a=1) + wx.CallAfter = original_CallAfter + + +# --------------------------------------------------------------------------- +# Tests for sendQueue +class DummyTransportSocket: + def __init__(self): + self.sent = [] + + def sendall(self, data): + self.sent.append(data) + + def close(self): + self.closed = True + + +class TestSendQueue(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + self.transport.serverSock = DummyTransportSocket() + self.transport.serverSockLock = threading.Lock() + self.transport.queue = Queue() + + def test_sendQueue_sends_messages(self): + item1 = b"msg1" + item2 = b"msg2" + self.transport.queue.put(item1) + self.transport.queue.put(item2) + self.transport.queue.put(None) # Signal to stop + self.transport.sendQueue() + self.assertEqual(self.transport.serverSock.sent, [item1, item2]) + + def test_sendQueue_stops_on_socket_error(self): + item1 = b"msg1" + self.transport.queue.put(item1) + + def fake_sendall(data): + raise socket.error("Test error") + + self.transport.serverSock.sendall = fake_sendall + # Should complete without raising further exception + self.transport.sendQueue() + + +# --------------------------------------------------------------------------- +# Tests for TCPTransport.createOutboundSocket +class DummyTCPSocket: + def __init__(self): + self.options = {} + self.blocking = True + self.connected = False + self.timeout = None + self.family = socket.AF_INET + self.type = socket.SOCK_STREAM + self.proto = 0 + + def setsockopt(self, level, optname, value): + self.options[(level, optname)] = value + + def settimeout(self, timeout): + self.timeout = timeout + + def gettimeout(self): + return self.timeout + + def connect(self, address): + self.connected = True + + def close(self): + self.connected = False + + def ioctl(self, *args, **kwargs): + return + + def getsockopt(self, level, optname): + return socket.SOCK_STREAM + + def fileno(self): + return 0 + + def recv(self, buffSize): + return b"" + + +class TestTCPTransportCreateOutboundSocket(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.host = "localhost" + self.port = 8090 + + @mock.patch("test.mock_socket.socket", autospec=True) + def test_createOutboundSocket_onion(self, mock_socket): + t = TCPTransport(self.serializer, (self.host + ".onion", self.port)) + fake_socket = DummyTCPSocket() + mock_socket.return_value = fake_socket + sock = t.createOutboundSocket(self.host + ".onion", self.port, insecure=False) + self.assertFalse(fake_socket.connected) + self.assertTrue(isinstance(sock, ssl.SSLSocket)) + + @mock.patch("test.mock_socket.socket", autospec=True) + def test_createOutboundSocket_regular_insecure(self, mock_socket): + t = TCPTransport(self.serializer, (self.host, self.port)) + fake_socket = DummyTCPSocket() + mock_socket.return_value = fake_socket + sock = t.createOutboundSocket(self.host, self.port, insecure=True) + self.assertFalse(fake_socket.connected) + self.assertTrue(isinstance(sock, ssl.SSLSocket)) + + +# --------------------------------------------------------------------------- +# Tests for RelayTransport.onConnected +class TestRelayTransportOnConnected(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + + def test_onConnected_with_channel(self): + # Create a RelayTransport with a channel set. + rt = RelayTransport( + serializer=self.serializer, + address=("localhost", 8090), + channel="mychannel", + connectionType="relayMode", + protocol_version=PROTOCOL_VERSION, + insecure=False, + ) + # Override send() to record calls. + rt.send = mock.MagicMock() + rt.onConnected() + # It should send protocol version message. + rt.send.assert_any_call(RemoteMessageType.PROTOCOL_VERSION, version=PROTOCOL_VERSION) + # And since channel is set, should send JOIN message. + rt.send.assert_any_call(RemoteMessageType.JOIN, channel="mychannel", connection_type="relayMode") + + def test_onConnected_without_channel(self): + # Create a RelayTransport with no channel. + rt = RelayTransport( + serializer=self.serializer, + address=("localhost", 8090), + channel=None, + connectionType="relayMode", + protocol_version=PROTOCOL_VERSION, + insecure=False, + ) + rt.send = mock.MagicMock() + rt.onConnected() + rt.send.assert_any_call(RemoteMessageType.PROTOCOL_VERSION, version=PROTOCOL_VERSION) + rt.send.assert_any_call(RemoteMessageType.GENERATE_KEY) + + +# --------------------------------------------------------------------------- +# Tests for ConnectorThread +class DummyConnectorTransport(Transport): + def __init__(self, serializer): + super().__init__(serializer) + self.run_called = 0 + + def run(self): + self.run_called += 1 + raise socket.error("Simulated socket error") + + def processIncomingSocketData(self): + pass + + +class TestConnectorThread(unittest.TestCase): + def test_connectorThread_runs_and_reconnects(self): + serializer = FakeSerializer() + fake_transport = DummyConnectorTransport(serializer) + connector = ConnectorThread(fake_transport, reconnectDelay=0.01) + connector.running = True + # Run connector.run() in a loop for a few iterations manually. + iterations = 3 + for _ in range(iterations): + try: + fake_transport.run() + except socket.error: + pass + connector.running = False + self.assertEqual(fake_transport.run_called, iterations) + + +# --------------------------------------------------------------------------- +# Tests for clearQueue function +class TestClearQueue(unittest.TestCase): + def test_clearQueue(self): + q = Queue() + for i in range(5): + q.put(i) + clearQueue(q) + self.assertTrue(q.empty()) + + +class TestRemoteExtensionPointIntegration(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + self.transport.connected = True + self.transport.queue = Queue() + self.fakeRegistrar = FakeHandlerRegistrar() + + def test_registerOutbound_and_trigger(self): + self.transport.registerOutbound(self.fakeRegistrar, RemoteMessageType.GENERATE_KEY) + for handler in self.fakeRegistrar.handlers: + handler(a=42) + item = self.transport.queue.get_nowait() + payload = self.serializer.deserialize(item) + self.assertEqual(payload["type"], RemoteMessageType.GENERATE_KEY.value) + self.assertEqual(payload["a"], 42) + + def test_unregisterOutbound(self): + self.transport.registerOutbound(self.fakeRegistrar, RemoteMessageType.GENERATE_KEY) + self.assertIn(RemoteMessageType.GENERATE_KEY, self.transport.outboundHandlers) + self.transport.unregisterOutbound(RemoteMessageType.GENERATE_KEY) + self.assertNotIn(RemoteMessageType.GENERATE_KEY, self.transport.outboundHandlers) + + +class TestInboundRegistration(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + self.called = False + + def handler(**kwargs): + self.called = True + + self.handler = handler + + def test_register_inbound(self): + self.transport.registerInbound(RemoteMessageType.PROTOCOL_VERSION, self.handler) + self.assertIn(RemoteMessageType.PROTOCOL_VERSION, self.transport.inboundHandlers) + self.transport.inboundHandlers[RemoteMessageType.PROTOCOL_VERSION].notify(a=123) + self.assertTrue(self.called) + + def test_unregister_inbound(self): + self.transport.registerInbound(RemoteMessageType.PROTOCOL_VERSION, self.handler) + self.transport.unregisterInbound(RemoteMessageType.PROTOCOL_VERSION, self.handler) + self.called = False + if RemoteMessageType.PROTOCOL_VERSION in self.transport.inboundHandlers: + self.transport.inboundHandlers[RemoteMessageType.PROTOCOL_VERSION].notify(a=456) + self.assertFalse(self.called) + + +class TestParseErrorHandling(unittest.TestCase): + def setUp(self): + self.serializer = FakeSerializer() + self.transport = DummyTransport(serializer=self.serializer) + self.transport.inboundHandlers = {} + + def test_parse_no_type(self): + with self.assertLogs(level="WARN") as cm: + self.transport.parse(b"invalid message\n") + self.assertTrue(any("Received message without type" in log for log in cm.output)) + + def test_parse_invalid_type(self): + with self.assertLogs(level="WARN") as cm: + message = self.serializer.serialize(type="NONEXISTENT", a=10) + self.transport.parse(message) + self.assertTrue(any("Received message with invalid type" in log for log in cm.output)) + + def test_parse_unhandled_type(self): + with self.assertLogs(level="WARN") as cm: + message = self.serializer.serialize(type=RemoteMessageType.GENERATE_KEY, b=10) + self.transport.parse(message) + self.assertTrue(any("Received message with unhandled type" in log for log in cm.output)) + + +if __name__ == "__main__": + unittest.main() From 067d3402075e4e4ccf07052d5413be5ceff103b5 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 11:55:30 +1100 Subject: [PATCH 133/203] Renamed remoteClient.session.SlaveSession to remoteClient.session.FollowerSession --- source/remoteClient/client.py | 6 +++--- source/remoteClient/localMachine.py | 2 +- source/remoteClient/secureDesktop.py | 8 ++++---- source/remoteClient/session.py | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 5a79a3adaac..fe04c8d1ddf 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -25,7 +25,7 @@ from .menu import RemoteMenu from .protocol import RemoteMessageType, addressToHostPort from .secureDesktop import SecureDesktopHandler -from .session import MasterSession, SlaveSession +from .session import MasterSession, FollowerSession from .protocol import hostPortToAddress from .transport import RelayTransport @@ -38,7 +38,7 @@ class RemoteClient: localScripts: Set[Callable] localMachine: LocalMachine masterSession: Optional[MasterSession] - slaveSession: Optional[SlaveSession] + slaveSession: Optional[FollowerSession] keyModifiers: Set[KeyModifier] hostPendingModifiers: Set[KeyModifier] connecting: bool @@ -307,7 +307,7 @@ def connectAsSlave(self, connectionInfo: ConnectionInfo): connection_info=connectionInfo, serializer=serializer.JSONSerializer(), ) - self.slaveSession = SlaveSession( + self.slaveSession = FollowerSession( transport=transport, localMachine=self.localMachine, ) diff --git a/source/remoteClient/localMachine.py b/source/remoteClient/localMachine.py index e75c3862b60..aa78d4b8a82 100644 --- a/source/remoteClient/localMachine.py +++ b/source/remoteClient/localMachine.py @@ -81,7 +81,7 @@ class LocalMachine: be created directly. All methods are called in response to remote messages. :seealso: - - :class:`session.SlaveSession` - Manages remote connections + - :class:`session.FollowerSession` - Manages remote connections - :mod:`transport` - Network transport layer """ diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index da556c4ea7c..e7ef08cf757 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -34,7 +34,7 @@ from .connectionInfo import ConnectionInfo, ConnectionMode from .protocol import RemoteMessageType from .serializer import JSONSerializer -from .session import SlaveSession +from .session import FollowerSession from .transport import RelayTransport @@ -68,7 +68,7 @@ def __init__(self, tempPath: Path = getProgramDataTempPath()) -> None: self.IPCFile = self.IPCPath / "remote.ipc" log.debug("Initialized SecureDesktopHandler with IPC file: %s", self.IPCFile) - self._slaveSession: Optional[SlaveSession] = None + self._slaveSession: Optional[FollowerSession] = None self.sdServer: Optional[server.LocalRelayServer] = None self.sdRelay: Optional[RelayTransport] = None self.sdBridge: Optional[bridge.BridgeTransport] = None @@ -88,11 +88,11 @@ def terminate(self) -> None: log.info("Secure desktop cleanup completed") @property - def slaveSession(self) -> Optional[SlaveSession]: + def slaveSession(self) -> Optional[FollowerSession]: return self._slaveSession @slaveSession.setter - def slaveSession(self, session: Optional[SlaveSession]) -> None: + def slaveSession(self, session: Optional[FollowerSession]) -> None: """Update slave session reference and handle necessary cleanup/setup.""" if self._slaveSession == session: log.debug("Slave session unchanged, skipping update") diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index b56d3a7bca4..9b6a4099919 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -45,7 +45,7 @@ - Connection management - Master-specific patches -SlaveSession +:class:`FollowerSession` Controlled by remote instance: - Command execution - Output forwarding @@ -237,7 +237,7 @@ def __del__(self) -> None: self.close() -class SlaveSession(RemoteSession): +class FollowerSession(RemoteSession): """Session that runs on the controlled (slave) NVDA instance. :ivar masters: Information about connected master clients From c7dad5b39d8e7c9fc2fa73c67b867ffdbf68374a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 13:48:24 +1100 Subject: [PATCH 134/203] Renamed slaveSession to followerSession on remoteClient.client.RemoteClient and remoteClient.secureDesktop.SecureDesktopHandler, and _slaveSession to _followerSession on remoteClient.secureDesktop.SecureDesktopHandler --- source/remoteClient/client.py | 28 +++++++++++------------ source/remoteClient/secureDesktop.py | 34 ++++++++++++++-------------- tests/unit/test_remote_client.py | 6 ++--- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index fe04c8d1ddf..3653b4815d3 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -38,7 +38,7 @@ class RemoteClient: localScripts: Set[Callable] localMachine: LocalMachine masterSession: Optional[MasterSession] - slaveSession: Optional[FollowerSession] + followerSession: Optional[FollowerSession] keyModifiers: Set[KeyModifier] hostPendingModifiers: Set[KeyModifier] connecting: bool @@ -55,7 +55,7 @@ def __init__( self.hostPendingModifiers = set() self.localScripts = set() self.localMachine = LocalMachine() - self.slaveSession = None + self.followerSession = None self.masterSession = None self.menu: Optional[RemoteMenu] = None if not isRunningOnSecureDesktop(): @@ -71,7 +71,7 @@ def __init__( connection = self.sdHandler.initializeSecureDesktop() if connection: self.connectAsSlave(connection) - self.slaveSession.transport.connectedEvent.wait( + self.followerSession.transport.connectedEvent.wait( self.sdHandler.SD_CONNECT_BLOCK_TIMEOUT, ) core.postNvdaStartup.register(self.performAutoconnect) @@ -79,7 +79,7 @@ def __init__( def performAutoconnect(self): controlServerConfig = configuration.get_config()["controlserver"] - if not controlServerConfig["autoconnect"] or self.masterSession or self.slaveSession: + if not controlServerConfig["autoconnect"] or self.masterSession or self.followerSession: log.debug("Autoconnect disabled or already connected") return key = controlServerConfig["key"] @@ -146,7 +146,7 @@ def copyLink(self): :note: Requires an active session """ - session = self.masterSession or self.slaveSession + session = self.masterSession or self.followerSession if session is None: # Translators: Message shown when trying to copy the link to connect to the remote computer while not connected. ui.message(_("Not connected.")) @@ -183,7 +183,7 @@ def disconnect(self): :note: Closes local control server and both master/slave sessions if active """ - if self.masterSession is None and self.slaveSession is None: + if self.masterSession is None and self.followerSession is None: log.debug("Disconnect called but no active sessions") return log.info("Disconnecting from remote session") @@ -192,7 +192,7 @@ def disconnect(self): self.localControlServer = None if self.masterSession is not None: self.disconnectAsMaster() - if self.slaveSession is not None: + if self.followerSession is not None: self.disconnectAsSlave() cues.disconnected() @@ -204,10 +204,10 @@ def disconnectAsMaster(self): def disconnectAsSlave(self): """Close slave session and clean up related resources.""" - self.slaveSession.close() - self.slaveSession = None + self.followerSession.close() + self.followerSession = None self.slaveTransport = None - self.sdHandler.slaveSession = None + self.sdHandler.followerSession = None @alwaysCallAfter def onConnectAsMasterFailed(self): @@ -307,11 +307,11 @@ def connectAsSlave(self, connectionInfo: ConnectionInfo): connection_info=connectionInfo, serializer=serializer.JSONSerializer(), ) - self.slaveSession = FollowerSession( + self.followerSession = FollowerSession( transport=transport, localMachine=self.localMachine, ) - self.sdHandler.slaveSession = self.slaveSession + self.sdHandler.followerSession = self.followerSession self.slaveTransport = transport transport.transportCertificateAuthenticationFailed.register( self.onSlaveCertificateFailed, @@ -328,7 +328,7 @@ def onConnectedAsSlave(self): cues.controlServerConnected() if self.menu: self.menu.handleConnected(ConnectionMode.SLAVE, True) - configuration.write_connection_to_config(self.slaveSession.getConnectionInfo()) + configuration.write_connection_to_config(self.followerSession.getConnectionInfo()) @alwaysCallAfter def onDisconnectedAsSlave(self): @@ -372,7 +372,7 @@ def onMasterCertificateFailed(self): @alwaysCallAfter def onSlaveCertificateFailed(self): - if self.handleCertificateFailure(self.slaveSession.transport): + if self.handleCertificateFailure(self.followerSession.transport): connectionInfo = ConnectionInfo( mode=ConnectionMode.SLAVE, hostname=self.lastFailAddress[0], diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index e7ef08cf757..fc537543109 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -68,7 +68,7 @@ def __init__(self, tempPath: Path = getProgramDataTempPath()) -> None: self.IPCFile = self.IPCPath / "remote.ipc" log.debug("Initialized SecureDesktopHandler with IPC file: %s", self.IPCFile) - self._slaveSession: Optional[FollowerSession] = None + self._followerSession: Optional[FollowerSession] = None self.sdServer: Optional[server.LocalRelayServer] = None self.sdRelay: Optional[RelayTransport] = None self.sdBridge: Optional[bridge.BridgeTransport] = None @@ -88,13 +88,13 @@ def terminate(self) -> None: log.info("Secure desktop cleanup completed") @property - def slaveSession(self) -> Optional[FollowerSession]: - return self._slaveSession + def followerSession(self) -> Optional[FollowerSession]: + return self._followerSession - @slaveSession.setter - def slaveSession(self, session: Optional[FollowerSession]) -> None: + @followerSession.setter + def followerSession(self, session: Optional[FollowerSession]) -> None: """Update slave session reference and handle necessary cleanup/setup.""" - if self._slaveSession == session: + if self._followerSession == session: log.debug("Slave session unchanged, skipping update") return @@ -102,10 +102,10 @@ def slaveSession(self, session: Optional[FollowerSession]) -> None: 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 + if self._followerSession is not None and self._followerSession.transport is not None: + transport = self._followerSession.transport transport.unregisterInbound(RemoteMessageType.SET_BRAILLE_INFO, self._onMasterDisplayChange) - self._slaveSession = session + self._followerSession = session session.transport.registerInbound( RemoteMessageType.SET_BRAILLE_INFO, self._onMasterDisplayChange, @@ -125,7 +125,7 @@ def _onSecureDesktopChange(self, isSecureDesktop: Optional[bool] = None) -> None 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: + if self.followerSession is None or self.followerSession.transport is None: log.warning("No slave session connected, not entering secure desktop.") return if not self.tempPath.exists(): @@ -150,12 +150,12 @@ def enterSecureDesktop(self) -> None: connectionType=ConnectionMode.MASTER, ) self.sdRelay.registerInbound(RemoteMessageType.CLIENT_JOINED, self._onMasterDisplayChange) - self.slaveSession.transport.registerInbound( + self.followerSession.transport.registerInbound( RemoteMessageType.SET_BRAILLE_INFO, self._onMasterDisplayChange, ) - self.sdBridge = bridge.BridgeTransport(self.slaveSession.transport, self.sdRelay) + self.sdBridge = bridge.BridgeTransport(self.followerSession.transport, self.sdRelay) relayThread = threading.Thread(target=self.sdRelay.run) relayThread.daemon = True @@ -185,12 +185,12 @@ def leaveSecureDesktop(self) -> None: self.sdRelay.close() self.sdRelay = None - if self.slaveSession is not None and self.slaveSession.transport is not None: - self.slaveSession.transport.unregisterInbound( + if self.followerSession is not None and self.followerSession.transport is not None: + self.followerSession.transport.unregisterInbound( RemoteMessageType.SET_BRAILLE_INFO, self._onMasterDisplayChange, ) - self.slaveSession.setDisplaySize() + self.followerSession.setDisplaySize() try: self.IPCFile.unlink() @@ -228,11 +228,11 @@ def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: 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: + if self.sdRelay is not None and self.followerSession is not None: log.debug("Propagating display size change to secure desktop relay") self.sdRelay.send( type=RemoteMessageType.SET_DISPLAY_SIZE, - sizes=self.slaveSession.masterDisplaySizes, + sizes=self.followerSession.masterDisplaySizes, ) else: log.warning("No secure desktop relay or slave session available, skipping display change") diff --git a/tests/unit/test_remote_client.py b/tests/unit/test_remote_client.py index 5aaf89eba1b..2011aca28f6 100644 --- a/tests/unit/test_remote_client.py +++ b/tests/unit/test_remote_client.py @@ -132,7 +132,7 @@ def test_push_clipboard_with_transport(self): def test_copy_link_no_session(self): # If there is no session, copyLink should warn the user. self.client.masterSession = None - self.client.slaveSession = None + self.client.followerSession = None self.ui_message.reset_mock() self.client.copyLink() self.ui_message.assert_called_with("Not connected.") @@ -190,7 +190,7 @@ def test_connect_dispatch(self): def test_disconnect(self): # Test disconnect with no active sessions. self.client.masterSession = None - self.client.slaveSession = None + self.client.followerSession = None with patch("remoteClient.client.log.debug") as mock_log_debug: self.client.disconnect() mock_log_debug.assert_called() @@ -198,7 +198,7 @@ def test_disconnect(self): fake_control = MagicMock() self.client.localControlServer = fake_control self.client.masterSession = MagicMock() - self.client.slaveSession = MagicMock() + self.client.followerSession = MagicMock() self.client.disconnect() fake_control.close.assert_called_once() From 5d0dfd172fa6a2244df3ec668cfa517c0167e6f4 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 13:52:27 +1100 Subject: [PATCH 135/203] Renamed remoteClient.session.MasterSession to remoteClient.session.LeaderSession --- source/remoteClient/client.py | 6 +++--- source/remoteClient/session.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 3653b4815d3..3c038a58c02 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -25,7 +25,7 @@ from .menu import RemoteMenu from .protocol import RemoteMessageType, addressToHostPort from .secureDesktop import SecureDesktopHandler -from .session import MasterSession, FollowerSession +from .session import LeaderSession, FollowerSession from .protocol import hostPortToAddress from .transport import RelayTransport @@ -37,7 +37,7 @@ class RemoteClient: localScripts: Set[Callable] localMachine: LocalMachine - masterSession: Optional[MasterSession] + masterSession: Optional[LeaderSession] followerSession: Optional[FollowerSession] keyModifiers: Set[KeyModifier] hostPendingModifiers: Set[KeyModifier] @@ -258,7 +258,7 @@ def connectAsMaster(self, connectionInfo: ConnectionInfo): connection_info=connectionInfo, serializer=serializer.JSONSerializer(), ) - self.masterSession = MasterSession( + self.masterSession = LeaderSession( transport=transport, localMachine=self.localMachine, ) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 9b6a4099919..cf7738929e3 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -31,14 +31,14 @@ Key Components: ------------ -RemoteSession +:class:`RemoteSession` Base session managing shared functionality: - Message handler registration - Connection validation - Version compatibility - MOTD handling -MasterSession +:class:`LeaderSession` Controls remote instance: - Input capture/forwarding - Remote output reception @@ -430,7 +430,7 @@ def hasBrailleMasters(self) -> bool: return bool([i for i in self.masterDisplaySizes if i > 0]) -class MasterSession(RemoteSession): +class LeaderSession(RemoteSession): """Session that runs on the controlling (master) NVDA instance. :ivar slaves: Information about connected slave clients From ab6fbd7053b74e9ba99eec32568e566582d8e64c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 13:57:22 +1100 Subject: [PATCH 136/203] Renamed slaveTransport to followerTransport in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 40 ++++++++++++++++---------------- tests/unit/test_remote_client.py | 10 ++++---- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 3c038a58c02..b30eb125734 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -37,13 +37,13 @@ class RemoteClient: localScripts: Set[Callable] localMachine: LocalMachine - masterSession: Optional[LeaderSession] + leaderSession: Optional[LeaderSession] followerSession: Optional[FollowerSession] keyModifiers: Set[KeyModifier] hostPendingModifiers: Set[KeyModifier] connecting: bool masterTransport: Optional[RelayTransport] - slaveTransport: Optional[RelayTransport] + followerTransport: Optional[RelayTransport] localControlServer: Optional[server.LocalRelayServer] sendingKeys: bool @@ -56,14 +56,14 @@ def __init__( self.localScripts = set() self.localMachine = LocalMachine() self.followerSession = None - self.masterSession = None + self.leaderSession = None self.menu: Optional[RemoteMenu] = None if not isRunningOnSecureDesktop(): self.menu: Optional[RemoteMenu] = RemoteMenu(self) self.connecting = False urlHandler.registerURLHandler() self.masterTransport = None - self.slaveTransport = None + self.followerTransport = None self.localControlServer = None self.sendingKeys = False self.sdHandler = SecureDesktopHandler() @@ -79,7 +79,7 @@ def __init__( def performAutoconnect(self): controlServerConfig = configuration.get_config()["controlserver"] - if not controlServerConfig["autoconnect"] or self.masterSession or self.followerSession: + if not controlServerConfig["autoconnect"] or self.leaderSession or self.followerSession: log.debug("Autoconnect disabled or already connected") return key = controlServerConfig["key"] @@ -128,7 +128,7 @@ def pushClipboard(self): :note: Requires an active connection :raises TypeError: If clipboard content cannot be serialized """ - connector = self.slaveTransport or self.masterTransport + connector = self.followerTransport 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.")) @@ -146,7 +146,7 @@ def copyLink(self): :note: Requires an active session """ - session = self.masterSession or self.followerSession + session = self.leaderSession or self.followerSession if session is None: # Translators: Message shown when trying to copy the link to connect to the remote computer while not connected. ui.message(_("Not connected.")) @@ -183,14 +183,14 @@ def disconnect(self): :note: Closes local control server and both master/slave sessions if active """ - if self.masterSession is None and self.followerSession is None: + if self.leaderSession is None and self.followerSession is None: log.debug("Disconnect called but no active sessions") return log.info("Disconnecting from remote session") if self.localControlServer is not None: self.localControlServer.close() self.localControlServer = None - if self.masterSession is not None: + if self.leaderSession is not None: self.disconnectAsMaster() if self.followerSession is not None: self.disconnectAsSlave() @@ -198,15 +198,15 @@ def disconnect(self): def disconnectAsMaster(self): """Close master session and clean up related resources.""" - self.masterSession.close() - self.masterSession = None + self.leaderSession.close() + self.leaderSession = None self.masterTransport = None def disconnectAsSlave(self): """Close slave session and clean up related resources.""" self.followerSession.close() self.followerSession = None - self.slaveTransport = None + self.followerTransport = None self.sdHandler.followerSession = None @alwaysCallAfter @@ -258,7 +258,7 @@ def connectAsMaster(self, connectionInfo: ConnectionInfo): connection_info=connectionInfo, serializer=serializer.JSONSerializer(), ) - self.masterSession = LeaderSession( + self.leaderSession = LeaderSession( transport=transport, localMachine=self.localMachine, ) @@ -277,7 +277,7 @@ def connectAsMaster(self, connectionInfo: ConnectionInfo): @alwaysCallAfter def onConnectedAsMaster(self): log.info("Successfully connected as master") - configuration.write_connection_to_config(self.masterSession.getConnectionInfo()) + configuration.write_connection_to_config(self.leaderSession.getConnectionInfo()) if self.menu: self.menu.handleConnected(ConnectionMode.MASTER, True) ui.message( @@ -312,7 +312,7 @@ def connectAsSlave(self, connectionInfo: ConnectionInfo): localMachine=self.localMachine, ) self.sdHandler.followerSession = self.followerSession - self.slaveTransport = transport + self.followerTransport = transport transport.transportCertificateAuthenticationFailed.register( self.onSlaveCertificateFailed, ) @@ -360,7 +360,7 @@ def handleCertificateFailure(self, transport: RelayTransport): @alwaysCallAfter def onMasterCertificateFailed(self): - if self.handleCertificateFailure(self.masterSession.transport): + if self.handleCertificateFailure(self.leaderSession.transport): connectionInfo = ConnectionInfo( mode=ConnectionMode.MASTER, hostname=self.lastFailAddress[0], @@ -485,11 +485,11 @@ def setReceivingBraille(self, state): :param state: True to enable remote braille, False to disable :note: Only enables if master session and braille handler are ready """ - if state and self.masterSession.callbacksAdded and braille.handler.enabled: - self.masterSession.registerBrailleInput() + if state and self.leaderSession.callbacksAdded and braille.handler.enabled: + self.leaderSession.registerBrailleInput() self.localMachine.receivingBraille = True elif not state: - self.masterSession.unregisterBrailleInput() + self.leaderSession.unregisterBrailleInput() self.localMachine.receivingBraille = False @alwaysCallAfter @@ -547,7 +547,7 @@ def isConnected(self): :return: True if either slave or master transport is connected :rtype: bool """ - connector = self.slaveTransport or self.masterTransport + connector = self.followerTransport or self.masterTransport if connector is not None: return connector.connected return False diff --git a/tests/unit/test_remote_client.py b/tests/unit/test_remote_client.py index 2011aca28f6..f26e65cde23 100644 --- a/tests/unit/test_remote_client.py +++ b/tests/unit/test_remote_client.py @@ -113,7 +113,7 @@ def test_toggle_mute(self): def test_push_clipboard_no_connection(self): # Without any transport (neither slave nor master), pushClipboard should warn. - self.client.slaveTransport = None + self.client.followerTransport = None self.client.masterTransport = None self.client.pushClipboard() self.ui_message.assert_called_with("Not connected.") @@ -131,7 +131,7 @@ def test_push_clipboard_with_transport(self): def test_copy_link_no_session(self): # If there is no session, copyLink should warn the user. - self.client.masterSession = None + self.client.leaderSession = None self.client.followerSession = None self.ui_message.reset_mock() self.client.copyLink() @@ -140,7 +140,7 @@ def test_copy_link_no_session(self): def test_copy_link_with_session(self): # With a fake session, copyLink should call api.copyToClip with the proper URL. fake_session = FakeSession("http://fake.url/connect") - self.client.masterSession = fake_session + self.client.leaderSession = fake_session FakeAPI.copied = None self.client.copyLink() self.assertEqual(FakeAPI.copied, "http://fake.url/connect") @@ -189,7 +189,7 @@ def test_connect_dispatch(self): def test_disconnect(self): # Test disconnect with no active sessions. - self.client.masterSession = None + self.client.leaderSession = None self.client.followerSession = None with patch("remoteClient.client.log.debug") as mock_log_debug: self.client.disconnect() @@ -197,7 +197,7 @@ def test_disconnect(self): # Test disconnect with an active localControlServer. fake_control = MagicMock() self.client.localControlServer = fake_control - self.client.masterSession = MagicMock() + self.client.leaderSession = MagicMock() self.client.followerSession = MagicMock() self.client.disconnect() fake_control.close.assert_called_once() From 23cb747e066ebcc420c15e2c5ef37d459a98f5e3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 14:55:10 +1100 Subject: [PATCH 137/203] Renamed masterTransport to leaderTransport in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 26 +++++++++++++------------- tests/unit/test_remote_client.py | 12 ++++++------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index b30eb125734..bf017a1f603 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -42,7 +42,7 @@ class RemoteClient: keyModifiers: Set[KeyModifier] hostPendingModifiers: Set[KeyModifier] connecting: bool - masterTransport: Optional[RelayTransport] + leaderTransport: Optional[RelayTransport] followerTransport: Optional[RelayTransport] localControlServer: Optional[server.LocalRelayServer] sendingKeys: bool @@ -62,7 +62,7 @@ def __init__( self.menu: Optional[RemoteMenu] = RemoteMenu(self) self.connecting = False urlHandler.registerURLHandler() - self.masterTransport = None + self.leaderTransport = None self.followerTransport = None self.localControlServer = None self.sendingKeys = False @@ -128,7 +128,7 @@ def pushClipboard(self): :note: Requires an active connection :raises TypeError: If clipboard content cannot be serialized """ - connector = self.followerTransport or self.masterTransport + connector = self.followerTransport or self.leaderTransport if not getattr(connector, "connected", False): # Translators: Message shown when trying to push the clipboard to the remote computer while not connected. ui.message(_("Not connected.")) @@ -159,10 +159,10 @@ def sendSAS(self): :note: Requires an active master transport connection """ - if self.masterTransport is None: + if self.leaderTransport is None: log.error("No master transport to send SAS") return - self.masterTransport.send(RemoteMessageType.SEND_SAS) + self.leaderTransport.send(RemoteMessageType.SEND_SAS) def connect(self, connectionInfo: ConnectionInfo): """Establish connection based on connection info. @@ -200,7 +200,7 @@ def disconnectAsMaster(self): """Close master session and clean up related resources.""" self.leaderSession.close() self.leaderSession = None - self.masterTransport = None + self.leaderTransport = None def disconnectAsSlave(self): """Close slave session and clean up related resources.""" @@ -211,8 +211,8 @@ def disconnectAsSlave(self): @alwaysCallAfter def onConnectAsMasterFailed(self): - if self.masterTransport.successfulConnects == 0: - log.error(f"Failed to connect to {self.masterTransport.address}") + if self.leaderTransport.successfulConnects == 0: + log.error(f"Failed to connect to {self.leaderTransport.address}") self.disconnectAsMaster() # Translators: Title of the connection error dialog. gui.messageBox( @@ -270,7 +270,7 @@ def connectAsMaster(self, connectionInfo: ConnectionInfo): transport.transportClosing.register(self.onDisconnectingAsMaster) transport.transportDisconnected.register(self.onDisconnectedAsMaster) transport.reconnectorThread.start() - self.masterTransport = transport + self.leaderTransport = transport if self.menu: self.menu.handleConnecting(connectionInfo.mode) @@ -432,7 +432,7 @@ def processKeyInput(self, vkCode=None, scanCode=None, extended=None, pressed=Non if script in self.localScripts: wx.CallAfter(script, gesture) return False - self.masterTransport.send( + self.leaderTransport.send( RemoteMessageType.KEY, vk_code=vkCode, extended=extended, @@ -447,7 +447,7 @@ def toggleRemoteKeyControl(self, gesture: KeyboardInputGesture): :param gesture: The keyboard gesture that triggered this :note: Also toggles braille input and mute state """ - if not self.masterTransport: + if not self.leaderTransport: gesture.send() return self.sendingKeys = not self.sendingKeys @@ -471,7 +471,7 @@ def releaseKeys(self): """ # release all pressed keys in the guest. for k in self.keyModifiers: - self.masterTransport.send( + self.leaderTransport.send( RemoteMessageType.KEY, vk_code=k[0], extended=k[1], @@ -547,7 +547,7 @@ def isConnected(self): :return: True if either slave or master transport is connected :rtype: bool """ - connector = self.followerTransport or self.masterTransport + connector = self.followerTransport or self.leaderTransport if connector is not None: return connector.connected return False diff --git a/tests/unit/test_remote_client.py b/tests/unit/test_remote_client.py index f26e65cde23..b69253012a1 100644 --- a/tests/unit/test_remote_client.py +++ b/tests/unit/test_remote_client.py @@ -114,14 +114,14 @@ def test_toggle_mute(self): def test_push_clipboard_no_connection(self): # Without any transport (neither slave nor master), pushClipboard should warn. self.client.followerTransport = None - self.client.masterTransport = None + self.client.leaderTransport = None self.client.pushClipboard() self.ui_message.assert_called_with("Not connected.") def test_push_clipboard_with_transport(self): # With a fake transport, pushClipboard should send the clipboard text. fake_transport = FakeTransport() - self.client.masterTransport = fake_transport + self.client.leaderTransport = fake_transport FakeAPI.clip_data = "TestClipboard" self.client.pushClipboard() self.assertTrue(len(fake_transport.sent) > 0) @@ -146,16 +146,16 @@ def test_copy_link_with_session(self): self.assertEqual(FakeAPI.copied, "http://fake.url/connect") def test_send_sas_no_master_transport(self): - # Without a masterTransport, sendSAS should log an error. - self.client.masterTransport = None + # Without a leaderTransport, sendSAS should log an error. + self.client.leaderTransport = None with patch("remoteClient.client.log.error") as mock_log_error: self.client.sendSAS() mock_log_error.assert_called_once_with("No master transport to send SAS") def test_send_sas_with_master_transport(self): - # With a fake masterTransport, sendSAS should forward the SEND_SAS message. + # With a fake leaderTransport, sendSAS should forward the SEND_SAS message. fake_transport = FakeTransport() - self.client.masterTransport = fake_transport + self.client.leaderTransport = fake_transport self.client.sendSAS() self.assertTrue(len(fake_transport.sent) > 0) messageType, _ = fake_transport.sent[0] From f387125c4fcf3d8ef6ac8298e55035ea4b1caa7a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:06:15 +1100 Subject: [PATCH 138/203] Renamed disconnectAsMaster to disconnectAsLeader in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index bf017a1f603..ec46a1a5e38 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -191,12 +191,12 @@ def disconnect(self): self.localControlServer.close() self.localControlServer = None if self.leaderSession is not None: - self.disconnectAsMaster() + self.disconnectAsLeader() if self.followerSession is not None: self.disconnectAsSlave() cues.disconnected() - def disconnectAsMaster(self): + def disconnectAsLeader(self): """Close master session and clean up related resources.""" self.leaderSession.close() self.leaderSession = None @@ -213,7 +213,7 @@ def disconnectAsSlave(self): def onConnectAsMasterFailed(self): if self.leaderTransport.successfulConnects == 0: log.error(f"Failed to connect to {self.leaderTransport.address}") - self.disconnectAsMaster() + self.disconnectAsLeader() # Translators: Title of the connection error dialog. gui.messageBox( parent=gui.mainFrame, From 5378f7279d48617a2f86d5b395ff299ea7b30e40 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:08:23 +1100 Subject: [PATCH 139/203] Renamed disconnectAsSlave to disconnectAsFollower in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index ec46a1a5e38..64eda5889be 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -193,7 +193,7 @@ def disconnect(self): if self.leaderSession is not None: self.disconnectAsLeader() if self.followerSession is not None: - self.disconnectAsSlave() + self.disconnectAsFollower() cues.disconnected() def disconnectAsLeader(self): @@ -202,7 +202,7 @@ def disconnectAsLeader(self): self.leaderSession = None self.leaderTransport = None - def disconnectAsSlave(self): + def disconnectAsFollower(self): """Close slave session and clean up related resources.""" self.followerSession.close() self.followerSession = None From 70f95ba20b28e5d259c523b805fbae1311f4c608 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:10:45 +1100 Subject: [PATCH 140/203] Renamed onConnectAsMasterFailed to onConnectAsLeaderFailed in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 64eda5889be..06016f8d96d 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -210,7 +210,7 @@ def disconnectAsFollower(self): self.sdHandler.followerSession = None @alwaysCallAfter - def onConnectAsMasterFailed(self): + def onConnectAsLeaderFailed(self): if self.leaderTransport.successfulConnects == 0: log.error(f"Failed to connect to {self.leaderTransport.address}") self.disconnectAsLeader() @@ -266,7 +266,7 @@ def connectAsMaster(self, connectionInfo: ConnectionInfo): self.onMasterCertificateFailed, ) transport.transportConnected.register(self.onConnectedAsMaster) - transport.transportConnectionFailed.register(self.onConnectAsMasterFailed) + transport.transportConnectionFailed.register(self.onConnectAsLeaderFailed) transport.transportClosing.register(self.onDisconnectingAsMaster) transport.transportDisconnected.register(self.onDisconnectedAsMaster) transport.reconnectorThread.start() From 1540e248ac99a50b1bf0f78bd4defc9c209d0cd1 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:12:44 +1100 Subject: [PATCH 141/203] Renamed connectAsMaster to connectAsLeader in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 6 +++--- tests/unit/test_remote_client.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 06016f8d96d..4486a4153d8 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -174,7 +174,7 @@ def connect(self, connectionInfo: ConnectionInfo): f"Initiating connection as {connectionInfo.mode} to {connectionInfo.hostname}:{connectionInfo.port}", ) if connectionInfo.mode == ConnectionMode.MASTER: - self.connectAsMaster(connectionInfo) + self.connectAsLeader(connectionInfo) elif connectionInfo.mode == ConnectionMode.SLAVE: self.connectAsSlave(connectionInfo) @@ -253,7 +253,7 @@ def handleDialogCompletion(dlgResult): gui.runScriptModalDialog(dlg, callback=handleDialogCompletion) - def connectAsMaster(self, connectionInfo: ConnectionInfo): + def connectAsLeader(self, connectionInfo: ConnectionInfo): transport = RelayTransport.create( connection_info=connectionInfo, serializer=serializer.JSONSerializer(), @@ -368,7 +368,7 @@ def onMasterCertificateFailed(self): key=self.lastFailKey, insecure=True, ) - self.connectAsMaster(connectionInfo=connectionInfo) + self.connectAsLeader(connectionInfo=connectionInfo) @alwaysCallAfter def onSlaveCertificateFailed(self): diff --git a/tests/unit/test_remote_client.py b/tests/unit/test_remote_client.py index b69253012a1..429a890e2f0 100644 --- a/tests/unit/test_remote_client.py +++ b/tests/unit/test_remote_client.py @@ -162,10 +162,10 @@ def test_send_sas_with_master_transport(self): self.assertEqual(messageType, RemoteMessageType.SEND_SAS) def test_connect_dispatch(self): - # Ensure that connect() dispatches to connectAsMaster or connectAsSlave based on connection mode. + # Ensure that connect() dispatches to connectAsLeader or connectAsSlave based on connection mode. fake_connect_as_master = MagicMock() fake_connect_as_slave = MagicMock() - self.client.connectAsMaster = fake_connect_as_master + self.client.connectAsLeader = fake_connect_as_master self.client.connectAsSlave = fake_connect_as_slave conn_info_master = ConnectionInfo( hostname="localhost", From 3aede4b1fd397ac83ae7771f9c9f8677ad6a163a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:14:52 +1100 Subject: [PATCH 142/203] Renamed onConnectedAsMaster to onConnectedAsLeader in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 4486a4153d8..3edb67b348b 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -265,7 +265,7 @@ def connectAsLeader(self, connectionInfo: ConnectionInfo): transport.transportCertificateAuthenticationFailed.register( self.onMasterCertificateFailed, ) - transport.transportConnected.register(self.onConnectedAsMaster) + transport.transportConnected.register(self.onConnectedAsLeader) transport.transportConnectionFailed.register(self.onConnectAsLeaderFailed) transport.transportClosing.register(self.onDisconnectingAsMaster) transport.transportDisconnected.register(self.onDisconnectedAsMaster) @@ -275,7 +275,7 @@ def connectAsLeader(self, connectionInfo: ConnectionInfo): self.menu.handleConnecting(connectionInfo.mode) @alwaysCallAfter - def onConnectedAsMaster(self): + def onConnectedAsLeader(self): log.info("Successfully connected as master") configuration.write_connection_to_config(self.leaderSession.getConnectionInfo()) if self.menu: From ac722983685f513e7560ead830c2f4e5862fc27c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:16:28 +1100 Subject: [PATCH 143/203] Renamed onDisconnectingAsMaster to onDisconnectingAsLeader in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 3edb67b348b..4eefbdb2584 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -267,7 +267,7 @@ def connectAsLeader(self, connectionInfo: ConnectionInfo): ) transport.transportConnected.register(self.onConnectedAsLeader) transport.transportConnectionFailed.register(self.onConnectAsLeaderFailed) - transport.transportClosing.register(self.onDisconnectingAsMaster) + transport.transportClosing.register(self.onDisconnectingAsLeader) transport.transportDisconnected.register(self.onDisconnectedAsMaster) transport.reconnectorThread.start() self.leaderTransport = transport @@ -287,7 +287,7 @@ def onConnectedAsLeader(self): cues.connected() @alwaysCallAfter - def onDisconnectingAsMaster(self): + def onDisconnectingAsLeader(self): log.info("Master session disconnecting") if self.menu: self.menu.handleConnected(ConnectionMode.MASTER, False) From ba2425d846b2acbceb6dff22c2e90b3000d7edac Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:17:50 +1100 Subject: [PATCH 144/203] Renamed onDisconnectedAsMaster to onDisconnectedAsLeader in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 4eefbdb2584..d43072cf738 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -268,7 +268,7 @@ def connectAsLeader(self, connectionInfo: ConnectionInfo): transport.transportConnected.register(self.onConnectedAsLeader) transport.transportConnectionFailed.register(self.onConnectAsLeaderFailed) transport.transportClosing.register(self.onDisconnectingAsLeader) - transport.transportDisconnected.register(self.onDisconnectedAsMaster) + transport.transportDisconnected.register(self.onDisconnectedAsLeader) transport.reconnectorThread.start() self.leaderTransport = transport if self.menu: @@ -297,7 +297,7 @@ def onDisconnectingAsLeader(self): self.keyModifiers = set() @alwaysCallAfter - def onDisconnectedAsMaster(self): + def onDisconnectedAsLeader(self): log.info("Master session disconnected") # Translators: Presented when connection to a remote computer was interupted. ui.message(_("Connection interrupted")) From d813d1e0289144e3541f04b357e4f7ff65b0b887 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:20:05 +1100 Subject: [PATCH 145/203] Renamed connectAsSlave to connectAsFollower in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 8 ++++---- tests/unit/test_remote_client.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index d43072cf738..70165f4e6a6 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -70,7 +70,7 @@ def __init__( if isRunningOnSecureDesktop(): connection = self.sdHandler.initializeSecureDesktop() if connection: - self.connectAsSlave(connection) + self.connectAsFollower(connection) self.followerSession.transport.connectedEvent.wait( self.sdHandler.SD_CONNECT_BLOCK_TIMEOUT, ) @@ -176,7 +176,7 @@ def connect(self, connectionInfo: ConnectionInfo): if connectionInfo.mode == ConnectionMode.MASTER: self.connectAsLeader(connectionInfo) elif connectionInfo.mode == ConnectionMode.SLAVE: - self.connectAsSlave(connectionInfo) + self.connectAsFollower(connectionInfo) def disconnect(self): """Close all active connections and clean up resources. @@ -302,7 +302,7 @@ def onDisconnectedAsLeader(self): # Translators: Presented when connection to a remote computer was interupted. ui.message(_("Connection interrupted")) - def connectAsSlave(self, connectionInfo: ConnectionInfo): + def connectAsFollower(self, connectionInfo: ConnectionInfo): transport = RelayTransport.create( connection_info=connectionInfo, serializer=serializer.JSONSerializer(), @@ -380,7 +380,7 @@ def onSlaveCertificateFailed(self): key=self.lastFailKey, insecure=True, ) - self.connectAsSlave(connectionInfo=connectionInfo) + self.connectAsFollower(connectionInfo=connectionInfo) def startControlServer(self, serverPort, channel): """Start local relay server for handling connections. diff --git a/tests/unit/test_remote_client.py b/tests/unit/test_remote_client.py index 429a890e2f0..21a90488745 100644 --- a/tests/unit/test_remote_client.py +++ b/tests/unit/test_remote_client.py @@ -162,11 +162,11 @@ def test_send_sas_with_master_transport(self): self.assertEqual(messageType, RemoteMessageType.SEND_SAS) def test_connect_dispatch(self): - # Ensure that connect() dispatches to connectAsLeader or connectAsSlave based on connection mode. + # Ensure that connect() dispatches to connectAsLeader or connectAsFollower based on connection mode. fake_connect_as_master = MagicMock() fake_connect_as_slave = MagicMock() self.client.connectAsLeader = fake_connect_as_master - self.client.connectAsSlave = fake_connect_as_slave + self.client.connectAsFollower = fake_connect_as_slave conn_info_master = ConnectionInfo( hostname="localhost", mode=ConnectionMode.MASTER, From 81ef31b0acb6695da64b524362803f30a1a3d226 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:21:52 +1100 Subject: [PATCH 146/203] Renamed onConnectedAsSlave to onConnectedAsFollower in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 70165f4e6a6..a2b881dd844 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -316,14 +316,14 @@ def connectAsFollower(self, connectionInfo: ConnectionInfo): transport.transportCertificateAuthenticationFailed.register( self.onSlaveCertificateFailed, ) - transport.transportConnected.register(self.onConnectedAsSlave) + transport.transportConnected.register(self.onConnectedAsFollower) transport.transportDisconnected.register(self.onDisconnectedAsSlave) transport.reconnectorThread.start() if self.menu: self.menu.handleConnecting(connectionInfo.mode) @alwaysCallAfter - def onConnectedAsSlave(self): + def onConnectedAsFollower(self): log.info("Control connector connected") cues.controlServerConnected() if self.menu: From 3e5f1b37b3546172dfdbf69a8d9c80c62f940040 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:23:32 +1100 Subject: [PATCH 147/203] Renamed onDisconnectedAsSlave to onDisconnectedAsFollower in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index a2b881dd844..e81c5a2edf5 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -317,7 +317,7 @@ def connectAsFollower(self, connectionInfo: ConnectionInfo): self.onSlaveCertificateFailed, ) transport.transportConnected.register(self.onConnectedAsFollower) - transport.transportDisconnected.register(self.onDisconnectedAsSlave) + transport.transportDisconnected.register(self.onDisconnectedAsFollower) transport.reconnectorThread.start() if self.menu: self.menu.handleConnecting(connectionInfo.mode) @@ -331,7 +331,7 @@ def onConnectedAsFollower(self): configuration.write_connection_to_config(self.followerSession.getConnectionInfo()) @alwaysCallAfter - def onDisconnectedAsSlave(self): + def onDisconnectedAsFollower(self): log.info("Control connector disconnected") # cues.control_server_disconnected() if self.menu: From 32dbf6b5b0858d67f6b1020787f28b5a08aefc40 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:25:05 +1100 Subject: [PATCH 148/203] Renamed onMasterCertificateFailed to onLeaderCertificateFailed in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index e81c5a2edf5..1ef89374c75 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -263,7 +263,7 @@ def connectAsLeader(self, connectionInfo: ConnectionInfo): localMachine=self.localMachine, ) transport.transportCertificateAuthenticationFailed.register( - self.onMasterCertificateFailed, + self.onLeaderCertificateFailed, ) transport.transportConnected.register(self.onConnectedAsLeader) transport.transportConnectionFailed.register(self.onConnectAsLeaderFailed) @@ -359,7 +359,7 @@ def handleCertificateFailure(self, transport: RelayTransport): return False @alwaysCallAfter - def onMasterCertificateFailed(self): + def onLeaderCertificateFailed(self): if self.handleCertificateFailure(self.leaderSession.transport): connectionInfo = ConnectionInfo( mode=ConnectionMode.MASTER, From 2e557b043d08222547247c16f6f2f3090ac2839b Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:26:44 +1100 Subject: [PATCH 149/203] Renamed onSlaveCertificateFailed to onFollowerCertificateFailed in remoteClient.client.RemoteClient --- source/remoteClient/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 1ef89374c75..2af882f759a 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -314,7 +314,7 @@ def connectAsFollower(self, connectionInfo: ConnectionInfo): self.sdHandler.followerSession = self.followerSession self.followerTransport = transport transport.transportCertificateAuthenticationFailed.register( - self.onSlaveCertificateFailed, + self.onFollowerCertificateFailed, ) transport.transportConnected.register(self.onConnectedAsFollower) transport.transportDisconnected.register(self.onDisconnectedAsFollower) @@ -371,7 +371,7 @@ def onLeaderCertificateFailed(self): self.connectAsLeader(connectionInfo=connectionInfo) @alwaysCallAfter - def onSlaveCertificateFailed(self): + def onFollowerCertificateFailed(self): if self.handleCertificateFailure(self.followerSession.transport): connectionInfo = ConnectionInfo( mode=ConnectionMode.SLAVE, From 41b03d4c962cb4de16aa011fe839d1ba678f5e75 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:32:55 +1100 Subject: [PATCH 150/203] Renamed ConnectionMode.MASTER to ConnectionMode.LEADER in remoteClient.connectionInfo --- source/remoteClient/client.py | 12 ++++++------ source/remoteClient/connectionInfo.py | 6 +++--- source/remoteClient/dialogs.py | 2 +- source/remoteClient/secureDesktop.py | 2 +- source/remoteClient/session.py | 2 +- tests/unit/test_remote_client.py | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 2af882f759a..fd241f9e627 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -92,7 +92,7 @@ def performAutoconnect(self): else: address = addressToHostPort(controlServerConfig["host"]) hostname, port = address - mode = ConnectionMode.SLAVE if controlServerConfig["connection_type"] == 0 else ConnectionMode.MASTER + mode = ConnectionMode.SLAVE if controlServerConfig["connection_type"] == 0 else ConnectionMode.LEADER conInfo = ConnectionInfo(mode=mode, hostname=hostname, port=port, key=key, insecure=insecure) self.connect(conInfo) @@ -173,7 +173,7 @@ def connect(self, connectionInfo: ConnectionInfo): log.info( f"Initiating connection as {connectionInfo.mode} to {connectionInfo.hostname}:{connectionInfo.port}", ) - if connectionInfo.mode == ConnectionMode.MASTER: + if connectionInfo.mode == ConnectionMode.LEADER: self.connectAsLeader(connectionInfo) elif connectionInfo.mode == ConnectionMode.SLAVE: self.connectAsFollower(connectionInfo) @@ -279,7 +279,7 @@ def onConnectedAsLeader(self): log.info("Successfully connected as master") configuration.write_connection_to_config(self.leaderSession.getConnectionInfo()) if self.menu: - self.menu.handleConnected(ConnectionMode.MASTER, True) + self.menu.handleConnected(ConnectionMode.LEADER, True) ui.message( # Translators: Presented when connected to the remote computer. _("Connected!"), @@ -290,7 +290,7 @@ def onConnectedAsLeader(self): def onDisconnectingAsLeader(self): log.info("Master session disconnecting") if self.menu: - self.menu.handleConnected(ConnectionMode.MASTER, False) + self.menu.handleConnected(ConnectionMode.LEADER, False) if self.localMachine: self.localMachine.isMuted = False self.sendingKeys = False @@ -362,7 +362,7 @@ def handleCertificateFailure(self, transport: RelayTransport): def onLeaderCertificateFailed(self): if self.handleCertificateFailure(self.leaderSession.transport): connectionInfo = ConnectionInfo( - mode=ConnectionMode.MASTER, + mode=ConnectionMode.LEADER, hostname=self.lastFailAddress[0], port=self.lastFailAddress[1], key=self.lastFailKey, @@ -514,7 +514,7 @@ def verifyAndConnect(self, conInfo: ConnectionInfo): key = conInfo.key # Prepare connection request message based on mode - if conInfo.mode == ConnectionMode.MASTER: + if conInfo.mode == ConnectionMode.LEADER: # Translators: Ask the user if they want to control the remote computer. question = _("Do you wish to control the machine on server {server} with key {key}?") else: diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py index 99d86955fe7..8890359deb1 100644 --- a/source/remoteClient/connectionInfo.py +++ b/source/remoteClient/connectionInfo.py @@ -24,11 +24,11 @@ class URLParsingError(Exception): class ConnectionMode(StrEnum): """Defines the connection mode for remote connections. - :cvar MASTER: Controller mode for controlling the remote system + :cvar LEADER: Controller mode for controlling the remote system :cvar SLAVE: Controlled mode for being controlled by remote system """ - MASTER = "master" + LEADER = "master" SLAVE = "slave" @@ -146,7 +146,7 @@ def getURLToConnect(self) -> str: :return: URL string with opposite connection mode """ # Flip master/slave for connection URL - connect_mode = ConnectionMode.SLAVE if self.mode == ConnectionMode.MASTER else ConnectionMode.MASTER + connect_mode = ConnectionMode.SLAVE if self.mode == ConnectionMode.LEADER else ConnectionMode.LEADER return self._build_url(connect_mode) def getURL(self) -> str: diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 254e90e947e..39069883679 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -294,7 +294,7 @@ 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 + mode = ConnectionMode.LEADER if self.connectionType.GetSelection() == 0 else ConnectionMode.SLAVE return ConnectionInfo( hostname=serverAddr, mode=mode, diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index fc537543109..591868477ba 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -147,7 +147,7 @@ def enterSecureDesktop(self) -> None: serializer=JSONSerializer(), channel=channel, insecure=True, - connectionType=ConnectionMode.MASTER, + connectionType=ConnectionMode.LEADER, ) self.sdRelay.registerInbound(RemoteMessageType.CLIENT_JOINED, self._onMasterDisplayChange) self.followerSession.transport.registerInbound( diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index cf7738929e3..24b64ebe1d9 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -443,7 +443,7 @@ class LeaderSession(RemoteSession): - Input handling patches """ - mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.MASTER + mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.LEADER slaves: dict[int, dict[str, Any]] # Information about connected slave def __init__( diff --git a/tests/unit/test_remote_client.py b/tests/unit/test_remote_client.py index 21a90488745..f3106eb3f72 100644 --- a/tests/unit/test_remote_client.py +++ b/tests/unit/test_remote_client.py @@ -169,7 +169,7 @@ def test_connect_dispatch(self): self.client.connectAsFollower = fake_connect_as_slave conn_info_master = ConnectionInfo( hostname="localhost", - mode=ConnectionMode.MASTER, + mode=ConnectionMode.LEADER, key="abc", port=1000, insecure=False, From 68ef12881d54dda84b317a233f9e1fb87f6025dc Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:35:54 +1100 Subject: [PATCH 151/203] Renamed ConnectionMode.SLAVE to ConnectionMode.FOLLOWER in remoteClient.connectionInfo --- source/remoteClient/client.py | 12 +++++++----- source/remoteClient/connectionInfo.py | 8 +++++--- source/remoteClient/dialogs.py | 4 +++- source/remoteClient/secureDesktop.py | 2 +- source/remoteClient/session.py | 2 +- tests/unit/test_remote_client.py | 2 +- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index fd241f9e627..951464e75a8 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -92,7 +92,9 @@ def performAutoconnect(self): else: address = addressToHostPort(controlServerConfig["host"]) hostname, port = address - mode = ConnectionMode.SLAVE if controlServerConfig["connection_type"] == 0 else ConnectionMode.LEADER + mode = ( + ConnectionMode.FOLLOWER if controlServerConfig["connection_type"] == 0 else ConnectionMode.LEADER + ) conInfo = ConnectionInfo(mode=mode, hostname=hostname, port=port, key=key, insecure=insecure) self.connect(conInfo) @@ -175,7 +177,7 @@ def connect(self, connectionInfo: ConnectionInfo): ) if connectionInfo.mode == ConnectionMode.LEADER: self.connectAsLeader(connectionInfo) - elif connectionInfo.mode == ConnectionMode.SLAVE: + elif connectionInfo.mode == ConnectionMode.FOLLOWER: self.connectAsFollower(connectionInfo) def disconnect(self): @@ -327,7 +329,7 @@ def onConnectedAsFollower(self): log.info("Control connector connected") cues.controlServerConnected() if self.menu: - self.menu.handleConnected(ConnectionMode.SLAVE, True) + self.menu.handleConnected(ConnectionMode.FOLLOWER, True) configuration.write_connection_to_config(self.followerSession.getConnectionInfo()) @alwaysCallAfter @@ -335,7 +337,7 @@ def onDisconnectedAsFollower(self): log.info("Control connector disconnected") # cues.control_server_disconnected() if self.menu: - self.menu.handleConnected(ConnectionMode.SLAVE, False) + self.menu.handleConnected(ConnectionMode.FOLLOWER, False) ### certificate handling @@ -374,7 +376,7 @@ def onLeaderCertificateFailed(self): def onFollowerCertificateFailed(self): if self.handleCertificateFailure(self.followerSession.transport): connectionInfo = ConnectionInfo( - mode=ConnectionMode.SLAVE, + mode=ConnectionMode.FOLLOWER, hostname=self.lastFailAddress[0], port=self.lastFailAddress[1], key=self.lastFailKey, diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py index 8890359deb1..eccbcae77e6 100644 --- a/source/remoteClient/connectionInfo.py +++ b/source/remoteClient/connectionInfo.py @@ -25,11 +25,11 @@ class ConnectionMode(StrEnum): """Defines the connection mode for remote connections. :cvar LEADER: Controller mode for controlling the remote system - :cvar SLAVE: Controlled mode for being controlled by remote system + :cvar FOLLOWER: Controlled mode for being controlled by remote system """ LEADER = "master" - SLAVE = "slave" + FOLLOWER = "slave" class ConnectionState(StrEnum): @@ -146,7 +146,9 @@ def getURLToConnect(self) -> str: :return: URL string with opposite connection mode """ # Flip master/slave for connection URL - connect_mode = ConnectionMode.SLAVE if self.mode == ConnectionMode.LEADER else ConnectionMode.LEADER + connect_mode = ( + ConnectionMode.FOLLOWER if self.mode == ConnectionMode.LEADER else ConnectionMode.LEADER + ) return self._build_url(connect_mode) def getURL(self) -> str: diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 39069883679..463c3071568 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -294,7 +294,9 @@ def getConnectionInfo(self) -> ConnectionInfo: if self.clientOrServer.GetSelection() == 0: # client host = self.panel.host.GetValue() serverAddr, port = protocol.addressToHostPort(host) - mode = ConnectionMode.LEADER if self.connectionType.GetSelection() == 0 else ConnectionMode.SLAVE + mode = ( + ConnectionMode.LEADER if self.connectionType.GetSelection() == 0 else ConnectionMode.FOLLOWER + ) return ConnectionInfo( hostname=serverAddr, mode=mode, diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 591868477ba..21d7f0c3ae5 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -215,7 +215,7 @@ def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: log.info(f"Successfully established secure desktop connection on port {port}") return ConnectionInfo( hostname="127.0.0.1", - mode=ConnectionMode.SLAVE, + mode=ConnectionMode.FOLLOWER, key=channel, port=port, insecure=True, diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 24b64ebe1d9..d11ae0627a1 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -250,7 +250,7 @@ class FollowerSession(RemoteSession): """ # Connection mode - always 'slave' - mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.SLAVE + mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.FOLLOWER # Information about connected master clients masters: dict[int, dict[str, Any]] masterDisplaySizes: list[int] # Braille display sizes of connected masters diff --git a/tests/unit/test_remote_client.py b/tests/unit/test_remote_client.py index f3106eb3f72..083bd1e7688 100644 --- a/tests/unit/test_remote_client.py +++ b/tests/unit/test_remote_client.py @@ -179,7 +179,7 @@ def test_connect_dispatch(self): fake_connect_as_master.reset_mock() conn_info_slave = ConnectionInfo( hostname="localhost", - mode=ConnectionMode.SLAVE, + mode=ConnectionMode.FOLLOWER, key="abc", port=1000, insecure=False, From 8ecc6d0533f492bf2e6152000c1c8716d1ce4af8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:44:24 +1100 Subject: [PATCH 152/203] Renamed _onMasterDisplayChange to _onLeaderDisplayChange in remoteClient.secureDesktop.SecureDesktopHandler --- source/remoteClient/secureDesktop.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 21d7f0c3ae5..2865f2e73ba 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -104,11 +104,11 @@ def followerSession(self, session: Optional[FollowerSession]) -> None: if self._followerSession is not None and self._followerSession.transport is not None: transport = self._followerSession.transport - transport.unregisterInbound(RemoteMessageType.SET_BRAILLE_INFO, self._onMasterDisplayChange) + transport.unregisterInbound(RemoteMessageType.SET_BRAILLE_INFO, self._onLeaderDisplayChange) self._followerSession = session session.transport.registerInbound( RemoteMessageType.SET_BRAILLE_INFO, - self._onMasterDisplayChange, + self._onLeaderDisplayChange, ) def _onSecureDesktopChange(self, isSecureDesktop: Optional[bool] = None) -> None: @@ -149,10 +149,10 @@ def enterSecureDesktop(self) -> None: insecure=True, connectionType=ConnectionMode.LEADER, ) - self.sdRelay.registerInbound(RemoteMessageType.CLIENT_JOINED, self._onMasterDisplayChange) + self.sdRelay.registerInbound(RemoteMessageType.CLIENT_JOINED, self._onLeaderDisplayChange) self.followerSession.transport.registerInbound( RemoteMessageType.SET_BRAILLE_INFO, - self._onMasterDisplayChange, + self._onLeaderDisplayChange, ) self.sdBridge = bridge.BridgeTransport(self.followerSession.transport, self.sdRelay) @@ -188,7 +188,7 @@ def leaveSecureDesktop(self) -> None: if self.followerSession is not None and self.followerSession.transport is not None: self.followerSession.transport.unregisterInbound( RemoteMessageType.SET_BRAILLE_INFO, - self._onMasterDisplayChange, + self._onLeaderDisplayChange, ) self.followerSession.setDisplaySize() @@ -225,7 +225,7 @@ def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: log.exception("Failed to initialize secure desktop connection.") return None - def _onMasterDisplayChange(self, **kwargs: Any) -> None: + def _onLeaderDisplayChange(self, **kwargs: Any) -> None: """Handle display size changes.""" log.debug("Master display change detected") if self.sdRelay is not None and self.followerSession is not None: From bbb07a21530183bc1e50c78d45f13403af2844a8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:49:44 +1100 Subject: [PATCH 153/203] Renamed masters to leaders in remoteClient.session.FollowerSession --- source/remoteClient/session.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index d11ae0627a1..76715055338 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -240,7 +240,7 @@ def __del__(self) -> None: class FollowerSession(RemoteSession): """Session that runs on the controlled (slave) NVDA instance. - :ivar masters: Information about connected master clients + :ivar leaders: Information about connected master clients :ivar masterDisplaySizes: Braille display sizes of connected masters :note: Handles: - Command execution from masters @@ -252,7 +252,7 @@ class FollowerSession(RemoteSession): # Connection mode - always 'slave' mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.FOLLOWER # Information about connected master clients - masters: dict[int, dict[str, Any]] + leaders: dict[int, dict[str, Any]] masterDisplaySizes: list[int] # Braille display sizes of connected masters def __init__( @@ -265,7 +265,7 @@ def __init__( RemoteMessageType.KEY, self.localMachine.sendKey, ) - self.masters = defaultdict(dict) + self.leaders = defaultdict(dict) self.masterDisplaySizes = [] self.transport.transportClosing.register(self.handleTransportClosing) self.transport.registerInbound( @@ -323,8 +323,8 @@ def unregisterCallbacks(self) -> None: 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.leaders[client["id"]]["active"] = True + if self.leaders: self.registerCallbacks() def handleChannelJoined( @@ -360,13 +360,13 @@ def handleClientDisconnected(self, client: dict[str, Any]) -> 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: + del self.leaders[client["id"]] + if not self.leaders: self.unregisterCallbacks() def setDisplaySize(self, sizes: list[int] | None = None) -> None: self.masterDisplaySizes = ( - sizes if sizes else [info.get("braille_numCells", 0) for info in self.masters.values()] + sizes if sizes else [info.get("braille_numCells", 0) for info in self.leaders.values()] ) log.debug("Setting slave display size to: %r", self.masterDisplaySizes) self.localMachine.setBrailleDisplay_size(self.masterDisplaySizes) @@ -377,10 +377,10 @@ def handleBrailleInfo( numCells: int = 0, origin: int | None = None, ) -> None: - if not self.masters.get(origin): + if not self.leaders.get(origin): return - self.masters[origin]["braille_name"] = name - self.masters[origin]["braille_numCells"] = numCells + self.leaders[origin]["braille_name"] = name + self.leaders[origin]["braille_numCells"] = numCells self.setDisplaySize() def _filterUnsupportedSpeechCommands(self, speechSequence: list[Any]) -> list[Any]: From 576b68e11ac9cd14a2ba25355874d2784f2d0fec Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:52:56 +1100 Subject: [PATCH 154/203] Renamed masterDisplaySizes to leaderDisplaySizes in remoteClient.session.FollowerSession --- source/remoteClient/secureDesktop.py | 2 +- source/remoteClient/session.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 2865f2e73ba..bc133702b67 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -232,7 +232,7 @@ def _onLeaderDisplayChange(self, **kwargs: Any) -> None: log.debug("Propagating display size change to secure desktop relay") self.sdRelay.send( type=RemoteMessageType.SET_DISPLAY_SIZE, - sizes=self.followerSession.masterDisplaySizes, + sizes=self.followerSession.leaderDisplaySizes, ) else: log.warning("No secure desktop relay or slave session available, skipping display change") diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 76715055338..12ca7a56a94 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -241,7 +241,7 @@ class FollowerSession(RemoteSession): """Session that runs on the controlled (slave) NVDA instance. :ivar leaders: Information about connected master clients - :ivar masterDisplaySizes: Braille display sizes of connected masters + :ivar leaderDisplaySizes: Braille display sizes of connected masters :note: Handles: - Command execution from masters - Output forwarding to masters @@ -253,7 +253,7 @@ class FollowerSession(RemoteSession): mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.FOLLOWER # Information about connected master clients leaders: dict[int, dict[str, Any]] - masterDisplaySizes: list[int] # Braille display sizes of connected masters + leaderDisplaySizes: list[int] # Braille display sizes of connected masters def __init__( self, @@ -266,7 +266,7 @@ def __init__( self.localMachine.sendKey, ) self.leaders = defaultdict(dict) - self.masterDisplaySizes = [] + self.leaderDisplaySizes = [] self.transport.transportClosing.register(self.handleTransportClosing) self.transport.registerInbound( RemoteMessageType.CHANNEL_JOINED, @@ -365,11 +365,11 @@ def handleClientDisconnected(self, client: dict[str, Any]) -> None: self.unregisterCallbacks() def setDisplaySize(self, sizes: list[int] | None = None) -> None: - self.masterDisplaySizes = ( + self.leaderDisplaySizes = ( sizes if sizes else [info.get("braille_numCells", 0) for info in self.leaders.values()] ) - log.debug("Setting slave display size to: %r", self.masterDisplaySizes) - self.localMachine.setBrailleDisplay_size(self.masterDisplaySizes) + log.debug("Setting slave display size to: %r", self.leaderDisplaySizes) + self.localMachine.setBrailleDisplay_size(self.leaderDisplaySizes) def handleBrailleInfo( self, @@ -427,7 +427,7 @@ def hasBrailleMasters(self) -> bool: 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]) + return bool([i for i in self.leaderDisplaySizes if i > 0]) class LeaderSession(RemoteSession): From 6c8c7148bba96031fa915d585046301a8ad10afb Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 15:54:36 +1100 Subject: [PATCH 155/203] Renamed hasBrailleMasters to hasBrailleLeaders in remoteClient.session.FollowerSession --- source/remoteClient/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 12ca7a56a94..43f9a064ac8 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -418,10 +418,10 @@ def display(self, cells: list[int]) -> None: 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(): + if self.hasBrailleLeaders(): self.transport.send(type=RemoteMessageType.DISPLAY, cells=cells) - def hasBrailleMasters(self) -> bool: + def hasBrailleLeaders(self) -> bool: """Check if any connected masters have braille displays. Returns: From 8071eebd13671a78c2939f9383a56145cea1fca0 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 10 Feb 2025 21:56:20 -0700 Subject: [PATCH 156/203] Move remote tests into test_remote folder --- tests/unit/test_remote/__init__.py | 0 tests/unit/{ => test_remote}/test_bridge.py | 0 tests/unit/{ => test_remote}/test_remote_client.py | 0 tests/unit/{ => test_remote}/test_serializer.py | 0 tests/unit/{ => test_remote}/test_transport.py | 0 5 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/unit/test_remote/__init__.py rename tests/unit/{ => test_remote}/test_bridge.py (100%) rename tests/unit/{ => test_remote}/test_remote_client.py (100%) rename tests/unit/{ => test_remote}/test_serializer.py (100%) rename tests/unit/{ => test_remote}/test_transport.py (100%) diff --git a/tests/unit/test_remote/__init__.py b/tests/unit/test_remote/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/test_bridge.py b/tests/unit/test_remote/test_bridge.py similarity index 100% rename from tests/unit/test_bridge.py rename to tests/unit/test_remote/test_bridge.py diff --git a/tests/unit/test_remote_client.py b/tests/unit/test_remote/test_remote_client.py similarity index 100% rename from tests/unit/test_remote_client.py rename to tests/unit/test_remote/test_remote_client.py diff --git a/tests/unit/test_serializer.py b/tests/unit/test_remote/test_serializer.py similarity index 100% rename from tests/unit/test_serializer.py rename to tests/unit/test_remote/test_serializer.py diff --git a/tests/unit/test_transport.py b/tests/unit/test_remote/test_transport.py similarity index 100% rename from tests/unit/test_transport.py rename to tests/unit/test_remote/test_transport.py From 8b1d834bff3407d317afecc7356ea2d66b5e4f28 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 16:02:35 +1100 Subject: [PATCH 157/203] Renamed slaves to followers in remoteClient.session.LeaderSession --- source/remoteClient/session.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 43f9a064ac8..57037d626c8 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -433,7 +433,7 @@ def hasBrailleLeaders(self) -> bool: class LeaderSession(RemoteSession): """Session that runs on the controlling (master) NVDA instance. - :ivar slaves: Information about connected slave clients + :ivar followers: Information about connected slave clients :note: Handles: - Control command sending - Remote output reception @@ -444,7 +444,7 @@ class LeaderSession(RemoteSession): """ mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.LEADER - slaves: dict[int, dict[str, Any]] # Information about connected slave + followers: dict[int, dict[str, Any]] # Information about connected slave def __init__( self, @@ -452,7 +452,7 @@ def __init__( transport: RelayTransport, ) -> None: super().__init__(localMachine, transport) - self.slaves = defaultdict(dict) + self.followers = defaultdict(dict) self.transport.registerInbound( RemoteMessageType.SPEAK, self.localMachine.speak, @@ -524,7 +524,7 @@ def handleChannelJoined( self.handleClientConnected(client) def handleClientConnected(self, client=None): - hasSlaves = bool(self.slaves) + hasSlaves = bool(self.followers) super().handleClientConnected(client) self.sendBrailleInfo() if not hasSlaves: @@ -535,7 +535,7 @@ def handleClientDisconnected(self, client=None): Also calls parent class disconnection handler. """ super().handleClientDisconnected(client) - if self.callbacksAdded and not self.slaves: + if self.callbacksAdded and not self.followers: self.unregisterCallbacks() def sendBrailleInfo( From f74a78119648c6ac4c094eac3c9c9accbf68c69a Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 16:50:38 +1100 Subject: [PATCH 158/203] Replaced instances of master with leader in comments, docstrings and log strings, except in the test suite --- source/config/configSpec.py | 2 +- source/remoteClient/client.py | 20 ++++++------ source/remoteClient/connectionInfo.py | 4 +-- source/remoteClient/secureDesktop.py | 2 +- source/remoteClient/session.py | 44 +++++++++++++-------------- tests/unit/test_remote_client.py | 2 +- 6 files changed, 37 insertions(+), 37 deletions(-) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 529b6422824..4e521441441 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -347,7 +347,7 @@ [[controlserver]] autoconnect = boolean(default=False) self_hosted = boolean(default=False) - connection_type = integer(default=0, min=0, max=1) # 0: slave, 1: master + connection_type = integer(default=0, min=0, max=1) # 0: slave, 1: leader host = string(default="") port = integer(default=6837) key = string(default="") diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 951464e75a8..9cc7641ae1f 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -159,10 +159,10 @@ def copyLink(self): def sendSAS(self): """Send Secure Attention Sequence to remote computer. - :note: Requires an active master transport connection + :note: Requires an active leader transport connection """ if self.leaderTransport is None: - log.error("No master transport to send SAS") + log.error("No leader transport to send SAS") return self.leaderTransport.send(RemoteMessageType.SEND_SAS) @@ -170,7 +170,7 @@ def connect(self, connectionInfo: ConnectionInfo): """Establish connection based on connection info. :param connectionInfo: Connection details including mode, host, port etc. - :note: Initiates either master or slave connection based on mode + :note: Initiates either leader or slave connection based on mode """ log.info( f"Initiating connection as {connectionInfo.mode} to {connectionInfo.hostname}:{connectionInfo.port}", @@ -183,7 +183,7 @@ def connect(self, connectionInfo: ConnectionInfo): def disconnect(self): """Close all active connections and clean up resources. - :note: Closes local control server and both master/slave sessions if active + :note: Closes local control server and both leader/slave sessions if active """ if self.leaderSession is None and self.followerSession is None: log.debug("Disconnect called but no active sessions") @@ -199,7 +199,7 @@ def disconnect(self): cues.disconnected() def disconnectAsLeader(self): - """Close master session and clean up related resources.""" + """Close leader session and clean up related resources.""" self.leaderSession.close() self.leaderSession = None self.leaderTransport = None @@ -278,7 +278,7 @@ def connectAsLeader(self, connectionInfo: ConnectionInfo): @alwaysCallAfter def onConnectedAsLeader(self): - log.info("Successfully connected as master") + log.info("Successfully connected as leader") configuration.write_connection_to_config(self.leaderSession.getConnectionInfo()) if self.menu: self.menu.handleConnected(ConnectionMode.LEADER, True) @@ -290,7 +290,7 @@ def onConnectedAsLeader(self): @alwaysCallAfter def onDisconnectingAsLeader(self): - log.info("Master session disconnecting") + log.info("Leader session disconnecting") if self.menu: self.menu.handleConnected(ConnectionMode.LEADER, False) if self.localMachine: @@ -300,7 +300,7 @@ def onDisconnectingAsLeader(self): @alwaysCallAfter def onDisconnectedAsLeader(self): - log.info("Master session disconnected") + log.info("Leader session disconnected") # Translators: Presented when connection to a remote computer was interupted. ui.message(_("Connection interrupted")) @@ -485,7 +485,7 @@ def setReceivingBraille(self, state): """Enable or disable receiving braille from remote. :param state: True to enable remote braille, False to disable - :note: Only enables if master session and braille handler are ready + :note: Only enables if leader session and braille handler are ready """ if state and self.leaderSession.callbacksAdded and braille.handler.enabled: self.leaderSession.registerBrailleInput() @@ -546,7 +546,7 @@ def verifyAndConnect(self, conInfo: ConnectionInfo): def isConnected(self): """Check if there is an active connection. - :return: True if either slave or master transport is connected + :return: True if either slave or leader transport is connected :rtype: bool """ connector = self.followerTransport or self.leaderTransport diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py index eccbcae77e6..31556d26239 100644 --- a/source/remoteClient/connectionInfo.py +++ b/source/remoteClient/connectionInfo.py @@ -55,7 +55,7 @@ class ConnectionInfo: port number and security settings. Provides methods for URL generation and parsing. :param hostname: Remote host address to connect to - :param mode: Connection mode (master/slave) + :param mode: Connection mode (leader/slave) :param key: Authentication key for securing the connection :param port: Port number to use for connection, defaults to SERVER_PORT :param insecure: Allow insecure connections without SSL/TLS, defaults to False @@ -145,7 +145,7 @@ def getURLToConnect(self) -> str: :return: URL string with opposite connection mode """ - # Flip master/slave for connection URL + # Flip leader/slave for connection URL connect_mode = ( ConnectionMode.FOLLOWER if self.mode == ConnectionMode.LEADER else ConnectionMode.LEADER ) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index bc133702b67..ec2f00b485a 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -227,7 +227,7 @@ def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: def _onLeaderDisplayChange(self, **kwargs: Any) -> None: """Handle display size changes.""" - log.debug("Master display change detected") + log.debug("Leader display change detected") if self.sdRelay is not None and self.followerSession is not None: log.debug("Propagating display size change to secure desktop relay") self.sdRelay.send( diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 57037d626c8..9a24aad7c93 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -17,7 +17,7 @@ Connection Roles: -------------- -Master (Controlling) +Leader (Controlling) - Captures and forwards input - Receives remote output (speech/braille) - Manages connection state @@ -25,8 +25,8 @@ Slave (Controlled) - Executes received commands - - Forwards output to master(s) - - Tracks connected masters + - Forwards output to leader(s) + - Tracks connected leaders - Patches output handling Key Components: @@ -43,13 +43,13 @@ - Input capture/forwarding - Remote output reception - Connection management - - Master-specific patches + - Leader-specific patches :class:`FollowerSession` Controlled by remote instance: - Command execution - Output forwarding - - Multi-master support + - Multi-leader support - Slave-specific patches Thread Safety: @@ -92,7 +92,7 @@ class RemoteSession: - """Base class for a session that runs on either the master or slave machine. + """Base class for a session that runs on either the leader or slave machine. :param localMachine: Interface to control local NVDA instance :param transport: Network transport layer instance @@ -240,20 +240,20 @@ def __del__(self) -> None: class FollowerSession(RemoteSession): """Session that runs on the controlled (slave) NVDA instance. - :ivar leaders: Information about connected master clients - :ivar leaderDisplaySizes: Braille display sizes of connected masters + :ivar leaders: Information about connected leader clients + :ivar leaderDisplaySizes: Braille display sizes of connected leaders :note: Handles: - - Command execution from masters - - Output forwarding to masters - - Multi-master connections + - Command execution from leaders + - Output forwarding to leaders + - Multi-leader connections - Braille display coordination """ # Connection mode - always 'slave' mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.FOLLOWER - # Information about connected master clients + # Information about connected leader clients leaders: dict[int, dict[str, Any]] - leaderDisplaySizes: list[int] # Braille display sizes of connected masters + leaderDisplaySizes: list[int] # Braille display sizes of connected leaders def __init__( self, @@ -359,7 +359,7 @@ def handleTransportDisconnected(self) -> None: def handleClientDisconnected(self, client: dict[str, Any]) -> None: super().handleClientDisconnected(client) if client["connection_type"] == "master": - log.info("Master client disconnected: %r", client) + log.info("Leader client disconnected: %r", client) del self.leaders[client["id"]] if not self.leaders: self.unregisterCallbacks() @@ -395,10 +395,10 @@ def _filterUnsupportedSpeechCommands(self, speechSequence: list[Any]) -> list[An return list([item for item in speechSequence if not isinstance(item, EXCLUDED_SPEECH_COMMANDS)]) def sendSpeech(self, speechSequence: list[Any], priority: str | None) -> None: - """Forward speech output to connected master instances. + """Forward speech output to connected leader instances. Filters the speech sequence for supported commands and sends it - to master instances for speaking. + to leader instances for speaking. """ self.transport.send( RemoteMessageType.SPEAK, @@ -409,29 +409,29 @@ def sendSpeech(self, speechSequence: list[Any], priority: str | None) -> None: ) def pauseSpeech(self, switch: bool) -> None: - """Toggle speech pause state on master instances.""" + """Toggle speech pause state on leader instances.""" self.transport.send(type=RemoteMessageType.PAUSE_SPEECH, switch=switch) def display(self, cells: list[int]) -> None: - """Forward braille display content to master instances. + """Forward braille display content to leader instances. - Only sends braille data if there are connected masters with braille displays. + Only sends braille data if there are connected leaders with braille displays. """ # Only send braille data when there are controlling machines with a braille display if self.hasBrailleLeaders(): self.transport.send(type=RemoteMessageType.DISPLAY, cells=cells) def hasBrailleLeaders(self) -> bool: - """Check if any connected masters have braille displays. + """Check if any connected leaders have braille displays. Returns: - True if at least one master has a braille display with cells > 0 + True if at least one leader has a braille display with cells > 0 """ return bool([i for i in self.leaderDisplaySizes if i > 0]) class LeaderSession(RemoteSession): - """Session that runs on the controlling (master) NVDA instance. + """Session that runs on the controlling (leader) NVDA instance. :ivar followers: Information about connected slave clients :note: Handles: diff --git a/tests/unit/test_remote_client.py b/tests/unit/test_remote_client.py index 083bd1e7688..6c29b6eae6e 100644 --- a/tests/unit/test_remote_client.py +++ b/tests/unit/test_remote_client.py @@ -150,7 +150,7 @@ def test_send_sas_no_master_transport(self): self.client.leaderTransport = None with patch("remoteClient.client.log.error") as mock_log_error: self.client.sendSAS() - mock_log_error.assert_called_once_with("No master transport to send SAS") + mock_log_error.assert_called_once_with("No leader transport to send SAS") def test_send_sas_with_master_transport(self): # With a fake leaderTransport, sendSAS should forward the SEND_SAS message. From c14c858d2bc71a049ffd5c762e97848d469e8279 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:00:53 +1100 Subject: [PATCH 159/203] Replaced instances of slave with follower in comments, docstrings and log strings, except in the test suite --- source/config/configSpec.py | 2 +- source/remoteClient/client.py | 8 ++++---- source/remoteClient/connectionInfo.py | 4 ++-- source/remoteClient/secureDesktop.py | 10 +++++----- source/remoteClient/session.py | 18 +++++++++--------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/source/config/configSpec.py b/source/config/configSpec.py index 4e521441441..791ad2e180e 100644 --- a/source/config/configSpec.py +++ b/source/config/configSpec.py @@ -347,7 +347,7 @@ [[controlserver]] autoconnect = boolean(default=False) self_hosted = boolean(default=False) - connection_type = integer(default=0, min=0, max=1) # 0: slave, 1: leader + connection_type = integer(default=0, min=0, max=1) # 0: follower, 1: leader host = string(default="") port = integer(default=6837) key = string(default="") diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 9cc7641ae1f..1fbebf889b1 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -170,7 +170,7 @@ def connect(self, connectionInfo: ConnectionInfo): """Establish connection based on connection info. :param connectionInfo: Connection details including mode, host, port etc. - :note: Initiates either leader or slave connection based on mode + :note: Initiates either leader or follower connection based on mode """ log.info( f"Initiating connection as {connectionInfo.mode} to {connectionInfo.hostname}:{connectionInfo.port}", @@ -183,7 +183,7 @@ def connect(self, connectionInfo: ConnectionInfo): def disconnect(self): """Close all active connections and clean up resources. - :note: Closes local control server and both leader/slave sessions if active + :note: Closes local control server and both leader/follower sessions if active """ if self.leaderSession is None and self.followerSession is None: log.debug("Disconnect called but no active sessions") @@ -205,7 +205,7 @@ def disconnectAsLeader(self): self.leaderTransport = None def disconnectAsFollower(self): - """Close slave session and clean up related resources.""" + """Close follower session and clean up related resources.""" self.followerSession.close() self.followerSession = None self.followerTransport = None @@ -546,7 +546,7 @@ def verifyAndConnect(self, conInfo: ConnectionInfo): def isConnected(self): """Check if there is an active connection. - :return: True if either slave or leader transport is connected + :return: True if either follower or leader transport is connected :rtype: bool """ connector = self.followerTransport or self.leaderTransport diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py index 31556d26239..d5b53bffc0a 100644 --- a/source/remoteClient/connectionInfo.py +++ b/source/remoteClient/connectionInfo.py @@ -55,7 +55,7 @@ class ConnectionInfo: port number and security settings. Provides methods for URL generation and parsing. :param hostname: Remote host address to connect to - :param mode: Connection mode (leader/slave) + :param mode: Connection mode (leader/follower) :param key: Authentication key for securing the connection :param port: Port number to use for connection, defaults to SERVER_PORT :param insecure: Allow insecure connections without SSL/TLS, defaults to False @@ -145,7 +145,7 @@ def getURLToConnect(self) -> str: :return: URL string with opposite connection mode """ - # Flip leader/slave for connection URL + # Flip leader/follower for connection URL connect_mode = ( ConnectionMode.FOLLOWER if self.mode == ConnectionMode.LEADER else ConnectionMode.LEADER ) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index ec2f00b485a..ed1771137fe 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -93,12 +93,12 @@ def followerSession(self) -> Optional[FollowerSession]: @followerSession.setter def followerSession(self, session: Optional[FollowerSession]) -> None: - """Update slave session reference and handle necessary cleanup/setup.""" + """Update follower session reference and handle necessary cleanup/setup.""" if self._followerSession == session: - log.debug("Slave session unchanged, skipping update") + log.debug("Follower session unchanged, skipping update") return - log.info("Updating slave session reference") + log.info("Updating follower session reference") if self.sdServer is not None: self.leaveSecureDesktop() @@ -126,7 +126,7 @@ def enterSecureDesktop(self) -> None: """Set up necessary components when entering secure desktop.""" log.debug("Attempting to enter secure desktop") if self.followerSession is None or self.followerSession.transport is None: - log.warning("No slave session connected, not entering secure desktop.") + log.warning("No follower session connected, not entering secure desktop.") return if not self.tempPath.exists(): log.debug(f"Creating temp directory: {self.tempPath}") @@ -235,4 +235,4 @@ def _onLeaderDisplayChange(self, **kwargs: Any) -> None: sizes=self.followerSession.leaderDisplaySizes, ) else: - log.warning("No secure desktop relay or slave session available, skipping display change") + log.warning("No secure desktop relay or follower session available, skipping display change") diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 9a24aad7c93..4b56be61fff 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -23,7 +23,7 @@ - Manages connection state - Patches input handling -Slave (Controlled) +Follower (Controlled) - Executes received commands - Forwards output to leader(s) - Tracks connected leaders @@ -50,7 +50,7 @@ - Command execution - Output forwarding - Multi-leader support - - Slave-specific patches + - Follower-specific patches Thread Safety: ------------ @@ -92,7 +92,7 @@ class RemoteSession: - """Base class for a session that runs on either the leader or slave machine. + """Base class for a session that runs on either the leader or follower machine. :param localMachine: Interface to control local NVDA instance :param transport: Network transport layer instance @@ -238,7 +238,7 @@ def __del__(self) -> None: class FollowerSession(RemoteSession): - """Session that runs on the controlled (slave) NVDA instance. + """Session that runs on the controlled (follower) NVDA instance. :ivar leaders: Information about connected leader clients :ivar leaderDisplaySizes: Braille display sizes of connected leaders @@ -353,7 +353,7 @@ def handleTransportDisconnected(self) -> None: 1. Plays a connection sound cue 2. Removes any NVDA patches """ - log.info("Transport disconnected from slave session") + log.info("Transport disconnected from follower session") cues.clientDisconnected() def handleClientDisconnected(self, client: dict[str, Any]) -> None: @@ -368,7 +368,7 @@ def setDisplaySize(self, sizes: list[int] | None = None) -> None: self.leaderDisplaySizes = ( sizes if sizes else [info.get("braille_numCells", 0) for info in self.leaders.values()] ) - log.debug("Setting slave display size to: %r", self.leaderDisplaySizes) + log.debug("Setting follower display size to: %r", self.leaderDisplaySizes) self.localMachine.setBrailleDisplay_size(self.leaderDisplaySizes) def handleBrailleInfo( @@ -433,7 +433,7 @@ def hasBrailleLeaders(self) -> bool: class LeaderSession(RemoteSession): """Session that runs on the controlling (leader) NVDA instance. - :ivar followers: Information about connected slave clients + :ivar followers: Information about connected follower clients :note: Handles: - Control command sending - Remote output reception @@ -444,7 +444,7 @@ class LeaderSession(RemoteSession): """ mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.LEADER - followers: dict[int, dict[str, Any]] # Information about connected slave + followers: dict[int, dict[str, Any]] # Information about connected follower def __init__( self, @@ -548,7 +548,7 @@ def sendBrailleInfo( if displaySize is None: displaySize = braille.handler.displaySize log.debug( - "Sending braille info to slave - display: %s, size: %d", + "Sending braille info to follower - display: %s, size: %d", display.name if display else "None", displaySize if displaySize else 0, ) From 8af09b84cfdac917472f846d1ac5ff8dc9074cb8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:07:42 +1100 Subject: [PATCH 160/203] Renamed hasSlaves to hasFollowers in remoteClient.session.LeaderSession.handleClientConnected --- source/remoteClient/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index 4b56be61fff..fa5994e52d9 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -524,10 +524,10 @@ def handleChannelJoined( self.handleClientConnected(client) def handleClientConnected(self, client=None): - hasSlaves = bool(self.followers) + hasFollowers = bool(self.followers) super().handleClientConnected(client) self.sendBrailleInfo() - if not hasSlaves: + if not hasFollowers: self.registerCallbacks() def handleClientDisconnected(self, client=None): From fe5f0dd534747fa128cf8746bcf8fa6924915070 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:12:22 +1100 Subject: [PATCH 161/203] Fixed more comments --- source/remoteClient/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index fa5994e52d9..a7a032b0eef 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -105,7 +105,7 @@ class RemoteSession: transport: RelayTransport # The transport layer handling network communication localMachine: LocalMachine # Interface to control the local NVDA instance - # Session mode - either 'master' or 'slave' + # Session mode - either 'leader' or 'follower' mode: connectionInfo.ConnectionMode | None = None callbacksAdded: bool = False # Whether callbacks are currently registered @@ -249,7 +249,7 @@ class FollowerSession(RemoteSession): - Braille display coordination """ - # Connection mode - always 'slave' + # Connection mode - always follower mode: Final[connectionInfo.ConnectionMode] = connectionInfo.ConnectionMode.FOLLOWER # Information about connected leader clients leaders: dict[int, dict[str, Any]] From baf65f1c8e4e351722c575255cec560f2cf50798 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:21:01 +1100 Subject: [PATCH 162/203] Replaced hardcoded "master" and "slave" with ConnectionMode.LEADER and ConnectionMode.FOLLOWER in remoteClient.dialogs.DirectConnectDialog.getConnectionInfo --- source/remoteClient/dialogs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 463c3071568..32e0b04f9b3 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -306,7 +306,9 @@ def getConnectionInfo(self) -> ConnectionInfo: ) else: # server port = int(self.panel.port.GetValue()) - mode = "master" if self.connectionType.GetSelection() == 0 else "slave" + mode = ( + ConnectionMode.LEADER if self.connectionType.GetSelection() == 0 else ConnectionMode.FOLLOWER + ) return ConnectionInfo( hostname="127.0.0.1", mode=mode, From d26187c9991c5a452bc30958754e02079147574d Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:29:34 +1100 Subject: [PATCH 163/203] Replaced hardcoded "master" with ConnectionMode.LEADER in handleClientConnected and handleClientDisconnected on remoteClient.session.FollowerSession --- source/remoteClient/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index a7a032b0eef..adb05308c3f 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -322,7 +322,7 @@ def unregisterCallbacks(self) -> None: def handleClientConnected(self, client: dict[str, Any]) -> None: super().handleClientConnected(client) - if client["connection_type"] == "master": + if client["connection_type"] == connectionInfo.ConnectionMode.LEADER.value: self.leaders[client["id"]]["active"] = True if self.leaders: self.registerCallbacks() @@ -358,7 +358,7 @@ def handleTransportDisconnected(self) -> None: def handleClientDisconnected(self, client: dict[str, Any]) -> None: super().handleClientDisconnected(client) - if client["connection_type"] == "master": + if client["connection_type"] == connectionInfo.ConnectionMode.LEADER.value: log.info("Leader client disconnected: %r", client) del self.leaders[client["id"]] if not self.leaders: From 937dc7f5b10a283a83efb1b6dfceb2d93e5368ce Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 11 Feb 2025 17:33:57 +1100 Subject: [PATCH 164/203] Replaced existing instances of master and slave with leader and follower in the test suite --- tests/unit/test_remote_client.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/unit/test_remote_client.py b/tests/unit/test_remote_client.py index 6c29b6eae6e..4632303e891 100644 --- a/tests/unit/test_remote_client.py +++ b/tests/unit/test_remote_client.py @@ -112,7 +112,7 @@ def test_toggle_mute(self): self.ui_message.assert_called_once() def test_push_clipboard_no_connection(self): - # Without any transport (neither slave nor master), pushClipboard should warn. + # Without any transport (neither follower nor leader), pushClipboard should warn. self.client.followerTransport = None self.client.leaderTransport = None self.client.pushClipboard() @@ -145,14 +145,14 @@ def test_copy_link_with_session(self): self.client.copyLink() self.assertEqual(FakeAPI.copied, "http://fake.url/connect") - def test_send_sas_no_master_transport(self): + def test_send_sas_no_leader_transport(self): # Without a leaderTransport, sendSAS should log an error. self.client.leaderTransport = None with patch("remoteClient.client.log.error") as mock_log_error: self.client.sendSAS() mock_log_error.assert_called_once_with("No leader transport to send SAS") - def test_send_sas_with_master_transport(self): + def test_send_sas_with_leader_transport(self): # With a fake leaderTransport, sendSAS should forward the SEND_SAS message. fake_transport = FakeTransport() self.client.leaderTransport = fake_transport @@ -163,29 +163,29 @@ def test_send_sas_with_master_transport(self): def test_connect_dispatch(self): # Ensure that connect() dispatches to connectAsLeader or connectAsFollower based on connection mode. - fake_connect_as_master = MagicMock() - fake_connect_as_slave = MagicMock() - self.client.connectAsLeader = fake_connect_as_master - self.client.connectAsFollower = fake_connect_as_slave - conn_info_master = ConnectionInfo( + fake_connect_as_leader = MagicMock() + fake_connect_as_follower = MagicMock() + self.client.connectAsLeader = fake_connect_as_leader + self.client.connectAsFollower = fake_connect_as_follower + conn_info_leader = ConnectionInfo( hostname="localhost", mode=ConnectionMode.LEADER, key="abc", port=1000, insecure=False, ) - self.client.connect(conn_info_master) - fake_connect_as_master.assert_called_once_with(conn_info_master) - fake_connect_as_master.reset_mock() - conn_info_slave = ConnectionInfo( + self.client.connect(conn_info_leader) + fake_connect_as_leader.assert_called_once_with(conn_info_leader) + fake_connect_as_leader.reset_mock() + conn_info_follower = ConnectionInfo( hostname="localhost", mode=ConnectionMode.FOLLOWER, key="abc", port=1000, insecure=False, ) - self.client.connect(conn_info_slave) - fake_connect_as_slave.assert_called_once_with(conn_info_slave) + self.client.connect(conn_info_follower) + fake_connect_as_follower.assert_called_once_with(conn_info_follower) def test_disconnect(self): # Test disconnect with no active sessions. From dfbbbec2226b78d72e45239fedfbe06336bfc7d8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 12:04:56 +1100 Subject: [PATCH 165/203] Update copyright headers --- .../nvdaControllerInternal/nvdaControllerInternal.acf | 2 +- source/NVDAHelper.py | 2 +- source/config/__init__.py | 2 +- source/core.py | 2 +- source/gui/guiHelper.py | 2 +- source/setup.py | 2 +- source/speech/__init__.py | 2 +- source/speech/extensions.py | 2 +- source/speech/manager.py | 2 +- source/tones.py | 2 +- tests/unit/test_remote/test_bridge.py | 5 +++++ tests/unit/test_remote/test_remote_client.py | 5 +++++ tests/unit/test_remote/test_serializer.py | 5 +++++ tests/unit/test_remote/test_transport.py | 5 +++++ 14 files changed, 30 insertions(+), 10 deletions(-) diff --git a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf index f7a17d76110..33d6d0b98c5 100644 --- a/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf +++ b/nvdaHelper/interfaces/nvdaControllerInternal/nvdaControllerInternal.acf @@ -1,7 +1,7 @@ /* This file is a part of the NVDA project. URL: http://www.nvda-project.org/ -Copyright 2006-2010 NVDA contributers. +Copyright 2006-2025 NVDA contributers. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License version 2.0, as published by the Free Software Foundation. diff --git a/source/NVDAHelper.py b/source/NVDAHelper.py index 8f1c93fa060..6c5573133b4 100755 --- a/source/NVDAHelper.py +++ b/source/NVDAHelper.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2008-2023 NV Access Limited, Peter Vagner, Davy Kager, Mozilla Corporation, Google LLC, +# Copyright (C) 2008-2025 NV Access Limited, Peter Vagner, Davy Kager, Mozilla Corporation, Google LLC, # Leonard de Ruijter # This file is covered by the GNU General Public License. # See the file COPYING for more details. diff --git a/source/config/__init__.py b/source/config/__init__.py index 3cf19ed51b9..f8a36d11e15 100644 --- a/source/config/__init__.py +++ b/source/config/__init__.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2024 NV Access Limited, Aleksey Sadovoy, Peter Vágner, Rui Batista, Zahari Yurukov, +# Copyright (C) 2006-2025 NV Access Limited, Aleksey Sadovoy, Peter Vágner, Rui Batista, Zahari Yurukov, # Joseph Lee, Babbage B.V., Łukasz Golonka, Julien Cochuyt, Cyrille Bougot # This file is covered by the GNU General Public License. # See the file COPYING for more details. diff --git a/source/core.py b/source/core.py index 360fbaf6845..ee159213712 100644 --- a/source/core.py +++ b/source/core.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2024 NV Access Limited, Aleksey Sadovoy, Christopher Toth, Joseph Lee, Peter Vágner, +# Copyright (C) 2006-2025 NV Access Limited, Aleksey Sadovoy, Christopher Toth, Joseph Lee, Peter Vágner, # Derek Riemer, Babbage B.V., Zahari Yurukov, Łukasz Golonka, Cyrille Bougot, Julien Cochuyt # This file is covered by the GNU General Public License. # See the file COPYING for more details. diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index 8b7d8a75f78..a60db17585a 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2016-2024 NV Access Limited, Łukasz Golonka +# Copyright (C) 2016-2025 NV Access Limited, Łukasz Golonka # This file is covered by the GNU General Public License. # See the file COPYING for more details. diff --git a/source/setup.py b/source/setup.py index 7a8b8d4c8be..8c1efe7896f 100755 --- a/source/setup.py +++ b/source/setup.py @@ -1,6 +1,6 @@ # -*- coding: UTF-8 -*- # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2006-2024 NV Access Limited, Peter Vágner, Joseph Lee +# Copyright (C) 2006-2025 NV Access Limited, Peter Vágner, Joseph Lee # This file is covered by the GNU General Public License. # See the file COPYING for more details. diff --git a/source/speech/__init__.py b/source/speech/__init__.py index 7e41ca6a31d..90655ac9c60 100644 --- a/source/speech/__init__.py +++ b/source/speech/__init__.py @@ -1,7 +1,7 @@ # A part of NonVisual Desktop Access (NVDA) # This file is covered by the GNU General Public License. # See the file COPYING for more details. -# Copyright (C) 2006-2023 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Babbage B.V., Bill Dengler, +# Copyright (C) 2006-2025 NV Access Limited, Peter Vágner, Aleksey Sadovoy, Babbage B.V., Bill Dengler, # Julien Cochuyt, Leonard de Ruijter from .speech import ( diff --git a/source/speech/extensions.py b/source/speech/extensions.py index e12a27a8d5d..240c5094350 100644 --- a/source/speech/extensions.py +++ b/source/speech/extensions.py @@ -1,7 +1,7 @@ # A part of NonVisual Desktop Access (NVDA) # This file is covered by the GNU General Public License. # See the file COPYING for more details. -# Copyright (C) 2023 NV Access Limited, Leonard de Ruijter +# Copyright (C) 2023-2025 NV Access Limited, Leonard de Ruijter """ Extension points for speech. diff --git a/source/speech/manager.py b/source/speech/manager.py index 8dccf44804a..d572cb130a9 100644 --- a/source/speech/manager.py +++ b/source/speech/manager.py @@ -2,7 +2,7 @@ # A part of NonVisual Desktop Access (NVDA) # This file is covered by the GNU General Public License. # See the file COPYING for more details. -# Copyright (C) 2006-2021 NV Access Limited +# Copyright (C) 2006-2025 NV Access Limited import typing import queueHandler diff --git a/source/tones.py b/source/tones.py index b0555f5a511..e5cc49cc0dd 100644 --- a/source/tones.py +++ b/source/tones.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2007-2024 NV Access Limited, Aleksey Sadovoy, Leonard de Ruijter, Babbage B.V. +# Copyright (C) 2007-2025 NV Access Limited, Aleksey Sadovoy, Leonard de Ruijter, Babbage B.V. # This file is covered by the GNU General Public License. # See the file COPYING for more details. diff --git a/tests/unit/test_remote/test_bridge.py b/tests/unit/test_remote/test_bridge.py index c9cc961ec50..3d29ab0e082 100644 --- a/tests/unit/test_remote/test_bridge.py +++ b/tests/unit/test_remote/test_bridge.py @@ -1,3 +1,8 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + import unittest from remoteClient.bridge import BridgeTransport from remoteClient.transport import Transport, RemoteMessageType diff --git a/tests/unit/test_remote/test_remote_client.py b/tests/unit/test_remote/test_remote_client.py index 4632303e891..ae1752d4f7f 100644 --- a/tests/unit/test_remote/test_remote_client.py +++ b/tests/unit/test_remote/test_remote_client.py @@ -1,3 +1,8 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + import unittest from unittest.mock import MagicMock, patch import remoteClient.client as rc_client diff --git a/tests/unit/test_remote/test_serializer.py b/tests/unit/test_remote/test_serializer.py index f9ffb74df15..d2a998a6f35 100644 --- a/tests/unit/test_remote/test_serializer.py +++ b/tests/unit/test_remote/test_serializer.py @@ -1,3 +1,8 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + import json import unittest from enum import Enum diff --git a/tests/unit/test_remote/test_transport.py b/tests/unit/test_remote/test_transport.py index a8b48e61dbc..7d53252e3fb 100644 --- a/tests/unit/test_remote/test_transport.py +++ b/tests/unit/test_remote/test_transport.py @@ -1,3 +1,8 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2025 NV Access Limited +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + """ Unit tests for the remoteClient.transport module. This test suite covers: From 0215cf96f40c17ead61e6952f80963477b48b6f3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 12:53:14 +1100 Subject: [PATCH 166/203] Fixed some type hints --- source/globalCommands.py | 4 ++-- source/remoteClient/client.py | 5 +++-- source/remoteClient/connectionInfo.py | 3 ++- source/remoteClient/cues.py | 2 +- source/remoteClient/transport.py | 4 ++-- source/tones.py | 7 ++++--- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/source/globalCommands.py b/source/globalCommands.py index d72990e422c..374ce1267b6 100755 --- a/source/globalCommands.py +++ b/source/globalCommands.py @@ -4943,7 +4943,7 @@ def script_disconnectFromRemote(self, gesture: "inputCore.InputGesture"): ) @gui.blockAction.when(gui.blockAction.Context.MODAL_DIALOG_OPEN) @gui.blockAction.when(gui.blockAction.Context.SECURE_MODE) - def script_connectToRemote(self, gesture): + def script_connectToRemote(self, gesture: "inputCore.InputGesture"): if remoteClient._remoteClient.isConnected() or remoteClient._remoteClient.connecting: # Translators: A message indicating that the remote client is already connected. ui.message(_("Already connected")) @@ -4956,7 +4956,7 @@ def script_connectToRemote(self, gesture): category=SCRCAT_REMOTE, gesture="kb:NVDA+f11", ) - def script_sendKeys(self, gesture): + def script_sendKeys(self, gesture: "inputCore.InputGesture"): remoteClient._remoteClient.toggleRemoteKeyControl(gesture) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 1fbebf889b1..14cde808716 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -4,7 +4,7 @@ # See the file COPYING for more details. import threading -from typing import Callable, Optional, Set, Tuple +from typing import Optional, Set, Tuple import api import braille @@ -18,6 +18,7 @@ from logHandler import log from gui.guiHelper import alwaysCallAfter from utils.security import isRunningOnSecureDesktop +import scriptHandler from . import configuration, cues, dialogs, serializer, server, urlHandler from .connectionInfo import ConnectionInfo, ConnectionMode @@ -35,7 +36,7 @@ class RemoteClient: - localScripts: Set[Callable] + localScripts: Set[scriptHandler._ScriptFunctionT] localMachine: LocalMachine leaderSession: Optional[LeaderSession] followerSession: Optional[FollowerSession] diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py index d5b53bffc0a..6ad6d514f20 100644 --- a/source/remoteClient/connectionInfo.py +++ b/source/remoteClient/connectionInfo.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from enum import StrEnum +from typing import Self from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from . import protocol @@ -75,7 +76,7 @@ def __post_init__(self) -> None: self.mode = ConnectionMode(self.mode) @classmethod - def fromURL(cls, url: str) -> "ConnectionInfo": + def fromURL(cls, url: str) -> Self: """Creates a ConnectionInfo instance from a URL string. :param url: The URL to parse in nvdaremote:// format diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index 4e7667f10c7..7427200ead5 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -100,5 +100,5 @@ def clipboardReceived(): _playCue("clipboardReceived") -def shouldPlaySounds(): +def shouldPlaySounds() -> bool: return configuration.get_config()["ui"]["play_sounds"] diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 83797d62578..ae3305bbcdd 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -34,7 +34,7 @@ from dataclasses import dataclass from logging import getLogger from queue import Queue -from typing import Any, Optional +from typing import Any, Optional, Self import wx from extensionPoints import Action, HandlerRegistrar @@ -647,7 +647,7 @@ def __init__( self.transportConnected.register(self.onConnected) @classmethod - def create(cls, connection_info: ConnectionInfo, serializer: Serializer) -> "RelayTransport": + def create(cls, connection_info: ConnectionInfo, serializer: Serializer) -> Self: """Create a RelayTransport from a ConnectionInfo object. :param connection_info: ConnectionInfo instance containing connection details diff --git a/source/tones.py b/source/tones.py index e5cc49cc0dd..f2aaafe4ffa 100644 --- a/source/tones.py +++ b/source/tones.py @@ -10,7 +10,8 @@ import threading import time from ctypes import create_string_buffer -from typing import Tuple, Union +from typing import TypeAlias +import collections.abc import config import extensionPoints @@ -94,8 +95,8 @@ def beep( player.feed(buf.raw) -BeepSequenceElement = Union[int, Tuple[int, int]] # Either delay_ms or (frequency_hz, duration_ms) -BeepSequence = collections.abc.Iterable[BeepSequenceElement] +BeepSequenceElement: TypeAlias = int | tuple[int, int] # Either delay_ms or (frequency_hz, duration_ms) +BeepSequence: TypeAlias = collections.abc.Iterable[BeepSequenceElement] def beepSequence(*sequence: BeepSequenceElement) -> None: From 44ccf1ff96c899e8828fb192bc156ca004498fcd Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:12:26 +1100 Subject: [PATCH 167/203] Improved type hints for alwaysCallAfter --- source/gui/guiHelper.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index a60db17585a..9b714783fcc 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -532,17 +532,24 @@ def functionWrapper(): return result -def alwaysCallAfter(func: Callable[..., None]) -> Callable[..., None]: +# TODO: Rewrite to use type parameter lists when upgrading to python 3.12 or later. +_AlwaysCallAfterP = ParamSpec("_AlwaysCallAfterP") + + +def alwaysCallAfter(func: Callable[_AlwaysCallAfterP, Any]) -> Callable[_AlwaysCallAfterP, None]: """Makes GUI updates thread-safe by running in the main thread. Example: @alwaysCallAfter def update_label(text): label.SetLabel(text) # Safe GUI update from any thread + + .. note:: + The value returned by the decorated function will be discarded. """ @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: _AlwaysCallAfterP.args, **kwargs: _AlwaysCallAfterP.kwargs) -> None: wx.CallAfter(func, *args, **kwargs) return wrapper From a0e48f368985fe83f3d31a5996ec9450b9f3abaa Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:54:24 +1100 Subject: [PATCH 168/203] Fix issues in settings dialogs --- source/gui/settingsDialogs.py | 92 +++++++++++++++++------------------ 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index 44933171df5..df0ba409403 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -3337,26 +3337,26 @@ 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 + clientOrServer: wx.RadioBox + connectionType: wx.RadioBox host: wx.TextCtrl port: wx.SpinCtrl key: wx.TextCtrl playSounds: wx.CheckBox deleteFingerprints: wx.Button - def makeSettings(self, settingsSizer): + def makeSettings(self, sizer): self.config = configuration.get_config() - sHelper = gui.guiHelper.BoxSizerHelper(self, sizer=settingsSizer) + sHelper = gui.guiHelper.BoxSizerHelper(self, sizer=sizer) 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"), + # Translators: A checkbox in Remote settings to set whether NVDA should automatically connect to a control server on startup. + label=_("Automatically connect to control server on startup"), ) - self.autoconnect.Bind(wx.EVT_CHECKBOX, self.on_autoconnect) + self.autoconnect.Bind(wx.EVT_CHECKBOX, self.onAutoconnect) sHelper.addItem(self.autoconnect) - self.client_or_server = wx.RadioBox( + self.clientOrServer = wx.RadioBox( self, wx.ID_ANY, choices=( @@ -3367,20 +3367,20 @@ def makeSettings(self, settingsSizer): ), 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) + self.clientOrServer.Bind(wx.EVT_RADIOBOX, self.onClientOrServer) + self.clientOrServer.SetSelection(0) + self.clientOrServer.Enable(False) + sHelper.addItem(self.clientOrServer) choices = [ # Translators: Radio button to allow this machine to be controlled _("Allow this machine to be controlled"), # Translators: Radio button to allow this machine to control another machine _("Control another machine"), ] - self.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) + self.connectionType = wx.RadioBox(self, wx.ID_ANY, choices=choices, style=wx.RA_VERTICAL) + self.connectionType.SetSelection(0) + self.connectionType.Enable(False) + sHelper.addItem(self.connectionType) sHelper.addItem(wx.StaticText(self, wx.ID_ANY, label=_("&Host:"))) self.host = wx.TextCtrl(self, wx.ID_ANY) self.host.Enable(False) @@ -3393,44 +3393,44 @@ def makeSettings(self, settingsSizer): 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. + # Translators: A checkbox in Remote settings to set whether sounds play instead of beeps. self.playSounds = wx.CheckBox(self, wx.ID_ANY, label=_("Play sounds instead of beeps")) sHelper.addItem(self.playSounds) - # Translators: A button in add-on options dialog to delete all fingerprints of unauthorized certificates. + # Translators: A button in Remote settings to delete all fingerprints of unauthorized certificates. self.deleteFingerprints = wx.Button(self, wx.ID_ANY, label=_("Delete all trusted fingerprints")) - self.deleteFingerprints.Bind(wx.EVT_BUTTON, self.on_delete_fingerprints) + self.deleteFingerprints.Bind(wx.EVT_BUTTON, self.onDeleteFingerprints) sHelper.addItem(self.deleteFingerprints) - self.set_from_config() + self.setFromConfig() - def on_autoconnect(self, evt: wx.CommandEvent) -> None: - self.set_controls() + def onAutoconnect(self, evt: wx.CommandEvent) -> None: + self.setControls() - def set_controls(self) -> None: + def setControls(self) -> None: state = bool(self.autoconnect.GetValue()) - self.client_or_server.Enable(state) - self.connection_type.Enable(state) + self.clientOrServer.Enable(state) + self.connectionType.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) + self.host.Enable(not bool(self.clientOrServer.GetSelection()) and state) + self.port.Enable(bool(self.clientOrServer.GetSelection()) and state) - def on_client_or_server(self, evt: wx.CommandEvent) -> None: + def onClientOrServer(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.setControls() + + def setFromConfig(self) -> None: + controlServer = self.config["controlserver"] + selfHosted = controlServer["self_hosted"] + connectionType = controlServer["connection_type"] + self.autoconnect.SetValue(controlServer["autoconnect"]) + self.clientOrServer.SetSelection(int(selfHosted)) + self.connectionType.SetSelection(connectionType) + self.host.SetValue(controlServer["host"]) + self.port.SetValue(str(controlServer["port"])) + self.key.SetValue(controlServer["key"]) + self.setControls() self.playSounds.SetValue(self.config["ui"]["play_sounds"]) - def on_delete_fingerprints(self, evt: wx.CommandEvent) -> None: + def onDeleteFingerprints(self, evt: wx.CommandEvent) -> None: if ( gui.messageBox( _( @@ -3448,7 +3448,7 @@ def on_delete_fingerprints(self, evt: wx.CommandEvent) -> None: def isValid(self) -> bool: if self.autoconnect.GetValue(): - if not self.client_or_server.GetSelection() and ( + if not self.clientOrServer.GetSelection() and ( not self.host.GetValue() or not self.key.GetValue() ): gui.messageBox( @@ -3459,7 +3459,7 @@ def isValid(self) -> bool: wx.OK | wx.ICON_ERROR, ) return False - elif self.client_or_server.GetSelection() and not self.port.GetValue() or not self.key.GetValue(): + elif self.clientOrServer.GetSelection() and not self.port.GetValue() or not self.key.GetValue(): gui.messageBox( # Translators: This message is presented when the user tries to save the settings with the port or key field empty. _("Both port and key must be set in the Remote section."), @@ -3473,8 +3473,8 @@ def isValid(self) -> bool: 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() + self_hosted = bool(self.clientOrServer.GetSelection()) + connection_type = self.connectionType.GetSelection() cs["self_hosted"] = self_hosted cs["connection_type"] = connection_type if not self_hosted: From b19f71678ca6f9df75a763b1a5b4355c6c217043 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 14:58:31 +1100 Subject: [PATCH 169/203] Fixes to client --- source/remoteClient/client.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 14cde808716..e5782eb3a5f 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -91,8 +91,7 @@ def performAutoconnect(self): insecure = True self.startControlServer(port, key) else: - address = addressToHostPort(controlServerConfig["host"]) - hostname, port = address + hostname, port = addressToHostPort(controlServerConfig["host"]) mode = ( ConnectionMode.FOLLOWER if controlServerConfig["connection_type"] == 0 else ConnectionMode.LEADER ) @@ -119,10 +118,10 @@ 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") + MUTE_MESSAGE = _("Muted remote") # 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 + UNMUTE_MESSAGE = _("Unmuted remote") + status = MUTE_MESSAGE if self.localMachine.isMuted else UNMUTE_MESSAGE ui.message(status) def pushClipboard(self): @@ -397,15 +396,20 @@ def startControlServer(self, serverPort, channel): serverThread.daemon = True serverThread.start() - def processKeyInput(self, vkCode=None, scanCode=None, extended=None, pressed=None): + def processKeyInput( + self, + vkCode: int | None = None, + scanCode: int | None = None, + extended: bool | None = None, + pressed: bool | None = None, + ) -> bool: """Process keyboard input and forward to remote if sending keys. :param vkCode: Virtual key code :param scanCode: Scan code :param extended: Whether this is an extended key :param pressed: True if key pressed, False if released - :return: True to allow local processing, False to block - :rtype: bool + :return: ``True`` to allow local processing, ``False`` to block """ if not self.sendingKeys: return True @@ -544,25 +548,24 @@ def verifyAndConnect(self, conInfo: ConnectionInfo): finally: self.connecting = False - def isConnected(self): + def isConnected(self) -> bool: """Check if there is an active connection. :return: True if either follower or leader transport is connected - :rtype: bool """ connector = self.followerTransport or self.leaderTransport if connector is not None: return connector.connected return False - def registerLocalScript(self, script): + def registerLocalScript(self, script: scriptHandler._ScriptFunctionT): """Add a script to be handled locally instead of sent to remote. :param script: Script function to register """ self.localScripts.add(script) - def unregisterLocalScript(self, script): + def unregisterLocalScript(self, script: scriptHandler._ScriptFunctionT): """Remove a script from local handling. :param script: Script function to unregister From 0c064326dca605b599bb173db1b0489090bc62b5 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 15:21:57 +1100 Subject: [PATCH 170/203] Improvements in connection info --- source/remoteClient/connectionInfo.py | 42 ++++++++++++--------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py index 6ad6d514f20..69d26e1e7fe 100644 --- a/source/remoteClient/connectionInfo.py +++ b/source/remoteClient/connectionInfo.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Self -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from urllib.parse import ParseResult, parse_qs, urlencode, urlparse from . import protocol from .protocol import SERVER_PORT, URL_PREFIX @@ -17,8 +17,6 @@ class URLParsingError(Exception): Raised when the URL cannot be parsed due to missing or invalid components such as hostname, key, or mode. - - :raises URLParsingError: When URL components are missing or invalid """ @@ -55,21 +53,23 @@ class ConnectionInfo: Handles connection details including hostname, mode, authentication key, port number and security settings. Provides methods for URL generation and parsing. - :param hostname: Remote host address to connect to - :param mode: Connection mode (leader/follower) - :param key: Authentication key for securing the connection - :param port: Port number to use for connection, defaults to SERVER_PORT - :param insecure: Allow insecure connections without SSL/TLS, defaults to False :raises URLParsingError: When URL components are missing or invalid - :return: A ConnectionInfo instance with the specified connection details - :rtype: ConnectionInfo """ hostname: str + """Remote host address to connect to""" + mode: ConnectionMode + """Connection mode (leader/follower)""" + key: str + """Authentication key for securing the connection""" + port: int = SERVER_PORT + """Port number to use for connection, defaults to :const:`SERVER_PORT`""" + insecure: bool = False + """Allow insecure connections without SSL/TLS, defaults to False""" def __post_init__(self) -> None: self.port = self.port or SERVER_PORT @@ -82,7 +82,6 @@ def fromURL(cls, url: str) -> Self: :param url: The URL to parse in nvdaremote:// format :raises URLParsingError: If URL cannot be parsed or contains invalid data :return: A new ConnectionInfo instance configured from the URL - :rtype: ConnectionInfo """ parsedUrl = urlparse(url) parsedQuery = parse_qs(parsedUrl.query) @@ -107,7 +106,6 @@ def getAddress(self) -> str: """Gets the formatted address string. :return: Address string in format hostname:port, with IPv6 brackets if needed - :rtype: str """ # Handle IPv6 addresses by adding brackets if needed hostname = f"[{self.hostname}]" if ":" in self.hostname else self.hostname @@ -129,17 +127,15 @@ def _build_url(self, mode: ConnectionMode) -> str: 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 - ), - ) + # Create our own ParseResult then get it to build the URL for us to make sure it's done properly + return ParseResult( + scheme=URL_PREFIX.removesuffix("://"), + netloc=netloc, + path="", + params="", + query=query, + fragment="", + ).geturl() def getURLToConnect(self) -> str: """Gets a URL for connecting with reversed mode. From 0b8f0e1d42023bd903e2543ef5d8b7812593cdf4 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 15:46:09 +1100 Subject: [PATCH 171/203] Typing and documentation fixes in bridge --- source/remoteClient/bridge.py | 40 +++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/source/remoteClient/bridge.py b/source/remoteClient/bridge.py index e2476b429eb..cded9e66157 100644 --- a/source/remoteClient/bridge.py +++ b/source/remoteClient/bridge.py @@ -26,9 +26,12 @@ bridge.disconnect() # Clean up when done """ +from collections.abc import Callable from .protocol import RemoteMessageType from .transport import Transport +_CallbackT = Callable[..., None] + class BridgeTransport: """A bridge between two NVDA Remote transport instances. @@ -37,18 +40,6 @@ class BridgeTransport: allowing them to exchange messages while providing message filtering capabilities. Automatically sets up message handlers for all RemoteMessageTypes and manages their lifecycle. - - :ivar excluded: Message types that should not be forwarded between transports. - By default includes connection management messages that should remain local. - :type excluded: Set[RemoteMessageType] - :ivar t1: First transport instance to bridge - :type t1: Transport - :ivar t2: Second transport instance to bridge - :type t2: Transport - :ivar t1_callbacks: Storage for t1's message handlers - :type t1_callbacks: Dict[RemoteMessageType, callable] - :ivar t2_callbacks: Storage for t2's message handlers - :type t2_callbacks: Dict[RemoteMessageType, callable] """ excluded: set[RemoteMessageType] = { @@ -57,6 +48,21 @@ class BridgeTransport: RemoteMessageType.CHANNEL_JOINED, RemoteMessageType.SET_BRAILLE_INFO, } + """Message types that should not be forwarded between transports + By default includes connection management messages that should remain local. + """ + + t1: Transport + """First transport instance to bridge""" + + t2: Transport + """Second transport instance to bridge""" + + t1Callbacks: dict[RemoteMessageType, _CallbackT] + """Storage for t1's message handlers""" + + t2Callbacks: dict[RemoteMessageType, _CallbackT] + """Storage for t2's message handlers""" def __init__(self, t1: Transport, t2: Transport) -> None: """Initialize the bridge between two transports. @@ -65,15 +71,13 @@ def __init__(self, t1: Transport, t2: Transport) -> None: by registering handlers for all possible message types. :param t1: First transport instance to bridge - :type t1: Transport :param t2: Second transport instance to bridge - :type t2: Transport """ self.t1 = t1 self.t2 = t2 # Store callbacks for each message type - self.t1Callbacks: dict[RemoteMessageType, callable] = {} - self.t2Callbacks: dict[RemoteMessageType, callable] = {} + self.t1Callbacks = {} + self.t2Callbacks = {} for messageType in RemoteMessageType: # Create and store callbacks @@ -83,7 +87,7 @@ def __init__(self, t1: Transport, t2: Transport) -> None: t1.registerInbound(messageType, self.t2Callbacks[messageType]) t2.registerInbound(messageType, self.t1Callbacks[messageType]) - def makeCallback(self, targetTransport: Transport, messageType: RemoteMessageType): + def makeCallback(self, targetTransport: Transport, messageType: RemoteMessageType) -> _CallbackT: """Create a callback function for handling a specific message type. :param targetTransport: Transport instance to forward messages to @@ -92,7 +96,7 @@ def makeCallback(self, targetTransport: Transport, messageType: RemoteMessageTyp :note: Creates a closure that forwards messages unless the type is excluded """ - def callback(*args, **kwargs): + def callback(*args, **kwargs) -> None: if messageType not in self.excluded: targetTransport.send(messageType, *args, **kwargs) From 3ed56fa321680073d43fd8985d6b09ed4ef73262 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:04:33 +1100 Subject: [PATCH 172/203] Fixes to dialogs --- source/remoteClient/dialogs.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 32e0b04f9b3..43a1c8bfb9f 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -177,7 +177,8 @@ def onGetIPSucceeded(self, data: PortCheckResponse) -> None: ) 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.") + # {port} will be replaced with the actual port number + warningMsg = _("Retrieved external IP, but port {port} is most likely not currently forwarded.") # Translators: Title of warning dialog warningTitle = _("Warning") wx.MessageBox( @@ -187,7 +188,7 @@ def onGetIPSucceeded(self, data: PortCheckResponse) -> None: ) self.externalIP.SetValue(ip) - self.externalIP.SetSelection(0, len(ip)) + self.externalIP.SelectAll() self.externalIP.SetFocus() def onGetIPFail(self, exc: Exception) -> None: @@ -324,7 +325,12 @@ def __init__(self, parent: Optional[wx.Window], fingerprint: Optional[str] = Non 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?", + "The certificate of this server could not be verified. Using the wrong fingerprint may allow a third party to access the remote session..\n" + "\n" + "Before continuing, please make sure that the following server certificate fingerprint is correct.\n" + "Server SHA256 fingerprint: {fingerprint}\n" + "\n" + "Continue connecting anyway?", ).format(fingerprint=fingerprint) super().__init__( parent, From 9c5f5c0a167dbfc98c589bf5f81ea44a274adf7f Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:18:35 +1100 Subject: [PATCH 173/203] Fix some issues in dialogs --- source/remoteClient/dialogs.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 43a1c8bfb9f..dd8d64dd6b4 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -272,10 +272,8 @@ def onOk(self, evt: wx.CommandEvent) -> None: 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() + 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. From d55f9d9278d383574fd0f19b7b12b7622ac8faf6 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:29:17 +1100 Subject: [PATCH 174/203] Typing and documentation fixes to input --- source/remoteClient/input.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index da95867dc27..2593a193cd7 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -77,7 +77,7 @@ def __init__(self, **kwargs): self.scriptPath = getattr(self, "scriptPath", None) self.script = self.findScript() if self.scriptPath else None - def findScript(self): + def findScript(self) -> scriptHandler._ScriptFunctionT | None: """Find and return a script function based on the script path. The script path must be a list containing three elements: @@ -158,7 +158,14 @@ def findScript(self): return None -def sendKey(vk=None, scan=None, extended=False, pressed=True): +def sendKey(vk: int | None = None, scan: int | None = None, extended: bool = False, pressed: bool = True): + """Execute remote keyboard input locally. + + :param vk: Virtual key code, defaults to None + :param scan: Scan code, defaults to None + :param extended: Whether this is an extended key, defaults to False + :param pressed: ``True`` if key pressed; ``False`` if released, defaults to True + """ i = INPUT() i.union.ki.wVk = vk if scan: From 8260b8bc22dcb0ea9e6c253dffbca81962c41c83 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 16:44:12 +1100 Subject: [PATCH 175/203] Documentation improvements in local machine --- source/remoteClient/localMachine.py | 37 ++++++++++------------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/source/remoteClient/localMachine.py b/source/remoteClient/localMachine.py index aa78d4b8a82..6b4d4cf1be4 100644 --- a/source/remoteClient/localMachine.py +++ b/source/remoteClient/localMachine.py @@ -8,19 +8,14 @@ This module provides functionality for controlling the local NVDA instance in response to commands received from remote connections. -:param speech: Controls speech output and cancellation -:param braille: Handles braille display sharing and input routing -:param audio: Provides feedback through wave files and tones -:param input: Simulates keyboard and system input -:param clipboard: Enables one-way text transfer from remote -:param system: Provides functions like Secure Attention Sequence (SAS) - The main class :class:`LocalMachine` implements all local control operations that can be triggered by remote NVDA instances. It includes safety features like muting and uses wxPython's CallAfter for thread synchronization. -:note: This module is part of the NVDA Remote protocol implementation and should - not be used directly outside of the remote connection infrastructure. +.. 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 @@ -37,16 +32,11 @@ import wx from speech.priorities import Spri from speech.types import SpeechSequence +from systemUtils import hasUiAccess +import ui from . import cues, input -try: - from systemUtils import hasUiAccess -except ModuleNotFoundError: - from config import hasUiAccess - -import ui - logger = logging.getLogger("local_machine") @@ -70,13 +60,6 @@ class LocalMachine: serving as the bridge between network commands and local NVDA operations. It ensures thread-safe execution and proper state management. - :ivar isMuted: When True, most remote commands will be ignored - :type isMuted: bool - :ivar receivingBraille: When True, braille output comes from remote - :type receivingBraille: bool - :ivar _cachedSizes: Cached braille display sizes from remote machines - :type _cachedSizes: Optional[List[int]] - :note: This class is instantiated by the remote session manager and should not be created directly. All methods are called in response to remote messages. @@ -91,8 +74,14 @@ def __init__(self) -> None: :note: The local machine starts unmuted with local braille enabled. """ self.isMuted: bool = False + """When True, most remote commands will be ignored""" + self.receivingBraille: bool = False + """When True, braille output comes from remote""" + self._cachedSizes: Optional[List[int]] = None + """Cached braille display sizes from remote machines""" + braille.decide_enabled.register(self.handleDecideEnabled) def terminate(self) -> None: @@ -268,5 +257,5 @@ def sendSAS(self) -> None: 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")) + ui.message(_("Unable to trigger Alt Control Delete from remote")) logger.warning("UI Access is disabled on this machine so cannot trigger CTRL+ALT+DEL") From e008763e658eb2f78137ecf4cf07898d11ab188d Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:24:57 +1100 Subject: [PATCH 176/203] Code clean-up in menu and serializer --- source/remoteClient/menu.py | 23 ++++++---------- source/remoteClient/protocol.py | 9 ++++--- source/remoteClient/secureDesktop.py | 1 + source/remoteClient/serializer.py | 33 ++++++++--------------- tests/unit/test_remote/test_serializer.py | 8 +++--- 5 files changed, 29 insertions(+), 45 deletions(-) diff --git a/source/remoteClient/menu.py b/source/remoteClient/menu.py index a09bd876bea..d605223579e 100644 --- a/source/remoteClient/menu.py +++ b/source/remoteClient/menu.py @@ -18,18 +18,11 @@ class RemoteMenu(wx.Menu): """Menu for the NVDA Remote functionality 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 + sysTrayIcon = gui.mainFrame.sysTrayIcon + toolsMenu = sysTrayIcon.toolsMenu self.connectItem: wx.MenuItem = self.Append( wx.ID_ANY, # Translators: Item in NVDA Remote submenu to connect to a remote computer. @@ -37,7 +30,7 @@ def __init__(self, client: "RemoteClient") -> None: # 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( + sysTrayIcon.Bind( wx.EVT_MENU, self.client.doConnect, self.connectItem, @@ -51,7 +44,7 @@ def __init__(self, client: "RemoteClient") -> None: _("Disconnect from another computer running NVDA Remote Access"), ) self.disconnectItem.Enable(False) - gui.mainFrame.sysTrayIcon.Bind( + sysTrayIcon.Bind( wx.EVT_MENU, self.onDisconnectItem, self.disconnectItem, @@ -65,7 +58,7 @@ def __init__(self, client: "RemoteClient") -> None: kind=wx.ITEM_CHECK, ) self.muteItem.Enable(False) - gui.mainFrame.sysTrayIcon.Bind(wx.EVT_MENU, self.onMuteItem, self.muteItem) + 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. @@ -74,7 +67,7 @@ def __init__(self, client: "RemoteClient") -> None: _("Push the clipboard to the other machine"), ) self.pushClipboardItem.Enable(False) - gui.mainFrame.sysTrayIcon.Bind( + sysTrayIcon.Bind( wx.EVT_MENU, self.onPushClipboardItem, self.pushClipboardItem, @@ -87,7 +80,7 @@ def __init__(self, client: "RemoteClient") -> None: _("Copy a link to the remote session"), ) self.copyLinkItem.Enable(False) - gui.mainFrame.sysTrayIcon.Bind( + sysTrayIcon.Bind( wx.EVT_MENU, self.onCopyLinkItem, self.copyLinkItem, @@ -99,7 +92,7 @@ def __init__(self, client: "RemoteClient") -> None: # Translators: Tooltip for the Send Ctrl+Alt+Del menu item in the NVDA Remote submenu. _("Send Ctrl+Alt+Del"), ) - gui.mainFrame.sysTrayIcon.Bind( + sysTrayIcon.Bind( wx.EVT_MENU, self.onSendCtrlAltDel, self.sendCtrlAltDelItem, diff --git a/source/remoteClient/protocol.py b/source/remoteClient/protocol.py index 31bdce6119e..c986a77debb 100644 --- a/source/remoteClient/protocol.py +++ b/source/remoteClient/protocol.py @@ -3,13 +3,13 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -import urllib -from enum import Enum +import urllib.parse +from enum import StrEnum PROTOCOL_VERSION: int = 2 -class RemoteMessageType(Enum): +class RemoteMessageType(StrEnum): # Connection and Protocol Messages PROTOCOL_VERSION = "protocol_version" JOIN = "join" @@ -53,7 +53,8 @@ class RemoteMessageType(Enum): def addressToHostPort(addr) -> tuple: """Converts an address such as google.com:80 into a tuple of (address, port). - If no port is given, use SERVER_PORT.""" + If no port is given, use SERVER_PORT. + """ addr = urllib.parse.urlparse("//" + addr) port = addr.port or SERVER_PORT return (addr.hostname, port) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index ed1771137fe..01344314c48 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -209,6 +209,7 @@ def initializeSecureDesktop(self) -> Optional[ConnectionInfo]: self.IPCFile.unlink() port, channel = data + # Try opening a socket to make sure we have the appropriate permissions testSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) testSocket.close() diff --git a/source/remoteClient/serializer.py b/source/remoteClient/serializer.py index fd7cc6e8d9d..b939b341f20 100644 --- a/source/remoteClient/serializer.py +++ b/source/remoteClient/serializer.py @@ -52,10 +52,8 @@ def serialize(self, type: Optional[str] = None, **obj: Any) -> bytes: """Convert a message to bytes for transmission. :param type: Message type identifier, used for routing - :type type: str, optional :param obj: Message payload as keyword arguments :return: Serialized message as bytes - :rtype: bytes :raises NotImplementedError: Must be implemented by subclasses """ raise NotImplementedError @@ -65,9 +63,7 @@ def deserialize(self, data: bytes) -> JSONDict: """Convert received bytes back into a message dict. :param data: Raw message bytes to deserialize - :type data: bytes :return: Dict containing the deserialized message - :rtype: Dict[str, Any] :raises NotImplementedError: Must be implemented by subclasses """ raise NotImplementedError @@ -79,9 +75,6 @@ class JSONSerializer(Serializer): 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. - - :cvar SEP: Message separator for streaming protocols - :type SEP: bytes """ SEP: bytes = b"\n" @@ -102,7 +95,7 @@ def serialize(self, type: Optional[str] = None, **obj: Any) -> bytes: if isinstance(type, Enum) and not isinstance(type, str): type = type.value obj["type"] = type - data = json.dumps(obj, cls=CustomEncoder).encode("UTF-8") + self.SEP + data = json.dumps(obj, cls=SpeechCommandJSONEncoder).encode("UTF-8") + self.SEP return data def deserialize(self, data: bytes) -> JSONDict: @@ -112,11 +105,9 @@ def deserialize(self, data: bytes) -> JSONDict: reconstruct NVDA speech commands. :param data: UTF-8 encoded JSON bytes - :type data: bytes :return: Dict containing the deserialized message - :rtype: Dict[str, Any] """ - obj = json.loads(data, object_hook=as_sequence) + obj = json.loads(data, object_hook=asSequence) return obj @@ -126,7 +117,7 @@ def deserialize(self, data: bytes) -> JSONDict: ) -class CustomEncoder(json.JSONEncoder): +class SpeechCommandJSONEncoder(json.JSONEncoder): """Custom JSON encoder for NVDA speech commands. Handles serialization of speech command objects by converting them @@ -139,28 +130,23 @@ def default(self, obj: Any) -> Any: """Convert speech commands to serializable format. :param obj: Object to serialize - :type obj: Any :return: For speech commands, returns a list containing [class_name, instance_vars]. For other types, returns the default JSON encoding. - :rtype: Any """ - if is_subclass_or_instance(obj, SEQUENCE_CLASSES): + if isSubclassOrInstance(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: +def isSubclassOrInstance(unknown: Any, possible: Union[Type[T], tuple[Type[T], ...]]) -> bool: """Check if an object is a subclass or instance of given type(s). Safely handles both types and instances, useful for type checking during serialization. :param unknown: Object or type to check - :type unknown: Any :param possible: Type or tuple of types to check against - :type possible: Union[Type[T], tuple[Type[T], ...]] :return: True if unknown is a subclass or instance of possible - :rtype: bool Example:: @@ -175,18 +161,21 @@ def is_subclass_or_instance(unknown: Any, possible: Union[Type[T], tuple[Type[T] return isinstance(unknown, possible) -def as_sequence(dct: JSONDict) -> JSONDict: +def asSequence(dct: JSONDict) -> JSONDict: """Reconstruct speech command objects from deserialized JSON. Handles the 'speak' message type by converting serialized speech commands back into their original object form. :param dct: Dict containing potentially serialized speech commands - :type dct: JSONDict :return: Dict with reconstructed speech command objects if applicable, otherwise returns the input unchanged - :rtype: JSONDict :warning: Logs a warning if an unknown sequence type is encountered + + .. warning:: + + This function modifies the input dictionary in place. + Copy the dictionary first if you need access to the unmodified data. """ if not ("type" in dct and dct["type"] == "speak" and "sequence" in dct): return dct diff --git a/tests/unit/test_remote/test_serializer.py b/tests/unit/test_remote/test_serializer.py index d2a998a6f35..a8db06b352e 100644 --- a/tests/unit/test_remote/test_serializer.py +++ b/tests/unit/test_remote/test_serializer.py @@ -6,7 +6,7 @@ import json import unittest from enum import Enum -from remoteClient.serializer import JSONSerializer, CustomEncoder, as_sequence +from remoteClient.serializer import JSONSerializer, SpeechCommandJSONEncoder, asSequence # Create a dummy Enum for test purposes. @@ -57,18 +57,18 @@ def test_custom_encoder(self): # Set __dict__ to a non-serializable object (set is not serializable by default) dummy.__dict__ = {"data": {1, 2, 3}} with self.assertRaises(TypeError) as cm: - json.dumps(dummy, cls=CustomEncoder) + json.dumps(dummy, cls=SpeechCommandJSONEncoder) self.assertRegex(str(cm.exception), "not JSON serializable") # Even if __dict__ is set to a serializable value, it should still raise error. dummy.__dict__ = {"data": "testdata"} with self.assertRaises(TypeError) as cm: - json.dumps(dummy, cls=CustomEncoder) + json.dumps(dummy, cls=SpeechCommandJSONEncoder) self.assertRegex(str(cm.exception), "not JSON serializable") def test_as_sequence_no_change(self): # Test that as_sequence returns the dictionary unchanged when no special keys exist. input_dict = {"type": "other", "foo": "bar"} - result = as_sequence(input_dict) + result = asSequence(input_dict) self.assertEqual(result, input_dict) From 4a047946445847cb7b993ac519b7b78f25e5e49c Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:35:33 +1100 Subject: [PATCH 177/203] Fixes to server --- source/remoteClient/server.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index ebd79479045..581e3e406e4 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -73,18 +73,14 @@ def ensureValidCertExists(self) -> None: log.info("Checking certificate validity") os.makedirs(self.certDir, exist_ok=True) - should_generate = False - if not self._filesExist(): - should_generate = True - else: + if self._filesExist(): try: self._validateCertificate() + return except Exception as e: log.warning(f"Certificate validation failed: {e}", exc_info=True) - should_generate = True - if should_generate: - self._generateSelfSignedCert() + self._generateSelfSignedCert() def _filesExist(self) -> bool: """Check if both certificate and key files exist.""" @@ -233,7 +229,7 @@ class LocalRelayServer: :ivar PING_TIME: Seconds between ping messages """ - PING_TIME: int = 300 + PING_TIME_SECONDS: int = 300 def __init__( self, @@ -295,7 +291,7 @@ def run(self) -> None: self._running = True self.lastPingTime = time.time() while self._running: - r, w, e = select( + read, write, error = select( self.clientSockets + [self.serverSocket, self.serverSocket6], [], self.clientSockets, @@ -303,7 +299,7 @@ def run(self) -> None: ) if not self._running: break - for sock in r: + for sock in read: if sock is self.serverSocket or sock is self.serverSocket6: self.acceptNewConnection(sock) continue From 32d17119554d811f7f7842e2d035315630ca5457 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 17:48:41 +1100 Subject: [PATCH 178/203] Clean up in session --- source/remoteClient/session.py | 38 ++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index adb05308c3f..c97a516bb3b 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -94,8 +94,6 @@ class RemoteSession: """Base class for a session that runs on either the leader or follower machine. - :param localMachine: Interface to control local NVDA instance - :param transport: Network transport layer instance :note: Handles core session tasks: - Version compatibility checks - Message of the day handling @@ -103,17 +101,28 @@ class RemoteSession: - Transport registration """ - transport: RelayTransport # The transport layer handling network communication - localMachine: LocalMachine # Interface to control the local NVDA instance - # Session mode - either 'leader' or 'follower' + transport: RelayTransport + """The transport layer handling network communication""" + + localMachine: LocalMachine + """Interface to control the local NVDA instance""" + mode: connectionInfo.ConnectionMode | None = None - callbacksAdded: bool = False # Whether callbacks are currently registered + """Session mode - either 'leader' or 'follower'""" + + callbacksAdded: bool = False + """Whether callbacks are currently registered""" def __init__( self, localMachine: LocalMachine, transport: RelayTransport, ) -> None: + """Initialise the remote session. + + :param localMachine: Interface to control local NVDA instance + :param transport: Network transport layer instance + """ log.info("Initializing Remote Session") self.localMachine = localMachine self.callbacksAdded = False @@ -174,10 +183,13 @@ def handleMOTD(self, motd: str, force_display: bool = False) -> None: def shouldDisplayMotd(self, motd: str) -> bool: """Check if MOTD should be displayed. - :param motd: Message to check - :return: True if message should be shown - :note: Compares message hash against previously shown messages - stored in config file per server + :param motd: Message to check + :return: True if message should be shown + :note: Compares message hash against previously shown messages + stored in config file per server + + .. warning:: + Calling this method will cause the MoTD to be registered as shown if it has not been already. """ conf = configuration.get_config() connection = self.getConnectionInfo() @@ -199,7 +211,7 @@ def handleClientConnected(self, client: dict[str, Any] | None) -> None: :param client: Dictionary containing client connection details :note: Logs connection info and plays connection sound """ - log.info("Client connected: %r", client) + log.info(f"Client connected: {client!r}") cues.clientConnected() def handleClientDisconnected(self, client: dict[str, Any] | None = None) -> None: @@ -523,14 +535,14 @@ def handleChannelJoined( for client in clients: self.handleClientConnected(client) - def handleClientConnected(self, client=None): + def handleClientConnected(self, client: dict[str, Any] | None = None): hasFollowers = bool(self.followers) super().handleClientConnected(client) self.sendBrailleInfo() if not hasFollowers: self.registerCallbacks() - def handleClientDisconnected(self, client=None): + def handleClientDisconnected(self, client: dict[str, Any] | None = None): """Handle client disconnection. Also calls parent class disconnection handler. """ From 7a4f270c8c1c5b041e5d90ed9388d96df6cc33db Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Mon, 24 Feb 2025 18:02:34 +1100 Subject: [PATCH 179/203] Misc clean-up --- source/remoteClient/transport.py | 23 +++++++++-------------- source/remoteClient/urlHandler.py | 18 +++++++++--------- source/setup.py | 2 +- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index ae3305bbcdd..312f68320e3 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -34,7 +34,7 @@ from dataclasses import dataclass from logging import getLogger from queue import Queue -from typing import Any, Optional, Self +from typing import Any, Literal, Optional, Self import wx from extensionPoints import Action, HandlerRegistrar @@ -54,35 +54,30 @@ class RemoteExtensionPoint: This class connects local NVDA extension points to the remote transport layer, allowing local events to trigger remote messages with optional argument transformation. - :param extensionPoint: The NVDA extension point to bridge - :type extensionPoint: HandlerRegistrar - :param messageType: The remote message type to send - :type messageType: RemoteMessageType - :param filter: Optional function to transform arguments before sending - :type filter: Optional[Callable[..., dict[str, Any]]] - :param transport: The transport instance (set on registration) - :type transport: Optional[Transport] - :note: The filter function, if provided, should take (*args, **kwargs) and return a new kwargs dict to be sent in the message. """ extensionPoint: HandlerRegistrar + """The NVDA extension point to bridge""" + messageType: RemoteMessageType + """The remote message type to send""" + filter: Optional[Callable[..., dict[str, Any]]] = None + """Optional function to transform arguments before sending""" + transport: Optional["Transport"] = None + """The transport instance (set on registration)""" - def remoteBridge(self, *args: Any, **kwargs: Any) -> bool: + def remoteBridge(self, *args: Any, **kwargs: Any) -> Literal[True]: """Bridge function that gets registered to the extension point. Handles calling the filter if present and sending the message. :param args: Positional arguments from the extension point - :type args: Any :param kwargs: Keyword arguments from the extension point - :type kwargs: Any :return: Always returns True to allow other handlers to process the event - :rtype: bool """ if self.filter is not None: # Filter should transform args/kwargs into just the kwargs needed for the message diff --git a/source/remoteClient/urlHandler.py b/source/remoteClient/urlHandler.py index 2b076ff729c..b1e44c7b394 100644 --- a/source/remoteClient/urlHandler.py +++ b/source/remoteClient/urlHandler.py @@ -55,33 +55,33 @@ def _createRegistryStructure(keyHandle: winreg.HKEYType, data: dict): raise OSError(f"Failed to set registry value {name}: {e}") -def _deleteRegistryKeyRecursive(base_key, subkey_path: str): +def _deleteRegistryKeyRecursive(baseKey: int, subkeyPath: str): """Recursively deletes a registry key and all its subkeys. - :param base_key: One of the HKEY_* constants from winreg - :param subkey_path: Full registry path to the key to delete + :param baseKey: One of the HKEY_* constants from winreg + :param subkeyPath: Full registry path to the key to delete :raises OSError: If deletion fails for reasons other than key not found """ try: # Try to delete directly first - winreg.DeleteKey(base_key, subkey_path) + winreg.DeleteKey(baseKey, subkeyPath) 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: + with winreg.OpenKey(baseKey, subkeyPath, 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}" - _deleteRegistryKeyRecursive(base_key, full_path) + full_path = f"{subkeyPath}\\{subkey_name}" + _deleteRegistryKeyRecursive(baseKey, full_path) except WindowsError: break # Now delete the key itself - winreg.DeleteKey(base_key, subkey_path) + winreg.DeleteKey(baseKey, subkeyPath) except WindowsError as e: if e.winerror != 2: # ERROR_FILE_NOT_FOUND - raise OSError(f"Failed to delete registry key {subkey_path}: {e}") + raise OSError(f"Failed to delete registry key {subkeyPath}: {e}") def registerURLHandler(): diff --git a/source/setup.py b/source/setup.py index 8c1efe7896f..9e62638e2d1 100755 --- a/source/setup.py +++ b/source/setup.py @@ -221,7 +221,7 @@ 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 is part of Python 3.11 as Tomlib, but is imported as tomli by cryptography, which causes an infinite loop in py2exe "tomli", ], "packages": [ From 12f46541814a1e3ff3c15ba292c9a1b64e16cee8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 25 Feb 2025 12:56:27 +1100 Subject: [PATCH 180/203] Applied suggestions in server --- source/remoteClient/input.py | 2 +- source/remoteClient/server.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index 2593a193cd7..3de4ec1c89a 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -73,7 +73,7 @@ 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.source = f"remote{self.source.capitalize()}" self.scriptPath = getattr(self, "scriptPath", None) self.script = self.findScript() if self.scriptPath else None diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 581e3e406e4..dfd4341cf7f 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -87,7 +87,13 @@ def _filesExist(self) -> bool: return self.certPath.is_file() and self.keyPath.is_file() def _validateCertificate(self) -> None: - """Validates the existing certificate and key.""" + """Validates the existing certificate and key. + + :raises ValueError: If the current date/time is outside the certificate's validity period, or if the certificate is approaching expiration. + :raises OSError: If the certificate or private key files cannot be opened. + :raises ValueError: If the private key data cannot be decoded. + :raises TypeError: If the private key is encrypted. + """ # Load and validate certificate with open(self.certPath, "rb") as f: certData = f.read() @@ -95,7 +101,7 @@ def _validateCertificate(self) -> None: # Check validity period now = datetime.utcnow() - if now >= cert.not_valid_after or now < cert.not_valid_before: + if not (cert.not_valid_before_utc < now <= cert.not_valid_after_utc): raise ValueError("Certificate is not within its validity period") # Check renewal threshold From 5df5771694a482b0e2e19d5acde46422df990afc Mon Sep 17 00:00:00 2001 From: David Sexton Date: Mon, 24 Feb 2025 18:27:50 -0800 Subject: [PATCH 181/203] Added manual testing instructions of remote feature to tests/manual/remote.md --- tests/manual/remote.md | 161 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/manual/remote.md diff --git a/tests/manual/remote.md b/tests/manual/remote.md new file mode 100644 index 00000000000..3a0d0c9cc5e --- /dev/null +++ b/tests/manual/remote.md @@ -0,0 +1,161 @@ +# NVDA Remote Manual Test Suite + +## Overview +Remote enables remote assistance functionality between two computers running NVDA. It allows a user to connect to another computer running NVDA and either control the remote system or have their system controlled. This enables remote support, training, and collaboration between screen reader users. The add-on provides features such as speech relay, keyboard control, clipboard sharing, and braille support over remote connections. + +## Environment Setup + +### Host Configuration +- Windows 11 Pro +- Memory: at least 16GB +- Processor: at least 4 core +- NVDA Version: latest +- NVDA Remote Version: 2.6.4 (installed via addon store) + +### Guest Configuration +- Another computer similar to the host or VMware Windows 11 Home running on the host with similar specs to the host computer +- Storage: 64GB disk +- Memory: 16GB +- Processor: 8 core +- NVDA Version: Custom build from https://github.com/nvda-art/nvda (remote branch) +- Base Position: latest + +## Pre-Test Setup +1. Build signed launcher +2. Host: Run installed stock NVDA +3. Guest: Install signed launcher + +## Connection Tests + +### Direct Connection +1. Open NVDA Remote on the host +2. Press NVDA+alt+page-up to open the "Connect" dialog +3. Choose "Host" option +4. Set a password and wait for incoming connection +5. Open NVDA Remote on the guest +6. Press NVDA+alt+page-up to open the "Connect" dialog +7. Choose "Client" option +8. Enter the host's IP address and password +9. Verify connection status announcements or sounds +10. Test reversing roles (host becomes client, client becomes host) + +### Control Server Connection +1. Open NVDA Remote on both systems +2. On both systems, press NVDA+alt+page-up to open the "Connect" dialog +3. Choose "Connect to Control Server" (nvdaremote.com) +4. Enter the same key on both systems +5. Set appropriate control permissions (Host controls guest, guest controls host) +6. Verify connection is established +7. Disconnect and retry with a different key +8. Verify behavior when server is unavailable +9. Test reconnection after temporary network interruption (by disabling networking or turning wifi off then back on again) +10. Test reversing roles (host becomes client, client becomes host) + +## Version Compatibility Tests + +### New Remote to New Remote +1. Install the new remote implementation on two test machines +2. Establish connection between the two instances +3. Test all control modes: +4. Verify all features work correctly compared to old plugin: + 1. Speech relay + 2. Remote keyboard input + 3. Clipboard transfer + 4. Braille routing +5. If possible, monitor CPU and memory usage during an extended session +6. Test connection stability during intensive screen reader usage + +### New Remote Controlling Old Plugin +1. Install the new remote implementation on one machine +2. Install the 2.6.4 plugin on another machine +3. Test connecting from new remote to old plugin system +4. Verify backward compatibility of control features: + 1. Keyboard commands + 2. Speech relay + 3. Clipboard sharing + 4. Braille support +5. Test automatic reconnection behavior +6. Switch control directions and verify functionality + +## Remote Control Features + +### Keyboard Input +1. Connect two machines with remote control enabled +2. Test basic typing in a text editor +3. Test system shortcuts: + 1. Alt+Tab to switch applications + 2. Windows key to open start menu + 3. Alt+F4 to close applications +4. Test NVDA-specific shortcuts: + 1. NVDA+T to read title + 2. NVDA+F to read formatting + 3. NVDA+Tab for focus reporting +5. Verify modifier key combinations work properly: + 1. Shift+arrows for selection + 2. Ctrl+C and Ctrl+V for copy/paste + 3. Alt key combinations for menu navigation + +## Speech and Braille + +### Speech Relay +1. Connect two machines with remote control enabled +2. Navigate through various UI elements on controlled machine +3. Verify speech output on controlling machine +4. Test with different speech synthesizers: + 1. eSpeak NG + 2. Windows OneCore + 3. Any third-party synthesizer +5. Measure speech latency and note any issues +6. Test speech interruption behavior (press ctrl when speaking) +7. Verify speech settings respect on host/guest machines + +### Braille Support +1. Connect a braille display to the controlling machine +2. Establish remote connection between machines +3. Verify braille output appears correctly +4. Test braille cursor routing functions +5. Test braille input commands +6. Verify braille display settings are respected +7. Test with different braille display models if available + +## Special Features + +### Clipboard Sharing +1. Connect two machines +2. Copy text on the controlling machine (Ctrl+C) +3. Push the clipboard text (NVDA+SHIFT+CTRL+C) +4. Paste text on the controlled machine (Ctrl+V) +5. Repeat in reverse direction +5. Test with various content types: + 1. Plain text + 2. Formatted text + 3. Large text (multiple paragraphs) +6. Verify handling of special characters +7. Test copying and pasting with keyboard shortcuts and context menus + +## Error Handling + +### Connection Issues +1. Establish remote connection between two machines +2. Temporarily disable network adapter on one machine +3. Verify appropriate error messages are displayed +4. Verify reconnection attempts occur automatically +5. Test behavior when connection times out +6. Verify recovery when network is restored +7. Test disconnection handling when one machine crashes + +### Resource Usage +1. Establish remote connection between two machines +2. Monitor CPU usage during an extended session (30+ minutes) +3. Monitor memory consumption over time +4. Run resource-intensive applications during connection +5. Verify system stability under load +6. Document any performance degradation +7. Test with different NVDA logging levels + +## Security Tests + +### Authentication +1. Test connection with valid password +2. Attempt connection with invalid password +3. Test empty password behavior From c2b4daf621e35191d39e9d580d4baf43b8220cc2 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 25 Feb 2025 17:36:21 +1100 Subject: [PATCH 182/203] Fixes to transport --- source/remoteClient/client.py | 4 +- source/remoteClient/transport.py | 327 ++++++++++------------- tests/unit/test_remote/test_bridge.py | 3 + tests/unit/test_remote/test_transport.py | 7 +- 4 files changed, 145 insertions(+), 196 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index e5782eb3a5f..4496cd1d455 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -257,7 +257,7 @@ def handleDialogCompletion(dlgResult): def connectAsLeader(self, connectionInfo: ConnectionInfo): transport = RelayTransport.create( - connection_info=connectionInfo, + connectionInfo=connectionInfo, serializer=serializer.JSONSerializer(), ) self.leaderSession = LeaderSession( @@ -306,7 +306,7 @@ def onDisconnectedAsLeader(self): def connectAsFollower(self, connectionInfo: ConnectionInfo): transport = RelayTransport.create( - connection_info=connectionInfo, + connectionInfo=connectionInfo, serializer=serializer.JSONSerializer(), ) self.followerSession = FollowerSession( diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 312f68320e3..72c54af9766 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -24,6 +24,7 @@ are called on the main wxPython thread for thread-safety. """ +from abc import ABC, abstractmethod import hashlib import select import socket @@ -96,7 +97,7 @@ def unregister(self) -> None: self.extensionPoint.unregister(self.remoteBridge) -class Transport: +class Transport(ABC): """Base class defining the network transport interface for NVDA Remote. This abstract base class defines the interface that all network transports must implement. @@ -123,65 +124,50 @@ class Transport: >>> transport = TCPTransport(serializer, ("localhost", 8090)) >>> transport.registerInbound(RemoteMessageType.key, handle_key) >>> transport.run() - - :param serializer: The serializer instance to use for message encoding/decoding - :type serializer: Serializer - - :ivar connected: True if transport has an active connection - :vartype connected: bool - :ivar successfulConnects: Counter of successful connection attempts - :vartype successfulConnects: int - :ivar connectedEvent: Event that is set when connected - :vartype connectedEvent: threading.Event - :ivar serializer: The message serializer instance - :vartype serializer: Serializer - :ivar inboundHandlers: Registered message handlers - :vartype inboundHandlers: Dict[RemoteMessageType, Callable] - - :cvar transportConnected: Fired after connection is established and ready - :vartype transportConnected: Action - :cvar transportDisconnected: Fired when existing connection is lost - :vartype transportDisconnected: Action - :cvar transportCertificateAuthenticationFailed: Fired when SSL certificate validation fails - :vartype transportCertificateAuthenticationFailed: Action - :cvar transportConnectionFailed: Fired when a connection attempt fails - :vartype transportConnectionFailed: Action - :cvar transportClosing: Fired before transport is shut down - :vartype transportClosing: Action """ - 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() + """Initialize the transport. + + :param serializer: The serializer instance to use for message encoding/decoding + """ + self.serializer: Serializer = serializer + """The message serializer instance""" + + self.connected: bool = False + """ True if transport has an active connection """ + + self.successfulConnects: int = 0 + """ Counter of successful connection attempts """ + + self.connectedEvent: threading.Event = threading.Event() + """ Event that is set when connected """ + self.inboundHandlers: dict[RemoteMessageType, Action] = {} + """ Registered message handlers """ + self.outboundHandlers: dict[RemoteMessageType, RemoteExtensionPoint] = {} - 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 - """ + """ Registered message handlers for outgoing messages""" + + self.transportConnected: Action = Action() + """Notifies when the transport is connected""" + + self.transportDisconnected: Action = Action() + """Notifies when the transport is disconnected""" + + self.transportCertificateAuthenticationFailed: Action = Action() + """Notifies when the transport fails to authenticate the certificate""" + + self.transportConnectionFailed: Action = Action() + """ Notifies when the transport fails to connect """ + + self.transportClosing: Action = Action() + """ Notifies when the transport is closing """ + + @abstractmethod + def run(self) -> None: + """Connection logic for this transport.""" + ... def onTransportConnected(self) -> None: """Handle successful transport connection. @@ -211,9 +197,9 @@ def registerInbound(self, type: RemoteMessageType, handler: Callable[..., None]) >>> transport.registerInbound(RemoteMessageType.key_press, handle_keypress) """ if type not in self.inboundHandlers: - log.debug("Creating new handler for %s", type) + log.debug(f"Creating new handler for {type}") self.inboundHandlers[type] = Action() - log.debug("Registering handler for %s", type) + log.debug(f"Registering handler for {type}") self.inboundHandlers[type].register(handler) def unregisterInbound(self, type: RemoteMessageType, handler: Callable) -> None: @@ -224,7 +210,7 @@ def unregisterInbound(self, type: RemoteMessageType, handler: Callable) -> None: :note: If handler was not registered, this is a no-op """ self.inboundHandlers[type].unregister(handler) - log.debug("Unregistered handler for %s", type) + log.debug(f"Unregistered handler for {type}") def registerOutbound( self, @@ -256,6 +242,15 @@ def unregisterOutbound(self, messageType: RemoteMessageType) -> None: self.outboundHandlers[messageType].unregister() del self.outboundHandlers[messageType] + @abstractmethod + def send(self, type: RemoteMessageType | str, **kwargs: Any) -> None: + """Send a message through this transport. + + :param type: Message type, typically a :class:`~remoteClient.protocol.RemoteMessageType` enum value. + :param kwargs: Message payload data to serialize. + """ + ... + class TCPTransport(Transport): """Secure TCP socket transport implementation. @@ -263,50 +258,8 @@ class TCPTransport(Transport): This class implements the Transport interface using TCP sockets with SSL/TLS encryption. It handles connection establishment, data transfer, and connection lifecycle management. - - :param serializer: Message serializer instance - :type serializer: Serializer - :param address: Remote address to connect to as (host, port) tuple - :type address: tuple[str, int] - :param timeout: Connection timeout in seconds, defaults to 0 - :type timeout: int, optional - :param insecure: Skip certificate verification, defaults to False - :type insecure: bool, optional - - :ivar buffer: Buffer for incomplete received data - :vartype buffer: bytes - :ivar closed: Whether transport is closed - :vartype closed: bool - :ivar queue: Queue of outbound messages - :vartype queue: Queue[Optional[bytes]] - :ivar insecure: Whether to skip certificate verification - :vartype insecure: bool - :ivar address: Remote address to connect to - :vartype address: tuple[str, int] - :ivar timeout: Connection timeout in seconds - :vartype timeout: int - :ivar serverSock: The SSL socket connection - :vartype serverSock: Optional[ssl.SSLSocket] - :ivar serverSockLock: Lock for thread-safe socket access - :vartype serverSockLock: threading.Lock - :ivar queueThread: Thread handling outbound messages - :vartype queueThread: Optional[threading.Thread] - :ivar reconnectorThread: Thread managing reconnection - :vartype reconnectorThread: ConnectorThread """ - 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, @@ -314,21 +267,45 @@ def __init__( timeout: int = 0, insecure: bool = False, ) -> None: + """Initialize the TCP transport. + + :param serializer: Message serializer instance + :param address: Remote address to connect to, as (host, port) tuple + :param timeout: Connection timeout in seconds, defaults to 0 + :param insecure: Skip certificate verification, defaults to False + """ super().__init__(serializer=serializer) - self.closed = False - # Buffer to hold partially received data + self.closed: bool = False + """Whether transport is closed""" + self.buffer = b"" + """ Buffer to hold partially received data """ + self.queue = Queue() + """ Queue of outbound messages """ + self.address = address + """ Remote address to connect to """ + self.serverSock = None - # Reading/writing from an SSL socket is not thread safe. + """ The SSL socket connection """ + + # Reading/writing from an SSL socket is not thread safe, so guard access to the socket with a lock. # See https://bugs.python.org/issue41597#msg375692 - # 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 + """ Lock for thread-safe socket access """ + + self.queueThread: threading.Thread | None = None + """ Thread handling outbound messages """ + + self.timeout: int = timeout + """ Connection timeout in seconds """ + + self.reconnectorThread: ConnectorThread = ConnectorThread(self) + """ Thread managing reconnection """ + + self.insecure: bool = insecure + """Whether to skip certificate verification""" def run(self) -> None: self.closed = False @@ -341,10 +318,10 @@ def run(self) -> None: 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() + tempConnection = self.createOutboundSocket(*self.address, insecure=True) + tempConnection.connect(self.address) + certBin = tempConnection.getpeercert(True) + tempConnection.close() fingerprint = hashlib.sha256(certBin).hexdigest().lower() except Exception: pass @@ -420,7 +397,7 @@ def createOutboundSocket( 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) + log.warn(f"Skipping certificate verification for {host}:{port}") ctx.check_hostname = not insecure ctx.load_default_certs() @@ -429,19 +406,19 @@ def createOutboundSocket( def getpeercert( self, - binary_form: bool = False, + binaryForm: bool = False, ) -> dict[str, Any] | bytes | None: """Get the certificate from the peer. Retrieves the certificate presented by the remote peer during SSL handshake. - :param binary_form: If True, return the raw certificate bytes, if False return a parsed dictionary, defaults to False + :param binaryForm: If True, return the raw certificate bytes, if False return a parsed dictionary, defaults to False :return: The peer's certificate, or None if not connected :raises ssl.SSLError: If certificate retrieval fails """ if self.serverSock is None: return None - return self.serverSock.getpeercert(binary_form) + return self.serverSock.getpeercert(binaryForm) def processIncomingSocketData(self) -> None: """Process incoming data from the server socket. @@ -497,17 +474,17 @@ def parse(self, line: bytes) -> None: """ obj = self.serializer.deserialize(line) if "type" not in obj: - log.warn("Received message without type: %r" % obj) + log.warn(f"Received message without type: {obj!r}") return try: messageType = RemoteMessageType(obj["type"]) except ValueError: - log.warn("Received message with invalid type: %r" % obj) + log.warn(f"Received message with invalid type: {obj!r}") return del obj["type"] extensionPoint = self.inboundHandlers.get(messageType) if not extensionPoint: - log.warn("Received message with unhandled type: %r %r", messageType, obj) + log.warn(f"Received message with unhandled type: {messageType} {obj!r}") return wx.CallAfter(extensionPoint.notify, **obj) @@ -540,7 +517,7 @@ def send(self, type: RemoteMessageType | str, **kwargs: Any) -> None: obj = self.serializer.serialize(type=type, **kwargs) self.queue.put(obj) else: - log.error("Attempted to send message %r while not connected", type) + log.error(f"Attempted to send message {type} while not connected") def _disconnect(self) -> None: """Internal method to disconnect the transport. @@ -574,34 +551,8 @@ class RelayTransport(TCPTransport): Extends TCPTransport with relay-specific protocol handling for channels and connection types. Manages protocol versioning and channel joining. - - :param serializer: Message serializer instance - :type serializer: Serializer - :param address: Relay server address as (host, port) tuple - :type address: tuple[str, int] - :param timeout: Connection timeout in seconds, defaults to 0 - :type timeout: int, optional - :param channel: Channel name to join, defaults to None - :type channel: str, optional - :param connectionType: Type of relay connection, defaults to None - :type connectionType: str, optional - :param protocol_version: Protocol version to use, defaults to PROTOCOL_VERSION - :type protocol_version: int, optional - :param insecure: Skip certificate verification, defaults to False - :type insecure: bool, optional - - :ivar channel: Relay channel name - :vartype channel: str or None - :ivar connectionType: Type of relay connection - :vartype connectionType: str or None - :ivar protocol_version: Protocol version in use - :vartype protocol_version: int """ - channel: str | None - connectionType: str | None - protocol_version: int - def __init__( self, serializer: Serializer, @@ -609,25 +560,18 @@ def __init__( timeout: int = 0, channel: str | None = None, connectionType: str | None = None, - protocol_version: int = PROTOCOL_VERSION, + protocolVersion: int = PROTOCOL_VERSION, insecure: bool = False, ) -> None: """Initialize a new RelayTransport instance. :param serializer: Serializer for encoding/decoding messages - :type serializer: Serializer :param address: Tuple of (host, port) to connect to - :type address: tuple[str, int] :param timeout: Connection timeout in seconds, defaults to 0 - :type timeout: int, optional - :param channel: Channel name to join, defaults to None - :type channel: str, optional - :param connectionType: Connection type identifier, defaults to None - :type connectionType: str, optional - :param protocol_version: Protocol version to use, defaults to PROTOCOL_VERSION - :type protocol_version: int, optional - :param insecure: Whether to skip certificate verification, defaults to False - :type insecure: bool, optional + :param channel: Channel name to join, defaults to ``None`` + :param connectionType: Connection type identifier, defaults to ``None`` + :param protocolVersion: Protocol version to use, defaults to :const:`PROTOCOL_VERSION` + :param insecure: Whether to skip certificate verification, defaults to ``False`` """ super().__init__( address=address, @@ -635,29 +579,32 @@ def __init__( timeout=timeout, insecure=insecure, ) - log.info("Connecting to %s channel %s" % (address, channel)) - self.channel = channel - self.connectionType = connectionType - self.protocol_version = protocol_version + log.info(f"Connecting to {address} channel {channel}") + self.channel: str | None = channel + """Relay channel name""" + + self.connectionType: str | None = connectionType + """ Type of relay connection """ + + self.protocolVersion: int = protocolVersion + """ Protocol version in use """ + self.transportConnected.register(self.onConnected) @classmethod - def create(cls, connection_info: ConnectionInfo, serializer: Serializer) -> Self: + def create(cls, connectionInfo: ConnectionInfo, serializer: Serializer) -> Self: """Create a RelayTransport from a ConnectionInfo object. - :param connection_info: ConnectionInfo instance containing connection details - :type connection_info: ConnectionInfo + :param connectionInfo: ConnectionInfo instance containing connection details :param serializer: Serializer instance for message encoding/decoding - :type serializer: Serializer :return: Configured RelayTransport instance ready for connection - :rtype: RelayTransport """ return cls( serializer=serializer, - address=(connection_info.hostname, connection_info.port), - channel=connection_info.key, - connectionType=connection_info.mode, - insecure=connection_info.insecure, + address=(connectionInfo.hostname, connectionInfo.port), + channel=connectionInfo.key, + connectionType=connectionInfo.mode, + insecure=connectionInfo.insecure, ) def onConnected(self) -> None: @@ -668,7 +615,7 @@ def onConnected(self) -> None: :raises ValueError: If protocol version is invalid """ - self.send(RemoteMessageType.PROTOCOL_VERSION, version=self.protocol_version) + self.send(RemoteMessageType.PROTOCOL_VERSION, version=self.protocolVersion) if self.channel is not None: self.send( RemoteMessageType.JOIN, @@ -685,28 +632,25 @@ class ConnectorThread(threading.Thread): Handles automatic reconnection with configurable delay between attempts. Runs until explicitly stopped. - :param connector: Transport instance to manage connections for - :type connector: Transport - :param reconnectDelay: Seconds between attempts, defaults to 5 - :type reconnectDelay: int, optional - - :ivar running: Whether thread should continue running - :vartype running: bool - :ivar connector: Transport to manage connections for - :vartype connector: Transport - :ivar reconnectDelay: Seconds to wait between connection attempts - :vartype reconnectDelay: int + To stop, set :attr:`running` to ``False``. """ - running: bool - connector: Transport - reconnectDelay: int - def __init__(self, connector: Transport, reconnectDelay: int = 5) -> None: + """Initialize the connector thread. + + :param connector: Transport instance to manage connections for + :param reconnectDelay: Seconds between attempts, defaults to 5 + """ super().__init__() - self.reconnectDelay = reconnectDelay - self.running = True - self.connector = connector + self.reconnectDelay: int = reconnectDelay + """Seconds to wait between connection attempts""" + + self.running: bool = True + """Whether thread should continue running""" + + self.connector: Transport = connector + """Transport to manage connections for""" + self.name = self.name + "_connector_loop" self.daemon = True @@ -719,7 +663,7 @@ def run(self): continue else: time.sleep(self.reconnectDelay) - log.info("Ending control connector thread %s" % self.name) + log.info(f"Ending control connector thread {self.name}") def clearQueue(queue: Queue[bytes | None]) -> None: @@ -729,7 +673,6 @@ def clearQueue(queue: Queue[bytes | None]) -> None: useful for cleaning up before disconnection. :param queue: Queue instance to clear - :type queue: Queue[Optional[bytes]] :note: This function catches and ignores any exceptions that occur while trying to get items from an empty queue. """ diff --git a/tests/unit/test_remote/test_bridge.py b/tests/unit/test_remote/test_bridge.py index 3d29ab0e082..e12a74a6a42 100644 --- a/tests/unit/test_remote/test_bridge.py +++ b/tests/unit/test_remote/test_bridge.py @@ -42,6 +42,9 @@ def send(self, type, **kwargs): def parse(self, line: bytes): pass # Not used in these tests. + def run(self): + pass + # Tests for BridgeTransport. class TestBridgeTransport(unittest.TestCase): diff --git a/tests/unit/test_remote/test_transport.py b/tests/unit/test_remote/test_transport.py index 7d53252e3fb..17d3f32c9b4 100644 --- a/tests/unit/test_remote/test_transport.py +++ b/tests/unit/test_remote/test_transport.py @@ -333,7 +333,7 @@ def test_onConnected_with_channel(self): address=("localhost", 8090), channel="mychannel", connectionType="relayMode", - protocol_version=PROTOCOL_VERSION, + protocolVersion=PROTOCOL_VERSION, insecure=False, ) # Override send() to record calls. @@ -351,7 +351,7 @@ def test_onConnected_without_channel(self): address=("localhost", 8090), channel=None, connectionType="relayMode", - protocol_version=PROTOCOL_VERSION, + protocolVersion=PROTOCOL_VERSION, insecure=False, ) rt.send = mock.MagicMock() @@ -374,6 +374,9 @@ def run(self): def processIncomingSocketData(self): pass + def send(self, type, **kwargs): + pass + class TestConnectorThread(unittest.TestCase): def test_connectorThread_runs_and_reconnects(self): From b61601ef5a50c18f3262387a869efba799173593 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 25 Feb 2025 18:11:47 +1100 Subject: [PATCH 183/203] Extract global variable --- source/remoteClient/urlHandler.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/source/remoteClient/urlHandler.py b/source/remoteClient/urlHandler.py index b1e44c7b394..d451c81c841 100644 --- a/source/remoteClient/urlHandler.py +++ b/source/remoteClient/urlHandler.py @@ -28,6 +28,8 @@ log = getLogger("url_handler") +_REGISTRY_KEY_PATH: str = r"SOFTWARE\Classes\nvdaremote" + def _createRegistryStructure(keyHandle: winreg.HKEYType, data: dict): """Creates a nested registry structure from a dictionary. @@ -90,8 +92,7 @@ def registerURLHandler(): :raises OSError: If registration in the registry fails """ try: - keyPath = r"SOFTWARE\Classes\nvdaremote" - with winreg.CreateKey(winreg.HKEY_CURRENT_USER, keyPath) as key: + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, _REGISTRY_KEY_PATH) as key: _createRegistryStructure(key, URL_HANDLER_REGISTRY) except OSError as e: raise OSError(f"Failed to register URL handler: {e}") @@ -103,7 +104,7 @@ def unregisterURLHandler(): :raises OSError: If unregistration from the registry fails """ try: - _deleteRegistryKeyRecursive(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Classes\nvdaremote") + _deleteRegistryKeyRecursive(winreg.HKEY_CURRENT_USER, _REGISTRY_KEY_PATH) except OSError as e: raise OSError(f"Failed to unregister URL handler: {e}") From 458f1b7af1a030418e015002392d7f3894af8c30 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Tue, 25 Feb 2025 18:13:11 +1100 Subject: [PATCH 184/203] Improvements to user guide --- user_docs/en/userGuide.md | 49 ++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/user_docs/en/userGuide.md b/user_docs/en/userGuide.md index 4deda8c1647..c50dc686991 100644 --- a/user_docs/en/userGuide.md +++ b/user_docs/en/userGuide.md @@ -3588,58 +3588,59 @@ To change NVDA's configuration during sign-in or on UAC screens, configure NVDA 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 +### Getting Started {#RemoteAccessGettingStarted} Before you begin, ensure NVDA is installed and running on both computers. The remote access feature is available from the Tools menu in NVDA—there’s no need for additional downloads or installations. -### Setting Up a Remote Session +### Setting Up a Remote Session {#RemoteAccessSetup} -You’ll need to decide which computer will be controlled (the **controlled computer**) and which will be controlling (the **controlling computer**). +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 +#### Steps for the Controlled Computer {#RemoteAccessSetupControlled} -1. Open the NVDA menu and select **Tools > Remote > Connect**. -1. Choose **Allow this computer to be controlled**. +1. Open the NVDA menu and select Tools, then Remote, then Connect. +1. Choose Allow this computer to be controlled. 1. Enter the connection details provided by the person controlling your computer: - * **Relay Server:** If using a server, enter the hostname (e.g., `nvdaremote.com`). - * **Direct Connection:** If connecting directly, share your external IP address and port (default: 6837). Ensure your network is set up for direct connections. + * 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 +#### Steps for the Controlling Computer {#RemoteAccessSetupControlling} -1. Open the NVDA menu and select **Tools > Remote > Connect**. -1. Choose **Control another computer**. +1. Open the NVDA menu and select Tools, then Remote, then Connect. +1. Choose Control another computer. 1. Enter the connection details and key provided by the controlled computer. 1. Press OK to connect. Once connected, you can control the other computer, including typing and navigating applications, just as if you were sitting in front of it. -### Remote Connection Options +### Remote Connection Options {#RemoteAccessConnectionOptions} You can choose between two connection types depending on your setup: -* **Relay Server (easier):** Uses a public or private server to mediate the connection. Only the server hostname and key are needed. -* **Direct Connection (advanced):** Connects directly without a server. Requires network setup, such as port forwarding. +* 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 +### Using Remote Access {#RemoteAccessUsage} -Once the session is active, you can switch between controlling the remote computer and your own: +Once the session is active, you can switch between controlling the remote computer and your own, share your clipboard, and mute the remote session: -* **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**. +* Press `NVDA+f11` to toggle between controlling and returning to your own computer. +* Push text from your clipboard to the other computer by opening the NVDA menu, then selecting Tools, then Remote, then Push Clipboard. +* Mute the remote computer's speech output on your local computer by opening the NVDA menu, then selecting Tools, then Remote, then Mute Remote. -### Remote Access Key Commands Summary +### Remote Access Key Commands Summary {#RemoteAccessGestures} | 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. | +| Toggle Control | `NVDA+f11` | Switch between controlling and local. | +| Push Clipboard | `NVDA+ctrl+shift+c` | Send clipboard text to the other machine. | +| Disconnect | `NVDA+alt+pageDown`| End the remote session. | +You can assign further commands in the Remote section of the [Input Gestures dialog](#InputGestures). + ## Add-ons and the Add-on Store {#AddonsManager} Add-ons are software packages which provide new or altered functionality for NVDA. From cb0586c3cd9e608c2de928b6450000e0c2f01fc2 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Mar 2025 16:35:03 -0700 Subject: [PATCH 185/203] refactor: Improve transport run method - Add comprehensive docstring explaining the transport lifecycle - Extract SSL certificate verification into dedicated methods - Add getHostFingerprint() for fingerprint retrieval - Add isFingerprintTrusted() for trust validation - Move socket read loop into _readLoop() method - Improve queue thread management with startQueueThread() - Add thread naming - Add thread state checking - Prevent duplicate thread creation --- source/remoteClient/transport.py | 71 +++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 72c54af9766..0e630c78083 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -308,6 +308,25 @@ def __init__( """Whether to skip certificate verification""" def run(self) -> None: + """ + Establishes a connection to the server and manages the transport lifecycle. + + This method attempts to create and connect an outbound socket to the server + using the provided address. If SSL certificate verification fails, it checks + if the host fingerprint is trusted. If trusted, it retries the connection + with insecure mode enabled. If not, it notifies about the certificate + authentication failure and raises the exception. For other exceptions, it + notifies about the connection failure and raises the exception. + + Once connected, it triggers the transport connected event, starts the queue + thread, and enters the read loop. Upon disconnection, it clears the connected + event, notifies about the transport disconnection, and performs cleanup. + + Raises: + ssl.SSLCertVerificationError: If SSL certificate verification fails and + the fingerprint is not trusted. + Exception: For any other exceptions during the connection process. + """ self.closed = False try: self.serverSock = self.createOutboundSocket( @@ -318,18 +337,10 @@ def run(self) -> None: except ssl.SSLCertVerificationError: fingerprint = None try: - tempConnection = self.createOutboundSocket(*self.address, insecure=True) - tempConnection.connect(self.address) - certBin = tempConnection.getpeercert(True) - tempConnection.close() - fingerprint = hashlib.sha256(certBin).hexdigest().lower() + fingerprint = self.getHostFingerprint() except Exception: pass - config = configuration.get_config() - if ( - hostPortToAddress(self.address) in config["trusted_certs"] - and config["trusted_certs"][hostPortToAddress(self.address)] == fingerprint - ): + if self.isFingerprintTrusted(fingerprint): self.insecure = True return self.run() self.lastFailFingerprint = fingerprint @@ -339,9 +350,43 @@ def run(self) -> None: self.transportConnectionFailed.notify() raise self.onTransportConnected() - self.queueThread = threading.Thread(target=self.sendQueue) + self.startQueueThread() + self._readLoop() + self.connected = False + self.connectedEvent.clear() + self.transportDisconnected.notify() + self._disconnect() + + def isFingerprintTrusted(self, fingerprint: str) -> bool: + """Check if the fingerprint is trusted. + + :param fingerprint: The fingerprint to check + :return: True if the fingerprint is trusted, False otherwise + """ + config = configuration.get_config() + return ( + hostPortToAddress(self.address) in config["trusted_certs"] + and config["trusted_certs"][hostPortToAddress(self.address)] == fingerprint + ) + + def getHostFingerprint(self) -> str: + tempConnection = self.createOutboundSocket(*self.address, insecure=True) + tempConnection.connect(self.address) + certBin = tempConnection.getpeercert(True) + tempConnection.close() + fingerprint = hashlib.sha256(certBin).hexdigest().lower() + return fingerprint + + def startQueueThread(self) -> None: + """Start the outbound message queue thread.""" + if self.queueThread and self.queueThread.is_alive(): + return + self.queueThread = threading.Thread(target=self.sendQueue, name="queue_thread") self.queueThread.daemon = True self.queueThread.start() + + def _readLoop(self) -> None: + """Main loop for reading data from the server socket.""" while self.serverSock is not None: try: readers, writers, error = select.select( @@ -361,10 +406,6 @@ def run(self) -> None: except socket.error: self.buffer = b"" break - self.connected = False - self.connectedEvent.clear() - self.transportDisconnected.notify() - self._disconnect() def createOutboundSocket( self, From ea2243c18c9a7f1653da1d5ee5edb1edb990c0e3 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Mar 2025 16:45:24 -0700 Subject: [PATCH 186/203] refactor: narrow type annotation for transport.send() to only accept RemoteMessageType --- source/remoteClient/transport.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 0e630c78083..d60f999df4e 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -243,7 +243,7 @@ def unregisterOutbound(self, messageType: RemoteMessageType) -> None: del self.outboundHandlers[messageType] @abstractmethod - def send(self, type: RemoteMessageType | str, **kwargs: Any) -> None: + def send(self, type: RemoteMessageType, **kwargs: Any) -> None: """Send a message through this transport. :param type: Message type, typically a :class:`~remoteClient.protocol.RemoteMessageType` enum value. @@ -546,7 +546,7 @@ def sendQueue(self) -> None: except socket.error: return - def send(self, type: RemoteMessageType | str, **kwargs: Any) -> None: + def send(self, type: RemoteMessageType, **kwargs: Any) -> None: """Send a message through the transport. :param type: Message type, typically a RemoteMessageType enum value From a41edc4a5dfc101e2ee05d70738d40e9821d2f94 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Mar 2025 16:47:21 -0700 Subject: [PATCH 187/203] Remove early return in cues preventing message --- source/remoteClient/cues.py | 1 - 1 file changed, 1 deletion(-) diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index 7427200ead5..1195857e44e 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -61,7 +61,6 @@ def _playCue(cueName: str) -> None: if beeps := CUES[cueName].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[cueName].get("wave"): From 2415c32097579a1381dd298c2d3c96b83957063a Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Mar 2025 16:51:02 -0700 Subject: [PATCH 188/203] rename and camel-case --- source/gui/settingsDialogs.py | 2 +- source/remoteClient/client.py | 6 +++--- source/remoteClient/configuration.py | 4 ++-- source/remoteClient/cues.py | 2 +- source/remoteClient/dialogs.py | 2 +- source/remoteClient/server.py | 2 +- source/remoteClient/session.py | 2 +- source/remoteClient/transport.py | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index df0ba409403..e07d243755b 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -3346,7 +3346,7 @@ class RemoteSettingsPanel(SettingsPanel): deleteFingerprints: wx.Button def makeSettings(self, sizer): - self.config = configuration.get_config() + self.config = configuration.getRemoteConfig() sHelper = gui.guiHelper.BoxSizerHelper(self, sizer=sizer) self.autoconnect = wx.CheckBox( parent=self, diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 4496cd1d455..0a74930fc1f 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -79,7 +79,7 @@ def __init__( inputCore.decide_handleRawKey.register(self.processKeyInput) def performAutoconnect(self): - controlServerConfig = configuration.get_config()["controlserver"] + controlServerConfig = configuration.getRemoteConfig()["controlserver"] if not controlServerConfig["autoconnect"] or self.leaderSession or self.followerSession: log.debug("Autoconnect disabled or already connected") return @@ -234,7 +234,7 @@ def doConnect(self, evt=None): """ if evt is not None: evt.Skip() - previousConnections = configuration.get_config()["connections"]["last_connected"] + previousConnections = configuration.getRemoteConfig()["connections"]["last_connected"] hostnames = list(reversed(previousConnections)) # Translators: Title of the connect dialog. dlg = dialogs.DirectConnectDialog( @@ -352,7 +352,7 @@ def handleCertificateFailure(self, transport: RelayTransport): wnd = dialogs.CertificateUnauthorizedDialog(None, fingerprint=certHash) a = wnd.ShowModal() if a == wx.ID_YES: - config = configuration.get_config() + config = configuration.getRemoteConfig() config["trusted_certs"][hostPortToAddress(self.lastFailAddress)] = certHash if a == wx.ID_YES or a == wx.ID_NO: return True diff --git a/source/remoteClient/configuration.py b/source/remoteClient/configuration.py index 438b64c7e05..6bb0696bb99 100644 --- a/source/remoteClient/configuration.py +++ b/source/remoteClient/configuration.py @@ -9,7 +9,7 @@ from .connectionInfo import ConnectionInfo -def get_config(): +def getRemoteConfig(): return config.conf["remote"] @@ -20,7 +20,7 @@ def write_connection_to_config(connection_info: ConnectionInfo): Args: connection_info: The ConnectionInfo object containing connection details """ - conf = get_config() + conf = getRemoteConfig() last_cons = conf["connections"]["last_connected"] address = connection_info.getAddress() if address in last_cons: diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index 1195857e44e..bd34f36bc10 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -100,4 +100,4 @@ def clipboardReceived(): def shouldPlaySounds() -> bool: - return configuration.get_config()["ui"]["play_sounds"] + return configuration.getRemoteConfig()["ui"]["play_sounds"] diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index dd8d64dd6b4..401c7512598 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -83,7 +83,7 @@ def handleCertificateFailed(self) -> None: wnd = CertificateUnauthorizedDialog(None, fingerprint=certHash) a = wnd.ShowModal() if a == wx.ID_YES: - config = configuration.get_config() + config = configuration.getRemoteConfig() config["trusted_certs"][self.host.GetValue()] = certHash if a != wx.ID_YES and a != wx.ID_NO: return diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index dfd4341cf7f..74c9a2c6ca2 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -183,7 +183,7 @@ def _generateSelfSignedCert(self) -> None: f.write(fingerprint) # Add to trusted certificates in config - config = configuration.get_config() + config = configuration.getRemoteConfig() if "trusted_certs" not in config: config["trusted_certs"] = {} config["trusted_certs"]["localhost"] = fingerprint diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index c97a516bb3b..a93bfcbe87a 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -191,7 +191,7 @@ def shouldDisplayMotd(self, motd: str) -> bool: .. warning:: Calling this method will cause the MoTD to be registered as shown if it has not been already. """ - conf = configuration.get_config() + conf = configuration.getRemoteConfig() connection = self.getConnectionInfo() address = "{host}:{port}".format( host=connection.hostname, diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index d60f999df4e..4585355f231 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -363,7 +363,7 @@ def isFingerprintTrusted(self, fingerprint: str) -> bool: :param fingerprint: The fingerprint to check :return: True if the fingerprint is trusted, False otherwise """ - config = configuration.get_config() + config = configuration.getRemoteConfig() return ( hostPortToAddress(self.address) in config["trusted_certs"] and config["trusted_certs"][hostPortToAddress(self.address)] == fingerprint From 2ff8d3ccbb7b0ed6baae4b30eac0ab2b44e11dbe Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Mar 2025 16:53:52 -0700 Subject: [PATCH 189/203] Remove unused local_beep --- source/remoteClient/cues.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index bd34f36bc10..55d14b0d5ca 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -9,12 +9,10 @@ import globalVars import nvwave import ui -from tones import BeepSequence, beep, beepSequenceAsync +from tones import BeepSequence, beepSequenceAsync from . import configuration -local_beep = beep - class Cue(TypedDict, total=False): wave: Optional[str] From dbbd4c217670265c41f3f2ffea17db4625f9493b Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sat, 1 Mar 2025 17:03:39 -0700 Subject: [PATCH 190/203] Improve handleCertificateFailed method documentation and error handling - Add comprehensive docstring - Replace log.error with log.exception for better error tracking - Move keyConnector cleanup to finally block to ensure proper resource handling - Remove unused exception variable from catch clause --- source/remoteClient/dialogs.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/source/remoteClient/dialogs.py b/source/remoteClient/dialogs.py index 401c7512598..7dd9fd78c47 100644 --- a/source/remoteClient/dialogs.py +++ b/source/remoteClient/dialogs.py @@ -77,6 +77,22 @@ def handleKeyGenerated(self, key: Optional[str] = None) -> None: @alwaysCallAfter def handleCertificateFailed(self) -> None: + """ + Handles the event when a certificate validation fails. + + This method attempts to retrieve the last failed certificate fingerprint + and displays a dialog to the user to decide whether to trust the certificate. + If the user chooses to trust the certificate, it is added to the trusted certificates + configuration. If an exception occurs during this process, it is logged. + + Steps: + 1. Retrieve the last failed certificate fingerprint. + 2. Display a dialog to the user with the certificate fingerprint. + 3. If the user chooses to trust the certificate, add it to the trusted certificates configuration. + 4. Handle any exceptions by logging the error. + 5. Close the key connector and reset it. + 6. Generate a new key from the server. + """ try: certHash = self.keyConnector.lastFailFingerprint @@ -87,11 +103,12 @@ def handleCertificateFailed(self) -> None: 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) + except Exception: + log.exception("Error handling certificate failure") return - self.keyConnector.close() - self.keyConnector = None + finally: + self.keyConnector.close() + self.keyConnector = None self.generateKeyCommand(True) From e879c2f6b86b820577d6bf8a8ffbf74707a1097d Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Sun, 2 Mar 2025 13:00:30 -0700 Subject: [PATCH 191/203] Fix logging port --- source/remoteClient/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 74c9a2c6ca2..3565d6b4094 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -293,7 +293,7 @@ def run(self) -> None: :raises socket.error: If there are socket communication errors """ - log.info(f"Starting NVDA Remote relay server on ports {self.port} (IPv4) " f"and {self.port} (IPv6)") + log.info(f"Starting NVDA Remote relay server on port {self.port}") self._running = True self.lastPingTime = time.time() while self._running: From 2f85daaa013a28f61c430cdd7c4d55d082928ebd Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 3 Mar 2025 21:17:16 -0700 Subject: [PATCH 192/203] refactor(server): clarify socket listen backlog parameter Add explicit parameter name 'backlog' and explanatory comment to the socket.listen() call to improve code readability and make the purpose of the value 5 more obvious to future maintainers. --- source/remoteClient/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 3565d6b4094..a7489ce073c 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -282,7 +282,7 @@ def createServerSocket(self, family: int, type: int, bind_addr: tuple[str, int]) sslContext = self.certManager.createSSLContext() serverSocket = sslContext.wrap_socket(serverSocket, server_side=True) serverSocket.bind(bind_addr) - serverSocket.listen(5) + serverSocket.listen(backlog=5) # Set the maximum number of queued connections return serverSocket def run(self) -> None: From 1f4eeafa40acf58111d2a0417004b992381bf87d Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 3 Mar 2025 21:33:58 -0700 Subject: [PATCH 193/203] Improve code clarity and documentation in remote client code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Standardize parameter naming from snake_case to camelCase (bind_host → bindHost) - Add docstrings to key classes and methods in server.py - Use more specific is_file() check instead of exists() for certificate fingerprint - Clean up parameter naming across secureDesktop.py and server.py --- source/remoteClient/secureDesktop.py | 2 +- source/remoteClient/server.py | 31 +++++++++++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/source/remoteClient/secureDesktop.py b/source/remoteClient/secureDesktop.py index 01344314c48..ac20be43878 100644 --- a/source/remoteClient/secureDesktop.py +++ b/source/remoteClient/secureDesktop.py @@ -134,7 +134,7 @@ def enterSecureDesktop(self) -> None: channel = str(uuid.uuid4()) log.debug("Starting local relay server") - self.sdServer = server.LocalRelayServer(port=0, password=channel, bind_host="127.0.0.1") + self.sdServer = server.LocalRelayServer(port=0, password=channel, bindHost="127.0.0.1") port = self.sdServer.serverSocket.getsockname()[1] log.info("Local relay server started on port %d", port) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index a7489ce073c..6290e414d76 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -63,6 +63,10 @@ class RemoteCertificateManager: CERT_RENEWAL_THRESHOLD_DAYS = 30 def __init__(self, certDir: Path | None = None): + """Initialize the certificate manager. + + :param certDir: Directory to store certificate files, defaults to program data temp path + """ self.certDir: Path = certDir or getProgramDataTempPath() self.certPath: Path = self.certDir / self.CERT_FILE self.keyPath: Path = self.certDir / self.KEY_FILE @@ -194,7 +198,7 @@ def _generateSelfSignedCert(self) -> None: def getCurrentFingerprint(self) -> str | None: """Get the fingerprint of the current certificate.""" try: - if self.fingerprintPath.exists(): + if self.fingerprintPath.is_file(): with open(self.fingerprintPath, "r") as f: return f.read().strip() except Exception as e: @@ -241,13 +245,21 @@ def __init__( self, port: int, password: str, - bind_host: str = "", - bind_host6: str = "[::]:", - cert_dir: Path | None = None, + bindHost: str = "", + bindHost6: str = "[::]:", + certDir: Path | None = None, ): + """Initialize the relay server. + + :param port: Port number to listen on + :param password: Channel password for client authentication + :param bindHost: IPv4 address to bind to, defaults to all interfaces + :param bindHost6: IPv6 address to bind to, defaults to all interfaces + :param certDir: Directory to store certificate files, defaults to None + """ self.port = port self.password = password - self.certManager = RemoteCertificateManager(cert_dir) + self.certManager = RemoteCertificateManager(certDir) self.certManager.ensureValidCertExists() # Initialize other server components @@ -261,12 +273,12 @@ def __init__( self.serverSocket = self.createServerSocket( socket.AF_INET, socket.SOCK_STREAM, - bind_addr=(bind_host, self.port), + bind_addr=(bindHost, self.port), ) self.serverSocket6 = self.createServerSocket( socket.AF_INET6, socket.SOCK_STREAM, - bind_addr=(bind_host6, self.port), + bind_addr=(bindHost6, self.port), ) def createServerSocket(self, family: int, type: int, bind_addr: tuple[str, int]) -> ssl.SSLSocket: @@ -378,6 +390,11 @@ class Client: _id_counter = count(1) def __init__(self, server: LocalRelayServer, socket: ssl.SSLSocket) -> None: + """Initialize a client connection. + + :param server: The relay server instance this client belongs to + :param socket: The SSL socket for this client connection + """ self.server: LocalRelayServer = server self.socket: ssl.SSLSocket = socket self.buffer: bytes = b"" From 3eee73ad90e593c7123ec5b95afc0ee02c5be35a Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 3 Mar 2025 21:37:22 -0700 Subject: [PATCH 194/203] Remove unused logging from url handler --- source/remoteClient/urlHandler.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/source/remoteClient/urlHandler.py b/source/remoteClient/urlHandler.py index d451c81c841..34d4236eb85 100644 --- a/source/remoteClient/urlHandler.py +++ b/source/remoteClient/urlHandler.py @@ -21,12 +21,6 @@ import sys import winreg -try: - from logHandler import log -except ImportError: - from logging import getLogger - - log = getLogger("url_handler") _REGISTRY_KEY_PATH: str = r"SOFTWARE\Classes\nvdaremote" From ad6e91216ce0a097d2aad2e1f02d6679952d15a4 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 5 Mar 2025 12:55:16 +1100 Subject: [PATCH 195/203] Fixed several instances of snake_case --- source/gui/guiHelper.py | 2 +- source/gui/settingsDialogs.py | 10 +++++----- source/remoteClient/client.py | 17 +++++++++-------- source/remoteClient/configuration.py | 14 +++++++------- source/remoteClient/connectionInfo.py | 10 ++++------ source/remoteClient/cues.py | 4 ++-- source/remoteClient/localMachine.py | 2 +- source/remoteClient/menu.py | 4 ++-- source/remoteClient/serializer.py | 4 ++-- source/remoteClient/server.py | 20 ++++++++++---------- source/remoteClient/session.py | 2 +- source/remoteClient/transport.py | 6 +++--- source/remoteClient/urlHandler.py | 18 +++++++++++------- 13 files changed, 58 insertions(+), 55 deletions(-) diff --git a/source/gui/guiHelper.py b/source/gui/guiHelper.py index 9b714783fcc..a80bbe658a0 100644 --- a/source/gui/guiHelper.py +++ b/source/gui/guiHelper.py @@ -541,7 +541,7 @@ def alwaysCallAfter(func: Callable[_AlwaysCallAfterP, Any]) -> Callable[_AlwaysC Example: @alwaysCallAfter - def update_label(text): + def updateLabel(text): label.SetLabel(text) # Safe GUI update from any thread .. note:: diff --git a/source/gui/settingsDialogs.py b/source/gui/settingsDialogs.py index e07d243755b..658016f382b 100644 --- a/source/gui/settingsDialogs.py +++ b/source/gui/settingsDialogs.py @@ -3473,11 +3473,11 @@ def isValid(self) -> bool: def onSave(self): cs = self.config["controlserver"] cs["autoconnect"] = self.autoconnect.GetValue() - self_hosted = bool(self.clientOrServer.GetSelection()) - connection_type = self.connectionType.GetSelection() - cs["self_hosted"] = self_hosted - cs["connection_type"] = connection_type - if not self_hosted: + selfHosted = bool(self.clientOrServer.GetSelection()) + connectionType = self.connectionType.GetSelection() + cs["self_hosted"] = selfHosted + cs["connection_type"] = connectionType + if not selfHosted: cs["host"] = self.host.GetValue() else: cs["port"] = int(self.port.GetValue()) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 0a74930fc1f..668d66c369a 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -279,7 +279,7 @@ def connectAsLeader(self, connectionInfo: ConnectionInfo): @alwaysCallAfter def onConnectedAsLeader(self): log.info("Successfully connected as leader") - configuration.write_connection_to_config(self.leaderSession.getConnectionInfo()) + configuration.writeConnectionToConfig(self.leaderSession.getConnectionInfo()) if self.menu: self.menu.handleConnected(ConnectionMode.LEADER, True) ui.message( @@ -330,12 +330,11 @@ def onConnectedAsFollower(self): cues.controlServerConnected() if self.menu: self.menu.handleConnected(ConnectionMode.FOLLOWER, True) - configuration.write_connection_to_config(self.followerSession.getConnectionInfo()) + configuration.writeConnectionToConfig(self.followerSession.getConnectionInfo()) @alwaysCallAfter def onDisconnectedAsFollower(self): log.info("Control connector disconnected") - # cues.control_server_disconnected() if self.menu: self.menu.handleConnected(ConnectionMode.FOLLOWER, False) @@ -508,11 +507,13 @@ def verifyAndConnect(self, conInfo: ConnectionInfo): :raises: Displays error if already connected """ 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) + gui.messageBox( + # Translators: Message shown when trying to connect while already connected. + _("NVDA Remote is already connected. Disconnect before opening a new connection."), + # Translators: Title of the connection error dialog. + _("NVDA Remote Already Connected"), + wx.OK | wx.ICON_WARNING, + ) return self.connecting = True diff --git a/source/remoteClient/configuration.py b/source/remoteClient/configuration.py index 6bb0696bb99..a03f27b63c9 100644 --- a/source/remoteClient/configuration.py +++ b/source/remoteClient/configuration.py @@ -13,16 +13,16 @@ def getRemoteConfig(): return config.conf["remote"] -def write_connection_to_config(connection_info: ConnectionInfo): - """Writes a connection to the last connected section of the config. +def writeConnectionToConfig(connectionInfo: ConnectionInfo): + """ + Writes a connection to the last connected section of the config. If the connection is already in the config, move it to the end. - Args: - connection_info: The ConnectionInfo object containing connection details + :param connectionInfo: The :class:`ConnectionInfo` object containing connection details """ conf = getRemoteConfig() - last_cons = conf["connections"]["last_connected"] - address = connection_info.getAddress() - if address in last_cons: + lastConnections = conf["connections"]["last_connected"] + address = connectionInfo.getAddress() + if address in lastConnections: conf["connections"]["last_connected"].remove(address) conf["connections"]["last_connected"].append(address) diff --git a/source/remoteClient/connectionInfo.py b/source/remoteClient/connectionInfo.py index 69d26e1e7fe..fffd537ac42 100644 --- a/source/remoteClient/connectionInfo.py +++ b/source/remoteClient/connectionInfo.py @@ -111,7 +111,7 @@ def getAddress(self) -> str: hostname = f"[{self.hostname}]" if ":" in self.hostname else self.hostname return f"{hostname}:{self.port}" - def _build_url(self, mode: ConnectionMode) -> str: + def _buildURL(self, mode: ConnectionMode) -> str: """Builds a URL string for the given mode. :param mode: The connection mode to use in the URL @@ -143,14 +143,12 @@ def getURLToConnect(self) -> str: :return: URL string with opposite connection mode """ # Flip leader/follower for connection URL - connect_mode = ( - ConnectionMode.FOLLOWER if self.mode == ConnectionMode.LEADER else ConnectionMode.LEADER - ) - return self._build_url(connect_mode) + connectMode = ConnectionMode.FOLLOWER if self.mode == ConnectionMode.LEADER else ConnectionMode.LEADER + return self._buildURL(connectMode) def getURL(self) -> str: """Gets the URL representation of the current connection info. :return: URL string with current connection mode """ - return self._build_url(self.mode) + return self._buildURL(self.mode) diff --git a/source/remoteClient/cues.py b/source/remoteClient/cues.py index 55d14b0d5ca..6adfae46ecb 100644 --- a/source/remoteClient/cues.py +++ b/source/remoteClient/cues.py @@ -57,8 +57,8 @@ def _playCue(cueName: str) -> None: if not shouldPlaySounds(): # Play beep sequence if beeps := CUES[cueName].get("beeps"): - filtered_beeps = [(freq, dur) for freq, dur in beeps if freq is not None] - beepSequenceAsync(*filtered_beeps) + filteredBeeps = [(freq, dur) for freq, dur in beeps if freq is not None] + beepSequenceAsync(*filteredBeeps) # Play wave file if wave := CUES[cueName].get("wave"): diff --git a/source/remoteClient/localMachine.py b/source/remoteClient/localMachine.py index 6b4d4cf1be4..dd8595c0e40 100644 --- a/source/remoteClient/localMachine.py +++ b/source/remoteClient/localMachine.py @@ -195,7 +195,7 @@ def brailleInput(self, **kwargs: Dict[str, Any]) -> None: except inputCore.NoInputGestureAction: pass - def setBrailleDisplay_size(self, sizes: List[int]) -> None: + def setBrailleDisplaySize(self, sizes: List[int]) -> None: """Cache remote braille display sizes for size negotiation. :param sizes: List of display sizes (cells) from remote machines diff --git a/source/remoteClient/menu.py b/source/remoteClient/menu.py index d605223579e..de16096fe5a 100644 --- a/source/remoteClient/menu.py +++ b/source/remoteClient/menu.py @@ -125,8 +125,8 @@ def terminate(self) -> None: self.Remove(self.sendCtrlAltDelItem.Id) self.sendCtrlAltDelItem.Destroy() self.sendCtrlAltDelItem = None - tools_menu = gui.mainFrame.sysTrayIcon.toolsMenu - tools_menu.Remove(self.remoteItem.Id) + toolsMenu = gui.mainFrame.sysTrayIcon.toolsMenu + toolsMenu.Remove(self.remoteItem.Id) self.remoteItem.Destroy() self.remoteItem = None try: diff --git a/source/remoteClient/serializer.py b/source/remoteClient/serializer.py index b939b341f20..27cecf5e561 100644 --- a/source/remoteClient/serializer.py +++ b/source/remoteClient/serializer.py @@ -150,9 +150,9 @@ def isSubclassOrInstance(unknown: Any, possible: Union[Type[T], tuple[Type[T], . Example:: - >>> is_subclass_or_instance(str, (int, str)) + >>> isSubclassOrInstance(str, (int, str)) True - >>> is_subclass_or_instance("hello", (int, str)) + >>> isSubclassOrInstance("hello", (int, str)) True """ try: diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 6290e414d76..d64aee795cf 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -273,27 +273,27 @@ def __init__( self.serverSocket = self.createServerSocket( socket.AF_INET, socket.SOCK_STREAM, - bind_addr=(bindHost, self.port), + bindAddress=(bindHost, self.port), ) self.serverSocket6 = self.createServerSocket( socket.AF_INET6, socket.SOCK_STREAM, - bind_addr=(bindHost6, self.port), + bindAddress=(bindHost6, self.port), ) - def createServerSocket(self, family: int, type: int, bind_addr: tuple[str, int]) -> ssl.SSLSocket: + def createServerSocket(self, family: int, type: int, bindAddress: tuple[str, int]) -> ssl.SSLSocket: """Creates an SSL wrapped socket using the certificate. :param family: Socket address family (AF_INET or AF_INET6) :param type: Socket type (typically SOCK_STREAM) - :param bind_addr: Tuple of (host, port) to bind to + :param bindAddress: Tuple of (host, port) to bind to :return: SSL wrapped server socket :raises socket.error: If socket creation or binding fails """ serverSocket = socket.socket(family, type) sslContext = self.certManager.createSSLContext() serverSocket = sslContext.wrap_socket(serverSocket, server_side=True) - serverSocket.bind(bind_addr) + serverSocket.bind(bindAddress) serverSocket.listen(backlog=5) # Set the maximum number of queued connections return serverSocket @@ -387,7 +387,7 @@ class Client: :ivar protocolVersion: Client protocol version number """ - _id_counter = count(1) + _idCounter = count(1) def __init__(self, server: LocalRelayServer, socket: ssl.SSLSocket) -> None: """Initialize a client connection. @@ -400,7 +400,7 @@ def __init__(self, server: LocalRelayServer, socket: ssl.SSLSocket) -> None: self.buffer: bytes = b"" self.serializer: JSONSerializer = server.serializer self.authenticated: bool = False - self.id: int = next(self._id_counter) + self.id: int = next(self._idCounter) self.connectionType: str | None = None self.protocolVersion: int = 1 @@ -461,16 +461,16 @@ def do_join(self, obj: dict[str, Any]) -> None: self.authenticated = True log.info(f"Client {self.id} authenticated successfully " f"(connection type: {self.connectionType})") clients = [] - client_ids = [] + clientIds = [] for client in list(self.server.clients.values()): if client is self or not client.authenticated: continue clients.append(client.asDict()) - client_ids.append(client.id) + clientIds.append(client.id) self.send( type=RemoteMessageType.CHANNEL_JOINED, channel=self.server.password, - user_ids=client_ids, + user_ids=clientIds, clients=clients, ) self.sendToOthers( diff --git a/source/remoteClient/session.py b/source/remoteClient/session.py index a93bfcbe87a..d5c2124b0e9 100644 --- a/source/remoteClient/session.py +++ b/source/remoteClient/session.py @@ -381,7 +381,7 @@ def setDisplaySize(self, sizes: list[int] | None = None) -> None: sizes if sizes else [info.get("braille_numCells", 0) for info in self.leaders.values()] ) log.debug("Setting follower display size to: %r", self.leaderDisplaySizes) - self.localMachine.setBrailleDisplay_size(self.leaderDisplaySizes) + self.localMachine.setBrailleDisplaySize(self.leaderDisplaySizes) def handleBrailleInfo( self, diff --git a/source/remoteClient/transport.py b/source/remoteClient/transport.py index 4585355f231..7a58e89308d 100644 --- a/source/remoteClient/transport.py +++ b/source/remoteClient/transport.py @@ -192,9 +192,9 @@ def registerInbound(self, type: RemoteMessageType, handler: Callable[..., None]) Handlers are called asynchronously on wx main thread via CallAfter. Handler will receive 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) + >>> def handleKeypress(keyCode, pressed): + ... print(f"Key {keyCode} {'pressed' if pressed else 'released'}") + >>> transport.registerInbound(RemoteMessageType.KEY, handleKeypress) """ if type not in self.inboundHandlers: log.debug(f"Creating new handler for {type}") diff --git a/source/remoteClient/urlHandler.py b/source/remoteClient/urlHandler.py index 34d4236eb85..52a415d57c7 100644 --- a/source/remoteClient/urlHandler.py +++ b/source/remoteClient/urlHandler.py @@ -12,9 +12,13 @@ - 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 + +-:func:`registerURLHandler`: + Registers the NVDARemote URL protocol in the Windows Registry +:func:`unregisterURLHandler`: + Removes the NVDARemote URL protocol registration +:func:`URLHandlerPath`: + Returns the path to the URL handler executable """ import os @@ -28,7 +32,7 @@ def _createRegistryStructure(keyHandle: winreg.HKEYType, data: dict): """Creates a nested registry structure from a dictionary. - :param key_handle: A handle to an open registry key + :param keyHandle: A handle to an open registry key :param data: Dictionary containing the registry structure to create :raises OSError: If creating registry keys or setting values fails """ @@ -68,9 +72,9 @@ def _deleteRegistryKeyRecursive(baseKey: int, subkeyPath: str): # Enumerate and delete all subkeys while True: try: - subkey_name = winreg.EnumKey(key, 0) - full_path = f"{subkeyPath}\\{subkey_name}" - _deleteRegistryKeyRecursive(baseKey, full_path) + subkeyName = winreg.EnumKey(key, 0) + fullPath = f"{subkeyPath}\\{subkeyName}" + _deleteRegistryKeyRecursive(baseKey, fullPath) except WindowsError: break # Now delete the key itself From c5a241639af8ca3244b3d377e44fe60a76193ff8 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 5 Mar 2025 14:55:13 +1100 Subject: [PATCH 196/203] Change to disconnected --- source/remoteClient/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/client.py b/source/remoteClient/client.py index 668d66c369a..b9a2f0dc17d 100644 --- a/source/remoteClient/client.py +++ b/source/remoteClient/client.py @@ -284,7 +284,7 @@ def onConnectedAsLeader(self): self.menu.handleConnected(ConnectionMode.LEADER, True) ui.message( # Translators: Presented when connected to the remote computer. - _("Connected!"), + _("Connected"), ) cues.connected() @@ -302,7 +302,7 @@ def onDisconnectingAsLeader(self): def onDisconnectedAsLeader(self): log.info("Leader session disconnected") # Translators: Presented when connection to a remote computer was interupted. - ui.message(_("Connection interrupted")) + ui.message(_("Disconnected")) def connectAsFollower(self, connectionInfo: ConnectionInfo): transport = RelayTransport.create( From b95b615b1412617e5f211215a58199953918c5c3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Wed, 5 Mar 2025 15:09:24 +1100 Subject: [PATCH 197/203] Made select timeout a constant --- source/remoteClient/server.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index d64aee795cf..38488562392 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -32,7 +32,7 @@ from pathlib import Path from select import select from itertools import count -from typing import Any +from typing import Any, Final import cffi # noqa # required for cryptography from cryptography import x509 @@ -240,6 +240,7 @@ class LocalRelayServer: """ PING_TIME_SECONDS: int = 300 + SELECT_TIMEOUT_SECONDS: Final[int] = 60 def __init__( self, @@ -313,7 +314,7 @@ def run(self) -> None: self.clientSockets + [self.serverSocket, self.serverSocket6], [], self.clientSockets, - 60, + self.SELECT_TIMEOUT_SECONDS, ) if not self._running: break From 96ef4695bd2090c56cdb144180601bbcc72ed818 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 7 Mar 2025 12:30:35 +1100 Subject: [PATCH 198/203] Update local relay server certificate --- source/remoteClient/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 38488562392..408788afed0 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -126,8 +126,8 @@ def _generateSelfSignedCert(self) -> None: subject = issuer = x509.Name( [ - x509.NameAttribute(NameOID.COMMON_NAME, "NVDARemote Relay"), - x509.NameAttribute(NameOID.ORGANIZATION_NAME, "NVDARemote"), + x509.NameAttribute(NameOID.COMMON_NAME, "NVDA Remote Access Service"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "NV Access"), ], ) From d8793f4b6c0fcd13a907023422a9f004f07f84d5 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 7 Mar 2025 12:34:43 +1100 Subject: [PATCH 199/203] Update changes --- user_docs/en/changes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/user_docs/en/changes.md b/user_docs/en/changes.md index b501ee5399d..74a84059234 100644 --- a/user_docs/en/changes.md +++ b/user_docs/en/changes.md @@ -15,7 +15,7 @@ Currently this is only supported in Foxit Reader & Foxit Editor. (#9288, @NSoiff * The ability to adjust the volume of other applications besides NVDA has been added. To use this feature, "allow NVDA to control the volume of other applications" must be enabled in the audio settings panel. (#16052, #17634, @mltony, @codeofdusk) * Added new unassigned gestures to increase, decrease and mute the volume of all other applications. -* 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) +* Remote access functionality, based on the NVDA Remote add-on, has been integrated into NVDA. (#4390, @ctoth, @tspivey, @daiverd, NVDA Remote Contributors and funders) * When editing in Microsoft PowerPoint text boxes, you can now move per sentence with `alt+upArrow`/`alt+downArrow`. (#17015, @LeonarddeR) * In Mozilla Firefox, NVDA will report the highlighted text when a URL containing a text fragment is visited. (#16910, @jcsteh) * NVDA can now report when a link destination points to the current page. (#141, @LeonarddeR, @nvdaes) From a758b581066902429f4d877d377cd51350e84522 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 7 Mar 2025 14:46:46 +1100 Subject: [PATCH 200/203] lint fixes to Remote manual test plan --- tests/manual/remote.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/manual/remote.md b/tests/manual/remote.md index 3a0d0c9cc5e..eb57acae9a6 100644 --- a/tests/manual/remote.md +++ b/tests/manual/remote.md @@ -1,11 +1,13 @@ # NVDA Remote Manual Test Suite ## Overview + Remote enables remote assistance functionality between two computers running NVDA. It allows a user to connect to another computer running NVDA and either control the remote system or have their system controlled. This enables remote support, training, and collaboration between screen reader users. The add-on provides features such as speech relay, keyboard control, clipboard sharing, and braille support over remote connections. ## Environment Setup ### Host Configuration + - Windows 11 Pro - Memory: at least 16GB - Processor: at least 4 core @@ -13,14 +15,16 @@ Remote enables remote assistance functionality between two computers running NV - NVDA Remote Version: 2.6.4 (installed via addon store) ### Guest Configuration + - Another computer similar to the host or VMware Windows 11 Home running on the host with similar specs to the host computer - Storage: 64GB disk - Memory: 16GB - Processor: 8 core -- NVDA Version: Custom build from https://github.com/nvda-art/nvda (remote branch) +- NVDA Version: Custom build from (remote branch) - Base Position: latest ## Pre-Test Setup + 1. Build signed launcher 2. Host: Run installed stock NVDA 3. Guest: Install signed launcher @@ -28,6 +32,7 @@ Remote enables remote assistance functionality between two computers running NV ## Connection Tests ### Direct Connection + 1. Open NVDA Remote on the host 2. Press NVDA+alt+page-up to open the "Connect" dialog 3. Choose "Host" option @@ -40,6 +45,7 @@ Remote enables remote assistance functionality between two computers running NV 10. Test reversing roles (host becomes client, client becomes host) ### Control Server Connection + 1. Open NVDA Remote on both systems 2. On both systems, press NVDA+alt+page-up to open the "Connect" dialog 3. Choose "Connect to Control Server" (nvdaremote.com) @@ -54,8 +60,9 @@ Remote enables remote assistance functionality between two computers running NV ## Version Compatibility Tests ### New Remote to New Remote + 1. Install the new remote implementation on two test machines -2. Establish connection between the two instances +2. Establish connection between the two instances 3. Test all control modes: 4. Verify all features work correctly compared to old plugin: 1. Speech relay @@ -66,6 +73,7 @@ Remote enables remote assistance functionality between two computers running NV 6. Test connection stability during intensive screen reader usage ### New Remote Controlling Old Plugin + 1. Install the new remote implementation on one machine 2. Install the 2.6.4 plugin on another machine 3. Test connecting from new remote to old plugin system @@ -80,6 +88,7 @@ Remote enables remote assistance functionality between two computers running NV ## Remote Control Features ### Keyboard Input + 1. Connect two machines with remote control enabled 2. Test basic typing in a text editor 3. Test system shortcuts: @@ -98,6 +107,7 @@ Remote enables remote assistance functionality between two computers running NV ## Speech and Braille ### Speech Relay + 1. Connect two machines with remote control enabled 2. Navigate through various UI elements on controlled machine 3. Verify speech output on controlling machine @@ -110,6 +120,7 @@ Remote enables remote assistance functionality between two computers running NV 7. Verify speech settings respect on host/guest machines ### Braille Support + 1. Connect a braille display to the controlling machine 2. Establish remote connection between machines 3. Verify braille output appears correctly @@ -121,21 +132,23 @@ Remote enables remote assistance functionality between two computers running NV ## Special Features ### Clipboard Sharing + 1. Connect two machines 2. Copy text on the controlling machine (Ctrl+C) 3. Push the clipboard text (NVDA+SHIFT+CTRL+C) 4. Paste text on the controlled machine (Ctrl+V) 5. Repeat in reverse direction -5. Test with various content types: +6. Test with various content types: 1. Plain text 2. Formatted text 3. Large text (multiple paragraphs) -6. Verify handling of special characters -7. Test copying and pasting with keyboard shortcuts and context menus +7. Verify handling of special characters +8. Test copying and pasting with keyboard shortcuts and context menus ## Error Handling ### Connection Issues + 1. Establish remote connection between two machines 2. Temporarily disable network adapter on one machine 3. Verify appropriate error messages are displayed @@ -145,6 +158,7 @@ Remote enables remote assistance functionality between two computers running NV 7. Test disconnection handling when one machine crashes ### Resource Usage + 1. Establish remote connection between two machines 2. Monitor CPU usage during an extended session (30+ minutes) 3. Monitor memory consumption over time @@ -156,6 +170,7 @@ Remote enables remote assistance functionality between two computers running NV ## Security Tests ### Authentication + 1. Test connection with valid password 2. Attempt connection with invalid password 3. Test empty password behavior From 4cf378540a51a5a9cdd951ec9c231293ca0d1d40 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 7 Mar 2025 15:18:02 +1100 Subject: [PATCH 201/203] minor updates to tests --- tests/unit/test_remote/test_bridge.py | 54 ++++----- tests/unit/test_remote/test_remote_client.py | 120 +++++++++---------- tests/unit/test_remote/test_serializer.py | 34 +++--- tests/unit/test_remote/test_transport.py | 88 +++++++------- 4 files changed, 148 insertions(+), 148 deletions(-) diff --git a/tests/unit/test_remote/test_bridge.py b/tests/unit/test_remote/test_bridge.py index e12a74a6a42..d44a43299db 100644 --- a/tests/unit/test_remote/test_bridge.py +++ b/tests/unit/test_remote/test_bridge.py @@ -22,7 +22,7 @@ def deserialize(self, data): # Override inboundHandlers to be a dict mapping RemoteMessageType -> list of handlers. self.inboundHandlers = {} # List to collect sent messages. - self.sent_messages = [] + self.sentMessages = [] def registerInbound(self, messageType, handler): if messageType in self.inboundHandlers: @@ -37,7 +37,7 @@ def unregisterInbound(self, messageType, handler): del self.inboundHandlers[messageType] def send(self, type, **kwargs): - self.sent_messages.append((type, kwargs)) + self.sentMessages.append((type, kwargs)) def parse(self, line: bytes): pass # Not used in these tests. @@ -54,7 +54,7 @@ def setUp(self): # Create a bridge between the two fake transports. self.bridge = BridgeTransport(self.transport1, self.transport2) - def test_inbound_registration_on_init(self): + def test_inboundRegistrationOnInit(self): # On initialization, both transports should have inbound handlers registered for every RemoteMessageType. for messageType in list(RemoteMessageType): self.assertIn( @@ -68,54 +68,54 @@ def test_inbound_registration_on_init(self): f"{messageType} not registered in transport2", ) - def test_forwarding_message(self): + def test_forwardingMessage(self): # Choose a message type that is not excluded. - non_excluded = None + nonExcluded = None for m in list(RemoteMessageType): if m not in BridgeTransport.excluded: - non_excluded = m + nonExcluded = m break - self.assertIsNotNone(non_excluded, "There must be at least one non-excluded message type") + self.assertIsNotNone(nonExcluded, "There must be at least one non-excluded message type") # Simulate an inbound message on transport1. - callbacks = self.transport1.inboundHandlers[non_excluded] + callbacks = self.transport1.inboundHandlers[nonExcluded] for callback in callbacks: callback(a=10, b=20) # Expect that transport2's send() was called with the same message type and payload. - self.assertTrue(len(self.transport2.sent_messages) > 0, "No message was forwarded to transport2") - for type_sent, payload in self.transport2.sent_messages: - self.assertEqual(type_sent, non_excluded) + self.assertTrue(len(self.transport2.sentMessages) > 0, "No message was forwarded to transport2") + for typeSent, payload in self.transport2.sentMessages: + self.assertEqual(typeSent, nonExcluded) self.assertEqual(payload, {"a": 10, "b": 20}) - def test_excluded_message_not_forwarded(self): + def test_excludedMessageNotForwarded(self): # Choose a message type that is excluded. - excluded_message = None + excludedMessage = None for m in list(RemoteMessageType): if m in BridgeTransport.excluded: - excluded_message = m + excludedMessage = m break - self.assertIsNotNone(excluded_message, "There must be at least one excluded message type") + self.assertIsNotNone(excludedMessage, "There must be at least one excluded message type") # Clear any previous sent messages. - self.transport2.sent_messages.clear() + self.transport2.sentMessages.clear() # Simulate an inbound message on transport1 for the excluded type. - callbacks = self.transport1.inboundHandlers[excluded_message] + callbacks = self.transport1.inboundHandlers[excludedMessage] for callback in callbacks: callback(a=99) # Expect that transport2's send() is not called. - self.assertEqual(len(self.transport2.sent_messages), 0, "Excluded message was forwarded") + self.assertEqual(len(self.transport2.sentMessages), 0, "Excluded message was forwarded") - def test_disconnect_unregisters_handlers(self): + def test_disconnectUnregistersHandlers(self): # Count initial number of registered handlers. - count_t1 = sum(len(handlers) for handlers in self.transport1.inboundHandlers.values()) - count_t2 = sum(len(handlers) for handlers in self.transport2.inboundHandlers.values()) - self.assertGreater(count_t1, 0) - self.assertGreater(count_t2, 0) + countT1 = sum(len(handlers) for handlers in self.transport1.inboundHandlers.values()) + countT2 = sum(len(handlers) for handlers in self.transport2.inboundHandlers.values()) + self.assertGreater(countT1, 0) + self.assertGreater(countT2, 0) # Disconnect the bridge. self.bridge.disconnect() # After disconnection, there should be no inbound handlers remaining. - total_t1 = sum(len(handlers) for handlers in self.transport1.inboundHandlers.values()) - total_t2 = sum(len(handlers) for handlers in self.transport2.inboundHandlers.values()) - self.assertEqual(total_t1, 0, "Still registered handlers in transport1 after disconnect") - self.assertEqual(total_t2, 0, "Still registered handlers in transport2 after disconnect") + totalT1 = sum(len(handlers) for handlers in self.transport1.inboundHandlers.values()) + totalT2 = sum(len(handlers) for handlers in self.transport2.inboundHandlers.values()) + self.assertEqual(totalT1, 0, "Still registered handlers in transport1 after disconnect") + self.assertEqual(totalT2, 0, "Still registered handlers in transport2 after disconnect") if __name__ == "__main__": diff --git a/tests/unit/test_remote/test_remote_client.py b/tests/unit/test_remote/test_remote_client.py index ae1752d4f7f..cd276526146 100644 --- a/tests/unit/test_remote/test_remote_client.py +++ b/tests/unit/test_remote/test_remote_client.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import MagicMock, patch -import remoteClient.client as rc_client +import remoteClient.client as rcClient from remoteClient.connectionInfo import ConnectionInfo, ConnectionMode from remoteClient.protocol import RemoteMessageType @@ -49,19 +49,19 @@ def __init__(self, url): def getConnectionInfo(self): class FakeConnectionInfo: - def getURLToConnect(_): + def getURLToConnect(_): # type: ignore return self.url return FakeConnectionInfo() class FakeAPI: - clip_data = "Fake clipboard text" + clipData = "Fake clipboard text" copied = None @staticmethod def getClipData(): - return FakeAPI.clip_data + return FakeAPI.clipData @staticmethod def copyToClip(text): @@ -75,137 +75,137 @@ def setUp(self): if not wx.GetApp(): self.app = wx.App() # Patch gui.mainFrame to a fake object so RemoteMenu can access sysTrayIcon.toolsMenu. - patcher_mainFrame = patch("remoteClient.client.gui.mainFrame") - self.addCleanup(patcher_mainFrame.stop) - mock_mainFrame = patcher_mainFrame.start() - mock_mainFrame.sysTrayIcon = MagicMock() - mock_mainFrame.sysTrayIcon.toolsMenu = MagicMock() - self.client = rc_client.RemoteClient() + patcherMainFrame = patch("remoteClient.client.gui.mainFrame") + self.addCleanup(patcherMainFrame.stop) + mockMainFrame = patcherMainFrame.start() + mockMainFrame.sysTrayIcon = MagicMock() + mockMainFrame.sysTrayIcon.toolsMenu = MagicMock() + self.client = rcClient.RemoteClient() # Override localMachine and menu with fake implementations. self.client.localMachine = FakeLocalMachine() self.client.menu = FakeMenu() # Patch ui.message to capture calls. patcher = patch("remoteClient.client.ui.message") self.addCleanup(patcher.stop) - self.ui_message = patcher.start() + self.uiMessage = patcher.start() # Patch the API module to use our fake API. - patcher_api = patch("remoteClient.client.api", new=FakeAPI) - self.addCleanup(patcher_api.stop) - patcher_api.start() + patcherAPI = patch("remoteClient.client.api", new=FakeAPI) + self.addCleanup(patcherAPI.stop) + patcherAPI.start() FakeAPI.copied = None - patcher_nvwave = patch("remoteClient.cues.nvwave.playWaveFile", return_value=None) + patcherNvwave = patch("remoteClient.cues.nvwave.playWaveFile", return_value=None) - self.addCleanup(patcher_nvwave.stop) - patcher_nvwave.start() + self.addCleanup(patcherNvwave.stop) + patcherNvwave.start() def tearDown(self): self.client = None - def test_toggle_mute(self): + def test_toggleMute(self): # Initially, local machine should not be muted. self.assertFalse(self.client.localMachine.isMuted) # Toggle mute: should mute the local machine. self.client.toggleMute() self.assertTrue(self.client.localMachine.isMuted) self.assertTrue(self.client.menu.muteItem.checked) - self.ui_message.assert_called_once() + self.uiMessage.assert_called_once() # Now toggle again: should unmute. - self.ui_message.reset_mock() + self.uiMessage.reset_mock() self.client.toggleMute() self.assertFalse(self.client.localMachine.isMuted) self.assertFalse(self.client.menu.muteItem.checked) - self.ui_message.assert_called_once() + self.uiMessage.assert_called_once() - def test_push_clipboard_no_connection(self): + def test_pushClipboardNoConnection(self): # Without any transport (neither follower nor leader), pushClipboard should warn. self.client.followerTransport = None self.client.leaderTransport = None self.client.pushClipboard() - self.ui_message.assert_called_with("Not connected.") + self.uiMessage.assert_called_with("Not connected.") - def test_push_clipboard_with_transport(self): + def test_pushClipboardWithTransport(self): # With a fake transport, pushClipboard should send the clipboard text. - fake_transport = FakeTransport() - self.client.leaderTransport = fake_transport - FakeAPI.clip_data = "TestClipboard" + fakeTransport = FakeTransport() + self.client.leaderTransport = fakeTransport + FakeAPI.clipData = "TestClipboard" self.client.pushClipboard() - self.assertTrue(len(fake_transport.sent) > 0) - messageType, kwargs = fake_transport.sent[0] + self.assertTrue(len(fakeTransport.sent) > 0) + messageType, kwargs = fakeTransport.sent[0] self.assertEqual(messageType, RemoteMessageType.SET_CLIPBOARD_TEXT) self.assertEqual(kwargs.get("text"), "TestClipboard") - def test_copy_link_no_session(self): + def test_copyLinkNoSession(self): # If there is no session, copyLink should warn the user. self.client.leaderSession = None self.client.followerSession = None - self.ui_message.reset_mock() + self.uiMessage.reset_mock() self.client.copyLink() - self.ui_message.assert_called_with("Not connected.") + self.uiMessage.assert_called_with("Not connected.") - def test_copy_link_with_session(self): + def test_copyLinkWithSession(self): # With a fake session, copyLink should call api.copyToClip with the proper URL. - fake_session = FakeSession("http://fake.url/connect") - self.client.leaderSession = fake_session + fakeSession = FakeSession("http://fake.url/connect") + self.client.leaderSession = fakeSession FakeAPI.copied = None self.client.copyLink() self.assertEqual(FakeAPI.copied, "http://fake.url/connect") - def test_send_sas_no_leader_transport(self): + def test_sendSasNoLeaderTransport(self): # Without a leaderTransport, sendSAS should log an error. self.client.leaderTransport = None - with patch("remoteClient.client.log.error") as mock_log_error: + with patch("remoteClient.client.log.error") as mockLogError: self.client.sendSAS() - mock_log_error.assert_called_once_with("No leader transport to send SAS") + mockLogError.assert_called_once_with("No leader transport to send SAS") - def test_send_sas_with_leader_transport(self): + def test_sendSasWithLeaderTransport(self): # With a fake leaderTransport, sendSAS should forward the SEND_SAS message. - fake_transport = FakeTransport() - self.client.leaderTransport = fake_transport + fakeTransport = FakeTransport() + self.client.leaderTransport = fakeTransport self.client.sendSAS() - self.assertTrue(len(fake_transport.sent) > 0) - messageType, _ = fake_transport.sent[0] + self.assertTrue(len(fakeTransport.sent) > 0) + messageType, _ = fakeTransport.sent[0] self.assertEqual(messageType, RemoteMessageType.SEND_SAS) - def test_connect_dispatch(self): + def test_connectDispatch(self): # Ensure that connect() dispatches to connectAsLeader or connectAsFollower based on connection mode. - fake_connect_as_leader = MagicMock() - fake_connect_as_follower = MagicMock() - self.client.connectAsLeader = fake_connect_as_leader - self.client.connectAsFollower = fake_connect_as_follower - conn_info_leader = ConnectionInfo( + fakeConnectAsLeader = MagicMock() + fakeConnectAsFollower = MagicMock() + self.client.connectAsLeader = fakeConnectAsLeader + self.client.connectAsFollower = fakeConnectAsFollower + connInfoLeader = ConnectionInfo( hostname="localhost", mode=ConnectionMode.LEADER, key="abc", port=1000, insecure=False, ) - self.client.connect(conn_info_leader) - fake_connect_as_leader.assert_called_once_with(conn_info_leader) - fake_connect_as_leader.reset_mock() - conn_info_follower = ConnectionInfo( + self.client.connect(connInfoLeader) + fakeConnectAsLeader.assert_called_once_with(connInfoLeader) + fakeConnectAsLeader.reset_mock() + connInfoFollower = ConnectionInfo( hostname="localhost", mode=ConnectionMode.FOLLOWER, key="abc", port=1000, insecure=False, ) - self.client.connect(conn_info_follower) - fake_connect_as_follower.assert_called_once_with(conn_info_follower) + self.client.connect(connInfoFollower) + fakeConnectAsFollower.assert_called_once_with(connInfoFollower) def test_disconnect(self): # Test disconnect with no active sessions. self.client.leaderSession = None self.client.followerSession = None - with patch("remoteClient.client.log.debug") as mock_log_debug: + with patch("remoteClient.client.log.debug") as mockLogDebug: self.client.disconnect() - mock_log_debug.assert_called() + mockLogDebug.assert_called() # Test disconnect with an active localControlServer. - fake_control = MagicMock() - self.client.localControlServer = fake_control + fakeControl = MagicMock() + self.client.localControlServer = fakeControl self.client.leaderSession = MagicMock() self.client.followerSession = MagicMock() self.client.disconnect() - fake_control.close.assert_called_once() + fakeControl.close.assert_called_once() if __name__ == "__main__": diff --git a/tests/unit/test_remote/test_serializer.py b/tests/unit/test_remote/test_serializer.py index a8db06b352e..5fb61b88ded 100644 --- a/tests/unit/test_remote/test_serializer.py +++ b/tests/unit/test_remote/test_serializer.py @@ -25,33 +25,33 @@ class TestJSONSerializer(unittest.TestCase): def setUp(self): self.serializer = JSONSerializer() - def test_serialize_basic(self): + def test_serializeBasic(self): # Test basic serialization with a string type and payload. - message_bytes = self.serializer.serialize(type="test_message", key=123) - self.assertTrue(message_bytes.endswith(b"\n")) - message_str = message_bytes.rstrip(b"\n").decode("UTF-8") - data = json.loads(message_str) + messageBytes = self.serializer.serialize(type="test_message", key=123) + self.assertTrue(messageBytes.endswith(b"\n")) + messageStr = messageBytes.rstrip(b"\n").decode("UTF-8") + data = json.loads(messageStr) self.assertEqual(data["type"], "test_message") self.assertEqual(data["key"], 123) - def test_serialize_enum(self): + def test_serializeEnum(self): # Test that passing an Enum type is serialized to its value. - message_bytes = self.serializer.serialize(type=DummyEnum.VALUE1, key="abc") - message_str = message_bytes.rstrip(b"\n").decode("UTF-8") - data = json.loads(message_str) + messageBytes = self.serializer.serialize(type=DummyEnum.VALUE1, key="abc") + messageStr = messageBytes.rstrip(b"\n").decode("UTF-8") + data = json.loads(messageStr) self.assertEqual(data["type"], "value1") self.assertEqual(data["key"], "abc") - def test_round_trip(self): + def test_roundTrip(self): # Test that serializing and then deserializing returns the same message data. original = {"type": "round_trip", "value": 999} - message_bytes = self.serializer.serialize(**original) + messageBytes = self.serializer.serialize(**original) # Remove the separator for deserialization. - data = self.serializer.deserialize(message_bytes.rstrip(JSONSerializer.SEP)) + data = self.serializer.deserialize(messageBytes.rstrip(JSONSerializer.SEP)) self.assertEqual(data["type"], "round_trip") self.assertEqual(data["value"], 999) - def test_custom_encoder(self): + def test_customEncoder(self): # Test that CustomEncoder falls back to default behavior for non-special objects. dummy = DummyCommand("test") # Set __dict__ to a non-serializable object (set is not serializable by default) @@ -65,11 +65,11 @@ def test_custom_encoder(self): json.dumps(dummy, cls=SpeechCommandJSONEncoder) self.assertRegex(str(cm.exception), "not JSON serializable") - def test_as_sequence_no_change(self): + def test_asSequenceNoChange(self): # Test that as_sequence returns the dictionary unchanged when no special keys exist. - input_dict = {"type": "other", "foo": "bar"} - result = asSequence(input_dict) - self.assertEqual(result, input_dict) + inputDict = {"type": "other", "foo": "bar"} + result = asSequence(inputDict) + self.assertEqual(result, inputDict) if __name__ == "__main__": diff --git a/tests/unit/test_remote/test_transport.py b/tests/unit/test_remote/test_transport.py index 17d3f32c9b4..ac4bb3727d0 100644 --- a/tests/unit/test_remote/test_transport.py +++ b/tests/unit/test_remote/test_transport.py @@ -81,14 +81,14 @@ def notify(self, **kwargs): # --------------------------------------------------------------------------- # Tests for RemoteExtensionPoint class TestRemoteExtensionPoint(unittest.TestCase): - def test_remoteBridge_with_filter(self): + def test_remoteBridgeWithFilter(self): # Create a fake extension point and a filter function registrar = FakeHandlerRegistrar() - def my_filter(*args, **kwargs): + def myFilter(*args, **kwargs): return {"filtered": True} - rep = RemoteExtensionPoint(extensionPoint=registrar, messageType="TEST", filter=my_filter) + rep = RemoteExtensionPoint(extensionPoint=registrar, messageType="TEST", filter=myFilter) # Create a fake transport that records calls to send class FakeTransport: @@ -105,7 +105,7 @@ def send(self, messageType, **kwargs): self.assertEqual(fakeTransport.sent, [("TEST", {"filtered": True})]) rep.unregister() - def test_remoteBridge_without_filter(self): + def test_remoteBridgeWithoutFilter(self): registrar = FakeHandlerRegistrar() rep = RemoteExtensionPoint(extensionPoint=registrar, messageType="TEST", filter=None) @@ -143,18 +143,18 @@ def setUp(self): self.transport.connected = True self.transport.queue = Queue() - def test_send_enqueues_serialized_message(self): + def test_sendEnqueuesSerializedMessage(self): self.transport.send("TEST_TYPE", key=123) item = self.transport.queue.get_nowait() result = self.serializer.deserialize(item) self.assertEqual(result["type"], "TEST_TYPE") self.assertEqual(result["key"], 123) - def test_send_when_not_connected_logs_error(self): + def test_sendWhenNotConnectedLogsError(self): self.transport.connected = False - with mock.patch("remoteClient.transport.log.error") as mock_error: + with mock.patch("remoteClient.transport.log.error") as mockError: self.transport.send("TEST", a=1) - mock_error.assert_called_once() + mockError.assert_called_once() # --------------------------------------------------------------------------- @@ -189,27 +189,27 @@ def setUp(self): self.transport.buffer = b"" def test_processIncomingSocketData(self): - parsed_lines = [] + parsedLines = [] - def fake_parse(line): - parsed_lines.append(line) + def fakeParse(line): + parsedLines.append(line) - self.transport.parse = fake_parse + self.transport.parse = fakeParse self.transport.processIncomingSocketData() self.assertEqual(self.transport.buffer, b"partial") - self.assertEqual(parsed_lines, [b"line1", b"line2"]) + self.assertEqual(parsedLines, [b"line1", b"line2"]) - def test_parse_calls_inboundHandler(self): + def test_parseCallsInboundHandler(self): # Set up an inbound handler for type RemoteMessageType.PROTOCOL_VERSION - dummy_inbound = mock.MagicMock() - self.transport.inboundHandlers = {RemoteMessageType.PROTOCOL_VERSION: dummy_inbound} + dummyInbound = mock.MagicMock() + self.transport.inboundHandlers = {RemoteMessageType.PROTOCOL_VERSION: dummyInbound} # Patch wx.CallAfter to simply call immediately original_CallAfter = wx.CallAfter wx.CallAfter = lambda func, *args, **kwargs: func(*args, **kwargs) # Prepare a message message = self.serializer.serialize(type=RemoteMessageType.PROTOCOL_VERSION, a=1) self.transport.parse(message) - dummy_inbound.notify.assert_called_once_with(a=1) + dummyInbound.notify.assert_called_once_with(a=1) wx.CallAfter = original_CallAfter @@ -234,7 +234,7 @@ def setUp(self): self.transport.serverSockLock = threading.Lock() self.transport.queue = Queue() - def test_sendQueue_sends_messages(self): + def test_sendQueueSendsMessages(self): item1 = b"msg1" item2 = b"msg2" self.transport.queue.put(item1) @@ -243,14 +243,14 @@ def test_sendQueue_sends_messages(self): self.transport.sendQueue() self.assertEqual(self.transport.serverSock.sent, [item1, item2]) - def test_sendQueue_stops_on_socket_error(self): + def test_sendQueueStopsOnSocketError(self): item1 = b"msg1" self.transport.queue.put(item1) - def fake_sendall(data): + def fakeSendall(data): raise socket.error("Test error") - self.transport.serverSock.sendall = fake_sendall + self.transport.serverSock.sendall = fakeSendall # Should complete without raising further exception self.transport.sendQueue() @@ -302,21 +302,21 @@ def setUp(self): self.port = 8090 @mock.patch("test.mock_socket.socket", autospec=True) - def test_createOutboundSocket_onion(self, mock_socket): + def test_createOutboundSocketOnion(self, mockSocket): t = TCPTransport(self.serializer, (self.host + ".onion", self.port)) - fake_socket = DummyTCPSocket() - mock_socket.return_value = fake_socket + fakeSocket = DummyTCPSocket() + mockSocket.return_value = fakeSocket sock = t.createOutboundSocket(self.host + ".onion", self.port, insecure=False) - self.assertFalse(fake_socket.connected) + self.assertFalse(fakeSocket.connected) self.assertTrue(isinstance(sock, ssl.SSLSocket)) @mock.patch("test.mock_socket.socket", autospec=True) - def test_createOutboundSocket_regular_insecure(self, mock_socket): + def test_createOutboundSocketRegularInsecure(self, mockSocket): t = TCPTransport(self.serializer, (self.host, self.port)) - fake_socket = DummyTCPSocket() - mock_socket.return_value = fake_socket + fakeSocket = DummyTCPSocket() + mockSocket.return_value = fakeSocket sock = t.createOutboundSocket(self.host, self.port, insecure=True) - self.assertFalse(fake_socket.connected) + self.assertFalse(fakeSocket.connected) self.assertTrue(isinstance(sock, ssl.SSLSocket)) @@ -326,7 +326,7 @@ class TestRelayTransportOnConnected(unittest.TestCase): def setUp(self): self.serializer = FakeSerializer() - def test_onConnected_with_channel(self): + def test_onConnectedWithChannel(self): # Create a RelayTransport with a channel set. rt = RelayTransport( serializer=self.serializer, @@ -344,7 +344,7 @@ def test_onConnected_with_channel(self): # And since channel is set, should send JOIN message. rt.send.assert_any_call(RemoteMessageType.JOIN, channel="mychannel", connection_type="relayMode") - def test_onConnected_without_channel(self): + def test_onConnectedWithoutChannel(self): # Create a RelayTransport with no channel. rt = RelayTransport( serializer=self.serializer, @@ -365,10 +365,10 @@ def test_onConnected_without_channel(self): class DummyConnectorTransport(Transport): def __init__(self, serializer): super().__init__(serializer) - self.run_called = 0 + self.runCalled = 0 def run(self): - self.run_called += 1 + self.runCalled += 1 raise socket.error("Simulated socket error") def processIncomingSocketData(self): @@ -379,20 +379,20 @@ def send(self, type, **kwargs): class TestConnectorThread(unittest.TestCase): - def test_connectorThread_runs_and_reconnects(self): + def testConnectorThreadRunsAndReconnects(self): serializer = FakeSerializer() - fake_transport = DummyConnectorTransport(serializer) - connector = ConnectorThread(fake_transport, reconnectDelay=0.01) + fakeTransport = DummyConnectorTransport(serializer) + connector = ConnectorThread(fakeTransport, reconnectDelay=0.01) connector.running = True # Run connector.run() in a loop for a few iterations manually. iterations = 3 for _ in range(iterations): try: - fake_transport.run() + fakeTransport.run() except socket.error: pass connector.running = False - self.assertEqual(fake_transport.run_called, iterations) + self.assertEqual(fakeTransport.runCalled, iterations) # --------------------------------------------------------------------------- @@ -414,7 +414,7 @@ def setUp(self): self.transport.queue = Queue() self.fakeRegistrar = FakeHandlerRegistrar() - def test_registerOutbound_and_trigger(self): + def test_registerOutboundAndTrigger(self): self.transport.registerOutbound(self.fakeRegistrar, RemoteMessageType.GENERATE_KEY) for handler in self.fakeRegistrar.handlers: handler(a=42) @@ -441,13 +441,13 @@ def handler(**kwargs): self.handler = handler - def test_register_inbound(self): + def test_registerInbound(self): self.transport.registerInbound(RemoteMessageType.PROTOCOL_VERSION, self.handler) self.assertIn(RemoteMessageType.PROTOCOL_VERSION, self.transport.inboundHandlers) self.transport.inboundHandlers[RemoteMessageType.PROTOCOL_VERSION].notify(a=123) self.assertTrue(self.called) - def test_unregister_inbound(self): + def test_unregisterInbound(self): self.transport.registerInbound(RemoteMessageType.PROTOCOL_VERSION, self.handler) self.transport.unregisterInbound(RemoteMessageType.PROTOCOL_VERSION, self.handler) self.called = False @@ -462,18 +462,18 @@ def setUp(self): self.transport = DummyTransport(serializer=self.serializer) self.transport.inboundHandlers = {} - def test_parse_no_type(self): + def test_parseNoType(self): with self.assertLogs(level="WARN") as cm: self.transport.parse(b"invalid message\n") self.assertTrue(any("Received message without type" in log for log in cm.output)) - def test_parse_invalid_type(self): + def test_parseInvalidType(self): with self.assertLogs(level="WARN") as cm: message = self.serializer.serialize(type="NONEXISTENT", a=10) self.transport.parse(message) self.assertTrue(any("Received message with invalid type" in log for log in cm.output)) - def test_parse_unhandled_type(self): + def test_parseUnhandledType(self): with self.assertLogs(level="WARN") as cm: message = self.serializer.serialize(type=RemoteMessageType.GENERATE_KEY, b=10) self.transport.parse(message) From 8ccb25930672fddb79361b817f339c3c6d472a42 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 7 Mar 2025 16:52:30 +1100 Subject: [PATCH 202/203] Make some constants enums and document where they come from --- source/remoteClient/input.py | 74 ++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/source/remoteClient/input.py b/source/remoteClient/input.py index 3de4ec1c89a..e3caca524cb 100644 --- a/source/remoteClient/input.py +++ b/source/remoteClient/input.py @@ -5,6 +5,7 @@ import ctypes from ctypes import POINTER, Structure, Union, c_long, c_ulong, wintypes +from enum import IntEnum, IntFlag import api import baseObject @@ -14,14 +15,57 @@ 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 InputType(IntEnum): + """Values permissible as the `type` field in an `INPUT` struct. + + .. seealso:: + https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-input + """ + + MOUSE = 0 + """The event is a mouse event. Use the mi structure of the union.""" + + KEYBOARD = 1 + """The event is a keyboard event. Use the ki structure of the union.""" + + HARDWARE = 2 + """The event is a hardware event. Use the hi structure of the union.""" + + +class VKMapType(IntEnum): + """Type of mapping to be performed between virtual key code and virtual scan code. + + .. seealso:: + https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-mapvirtualkeyw + """ + + VK_TO_VSC = 0 + """Maps a virtual key code to a scan code.""" + + +class KeyEventFlag(IntFlag): + """Specifies various aspects of a keystroke in a KEYBDINPUT struct. + + .. seealso:: + https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-keybdinput + """ + + EXTENDED_KEY = 0x0001 + """If specified, the wScan scan code consists of a sequence of two bytes, where the first byte has a value of 0xE0.""" + + KEY_UP = 0x0002 + """If specified, the key is being released. If not specified, the key is being pressed. """ + + SCAN_CODE = 0x0008 + """If specified, wScan identifies the key and wVk is ignored. """ + + UNICODE = 0x0004 + """If specified, the system synthesizes a VK_PACKET keystroke. + + .. warning:: + Must only be combined with :const:`KEY_UP`. + """ class MOUSEINPUT(Structure): @@ -62,6 +106,12 @@ class INPUTUnion(Union): class INPUT(Structure): + """Stores information for synthesizing input events. + + .. seealso:: + https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-input + """ + _fields_ = ( ("type", wintypes.DWORD), ("union", INPUTUnion), @@ -171,10 +221,10 @@ def sendKey(vk: int | None = None, scan: int | None = None, extended: bool = Fal 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) + i.union.ki.wScan = ctypes.windll.user32.MapVirtualKeyW(vk, VKMapType.VK_TO_VSC) if not pressed: - i.union.ki.dwFlags |= KEYEVENTF_KEYUP + i.union.ki.dwFlags |= KeyEventFlag.KEY_UP if extended: - i.union.ki.dwFlags |= KEYEVENTF_EXTENDEDKEY - i.type = INPUT_KEYBOARD + i.union.ki.dwFlags |= KeyEventFlag.EXTENDED_KEY + i.type = InputType.KEYBOARD ctypes.windll.user32.SendInput(1, ctypes.byref(i), ctypes.sizeof(INPUT)) From 759cfd3f8b9f71db658cc5c1c7ef9c862b7189a3 Mon Sep 17 00:00:00 2001 From: Sascha Cowley <16543535+SaschaCowley@users.noreply.github.com> Date: Fri, 7 Mar 2025 17:18:55 +1100 Subject: [PATCH 203/203] Fixed name of RemoteMessageType.PING --- source/remoteClient/protocol.py | 2 +- source/remoteClient/server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/source/remoteClient/protocol.py b/source/remoteClient/protocol.py index c986a77debb..f3d434da9bf 100644 --- a/source/remoteClient/protocol.py +++ b/source/remoteClient/protocol.py @@ -40,7 +40,7 @@ class RemoteMessageType(StrEnum): # System Messages MOTD = "motd" VERSION_MISMATCH = "version_mismatch" - PINGping = "ping" + PING = "ping" ERROR = "error" NVDA_NOT_CONNECTED = ( "nvda_not_connected" # This was added in version 2 but never implemented on the server diff --git a/source/remoteClient/server.py b/source/remoteClient/server.py index 408788afed0..e0ecffbca42 100644 --- a/source/remoteClient/server.py +++ b/source/remoteClient/server.py @@ -326,7 +326,7 @@ def run(self) -> None: if time.time() - self.lastPingTime >= self.PING_TIME: for client in self.clients.values(): if client.authenticated: - client.send(type=RemoteMessageType.PINGping) + client.send(type=RemoteMessageType.PING) self.lastPingTime = time.time() def acceptNewConnection(self, sock: ssl.SSLSocket) -> None: