Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remote Access #17580

Open
wants to merge 56 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
6b9742a
Add NVDA Remote functionality to core NVDA
ctoth Dec 24, 2024
8daee13
Fix failed certificate callback
ctoth Dec 27, 2024
855ef1e
refactor: simplify remote speech handling using extension points
ctoth Dec 28, 2024
c8889a4
Warn, not error for unknown message types
ctoth Dec 28, 2024
f1a3afb
refactor: remove NVDA patcher system and integrate braille handling d…
ctoth Dec 28, 2024
1799693
Remove unused `CallbackManager` and patcher modules
ctoth Dec 28, 2024
5ecae9a
Improve logic for callback registration
ctoth Dec 28, 2024
e8f1e61
fix: prevent duplicate callback registration and improve callback lif…
ctoth Dec 28, 2024
d2b43da
Improve logging
ctoth Dec 28, 2024
fd0a061
Add missing @alwaysCallAfter decorators
ctoth Dec 28, 2024
3810a1f
Fix braille input
ctoth Dec 29, 2024
e9a515f
feat(remoteClient): add TypedDict for PortCheckResponse
ctoth Dec 29, 2024
6600c64
Fix translator note comment
ctoth Dec 29, 2024
2fdb478
refactor: migrate remote config to use main config system (#350)
ctoth Dec 29, 2024
54d25c5
refactor: Remote Client: Ensure consistent feedback for deafblind users
ctoth Dec 29, 2024
40ceeff
Naming and typing fixes
ctoth Dec 29, 2024
893292b
Add `pre_speechQueued` to the table of extension points and export it…
ctoth Dec 29, 2024
e063085
docs: add remote access documentation to user guide
ctoth Dec 29, 2024
01359d5
feat(remoteClient): make RemoteMenu conditional on secure desktop status
ctoth Dec 29, 2024
1d2926f
feat: block remote connection actions in secure mode and rename for c…
ctoth Dec 29, 2024
8648eb7
Use playWaveFile directly for remote sounds
ctoth Dec 29, 2024
19de99e
Fix secure desktop check
ctoth Dec 29, 2024
5d9e5b1
Don't write config after receiving MOTD
ctoth Dec 29, 2024
e19b68d
Add .wav suffix to cues
ctoth Dec 29, 2024
8d1ca24
Better handle wave files
ctoth Dec 29, 2024
8346b17
Added Remote Certificate Manager to self-sign certificates for the bu…
ctoth Dec 31, 2024
f0acb7a
Add cryptography dependency to requirements.txt
ctoth Dec 31, 2024
984b5c8
Hidden import of CFFI to satisfy py2exe per pyca/cryptography#5122
ctoth Dec 31, 2024
42c1112
security: Improve certificate handling for self-hosted remote connect…
ctoth Jan 3, 2025
d82559e
fix: Update SSL socket configuration and connection settings
ctoth Jan 3, 2025
0b3c425
fix: Change localhost binding from IP to hostname in secure desktop r…
ctoth Jan 4, 2025
bea5670
feat: Add comprehensive logging to SecureDesktopHandler module
ctoth Jan 4, 2025
92a9eb0
feat: Add comprehensive logging to server module for improved monitor…
ctoth Jan 4, 2025
60eeede
refactor: Replace localhost with 127.0.0.1 and remove SSL socket wrap…
ctoth Jan 4, 2025
1f14357
refactor: Simplify logging and formatting in server and secureDesktop…
ctoth Jan 4, 2025
e76b406
Use the Enum's value
ctoth Jan 4, 2025
235decb
Remove old static certificate
ctoth Jan 4, 2025
59166e2
Ch-ch-ch-ch-changes
ctoth Jan 4, 2025
04b81ad
Braille secure desktop fix
ctoth Jan 4, 2025
79a2b09
Better logging
ctoth Jan 5, 2025
95f7cb4
Use enum
ctoth Jan 5, 2025
9a63bec
Fix logging in server module
ctoth Jan 5, 2025
fe6660d
We don't need to register these Braille callbacks for anything but th…
ctoth Jan 5, 2025
8dc193d
fix: Enforce TLS 1.2 minimum version for secure socket connections
ctoth Jan 5, 2025
3fc4047
Include reference to PR in changelog.
ctoth Jan 5, 2025
2bc026c
fix: Update SSL context configuration for TLS 1.2 protocol
ctoth Jan 5, 2025
eacf802
refactor: Rename variables and methods to follow camelCase convention
ctoth Jan 12, 2025
79c5695
Unmute remote speech when controlling the remote machine
ctoth Jan 12, 2025
90471a6
Added support for TOR hidden services (.onion addresses) per nvdaremo…
ctoth Jan 12, 2025
b2220f8
Update docs
ctoth Jan 13, 2025
b1b1446
Move contents of `socket_utils` into `protocol` module
ctoth Jan 13, 2025
b101056
Camel-case input module
ctoth Jan 13, 2025
9040806
Add copyright notices to all remote modules
ctoth Jan 16, 2025
7f6068c
connection_info -> connectionInfo
ctoth Jan 16, 2025
a549de3
refactor: Add ABCMeta to Serializer for abstract base class definition
ctoth Jan 16, 2025
23442ed
globalVars.remoteClient -> remoteClient.client per review
ctoth Jan 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions projectDocs/dev/developerGuide/developerGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions source/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -1049,6 +1055,7 @@ def _doPostNvdaStartupAction():
" This likely indicates NVDA is exiting due to WM_QUIT.",
)
queueHandler.pumpAll()
_terminate(remoteClient)
_terminate(gui)
config.saveOnExit()

