From 5e05cd706893e3090ccbea8e4eab20f1d7bfc5f0 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Fri, 29 Jan 2021 21:50:00 +0100 Subject: [PATCH 01/61] Don't resize at autoresize if fullscreen --- src/rmview/rmview.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 6713ea9..13be9ab 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -156,6 +156,8 @@ def detectOrientation(self, image): 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(): From 6145d3e3e017f195e40441de95c9ef4511511c1b Mon Sep 17 00:00:00 2001 From: Emanuele Date: Sun, 31 Jan 2021 02:15:54 +0100 Subject: [PATCH 02/61] Forwarding key/pointer events - Refactoring to allow calling protocol methods in fbworker - Adding Pause/Resume - Forwarding keys and pointer events (shift -> pen, clicks -> touch) --- src/rmview/rmview.py | 34 ++++++++++++++++++++-- src/rmview/viewer.py | 29 +++++++++++++++++-- src/rmview/workers.py | 65 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 117 insertions(+), 11 deletions(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 13be9ab..0c1e33b 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import * from . import resources -from .workers import FrameBufferWorker, PointerWorker +from .workers import FrameBufferWorker, PointerWorker, KEY_Left, KEY_Right from .connection import rMConnect, RejectNewHostKey, AddNewHostKey, UnknownHostKeyException from .viewer import QtImageViewer @@ -31,6 +31,8 @@ class rMViewApp(QApplication): penworker = None ssh = None + streaming = True + pen = None pen_size = 15 trail = None # None: disabled, False: inactive, True: active @@ -80,6 +82,11 @@ def __init__(self, args): act.triggered.connect(self.cloneViewer) self.viewer.menu.addAction(act) ### + self.pauseMenu = act = QAction('Pause Streaming', self) + act.setShortcut('Ctrl+P') + act.triggered.connect(self.toggleStreaming) + self.viewer.menu.addAction(act) + ### self.viewer.menu.addSeparator() # -------------------------- ### act = QAction('Settings...', self) @@ -123,6 +130,8 @@ def __init__(self, args): act.triggered.connect(self.viewer.screenshot) menu.addAction(act) menu.addSeparator() + menu.addAction(self.pauseMenu) + menu.addSeparator() if not self.ensureConnConfig(): # I know, it's ugly @@ -224,7 +233,7 @@ def joinWorkers(self): @pyqtSlot(object) def connected(self, ssh): self.ssh = ssh - self.viewer.setWindowTitle("rMview - " + self.config.get('ssh').get('address')) + self.viewer.setWindowTitle("rMview - " + ssh.hostname) _,out,_ = ssh.exec_command("cat /sys/devices/soc0/machine") rmv = out.read().decode("utf-8") @@ -287,6 +296,12 @@ def connected(self, ssh): self.fbworker.signals.onNewFrame.connect(self.onNewFrame) self.fbworker.signals.onFatalError.connect(self.frameError) self.threadpool.start(self.fbworker) + if self.config.get("forward_mouse_events", True): + self.viewer.pointerEvent.connect(self.fbworker.pointerEvent) + if self.config.get("forward_key_events", True): + self.viewer.keyLeft.connect(lambda: self.fbworker.keyEvent(KEY_Left)) + self.viewer.keyRight.connect(lambda: self.fbworker.keyEvent(KEY_Right)) + self.penworker = PointerWorker(ssh, path="/dev/input/event%d" % (version-1)) self.threadpool.start(self.penworker) @@ -347,6 +362,21 @@ def cloneViewer(self): v.setImage(img) v.show() + @pyqtSlot() + def toggleStreaming(self): + if self.streaming: + self.fbworker.pause() + self.penworker.pause() + self.streaming = False + self.pauseMenu.setText("Resume Streaming") + self.viewer.setWindowTitle("rMview - " + self.ssh.hostname + " [PAUSED]") + else: + self.fbworker.resume() + self.penworker.resume() + self.streaming = True + self.pauseMenu.setText("Pause Streaming") + self.viewer.setWindowTitle("rMview - " + self.ssh.hostname) + @pyqtSlot() def openSettings(self, prompt=True): if prompt: diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py index f235542..478cc24 100644 --- a/src/rmview/viewer.py +++ b/src/rmview/viewer.py @@ -1,10 +1,15 @@ 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.QtWidgets import * class QtImageViewer(QGraphicsView): + pointerEvent = pyqtSignal(int, int, int) + _button = 0 + keyLeft = pyqtSignal() + keyRight = pyqtSignal() + zoomInFactor = 1.25 zoomOutFactor = 1 / zoomInFactor @@ -107,6 +112,24 @@ def updateViewer(self): def resizeEvent(self, event): self.updateViewer() + def mousePressEvent(self, event): + scenePos = self.mapToScene(event.pos()) + if int(event.modifiers()) & int(Qt.ShiftModifier): + self._button = 1 + else: + self._button = 4 + self.pointerEvent.emit(scenePos.x(), scenePos.y(), self._button) + + def mouseReleaseEvent(self, event): + scenePos = self.mapToScene(event.pos()) + self._button = 0 + self.pointerEvent.emit(scenePos.x(), scenePos.y(), 0) + + def mouseMoveEvent(self, event): + if self._button > 0: + scenePos = self.mapToScene(event.pos()) + self.pointerEvent.emit(scenePos.x(), scenePos.y(), self._button) + def mouseDoubleClickEvent(self, event): # scenePos = self.mapToScene(event.pos()) if event.button() == Qt.LeftButton: @@ -198,9 +221,9 @@ def actualSize(self): def keyPressEvent(self, event): if event.key() == Qt.Key_Left: - self.rotateCCW() + self.keyLeft.emit() elif event.key() == Qt.Key_Right: - self.rotateCW() + self.keyRight.emit() elif event.key() == Qt.Key_F: self.setFit(True) elif event.key() == Qt.Key_1: diff --git a/src/rmview/workers.py b/src/rmview/workers.py index 9c5f1f1..89bf9ad 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -14,7 +14,7 @@ from twisted.internet.protocol import Protocol -from twisted.internet import protocol, reactor +from twisted.internet import protocol, reactor, threads from twisted.application import internet, service from .rfb import * @@ -36,15 +36,22 @@ class RFB(RFBClient): img = QImage(WIDTH, HEIGHT, IMG_FORMAT) painter = QPainter(img) + def __init__(self, signals): + super(RFB, self).__init__() + self.signals = signals + + def emitImage(self): + self.signals.onNewFrame.emit(self.img) + def vncConnectionMade(self): - self.signals = self.factory.signals + # 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 + # time.sleep(.1) # get first image without artifacts self.framebufferUpdateRequest() def sendPassword(self, password): @@ -61,11 +68,17 @@ def updateRectangle(self, x, y, width, height, data): class RFBFactory(RFBFactory): protocol = RFB + instance = None def __init__(self, signals): super(RFBFactory, self).__init__() self.signals = signals + def buildProtocol(self, addr): + self.instance = RFB(self.signals) + self.instance.factory = self + return self.instance + def clientConnectionLost(self, connector, reason): log.warning("Connection lost: %s", reason.getErrorMessage()) connector.connect() @@ -78,6 +91,8 @@ def clientConnectionFailed(self, connector, reason): class FrameBufferWorker(QRunnable): _stop = False + vncClient = None + ignoreEvents = False def __init__(self, ssh, delay=None, lz4_path=None, img_format=IMG_FORMAT): super(FrameBufferWorker, self).__init__() @@ -89,7 +104,10 @@ def __init__(self, ssh, delay=None, lz4_path=None, img_format=IMG_FORMAT): def stop(self): self._stop = True log.info("Stopping framebuffer thread...") - reactor.callFromThread(reactor.stop) + try: + self.vncClient.disconnect() + except Exception: + reactor.callFromThread(reactor.stop) try: self.ssh.exec_command("killall rM-vnc-server-standalone", timeout=3) except Exception as e: @@ -110,12 +128,39 @@ def run(self): while self._stop == False: log.info("Starting VNC server") try: - self.vncClient = internet.TCPClient(self.ssh.hostname, 5900, RFBFactory(self.signals)) - self.vncClient.startService() + self.factory = RFBFactory(self.signals) + self.vncClient = reactor.connectTCP(self.ssh.hostname, 5900, self.factory) reactor.run(installSignalHandlers=0) except Exception as e: log.error(e) + @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 pause") + + # @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.factory.instance.keyEvent, key) + reactor.callFromThread(self.factory.instance.keyEvent, key, 0) + class PWSignals(QObject): onFatalError = pyqtSignal(Exception) @@ -140,6 +185,14 @@ def __init__(self, ssh, path="/dev/input/event0", threshold=1000): self.threshold = threshold self.signals = PWSignals() + @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 From 74de306211410b582ec92666e75b53c3e9953990 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Mon, 1 Feb 2021 21:12:02 +0100 Subject: [PATCH 03/61] Better handling of disconnection --- src/rmview/workers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index 89bf9ad..e214f0b 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -81,7 +81,7 @@ def buildProtocol(self, addr): def clientConnectionLost(self, connector, reason): log.warning("Connection lost: %s", reason.getErrorMessage()) - connector.connect() + reactor.callFromThread(reactor.stop) def clientConnectionFailed(self, connector, reason): self.signals.onFatalError.emit(Exception("Connection failed: " + str(reason))) @@ -109,7 +109,7 @@ def stop(self): except Exception: reactor.callFromThread(reactor.stop) try: - self.ssh.exec_command("killall rM-vnc-server-standalone", timeout=3) + 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.") @@ -120,13 +120,15 @@ def stop(self): @pyqtSlot() def run(self): try: + log.info("Starting VNC server") _,_,out = self.ssh.exec_command("$HOME/rM-vnc-server-standalone") log.info(next(out)) except Exception as e: self.signals.onFatalError.emit(e) + return while self._stop == False: - log.info("Starting VNC server") + log.info("Connecting to VNC server") try: self.factory = RFBFactory(self.signals) self.vncClient = reactor.connectTCP(self.ssh.hostname, 5900, self.factory) From c38c44d5db3d83d6fad30ded2cde2fbf0de188f1 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Mon, 1 Feb 2021 21:30:03 +0100 Subject: [PATCH 04/61] Handle key input emulation via shortcuts --- src/rmview/rmview.py | 23 +++++++++++++++-------- src/rmview/viewer.py | 10 ++-------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 0c1e33b..e2d6871 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import * from . import resources -from .workers import FrameBufferWorker, PointerWorker, KEY_Left, KEY_Right +from .workers import FrameBufferWorker, PointerWorker, KEY_Left, KEY_Right, KEY_Escape from .connection import rMConnect, RejectNewHostKey, AddNewHostKey, UnknownHostKeyException from .viewer import QtImageViewer @@ -117,11 +117,11 @@ def __init__(self, args): # Setup global menu menu = self.bar.addMenu('&View') act = QAction('Rotate clockwise', self) - act.setShortcut('Ctrl+Right') + act.setShortcut('Ctrl+R') act.triggered.connect(self.viewer.rotateCW) menu.addAction(act) act = QAction('Rotate counter-clockwise', self) - act.setShortcut('Ctrl+Left') + act.setShortcut('Ctrl+L') act.triggered.connect(self.viewer.rotateCCW) menu.addAction(act) menu.addSeparator() @@ -131,7 +131,18 @@ def __init__(self, args): menu.addAction(act) menu.addSeparator() menu.addAction(self.pauseMenu) - menu.addSeparator() + act = QAction('Emulate Left Button', self) + act.setShortcut('Ctrl+Left') + act.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Left)) + menu.addAction(act) + act = QAction('Emulate Right Button', self) + act.setShortcut('Ctrl+Right') + act.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Right)) + menu.addAction(act) + act = QAction('Emulate Central Button', self) + act.setShortcut('Ctrl+Esc') + act.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Escape)) + menu.addAction(act) if not self.ensureConnConfig(): # I know, it's ugly @@ -298,10 +309,6 @@ def connected(self, ssh): self.threadpool.start(self.fbworker) if self.config.get("forward_mouse_events", True): self.viewer.pointerEvent.connect(self.fbworker.pointerEvent) - if self.config.get("forward_key_events", True): - self.viewer.keyLeft.connect(lambda: self.fbworker.keyEvent(KEY_Left)) - self.viewer.keyRight.connect(lambda: self.fbworker.keyEvent(KEY_Right)) - self.penworker = PointerWorker(ssh, path="/dev/input/event%d" % (version-1)) self.threadpool.start(self.penworker) diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py index 478cc24..0e1d252 100644 --- a/src/rmview/viewer.py +++ b/src/rmview/viewer.py @@ -7,8 +7,6 @@ class QtImageViewer(QGraphicsView): pointerEvent = pyqtSignal(int, int, int) _button = 0 - keyLeft = pyqtSignal() - keyRight = pyqtSignal() zoomInFactor = 1.25 zoomOutFactor = 1 / zoomInFactor @@ -114,7 +112,7 @@ def resizeEvent(self, event): def mousePressEvent(self, event): scenePos = self.mapToScene(event.pos()) - if int(event.modifiers()) & int(Qt.ShiftModifier): + if int(event.modifiers()) & int(Qt.ControlModifier): self._button = 1 else: self._button = 4 @@ -220,11 +218,7 @@ def actualSize(self): self.rotate(self._rotation) def keyPressEvent(self, event): - if event.key() == Qt.Key_Left: - self.keyLeft.emit() - elif event.key() == Qt.Key_Right: - self.keyRight.emit() - elif event.key() == Qt.Key_F: + if event.key() == Qt.Key_F: self.setFit(True) elif event.key() == Qt.Key_1: self.actualSize() From 12e456cd31734241d0cb77d74d36683fa1e60c75 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Mon, 1 Feb 2021 23:59:12 +0100 Subject: [PATCH 05/61] Refactor actions and shortcuts --- src/rmview/rmview.py | 102 +++++++++++++++++++++++-------------------- src/rmview/viewer.py | 66 +++++++++++++++++----------- 2 files changed, 95 insertions(+), 73 deletions(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index e2d6871..bfa3b3c 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -78,27 +78,53 @@ def __init__(self, args): 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.pauseMenu = act = QAction('Pause Streaming', self) - act.setShortcut('Ctrl+P') - act.triggered.connect(self.toggleStreaming) - self.viewer.menu.addAction(act) + self.pauseAction = QAction('Pause Streaming', self.viewer) + self.pauseAction.setShortcut('Ctrl+P') + self.pauseAction.triggered.connect(self.toggleStreaming) + self.viewer.addAction(self.pauseAction) ### - self.viewer.menu.addSeparator() # -------------------------- + self.settingsAction = QAction('Settings...', self.viewer) + self.settingsAction.triggered.connect(self.openSettings) + self.viewer.addAction(self.settingsAction) ### - act = QAction('Settings...', self) - act.triggered.connect(self.openSettings) - self.viewer.menu.addAction(act) + self.quitAction = QAction('Quit', self.viewer) + self.quitAction.setShortcut('Ctrl+Q') + self.quitAction.triggered.connect(self.quit) + self.viewer.addAction(self.quitAction) ### - self.viewer.menu.addSeparator() # -------------------------- + 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) + ### + 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) ### - act = QAction('Quit', self) - act.setShortcut('Ctrl+Q') - act.triggered.connect(self.quit) - self.viewer.menu.addAction(act) + 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() @@ -114,35 +140,17 @@ def __init__(self, args): self.autoResize(HEIGHT / WIDTH) self.orient = True - # Setup global menu - menu = self.bar.addMenu('&View') - act = QAction('Rotate clockwise', self) - act.setShortcut('Ctrl+R') - act.triggered.connect(self.viewer.rotateCW) - menu.addAction(act) - act = QAction('Rotate counter-clockwise', self) - act.setShortcut('Ctrl+L') - 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() - menu.addAction(self.pauseMenu) - act = QAction('Emulate Left Button', self) - act.setShortcut('Ctrl+Left') - act.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Left)) - menu.addAction(act) - act = QAction('Emulate Right Button', self) - act.setShortcut('Ctrl+Right') - act.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Right)) - menu.addAction(act) - act = QAction('Emulate Central Button', self) - act.setShortcut('Ctrl+Esc') - act.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Escape)) - menu.addAction(act) + # # 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 @@ -375,13 +383,13 @@ def toggleStreaming(self): self.fbworker.pause() self.penworker.pause() self.streaming = False - self.pauseMenu.setText("Resume Streaming") + self.pauseAction.setText("Resume Streaming") self.viewer.setWindowTitle("rMview - " + self.ssh.hostname + " [PAUSED]") else: self.fbworker.resume() self.penworker.resume() self.streaming = True - self.pauseMenu.setText("Pause Streaming") + self.pauseAction.setText("Pause Streaming") self.viewer.setWindowTitle("rMview - " + self.ssh.hostname) @pyqtSlot() diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py index 0e1d252..2422c79 100644 --- a/src/rmview/viewer.py +++ b/src/rmview/viewer.py @@ -1,5 +1,5 @@ from PyQt5.QtCore import Qt, QRectF, pyqtSignal, QT_VERSION_STR -from PyQt5.QtGui import QWindow, QImage, QPixmap, QTransform, QIcon +from PyQt5.QtGui import QWindow, QImage, QPixmap, QTransform, QIcon, QKeySequence from PyQt5.QtWidgets import * @@ -24,39 +24,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 From 254d5f54ea7b4b7aff9165e218aeaa489752d59c Mon Sep 17 00:00:00 2001 From: Emanuele Date: Wed, 3 Feb 2021 15:26:29 +0100 Subject: [PATCH 06/61] Proper handling of reactor thread --- src/rmview/workers.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index e214f0b..11fcfeb 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -105,7 +105,7 @@ def stop(self): self._stop = True log.info("Stopping framebuffer thread...") try: - self.vncClient.disconnect() + reactor.callFromThread(self.vncClient.disconnect) except Exception: reactor.callFromThread(reactor.stop) try: @@ -160,8 +160,12 @@ def pointerEvent(self, x, y, button): def keyEvent(self, key): if self.ignoreEvents: return - reactor.callFromThread(self.factory.instance.keyEvent, key) - reactor.callFromThread(self.factory.instance.keyEvent, key, 0) + reactor.callFromThread(self.emulatePressRelease, key) + + def emulatePressRelease(self, key): + self.factory.instance.keyEvent(key) + # time.sleep(.1) + self.factory.instance.keyEvent(key, 0) class PWSignals(QObject): From 3811abd2513e3dd8fd1d020be7bd3c7043c51767 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Wed, 20 Jan 2021 15:48:18 +0100 Subject: [PATCH 07/61] New auto orientation option - "auto_on_load" detects orientation once at startup - "auto" detects orientation at each update Todo: make detection more reliable --- src/rmview/rmview.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index bfa3b3c..0bf58c1 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -129,16 +129,16 @@ def __init__(self, args): self.viewer.setWindowTitle("rMview") self.viewer.show() - self.orient = None + 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 + self.orient = 1 if orient == "auto_on_load" else 2 # # Setup global menu # menu = self.bar.addMenu('&View') @@ -336,9 +336,10 @@ def connected(self, ssh): @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() From 1327759f4f8e204a5b19ecfaae43b3fd198943f1 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Mon, 1 Feb 2021 02:25:06 +0100 Subject: [PATCH 08/61] More principled detection of orientation --- src/rmview/rmparams.py | 57 +++++++++++++++++++++++++++++++++++++++++- src/rmview/rmview.py | 35 ++++++++++++-------------- src/rmview/viewer.py | 6 +++++ 3 files changed, 78 insertions(+), 20 deletions(-) diff --git a/src/rmview/rmparams.py b/src/rmview/rmparams.py index 4e81ae5..c68a5e5 100644 --- a/src/rmview/rmparams.py +++ b/src/rmview/rmparams.py @@ -19,4 +19,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 0bf58c1..7872b18 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -32,6 +32,7 @@ class rMViewApp(QApplication): ssh = None streaming = True + right_mode = True pen = None pen_size = 15 @@ -70,6 +71,7 @@ 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')) @@ -162,26 +164,21 @@ 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): diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py index 2422c79..cef2cad 100644 --- a/src/rmview/viewer.py +++ b/src/rmview/viewer.py @@ -186,12 +186,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 From 85e42d69aabc30a22ab0aa97f72328bc31d99d8c Mon Sep 17 00:00:00 2001 From: Emanuele Date: Mon, 1 Mar 2021 16:31:22 +0100 Subject: [PATCH 09/61] Properly saving and merging known_hosts --- src/rmview/connection.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/rmview/connection.py b/src/rmview/connection.py index 3f00e6c..623cecf 100644 --- a/src/rmview/connection.py +++ b/src/rmview/connection.py @@ -60,6 +60,7 @@ 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, onConnect=None, onError=None, host_key_policy=None, known_hosts=None, **kwargs): @@ -75,15 +76,12 @@ def __init__(self, address='10.11.99.1', username='root', password=None, key=Non self.client = paramiko.SSHClient() if host_key_policy != "ignore_all": + self.client.load_system_host_keys() + self._known_hosts = known_hosts if known_hosts and os.path.isfile(known_hosts): - self.client.load_host_keys(known_hosts) + self.client.load_system_host_keys(known_hosts) + self.client.load_host_keys(known_hosts) # to preserve them when saving log.info("LOADED %s", known_hosts) - else: - # 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 - self.client.load_system_host_keys() - policy = HOST_KEY_POLICY.get(host_key_policy, RejectNewHostKey) self.client.set_missing_host_key_policy(policy()) @@ -128,6 +126,11 @@ def run(self): 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: + 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') From 5cb72886c2de23b43cc85815b8650316040ff890 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Mon, 1 Mar 2021 16:32:13 +0100 Subject: [PATCH 10/61] Render hint flags --- src/rmview/viewer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py index cef2cad..22459d9 100644 --- a/src/rmview/viewer.py +++ b/src/rmview/viewer.py @@ -1,5 +1,5 @@ from PyQt5.QtCore import Qt, QRectF, pyqtSignal, QT_VERSION_STR -from PyQt5.QtGui import QWindow, QImage, QPixmap, QTransform, QIcon, QKeySequence +from PyQt5.QtGui import * from PyQt5.QtWidgets import * @@ -15,6 +15,9 @@ def __init__(self): QGraphicsView.__init__(self) # self.setAttribute(Qt.WA_OpaquePaintEvent, True) + self.setRenderHint(QPainter.Antialiasing) + self.setRenderHint(QPainter.SmoothPixmapTransform) + self.scene = QGraphicsScene() self.setScene(self.scene) From 01165fe96c976133e16e606570fe152a90bbace3 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Mon, 1 Mar 2021 16:43:21 +0100 Subject: [PATCH 11/61] Show pen delay --- src/rmview/rmview.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 7872b18..a94fbc3 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -15,6 +15,7 @@ import os import json import re +import time import logging logging.basicConfig(format='%(message)s') @@ -320,6 +321,8 @@ def connected(self, ssh): 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) @@ -327,7 +330,7 @@ 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) @@ -343,12 +346,21 @@ def onNewFrame(self, image): 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) @@ -367,6 +379,10 @@ 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): From 939d1b0a6a94b38b3ef9a6e102d1424be748952d Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 4 Mar 2021 16:05:51 +0100 Subject: [PATCH 12/61] Remove frame around QGraphicsView On some platforms there's an unwanted border otherwise. --- src/rmview/viewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py index 22459d9..b18eb65 100644 --- a/src/rmview/viewer.py +++ b/src/rmview/viewer.py @@ -13,7 +13,7 @@ class QtImageViewer(QGraphicsView): def __init__(self): QGraphicsView.__init__(self) - # self.setAttribute(Qt.WA_OpaquePaintEvent, True) + self.setFrameStyle(QFrame.NoFrame) self.setRenderHint(QPainter.Antialiasing) self.setRenderHint(QPainter.SmoothPixmapTransform) From 88d998b9928ce44808f303346d07f7a8e7b94538 Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sat, 13 Mar 2021 23:23:10 +0100 Subject: [PATCH 13/61] Don't log raw password to the console. --- src/rmview/rmview.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 6713ea9..8024253 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -15,6 +15,7 @@ import os import json import re +import copy import logging logging.basicConfig(format='%(message)s') @@ -194,7 +195,11 @@ def ensureConnConfig(self): if not os.path.isfile(self.LOCAL_KNOWN_HOSTS): open(self.LOCAL_KNOWN_HOSTS, 'a').close() - log.info(self.config) + config_sanitized = copy.deepcopy(self.config) + if "password" in self.config.get("ssh", {}): + config_sanitized["ssh"]["password"] = config_sanitized["ssh"]["password"][:3] + "*****" + + log.info(config_sanitized) return True def requestConnect(self, host_key_policy=None): From 311c8b87ba86f2148b49ee9397ecdff257c7f8cd Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sat, 13 Mar 2021 23:27:05 +0100 Subject: [PATCH 14/61] Add support for specifing password for password protected key file in the config using ssh.password config option. --- src/rmview/connection.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/rmview/connection.py b/src/rmview/connection.py index 3f00e6c..8fef2bc 100644 --- a/src/rmview/connection.py +++ b/src/rmview/connection.py @@ -90,14 +90,19 @@ def __init__(self, address='10.11.99.1', username='root', password=None, key=Non 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") + + if password: + # password protected key file, password provided in the config + pkey = paramiko.RSAKey.from_private_key_file(key, password=password) + else: + 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: From 4b5041bcd4c45911e52590590a2d0ffde23b2c4a Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sat, 13 Mar 2021 23:28:13 +0100 Subject: [PATCH 15/61] Allow app to be stopped gracefully using CTRL+C when QT app main loop is running. --- src/rmview/rmview.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 8024253..b5fee8e 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -16,6 +16,7 @@ import json import re import copy +import signal import logging logging.basicConfig(format='%(message)s') @@ -199,7 +200,7 @@ def ensureConnConfig(self): if "password" in self.config.get("ssh", {}): config_sanitized["ssh"]["password"] = config_sanitized["ssh"]["password"][:3] + "*****" - log.info(config_sanitized) + log.info("Config values: %s" % (str(config_sanitized))) return True def requestConnect(self, host_key_policy=None): @@ -451,10 +452,18 @@ 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) QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling) - ecode = rMViewApp(sys.argv).exec_() + 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()) + app.startTimer(500) + ecode = app.exec_() print('\nBye!') sys.exit(ecode) From 3b3dcecdb4dce69868ba22256e1d0bc6964666f4 Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sat, 13 Mar 2021 23:38:07 +0100 Subject: [PATCH 16/61] Use larger default timeout. --- src/rmview/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rmview/connection.py b/src/rmview/connection.py index 8fef2bc..e075052 100644 --- a/src/rmview/connection.py +++ b/src/rmview/connection.py @@ -61,7 +61,7 @@ class rMConnect(QRunnable): _exception = 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 From c26efcdeade3dbd5c77baa517951be50b93c440a Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sat, 13 Mar 2021 23:58:11 +0100 Subject: [PATCH 17/61] Update some log statements, add a comment on slow host loading. --- README.md | 12 ++++++++++++ src/rmview/connection.py | 5 ++++- src/rmview/workers.py | 3 ++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 691842e..8080895 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,18 @@ If you use the "Add/Update" feature when prompted by rMview (for example after a :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. ## To Do diff --git a/src/rmview/connection.py b/src/rmview/connection.py index e075052..e77ac72 100644 --- a/src/rmview/connection.py +++ b/src/rmview/connection.py @@ -76,15 +76,18 @@ def __init__(self, address='10.11.99.1', username='root', password=None, key=Non if host_key_policy != "ignore_all": if known_hosts and os.path.isfile(known_hosts): + log.info("Using known hosts file: %s" % (known_hosts)) self.client.load_host_keys(known_hosts) log.info("LOADED %s", known_hosts) else: + log.info("Using system default known hosts file") # 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() - policy = HOST_KEY_POLICY.get(host_key_policy, RejectNewHostKey) self.client.set_missing_host_key_policy(policy()) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index 9c5f1f1..b18e388 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -101,6 +101,7 @@ def stop(self): @pyqtSlot() def run(self): + log.info("Starting VNC server") try: _,_,out = self.ssh.exec_command("$HOME/rM-vnc-server-standalone") log.info(next(out)) @@ -108,7 +109,7 @@ def run(self): self.signals.onFatalError.emit(e) while self._stop == False: - log.info("Starting VNC server") + log.info("Establishing connection to remote VNC server") try: self.vncClient = internet.TCPClient(self.ssh.hostname, 5900, RFBFactory(self.signals)) self.vncClient.startService() From ba98c0e28b56a512f350f8bde74315ccffc7127f Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sun, 14 Mar 2021 14:02:38 +0100 Subject: [PATCH 18/61] Add support for using an SSH tunnel. This should be preffered approach when connecting to remarkable over (W)LAN for security reasons. --- README.md | 19 +++- example_ssh_key_auth_with_ssh_tunnel.json | 17 ++++ setup.py | 2 +- src/rmview/rmview.py | 3 +- src/rmview/workers.py | 101 +++++++++++++++++++--- 5 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 example_ssh_key_auth_with_ssh_tunnel.json diff --git a/README.md b/README.md index 8080895..3ade531 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ 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) | The `address` parameter can be either: - a single string, in which case the address is used for connection @@ -121,6 +122,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,7 +143,6 @@ 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. @@ -158,6 +159,22 @@ 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 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/setup.py b/setup.py index 86c4587..38afe47 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ def run(self): 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', ], packages=['rmview'], - install_requires=['pyqt5', 'paramiko', 'twisted'], + install_requires=['pyqt5', 'paramiko', 'twisted', 'sshtunnel'], entry_points={ 'console_scripts':['rmview = rmview.rmview:rmViewMain'] }, diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index b5fee8e..1617147 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -287,7 +287,8 @@ def connected(self, ssh): self.openSettings(prompt=False) return - self.fbworker = FrameBufferWorker(ssh, delay=self.config.get('fetch_frame_delay')) + self.fbworker = FrameBufferWorker(ssh, ssh_config=self.config.get('ssh', {}), + delay=self.config.get('fetch_frame_delay')) self.fbworker.signals.onNewFrame.connect(self.onNewFrame) self.fbworker.signals.onFatalError.connect(self.frameError) self.threadpool.start(self.fbworker) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index b18e388..844eedc 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -5,13 +5,14 @@ from .rmparams import * import paramiko +import sshtunnel import struct import time import sys import os import logging - +import atexit from twisted.internet.protocol import Protocol from twisted.internet import protocol, reactor @@ -79,43 +80,117 @@ class FrameBufferWorker(QRunnable): _stop = False - def __init__(self, ssh, delay=None, lz4_path=None, img_format=IMG_FORMAT): + def __init__(self, ssh, ssh_config, delay=None, lz4_path=None, img_format=IMG_FORMAT): super(FrameBufferWorker, self).__init__() self.ssh = ssh + self.ssh_config = ssh_config self.img_format = img_format + self.use_ssh_tunnel = self.ssh_config.get("tunnel", False) + + self.vncClient = None + self.sshTunnel = None self.signals = FBWSignals() def stop(self): + if self._stop: + # Already stopped + return + self._stop = True + log.info("Stopping framebuffer thread...") reactor.callFromThread(reactor.stop) + try: + log.info("Stopping VNC server...") 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) + + if self.sshTunnel: + try: + log.info("Stopping SSH tunnel...") + self.sshTunnel.stop() + except Exception as e: + log.error(e) + log.info("Framebuffer thread stopped") @pyqtSlot() def run(self): - log.info("Starting VNC server") + # On start up we try to kill any previous "stray" running VNC server processes try: - _,_,out = self.ssh.exec_command("$HOME/rM-vnc-server-standalone") - log.info(next(out)) + self.ssh.exec_command("killall rM-vnc-server-standalone", timeout=3) + except Exception: + pass + + # If using SSH tunnel, we ensure VNC server only listens on localhost + if self.use_ssh_tunnel: + 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)) + + try: + _,_,out = self.ssh.exec_command(server_run_cmd) + log.info("Command output: %s" % (next(out))) except Exception as e: self.signals.onFatalError.emit(e) - while self._stop == False: - log.info("Establishing connection to remote 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) + # Register atexit handler to ensure we always try to kill started server on exit + atexit.register(self.stop) + + if self.use_ssh_tunnel: + tunnel = self._get_ssh_tunnel() + tunnel.start() + + self.sshTunnel = tunnel + + log.info("Setting up SSH tunnel %s:%s <-> %s:%s" % ("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 + + while not self._stop: + log.info("Establishing connection to remote VNC server to %s:%s" % (vnc_server_host, + vnc_server_port)) + try: + self.vncClient = internet.TCPClient(vnc_server_host, vnc_server_port, RFBFactory(self.signals)) + self.vncClient.startService() + reactor.run(installSignalHandlers=0) + except Exception as e: + log.error("Failed to connect to the VNC server: %s" % (str(e))) + + 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"] + + 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',), + **open_tunnel_kwargs) + + return tunnel class PWSignals(QObject): From 64d14bae7a0f9152697ed8fce8e9a6c3e6f735f0 Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sun, 14 Mar 2021 14:52:13 +0100 Subject: [PATCH 19/61] Display connecting screen until we successfully connect. --- assets/connecting.png | Bin 0 -> 33111 bytes resources.qrc | 3 ++- src/rmview/rmview.py | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 assets/connecting.png diff --git a/assets/connecting.png b/assets/connecting.png new file mode 100644 index 0000000000000000000000000000000000000000..a2ff231a6d6a9f83921511b683ff60ca22bcf408 GIT binary patch literal 33111 zcmeIb2UJwswk^7hs3@q2fJ6hL2oyO7Q2`YcBuLH_h(HxkHn7VdM-KL6c+-@onu`&)ajQM>I1RkhY!bImzMAAR&O76z$ZRob`b z$Q}ei_FcMoK^;MMcp(Ut+OD1O3EuCH9D?lR^w89CQa5$wuy@2;TH9hdoZRiP99TDN zO9XN2n$W%$IU%}dt)1I)_tq0z>gCU+K4oo~A%0$TX&&>rq!oE-c&yW#H{bT?4|^nP zEzvh|2E)R0M|te#r<&K|PsNB8Vc**iG(W!SnbG__ZDDadYWcRV_avjn?Q2G~wk3)y z(Y}}^#?$ugzWXi4)=Yf7e@uvEd(%~B%4aAp%J$!0<-NEx@7K3=0DGMkx01A*P+q;b z62&}qX7THIT9@2f=9H1k`0)n)i`*GJtLkSbBZ9Z%wrfZFGdNUv*BJbm4!C6h8Nd8y z{h8KscwK5D#y5siLf#o1+V1tCQrjxLd0LG4#y8fNnkOQ}w?ns$*kt@GqU_U9>_ZYM z?!9A^k1m5;a`UhpC$7wSs~@`^O_<_w7`S z#~jG{CY6U{sonPeM^*TAmB?2wYQKp}pBa0i{ro(L zcc1iZ54zmBG#Msxxw*J!StKlO2S?G~Yi9=!R-_)yI_mSD$?R0oT$?AmVX-qwz~X&N z4WlG(hML~4%lUQc8=GjI9NeeY^k{wYVh#xx_6*!4+pR0xBDbY&!(32F5bPc&sKkqU zSD#x-&s#a79QAnkOr*v^_hU*o^*pLyg{gJfEz0EKy2}PRTEpX9R+y9`SmmaJjyw)< znfta}b1jLsbxD4->~-R6(>J35yNX6$8aMbciftY6IB@ptY*qS^-0IFs$@5>8@P+!sS9aKS_;C&ZSU`t)I;>_`OauRCjOE^*)&?zyB=n;L#V4sGci- z8f)by{`yE~yLA4_%Aomj4zlhEmyYZP&Z)69zZE#Xys+ivNW z(2*x5br*-Yq;BaI=GfchMJ1eOmDs=i`s41%j9t6#RT|bzj)gi)2IlE8zvsNl8r7un z@ySw;W+3t0fh`o~KvwEnC05QV#j9i4O?&2xg)Vk#1jw&Q@%S6R0G&rBGvXwny`OE%2eP4_9 z%jvSBiqEfI9wv18pE>@uMeETn)`xBiQrRtZ-&}ru8gcO8dC4P=m9jPX^i&6T&gI_N zp;{HLY(^tp;gZl#YkgeLYpu<%_V1IjzZ>7Q@1E<{ye32_@+3?4$@H<}&Zp8Pv?tnc zMP8YzyDKt&|C&sD+|GKcn>2!1eX2;b^DcjJbu9j&M(#w=%N}XR_lI`n2|ji2$!?ya z{kk%-{*x5pIO?g)O>8X-ACtqQRqx)aNhS*uIZZP zClgs&%c!Dda8AQr6|Xbx=;*KdSzHNdrN_?Y*_Ov`Eg{}0=G?=Ps>{u;jw_dTx^`9eG3MXt>irzTA&*P@^ z=3ky5%WpfxtRo+Y2>4e?nCQ_I8+Gh@eCXmA@2#V0NyA%*M?bDqyL`EB&itr6C!)U@ zb4bY~V)V^qj)|6w;ga6$@!z}ErYcT5i(6oI^i+O!9#}cmOnvC>nP2j?rAxoD-!(7# zSrcw8mQGK^5MH#Oy*RH(cVV3oU-tGGx;m3j3z50&cz-hE_2kso0@z0@h6MvU7;VxK;QJ8)AjHq^^JZ2Zo z+wNr6#aP^zEaC8+gSQ5VXKM+}E*AbC-xQT7*g)Q~R++FbbhlhCo}_-jTtrQMkNntbXIb;0Fh}zFyCs(rv4a+!~Du+n?Qh^57EZJp&)D zU1i7ow%mPiR>ka!0${70rpT=r)vwH~ zydK;4;tylgm)>gwmcba||IBWF~J7HpZeIEA2gfU1V`@B)e5>Wv96K zb~Da8qeVAYV^=&S!Fq9VTli(i>_x9G2;;ON))2sTuBb?wdUW7;BD_^;TX$KJ5M=;4Uf@VKgJ?qOptVR1}O zc8|21Bn)7Obu#5}v$Mq!B;90=ZHy}kzoQ>xj&W=p;$$OpOy`Ok$9cRXmP43Nn2(=V z!Ohx5@R;l#4rxaVOG)($ihm6O?_`eMaB{Mj#9&-qUHM#v`0$Qa7y$_h2@Jm=Mo^F! zj^HJ@?J6hX2S>tgW=$NKvcxNY>W5?ioj=u(nZ_O3l;KRQz zfWF@tp5SDGxdd;7;eOx%j37V1I4{2-ub>3x@8`p}SFZf?Y#iaQRfP4#xS85x1o-$d zc6R@E2Ej?eWI0*}I|V+nX?M{}%#3l`^e{O=dFw{<4`{hH1MEc(&LylpKk zF)*o(EC2l*rAt@T{&^0%j8@im_8VuQNB{ju3-f;-XYcH2yK#(#IR zFpj9On1q?2gb=Tws2L1lDJIS=TmibMJ@XOUFFn{S-BaWK{b^|!w~?wqL=X5+0i=07|5e;P^S z2Hq9-|7@Ip9s18vk8%xE(u>k^-rsjW^pJ3{OwbWceM zFaIAL_-6zaYjZ4)fd9{?{`W)vEX&{G4$S$_XAsXIJYxPCAOA87)N}qDUw@g~f8!Nk z=*^4#m)3W4xi**UzqA7XrGq!EYje5&ODphSI(XB%{y$r;J^#wEu{a=lu8=>kaOrkK z?!D7YS?K~oLH~PJn(`1n*=>JOmw+JDbm;$Ekf(8s@FA7cr7H?l!`o=~2{I__;-e6R z1G#iTUem2>yhHv#1wZoXo%55~Ikwjr{o%Lk z69f8_(eF4!{gH z$-!pofSSQ3IoKozU%%ng-zvPQ#k-L*dz!4A_oQIy`El%*m%r3 zp8AwZ)l1f7kE;T^KP$XDu|w%my)ZT$bHVb)=z(eeiw7}-`(EvSVX!^YKK8It*LLZ_ zP=a{xhZxgv8qx0uVv;%xxi}ey_a%2b%r=VEJQcCo zdkRq#MUm~)7J0n;%WosUfB%ktFO=*`>SJ~7bGPErdw(jyYv$6nJ;z4daF~0bcT-W_9T>ZE0x{81*PBE>6nK#D7d68m|u< zKRGd_mu(Rg^6KvP(qW-atIOr9GxgK$zH9B%-!pwgMD4mP&TjD&5YvL)dDt?NlE}tR zS*lSoRQ2`sU7a_k%-@x+fth8VFeSyi_A&AD^71`B2+I-yyZHLeo1ldS7gPLhXIEFH zD_5=vi-=sQdnfj=_JWdsGsG3kl^NsXxJ#mC2^`%)Q#DHBii=<4cv zQCKKJBQ^eRx3iD7vG@GzF`Y6m_c&NOz9$F4NHDeOjtlKyzU+tHOKc^o85*+c=h-qj z%V@k(agaxlBr|`wdF#e#f#YQhxtU=_2@FPVrXP9Tr%FlQ7&iBz2QTVXV?}w{+!&xYs-I-3k6=9@D7xRsw*L0P`LZ?+kQ9@M+ zLrq0R#a)TaOt0ZH3Q_R_=8CIxDRN=oRONbisGozoegNi?ntIvlk&dCEA=tp~wXIbT z;vA}(ravE#sJy1ciiH(+-~_umS?aqK;2W4+BrM#$YOTFaE2t%N7iZOs(I)E8y|?hD14I7#@qo!tgh2ZR6e(?Z>lfs*@sQkEA4c zq;A=PREMGFHMc%ROW}XQXDA7FP*gNfNK9Z}oGo1FvI*fM*Q)vmg7JtmC@^VVzpkjN zs`@@RcO+EsVzN4CVPZ}F+{m}V6uC8Pucc4yqR~i20UD}<>|q^h;Ny{N-?PkX!X121 zx3jwT9H6X|D7dp+w_qVJv8r6PfOU9Sgzum77pv@b?Pq0Q`>jOHHRZ6o*L=)8Q3+DNzQDh6k11mWifSM@vELui(nuGmH!dPyIQDF}9W%n3nET2Y{p zc%PRSen~37&%?&HGg~2G8`#)Mn9=8-ElTWR?5jVhuyzlB0#a@vdB(^8UuBB4=Qe3SNwr{Ca9r=a>#)Fmb;|%C77E(w6{PP0e`J^ba6N z#TFHyRwpR532!t8tC{gUMPA0n#$JQW{ij9*>^j~ZU0dnFjta94Ic$j$bQc1Gy>x(}@(;L&GK5F z`8_cargAa|b2lSBJrurqoskh_q_9YPWYhRxkzylexLMz2pz_;5)7D?Mmm*pN9*&s?@CHwwr2eYtPNk z@AFiJO;_Z8+(qgb8yz)UUtQ)bInABD?#Xgd-5Fh9U;k_&NDe1V>9J-`^QFLh@8+Qo_sd|F!PZf; zhC4Ew>?yovY)oR;K%&R9k&2h1{QUQ?B&&DAceWQSjvte9o$NWfXqbCbD=$0SvEQfA z2TsmOyZ)AYNoQqcB^yxKJy&2!Lt8s)VtkxO*B7?#C^^?*=z-E?w@)>b)sVMsIYr~z zwFiwBf=RN~{{H?)#}Y+suDd|E>9iG%l_8bP21{0tWfW)Fw(D^}u50`Loxax>oVc^B ztW2N~<3rIQ^wMqHw#_|W1D4LEGaE~OY@B7L@L}ysTbt;>bf#YJ&C0<#XO(2Hy!ZL} z<&&N2pH<<=ywXw`DSm!6O>**hH;i?=DY>XfGWK$^VsAjZp{wBs;ya(UmB?^YD=RDc zMLV#AyyWEM-wxZ5>O@Xf#3XX`U$N!Yt5*lgulII$Gec0XwHzIt%X@%i96i%$%jlix`|+t0#;3N zCE>mwZtsxDOlg@jx3RH7PxSQkY*7}q5R8E+)jVcU$N4AdHN*&|JR^+2J;3B4J~BNd zs!KRIIbDFIegFRbp;pS;+FDCzCzFVXNK0?;1iL;GeHYxHzm~_iOd4eb@Wq46$umZo zV*T&L2IR`x;xZEw61-~!kKDQC1Q*vOlVnKls;eW3>((v9Q9QZ_V@2(_i&g<^WQRx* z038KO10h_kxuT5Hzh-ZpJF2Ir2T``rVm|rXh|W8c4`qIWiDM=A--#W2k(cMv@Z{x< zn>V>{P#~V)mbGBI3M+!%#1C5BjQe`%0@JnE=;h_{Swumj8lzyci!f2fEdnAv{T!YO z;THgCGdai@mwa7AgKpoxeLEV(nd5foeD-}uWL^>#6Jsz~yeKIprF>*3&G4^SJYUf` z%3)TdOoA3Iqb7 zGf_FYDD$#+|Eq7-&oPLKAjE*^3V^&MpdPPO6OHDy)2R39a&vPl?)4IiQGuJcs?^2P z)6-|!|NN)}CU23``Fm_EBrh-T_3PJ+c?R5%k2w2@#C7Ia)lin$DM`O)e98H~%z$M( zqR0ab<>Eu6{@H}Z%GKpru1XOx@mXhKQ`<60Ny$CyMPAR9WA-M4S^e;HOW)dW7DWpf zwfs6k$UN(1-H!m(VIRvUHi|gz$HBl=caup9u;WJ0=U^yva6tin{{7&SSxzHgU#F(l zr5SJWtCfc+K&J*z9Kpo7GS!^Lfn)86YUUbmB&JQ}=H|A5`@l!!z7->2PZJXpfXPaG zy^D*AE|-ap*z`JmyW;Rgo{C<}r8z;yVIsHvu;#A>&&i-f$|~_Zj=8buW3n*g=+K^Z zSfLkh-`W}bB{o{3lWFPb&~b`|2;n}V-JpK*{K&{iZE>UgEU`JFuC7ji`HmTQ$BU1o z4@}PsS8`HQxu5XrABIJG&}cbJvkUbR%-GB>AaX5eqSG?2L`}1DE&$AzKq|7!+WxgIPnk*U%E}FdlR`2oK^mG;%l>p`t`ZL!p z&Tw-FOa6LL?e-^T05to{)ELMqNAabIF+DT#{?qY`oj*bWK1{-i?+Q4&}dgS}o9s)eAv{CV$O_n|NV` z3l}!e&dDKEb_Oh;S{SyxS5j&JUh(O&Y}`2`?x@Sj{-yaxn3=;mGc^1*+;T^fd~&=m3E$kvMiix9e#9RpNXoEWppe`bf>|*MH?c zQa){ba2N6@0I1W+XrQbR{aLk6WqHhxz9{RHF?jd*YRc>ix0kuOIq-$y??$r5v{zHU zVaa>u!QKa{jhD`~3`4d@ssS!6IWCJPXlF7@96#Int`mLPa;%!!+Q;msu!f&@h=c9B z^ICuT@pa$+A2I$064868P7H^$PL_Jkj>*4`IZhCZl8@KG3WaHg*_!({GJ2z zHQAa97UNt&Kg~Y&J1;l43e3~2Dww{dt?e*117Cy09k&8X2j0%EF|F~O(ZXX=L#<=e zOzFyKnt9iIYlSHGH^!4CvrQfEOh5UQ%l&?KXTYBs$Jjn*YkzXd{(~00O%1+B&4BL2VlN$Pf>!$@hR6E?JM#OS3+sl3jtgt+Jhd?hmiXnkqimVY zRasd%9iqAB;#G?v8r-;5UPX|mc2SUT3W8<32a9cRZ*DH0bEK}jyi0R@#4j? z>*+cN0LWVU`q%*XfzPnR0;8b~B3#zZ@2TxO>R@&fRt&Gy-;@~L)zsJ5|I%JZRUZne zd$}#fJ{B^mK4qc3!?&o|0@>H60?y;Qb8WPnqKdsI35g+3pFVXcEj!Gk!#_}PSTf<> zr15JA^*VXBx*u{bgX5XiMsl|F_p<}G!G^piB|spK=4r6B-i#Sh^3mRnX zM0da}_9Xn&)7BOnNC(I)f?SEbHS?#X-*C>O^A{?s!L$)xO^b^%s?EW38-`b>MyZD$g%rKi{D;dsV`Qoe*?G(FSGh5yb_D2 zg)3M?rZNHZU~+r(sPi&1aFv`FP~r%z3gY;JH^1PMdWpT4lhsjLa(kh9Q5?2g(IEI) z&-1klo@KDqFWTshAfyzqx*qL+d1|gd!pmyX#nqKq+Cz=-=K{HDZkUrj)d+jknc+-Iu4}M&0;a{R1AXV+issyCehSvo#vDN*$ z1z~J+%hXub8(!Trog8I!M98X%r)X76+mIejNfs&i;)%SD_txKh$fy#KQ7{m^TcjF{ z5{Q%;fDz=wD@6>MVZ`<}T8sJlc{C(~@Acq=0sufEpN6bu{e@*I*npC*!)JByfuJkp+%`0HLrFKt($l*2Cw=6^!KAP_u<;02xR92!cA;vhf!| znorAXQ~7V-PWX8-X%`xa>d*}9HJ32n5vtze7gQBVgPgs3ufZq{q>DMs^l~Ma>;d0- zZC`o_t6*plJq89sFyZ?6Qs>Xk4pxm%>7xuBYhV5j`7jj~l+^Q+{NqTqxwH3`6RNsl71^|BtoevJ-}EuQDGAtP*78Cj zQ$HksKTytKAk9lF?d7sV3(WWu0W@1L8GR~+?EPW%u}w zn)=HkEYUn82r?wh)Q)H6R_iTx#rF-P{I7bvGbaS+3xj8QlNZ)}e0v;fs%qrcr%T3a zEprrdjcp~wzcj{6wF@yJ7q^2#bN`!K{lQ0O{XvtN>!X>MjfewPA+rhA(lJ;;CNY|a zUoCW=KKnveKD!s8I;kuPSv`IB%kxLcSDjFCv4ki?3Q}VCet%-V6#aF1mdG`8q}D<( z%W=34;2ro8oipowvJpGW?1|F%;4HXvr7s0Xnst$9UTCHchsIQvR9FE3IsA&TdXk>; z5(F_{!;08P@9;d%K=wiX>La(ew@1|0=R3OF6~tamX&mqgGizUGttZnX;!{AH{US#u zCayFn-)<5g3|;y`CC9kf=Sg}!DnX1R41@|bMYozit?Z0@cnK6!;DR@R;d<(WvU)w! zs1)-h|3t;qNsk{eaMGJMwP&|fY*7S&exxE9f3;QrC%$m_$u+975C%+wCec=uSlQau zbp#Cm8d&&O58he-eo-ePCKh$V zPZgqNPY(;>S3}Uqw-hw{c5-n!%(4IkMGSoGAkd&7#wat(%!v8D{pYq(Kt0Hr+42Li z09j8>@J`OThr;!)(*sowi@mNjeWjk%+^)b?tHQ+k#Gg0wKCG>blOP1s-2K&@$o?k= z$))}{b9zjcXv42&T6slR#T`waPkpu*yP4FP$Buea|(5O~p6u{v%g#|%-QZlwpJlA7A*`7YfO z@L!aszIkKa`ds@>)amHG!KJDYCqU3nd7TZAx+9u5PRIhcUp{sz^@dU~c{KuQc}>D&GPTfD9bfQy_9y2F%2$y!ydZ zHaIQ#-i%cGvf<-SWac<%TaMf=JN4hR9f>~$0u?>V`)B*gb8|&3o8ly%H@~^h^a_%p zbA5uS3A)9P2U2!%F~9=Y$4pL6mMr3L+=xC@PyguhQFfmTtCV{E&|HXKke+ZC^xUxY z--bMlKr!=8hyC;X{QTd}=eSZqcHo0JBRb%-p(O!b`6?ZPkoPSRElhsi&c34|Pm0x)>AvbZ-5hD}M~f(^MJ9aDx52bsiy zMRJ{XS)?|7{bq zrZ~egb|4pJQ4Kg9mh46S!rWYCiTmt9p|tUtdJMaZ=r1T~3_xWfeu{YQ#;*SE?y+Q5 zY*|e(aN8GN^(ibDpPQhyv?BWt6Rq#&u_wudm!TniP%Eq2!bV8+M_K$Wu=Z zly$~$*D~a*@;`|iiKa(e((ZV84=MvHHpq`j(qflqhJ*BTSdqxHuvVe358YA+<8z<= zH9_qgg)vSL(yaS~dcT1S!MdS3ib|ra3~KKn4H%FIP;orK>u9$_4XaqHi5Cz3?Bl7n zw(DcQPg-s~ae>pge{*^*gX9&YAK-cF9*-1gPS<99_^yMex8!`JLYb9@h6Xw)Y|;MS z-d+@B;})0e9-o1diWDTpsNN!gt$PHE6=*B!S@CvxFGAP*p$I`3FM&D)s@uyGIW}Z6 z8Q3j7eVnkUX!uxH%z*;|Y#uj2zpf(6QI^}+d?0uTIFAl3^OS<+iQ;;q7uWBM`SO9l z+fmV>T@}&}avrc0^zygs0ihIuwKp-0iwFy&Xf5wdlkuq(oD@{XCU-doLJ6Jx(SAwD z_XC|IWuD(|1O$GanaMtXq#G@6d3Xdrg+HjHFEy!bfFNWLqbs&+H)vz?Z95NwlfC?T z0XX&C!b14UnTo2Lwzez+C)T5Um!mpzZQ6axDHC{KW;t?a^T6CURZMM`7fN>_W{Uu( z9y>PBs-em|(q1Bl>(QAzl8VO10kFLYsEv7btQ|RWWDkuMRXZg;EiD9&Z#OiCa19}= zW<0I;p1%Q#Da$g~Xy8__!5vO5qble-6M2;2e&^k`Im9QLeDwgSv$%4V~@zysQgtqBwu`&S|QX{);sV%=OH*=CqDlfN~&(O=| zCc_8LnrqGjL0p!J-V2?k-6LaT%(D&+^WR!qHNS}HK&q}O_4x7QPEBsDubyTjUz2yR z*Q9Et`M)KA_@J!gj`Q~M2X1=TudGeeD ziIkG`E42LM-tJ1C$d5|L;7X>eBimd_rHhudP`!$r>-JsCq==B~jn^GJ7U~Wl*EKaC z@y_vnmw~)UE_FX%O~907G9|4}PGXAvxssildh^`&vgJAXtXm{CJ9_4uhsonTIaaC* zbcstBu1&B(F|QL!biTZ=OSATRGcz-^Gp)4i^sMSbd!$iW%_DS}hViD2 zjndw`E|WbmYqN5c+1q35s0#J7dKC%@pV^$g$aB&fvwKZ_h_iblS;D^ebKL>WC*0_5 zq9SI_q3-VPdN#N4Uu^Z?qfm`m@=>!0xvaz92;Uf z(vLeiXw6D~;xKv3g`>?6y=47R0e2&bR}UyHr#O?;`DWjNLq+;4-Fbda*0jFut$q$v zJ9&%ZXPf2ZilM5oTi_R}2zQ=)`rt|SDXrh#9UTQ2Y2Nu6n&DlZ#>I2BkSgyvk_pT? z(tE#13FryU zmVz-3%p@SG3?k*og`+zqqL9El%)~0oDc5!im3QBMov)C`^Owgl9Vr9v{qfk9(eB7&-gvzbS$=Gu5 z*+N+*b$xwil#GI607QqA=~G%p5jUI1@8{VD>>yVNpmJ~hKG}!gMx2bySG&rrt*x16 zinbsp(ZH;B7&-x9IH>rKlPN%X$XoBiWg>U*KDrK-QV^X`3SVonu)`L37hoVO1jA}v z9O>-`Kes71PdgL94OYOe9p0~n827+BC_<&%wx>V<5MtrGP|K%sNCtt=Hy7GXb<|Ir zwp$csQq{OlQD|jDs4`=Lx|PVeZ9`7-!DKetlvKV$e*wH|IUusbYqM$Rzqx5=04*)5 zY=~hE~waMkS4=n1g0eT5}-AyDEac8922leDW_XgoKD^eNL4kj5a5X zsO=F1o)aerh3FWxCN8iDIhGOxSl~3(d3N1SyP#AYq=U*G8qLzPDhBo+c1-6auBljo zRXvJkDnlxwJByrcynYEKG6{^Aq9P0kuhUS^t^&>TAZQ^$6W#eWP~K*sm6=oXS{pNt z(9N+NQLFBSGBr!5t;=V2jO{kWL>leJ2#vu*wRjqesT{-w^9UJHRA+P}dG;6g>!pG` z6#|P`g^H}+Bu`^6Xj_R$Tpi+?uEO|StAX2IO*suQH;ozbbJWkr8OA{kRwAF>b3ALK z!2_!0hH9m;V)*dH^|_RdigM-}u+vpg48nm0RYS3RFvcn&$asAw%AxXr651z05AKZW zTH-(<27V_+gul^HCf|DvFj>7aKV}YOV;UL-YVOd=GD5eGMG3i=6b?HsgJ#fBzgu~E z15aLpQ8=Ix1Ij)7P25uqHFGTCqlLKi9mrkw5h^B%(hbRjepmcmS-0HL3^fEt51 zj5Mf`xT0y>S0X`jgn2u3S*KKiUV}DAuzeTUFbG2pF_X*BE56^@D2Q6 zb(q749So0NVw$B$B}JUgnPHTCdm&!wLMgLP*zd6sl=rSokC z>nme2m0)`Z(0J;iDm{In$qxLUJP>$1{FHkvT0_b7nNO35vKNPs%z$oB15(vM32E@L zwowpFq>3}jATp;ZEwfA#`foJm1Pom|u~|^q@5nM&TAg$)4?$5Cc*sKnMUz`36^Nwg zot*PxwjKOK3pvfQQj_JBb+?h^D6_@MUbMqRr`*RI7z_RR$#T2yyrr)8vcoV9*6g?4 z_;T-#idn$8Fla-=KxMLUsh8x#Q0z(yf2*Hg4UT^7@#QM;>)1f@E6u@tIIUphRjlI^ zsnw}ZgOkOiaA*lT>H-aUupS|hHJ*~4x<%{ewgpj9d?K(Dshb$jX>MBnn$qmEkb||( z$*6mlri0Fg&zpHWmuBAQU8Kc7oXxX^l+rMV$0{1Sl z$dnOw?$KbfT{2sF4V$NJhaj!=RK@+8C76|^s_gQRX}yOvGg47$j!I%K@QFo}9p1fO zzwYZVXd}E&=x!A2X8}m7yWfpg`aqojUsAkgGsb67z>f;EYd(}F*D@iH$r5u6 zK=2=3RqpIV?>CN&feu9?BO7D~(`bK~RVRw9<^3{1@g){16;!hO(S9Zs5PUlba_EYp z&ddTi#lwY!)5A+^-Q{cgO>3DYGj-x?bKe4b7V`2-?K+}(w?bAAm&B37eYtm z5)o>0aouuDA82Xx*Iy@v2UBB8_=yYiW9oNSg^b-x{et0W($$3rb7=s z)qGfpx0evSqQx_x5qQ~NrL!FnE_$DXbk1vg}J>6csmBqz-=8KoZH ze^ID1fI5Pz6hh*{{%uI~g^lsyuBIjOWnkMl6NjbLKRZu-pn{>nI;!Kk0TK1SOtS#* z{)EHK>*XZdW=3;z?wLlxlazOvHB?nInWY>oou=5kfT`n7-`p!P_dsx)V?dIgjrYxG zxb7?V27KnU!xy|0J%+mS@wVwMj+sUJqVVyHV~3(VSO?Yf#MGB`E+5YoQFn_u1Oq-e zH{&&V52_@c{{lZ(K)$`jrNza?FY6^&Kd`TUc-K7}^Sl@sNL)7*Hff`?Aj|&bh?83E zAsmlw3?C)h;XU>H1{a{*Q53UBSUoVIx%8c7;}O=~2!GH9#Cz83_Z@_uQs<@#P!z9c z80cm%pjHC9M|0uYo0NnjSsUC$BA*rFIaNc)zV;~8G%p|d$mX;3bNUvo7x1DQcHf>x z&1dInWrSrxiTTwd`;~eZ)y|y*!he{hnjt9vmC{1 z6-~y+C>1f1BC<0NB8GSfz$t0;iD;l;r%baGaB{z*&1vG~4yRmY73D7>m67*k5TGk` z`YqlY?U^%LB%juJwO0xUDGOQ|iHvIpKJb9h4N5qjh`;X)mv1DYJHlr!$+TV;HaY1g z87N!`dd_kV!02w>!-&aO%Dzl$#4pPpl$Po%V!e+DWFS+6sDBKAcIIwWFJ;@E$6E%T zQpIEycK~|c`tW2ZQYPe#f7doOcm-0@e$7MQ(<@jBWB5hbp>9rzpxNa>)m z@rrtnFJ&zYBuzJfYI9Ow=`_T-O7PJ^XzvIr2Y3pD>@Zr+*C*Y$+~;%ARdCIvkn*LF zI6IL3&_sF=N*sYQo(q*|Uy^qDik8=My>WOqu9(-VuohiV+R!k{3A?|af;L`4BY0qb+5FpSl$^1Pa>)L?OE-W{ zxM@PH$NszlELIkX6CEY)gz=t2At+Wh>3*%7~>K{QtieH8Vp9D%f@o8q1k%x6{R}mDq%|T$6__R7)A5{Y@sr0?+bV?jpBtzNMy`_%~ zW$*e)(CLh?StcgREwBFo*$5@N161H<_q%}$>}Oy35+MByK@NU|zmjF5g7&_GwmJYg zk8NkSBsj@z_#PLnrHEA3gJTY_j^fv<0=T1*iX7h7o21^d8PLE@uj z&q{rmClHmd96GU4`-=P7*IL-CB79c|(QVWHLfy_+^!tU|k6Tm&KuW{KN=L5jAsD;c$gO-6v9_@s!5Kr z>Nff&1!d$}E)!jDlZC_qG@ChD1w-#N?0n9@8I0`Ewj( zI2&miBpv8FPj~mPi~4}yRipVPn)yKqNql+e;UH+qA!xsnfOBedjju5@*m#Ybvv)6k zEitC7w4-f8Zcu_X16G^`!2IXZ}26#a_N82!hP!~fr z0|2^kR6l@cK?VVPf4pFf>-46$AKC7Y& ze^<`m37(=D>T!XrYmX2QAZKZN6Ozx(u3%pzn*? z!tr31UC4@{*PMjhI|3TIeS=Y-Xq$lZx2J<bR+Tv@#5S~e1Y=Dw+&tkAEz z(TWdx%E6D&Oo6^OpF8~D?D?|);~yqnmvb3Ei~ciECilzdWy(H+EQp-EyP-B$-2J05 zNACw`f*0M#jfZGW&VhsgFO^;JC>s22gx#pCfXsOG&qKJICnK8+hHM(iregpoY=Xfi z7(i&*j2N3yauW;y4>y^?CKzl|W;9*cWCs85z+gdMEEDvE8|c3gu!$<0sIrMFo2atU z5x5Bkn_#dB2Ag2;|2r7mtf7IwF9iSK(XxL3;F-|>voz!_(Y%=8(2GtRQuif=s~0lP HncV&#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/src/rmview/rmview.py b/src/rmview/rmview.py index 1617147..61d5aa8 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -75,6 +75,7 @@ def __init__(self, args): 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')))) @@ -98,6 +99,9 @@ def __init__(self, args): self.viewer.setWindowTitle("rMview") self.viewer.show() + # Display connecting image until we successfuly connect + self.viewer.setImage(QPixmap(':/assets/connecting.png')) + self.orient = None orient = self.config.get('orientation', 'landscape') if orient == 'landscape': From 9db8efe631f422efef57a092d6bb86586efb42ee Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sun, 14 Mar 2021 15:00:11 +0100 Subject: [PATCH 20/61] Move loading of the system known hosts files from constructor to the run() method. Loading system hosts can easily take 10+ seconds and doing it inside the constructor and not inside run() which runs in a thread pool will block the app and make the main Window freeze since it blocks main qt event loop from running. --- src/rmview/connection.py | 54 +++++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/src/rmview/connection.py b/src/rmview/connection.py index e77ac72..e924da3 100644 --- a/src/rmview/connection.py +++ b/src/rmview/connection.py @@ -64,64 +64,84 @@ class rMConnect(QRunnable): 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.key = key + self.timeout = timeout + self.host_key_policy = host_key_policy + self.known_hosts = known_hosts + 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 main qt rendere loop try: self.client = paramiko.SSHClient() - if host_key_policy != "ignore_all": - if known_hosts and os.path.isfile(known_hosts): - log.info("Using known hosts file: %s" % (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) + if self.key is not None: + key = os.path.expanduser(self.key) - if password: + if self.password: # password protected key file, password provided in the config - pkey = paramiko.RSAKey.from_private_key_file(key, password=password) + pkey = paramiko.RSAKey.from_private_key_file(key, password=self.password) else: try: - pkey = paramiko.RSAKey.from_private_key_file(key) + pkey = paramiko.RSAKey.from_private_key_file(self.key) except paramiko.ssh_exception.PasswordRequiredException: - passphrase, ok = QInputDialog.getText(None, "Configuration","SSH key passphrase:", QLineEdit.Password) + passphrase, ok = QInputDialog.getText(None, "Configuration","SSH key passphrase:", + QLineEdit.Password) if ok: - pkey = paramiko.RSAKey.from_private_key_file(key, password=passphrase) + pkey = paramiko.RSAKey.from_private_key_file(self.key, password=passphrase) else: raise Exception("A passphrase for SSH key is required") else: pkey = None - if password is None: + if self.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, + 'username': self.username, + 'password': self.password, 'pkey': pkey, - 'timeout': timeout, + 'timeout': self.timeout, } except Exception as e: self._exception = e @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) From b5db56f7b8392c2432b8dbec0dbf82a38b986c5d Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sun, 14 Mar 2021 15:11:10 +0100 Subject: [PATCH 21/61] Fix typo. --- src/rmview/connection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/rmview/connection.py b/src/rmview/connection.py index e924da3..e2c887a 100644 --- a/src/rmview/connection.py +++ b/src/rmview/connection.py @@ -85,7 +85,8 @@ def __init__(self, address='10.11.99.1', username='root', password=None, key=Non 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 main qt rendere loop + # 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() From 825be6a1d787dd93b18d6bed03bbd847a61ee6e8 Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sun, 14 Mar 2021 15:13:00 +0100 Subject: [PATCH 22/61] Prefix log messages with a timestamp and level. --- src/rmview/rmview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 61d5aa8..a6d93a1 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -19,7 +19,7 @@ import signal import logging -logging.basicConfig(format='%(message)s') +logging.basicConfig(format='%(asctime)s %(levelname)s [-] %(message)s') log = logging.getLogger('rmview') From 43023250072d99fb49ed7039fe4ffdad2fc7f84c Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sun, 14 Mar 2021 15:20:36 +0100 Subject: [PATCH 23/61] Improve logging. --- src/rmview/workers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index 844eedc..e6a4d85 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -38,6 +38,8 @@ class RFB(RFBClient): painter = QPainter(img) def vncConnectionMade(self): + log.info("Connection to VNC server has been established") + self.signals = self.factory.signals self.setEncodings([ HEXTILE_ENCODING, @@ -161,7 +163,7 @@ def run(self): vnc_server_port = 5900 while not self._stop: - log.info("Establishing connection to remote VNC server to %s:%s" % (vnc_server_host, + log.info("Establishing connection to remote VNC server on %s:%s" % (vnc_server_host, vnc_server_port)) try: self.vncClient = internet.TCPClient(vnc_server_host, vnc_server_port, RFBFactory(self.signals)) From 91741394e580b85eff284972270baf3739beea6e Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sun, 14 Mar 2021 15:26:29 +0100 Subject: [PATCH 24/61] Enable SSH tunnel compression since it seems to result in slightly better performance. --- README.md | 1 + src/rmview/workers.py | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index 3ade531..ec39d47 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Connection parameters are provided as a dictionary with the following keys (all | `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 connect to VNC server over a local SSH tunnel | default: `true` (description below) | The `address` parameter can be either: - a single string, in which case the address is used for connection diff --git a/src/rmview/workers.py b/src/rmview/workers.py index e6a4d85..d8edd6d 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -190,6 +190,7 @@ def _get_ssh_tunnel(self): 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", True), **open_tunnel_kwargs) return tunnel From 3a84027655f8ee7170a4fe46cb3a28ad0cec54bb Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sun, 14 Mar 2021 15:28:17 +0100 Subject: [PATCH 25/61] Update comment. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ec39d47..51bc852 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Connection parameters are provided as a dictionary with the following keys (all | `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 connect to VNC server over a local SSH tunnel | default: `true` (description below) | +| `tunnel_compression` | True to enable compression for SSH tunnel | default: `true` (description below) | The `address` parameter can be either: - a single string, in which case the address is used for connection From 0f796e23a39adb96ebe2cfd3303fcea2fa92b053 Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Sun, 14 Mar 2021 16:00:24 +0100 Subject: [PATCH 26/61] Use custom made connecting image to avoid potential copyright / trademark issues. --- assets/connecting.png | Bin 33111 -> 47858 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/connecting.png b/assets/connecting.png index a2ff231a6d6a9f83921511b683ff60ca22bcf408..ae440a69cb7ec00581ec373b83cb3d0493ff9b38 100644 GIT binary patch literal 47858 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 literal 33111 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 HncV&# Date: Sun, 14 Mar 2021 16:00:41 +0100 Subject: [PATCH 27/61] Update readme. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 51bc852..479c948 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ If you plan to modify the source code, use `pip install -e .` so that when execu Install the dependencies ([PyQt5][pyqt5], [Paramiko][paramiko], [Twisted][twisted]) with `pip` or `conda` manually: # install dependencies - pip install pyqt5 paramiko twisted + pip install pyqt5 paramiko twisted sshtunnel # build resources file pyrcc5 -o src/rmview/resources.py resources.qrc From 9776b6031cb179b503abcd22100fb450e1b0c4df Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Wed, 17 Mar 2021 11:36:12 +0100 Subject: [PATCH 28/61] Log a warning message if config file is readable by others. --- src/rmview/rmview.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index d3b3ce0..91a4255 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -13,6 +13,7 @@ import sys import os +import stat import json import re import copy @@ -67,6 +68,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)) @@ -235,6 +239,23 @@ def ensureConnConfig(self): log.info("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 you are 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') From 5001b6ff3191141ceddb8751da6392f68676c81c Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Wed, 17 Mar 2021 11:37:22 +0100 Subject: [PATCH 29/61] Fix merge conflict resolution. --- src/rmview/rmview.py | 2 +- src/rmview/workers.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 91a4255..68c1e83 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -249,7 +249,7 @@ def _checkConfigFilePermissions(self, file_path): file_permissions = str(oct(st_mode)[4:]) if file_permissions.startswith("0") and len(file_permissions) == 4: - file_permissions = file_permissions[1:] + file_permissions = file_permissions[1:] log .warn("Config file \"%s\" is readable by others (permissions=%s). If you are config " "file contains secrets (e.g. password) you are strongly encouraged to make sure " diff --git a/src/rmview/workers.py b/src/rmview/workers.py index 6657d37..a2226b9 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -159,7 +159,6 @@ def run(self): log.info("Starting VNC server (command=%s)" % (server_run_cmd)) try: - log.info("Starting VNC server") _,_,out = self.ssh.exec_command(server_run_cmd) log.info("Start command output: %s" % (next(out))) except Exception as e: From 805129b050cf2f711e6247195cdfcff74722c667 Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Wed, 17 Mar 2021 11:48:35 +0100 Subject: [PATCH 30/61] Don't try to start a new vnc instance if one is already started and running on remarkable. --- src/rmview/workers.py | 51 +++++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index a2226b9..f67851b 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -144,26 +144,38 @@ def stop(self): @pyqtSlot() def run(self): - # On start up we try to kill any previous "stray" running VNC server processes - try: - self.ssh.exec_command("killall rM-vnc-server-standalone", timeout=3) - except Exception: - pass - - # If using SSH tunnel, we ensure VNC server only listens on localhost - if self.use_ssh_tunnel: - server_run_cmd = "$HOME/rM-vnc-server-standalone -listen localhost" + # Check if an existing instance of server is running and for now if it is log a warning and + # try to re-use that instance + _, stdout, stderr = self.ssh.exec_command("ps | grep rM-vnc-server-standalone | grep -v grep", + get_pty=True, timeout=3) + + if b"rM-vnc-server-standalone" in stdout.read(): + # TODO: Add config option to force kill and start a fresh server in this case + vnc_server_already_running = True + log.warn("Found an existing instance of rM-vnc-server-standalone process on reMarkable. " + "Will try to use that instance instead of starting a new one.") + + # TODO: Warn if ssh tunnel is configured, but existing instance is not using "-listen + # localhost" flag, this would indicate that VNC server is listening on all interfaces which + # can pose a security risk else: - server_run_cmd = "$HOME/rM-vnc-server-standalone" + vnc_server_already_running = False - log.info("Starting VNC server (command=%s)" % (server_run_cmd)) + if not vnc_server_already_running: + # If using SSH tunnel, we ensure VNC server only listens on localhost + if self.use_ssh_tunnel: + server_run_cmd = "$HOME/rM-vnc-server-standalone -listen localhost" + else: + server_run_cmd = "$HOME/rM-vnc-server-standalone" - try: - _,_,out = self.ssh.exec_command(server_run_cmd) - log.info("Start command output: %s" % (next(out))) - except Exception as e: - self.signals.onFatalError.emit(e) - return + log.info("Starting VNC server (command=%s)" % (server_run_cmd)) + + try: + _,_,out = self.ssh.exec_command(server_run_cmd) + log.info("Start command output: %s" % (next(out).strip())) + except Exception as e: + self.signals.onFatalError.emit(e) + return # Register atexit handler to ensure we always try to kill started server on exit atexit.register(self.stop) @@ -174,8 +186,9 @@ def run(self): self.sshTunnel = tunnel - log.info("Setting up SSH tunnel %s:%s <-> %s:%s" % ("127.0.0.1", 5900, tunnel.local_bind_host, - tunnel.local_bind_port)) + 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 From 103c5dc886056e94df15266b7d15f49e48be9758 Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Wed, 17 Mar 2021 20:35:16 +0100 Subject: [PATCH 31/61] Fix formatting. --- src/rmview/workers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index f67851b..44ddcb6 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -152,8 +152,8 @@ def run(self): if b"rM-vnc-server-standalone" in stdout.read(): # TODO: Add config option to force kill and start a fresh server in this case vnc_server_already_running = True - log.warn("Found an existing instance of rM-vnc-server-standalone process on reMarkable. " - "Will try to use that instance instead of starting a new one.") + 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.") # TODO: Warn if ssh tunnel is configured, but existing instance is not using "-listen # localhost" flag, this would indicate that VNC server is listening on all interfaces which From 19dce1347cbfcfe8ab60ee5506d17e2e77c6b37e Mon Sep 17 00:00:00 2001 From: Tomaz Muraus Date: Wed, 17 Mar 2021 20:58:40 +0100 Subject: [PATCH 32/61] Refactor functionality into multiple utility methods, don't stop VNC server on exit if we didn't spawn one but used one which was already running, other clean up. --- src/rmview/workers.py | 133 ++++++++++++++++++++++++++++++------------ 1 file changed, 96 insertions(+), 37 deletions(-) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index 44ddcb6..3230691 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -1,3 +1,5 @@ +from typing import Tuple + from PyQt5.QtGui import * from PyQt5.QtWidgets import * from PyQt5.QtCore import * @@ -94,7 +96,11 @@ def clientConnectionFailed(self, connector, reason): class FrameBufferWorker(QRunnable): _stop = False + ignoreEvents = False + factory = None + vncClient = None + sshTunnel = None def __init__(self, ssh, ssh_config, delay=None, lz4_path=None, img_format=IMG_FORMAT): super(FrameBufferWorker, self).__init__() @@ -103,9 +109,7 @@ def __init__(self, ssh, ssh_config, delay=None, lz4_path=None, img_format=IMG_FO self.img_format = img_format self.use_ssh_tunnel = self.ssh_config.get("tunnel", False) - self.factory = None - self.vncClient = None - self.sshTunnel = None + self._vnc_server_already_running = False self.signals = FBWSignals() @@ -118,11 +122,16 @@ def stop(self): log.info("Stopping framebuffer thread...") - try: - log.info("Disconnecting from VNC server...") - reactor.callFromThread(self.vncClient.disconnect) - except Exception: - reactor.callFromThread(reactor.stop) + if self.vncClient: + try: + log.info("Disconnecting from VNC server...") + reactor.callFromThread(self.vncClient.disconnect) + except Exception: + reactor.callFromThread(reactor.stop) + + if self._vnc_server_already_running: + # We used an existing running instance and didn't start one ourselves so we will not kill it. + return try: log.info("Stopping VNC server...") @@ -144,42 +153,100 @@ def stop(self): @pyqtSlot() def run(self): - # Check if an existing instance of server is running and for now if it is log a warning and - # try to re-use that instance + success = self._start_vnc_server() + + if not success: + log.error("Failed to start VNC server, exiting...") + self.stop() + return + + vnc_server_host, vnc_server_port = self._setup_ssh_tunnel_if_configured() + + while not self._stop: + log.info("Establishing connection to remote VNC server on %s:%s" % (vnc_server_host, + vnc_server_port)) + try: + self.factory = RFBFactory(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 | grep rM-vnc-server-standalone | grep -v grep", get_pty=True, timeout=3) - if b"rM-vnc-server-standalone" in stdout.read(): + 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.") - # TODO: Warn if ssh tunnel is configured, but existing instance is not using "-listen - # localhost" flag, this would indicate that VNC server is listening on all interfaces which - # can pose a security risk + 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 - if not vnc_server_already_running: - # If using SSH tunnel, we ensure VNC server only listens on localhost - if self.use_ssh_tunnel: - server_run_cmd = "$HOME/rM-vnc-server-standalone -listen localhost" - else: - server_run_cmd = "$HOME/rM-vnc-server-standalone" + return vnc_server_already_running - log.info("Starting VNC server (command=%s)" % (server_run_cmd)) + def _start_vnc_server(self) -> bool: + """ + Start VNC server on reMarkable if it's not already running. + """ + self._vnc_server_already_running = self._check_vnc_server_is_already_running() - try: - _,_,out = self.ssh.exec_command(server_run_cmd) - log.info("Start command output: %s" % (next(out).strip())) - except Exception as e: - self.signals.onFatalError.emit(e) - return + if self._vnc_server_already_running: + # Server already running, we will try to use that instance + return True + + 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)) + + try: + _, _, 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)) + + except Exception as e: + self.signals.onFatalError.emit(e) + return False # Register atexit handler to ensure we always try to kill started server on exit atexit.register(self.stop) + return True + + def _setup_ssh_tunnel_if_configured(self) -> Tuple[str, int]: + """ + Set up and start SSH tunnel (if configured). + """ if self.use_ssh_tunnel: tunnel = self._get_ssh_tunnel() tunnel.start() @@ -196,16 +263,8 @@ def run(self): vnc_server_host = self.ssh.hostname vnc_server_port = 5900 - while not self._stop: - log.info("Establishing connection to remote VNC server on %s:%s" % (vnc_server_host, - vnc_server_port)) - try: - self.factory = RFBFactory(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))) + + return (vnc_server_host, vnc_server_port) def _get_ssh_tunnel(self): open_tunnel_kwargs = { From 16aee528ab16e69fa0c0f3aa3256ac3ef9500683 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 25 Mar 2021 19:25:33 +0100 Subject: [PATCH 33/61] Bug in handling SSH keys --- src/rmview/connection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/rmview/connection.py b/src/rmview/connection.py index 4604ffb..47d4b5e 100644 --- a/src/rmview/connection.py +++ b/src/rmview/connection.py @@ -118,12 +118,12 @@ def _initialize(self): pkey = paramiko.RSAKey.from_private_key_file(key, password=self.password) else: try: - pkey = paramiko.RSAKey.from_private_key_file(self.key) + 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(self.key, password=passphrase) + pkey = paramiko.RSAKey.from_private_key_file(key, password=passphrase) else: raise Exception("A passphrase for SSH key is required") else: From be9a20af1ab9381032077ad9851e1f3bfcefda3f Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 25 Mar 2021 19:27:19 +0100 Subject: [PATCH 34/61] Rationalising logging --- src/rmview/rmview.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 68c1e83..8e070b1 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -21,7 +21,7 @@ import time import logging -logging.basicConfig(format='%(asctime)s %(levelname)s [-] %(message)s') +logging.basicConfig(format='[%(levelname)s] %(message)s') log = logging.getLogger('rmview') @@ -544,6 +544,15 @@ def 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) app = rMViewApp(sys.argv) # We register custom signal handler so we can gracefuly stop app with CTRL+C when QT main loop is @@ -551,7 +560,7 @@ def rmViewMain(): signal.signal(signal.SIGINT, lambda *args: app.quit()) app.startTimer(500) ecode = app.exec_() - print('\nBye!') + log.info("QUITTING: %s", time.asctime()) sys.exit(ecode) if __name__ == '__main__': From d46f9b10f93d2c3fb8ba22193bdd502b1e6f69d8 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 25 Mar 2021 19:28:11 +0100 Subject: [PATCH 35/61] Only log sanitised config if verbose --- src/rmview/rmview.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 8e070b1..658e820 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -16,7 +16,6 @@ import stat import json import re -import copy import signal import time @@ -232,11 +231,13 @@ def ensureConnConfig(self): if not os.path.isfile(self.LOCAL_KNOWN_HOSTS): open(self.LOCAL_KNOWN_HOSTS, 'a').close() - config_sanitized = copy.deepcopy(self.config) - if "password" in self.config.get("ssh", {}): - config_sanitized["ssh"]["password"] = config_sanitized["ssh"]["password"][:3] + "*****" + 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))) - log.info("Config values: %s" % (str(config_sanitized))) return True def _checkConfigFilePermissions(self, file_path): @@ -251,7 +252,7 @@ def _checkConfigFilePermissions(self, file_path): 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 you are config " + 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)) From 235d0ac23bfad9b2f859f72fef28bb49ebfb7ecb Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 25 Mar 2021 19:39:42 +0100 Subject: [PATCH 36/61] FB Worker: better handling of exceptions, properly stopping client, no reconnect on fatal error --- src/rmview/workers.py | 66 +++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index 3230691..44741e9 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -120,13 +120,14 @@ def stop(self): self._stop = True - log.info("Stopping framebuffer thread...") + log.debug("Stopping framebuffer thread...") if self.vncClient: try: log.info("Disconnecting from VNC server...") - reactor.callFromThread(self.vncClient.disconnect) - except Exception: + reactor.callFromThread(self.vncClient.stopService) + except Exception as e: + log.debug("Disconnect failed (%s), stopping reactor" % str(e)) reactor.callFromThread(reactor.stop) if self._vnc_server_already_running: @@ -149,29 +150,26 @@ def stop(self): except Exception as e: log.error(e) - log.info("Framebuffer thread stopped") + log.debug("Framebuffer thread stopped") @pyqtSlot() def run(self): - success = self._start_vnc_server() - - if not success: - log.error("Failed to start VNC server, exiting...") - self.stop() + 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 - vnc_server_host, vnc_server_port = self._setup_ssh_tunnel_if_configured() - - while not self._stop: - log.info("Establishing connection to remote VNC server on %s:%s" % (vnc_server_host, - vnc_server_port)) - try: - self.factory = RFBFactory(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))) + log.info("Establishing connection to remote VNC server on %s:%s" % (vnc_server_host, + vnc_server_port)) + try: + self.factory = RFBFactory(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: """ @@ -203,7 +201,7 @@ def _check_vnc_server_is_already_running(self) -> bool: return vnc_server_already_running - def _start_vnc_server(self) -> bool: + def _start_vnc_server(self): """ Start VNC server on reMarkable if it's not already running. """ @@ -211,7 +209,7 @@ def _start_vnc_server(self) -> bool: if self._vnc_server_already_running: # Server already running, we will try to use that instance - return True + return if self.use_ssh_tunnel: # If using SSH tunnel, we ensure VNC server only listens on localhost. That's important for @@ -222,26 +220,20 @@ def _start_vnc_server(self) -> bool: log.info("Starting VNC server (command=%s)" % (server_run_cmd)) - try: - _, _, stdout = self.ssh.exec_command(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)) + # 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)) - - except Exception as e: - self.signals.onFatalError.emit(e) - return False + 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) - return True def _setup_ssh_tunnel_if_configured(self) -> Tuple[str, int]: """ From b872a37789163d16eedf6410767fa79d2904428b Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 25 Mar 2021 19:40:44 +0100 Subject: [PATCH 37/61] Bugfix: stop tunnel even if VNC was already running --- src/rmview/workers.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index 44741e9..75ce356 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -130,18 +130,16 @@ def stop(self): log.debug("Disconnect failed (%s), stopping reactor" % str(e)) reactor.callFromThread(reactor.stop) - if self._vnc_server_already_running: - # We used an existing running instance and didn't start one ourselves so we will not kill it. - return - - 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 %s killall rM-vnc-server-standalone`.", self.ssh.hostname) - log.error(e) + # 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 %s killall rM-vnc-server-standalone`.", self.ssh.hostname) + log.error(e) if self.sshTunnel: try: From 455398faa019bfbeaeae325f7590bf37516c4ae4 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 25 Mar 2021 19:41:12 +0100 Subject: [PATCH 38/61] Make `sshtunnel` optional --- README.md | 6 +++++- setup.py | 3 ++- src/rmview/workers.py | 28 +++++++++++++++------------- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 479c948..af92416 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,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. @@ -54,7 +57,8 @@ If you plan to modify the source code, use `pip install -e .` so that when execu Install the dependencies ([PyQt5][pyqt5], [Paramiko][paramiko], [Twisted][twisted]) with `pip` or `conda` manually: # install dependencies - pip install pyqt5 paramiko twisted sshtunnel + pip install pyqt5 paramiko twisted + pip install sshtunnel # optional # build resources file pyrcc5 -o src/rmview/resources.py resources.qrc diff --git a/setup.py b/setup.py index 38afe47..3943887 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,8 @@ def run(self): 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', ], packages=['rmview'], - install_requires=['pyqt5', 'paramiko', 'twisted', 'sshtunnel'], + install_requires=['pyqt5', 'paramiko', 'twisted'], + extras_require = { 'tunnel': ['sshtunnel'] }, entry_points={ 'console_scripts':['rmview = rmview.rmview:rmViewMain'] }, diff --git a/src/rmview/workers.py b/src/rmview/workers.py index 75ce356..ae4478c 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -7,7 +7,6 @@ from .rmparams import * import paramiko -import sshtunnel import struct import time @@ -85,7 +84,7 @@ def buildProtocol(self, addr): return self.instance def clientConnectionLost(self, connector, reason): - log.warning("Connection lost: %s", reason.getErrorMessage()) + log.warning("Disconnected: %s", reason.getErrorMessage()) reactor.callFromThread(reactor.stop) def clientConnectionFailed(self, connector, reason): @@ -238,20 +237,19 @@ def _setup_ssh_tunnel_if_configured(self) -> Tuple[str, int]: Set up and start SSH tunnel (if configured). """ if self.use_ssh_tunnel: - tunnel = self._get_ssh_tunnel() - tunnel.start() + tunnel = self._get_ssh_tunnel() + tunnel.start() + self.sshTunnel = tunnel - 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)) - 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 + 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 + vnc_server_host = self.ssh.hostname + vnc_server_port = 5900 return (vnc_server_host, vnc_server_port) @@ -269,6 +267,10 @@ def _get_ssh_tunnel(self): 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), From b471450603560cfbb03b8fc0810469eea56ddced Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 25 Mar 2021 19:42:25 +0100 Subject: [PATCH 39/61] Document verbosity options --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index af92416..dac9363 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,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. From e624adf1a627df38ac3b73c1cbb8032b6a5c0e27 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 25 Mar 2021 19:44:30 +0100 Subject: [PATCH 40/61] Right click should not trigger pointer event --- src/rmview/viewer.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py index b18eb65..3c3a71a 100644 --- a/src/rmview/viewer.py +++ b/src/rmview/viewer.py @@ -128,12 +128,13 @@ def resizeEvent(self, event): self.updateViewer() def mousePressEvent(self, event): - scenePos = self.mapToScene(event.pos()) - if int(event.modifiers()) & int(Qt.ControlModifier): - self._button = 1 - else: - self._button = 4 - self.pointerEvent.emit(scenePos.x(), scenePos.y(), self._button) + 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(scenePos.x(), scenePos.y(), self._button) def mouseReleaseEvent(self, event): scenePos = self.mapToScene(event.pos()) From 1856e1c91ff19e4b2e15821d52905862d9927792 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 25 Mar 2021 19:44:50 +0100 Subject: [PATCH 41/61] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 86c4587..f1995c3 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', From dac5b0b56ced871b313e02ad539abd310574f364 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 25 Mar 2021 19:44:55 +0100 Subject: [PATCH 42/61] Minor --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index cbf9b10..c390b05 100644 --- a/README.md +++ b/README.md @@ -150,10 +150,9 @@ If you get an error when connect using a key, but the key seems ok when connecti - [ ] 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 From 50137dbd6a40767aad736ba0242638fdc82f5b8b Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 8 Apr 2021 11:23:54 +0200 Subject: [PATCH 43/61] Make sure cloned frames do not share buffers with live ones (see #61) --- src/rmview/rmview.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index a94fbc3..66cc16e 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -387,6 +387,8 @@ def movePen(self, x, y): @pyqtSlot() def cloneViewer(self): img = self.viewer.image() + img = QPixmap.fromImage(img) + img.detach() v = QtImageViewer() v.setImage(img) v.show() From c7b5469062c18ee59b5e1c59e083670f796fb845 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 8 Apr 2021 21:41:11 +0200 Subject: [PATCH 44/61] Bugfix: failing to detect running instance because of truncated ps output --- src/rmview/workers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index ae4478c..c5b0930 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -174,8 +174,7 @@ def _check_vnc_server_is_already_running(self) -> bool: If it is, True is returned by this method and a log message if emitted. """ - _, stdout, stderr = self.ssh.exec_command("ps | grep rM-vnc-server-standalone | grep -v grep", - get_pty=True, timeout=3) + _, stdout, stderr = self.ssh.exec_command("ps -ww | grep rM-vnc-server-standalone | grep -v grep") stdout_bytes = stdout.read() From f05a37a16033a6348143dc2aa3b1185dd5e4ac9f Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 8 Apr 2021 21:46:21 +0200 Subject: [PATCH 45/61] Tunnel compression disabled by default (performance seems better without on RM1) --- README.md | 2 +- src/rmview/workers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5e271be..58fba85 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Connection parameters are provided as a dictionary with the following keys (all | `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: `true` (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 diff --git a/src/rmview/workers.py b/src/rmview/workers.py index c5b0930..626182a 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -275,7 +275,7 @@ def _get_ssh_tunnel(self): 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", True), + compression=self.ssh_config.get("tunnel_compression", False), **open_tunnel_kwargs) return tunnel From 853f25a9bcd513f21bd79f926b532fde2c56d9c0 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Sat, 17 Apr 2021 00:56:45 +0200 Subject: [PATCH 46/61] Move key loading to init of connection worker Prompting for the passphrase can only be done from main thread! --- src/rmview/connection.py | 46 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/rmview/connection.py b/src/rmview/connection.py index 47d4b5e..095343e 100644 --- a/src/rmview/connection.py +++ b/src/rmview/connection.py @@ -69,11 +69,32 @@ def __init__(self, address='10.11.99.1', username='root', password=None, key=Non self.address = address self.username = username self.password = password - self.key = key 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 @@ -110,31 +131,10 @@ def _initialize(self): policy = HOST_KEY_POLICY.get(self.host_key_policy, RejectNewHostKey) self.client.set_missing_host_key_policy(policy()) - if self.key is not None: - key = os.path.expanduser(self.key) - - if self.password: - # password protected key file, password provided in the config - pkey = paramiko.RSAKey.from_private_key_file(key, password=self.password) - else: - 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 self.password is None: - log.warning("No key nor password given. System-wide SSH connection parameters are going to be used.") - self.options = { 'username': self.username, 'password': self.password, - 'pkey': pkey, + 'pkey': self.pkey, 'timeout': self.timeout, } except Exception as e: From 34a3d92f957b4adae40ddcecf6cc34f16dc0c6cb Mon Sep 17 00:00:00 2001 From: Emanuele Date: Wed, 28 Apr 2021 01:03:52 +0200 Subject: [PATCH 47/61] Minor cleanup --- src/rmview/rmview.py | 1 - src/rmview/viewer.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index e8daf1f..3d1ee28 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -561,7 +561,6 @@ def rmViewMain(): # 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()) - app.startTimer(500) ecode = app.exec_() log.info("QUITTING: %s", time.asctime()) sys.exit(ecode) diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py index 3c3a71a..7526f9b 100644 --- a/src/rmview/viewer.py +++ b/src/rmview/viewer.py @@ -134,17 +134,17 @@ def mousePressEvent(self, event): self._button = 1 else: self._button = 4 - self.pointerEvent.emit(scenePos.x(), scenePos.y(), self._button) + 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(scenePos.x(), scenePos.y(), 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(scenePos.x(), scenePos.y(), self._button) + self.pointerEvent.emit(int(scenePos.x()), int(scenePos.y()), self._button) def mouseDoubleClickEvent(self, event): # scenePos = self.mapToScene(event.pos()) From 314e8223d6d6b2cedf7ebaf367279152443372dd Mon Sep 17 00:00:00 2001 From: Emanuele Date: Fri, 30 Apr 2021 00:47:55 +0200 Subject: [PATCH 48/61] Wrong field name known_hosts --- src/rmview/connection.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/rmview/connection.py b/src/rmview/connection.py index 095343e..93d1c7d 100644 --- a/src/rmview/connection.py +++ b/src/rmview/connection.py @@ -71,7 +71,7 @@ def __init__(self, address='10.11.99.1', username='root', password=None, key=Non self.password = password self.timeout = timeout self.host_key_policy = host_key_policy - self.known_hosts = known_hosts + self._known_hosts = known_hosts if key is not None: key = os.path.expanduser(key) @@ -113,10 +113,10 @@ def _initialize(self): self.client = paramiko.SSHClient() 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) + 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...") @@ -159,7 +159,7 @@ def run(self): log.info("Please check your remarkable is connected and retry.") self.signals.onError.emit(e) try: - if self._known_hosts: + 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) From 6cc94327108782ad495d0ceb27185d0d709727d6 Mon Sep 17 00:00:00 2001 From: Lars Blumberg Date: Wed, 5 May 2021 21:21:53 +0200 Subject: [PATCH 49/61] Add dockerfile and run script --- Dockerfile | 23 +++++++++++++++++++++++ README.md | 6 ++++++ docker-run.sh | 13 +++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 Dockerfile create mode 100755 docker-run.sh 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 58fba85..fa7da15 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,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:** 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 From 178c806ca7b2b568e411f9a02501ce7c4b0c8dbc Mon Sep 17 00:00:00 2001 From: Emanuele Date: Wed, 5 May 2021 23:21:54 +0200 Subject: [PATCH 50/61] Disable forward_mouse_events by default, add docs --- README.md | 11 ++++++++++- src/rmview/rmview.py | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 58fba85..0e2dcd9 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,15 @@ 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` | + +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. + +**PLEASE NOTE**: due to how the VNC server handles this at the moment, you may experience unresponsiveness of the physical buttons on the tablet. +This is because the palm rejection mechanism can get confused by the synthetic events. +To resolve this, it is sufficient to move the physical pen close to the tablet and then away from it, and the buttons should go back to working properly. Connection parameters are provided as a dictionary with the following keys (all optional): @@ -119,7 +128,7 @@ Connection parameters are provided as a dictionary with the following keys (all | `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) | +| `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 diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 3d1ee28..31532ba 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -346,7 +346,7 @@ def connected(self, ssh): self.fbworker.signals.onNewFrame.connect(self.onNewFrame) self.fbworker.signals.onFatalError.connect(self.frameError) self.threadpool.start(self.fbworker) - if self.config.get("forward_mouse_events", True): + 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)) From 1133189229358ef6a47671db310016e4ae3803cc Mon Sep 17 00:00:00 2001 From: Emanuele Date: Fri, 7 May 2021 11:43:14 +0200 Subject: [PATCH 51/61] Correcting warning message (fixes #73) --- src/rmview/workers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rmview/workers.py b/src/rmview/workers.py index 626182a..39690cd 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -137,7 +137,7 @@ def stop(self): 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.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: From f2fd104a715e8542eea4c3a25c0cf3d8129d0cfa Mon Sep 17 00:00:00 2001 From: Emanuele Date: Tue, 18 May 2021 16:30:23 +0200 Subject: [PATCH 52/61] Pinch to Zoom --- src/rmview/viewer.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py index 7526f9b..03ea352 100644 --- a/src/rmview/viewer.py +++ b/src/rmview/viewer.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import Qt, QRectF, pyqtSignal, QT_VERSION_STR +from PyQt5.QtCore import * from PyQt5.QtGui import * from PyQt5.QtWidgets import * @@ -18,6 +18,8 @@ def __init__(self): self.setRenderHint(QPainter.Antialiasing) self.setRenderHint(QPainter.SmoothPixmapTransform) + self.viewport().grabGesture(Qt.PinchGesture) + self.scene = QGraphicsScene() self.setScene(self.scene) @@ -156,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: From 462d7deeb3e421cc92ad59dba33d7343bcc30e8f Mon Sep 17 00:00:00 2001 From: Valentin Boettcher Date: Wed, 26 May 2021 18:53:40 +0200 Subject: [PATCH 53/61] do not garbage collect cloned frames and create them with the correct rotation --- src/rmview/rmview.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index 31532ba..adf7c5d 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -41,6 +41,8 @@ class rMViewApp(QApplication): pen_size = 15 trail = None # None: disabled, False: inactive, True: active + cloned_frames = [] + def __init__(self, args): super(rMViewApp, self).__init__(args) @@ -425,6 +427,8 @@ def cloneViewer(self): v = QtImageViewer() v.setImage(img) v.show() + v.rotate(self.viewer._rotation) + self.cloned_frames.append(v) @pyqtSlot() def toggleStreaming(self): From bc7882fda939489057c2eabc17d893eaf81eb4eb Mon Sep 17 00:00:00 2001 From: Emanuele Date: Tue, 1 Jun 2021 15:21:38 +0200 Subject: [PATCH 54/61] Garbage collect cloned frames on close --- src/rmview/rmview.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index adf7c5d..ee33164 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -41,7 +41,7 @@ class rMViewApp(QApplication): pen_size = 15 trail = None # None: disabled, False: inactive, True: active - cloned_frames = [] + cloned_frames = set() def __init__(self, args): super(rMViewApp, self).__init__(args) @@ -425,10 +425,12 @@ def cloneViewer(self): 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.append(v) + self.cloned_frames.add(v) + v.destroyed.connect(lambda: self.cloned_frames.discard(v)) @pyqtSlot() def toggleStreaming(self): From 477207320862b8c9b704da6c1f1ecb56cb91f019 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Mon, 12 Jul 2021 12:35:18 +0200 Subject: [PATCH 55/61] Line length in setup.cfg (prepare for linting) --- setup.cfg | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 5e70df80ec0efe490f734b6e1ffccbbfb95c9eef Mon Sep 17 00:00:00 2001 From: Emanuele Date: Mon, 6 Sep 2021 17:06:55 +0200 Subject: [PATCH 56/61] Refactoring: move pen tracker out of workers --- src/rmview/pentracker.py | 104 +++++++++++++++++++++++++++++++++++++++ src/rmview/rmview.py | 5 +- src/rmview/workers.py | 94 +---------------------------------- 3 files changed, 108 insertions(+), 95 deletions(-) create mode 100644 src/rmview/pentracker.py 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/rmview.py b/src/rmview/rmview.py index ee33164..c00d171 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -3,7 +3,8 @@ from PyQt5.QtCore import * from . import resources -from .workers import FrameBufferWorker, PointerWorker, KEY_Left, KEY_Right, KEY_Escape +from .workers import FrameBufferWorker, KEY_Left, KEY_Right, KEY_Escape +from .pentracker import PenTracker from .connection import rMConnect, RejectNewHostKey, AddNewHostKey, UnknownHostKeyException from .viewer import QtImageViewer @@ -351,7 +352,7 @@ def connected(self, ssh): 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')), diff --git a/src/rmview/workers.py b/src/rmview/workers.py index 39690cd..08fb8b8 100644 --- a/src/rmview/workers.py +++ b/src/rmview/workers.py @@ -29,6 +29,7 @@ log = logging.getLogger('rmview') + class FBWSignals(QObject): onFatalError = pyqtSignal(Exception) onNewFrame = pyqtSignal(QImage) @@ -69,7 +70,6 @@ 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 instance = None @@ -310,95 +310,3 @@ def emulatePressRelease(self, key): self.factory.instance.keyEvent(key) # time.sleep(.1) self.factory.instance.keyEvent(key, 0) - - -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() - - @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() - - - From 02f5947d5b2dc3f1631656b3eb7bb66bcc7228cb Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 9 Sep 2021 18:51:55 +0200 Subject: [PATCH 57/61] Detect tablet/software version on connection --- src/rmview/connection.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/rmview/connection.py b/src/rmview/connection.py index 93d1c7d..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 @@ -140,6 +141,19 @@ def _initialize(self): 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() @@ -153,6 +167,8 @@ 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) From bfd9a98da4276e3479a05114fa9ccf33848f34b3 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 9 Sep 2021 18:52:27 +0200 Subject: [PATCH 58/61] Software version timestamps --- src/rmview/rmparams.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/rmview/rmparams.py b/src/rmview/rmparams.py index c68a5e5..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 From 1198a2125f9b86735e0966ea77d7c3c8660e22ac Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 9 Sep 2021 18:53:32 +0200 Subject: [PATCH 59/61] Make RFB client compatible with ScreenShare (thanks to @ddvk #87) --- src/rmview/rfb.py | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) 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) From 68456c344e8f4ebf865cdd6abe04e305026cba13 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 9 Sep 2021 18:54:16 +0200 Subject: [PATCH 60/61] Add compatibility with ScreenShare (fixes #86, #87) --- src/rmview/rmview.py | 124 ++++++++++-------- src/rmview/screenstream/__init__.py | 0 src/rmview/screenstream/common.py | 80 +++++++++++ src/rmview/screenstream/screenshare.py | 75 +++++++++++ .../{workers.py => screenstream/vnc.py} | 121 ++++------------- 5 files changed, 252 insertions(+), 148 deletions(-) create mode 100644 src/rmview/screenstream/__init__.py create mode 100644 src/rmview/screenstream/common.py create mode 100644 src/rmview/screenstream/screenshare.py rename src/rmview/{workers.py => screenstream/vnc.py} (74%) diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index c00d171..6c22b6e 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -3,7 +3,9 @@ from PyQt5.QtCore import * from . import resources -from .workers import FrameBufferWorker, KEY_Left, KEY_Right, KEY_Escape +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 @@ -287,67 +289,42 @@ def connected(self, ssh): self.ssh = ssh self.viewer.setWindowTitle("rMview - " + ssh.hostname) - _,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()) + 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, ssh_config=self.config.get('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) @@ -370,6 +347,43 @@ def connected(self, ssh): 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 > 0: 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/workers.py b/src/rmview/screenstream/vnc.py similarity index 74% rename from src/rmview/workers.py rename to src/rmview/screenstream/vnc.py index 08fb8b8..1c75c1c 100644 --- a/src/rmview/workers.py +++ b/src/rmview/screenstream/vnc.py @@ -1,98 +1,20 @@ -from typing import Tuple - -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 import atexit -from twisted.internet.protocol import Protocol -from twisted.internet import protocol, reactor, threads -from twisted.application import internet, service +from PyQt5.QtGui import * +from PyQt5.QtCore import * -from .rfb import * +from twisted.internet import reactor +from twisted.application import internet -try: - IMG_FORMAT = QImage.Format_Grayscale16 -except Exception: - IMG_FORMAT = QImage.Format_RGB16 -BYTES_PER_PIXEL = 2 +from rmview.screenstream.common import * +# from rmview.rmparams import * +# from rmview.rfb import * 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 __init__(self, signals): - super(RFB, 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, - 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 - instance = None - - def __init__(self, signals): - super(RFBFactory, self).__init__() - self.signals = signals - - def buildProtocol(self, addr): - self.instance = RFB(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) - - -class FrameBufferWorker(QRunnable): +class VncStreamer(QRunnable): _stop = False @@ -101,16 +23,29 @@ class FrameBufferWorker(QRunnable): vncClient = None sshTunnel = None - def __init__(self, ssh, ssh_config, delay=None, lz4_path=None, img_format=IMG_FORMAT): - super(FrameBufferWorker, self).__init__() + def __init__(self, ssh, ssh_config, delay=None): + super(VncStreamer, self).__init__() self.ssh = ssh self.ssh_config = ssh_config - self.img_format = img_format self.use_ssh_tunnel = self.ssh_config.get("tunnel", False) self._vnc_server_already_running = False - self.signals = FBWSignals() + 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: @@ -161,7 +96,7 @@ def run(self): log.info("Establishing connection to remote VNC server on %s:%s" % (vnc_server_host, vnc_server_port)) try: - self.factory = RFBFactory(self.signals) + self.factory = VncFactory(self.signals) self.vncClient = internet.TCPClient(vnc_server_host, vnc_server_port, self.factory) self.vncClient.startService() reactor.run(installSignalHandlers=0) @@ -231,7 +166,7 @@ def _start_vnc_server(self): atexit.register(self.stop) - def _setup_ssh_tunnel_if_configured(self) -> Tuple[str, int]: + def _setup_ssh_tunnel_if_configured(self): """ Set up and start SSH tunnel (if configured). """ @@ -292,7 +227,7 @@ def resume(self): try: self.factory.instance.emitImage() except Exception: - log.warning("Not ready to pause") + log.warning("Not ready to resume") # @pyqtSlot(int,int,int) def pointerEvent(self, x, y, button): From 23f56feed030bdc3450d658d8fcd0e05c56fdfd9 Mon Sep 17 00:00:00 2001 From: Emanuele Date: Thu, 9 Sep 2021 19:01:37 +0200 Subject: [PATCH 61/61] Some docs --- README.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1d59d90..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. @@ -104,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"` | @@ -113,14 +115,16 @@ All the settings are optional. | `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. -**PLEASE NOTE**: due to how the VNC server handles this at the moment, you may experience unresponsiveness of the physical buttons on the tablet. -This is because the palm rejection mechanism can get confused by the synthetic events. -To resolve this, it is sufficient to move the physical pen close to the tablet and then away from it, and the buttons should go back to working properly. - Connection parameters are provided as a dictionary with the following keys (all optional):