From 317de66f84bdcd619a3dd5c92fe32f595ca0df59 Mon Sep 17 00:00:00 2001 From: Philipp Hahn Date: Sat, 22 Jun 2024 13:32:38 +0200 Subject: [PATCH] A bunch of fixes and improvemenbs (#289) * test: keys are released in reverse order Since: a4bb988aa8959d9d5ded621bde87dda6cf1ba472 * ci: Add Python 3.12 as supported * ci: Quote shell variables * ci: Move vncdo from /tmp/ to $PWD/.vncdo Hard-coded paths below /tmp/ are a security issue on shared hosts. /tmp/ is (often) cleaned up on reboot. * doc: Fix building documentation * doc: Improve documentation Fix spelling errors. Fix broken links, e.g. https:// everywhere. Fix Sphinx reST markup. * doc: Document API shutdown Issue #288 * refactor: modernize type annotations pyupgrade --py38-plus ruff check --fix setup.py vncdotool/*.py * fix: mouseDrag diagonally move mouse diagonally instead of first going up/down and then left/right. Do not use `time.sleep()` with Twisted reactor as it will also pause all other event processing. Patch `vncev` to print all mouse events, including those where not button is pressed / released. Issue #287 * fixup! ci: Add Python 3.12 as supported * fixup! doc: Improve documentation --- .github/workflows/pythonapp.yml | 2 +- .gitignore | 2 + CHANGELOG.rst | 8 +++ DEVELOP.rst | 9 ++- Makefile | 8 +-- README.rst | 5 +- docs/_static/custom.css | 1 + docs/commands.rst | 2 +- docs/conf.py | 22 +++--- docs/install.rst | 18 +++-- docs/library.rst | 19 +++-- docs/modules.rst | 31 ++++++++ libvncserver.mk | 7 +- requirements-dev.txt | 5 +- requirements.txt | 8 +-- setup.cfg | 1 + tests/functional/test_proxy.py | 2 +- tests/functional/test_send_events.py | 10 ++- vncdotool/api.py | 53 +++++++++----- vncdotool/client.py | 78 ++++++++------------- vncdotool/command.py | 26 +++---- vncdotool/loggingproxy.py | 49 +++++++------ vncdotool/rfb.py | 101 ++++++++++++++------------- 23 files changed, 275 insertions(+), 192 deletions(-) create mode 100644 docs/_static/custom.css diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index a97e80f8..15969d89 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index 0d5d1642..9661fd46 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ vncdotool.egg-info .coverage .tox docs/_build +.venv/ +.vncdo/ *~ #* diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2fd194d1..d479fe1b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,11 @@ +1.3.0 (UNRELEASED) +---------------------- + - Fix functional test suite (@phahn) + - Python 3.12 is supported (@phahn) + - Improve documentation (@phahn) + - Improve PEP-484 type hinting (@phahn) + - Fix mouse dragging (@phahn) + 1.2.0 (2023-06-06) ---------------------- - fixes for api.shutdown and disconnect raise exceptions, #256 diff --git a/DEVELOP.rst b/DEVELOP.rst index 4fdced3b..eb44b9cc 100644 --- a/DEVELOP.rst +++ b/DEVELOP.rst @@ -18,7 +18,7 @@ running. You can either manually configure and update your path or use the prov OR make libvnc-examples - export PATH=$PATH:/tmp/vncdo/libvncserver/examples + export PATH="$PATH:.vncdo/libvncserver-LibVNCServer-0.9.14/examples" python -m unittest discover tests/functional @@ -30,9 +30,8 @@ There is a community effort to document the protcol, _rfbproto_. Preparing a Release ------------------------ 1. ensure CHANGELOG.rst contains correct version - 1. make version-new-version-number - 6. add new section to CHANGELOG.rst - 7. update vncdotool/__init__.py version - 8. blog post/twitter + 1. ``make version-new-version-number`` + 1. ``make release`` + 1. blog post/twitter .. _rfbproto: https://github.com/rfbproto/rfbproto/blob/master/rfbproto.rst diff --git a/Makefile b/Makefile index 0ad562b4..7aeff85b 100644 --- a/Makefile +++ b/Makefile @@ -21,8 +21,8 @@ version: version-%: OLDVERSION:=$(shell ./setup.py --version) version-%: NEWVERSION=$(subst -,.,$*) version-%: - sed -i '' -e s/$(OLDVERSION)/$(NEWVERSION)/ $(VERSION_FILE) - git ci $(VERSION_FILE) -m"bump version to $*" + sed -e "s/$(OLDVERSION)/$(NEWVERSION)/" -i "$(VERSION_FILE)" + git ci -m"bump version to $*" -- "$(VERSION_FILE)" .PHONY: release release: release-test release-tag upload @@ -33,13 +33,13 @@ release-test: test-unit #test-func .PHONY: release-tag release-tag: VERSION:=$(shell ./setup.py --version) release-tag: - git tag -a v$(VERSION) -m"release version $(VERSION)" + git tag -a "v$(VERSION)" -m"release version $(VERSION)" git push --tags .PHONY: upload upload: ./setup.py sdist - twine upload dist/$(shell ./setup.py --fullname).* + twine upload dist/"$(shell ./setup.py --fullname)".* .PHONY: docs docs: diff --git a/README.rst b/README.rst index 667f9dd2..70c03dd2 100644 --- a/README.rst +++ b/README.rst @@ -69,9 +69,9 @@ More documentation can be found on `Read the Docs`_. Feedback -------------------------------- -If you need help getting VNCDoTool working try the community at `_Stackoverflow` +If you need help getting VNCDoTool working try the community at Stackoverflow_\ . -Patches, and ideas for improvements are welcome and appreciated, via `_GitHub` issues. +Patches, and ideas for improvements are welcome and appreciated, via GitHub_ issues. If you are reporting a bug or issue please include the version of both vncdotool and the VNC server you are using it with. @@ -87,5 +87,4 @@ Also, to the TigerVNC_ project for creating a community focus RFB specification .. _Read The Docs: http://vncdotool.readthedocs.org .. _GitHub: http://github.com/sibson/vncdotool .. _TigerVNC: http://sourceforge.net/apps/mediawiki/tigervnc/index.php?title=Main_Page -.. _python-vnc-viewer: http://code.google.com/p/python-vnc-viewer .. _Stackoverflow: https://stackoverflow.com/questions/ask?tags=vncdotool diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 00000000..8ba9869c --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1 @@ +.sphinxsidebarwrapper { overflow-y: scroll; } diff --git a/docs/commands.rst b/docs/commands.rst index c8fb99dd..4a7b11b2 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -29,7 +29,7 @@ mousedown BUTTON ------------------ mousemove X Y ------------ +--------------- mouseup BUTTON ----------------- diff --git a/docs/conf.py b/docs/conf.py index 2d92d24e..12d6b8fd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,13 +12,16 @@ # serve to show the default. # flake8: noqa -import os -import sys +from __future__ import annotations + +#from pathlib import Path +#import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +#sys.path.insert(0, str(Path(__file__).parents[1])) +#autodoc_mock_imports = ["twisted"] # -- General configuration ----------------------------------------------------- @@ -43,16 +46,16 @@ # General information about the project. project = u'VNCDoTool' -copyright = u'2013, Marc Sibson' +copyright = u'2013-2024, Marc Sibson' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.9' +from vncdotool import __version__ as version # The full version, including alpha/beta/rc tags. -release = '0.9.0.dev0' +release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -101,7 +104,9 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +html_theme_options = { + "sidebarwidth": "250px" +} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] @@ -126,6 +131,7 @@ # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] +html_css_files = ["custom.css"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. @@ -174,7 +180,7 @@ # -- Options for LaTeX output -------------------------------------------------- -latex_elements = { +latex_elements: dict[str, str] = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', diff --git a/docs/install.rst b/docs/install.rst index 3c4042eb..1e691793 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -5,7 +5,7 @@ vncdotool is available on PyPI_, so in most cases you should be able to simply r pip install vncdotool -vncdotool relies on a number of libraries, the two major ones are PIL_, the Python Imaging Library and +vncdotool relies on a number of libraries, the two major ones are Pillow_, the Python Imaging Library and Twisted_, an asynchronous networking library. While vncdotool should work with any recent version of these libraries sometimes things break. If you are having issues getting things to work you can try using a stable set of libraries @@ -50,15 +50,13 @@ If you are not familiar with Python, the most reliable way to install vncdotool 5. If Hello World shows up on the remote machine that has a VNC server running then its time to celebrate. Otherwise, first check you can connect from your local machine to the remote using a normal GUI VNC Client. - Once you get the normal GUI client working try vncdotool again and if you still have problems try the community at `_Stackoverflow`. + Once you get the normal GUI client working try vncdotool again and if you still have problems try the community at Stackoverflow_\ . .. _PyPI: https://pypi.python.org/pypi -.. _PIL: http://www.pythonware.com/products/pil/ -.. _PIL Downloads: http://www.pythonware.com/products/pil/ -.. _Official Python: http://python.org/downloads/ -.. _Twisted: http://twistedmatrix.com/ -.. _Twisted Downloads: http://twistedmatrix.com/trac/wiki/Downloads -.. _virtualenv: http://www.virtualenv.org/ -.. _ez_setup.py: https://bitbucket.org/pypa/setuptools/raw/bootstrap/ez_setup.py -.. _get_pip.py: https://raw.github.com/pypa/pip/master/contrib/get-pip.py +.. _Pillow: https://python-pillow.org/ +.. _PIL Downloads: https://pypi.org/project/pillow/ +.. _Official Python: https://python.org/downloads/ +.. _Twisted: https://twistedmatrix.com/ +.. _Twisted Downloads: https://pypi.org/project/Twisted/ +.. _virtualenv: https://virtualenv.pypa.io/ .. _Stackoverflow: https://stackoverflow.com/questions/ask?tags=vncdotool diff --git a/docs/library.rst b/docs/library.rst index 18075b34..3c98435d 100644 --- a/docs/library.rst +++ b/docs/library.rst @@ -1,15 +1,26 @@ Embedding in Python Applications =================================== -vncdotool is built with the Twisted_ framework, as such it best intergrates with other Twisted Applications +vncdotool is built with the Twisted_ framework, as such it best integrates with other Twisted Applications. Rewriting your application to use Twisted may not be an option, so vncdotool provides a compatibility layer. -It uses a separate thread to run the Twisted reactor and communicates with the main program using a threadsafe Queue. +It uses a separate thread to run the Twisted reactor and communicates with the main program using a thread-safe Queue. + +.. warning:: + + While the Twisted reactor runs as a *daemon* thread, the reactor itself will start additional *worker threads*, which are *no daemon threads*. + Therefore the Reactor must be shut down explicitly by calling :func:`vncdotool.api.shutdown`. + Otherwise your application will not terminate as those worker threads remain running in the background. + + This also applied when using the API as a context manager: + As the reactor cannot be restarted, it is a design decision to not shut it down as the end of the context. + That would prevent the API from being used multiple times in the same process. To use the synchronous API you can do the following:: from vncdotool import api client = api.connect('vncserver', password=None) -The first argument passed to the `connect` method is the VNC server to connect to, and it needs to be in the format `address[:display|::port]`. For example:: +The first argument passed to the :func:`~vncdotool.api.connect` method is the VNC server to connect to, and it needs to be in the format ``address[:display|::port]``. +For example:: # connect to 192.168.1.1 on default port 5900 client = api.connect('192.168.1.1', password=None) @@ -38,7 +49,7 @@ It is possible to set a per-client timeout in seconds to prevent calls from bloc except TimeoutError: print('Timeout when capturing screen') -In case of too many timeout errors, it is recommended to reset the client connection via the `disconnect` and `connect` methods. +In case of too many timeout errors, it is recommended to reset the client connection via the `disconnect` and :func:`~vncdotool.api.connect` methods. The :class:`vncdotool.client.VNCDoToolClient` supports the context manager protocol. diff --git a/docs/modules.rst b/docs/modules.rst index 63031400..b94b25f4 100644 --- a/docs/modules.rst +++ b/docs/modules.rst @@ -1,6 +1,14 @@ Code Documentation ====================== +:mod:`api` Module +----------------- + +.. automodule:: vncdotool.api + :members: + :undoc-members: + :show-inheritance: + :mod:`client` Module -------------------- @@ -9,3 +17,26 @@ Code Documentation :undoc-members: :show-inheritance: +:mod:`rfb` Module +----------------- + +.. automodule:: vncdotool.rfb + :members: + :undoc-members: + :show-inheritance: + +:mod:`command` Module +--------------------- + +.. automodule:: vncdotool.command + :members: + :undoc-members: + :show-inheritance: + +:mod:`loggingproxy` Module +-------------------------- + +.. automodule:: vncdotool.loggingproxy + :members: + :undoc-members: + :show-inheritance: diff --git a/libvncserver.mk b/libvncserver.mk index 8730f2f5..5abfaa5b 100644 --- a/libvncserver.mk +++ b/libvncserver.mk @@ -1,5 +1,5 @@ LIBVNCSERVER_VERSION?=0.9.14 -BUILD_DIR?=/tmp/vncdo +BUILD_DIR?=.vncdo PYTHON?=python3 @@ -21,11 +21,11 @@ LIBVNCSERVER_EXAMPLES_SRCS=$(addsuffix .c, $(LIBVNCSERVER_EXAMPLES)) libvnc-examples: $(LIBVNCSERVER_EXAMPLES) .PHONY: veryclean -veryclean: +veryclean:: rm -rf $(BUILD_DIR) .PHONY: clean -clean: +clean:: rm -f $(LIBVNCSERVER_EXAMPLES) @@ -35,6 +35,7 @@ $(BUILD_DIR)/$(LIBVNCSERVER_TGZ): $(LIBVNCSERVER_DIR): $(BUILD_DIR)/$(LIBVNCSERVER_TGZ) tar xfzv $< -C $(BUILD_DIR) + sed -e '/^\s*if(buttonMask)\s*{$/s/buttonMask/1/' -i "$(LIBVNCSERVER_DIR)/examples/vncev.c" $(LIBVNCSERVER_MAKEFILE): $(LIBVNCSERVER_MAKEFILE_SRCS) cd $(LIBVNCSERVER_DIR) && cmake . diff --git a/requirements-dev.txt b/requirements-dev.txt index d6629d06..86ec19f3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,5 @@ -r requirements.txt -pexpect==4.8.0 -pyvirtualdisplay==3.0 +pexpect>=4.8.0 +pyvirtualdisplay>=3.0 +sphinx twine diff --git a/requirements.txt b/requirements.txt index ab57a3d5..a6897eb2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Pillow==9.3.0 -Twisted==22.10.0 -zope.interface==5.4.0 -pycryptodomex==3.12.0 +Pillow>=9.3.0 +Twisted>=22.10.0 +zope.interface>=5.4.0 +pycryptodomex>=3.12.0 diff --git a/setup.cfg b/setup.cfg index c2bc77a8..2949fdce 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,7 @@ classifiers = Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 Topic :: Multimedia :: Graphics :: Viewers Topic :: Software Development :: Testing diff --git a/tests/functional/test_proxy.py b/tests/functional/test_proxy.py index ded0dd39..16b46b56 100644 --- a/tests/functional/test_proxy.py +++ b/tests/functional/test_proxy.py @@ -55,8 +55,8 @@ def test_key_ctrl_a(self) -> None: self.run_vncdo('key ctrl-a') self.assertKeyDown(rfb.KEY_ControlLeft) self.assertKeyDown(ord('a')) - self.assertKeyUp(rfb.KEY_ControlLeft) self.assertKeyUp(ord('a')) + self.assertKeyUp(rfb.KEY_ControlLeft) def test_mouse(self) -> None: self.run_vncdo('move 111 222 click 1') diff --git a/tests/functional/test_send_events.py b/tests/functional/test_send_events.py index 644130bc..b69cfd0a 100644 --- a/tests/functional/test_send_events.py +++ b/tests/functional/test_send_events.py @@ -52,8 +52,8 @@ def test_key_ctrl_a(self) -> None: self.run_vncdo('key ctrl-a') self.assertKeyDown(int(0xffe3)) self.assertKeyDown(ord('a')) - self.assertKeyUp(int(0xffe3)) self.assertKeyUp(ord('a')) + self.assertKeyUp(int(0xffe3)) self.assertDisconnect() def test_type(self) -> None: @@ -65,11 +65,17 @@ def test_type(self) -> None: self.assertDisconnect() def test_mouse_move(self) -> None: - # vncev only prints click events, but will include the position self.run_vncdo('move 10 20 click 1') self.assertMouse(10, 20, 0x1) self.assertDisconnect() + def test_mouse_drag(self) -> None: + self.run_vncdo('move 10 20 drag 30 30 click 1') + self.assertMouse(10, 20, 0x0) + self.assertMouse(20, 25, 0x0) + self.assertMouse(30, 30, 0x1) + self.assertDisconnect() + def test_mouse_click_button_two(self) -> None: self.run_vncdo('click 2') self.assertMouse(0, 0, 0x2) diff --git a/vncdotool/api.py b/vncdotool/api.py index 9201e5bc..2a7a06f2 100644 --- a/vncdotool/api.py +++ b/vncdotool/api.py @@ -1,16 +1,19 @@ """ Helpers to allow vncdotool to be intergrated into other applications. -This feature is under development, your help testing and -debugging is appreciated. +.. warning:: + EXPERIMENTAL. + This feature is under development, your help testing and debugging is appreciated. """ +from __future__ import annotations + import logging import queue import socket import sys import threading from types import TracebackType -from typing import Any, List, Optional, Type, TypeVar +from typing import Any, TypeVar, overload from twisted.internet import reactor from twisted.internet.defer import Deferred, maybeDeferred @@ -23,14 +26,15 @@ V = TypeVar("V") TProxy = TypeVar("TProxy", bound="ThreadedVNCClientProxy") -__all__ = ["connect"] +__all__ = ["shutdown", "connect"] log = logging.getLogger(__name__) -_THREAD: Optional[threading.Thread] = None +_THREAD: threading.Thread | None = None def shutdown() -> None: + """Shutdown background thread running Twisted reactor.""" if not reactor.running: return @@ -43,12 +47,12 @@ def shutdown() -> None: class ThreadedVNCClientProxy: def __init__( - self, factory: Type[VNCDoToolFactory], timeout: Optional[float] = 60 * 60 + self, factory: type[VNCDoToolFactory], timeout: float | None = 60 * 60 ) -> None: self.factory = factory self.queue: queue.Queue[Any] = queue.Queue() self.timeout = timeout - self.protocol: Optional[VNCDoToolClient] = None + self.protocol: VNCDoToolClient | None = None def __enter__(self: TProxy) -> TProxy: return self @@ -114,30 +118,43 @@ def callable_threaded_proxy(*args: Any, **kwargs: Any) -> Any: return callable_threaded_proxy - def __dir__(self) -> List[str]: + def __dir__(self) -> list[str]: return dir(self.__class__) + dir(self.factory.protocol) +@overload +def connect(server: str) -> ThreadedVNCClientProxy: ... +@overload +def connect(server: str, password: str | None) -> ThreadedVNCClientProxy: ... +@overload +def connect(server: str, password: str | None, factory_class: type[VNCDoToolFactory]) -> ThreadedVNCClientProxy: ... +@overload +def connect(server: str, password: str | None, factory_class: type[VNCDoToolFactory], proxy: type[TProxy]) -> TProxy: ... +@overload +def connect(server: str, password: str | None, factory_class: type[VNCDoToolFactory], proxy: type[TProxy], timeout: float | None) -> TProxy: ... +@overload +def connect(server: str, password: str | None, factory_class: type[VNCDoToolFactory], proxy: type[TProxy], timeout: float | None, username: str | None) -> TProxy: ... def connect( server: str, - password: Optional[str] = None, - factory_class: Type[VNCDoToolFactory] = VNCDoToolFactory, - proxy: Type[ThreadedVNCClientProxy] = ThreadedVNCClientProxy, - timeout: Optional[float] = None, - username: Optional[str] = None, + password: str | None = None, + factory_class: type[VNCDoToolFactory] = VNCDoToolFactory, + proxy: type[ThreadedVNCClientProxy] = ThreadedVNCClientProxy, + timeout: float | None = None, + username: str | None = None, ) -> ThreadedVNCClientProxy: """Connect to a VNCServer and return a Client instance that is usable - in the main thread of non-Twisted Python Applications, EXPERIMENTAL. + in the main thread of non-Twisted Python Applications, >>> from vncdotool import api >>> with api.connect('host') as client >>> client.keyPress('c') - You may then call any regular VNCDoToolClient method on client from your + You may then call any regular :py:class:`VNCDoToolClient` method on client from your application code. If you are using a GUI toolkit or other major async library please read - http://twistedmatrix.com/documents/13.0.0/core/howto/choosing-reactor.html + `Choosing a Reactor and GUI Toolkit Integration + `_ for a better method of intergrating vncdotool. """ if not reactor.running: @@ -145,7 +162,9 @@ def connect( sys_excepthook = sys.excepthook def ensure_reactor_stopped( - etype: Type[BaseException], value: BaseException, traceback: TracebackType + etype: type[BaseException], + value: BaseException, + traceback: TracebackType | None, ) -> None: shutdown() sys_excepthook(etype, value, traceback) diff --git a/vncdotool/client.py b/vncdotool/client.py index 09451630..129cfbce 100644 --- a/vncdotool/client.py +++ b/vncdotool/client.py @@ -1,21 +1,21 @@ """ -Twisted based VNC client protocol and factory - -(c) 2010 Marc Sibson - -MIT License +Twisted based VNC client protocol and factory. """ +# (c) 2010-2024 Marc Sibson +# +# MIT License + +from __future__ import annotations import logging import math import socket -import time from pathlib import Path from struct import pack -from typing import IO, Any, List, Optional, TypeVar, Union +from typing import IO, Any, Iterator, TypeVar, Union from twisted.internet import reactor -from twisted.internet.defer import Deferred +from twisted.internet.defer import Deferred, inlineCallbacks, returnValue from twisted.internet.endpoints import HostnameEndpoint, UNIXClientEndpoint from twisted.internet.interfaces import IConnector, ITCPTransport from twisted.python.failure import Failure @@ -153,12 +153,12 @@ class VNCDoToolClient(rfb.RFBClient): x = 0 y = 0 buttons = 0 - screen: Optional[Image.Image] = None + screen: Image.Image | None = None image_mode = PF2IM[rfb.PixelFormat()] - deferred: Optional[Deferred] = None + deferred: Deferred | None = None - cursor: Optional[Image.Image] = None - cmask: Optional[Image.Image] = None + cursor: Image.Image | None = None + cmask: Image.Image | None = None SPECIAL_KEYS_US = '~!@#$%^&*()_+{}|:"<>?' MAX_DESKTOP_SIZE = 0x10000 @@ -173,7 +173,7 @@ def connectionLost(self, reason: Failure) -> None: super().connectionLost(reason) self.factory.clientConnectionLost(self, reason) - def _decodeKey(self, key: str) -> List[int]: + def _decodeKey(self, key: str) -> list[int]: if self.factory.force_caps: if key.isupper() or key in self.SPECIAL_KEYS_US: key = "shift-%c" % key @@ -193,7 +193,7 @@ def pause(self, duration: float) -> Deferred: def keyPress(self: TClient, key: str) -> TClient: """Send a key press to the server - key: string: either [a-z] or a from KEYMAP + :param key: either [a-z] or a from :const:`KEYMAP`. """ keys = self._decodeKey(key) log.debug("keyPress %s", keys) @@ -223,8 +223,7 @@ def keyUp(self: TClient, key: str) -> TClient: def mousePress(self: TClient, button: int) -> TClient: """Send a mouse click at the last set position - button: int: [1-n] - + :param button: [1-n] """ log.debug("mousePress %s", button) self.mouseDown(button) @@ -235,8 +234,7 @@ def mousePress(self: TClient, button: int) -> TClient: def mouseDown(self: TClient, button: int) -> TClient: """Send a mouse button down at the last set position - button: int: [1-n] - + :param button: [1-n] """ log.debug("mouseDown %s", button) self.buttons |= 1 << (button - 1) @@ -247,8 +245,7 @@ def mouseDown(self: TClient, button: int) -> TClient: def mouseUp(self: TClient, button: int) -> TClient: """Send mouse button released at the last set position - button: int: [1-n] - + :param button: [1-n] """ log.debug("mouseUp %s", button) self.buttons &= ~(1 << (button - 1)) @@ -292,9 +289,8 @@ def _captureSave(self: TClient, data: object, fp: TFile, *args: int) -> TClient: def expectScreen(self, filename: str, maxrms: float = 0) -> Deferred: """Wait until the display matches a target image - filename: an image file to read and compare against - maxrms: the maximum root mean square between histograms of the - screen and target image + :param filename: an image file to read and compare against. + :param maxrms: the maximum root mean square between histograms of the screen and target image. """ log.debug("expectScreen %s", filename) return self._expectFramebuffer(filename, 0, 0, maxrms) @@ -349,32 +345,20 @@ def mouseMove(self: TClient, x: int, y: int) -> TClient: self.pointerEvent(x, y, self.buttons) return self - def mouseDrag(self: TClient, x: int, y: int, step: int = 1) -> TClient: + @inlineCallbacks + def mouseDrag(self: TClient, x: int, y: int, step: int = 1) -> Iterator[Deferred]: """Move the mouse point to position (x, y) in increments of step""" log.debug("mouseDrag %d,%d", x, y) - if x < self.x: - xsteps = range(self.x - step, x, -step) - else: - xsteps = range(self.x + step, x, step) - - if y < self.y: - ysteps = range(self.y - step, y, -step) - else: - ysteps = range(self.y + step, y, step) - - for ypos in ysteps: - self.mouseMove(self.x, ypos) - reactor.doPoll(timeout=5) - time.sleep(0.2) - - for xpos in xsteps: - self.mouseMove(xpos, self.y) - reactor.doPoll(timeout=5) - time.sleep(0.2) + ox, oy = self.x, self.y + dx, dy = x - ox, y - oy + dmax = max(abs(dx), abs(dy)) + for s in range(0, dmax, step): + self.mouseMove(ox + dx * s // dmax, oy + dy * s // dmax) + yield self.pause(0.2) self.mouseMove(x, y) - return self + returnValue(self) def setImageMode(self) -> None: """Check support for PixelFormats announced by server or select client supported alternative.""" @@ -453,7 +437,7 @@ def updateRectangle( self.drawCursor() - def commitUpdate(self, rectangles: Optional[List[rfb.Rect]] = None) -> None: + def commitUpdate(self, rectangles: list[rfb.Rect] | None = None) -> None: if self.deferred: d = self.deferred self.deferred = None @@ -527,8 +511,8 @@ def dataReceived(self, data: bytes) -> None: class VNCDoToolFactory(rfb.RFBFactory): - username: Optional[str] = None - password: Optional[str] = None + username: str | None = None + password: str | None = None protocol = VNCDoToolClient shared = True diff --git a/vncdotool/command.py b/vncdotool/command.py index 041ec08c..63b7e3c3 100644 --- a/vncdotool/command.py +++ b/vncdotool/command.py @@ -1,11 +1,12 @@ #!/usr/bin/env python """ -Command line interface to interact with a VNC Server - -(c) 2010 Marc Sibson - -MIT License +Command line interface to interact with a VNC Server. """ +# (c) 2010-2024 Marc Sibson +# +# MIT License + +from __future__ import annotations import getpass import ipaddress @@ -17,7 +18,6 @@ import socket import sys from types import TracebackType -from typing import List, Optional, Tuple, Type from twisted.internet import protocol, reactor from twisted.internet.error import ConnectionDone @@ -38,7 +38,7 @@ class TimeoutError(RuntimeError): def log_exceptions( - type_: Type[BaseException], value: BaseException, tb: Optional[TracebackType] + type_: type[BaseException], value: BaseException, tb: TracebackType | None ) -> None: log.critical("Unhandled exception:", exc_info=(type_, value, tb)) @@ -86,7 +86,7 @@ def errReceived(self, data: bytes) -> None: class VNCDoToolOptionParser(optparse.OptionParser): - def format_help(self, formatter: Optional[optparse.HelpFormatter] = None) -> str: + def format_help(self, formatter: optparse.HelpFormatter | None = None) -> str: result = super().format_help(formatter) result += ( "\n" @@ -121,8 +121,8 @@ class CommandParseError(RuntimeError): def build_command_list( factory: VNCDoCLIFactory, - args: List[str], - delay: Optional[float] = None, + args: list[str], + delay: float | None = None, warp: float = 1.0, incremental_refreshes: bool = False, ) -> None: @@ -225,7 +225,7 @@ def build_command_list( factory.deferred.addCallback(client.pause, delay) -def build_tool(options: optparse.Values, args: List[str]) -> VNCDoCLIFactory: +def build_tool(options: optparse.Values, args: list[str]) -> VNCDoCLIFactory: factory = VNCDoCLIFactory() if options.verbose: @@ -241,7 +241,7 @@ def build_tool(options: optparse.Values, args: List[str]) -> VNCDoCLIFactory: factory, args, options.delay, options.warp, options.incremental_refreshes ) except CommandParseError as exc: - sys.exit(exc) + sys.exit(str(exc)) factory_connect(factory, options.host, options.port, options.address_family) reactor.exit_status = 1 @@ -314,7 +314,7 @@ def setup_logging(options: optparse.Values) -> None: PythonLoggingObserver().start() -def parse_server(server: str) -> Tuple[socket.AddressFamily, str, int]: +def parse_server(server: str) -> tuple[socket.AddressFamily, str, int]: if server.startswith("["): host, sep, server = server[1:].partition("]") if not sep: diff --git a/vncdotool/loggingproxy.py b/vncdotool/loggingproxy.py index 1ea5e3eb..37b0177e 100644 --- a/vncdotool/loggingproxy.py +++ b/vncdotool/loggingproxy.py @@ -1,10 +1,12 @@ +from __future__ import annotations + import logging import os.path import socket import sys import time from struct import unpack, unpack_from -from typing import IO, Callable, List, Optional, Sequence, Tuple, Union +from typing import IO, Callable, Sequence from twisted.internet.protocol import Protocol from twisted.protocols import portforward @@ -21,7 +23,7 @@ class ProtocolError(Exception): class MsgC2S(IntEnumLookup): - """RFC 6143 §7.5. Client-to-Server Messages.""" + """:rfc:`6143` §7.5. Client-to-Server Messages.""" SET_PIXEL_FORMAT = 0 SET_ENCODING = 2 @@ -59,7 +61,10 @@ class MsgC2S(IntEnumLookup): class QemuClientMessage(IntEnumLookup): - """https://github.com/rfbproto/rfbproto/blob/master/rfbproto.rst#qemu-client-message""" + """ + `QEMU Client Message + `_ + """ EXTENDED_KEY_EVENT = 0 AUDIO = 1 @@ -78,7 +83,7 @@ class QemuClientMessage(IntEnumLookup): class RFBServer(Protocol): # type: ignore[misc] - _handler: Tuple[Callable[..., None], int] = (lambda data: None, 0) + _handler: tuple[Callable[..., None], int] = (lambda data: None, 0) def connectionMade(self) -> None: super().connectionMade() @@ -217,11 +222,11 @@ def loseConnection(self) -> None: class VNCLoggingClient(VNCDoToolClient): - """Specialization of a VNCDoToolClient that will save screen captures""" + """Specialization of a :class:`VNCDoToolClient` that will save screen captures.""" - capture_file: Optional[str] = None + capture_file: str | None = None - def commitUpdate(self, rectangles: Optional[List[Rect]] = None) -> None: + def commitUpdate(self, rectangles: list[Rect] | None = None) -> None: if self.capture_file: assert self.screen is not None self.screen.save(self.capture_file) @@ -230,16 +235,18 @@ def commitUpdate(self, rectangles: Optional[List[Rect]] = None) -> None: class VNCLoggingClientProxy(portforward.ProxyClient): # type: ignore[misc] - """Accept data from a server and forward to logger and downstream client + """Accept data from a server and forward to logger and downstream client. - VNC server -> VNCLoggingClientProxy -> VNC client - -> VNCLoggingClient + :: + + VNC server -> VNCLoggingClientProxy -> VNC client + -> VNCLoggingClient """ - vnclog: Optional[VNCLoggingClient] = None + vnclog: VNCLoggingClient | None = None ncaptures = 0 - def startLogging(self, peer: "VNCLoggingServerProxy") -> None: + def startLogging(self, peer: VNCLoggingServerProxy) -> None: self.vnclog = VNCLoggingClient() self.vnclog.transport = NullTransport() self.vnclog.factory = self.peer.factory @@ -260,23 +267,25 @@ class VNCLoggingClientFactory(portforward.ProxyClientFactory): # type: ignore[m class VNCLoggingServerProxy(portforward.ProxyServer, RFBServer): # type: ignore[misc] - """Proxy in the middle, decodes and logs RFB messages before sending them upstream + """Proxy in the middle, decodes and logs RFB messages before sending them upstream. + + :: - VNC client -> VNCLoggingServerProxy -> VNC server - -> RFBServer + VNC client -> VNCLoggingServerProxy -> VNC server + -> RFBServer """ clientProtocolFactory = VNCLoggingClientFactory - server: Optional[str] = None + server: str | None = None buttons = 0 - recorder: Optional[Callable[[str], int]] = None + recorder: Callable[[str], int] | None = None def connectionMade(self) -> None: log.info("new connection from %s", self.transport.getPeer().host) super().connectionMade() RFBServer.connectionMade(self) - self.mouse: Tuple[Optional[int], Optional[int]] = (None, None) + self.mouse: tuple[int | None, int | None] = (None, None) self.last_event = time.time() self.recorder = self.factory.getRecorder() @@ -337,8 +346,8 @@ class VNCLoggingServerFactory(portforward.ProxyFactory): # type: ignore[misc] password_required = False - output: Union[IO[str], str] = sys.stdout - _out: Optional[IO[str]] = None + output: IO[str] | str = sys.stdout + _out: IO[str] | None = None def getRecorder(self) -> Callable[[str], int]: if isinstance(self.output, str): diff --git a/vncdotool/rfb.py b/vncdotool/rfb.py index 54a2301f..9e28ff7b 100644 --- a/vncdotool/rfb.py +++ b/vncdotool/rfb.py @@ -1,16 +1,17 @@ """ RFB protocol implementattion, client side. -Override RFBClient and RFBFactory in your application. +Override :class:`RFBClient` and :class:`RFBFactory` in your application. See vncviewer.py for an example. Reference: http://www.realvnc.com/docs/rfbproto.pdf - -(C) 2003 cliechti@gmx.net - -MIT License """ +# (C) 2003 cliechti@gmx.net +# +# MIT License + +from __future__ import annotations import getpass import os @@ -24,10 +25,8 @@ Callable, ClassVar, Collection, - Dict, Iterator, List, - Optional, Tuple, cast, ) @@ -55,13 +54,13 @@ def lookup(cls, value: int) -> object: class Encoding(IntEnumLookup): - """encoding-type for SetEncodings()""" + """encoding-type for :meth:`setEncodings`.""" @staticmethod def s32(value: int) -> int: return value - 0x1_0000_0000 if value >= 0x8000_0000 else value - def __new__(cls, value: int) -> "Encoding": + def __new__(cls, value: int) -> Encoding: return int.__new__(cls, cls.s32(value)) @classmethod @@ -182,7 +181,7 @@ def lookup(cls, value: int) -> object: class HextileEncoding(IntFlag): - """RFC 6153 §7.7.4. Hextile Encoding.""" + """:rfc:`6153` §7.7.4. Hextile Encoding.""" RAW = 1 BACKGROUND_SPECIFIED = 2 @@ -192,7 +191,7 @@ class HextileEncoding(IntFlag): class AuthTypes(IntEnumLookup): - """RFC 6143 §7.1.2. Security Handshake.""" + """:rfc:`6143` §7.1.2. Security Handshake.""" INVALID = 0 NONE = 1 @@ -237,7 +236,7 @@ class AuthTypes(IntEnumLookup): class MsgS2C(IntEnumLookup): - """RFC 6143 §7.6. Server-to-Client Messages.""" + """:rfc:`6143` §7.6. Server-to-Client Messages.""" FRAMEBUFFER_UPDATE = 0 SET_COLOUR_MAP_ENTRIES = 1 @@ -342,7 +341,7 @@ class MsgS2C(IntEnumLookup): @dataclass(frozen=True) class PixelFormat: - """RFC 6143 §7.4. Pixel Format Data Structure""" + """:rfc:`6143` §7.4. Pixel Format Data Structure.""" bpp: int = 32 # u8: bits-per-pixel depth: int = 24 # u8 @@ -379,7 +378,7 @@ def bypp(self) -> int: # bytes-per-pixel return (7 + self.bpp) // 8 @classmethod - def from_bytes(cls, block: bytes) -> "PixelFormat": + def from_bytes(cls, block: bytes) -> PixelFormat: return cls(*cls.STRUCT.unpack(block)) def to_bytes(self) -> bytes: @@ -467,8 +466,8 @@ def __init__(self) -> None: self._packet = bytearray() self._handler = self._handleInitial self._expected_len = 12 - self._expected_args: Tuple[Any, ...] = () - self._expected_kwargs: Dict[str, Any] = {} + self._expected_args: tuple[Any, ...] = () + self._expected_kwargs: dict[str, Any] = {} self._already_expecting = False self._version: Ver = (0, 0) self._version_server: Ver = (0, 0) @@ -682,7 +681,7 @@ def _handleConnection(self, block: bytes) -> None: def _handleFramebufferUpdate(self, block: bytes) -> None: (self.rectangles,) = unpack("!xH", block) - self.rectanglePos: List[Rect] = [] + self.rectanglePos: list[Rect] = [] self.beginUpdate() self._doConnection() @@ -811,14 +810,14 @@ def _handleDecodeCORRERectangles(self, block: bytes, topx: int, topy: int) -> No def _doNextHextileSubrect( self, - bg: Optional[bytes], - color: Optional[bytes], + bg: bytes | None, + color: bytes | None, x: int, y: int, width: int, height: int, - tx: Optional[int], - ty: Optional[int], + tx: int | None, + ty: int | None, ) -> None: # ~ print("_doNextHextileSubrect %r" % ((color, x, y, width, height, tx, ty),)) # coords of next tile @@ -992,8 +991,8 @@ def _handleDecodeHextileRAW( def _handleDecodeHextileSubrectsColoured( self, block: bytes, - bg: Optional[bytes], - color: Optional[bytes], + bg: bytes | None, + color: bytes | None, subrects: int, x: int, y: int, @@ -1061,8 +1060,8 @@ def _handleDecodeZRLE( ) -> None: """ Handle ZRLE encoding. - See https://tools.ietf.org/html/rfc6143#section-7.7.6 (ZRLE) - and https://tools.ietf.org/html/rfc6143#section-7.7.5 (TRLE) + See `ZRLW `_ + and `TRLE `_ """ (compressed_bytes,) = unpack("!L", block) self.expect(self._handleDecodeZRLEdata, compressed_bytes, x, y, width, height) @@ -1264,8 +1263,8 @@ def framebufferUpdateRequest( self, x: int = 0, y: int = 0, - width: Optional[int] = None, - height: Optional[int] = None, + width: int | None = None, + height: int | None = None, incremental: bool = False, ) -> None: if width is None: @@ -1276,7 +1275,7 @@ def framebufferUpdateRequest( def keyEvent(self, key: int, down: bool = True) -> None: """For most ordinary keys, the "keysym" is the same as the corresponding ASCII value. - Other common keys are shown in the KEY_ constants.""" + Other common keys are shown in the ``KEY_`` constants.""" self.transport.write(pack("!BBxxI", 4, down, key)) def pointerEvent(self, x: int, y: int, buttonmask: int = 0) -> None: @@ -1302,7 +1301,7 @@ def vncConnectionMade(self) -> None: typicaly, the pixel format is set here.""" def vncRequestPassword(self) -> None: - """a password is needed to log on, use sendPassword() to + """a password is needed to log on, use :meth:`sendPassword` to send one.""" if self.factory.password is None: log.msg("need a password") @@ -1316,22 +1315,26 @@ def vncAuthFailed(self, reason: Failure) -> None: log.msg(f"Cannot connect {reason}") def beginUpdate(self) -> None: - """called before a series of updateRectangle(), - copyRectangle() or fillRectangle().""" + """called before a series of :meth:`updateRectangle`, + :meth:`copyRectangle` or :meth:`fillRectangle`.""" - def commitUpdate(self, rectangles: Optional[List[Rect]] = None) -> None: - """called after a series of updateRectangle(), copyRectangle() - or fillRectangle() are finished. - typicaly, here is the place to request the next screen - update with FramebufferUpdateRequest(incremental=1). - argument is a list of tuples (x,y,w,h) with the updated - rectangles.""" + def commitUpdate(self, rectangles: list[Rect] | None = None) -> None: + """called after a series of :meth:`updateRectangle`, :meth:`copyRectangle` + or :meth:`fillRectangle` are finished. + + Typicaly, here is the place to request the next screen + update with :meth:`framebufferUpdateRequest` with ``incremental=True``. + + :param rectangles: a list of tuples (x,y,w,h) with the updated rectangles. + """ def updateRectangle( self, x: int, y: int, width: int, height: int, data: bytes ) -> None: - """new bitmap data. data is a string in the pixel format set - up earlier.""" + """new bitmap data. + + :param data: bytes in the pixel format set up earlier. + """ def copyRectangle( self, srcx: int, srcy: int, x: int, y: int, width: int, height: int @@ -1342,8 +1345,10 @@ def copyRectangle( def fillRectangle( self, x: int, y: int, width: int, height: int, color: bytes ) -> None: - """fill the area with the color. the color is a string in - the pixel format set up earlier""" + """fill the area with the color. + + :param color: bytes in the pixel format set up earlier. + """ # fallback variant, use update recatngle # override with specialized function for better performance self.updateRectangle(x, y, width, height, color * width * height) @@ -1356,7 +1361,7 @@ def updateCursor( def updateDesktopSize(self, width: int, height: int) -> None: """New desktop size of width*height.""" - def set_color_map(self, first: int, colors: List[Tuple[int, int, int]]) -> None: + def set_color_map(self, first: int, colors: list[tuple[int, int, int]]) -> None: """The server is using a new color map.""" def bell(self) -> None: @@ -1374,13 +1379,15 @@ class RFBFactory(protocol.ClientFactory): # type: ignore[misc] # should be overriden by application to use a derrived class protocol = RFBClient - def __init__(self, password: Optional[str] = None, shared: bool = False) -> None: + def __init__(self, password: str | None = None, shared: bool = False) -> None: self.password = password self.shared = shared def _vnc_des(password: str) -> bytes: - """RFB protocol for authentication requires client to encrypt + """Custom DES variant for RFB protocol. + + RFB protocol for authentication requires client to encrypt challenge sent by server with password using DES method. However, bits in each byte of the password are put in reverse order before using it as encryption key.""" @@ -1402,8 +1409,8 @@ class RFBTest(RFBClient): def vncConnectionMade(self) -> None: print(f"Screen format: {self.pixel_format}") print(f"Desktop name: {self.name!r}") - self.SetEncodings([Encoding.RAW]) - self.FramebufferUpdateRequest() + self.setEncodings([Encoding.RAW]) + self.framebufferUpdateRequest() def updateRectangle( self, x: int, y: int, width: int, height: int, data: bytes