Expand Down
68 changes: 67 additions & 1 deletion source/globalCommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
from utils.security import objectBelowLockScreenAndWindowsIsLocked
import audio
from audio import appsVolume

import remoteClient

#: Script category for text review commands.
# Translators: The name of a category of NVDA commands.
Expand Down Expand Up @@ -119,6 +119,9 @@
#: Script category for audio streaming commands.
# Translators: The name of a category of NVDA commands.
SCRCAT_AUDIO = _("Audio")
#: Script category for Remote commands.
# Translators: The name of a category of NVDA commands.
SCRCAT_REMOTE = _("Remote")

# Translators: Reported when there are no settings to configure in synth settings ring
# (example: when there is no setting for language).
Expand Down Expand Up @@ -4888,6 +4891,69 @@ def script_toggleApplicationsVolumeAdjuster(self, gesture: "inputCore.InputGestu
def script_toggleApplicationsMute(self, gesture: "inputCore.InputGesture") -> None:
appsVolume._toggleAppsVolumeMute()

@script(
# Translators: Describes a command.
description=_("""Mute or unmute the speech coming from the remote computer"""),
category=SCRCAT_REMOTE,
)
def script_toggle_remote_mute(self, gesture):
remoteClient.client.toggleMute()

@script(
gesture="kb:control+shift+NVDA+c",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is remote copy allowed for controlled computer? If not, I'd say to unassign the default gesture for this command.

category=SCRCAT_REMOTE,
# Translators: Documentation string for the script that sends the contents of the clipboard to the remote machine.
description=_("Sends the contents of the clipboard to the remote machine"),
)
def script_push_clipboard(self, gesture):
remoteClient.client.pushClipboard()

@script(
# Translators: Documentation string for the script that copies a link to the remote session to the clipboard.
description=_("""Copies a link to the remote session to the clipboard"""),
category=SCRCAT_REMOTE,
)
def script_copy_link(self, gesture):
remoteClient.client.copyLink()
# Translators: A message indicating that a link has been copied to the clipboard.
ui.message(_("Copied link"))

@script(
gesture="kb:alt+NVDA+pageDown",
category=SCRCAT_REMOTE,
# Translators: Documentation string for the script that disconnects a remote session.
description=_("""Disconnect a remote session"""),
)
@gui.blockAction.when(gui.blockAction.Context.SECURE_MODE)
def script_disconnectFromRemote(self, gesture):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this command available from controlled computer? I hope yes; just to confirm the UX.

if not remoteClient.client.isConnected:
# Translators: A message indicating that the remote client is not connected.
ui.message(_("Not connected."))
return
remoteClient.client.disconnect()

@script(
gesture="kb:alt+NVDA+pageUp",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO gestures including direction or opposition (arrows, pg up/down, + and -) are valuable. They should be reserved to usages where they are most suitable. For example, this may be an important point when NVDA Magnifier sees the light of day (see #17416 by @seanbudd and NVDA roadmap).

Connect or disconnect commands do not seem to match this usage. Could you think to other shortcut keys?

Also, unless it is technically difficult, I think that using the same command to connect and disconnect (toggle command) would be more user-friendly: no error warning the user that he cannot disconnect while already disconnected. Using a simple toggle command would also allow to use only one gesture instead of two.

# Translators: Documentation string for the script that invokes the remote session.
description=_("""Connect to a remote computer"""),
category=SCRCAT_REMOTE,
)
@gui.blockAction.when(gui.blockAction.Context.MODAL_DIALOG_OPEN)
@gui.blockAction.when(gui.blockAction.Context.SECURE_MODE)
def script_connectToRemote(self, gesture):
if remoteClient.client.isConnected() or remoteClient.client.connecting:
return
remoteClient.client.doConnect()

@script(
# Translators: Documentation string for the script that toggles the control between guest and host machine.
description=_("Toggles the control between guest and host machine"),
category=SCRCAT_REMOTE,
gesture="kb:f11",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usually, NVDA gestures include NVDA modifier unless the gesture overrides Windows or application native commands (e.g. control+left/rightArrow). This avoid overriding native Windows or application commands.

f11 is natively used by some applications, e.g.:

  • full screen mode for browsers, Windows Explorer and maybe some other apps
  • debugging commands such as "step into" in some IDEs such as Visual Studio or VBA editor
  • create graphic in Excel
  • and probably other ones

Moreover, the majority of NVDA users do not (and will never) use remote connection feature.

Thus, I'd recommend to define a gesture including NVDA modifier, for example:

Suggested change
gesture="kb:f11",
gesture="kb:NVDA+f11",

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option is just to remove the default gesture assignment for this script, if we consider that only gestures used on the controlled machine need to be assigned.

)
def script_sendKeys(self, gesture):
remoteClient.client.toggleRemoteKeyControl(gesture)


#: The single global commands instance.
#: @type: L{GlobalCommands}
Expand Down
156 changes: 156 additions & 0 deletions source/gui/settingsDialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import gui.contextHelp
import globalVars
from logHandler import log
from remoteClient import configuration
import audio
import audioDucking
import queueHandler
Expand Down Expand Up @@ -3340,6 +3341,159 @@ def onSave(self):
config.conf["addonStore"]["automaticUpdates"] = [x.value for x in AddonsAutomaticUpdate][index]


class RemoteSettingsPanel(SettingsPanel):
# Translators: This is the label for the remote settings category in NVDA Settings screen.
title = _("Remote")
autoconnect: wx.CheckBox
client_or_server: wx.RadioBox
connection_type: wx.RadioBox
host: wx.TextCtrl
port: wx.SpinCtrl
key: wx.TextCtrl
play_sounds: wx.CheckBox
delete_fingerprints: wx.Button

def makeSettings(self, settingsSizer):
self.config = configuration.get_config()
sHelper = gui.guiHelper.BoxSizerHelper(self, sizer=settingsSizer)
self.autoconnect = wx.CheckBox(
parent=self,
id=wx.ID_ANY,
# Translators: A checkbox in add-on options dialog to set whether NVDA should automatically connect to a control server on startup.
label=_("Auto-connect to control server on startup"),
)
self.autoconnect.Bind(wx.EVT_CHECKBOX, self.on_autoconnect)
sHelper.addItem(self.autoconnect)
# Translators: Whether or not to use a relay server when autoconnecting
self.client_or_server = wx.RadioBox(
self,
wx.ID_ANY,
choices=(
# Translators: Use a remote control server
_("Use Remote Control Server"),
# Translators: Host a control server
_("Host Control Server"),
),
style=wx.RA_VERTICAL,
)
self.client_or_server.Bind(wx.EVT_RADIOBOX, self.on_client_or_server)
self.client_or_server.SetSelection(0)
self.client_or_server.Enable(False)
sHelper.addItem(self.client_or_server)
choices = [
# Translators: Radio button to allow this machine to be controlled
_("Allow this machine to be controlled"),
# Translators: Radio button to allow this machine to control another machine
_("Control another machine"),
]
self.connection_type = wx.RadioBox(self, wx.ID_ANY, choices=choices, style=wx.RA_VERTICAL)
self.connection_type.SetSelection(0)
self.connection_type.Enable(False)
sHelper.addItem(self.connection_type)
sHelper.addItem(wx.StaticText(self, wx.ID_ANY, label=_("&Host:")))
self.host = wx.TextCtrl(self, wx.ID_ANY)
self.host.Enable(False)
sHelper.addItem(self.host)
sHelper.addItem(wx.StaticText(self, wx.ID_ANY, label=_("&Port:")))
self.port = wx.SpinCtrl(self, wx.ID_ANY, min=1, max=65535)
self.port.Enable(False)
sHelper.addItem(self.port)
sHelper.addItem(wx.StaticText(self, wx.ID_ANY, label=_("&Key:")))
self.key = wx.TextCtrl(self, wx.ID_ANY)
self.key.Enable(False)
sHelper.addItem(self.key)
# Translators: A checkbox in add-on options dialog to set whether sounds play instead of beeps.
self.play_sounds = wx.CheckBox(self, wx.ID_ANY, label=_("Play sounds instead of beeps"))
sHelper.addItem(self.play_sounds)
# Translators: A button in add-on options dialog to delete all fingerprints of unauthorized certificates.
self.delete_fingerprints = wx.Button(self, wx.ID_ANY, label=_("Delete all trusted fingerprints"))
self.delete_fingerprints.Bind(wx.EVT_BUTTON, self.on_delete_fingerprints)
sHelper.addItem(self.delete_fingerprints)
self.set_from_config()

def on_autoconnect(self, evt: wx.CommandEvent) -> None:
self.set_controls()

def set_controls(self) -> None:
state = bool(self.autoconnect.GetValue())
self.client_or_server.Enable(state)
self.connection_type.Enable(state)
self.key.Enable(state)
self.host.Enable(not bool(self.client_or_server.GetSelection()) and state)
self.port.Enable(bool(self.client_or_server.GetSelection()) and state)

def on_client_or_server(self, evt: wx.CommandEvent) -> None:
evt.Skip()
self.set_controls()

def set_from_config(self) -> None:
cs = self.config["controlserver"]
self_hosted = cs["self_hosted"]
connection_type = cs["connection_type"]
self.autoconnect.SetValue(cs["autoconnect"])
self.client_or_server.SetSelection(int(self_hosted))
self.connection_type.SetSelection(connection_type)
self.host.SetValue(cs["host"])
self.port.SetValue(str(cs["port"]))
self.key.SetValue(cs["key"])
self.set_controls()
self.play_sounds.SetValue(self.config["ui"]["play_sounds"])

def on_delete_fingerprints(self, evt: wx.CommandEvent) -> None:
if (
gui.messageBox(
_(
# Translators: This message is presented when the user tries to delete all stored trusted fingerprints.
"When connecting to an unauthorized server, you will again be prompted to accepts its certificate.",
),
# Translators: This is the title of the dialog presented when the user tries to delete all stored trusted fingerprints.
_("Are you sure you want to delete all stored trusted fingerprints?"),
wx.YES | wx.NO | wx.NO_DEFAULT | wx.ICON_WARNING,
)
== wx.YES
):
self.config["trusted_certs"].clear()
evt.Skip()

def isValid(self) -> bool:
if self.autoconnect.GetValue():
if not self.client_or_server.GetSelection() and (
not self.host.GetValue() or not self.key.GetValue()
):
gui.messageBox(
# Translators: This message is presented when the user tries to save the settings with the host or key field empty.
_("Both host and key must be set in the Remote section."),
# Translators: This is the title of the dialog presented when the user tries to save the settings with the host or key field empty.
_("Remote Error"),
wx.OK | wx.ICON_ERROR,
)
return False
elif self.client_or_server.GetSelection() and not self.port.GetValue() or not self.key.GetValue():
gui.messageBox(
# Translators: This message is presented when the user tries to save the settings with the port or key field empty.
_("Both port and key must be set in the Remote section."),
# Translators: This is the title of the dialog presented when the user tries to save the settings with the port or key field empty.
_("Remote Error"),
wx.OK | wx.ICON_ERROR,
)
return False
return True

def onSave(self):
cs = self.config["controlserver"]
cs["autoconnect"] = self.autoconnect.GetValue()
self_hosted = bool(self.client_or_server.GetSelection())
connection_type = self.connection_type.GetSelection()
cs["self_hosted"] = self_hosted
cs["connection_type"] = connection_type
if not self_hosted:
cs["host"] = self.host.GetValue()
else:
cs["port"] = int(self.port.GetValue())
cs["key"] = self.key.GetValue()
self.config["ui"]["play_sounds"] = self.play_sounds.GetValue()


class TouchInteractionPanel(SettingsPanel):
# Translators: This is the label for the touch interaction settings panel.
title = _("Touch Interaction")
Expand Down Expand Up @@ -5207,6 +5361,8 @@ class NVDASettingsDialog(MultiCategorySettingsDialog):
DocumentNavigationPanel,
AddonStorePanel,
]
if not globalVars.appArgs.secure:
categoryClasses.append(RemoteSettingsPanel)
if touchHandler.touchSupported():
categoryClasses.append(TouchInteractionPanel)
if winVersion.isUwpOcrAvailable():
Expand Down
22 changes: 22 additions & 0 deletions source/remoteClient/__init__.py
ctoth marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2015-2025 NV Access Limited, Christopher Toth, Tyler Spivey, Babbage B.V., David Sexton and others.
# This file is covered by the GNU General Public License.
# See the file COPYING for more details.

from .client import RemoteClient

client: RemoteClient = None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I now realize that client is also a submodule. Interestingly the linter doesn't seem to complain about this.
May be call this remoteClient as well to be sure that there's no name collision.

Suggested change
client: RemoteClient = None
remoteClient: RemoteClient = None



def initialize():
"""Initialise the remote client."""
global client
import globalCommands

client = RemoteClient()
client.registerLocalScript(globalCommands.commands.script_sendKeys)


def terminate():
"""Terminate the remote client."""
client.terminate()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be set back to None?

35 changes: 35 additions & 0 deletions source/remoteClient/beepSequence.py
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copyright header. Also please consider moving this to tones module

Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import collections.abc
import threading
import time
from typing import Tuple, Union

import tones

local_beep = tones.beep

BeepElement = Union[int, Tuple[int, int]] # Either delay_ms or (frequency_hz, duration_ms)
BeepSequence = collections.abc.Iterable[BeepElement]


def beepSequence(*sequence: BeepElement) -> None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this can safely go into the tones module.

"""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
Loading