diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..51110ea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.9-slim-buster +RUN apt-get update +RUN apt-get install -y \ + libdbus-1-3 \ + libfontconfig \ + libgl1-mesa-glx \ + libglib2.0-0 \ + libxcb-icccm4 \ + libxcb-image0 \ + libxkbcommon-x11-0 +RUN apt-get clean +WORKDIR /rmview +COPY resources.qrc setup.cfg setup.py ./ +COPY assets ./assets +COPY bin ./bin +COPY src ./src +RUN pip install --upgrade pip +# TODO: setup.py could to be fixed to include install_requires +# see also: https://stackoverflow.com/q/21915469/543875 +RUN pip install pyqt5==5.14.2 paramiko twisted +RUN pip install .[tunnel] +RUN pip cache purge +CMD rmview diff --git a/README.md b/README.md index 691842e..309ed2f 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,15 @@ * Demo [:rocket: here][demo] * Fast streaming of the screen of your reMarkable to a window in your computer * Support for reMarkable 1 and 2 +* Works with software version pre 2.7 +* Compatible with ScreenShare (post 2.9) * UI for zooming, panning, rotating * Pen tracking: a pointer follows the position of the pen when hovering on the reMarkable * Clone a frame into separate window for reference * Save screenshots as PNG -> :warning: **For reMarkable 2 users** :warning:: -> rMview should work out of the box with the stock firmware. -> If you use [`rm2fb`](https://github.com/ddvk/remarkable2-framebuffer) there are known compatibilities issues that are [being addressed](https://github.com/pl-semiotics/rM-vnc-server/issues/5). +> :warning: **Update 2.9+ users** :warning:: +> To use rmview with the ScreenShare feature you have to **first** start the ScreenShare from the tablet, and then start rmview. @@ -42,6 +43,9 @@ The easiest installation method is by using `pip`, from the root folder of this (please note the command ends with a dot) which will install all required dependencies and install a new `rmview` command. +If you want to use the SSH tunnel feature, install with + + pip install ".[tunnel]" Then, from anywhere, you can execute `rmview` from the command line. The tool will ask for the connection parameters and then ask permission to install the VNC server on the tablet. @@ -55,6 +59,7 @@ Install the dependencies ([PyQt5][pyqt5], [Paramiko][paramiko], [Twisted][twiste # install dependencies pip install pyqt5 paramiko twisted + pip install sshtunnel # optional # build resources file pyrcc5 -o src/rmview/resources.py resources.qrc @@ -68,6 +73,12 @@ On the reMarkable itself you need to install [rM-vnc-server][vnc] by copying the Then you can run the program with `python -m rmview`. +### Using Docker + +This project contains a `Dockerfile` so that `rmview` and all its dependencies can be installed and run inside a Docker container. +Since `rmview` not only reads your local configuration but also needs an X11 display, you should run `docker-run.sh` which takes care of the host mappings. +Please note that `docker-run.sh` is written for Unix-like OSes and expects your rmview configuration inside your local `$HOME/.config/rmview/` folder. + ## Usage and configuration **Suggested first use:** @@ -78,9 +89,10 @@ the default configuration file which you can edit according to the documentation More generally, you can invoke the program with - rmview [config] + rmview [-v|-q] [config] -the optional `config` parameter is the filename of a json configuration file. +The optional `-v` flag makes the console output verbose, `-q` makes it quiet (only errors). +The optional `config` parameter is the filename of a json configuration file. If the parameter is not found, the program will look for a `rmview.json` file in the current directory, or, if not found, for the path stored in the environment variable `RMVIEW_CONF`. If none are found, or if the configuration is underspecified, the tool is going to prompt for address/password. @@ -93,6 +105,7 @@ All the settings are optional. | Setting key | Values | Default | | ------------------------ | ------------------------------------------------------- | ------------- | | `ssh` | Connection parameters (see below) | `{}` | +| `backend` | `"auto"`, `"screenshare"`, `"vncserver"` | `"auto"` | | `orientation` | `"landscape"`, `"portrait"`, `"auto"` | `"landscape"` | | `pen_size` | diameter of pointer in px | `15` | | `pen_color` | color of pointer and trail | `"red"` | @@ -100,6 +113,17 @@ All the settings are optional. | `background_color` | color of window | `"white"` | | `hide_pen_on_press` | if true, the pointer is hidden while writing | `true` | | `show_pen_on_lift` | if true, the pointer is shown when lifting the pen | `true` | +| `forward_mouse_events` | Send mouse events to tablet (see below) | `false` | + +**PLEASE NOTE:** +When `backend` is `auto`, if the tablet is using version 2.9 and above then `screenshare` is used; +otherwise `vncserver` is selected. +Note that currently `screenshare` is only compatible with version 2.9 and above, +and `vncserver` with version 2.6 and below. + +If `forward_mouse_events` is enabled, clicks and mouse drags on the main window +will be sent to the tablet as touch events, +mouse drags while pressing CTRL will be sent as pen events, allowing drawing. Connection parameters are provided as a dictionary with the following keys (all optional): @@ -113,6 +137,8 @@ Connection parameters are provided as a dictionary with the following keys (all | `key` | Local path to key for ssh | not needed if password provided | | `timeout` | Connection timeout in seconds | default: 1 | | `host_key_policy` | `"ask"`, `"ignore_new"`, `"ignore_all"`, `"auto_add"` | default: `"ask"` (description below) | +| `tunnel` | True to connect to VNC server over a local SSH tunnel | default: `false` (description below) | +| `tunnel_compression` | True to enable compression for SSH tunnel | default: `false` (description below) | The `address` parameter can be either: - a single string, in which case the address is used for connection @@ -121,6 +147,7 @@ The `address` parameter can be either: To establish a connection with the tablet, you can use any of the following: - Leave `auth_method`, `password` and `key` unspecified: this will ask for a password - Specify `"auth_method": "key"` to use a SSH key. In case an SSH key hasn't already been associated with the tablet, you can provide its path with the `key` setting. + If key is password protected, you can specify key passphrase using `password` parameter. - Provide a `password` in settings If `auth_method` is `password` but no password is specified, then the tool will ask for the password on connection. @@ -141,19 +168,45 @@ The old `"insecure_auto_add_host": true` parameter is deprecated and equivalent In case your `~/.ssh/known_hosts` file contains the relevant key associations, rMview should pick them up. If you use the "Add/Update" feature when prompted by rMview (for example after a tablet update) then `~/.ssh/known_hosts` will be ignored from then on. - :warning: **Key format error:** If you get an error when connect using a key, but the key seems ok when connecting manually with ssh, you probably need to convert the key to the PEM format (or re-generate it using the `-m PEM` option of `ssh-keygen`). See [here](https://github.com/paramiko/paramiko/issues/340#issuecomment-492448662) for details. +NOTE: If you have a lot of known hosts in system known hosts file (`~/.ssh/known_hosts`), you are advised to add +known host entry for remarkable to `~/.config/rmview_known_hosts` because paramiko can be very slow when loading +large known hosts file which slows down the whole connection routine. + +If your user system known hosts file already contains entry for remarkable, you can add it to rmview specific +hosts file using this command: + +```bash +cat ~/.ssh/known_hosts | grep 10.11.99.1 >> ~/.config/rmview_known_hosts +``` + +You should of course replace IP with your remarkable IP. + +### Note on security and using an SSH tunnel + +By default, this program will start VNC server on remarkable which listens on all the interfaces and doesn't expose +any authentication mechanism or uses encryption. + +This program will then connect to the VNC server over the IP specified in the config. + +Not using any authentication and exposing VNC server on all the network interfaces may be OK when connecting to the +remarkable over USB interface, but when you are connecting to remarkable over WLAN, you are strongly encouraged to +use built-in SSH tunnel functionality. + +When SSH tunnel functionality is used, VNC server which is started on remarkable will only listen on localhost, this +program will create SSH tunnel to the remarkable and connect to the VNC server over the local SSH tunnel. + +This means that the connection will be encrypted and existing SSH authentication will be used. ## To Do - [ ] Settings dialog - [ ] About dialog - - [ ] Pause stream of screen/pen + - [x] Pause stream of screen/pen - [ ] Binary bundles for Window, Linux and MacOs (PyInstaller?) - [ ] Add interaction for Lamy button? (1 331 1 down, 1 331 0 up) - - [ ] Remove dependency to Twisted in `vnc` branch ## Legacy reStreamer-like version diff --git a/assets/connecting.png b/assets/connecting.png new file mode 100644 index 0000000..ae440a6 Binary files /dev/null and b/assets/connecting.png differ diff --git a/docker-run.sh b/docker-run.sh new file mode 100755 index 0000000..1f5fb69 --- /dev/null +++ b/docker-run.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +CONFIG_DIR=$HOME/.config/rmview +mkdir -p $CONFIG_DIR +xhost local:root +docker build -t rmview . +docker run \ + --env DISPLAY=$DISPLAY \ + --network host \ + --volume $CONFIG_DIR:/root/.config \ + --volume /tmp/.X11-unix:/tmp/.X11-unix \ + rmview diff --git a/example_ssh_key_auth_with_ssh_tunnel.json b/example_ssh_key_auth_with_ssh_tunnel.json new file mode 100644 index 0000000..cda71fa --- /dev/null +++ b/example_ssh_key_auth_with_ssh_tunnel.json @@ -0,0 +1,17 @@ +{ + "ssh": { + "timeout": 4, + "address": "192.168.160.100", + "username": "root", + "auth_method": "key", + "key": "/home/user/.ssh/id_rsa_remarkable", + "password": "ssh key passphrase", + "tunnel": true + }, + "orientation": "auto", + "pen_size": 15, + "pen_color": "red", + "pen_trail": 200, + "background_color": "white", + "hide_pen_on_press": true +} diff --git a/resources.qrc b/resources.qrc index c88190d..801adef 100644 --- a/resources.qrc +++ b/resources.qrc @@ -4,7 +4,8 @@ assets/tablet.svg assets/dead.svg assets/problem.svg + assets/connecting.png bin/rM1-vnc-server-standalone bin/rM2-vnc-server-standalone - \ No newline at end of file + diff --git a/setup.cfg b/setup.cfg index 76abe14..8440f46 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,10 @@ +[pycodestyle] +max-line-length = 88 + [options] package_dir= =src packages=find: [options.packages.find] -where=src \ No newline at end of file +where=src diff --git a/setup.py b/setup.py index 86c4587..ca1b90f 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def run(self): setup( name='rmview', - version='2.1', + version='2.2', url='https://github.com/bordaigorl/rmview', description='rMview: a fast live viewer for reMarkable', author='bordaigorl', @@ -42,6 +42,7 @@ def run(self): ], packages=['rmview'], install_requires=['pyqt5', 'paramiko', 'twisted'], + extras_require = { 'tunnel': ['sshtunnel'] }, entry_points={ 'console_scripts':['rmview = rmview.rmview:rmViewMain'] }, diff --git a/src/rmview/connection.py b/src/rmview/connection.py index 3f00e6c..5b7eb47 100644 --- a/src/rmview/connection.py +++ b/src/rmview/connection.py @@ -6,6 +6,7 @@ import paramiko import struct import time +import re from binascii import hexlify import sys @@ -60,60 +61,103 @@ class rMConnectSignals(QObject): class rMConnect(QRunnable): _exception = None + _known_hosts = None - def __init__(self, address='10.11.99.1', username='root', password=None, key=None, timeout=1, + def __init__(self, address='10.11.99.1', username='root', password=None, key=None, timeout=3, onConnect=None, onError=None, host_key_policy=None, known_hosts=None, **kwargs): super(rMConnect, self).__init__() + self.address = address + self.username = username + self.password = password + self.timeout = timeout + self.host_key_policy = host_key_policy + self._known_hosts = known_hosts + + if key is not None: + key = os.path.expanduser(key) + + if password: + # password protected key file, password provided in the config + self.pkey = paramiko.RSAKey.from_private_key_file(key, password=password) + else: + try: + self.pkey = paramiko.RSAKey.from_private_key_file(key) + except paramiko.ssh_exception.PasswordRequiredException: + passphrase, ok = QInputDialog.getText(None, "Configuration","SSH key passphrase:", + QLineEdit.Password) + if ok: + self.pkey = paramiko.RSAKey.from_private_key_file(key, password=passphrase) + else: + raise Exception("A passphrase for SSH key is required") + else: + self.pkey = None + if self.password is None: + log.warning("No key nor password given. System-wide SSH connection parameters are going to be used.") + + self.signals = rMConnectSignals() + + self.client = None + self._exception = None + if callable(onConnect): self.signals.onConnect.connect(onConnect) if callable(onError): self.signals.onError.connect(onError) + def _initialize(self): + # NOTE: Loading system known hosts can take a long time that's why it should happen inside + # run() so it doesn't block the main qt render loop which will cause main QT window to freeze + # until the loading completes. try: self.client = paramiko.SSHClient() - if host_key_policy != "ignore_all": - if known_hosts and os.path.isfile(known_hosts): - self.client.load_host_keys(known_hosts) - log.info("LOADED %s", known_hosts) + if self.host_key_policy != "ignore_all": + if self._known_hosts and os.path.isfile(self._known_hosts): + log.info("Using known hosts file: %s" % (self._known_hosts)) + self.client.load_host_keys(self._known_hosts) + log.info("Loaded known hosts from %s", self._known_hosts) else: + log.info("Using system default known hosts file") + log.info("Loading system default known hosts file, this may take a while...") # ideally we would want to always load the system ones # and have the local keys have precedence, but paramiko gives # always precedence to system keys + # There is extremly slow in system with many known host entries... :/ + # See https://github.com/paramiko/paramiko/issues/191 self.client.load_system_host_keys() + log.info("System default known host file loaded") - - policy = HOST_KEY_POLICY.get(host_key_policy, RejectNewHostKey) + policy = HOST_KEY_POLICY.get(self.host_key_policy, RejectNewHostKey) self.client.set_missing_host_key_policy(policy()) - if key is not None: - key = os.path.expanduser(key) - try: - pkey = paramiko.RSAKey.from_private_key_file(key) - except paramiko.ssh_exception.PasswordRequiredException: - passphrase, ok = QInputDialog.getText(None, "Configuration","SSH key passphrase:", QLineEdit.Password) - if ok: - pkey = paramiko.RSAKey.from_private_key_file(key, password=passphrase) - else: - raise Exception("A passphrase for SSH key is required") - else: - pkey = None - if password is None: - log.warning("No key nor password given. System-wide SSH connection parameters are going to be used.") - self.options = { - 'username': username, - 'password': password, - 'pkey': pkey, - 'timeout': timeout, + 'username': self.username, + 'password': self.password, + 'pkey': self.pkey, + 'timeout': self.timeout, } except Exception as e: self._exception = e + def _getVersion(self): + _, out, _ = self.client.exec_command("cat /sys/devices/soc0/machine") + rmv = out.read().decode("utf-8") + version = re.fullmatch(r"reMarkable(?: Prototype)? (\d+)(\.\d+)*\n", rmv) + if version is not None: + version = int(version[1]) + return version, rmv.strip() + + def _getSwVersion(self): + _, out, _ = self.client.exec_command("cat /etc/version") + return int(out.read().decode("utf-8")) + + @pyqtSlot() def run(self): + self._initialize() + if self._exception is not None: self.signals.onError.emit(self._exception) log.debug('Aborting connection: %s', self._exception) @@ -123,11 +167,18 @@ def run(self): self.client.connect(self.address, **self.options) log.info("Connected to {}".format(self.address)) self.client.hostname = self.address + self.client.deviceVersion, self.client.fullDeviceVersion = self._getVersion() + self.client.softwareVersion = self._getSwVersion() self.signals.onConnect.emit(self.client) except Exception as e: log.error("Could not connect to %s: %s", self.address, e) log.info("Please check your remarkable is connected and retry.") self.signals.onError.emit(e) + try: + if self._known_hosts and os.path.isfile(self._known_hosts): + self.client.save_host_keys(self._known_hosts) + except Exception as e: + log.warning("Could not save known keys at '%s'" % self._known_hosts) log.debug('Stopping connection worker') diff --git a/src/rmview/pentracker.py b/src/rmview/pentracker.py new file mode 100644 index 0000000..f6821ee --- /dev/null +++ b/src/rmview/pentracker.py @@ -0,0 +1,104 @@ +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * +from PyQt5.QtCore import * + +from .rmparams import * + +import paramiko +import struct +import time + +import sys +import os +import logging + +log = logging.getLogger('rmview') + + +class PenTrackerSignals(QObject): + onFatalError = pyqtSignal(Exception) + onPenMove = pyqtSignal(int, int) + onPenPress = pyqtSignal() + onPenLift = pyqtSignal() + onPenNear = pyqtSignal() + onPenFar = pyqtSignal() + + +LIFTED = 0 +PRESSED = 1 + + +class PenTracker(QRunnable): + + _stop = False + + def __init__(self, ssh, path="/dev/input/event0", threshold=1000): + super(PenTracker, self).__init__() + self.event = path + self.ssh = ssh + self.threshold = threshold + self.signals = PenTrackerSignals() + + @pyqtSlot() + def pause(self): + self.signals.blockSignals(True) + + @pyqtSlot() + def resume(self): + self.signals.blockSignals(False) + + def stop(self): + self._penkill.write('\n') + self._stop = True + + @pyqtSlot() + def run(self): + penkill, penstream, _ = self.ssh.exec_command('cat %s & { read ; kill %%1; }' % self.event) + self._penkill = penkill + new_x = new_y = False + state = LIFTED + + while not self._stop: + try: + _, _, e_type, e_code, e_value = struct.unpack('2IHHi', penstream.read(16)) + except struct.error: + return + except Exception as e: + log.error('Error in pointer worker: %s %s', type(e), e) + return + + # decoding adapted from remarkable_mouse + if e_type == e_type_abs: + + # handle x direction + if e_code == e_code_stylus_xpos: + x = e_value + new_x = True + + # handle y direction + if e_code == e_code_stylus_ypos: + y = e_value + new_y = True + + # handle draw + if e_code == e_code_stylus_pressure: + if e_value > self.threshold: + if state == LIFTED: + log.debug('PRESS') + state = PRESSED + self.signals.onPenPress.emit() + else: + if state == PRESSED: + log.debug('RELEASE') + state = LIFTED + self.signals.onPenLift.emit() + + if new_x and new_y: + self.signals.onPenMove.emit(x, y) + new_x = new_y = False + + if e_type == e_type_key and e_code == e_code_stylus_proximity: + if e_value == 0: + self.signals.onPenFar.emit() + else: + self.signals.onPenNear.emit() diff --git a/src/rmview/rfb.py b/src/rmview/rfb.py index bbf0d26..ca16b38 100644 --- a/src/rmview/rfb.py +++ b/src/rmview/rfb.py @@ -174,7 +174,7 @@ def __init__(self): def _handleInitial(self): buffer = b''.join(self._packet) if b'\n' in buffer: - version = 3.3 + version = 3.8 if buffer[:3] == b'RFB': version_server = float(buffer[3:-1].replace(b'0', b'')) SUPPORTED_VERSIONS = (3.3, 3.7, 3.8) @@ -195,6 +195,7 @@ def _handleInitial(self): self._handler = self._handleExpected self._version = version self._version_server = version_server + if version < 3.7: self.expect(self._handleAuth, 4) else: @@ -212,7 +213,7 @@ def _handleNumberSecurityTypes(self, block): def _handleSecurityTypes(self, block): types = unpack("!%dB" % len(block), block) - SUPPORTED_TYPES = (1, 2) + SUPPORTED_TYPES = (1, 2, 100) valid_types = [sec_type for sec_type in types if sec_type in SUPPORTED_TYPES] if valid_types: sec_type = max(valid_types) @@ -222,11 +223,29 @@ def _handleSecurityTypes(self, block): self._doClientInitialization() else: self.expect(self._handleVNCAuthResult, 4) + elif sec_type == 100: + self.expect(self._handleRMAuth,4) else: self.expect(self._handleVNCAuth, 16) else: log.msg("unknown security types: %s" % repr(types)) + def _handleRMAuth(self, block): + #4 zero bytes ignored + + #TODO: the security is not checked atm, so an empty challenged is sent + #the algo for the challenge is a sha256(timestamp+sha256(usedId)) + #the timestamp comes from the udp broadcast on port 5901 + self.transport.write(pack("!I", 32)) #challenge length + self.transport.write(b'\x00'*32) #challenge + self.expect(self._handleRMResult, 1) + + def _handleRMResult(self, block): + if block[0] != 0: + log.msg("auth failed, currently ignored") + self._doClientInitialization() + + def _handleAuth(self, block): (auth,) = unpack("!I", block) #~ print "auth:", auth @@ -818,22 +837,6 @@ class RFBFactory(protocol.ClientFactory): # should be overriden by application to use a derrived class protocol = RFBClient - def __init__(self, password = None, shared = 0): + def __init__(self, password = None, shared = 1): self.password = password self.shared = shared - -# class RFBDes(pyDes.des): -# def setKey(self, key): -# """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.""" -# newkey = [] -# for ki in range(len(key)): -# bsrc = ord(key[ki]) -# btgt = 0 -# for i in range(8): -# if bsrc & (1 << i): -# btgt = btgt | (1 << 7-i) -# newkey.append(chr(btgt)) -# super(RFBDes, self).setKey(newkey) diff --git a/src/rmview/rmparams.py b/src/rmview/rmparams.py index 4e81ae5..a14e8cb 100644 --- a/src/rmview/rmparams.py +++ b/src/rmview/rmparams.py @@ -3,6 +3,12 @@ PIXELS_NUM = WIDTH * HEIGHT TOTAL_BYTES = PIXELS_NUM * 2 +SW_VER_TIMESTAMPS = { + '2.7': 20210504114631, + '2.9': 20210709092503 +} + + # evtype_sync = 0 e_type_key = 1 e_type_abs = 3 @@ -19,4 +25,59 @@ e_code_stylus_proximity = 320 stylus_width = 15725 -stylus_height = 20951 \ No newline at end of file +stylus_height = 20951 + + +# Heuristic detection of orientation +# based on locating the menu button (O) and close button (X) + +CIRCLE_BLACK = [ + (-18,0), (-13,-13), (0,-18), (13,-13), (18,0), (13,13), (0,18), (-13,13) +] +CIRCLE_WHITE = [ + (-14,0), (-10,-10), (0,-14), (10,-10), (14,0), (10,10), (0,14), (-10,10) +] +CIRCLE_ICON = [(-5,-5), (-5,5), (5,-5), (5,5)] + +CIRCLE_POS = [(59,60), (60,1812), (1343,60)] + +BLACK = 4278190080 +WHITE = 4294967295 + +O_BUTTON = 1 +X_BUTTON = 2 + +def find_circle_buttons(img): + return [find_circle_button(img, x, y) for (x,y) in CIRCLE_POS] + +def find_circle_button(img, x, y): + p = img.pixel + for (dx,dy) in CIRCLE_BLACK: + if p(x+dx,y+dy) != BLACK: + return None + for (dx,dy) in CIRCLE_WHITE: + if p(x+dx,y+dy) != WHITE: + return None + b = [p(x+dx,y+dy) == BLACK for (dx,dy) in CIRCLE_ICON] + if all(b): + return X_BUTTON + if sum(b) == 1: + return O_BUTTON + else: + return None + + +# NAMES = { +# BLACK: 'b', +# WHITE: 'w' +# } + +# def debug_circle_buttons(img): +# return [debug_circle_button(img, x, y) for (x,y) in CIRCLE_POS] + +# def debug_circle_button(img, x, y): +# p = img.pixel +# b = [ NAMES.get(p(x+dx,y+dy), 'x') for (dx,dy) in CIRCLE_BLACK ] +# w = [ NAMES.get(p(x+dx,y+dy), 'x') for (dx,dy) in CIRCLE_WHITE ] +# i = [ NAMES.get(p(x+dx,y+dy), 'x') for (dx,dy) in CIRCLE_ICON ] +# return (b,w,i) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 6713ea9..6c22b6e 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -3,7 +3,10 @@ from PyQt5.QtCore import * from . import resources -from .workers import FrameBufferWorker, PointerWorker +from .screenstream.common import KEY_Left, KEY_Right, KEY_Escape +from .screenstream.vnc import VncStreamer +from .screenstream.screenshare import ScreenShareStream +from .pentracker import PenTracker from .connection import rMConnect, RejectNewHostKey, AddNewHostKey, UnknownHostKeyException from .viewer import QtImageViewer @@ -13,11 +16,14 @@ import sys import os +import stat import json import re +import signal +import time import logging -logging.basicConfig(format='%(message)s') +logging.basicConfig(format='[%(levelname)s] %(message)s') log = logging.getLogger('rmview') @@ -31,10 +37,15 @@ class rMViewApp(QApplication): penworker = None ssh = None + streaming = True + right_mode = True + pen = None pen_size = 15 trail = None # None: disabled, False: inactive, True: active + cloned_frames = set() + def __init__(self, args): super(rMViewApp, self).__init__(args) @@ -61,6 +72,9 @@ def __init__(self, args): log.error("Malformed configuration in %s: %s" % (f, e)) except Exception as e: log.debug("Configuration failure in %s: %s" % (f, e)) + + self._checkConfigFilePermissions(self.config_file) + self.config.setdefault('ssh', {}) self.pen_size = self.config.get('pen_size', self.pen_size) self.trailPen = QPen(QColor(self.config.get('pen_color', 'red')), max(1, self.pen_size // 3)) @@ -68,61 +82,92 @@ def __init__(self, args): self.trailPen.setJoinStyle(Qt.RoundJoin) self.trailDelay = self.config.get('pen_trail', 200) self.trail = None if self.trailDelay == 0 else False + self.right_mode = self.config.get('right_mode', True) self.bar = QMenuBar() self.setWindowIcon(QIcon(':/assets/rmview.svg')) self.viewer = QtImageViewer() + if 'background_color' in self.config: self.viewer.setBackgroundBrush(QBrush(QColor(self.config.get('background_color')))) - act = QAction('Clone current frame', self) - act.triggered.connect(self.cloneViewer) - self.viewer.menu.addAction(act) + ### ACTIONS + self.cloneAction = QAction('Clone current frame', self.viewer) + self.cloneAction.setShortcut(QKeySequence.New) + self.cloneAction.triggered.connect(self.cloneViewer) + self.viewer.addAction(self.cloneAction) ### - self.viewer.menu.addSeparator() # -------------------------- + self.pauseAction = QAction('Pause Streaming', self.viewer) + self.pauseAction.setShortcut('Ctrl+P') + self.pauseAction.triggered.connect(self.toggleStreaming) + self.viewer.addAction(self.pauseAction) ### - act = QAction('Settings...', self) - act.triggered.connect(self.openSettings) - self.viewer.menu.addAction(act) + self.settingsAction = QAction('Settings...', self.viewer) + self.settingsAction.triggered.connect(self.openSettings) + self.viewer.addAction(self.settingsAction) ### - self.viewer.menu.addSeparator() # -------------------------- + self.quitAction = QAction('Quit', self.viewer) + self.quitAction.setShortcut('Ctrl+Q') + self.quitAction.triggered.connect(self.quit) + self.viewer.addAction(self.quitAction) + ### + self.leftAction = QAction('Emulate Left Button', self) + self.leftAction.setShortcut('Ctrl+Left') + self.leftAction.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Left)) + self.viewer.addAction(self.leftAction) ### - act = QAction('Quit', self) - act.setShortcut('Ctrl+Q') - act.triggered.connect(self.quit) - self.viewer.menu.addAction(act) + self.rightAction = QAction('Emulate Right Button', self) + self.rightAction.setShortcut('Ctrl+Right') + self.rightAction.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Right)) + self.viewer.addAction(self.rightAction) + ### + self.homeAction = QAction('Emulate Central Button', self) + self.homeAction.setShortcut(QKeySequence.Cancel) + self.homeAction.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Escape)) + self.viewer.addAction(self.homeAction) + + + ### VIEWER MENU ADDITIONS + self.viewer.menu.addAction(self.cloneAction) + self.viewer.menu.addAction(self.pauseAction) + # inputMenu = self.viewer.menu.addMenu("Input") + # inputMenu.addAction(self.leftAction) + # inputMenu.addAction(self.rightAction) + # inputMenu.addAction(self.homeAction) + self.viewer.menu.addSeparator() # -------------------------- + self.viewer.menu.addAction(self.settingsAction) + self.viewer.menu.addSeparator() # -------------------------- + self.viewer.menu.addAction(self.quitAction) self.viewer.setWindowTitle("rMview") self.viewer.show() - self.orient = None + # Display connecting image until we successfuly connect + self.viewer.setImage(QPixmap(':/assets/connecting.png')) + + self.orient = 0 orient = self.config.get('orientation', 'landscape') if orient == 'landscape': self.viewer.rotateCW() self.autoResize(WIDTH / HEIGHT) elif orient == 'portrait': self.autoResize(HEIGHT / WIDTH) - else: # orient + else: # auto self.autoResize(HEIGHT / WIDTH) - self.orient = True - - # Setup global menu - menu = self.bar.addMenu('&View') - act = QAction('Rotate clockwise', self) - act.setShortcut('Ctrl+Right') - act.triggered.connect(self.viewer.rotateCW) - menu.addAction(act) - act = QAction('Rotate counter-clockwise', self) - act.setShortcut('Ctrl+Left') - act.triggered.connect(self.viewer.rotateCCW) - menu.addAction(act) - menu.addSeparator() - act = QAction('Save screenshot', self) - act.setShortcut('Ctrl+S') - act.triggered.connect(self.viewer.screenshot) - menu.addAction(act) - menu.addSeparator() + self.orient = 1 if orient == "auto_on_load" else 2 + + # # Setup global menu + # menu = self.bar.addMenu('&View') + # menu.addAction(self.viewer.rotCWAction) + # menu.addAction(self.viewer.rotCCWAction) + # menu.addSeparator() + # menu.addAction(self.viewer.screenshotAction) + # menu.addSeparator() + # menu.addAction(self.pauseAction) + # menu.addAction(self.leftAction) + # menu.addAction(self.rightAction) + # menu.addAction(self.homeAction) if not self.ensureConnConfig(): # I know, it's ugly @@ -134,28 +179,25 @@ def __init__(self, args): self.requestConnect() def detectOrientation(self, image): - c = image.pixel - portrait = False - # print(c(48, 47) , c(72, 72) , c(55, 55) , c(64, 65)) - if c(48, 47) == 4278190080 and c(72, 72) == 4278190080 and \ - (c(55, 55) == 4294967295 or c(64, 65) == 4294967295): - if c(61, 1812) != 4278190080 or c(5,5) == 4278190080: - portrait = True - elif c(1356, 47) == 4278190080 and c(1329, 72) == 4278190080 and \ - (c(1348, 54) == 4294967295 or c(1336, 65) == 4294967295): - portrait = True - elif c(5,5) == 4278190080: - portrait = True - elif c(40,47) == 4278190080 and c(40,119) == 4278190080: - portrait = True - if portrait: - self.viewer.portrait() - self.autoResize(HEIGHT / WIDTH) + (tl,bl,tr) = find_circle_buttons(image) + if tl is None and bl is None and tr is None: + portrait = True # We are in the main screen/settings + elif bl is None: + portrait = self.right_mode else: - self.viewer.landscape() - self.autoResize(WIDTH / HEIGHT) + portrait = False + + if portrait: + if not self.viewer.is_portrait(): + self.viewer.portrait() + self.autoResize(HEIGHT / WIDTH) + elif not self.viewer.is_landscape(): + self.viewer.landscape() + self.autoResize(WIDTH / HEIGHT) def autoResize(self, ratio): + if self.viewer.windowState() & (QWindow.FullScreen | QWindow.Maximized): + return dg = self.desktop().availableGeometry(self.viewer) ds = dg.size() * 0.7 if ds.width() * ratio > ds.height(): @@ -194,9 +236,32 @@ def ensureConnConfig(self): if not os.path.isfile(self.LOCAL_KNOWN_HOSTS): open(self.LOCAL_KNOWN_HOSTS, 'a').close() - log.info(self.config) + if log.isEnabledFor(logging.DEBUG): + import copy + config_sanitized = copy.deepcopy(self.config) + if "password" in self.config.get("ssh", {}): + config_sanitized["ssh"]["password"] = config_sanitized["ssh"]["password"][:3] + "*****" + log.debug("Config values: %s" % (str(config_sanitized))) + return True + def _checkConfigFilePermissions(self, file_path): + """ + Emit a warning message if config file is readable by others. + """ + st_mode = os.stat(file_path).st_mode + + if bool(st_mode & stat.S_IROTH) or bool(st_mode & stat.S_IWOTH): + file_permissions = str(oct(st_mode)[4:]) + + if file_permissions.startswith("0") and len(file_permissions) == 4: + file_permissions = file_permissions[1:] + + log.warn("Config file \"%s\" is readable by others (permissions=%s). If your config " + "file contains secrets (e.g. password) you are strongly encouraged to make sure " + "it's not readable by other users (chmod 600 %s)" % (file_path, file_permissions, + file_path)) + def requestConnect(self, host_key_policy=None): self.viewer.setWindowTitle("rMview - Connecting...") args = self.config.get('ssh') @@ -222,75 +287,55 @@ def joinWorkers(self): @pyqtSlot(object) def connected(self, ssh): self.ssh = ssh - self.viewer.setWindowTitle("rMview - " + self.config.get('ssh').get('address')) - - _,out,_ = ssh.exec_command("cat /sys/devices/soc0/machine") - rmv = out.read().decode("utf-8") - version = re.fullmatch(r"reMarkable(?: Prototype)? (\d+)(\.\d+)*\n", rmv) - if version is None or version[1] not in ["1", "2"]: - log.error("Device is unsupported: '%s' [%s]", rmv.strip(), version[1] if version else "unknown device") - QMessageBox.critical(None, "Unsupported device", "The detected device is '%s'.\nrmView currently only supports reMarkable 1 and 2." % rmv.strip()) + self.viewer.setWindowTitle("rMview - " + ssh.hostname) + + log.info("Detected %s", ssh.fullDeviceVersion) + version = ssh.deviceVersion + if version not in [1, 2]: + log.error("Device is unsupported: '%s' [%s]", ssh.fullDeviceVersion, version or "unknown device") + QMessageBox.critical(None, "Unsupported device", "The detected device is '%s'.\nrmView currently only supports reMarkable 1 and 2." % ssh.fullDeviceVersion) self.quit() return - version = int(version[1]) - - # check needed files are in place - _,out,_ = ssh.exec_command("[ -x $HOME/rM-vnc-server-standalone ]") - if out.channel.recv_exit_status() != 0: - mbox = QMessageBox(QMessageBox.NoIcon, 'Missing components', 'Your reMarkable is missing some needed components.') - icon = QPixmap(":/assets/problem.svg") - icon.setDevicePixelRatio(self.devicePixelRatio()) - mbox.setIconPixmap(icon) - mbox.setInformativeText( - "To work properly, rmView needs the rM-vnc-server-standalone program "\ - "to be installed on your tablet.\n"\ - "You can install them manually, or let rmView do the work for you by pressing 'Auto Install' below.\n\n"\ - "If you are unsure, please consult the documentation.") - mbox.addButton(QMessageBox.Cancel) - mbox.addButton(QMessageBox.Help) - mbox.addButton("Settings...", QMessageBox.ResetRole) - mbox.addButton("Auto Install", QMessageBox.AcceptRole) - mbox.setDefaultButton(0) - answer = mbox.exec() - log.info(answer) - if answer == 1: - log.info("Installing...") - try: - sftp = ssh.open_sftp() - from stat import S_IXUSR - fo = QFile(':bin/rM%d-vnc-server-standalone' % version) - fo.open(QIODevice.ReadOnly) - sftp.putfo(fo, 'rM-vnc-server-standalone') - fo.close() - sftp.chmod('rM-vnc-server-standalone', S_IXUSR) - log.info("Installation successful!") - except Exception as e: - log.error('%s %s', type(e), e) - QMessageBox.critical(None, "Error", 'There has been an error while trying to install the required components on the tablet.\n%s\n.' % e) - self.quit() - return - elif answer == QMessageBox.Cancel: - self.quit() - return - elif answer == QMessageBox.Help: - QDesktopServices.openUrl(QUrl("https://github.com/bordaigorl/rmview")) - self.quit() - return + backend = self.config.get('backend', 'auto') + if backend == 'auto': + if ssh.softwareVersion >= SW_VER_TIMESTAMPS['2.9']: + backend = 'screenshare' else: - self.openSettings(prompt=False) - return + backend = 'vncserver' + if ssh.softwareVersion >= SW_VER_TIMESTAMPS['2.7']: + log.warning("Detected version 2.7 or 2.8. The server might not work with these versions.") + + log.info("Using backend '%s'", backend) + if backend == 'screenshare': + self.fbworker = ScreenShareStream(ssh) + # does not support key/pointer events + self.leftAction.setEnabled(False) + self.rightAction.setEnabled(False) + self.homeAction.setEnabled(False) + elif backend == 'vncserver': + self.fbworker = VncStreamer(ssh, ssh_config=self.config.get('ssh', {}), + delay=self.config.get('fetch_frame_delay')) - self.fbworker = FrameBufferWorker(ssh, delay=self.config.get('fetch_frame_delay')) self.fbworker.signals.onNewFrame.connect(self.onNewFrame) self.fbworker.signals.onFatalError.connect(self.frameError) + + # check needed files are in place + if self.fbworker.needsDependencies(): + if not self.promptDependenciesInstall(): + return + self.threadpool.start(self.fbworker) + if self.config.get("forward_mouse_events", False): + self.viewer.pointerEvent.connect(self.fbworker.pointerEvent) - self.penworker = PointerWorker(ssh, path="/dev/input/event%d" % (version-1)) + self.penworker = PenTracker(ssh, path="/dev/input/event%d" % (version-1)) self.threadpool.start(self.penworker) self.pen = self.viewer.scene.addEllipse(0,0,self.pen_size,self.pen_size, pen=QPen(QColor('white')), brush=QBrush(QColor(self.config.get('pen_color', 'red')))) + self.pen.lastShown = None + self.pen.showDelay = self.config.get("pen_show_delay", 0.4) self.pen.hide() self.pen.setZValue(100) self.penworker.signals.onPenMove.connect(self.movePen) @@ -298,27 +343,74 @@ def connected(self, ssh): self.penworker.signals.onPenLift.connect(self.showPen) if self.config.get("hide_pen_on_press", True): self.penworker.signals.onPenPress.connect(self.hidePen) - self.penworker.signals.onPenNear.connect(self.showPen) + self.penworker.signals.onPenNear.connect(self.showPenNow) self.penworker.signals.onPenFar.connect(self.hidePen) + def promptDependenciesInstall(self): + mbox = QMessageBox(QMessageBox.NoIcon, 'Missing components', 'Your reMarkable is missing some needed components.') + icon = QPixmap(":/assets/problem.svg") + icon.setDevicePixelRatio(self.devicePixelRatio()) + mbox.setIconPixmap(icon) + mbox.setInformativeText( + "To work properly, rmView needs some dependencies "\ + "to be installed on your tablet.\n"\ + "You can install them manually, or let rmView do the work for you by pressing 'Auto Install' below.\n\n"\ + "If you are unsure, please consult the documentation.") + mbox.addButton(QMessageBox.Cancel) + mbox.addButton(QMessageBox.Help) + mbox.addButton("Settings...", QMessageBox.ResetRole) + mbox.addButton("Auto Install", QMessageBox.AcceptRole) + mbox.setDefaultButton(0) + answer = mbox.exec() + log.info(answer) + if answer == 1: + log.info("Installing...") + try: + self.fbworker.installDependencies() + log.info("Installation successful!") + return True + except Exception as e: + log.error('%s %s', type(e), e) + QMessageBox.critical(None, "Error", 'There has been an error while trying to install the required components on the tablet.\n%s\n.' % e) + self.quit() + elif answer == QMessageBox.Cancel: + self.quit() + elif answer == QMessageBox.Help: + QDesktopServices.openUrl(QUrl("https://github.com/bordaigorl/rmview")) + self.quit() + else: + self.openSettings(prompt=False) + + return False + @pyqtSlot(QImage) def onNewFrame(self, image): - if self.orient: + if self.orient > 0: self.detectOrientation(image) - self.orient = False + if self.orient == 1: + self.orient = 0 self.viewer.setImage(image) @pyqtSlot() def hidePen(self): if self.trail is not None: self.trail = False + self.pen.lastShown = None self.pen.hide() @pyqtSlot() def showPen(self): if self.trail is not None: self.trail = False + self.pen.lastShown = time.perf_counter() + # self.pen.show() + + @pyqtSlot() + def showPenNow(self): + if self.trail is not None: + self.trail = False + self.pen.lastShown = None self.pen.show() @pyqtSlot(int, int) @@ -337,13 +429,38 @@ def movePen(self, x, y): QTimer.singleShot(self.trailDelay // 2, lambda: t.setOpacity(.5)) QTimer.singleShot(self.trailDelay, lambda: self.viewer.scene.removeItem(t)) self.pen.setRect(x - (self.pen_size // 2), y - (self.pen_size // 2), self.pen_size, self.pen_size) + if self.pen.lastShown is not None: + if time.perf_counter() - self.pen.lastShown > self.pen.showDelay: + self.pen.show() + self.pen.lastShown = None @pyqtSlot() def cloneViewer(self): img = self.viewer.image() + img = QPixmap.fromImage(img) + img.detach() v = QtImageViewer() + v.setAttribute(Qt.WA_DeleteOnClose) v.setImage(img) v.show() + v.rotate(self.viewer._rotation) + self.cloned_frames.add(v) + v.destroyed.connect(lambda: self.cloned_frames.discard(v)) + + @pyqtSlot() + def toggleStreaming(self): + if self.streaming: + self.fbworker.pause() + self.penworker.pause() + self.streaming = False + self.pauseAction.setText("Resume Streaming") + self.viewer.setWindowTitle("rMview - " + self.ssh.hostname + " [PAUSED]") + else: + self.fbworker.resume() + self.penworker.resume() + self.streaming = True + self.pauseAction.setText("Pause Streaming") + self.viewer.setWindowTitle("rMview - " + self.ssh.hostname) @pyqtSlot() def openSettings(self, prompt=True): @@ -446,11 +563,27 @@ def frameError(self, e): QMessageBox.critical(self.viewer, "Error", 'Please check your reMarkable is properly configured, see the documentation for instructions.\n\n%s' % e) self.quit() + def event(self, e): + return QApplication.event(self, e) + def rmViewMain(): log.setLevel(logging.INFO) + if len(sys.argv) > 1: + if sys.argv[1] == "-v": + log.setLevel(logging.DEBUG) + del sys.argv[1] + elif sys.argv[1] == "-q": + log.setLevel(logging.ERROR) + del sys.argv[1] + + log.info("STARTING: %s", time.asctime()) QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling) - ecode = rMViewApp(sys.argv).exec_() - print('\nBye!') + app = rMViewApp(sys.argv) + # We register custom signal handler so we can gracefuly stop app with CTRL+C when QT main loop is + # running + signal.signal(signal.SIGINT, lambda *args: app.quit()) + ecode = app.exec_() + log.info("QUITTING: %s", time.asctime()) sys.exit(ecode) if __name__ == '__main__': diff --git a/src/rmview/screenstream/__init__.py b/src/rmview/screenstream/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/rmview/screenstream/common.py b/src/rmview/screenstream/common.py new file mode 100644 index 0000000..949fe72 --- /dev/null +++ b/src/rmview/screenstream/common.py @@ -0,0 +1,80 @@ +import logging +import atexit + +from PyQt5.QtGui import * +from PyQt5.QtCore import * + +from twisted.internet import reactor + +from rmview.rmparams import * +from rmview.rfb import * + +try: + IMG_FORMAT = QImage.Format_Grayscale16 +except Exception: + IMG_FORMAT = QImage.Format_RGB16 +BYTES_PER_PIXEL = 2 + +log = logging.getLogger('rmview') + + +class ScreenStreamSignals(QObject): + onFatalError = pyqtSignal(Exception) + onNewFrame = pyqtSignal(QImage) + + +class VncClient(RFBClient): + img = QImage(WIDTH, HEIGHT, IMG_FORMAT) + painter = QPainter(img) + + def __init__(self, signals): + super(VncClient, self).__init__() + self.signals = signals + + def emitImage(self): + self.signals.onNewFrame.emit(self.img) + + def vncConnectionMade(self): + log.info("Connection to VNC server has been established") + + # self.signals = self.factory.signals + self.setEncodings([ + HEXTILE_ENCODING, + CORRE_ENCODING, + PSEUDO_CURSOR_ENCODING, + RRE_ENCODING, + ZRLE_ENCODING, + RAW_ENCODING ]) + self.framebufferUpdateRequest() + + def sendPassword(self, password): + self.signals.onFatalError.emit(Exception("Unsupported password request.")) + + def commitUpdate(self, rectangles=None): + self.signals.onNewFrame.emit(self.img) + self.framebufferUpdateRequest(incremental=1) + + def updateRectangle(self, x, y, width, height, data): + self.painter.drawImage(x,y,QImage(data, width, height, width * BYTES_PER_PIXEL, IMG_FORMAT)) + + +class VncFactory(RFBFactory): + protocol = VncClient + instance = None + + def __init__(self, signals): + super(VncFactory, self).__init__() + self.signals = signals + + def buildProtocol(self, addr): + self.instance = VncClient(self.signals) + self.instance.factory = self + return self.instance + + def clientConnectionLost(self, connector, reason): + log.warning("Disconnected: %s", reason.getErrorMessage()) + reactor.callFromThread(reactor.stop) + + def clientConnectionFailed(self, connector, reason): + self.signals.onFatalError.emit(Exception("Connection failed: " + str(reason))) + reactor.callFromThread(reactor.stop) diff --git a/src/rmview/screenstream/screenshare.py b/src/rmview/screenstream/screenshare.py new file mode 100644 index 0000000..511c695 --- /dev/null +++ b/src/rmview/screenstream/screenshare.py @@ -0,0 +1,75 @@ +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * +from PyQt5.QtCore import * + +from rmview.rmparams import * + +import paramiko +import struct +import time + +import sys +import os +import logging + +from twisted.internet.protocol import Protocol +from twisted.internet import protocol, reactor, ssl +from twisted.application import internet, service + +from rmview.screenstream.common import * + +log = logging.getLogger('rmview') + + +class ScreenShareStream(QRunnable): + + factory = None + + def __init__(self, ssh): + super(ScreenShareStream, self).__init__() + self.ssh = ssh + self.signals = ScreenStreamSignals() + + def needsDependencies(self): + return False + + def installDependencies(self): + pass + + def stop(self): + log.info("Stopping framebuffer thread...") + reactor.callFromThread(reactor.stop) + + @pyqtSlot() + def run(self): + log.info("Connecting to ScreenShare (make sure it's enabled!)") + try: + self.factory = VncFactory(self.signals) + #left for testing with stunnel + #self.vncClient = internet.TCPClient("localhost", 31337, self.factory) + self.vncClient = internet.SSLClient(self.ssh.hostname, 5900, self.factory, ssl.ClientContextFactory()) + self.vncClient.startService() + reactor.run(installSignalHandlers=0) + except Exception as e: + log.error(e) + + @pyqtSlot() + def pause(self): + self.signals.blockSignals(True) + + @pyqtSlot() + def resume(self): + self.signals.blockSignals(False) + try: + self.factory.instance.emitImage() + except Exception: + log.warning("Not ready to resume") + + def pointerEvent(self, x, y, button): + pass + + def keyEvent(self, key): + pass + + def emulatePressRelease(self, key): + pass diff --git a/src/rmview/screenstream/vnc.py b/src/rmview/screenstream/vnc.py new file mode 100644 index 0000000..1c75c1c --- /dev/null +++ b/src/rmview/screenstream/vnc.py @@ -0,0 +1,247 @@ +import logging +import atexit + +from PyQt5.QtGui import * +from PyQt5.QtCore import * + +from twisted.internet import reactor +from twisted.application import internet + +from rmview.screenstream.common import * +# from rmview.rmparams import * +# from rmview.rfb import * + +log = logging.getLogger('rmview') + + +class VncStreamer(QRunnable): + + _stop = False + + ignoreEvents = False + factory = None + vncClient = None + sshTunnel = None + + def __init__(self, ssh, ssh_config, delay=None): + super(VncStreamer, self).__init__() + self.ssh = ssh + self.ssh_config = ssh_config + self.use_ssh_tunnel = self.ssh_config.get("tunnel", False) + + self._vnc_server_already_running = False + + self.signals = ScreenStreamSignals() + + def needsDependencies(self): + _, out, _ = self.ssh.exec_command("[ -x $HOME/rM-vnc-server-standalone ]") + log.info("%s %s", QFile, QIODevice) + return out.channel.recv_exit_status() != 0 + + def installDependencies(self): + sftp = self.ssh.open_sftp() + from stat import S_IXUSR + fo = QFile(':bin/rM%d-vnc-server-standalone' % ssh.deviceVersion) + fo.open(QIODevice.ReadOnly) + sftp.putfo(fo, 'rM-vnc-server-standalone') + fo.close() + sftp.chmod('rM-vnc-server-standalone', S_IXUSR) + + def stop(self): + if self._stop: + # Already stopped + return + + self._stop = True + + log.debug("Stopping framebuffer thread...") + + if self.vncClient: + try: + log.info("Disconnecting from VNC server...") + reactor.callFromThread(self.vncClient.stopService) + except Exception as e: + log.debug("Disconnect failed (%s), stopping reactor" % str(e)) + reactor.callFromThread(reactor.stop) + + # If we used an existing running instance and didn't start one ourselves we will not kill it. + if not self._vnc_server_already_running: + try: + log.info("Stopping VNC server...") + self.ssh.exec_command("killall -SIGINT rM-vnc-server-standalone") + except Exception as e: + log.warning("VNC could not be stopped on the reMarkable.") + log.warning("Although this is not a big problem, it may consume some resources until you restart the tablet.") + log.warning("You can manually terminate it by running `ssh root@%s killall rM-vnc-server-standalone`.", self.ssh.hostname) + log.error(e) + + if self.sshTunnel: + try: + log.info("Stopping SSH tunnel...") + self.sshTunnel.stop() + except Exception as e: + log.error(e) + + log.debug("Framebuffer thread stopped") + + @pyqtSlot() + def run(self): + try: + self._start_vnc_server() + vnc_server_host, vnc_server_port = self._setup_ssh_tunnel_if_configured() + except Exception as e: + self.signals.onFatalError.emit(e) + return + + log.info("Establishing connection to remote VNC server on %s:%s" % (vnc_server_host, + vnc_server_port)) + try: + self.factory = VncFactory(self.signals) + self.vncClient = internet.TCPClient(vnc_server_host, vnc_server_port, self.factory) + self.vncClient.startService() + reactor.run(installSignalHandlers=0) + except Exception as e: + log.error("Failed to connect to the VNC server: %s" % (str(e))) + + def _check_vnc_server_is_already_running(self) -> bool: + """ + Check if VNC server is already running on reMarkable. + + If it is, True is returned by this method and a log message if emitted. + """ + _, stdout, stderr = self.ssh.exec_command("ps -ww | grep rM-vnc-server-standalone | grep -v grep") + + stdout_bytes = stdout.read() + + if b"rM-vnc-server-standalone" in stdout_bytes: + # TODO: Add config option to force kill and start a fresh server in this case + vnc_server_already_running = True + log.info("Found an existing instance of rM-vnc-server-standalone process on reMarkable. " + "Will try to use that instance instead of starting a new one.") + + if self.use_ssh_tunnel and b"-listen localhost" not in stdout_bytes: + # If user has configured SSH tunnel, but existing VNC server instance is not using "-listen + # localhost" flag this likely indicates that the running server is listening on all the + # interfaces. This could pose a security risk so we log a warning. + log.warn("Existing VNC server is not running with \"-listen localhost\" flag. This means " + "that the existing server is likely listening on all the interfaces. This could " + "pose a security risk so you are advised to run server with \"-listen localhost\" " + "flag when using an SSH tunnel.") + else: + vnc_server_already_running = False + + return vnc_server_already_running + + def _start_vnc_server(self): + """ + Start VNC server on reMarkable if it's not already running. + """ + self._vnc_server_already_running = self._check_vnc_server_is_already_running() + + if self._vnc_server_already_running: + # Server already running, we will try to use that instance + return + + if self.use_ssh_tunnel: + # If using SSH tunnel, we ensure VNC server only listens on localhost. That's important for + # security reasons. + server_run_cmd = "$HOME/rM-vnc-server-standalone -listen localhost" + else: + server_run_cmd = "$HOME/rM-vnc-server-standalone" + + log.info("Starting VNC server (command=%s)" % (server_run_cmd)) + + _, _, stdout = self.ssh.exec_command(server_run_cmd) + + # TODO: This method for consuming stdout is not really good, it assumed there will always be + # at least one line produced... + # And we should also check exit code and not stdout for better robustness. + stdout_bytes = next(stdout).strip() + log.info("Start command stdout output: %s" % (stdout_bytes)) + + if "listening for vnc connections on" not in stdout_bytes.lower(): + raise Exception("Failed to start VNC server on reMarkable: %s" % (stdout_bytes)) + + # Register atexit handler to ensure we always try to kill started server on exit + atexit.register(self.stop) + + + def _setup_ssh_tunnel_if_configured(self): + """ + Set up and start SSH tunnel (if configured). + """ + if self.use_ssh_tunnel: + tunnel = self._get_ssh_tunnel() + tunnel.start() + self.sshTunnel = tunnel + + log.info("Setting up SSH tunnel %s:%s (rm) <-> %s:%s (localhost)" % ("127.0.0.1", 5900, + tunnel.local_bind_host, + tunnel.local_bind_port)) + + vnc_server_host = tunnel.local_bind_host + vnc_server_port = tunnel.local_bind_port + else: + vnc_server_host = self.ssh.hostname + vnc_server_port = 5900 + + + return (vnc_server_host, vnc_server_port) + + def _get_ssh_tunnel(self): + open_tunnel_kwargs = { + "ssh_username" : self.ssh_config.get("username", "root"), + } + + if self.ssh_config.get("auth_method", "password") == "key": + open_tunnel_kwargs["ssh_pkey"] = self.ssh_config["key"] + + if self.ssh_config.get("password", None): + open_tunnel_kwargs["ssh_private_key_password"] = self.ssh_config["password"] + else: + open_tunnel_kwargs["ssh_password"] = self.ssh_config["password"] + + try: + import sshtunnel + except ModuleNotFoundError: + raise Exception("You need to install `sshtunnel` to use the tunnel feature") + tunnel = sshtunnel.open_tunnel( + (self.ssh.hostname, 22), + remote_bind_address=("127.0.0.1", 5900), + # We don't specify port so library auto assigns random unused one in the high range + local_bind_address=('127.0.0.1',), + compression=self.ssh_config.get("tunnel_compression", False), + **open_tunnel_kwargs) + + return tunnel + + @pyqtSlot() + def pause(self): + self.ignoreEvents = True + self.signals.blockSignals(True) + + @pyqtSlot() + def resume(self): + self.ignoreEvents = False + self.signals.blockSignals(False) + try: + self.factory.instance.emitImage() + except Exception: + log.warning("Not ready to resume") + + # @pyqtSlot(int,int,int) + def pointerEvent(self, x, y, button): + if self.ignoreEvents: return + try: + reactor.callFromThread(self.factory.instance.pointerEvent, x, y, button) + except Exception as e: + log.warning("Not ready to send pointer events! [%s]", e) + + def keyEvent(self, key): + if self.ignoreEvents: return + reactor.callFromThread(self.emulatePressRelease, key) + + def emulatePressRelease(self, key): + self.factory.instance.keyEvent(key) + # time.sleep(.1) + self.factory.instance.keyEvent(key, 0) diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py index f235542..03ea352 100644 --- a/src/rmview/viewer.py +++ b/src/rmview/viewer.py @@ -1,16 +1,24 @@ -from PyQt5.QtCore import Qt, QRectF, pyqtSignal, QT_VERSION_STR -from PyQt5.QtGui import QWindow, QImage, QPixmap, QTransform, QIcon -from PyQt5.QtWidgets import QApplication, QGraphicsView, QGraphicsScene, QFileDialog, QAction, QMenu +from PyQt5.QtCore import * +from PyQt5.QtGui import * +from PyQt5.QtWidgets import * class QtImageViewer(QGraphicsView): + pointerEvent = pyqtSignal(int, int, int) + _button = 0 + zoomInFactor = 1.25 zoomOutFactor = 1 / zoomInFactor def __init__(self): QGraphicsView.__init__(self) - # self.setAttribute(Qt.WA_OpaquePaintEvent, True) + self.setFrameStyle(QFrame.NoFrame) + + self.setRenderHint(QPainter.Antialiasing) + self.setRenderHint(QPainter.SmoothPixmapTransform) + + self.viewport().grabGesture(Qt.PinchGesture) self.scene = QGraphicsScene() self.setScene(self.scene) @@ -21,39 +29,53 @@ def __init__(self): self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.setAlignment(Qt.AlignCenter) - self.menu = QMenu(self) - act = QAction('Fit to view', self, checkable=True) - self.fitAction = act - act.triggered.connect(lambda: self.setFit(True)) - self.menu.addAction(act) + ### ACTIONS + self.fitAction = QAction('Fit to view', self, checkable=True) + self.fitAction.setShortcut("Ctrl+0") + self.fitAction.triggered.connect(lambda: self.setFit(True)) + self.addAction(self.fitAction) ### - act = QAction('Actual Size', self) - act.triggered.connect(lambda: self.actualSize()) - self.menu.addAction(act) + self.actualSizeAction = QAction('Actual Size', self) + self.actualSizeAction.setShortcut("Ctrl+1") + self.actualSizeAction.triggered.connect(lambda: self.actualSize()) + self.addAction(self.actualSizeAction) ### - act = QAction('Zoom In', self) - act.triggered.connect(self.zoomIn) - self.menu.addAction(act) + self.zoomInAction = QAction('Zoom In', self) + self.zoomInAction.setShortcut(QKeySequence.ZoomIn) + self.zoomInAction.triggered.connect(self.zoomIn) + self.addAction(self.zoomInAction) ### - act = QAction('Zoom Out', self) - act.triggered.connect(self.zoomOut) - self.menu.addAction(act) + self.zoomOutAction = QAction('Zoom Out', self) + self.zoomOutAction.setShortcut(QKeySequence.ZoomOut) + self.zoomOutAction.triggered.connect(self.zoomOut) + self.addAction(self.zoomOutAction) ### - self.menu.addSeparator() # -------------------------- + self.rotCWAction = QAction('Rotate clockwise', self) + self.rotCWAction.setShortcut("Ctrl+R") + self.rotCWAction.triggered.connect(self.rotateCW) + self.addAction(self.rotCWAction) ### - act = QAction('Rotate clockwise', self) - act.triggered.connect(self.rotateCW) - self.menu.addAction(act) + self.rotCCWAction = QAction('Rotate counter-clockwise', self) + self.rotCCWAction.setShortcut("Ctrl+L") + self.rotCCWAction.triggered.connect(self.rotateCCW) + self.addAction(self.rotCCWAction) ### - act = QAction('Rotate counter-clockwise', self) - act.triggered.connect(self.rotateCCW) - self.menu.addAction(act) + self.screenshotAction = QAction('Save screenshot', self) + self.screenshotAction.setShortcut(QKeySequence.Save) + self.screenshotAction.triggered.connect(self.screenshot) + self.addAction(self.screenshotAction) ### + + self.menu = QMenu(self) + self.menu.addAction(self.fitAction) + self.menu.addAction(self.actualSizeAction) + self.menu.addAction(self.zoomInAction) + self.menu.addAction(self.zoomOutAction) self.menu.addSeparator() # -------------------------- - ### - act = QAction('Save screenshot', self) - act.triggered.connect(self.screenshot) - self.menu.addAction(act) + self.menu.addAction(self.rotCWAction) + self.menu.addAction(self.rotCCWAction) + self.menu.addSeparator() # -------------------------- + self.menu.addAction(self.screenshotAction) self._fit = True self._rotation = 0 # used to produce a rotated screenshot @@ -107,6 +129,25 @@ def updateViewer(self): def resizeEvent(self, event): self.updateViewer() + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + scenePos = self.mapToScene(event.pos()) + if int(event.modifiers()) & int(Qt.ControlModifier): + self._button = 1 + else: + self._button = 4 + self.pointerEvent.emit(int(scenePos.x()), int(scenePos.y()), self._button) + + def mouseReleaseEvent(self, event): + scenePos = self.mapToScene(event.pos()) + self._button = 0 + self.pointerEvent.emit(int(scenePos.x()), int(scenePos.y()), 0) + + def mouseMoveEvent(self, event): + if self._button > 0: + scenePos = self.mapToScene(event.pos()) + self.pointerEvent.emit(int(scenePos.x()), int(scenePos.y()), self._button) + def mouseDoubleClickEvent(self, event): # scenePos = self.mapToScene(event.pos()) if event.button() == Qt.LeftButton: @@ -117,6 +158,14 @@ def mouseDoubleClickEvent(self, event): # self.rightMouseButtonDoubleClicked.emit(scenePos.x(), scenePos.y()) QGraphicsView.mouseDoubleClickEvent(self, event) + def viewportEvent(self, event): + if event.type() == QEvent.Gesture: + pinch = event.gesture(Qt.PinchGesture) + if pinch is not None: + self._fit = False + self.scale(pinch.scaleFactor(), pinch.scaleFactor()) + return True + return bool(QGraphicsView.viewportEvent(self, event)) def wheelEvent(self, event): if event.modifiers() == Qt.NoModifier: @@ -151,12 +200,18 @@ def screenshot(self): img = img.transformed(QTransform().rotate(self._rotation)) img.save(fileName) + def is_landscape(self): + return self._rotation == 90 + def landscape(self): self.resetTransform() self.rotate(90) self._rotation = 90 self.updateViewer() + def is_portrait(self): + return self._rotation == 0 + def portrait(self): self.resetTransform() self._rotation = 0 @@ -197,11 +252,7 @@ def actualSize(self): self.rotate(self._rotation) def keyPressEvent(self, event): - if event.key() == Qt.Key_Left: - self.rotateCCW() - elif event.key() == Qt.Key_Right: - self.rotateCW() - elif event.key() == Qt.Key_F: + if event.key() == Qt.Key_F: self.setFit(True) elif event.key() == Qt.Key_1: self.actualSize() diff --git a/src/rmview/workers.py b/src/rmview/workers.py deleted file mode 100644 index 9c5f1f1..0000000 --- a/src/rmview/workers.py +++ /dev/null @@ -1,201 +0,0 @@ -from PyQt5.QtGui import * -from PyQt5.QtWidgets import * -from PyQt5.QtCore import * - -from .rmparams import * - -import paramiko -import struct -import time - -import sys -import os -import logging - - -from twisted.internet.protocol import Protocol -from twisted.internet import protocol, reactor -from twisted.application import internet, service - -from .rfb import * - -try: - IMG_FORMAT = QImage.Format_Grayscale16 -except Exception: - IMG_FORMAT = QImage.Format_RGB16 -BYTES_PER_PIXEL = 2 - -log = logging.getLogger('rmview') - -class FBWSignals(QObject): - onFatalError = pyqtSignal(Exception) - onNewFrame = pyqtSignal(QImage) - - -class RFB(RFBClient): - img = QImage(WIDTH, HEIGHT, IMG_FORMAT) - painter = QPainter(img) - - def vncConnectionMade(self): - self.signals = self.factory.signals - self.setEncodings([ - HEXTILE_ENCODING, - CORRE_ENCODING, - PSEUDO_CURSOR_ENCODING, - RRE_ENCODING, - RAW_ENCODING ]) - time.sleep(.1) # get first image without artifacts - self.framebufferUpdateRequest() - - def sendPassword(self, password): - self.signals.onFatalError.emit(Exception("Unsupported password request.")) - - def commitUpdate(self, rectangles=None): - self.signals.onNewFrame.emit(self.img) - self.framebufferUpdateRequest(incremental=1) - - def updateRectangle(self, x, y, width, height, data): - self.painter.drawImage(x,y,QImage(data, width, height, width * BYTES_PER_PIXEL, IMG_FORMAT)) - - - -class RFBFactory(RFBFactory): - protocol = RFB - - def __init__(self, signals): - super(RFBFactory, self).__init__() - self.signals = signals - - def clientConnectionLost(self, connector, reason): - log.warning("Connection lost: %s", reason.getErrorMessage()) - connector.connect() - - def clientConnectionFailed(self, connector, reason): - self.signals.onFatalError.emit(Exception("Connection failed: " + str(reason))) - reactor.callFromThread(reactor.stop) - - -class FrameBufferWorker(QRunnable): - - _stop = False - - def __init__(self, ssh, delay=None, lz4_path=None, img_format=IMG_FORMAT): - super(FrameBufferWorker, self).__init__() - self.ssh = ssh - self.img_format = img_format - - self.signals = FBWSignals() - - def stop(self): - self._stop = True - log.info("Stopping framebuffer thread...") - reactor.callFromThread(reactor.stop) - try: - self.ssh.exec_command("killall rM-vnc-server-standalone", timeout=3) - except Exception as e: - log.warning("VNC could not be stopped on the reMarkable.") - log.warning("Although this is not a big problem, it may consume some resources until you restart the tablet.") - log.warning("You can manually terminate it by running `ssh %s killall rM-vnc-server-standalone`.", self.ssh.hostname) - log.error(e) - log.info("Framebuffer thread stopped") - - @pyqtSlot() - def run(self): - try: - _,_,out = self.ssh.exec_command("$HOME/rM-vnc-server-standalone") - log.info(next(out)) - except Exception as e: - self.signals.onFatalError.emit(e) - - while self._stop == False: - log.info("Starting VNC server") - try: - self.vncClient = internet.TCPClient(self.ssh.hostname, 5900, RFBFactory(self.signals)) - self.vncClient.startService() - reactor.run(installSignalHandlers=0) - except Exception as e: - log.error(e) - - -class PWSignals(QObject): - onFatalError = pyqtSignal(Exception) - onPenMove = pyqtSignal(int, int) - onPenPress = pyqtSignal() - onPenLift = pyqtSignal() - onPenNear = pyqtSignal() - onPenFar = pyqtSignal() - -LIFTED = 0 -PRESSED = 1 - - -class PointerWorker(QRunnable): - - _stop = False - - def __init__(self, ssh, path="/dev/input/event0", threshold=1000): - super(PointerWorker, self).__init__() - self.event = path - self.ssh = ssh - self.threshold = threshold - self.signals = PWSignals() - - def stop(self): - self._penkill.write('\n') - self._stop = True - - @pyqtSlot() - def run(self): - penkill, penstream, _ = self.ssh.exec_command('cat %s & { read ; kill %%1; }' % self.event) - self._penkill = penkill - new_x = new_y = False - state = LIFTED - - while not self._stop: - try: - _, _, e_type, e_code, e_value = struct.unpack('2IHHi', penstream.read(16)) - except struct.error: - return - except Exception as e: - log.error('Error in pointer worker: %s %s', type(e), e) - return - - # decoding adapted from remarkable_mouse - if e_type == e_type_abs: - - - # handle x direction - if e_code == e_code_stylus_xpos: - x = e_value - new_x = True - - # handle y direction - if e_code == e_code_stylus_ypos: - y = e_value - new_y = True - - # handle draw - if e_code == e_code_stylus_pressure: - if e_value > self.threshold: - if state == LIFTED: - log.debug('PRESS') - state = PRESSED - self.signals.onPenPress.emit() - else: - if state == PRESSED: - log.debug('RELEASE') - state = LIFTED - self.signals.onPenLift.emit() - - if new_x and new_y: - self.signals.onPenMove.emit(x, y) - new_x = new_y = False - - if e_type == e_type_key and e_code == e_code_stylus_proximity: - if e_value == 0: - self.signals.onPenFar.emit() - else: - self.signals.onPenNear.emit() - - -