diff --git a/bin/pyrdp-player.py b/bin/pyrdp-player.py index 8314ea4ea..fe1535717 100755 --- a/bin/pyrdp-player.py +++ b/bin/pyrdp-player.py @@ -7,9 +7,11 @@ # # asyncio needs to be imported first to ensure that the reactor is -# installed properly. Do not re-order. -import asyncio # noqa +# installed properly. ***DO NOT RE-ORDER***. +import asyncio # noqa + from twisted.internet import asyncioreactor + asyncioreactor.install(asyncio.get_event_loop()) from pyrdp.core import settings # noqa @@ -86,6 +88,7 @@ def main(): if not args.headless: app = QApplication(sys.argv) mainWindow = MainWindow(args.bind, int(args.port), args.replay) + mainWindow.showMaximized() mainWindow.show() return app.exec_() diff --git a/pyrdp/player/BaseTab.py b/pyrdp/player/BaseTab.py index 316274d46..cacce8106 100644 --- a/pyrdp/player/BaseTab.py +++ b/pyrdp/player/BaseTab.py @@ -1,6 +1,6 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2018 GoSecure Inc. +# Copyright (C) 2018-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # @@ -33,11 +33,12 @@ def __init__(self, viewer: QRemoteDesktop, parent: QWidget = None): self.text.setMinimumHeight(150) self.log = logging.getLogger(LOGGER_NAMES.PLAYER) - scrollViewer = QScrollArea() - scrollViewer.setWidget(self.widget) - self.tabLayout = QVBoxLayout() - self.tabLayout.addWidget(scrollViewer, 8) + + self.scrollViewer = QScrollArea() + self.scrollViewer.setWidget(self.widget) + + self.tabLayout.addWidget(self.scrollViewer, 10) self.tabLayout.addWidget(self.text, 2) self.setLayout(self.tabLayout) diff --git a/pyrdp/player/LiveTab.py b/pyrdp/player/LiveTab.py index 7610264fe..cb5ec609a 100644 --- a/pyrdp/player/LiveTab.py +++ b/pyrdp/player/LiveTab.py @@ -1,6 +1,6 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2018, 2019 GoSecure Inc. +# Copyright (C) 2018-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # @@ -9,12 +9,12 @@ from PySide2.QtCore import Qt from PySide2.QtWidgets import QHBoxLayout, QWidget +from pyrdp.mitm.PlayerLayerSet import AsyncIOPlayerLayerSet from pyrdp.player.AttackerBar import AttackerBar from pyrdp.player.BaseTab import BaseTab from pyrdp.player.filesystem import DirectoryObserver, FileSystem from pyrdp.player.FileSystemWidget import FileSystemWidget from pyrdp.player.LiveEventHandler import LiveEventHandler -from pyrdp.mitm.PlayerLayerSet import AsyncIOPlayerLayerSet from pyrdp.player.RDPMITMWidget import RDPMITMWidget @@ -23,7 +23,7 @@ class LiveTab(BaseTab, DirectoryObserver): Tab playing a live RDP connection as data is being received over the network. """ - def __init__(self, parent: QWidget = None): + def __init__(self, parent: QWidget): layers = AsyncIOPlayerLayerSet() rdpWidget = RDPMITMWidget(1024, 768, layers.player) diff --git a/pyrdp/player/LiveWindow.py b/pyrdp/player/LiveWindow.py index 591233e9d..7eeb420fc 100644 --- a/pyrdp/player/LiveWindow.py +++ b/pyrdp/player/LiveWindow.py @@ -1,6 +1,6 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2019 GoSecure Inc. +# Copyright (C) 2019, 2020 GoSecure Inc. # Licensed under the GPLv3 or later. # @@ -15,6 +15,7 @@ from pyrdp.player.LiveTab import LiveTab from pyrdp.player.LiveThread import LiveThread + class LiveWindow(BaseWindow): """ Class that holds logic for live player (network RDP connections as they happen) tabs. @@ -23,7 +24,7 @@ class LiveWindow(BaseWindow): connectionReceived = Signal() closedTabText = " - Closed" - def __init__(self, address: str, port: int, updateCountSignal: Signal, options: Dict[str, object], parent: QWidget = None): + def __init__(self, address: str, port: int, updateCountSignal: Signal, options: Dict[str, object], parent: QWidget): super().__init__(options, parent) QApplication.instance().aboutToQuit.connect(self.onClose) @@ -40,7 +41,7 @@ def onConnection(self) -> asyncio.Protocol: return tab.getProtocol() def createLivePlayerTab(self): - tab = LiveTab() + tab = LiveTab(parent=self) tab.addIconToTab.connect(self.addIconToTab) tab.renameTab.connect(self.renameLivePlayerTab) tab.connectionClosed.connect(self.onConnectionClosed) diff --git a/pyrdp/player/MainWindow.py b/pyrdp/player/MainWindow.py index 96093f756..3f587e75a 100644 --- a/pyrdp/player/MainWindow.py +++ b/pyrdp/player/MainWindow.py @@ -1,12 +1,14 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2018, 2019 GoSecure Inc. +# Copyright (C) 2018-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # +from typing import List from PySide2.QtCore import Qt, Signal -from PySide2.QtWidgets import QAction, QFileDialog, QMainWindow, QTabWidget, QInputDialog +from PySide2.QtWidgets import QAction, QFileDialog, QInputDialog, QMainWindow, QTabWidget +from pyrdp.player import BaseTab from pyrdp.player.LiveWindow import LiveWindow from pyrdp.player.ReplayWindow import ReplayWindow @@ -32,13 +34,14 @@ def __init__(self, bind_address: str, port: int, filesToRead: [str]): "closeTabOnCtrlW": True # Allow user to toggle Ctrl+W passthrough. } - self.liveWindow = LiveWindow(bind_address, port, self.updateCountSignal, self.options) - self.replayWindow = ReplayWindow(self.options) + self.liveWindow = LiveWindow(bind_address, port, self.updateCountSignal, self.options, parent=self) + self.replayWindow = ReplayWindow(self.options, parent=self) self.tabManager = QTabWidget() self.tabManager.addTab(self.liveWindow, "Live connections") self.tabManager.addTab(self.replayWindow, "Replays") self.setCentralWidget(self.tabManager) self.updateCountSignal.connect(self.updateTabConnectionCount) + self.resizeObservers: List[BaseTab] = [] # File menu openAction = QAction("Open...", self) @@ -110,7 +113,6 @@ def onOpenFile(self): for fileName in fileNames: self.replayWindow.openFile(fileName) - def sendKeySequence(self, keys: [Qt.Key]): if self.tabManager.currentWidget() is self.liveWindow: self.liveWindow.sendKeySequence(keys) diff --git a/pyrdp/player/ReplayBar.py b/pyrdp/player/ReplayBar.py index 6330099a6..aa1109bec 100644 --- a/pyrdp/player/ReplayBar.py +++ b/pyrdp/player/ReplayBar.py @@ -1,12 +1,13 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2018 GoSecure Inc. +# Copyright (C) 2018-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # import logging from PySide2.QtCore import Qt, Signal -from PySide2.QtWidgets import QHBoxLayout, QLabel, QSizePolicy, QSlider, QSpacerItem, QVBoxLayout, QWidget +from PySide2.QtWidgets import QCheckBox, QHBoxLayout, QLabel, QSizePolicy, QSlider, QSpacerItem, \ + QVBoxLayout, QWidget from pyrdp.logging import LOGGER_NAMES from pyrdp.player.SeekBar import SeekBar @@ -31,6 +32,8 @@ def __init__(self, duration: float, parent: QWidget = None): self.button.setMaximumWidth(100) self.button.clicked.connect(self.onButtonClicked) + self.scaleCheckbox = QCheckBox("Scale to window") + self.timeSlider = SeekBar() self.timeSlider.setMinimum(0) self.timeSlider.setMaximum(int(duration * 1000)) @@ -49,6 +52,7 @@ def __init__(self, duration: float, parent: QWidget = None): horizontal = QHBoxLayout() horizontal.addWidget(self.speedLabel) horizontal.addWidget(self.speedSlider) + horizontal.addWidget(self.scaleCheckbox) horizontal.addItem(QSpacerItem(20, 40, QSizePolicy.Expanding, QSizePolicy.Expanding)) vertical.addLayout(horizontal) diff --git a/pyrdp/player/ReplayTab.py b/pyrdp/player/ReplayTab.py index 269c9fbe2..d116fe87a 100644 --- a/pyrdp/player/ReplayTab.py +++ b/pyrdp/player/ReplayTab.py @@ -1,9 +1,9 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2019 GoSecure Inc. +# Copyright (C) 2019, 2020 GoSecure Inc. # Licensed under the GPLv3 or later. # - +from PySide2.QtGui import QResizeEvent from PySide2.QtWidgets import QApplication, QWidget from pyrdp.layer import PlayerLayer @@ -20,7 +20,7 @@ class ReplayTab(BaseTab): Tab that displays a RDP Connection that is being replayed from a file. """ - def __init__(self, fileName: str, parent: QWidget = None): + def __init__(self, fileName: str, parent: QWidget): """ :param fileName: name of the file to read. :param parent: parent widget. @@ -45,6 +45,7 @@ def __init__(self, fileName: str, parent: QWidget = None): self.controlBar.pause.connect(self.thread.pause) self.controlBar.seek.connect(self.thread.seek) self.controlBar.speedChanged.connect(self.thread.setSpeed) + self.controlBar.scaleCheckbox.stateChanged.connect(self.setScaleToWindow) self.controlBar.button.setDefault(True) self.tabLayout.insertWidget(0, self.controlBar) @@ -88,3 +89,21 @@ def clear(self): def onClose(self): self.thread.close() self.thread.wait() + + def setScaleToWindow(self, status: int): + """ + Called when the scale to window checkbox is checked or unchecked, refresh + the scaling calculation. + :param status: state of the checkbox + """ + self.parentResized(None) + self.widget.setScaleToWindow(status) + + def parentResized(self, event: QResizeEvent): + """ + Called when the main PyRDP window is resized to allow to scale the current + RDP session being displayed. + :param event: The event of the parent that has been resized + """ + newScale = (self.scrollViewer.height() - self.scrollViewer.horizontalScrollBar().height()) / self.widget.sessionHeight + self.widget.scale(newScale) diff --git a/pyrdp/player/ReplayThread.py b/pyrdp/player/ReplayThread.py index 9dc35377e..0e776663e 100644 --- a/pyrdp/player/ReplayThread.py +++ b/pyrdp/player/ReplayThread.py @@ -1,6 +1,6 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2018 GoSecure Inc. +# Copyright (C) 2018-2020 GoSecure Inc. # Licensed under the GPLv3 or later. # @@ -9,7 +9,7 @@ from multiprocessing import Queue from time import sleep -from PySide2.QtCore import Signal, QThread +from PySide2.QtCore import QThread, Signal from pyrdp.core import Timer from pyrdp.player.Replay import Replay @@ -44,41 +44,41 @@ def __init__(self, replay: Replay): self.lastSeekTime = 0 self.requestedSpeed = 1 self.replay = replay + self.timer = Timer() def run(self): step = 16 / 1000 currentIndex = 0 runThread = True timestamps = sorted(self.replay.events.keys()) - timer = Timer() while runThread: - timer.update() + self.timer.update() try: while True: event = self.queue.get_nowait() if event == ReplayThreadEvent.PLAY: - timer.start() + self.timer.start() elif event == ReplayThreadEvent.PAUSE: - timer.stop() + self.timer.stop() elif event == ReplayThreadEvent.SEEK: - if self.lastSeekTime < timer.getElapsedTime(): + if self.lastSeekTime < self.timer.getElapsedTime(): currentIndex = 0 self.clearNeeded.emit() - timer.setTime(self.lastSeekTime) + self.timer.setTime(self.lastSeekTime) elif event == ReplayThreadEvent.SPEED: - timer.setSpeed(self.requestedSpeed) + self.timer.setSpeed(self.requestedSpeed) elif event == ReplayThreadEvent.EXIT: runThread = False except queue.Empty: pass - if timer.isRunning(): - currentTime = timer.getElapsedTime() + if self.timer.isRunning(): + currentTime = self.timer.getElapsedTime() self.timeUpdated.emit(currentTime) while currentIndex < len(timestamps) and timestamps[currentIndex] / 1000.0 <= currentTime: @@ -107,4 +107,4 @@ def setSpeed(self, speed: float): self.queue.put(ReplayThreadEvent.SPEED) def close(self): - self.queue.put(ReplayThreadEvent.EXIT) \ No newline at end of file + self.queue.put(ReplayThreadEvent.EXIT) diff --git a/pyrdp/player/ReplayWindow.py b/pyrdp/player/ReplayWindow.py index 830dcae14..8b4636d23 100644 --- a/pyrdp/player/ReplayWindow.py +++ b/pyrdp/player/ReplayWindow.py @@ -1,11 +1,12 @@ # # This file is part of the PyRDP project. -# Copyright (C) 2019 GoSecure Inc. +# Copyright (C) 2019, 2020 GoSecure Inc. # Licensed under the GPLv3 or later. # from typing import Dict +from PySide2.QtGui import QResizeEvent from PySide2.QtWidgets import QWidget from pyrdp.player.BaseWindow import BaseWindow @@ -17,16 +18,21 @@ class ReplayWindow(BaseWindow): Class for managing replay tabs. """ - def __init__(self, options: Dict[str, object], parent: QWidget = None): - super().__init__(options, parent) + def __init__(self, options: Dict[str, object], parent: QWidget): + super().__init__(options, parent=parent) def openFile(self, fileName: str, autoplay: bool = False): """ Open a replay file and open a new tab. :param fileName: replay path. """ - tab = ReplayTab(fileName) + tab = ReplayTab(fileName, parent=self) self.addTab(tab, fileName) self.log.debug("Loading replay file %(arg1)s", {"arg1": fileName}) if autoplay: tab.play() + + def resizeEvent(self, event: QResizeEvent): + super().resizeEvent(event) + for i in range(self.count()): + self.widget(i).parentResized(event) diff --git a/pyrdp/ui/qt.py b/pyrdp/ui/qt.py index f198207b8..8f017dc93 100644 --- a/pyrdp/ui/qt.py +++ b/pyrdp/ui/qt.py @@ -2,7 +2,10 @@ # Copyright (c) 2014-2015 Sylvain Peyrefitte # Copyright (c) 2018-2020 GoSecure Inc. # -# This file is part of rdpy. +# This file is part of PyRDP. +# This file was part of rdpy. +# +# PyRDP is licensed under the GPLv3 or later. # # rdpy is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -24,10 +27,10 @@ QRemoteDesktop is a widget use for render in rdpy """ +import rle from io import BytesIO -import rle -from PySide2.QtCore import QEvent, QPoint, Signal +from PySide2.QtCore import QEvent, QPoint, Qt, Signal from PySide2.QtGui import QColor, QImage, QMatrix, QPainter from PySide2.QtWidgets import QWidget @@ -122,8 +125,10 @@ def convert8bppTo16bpp(buf: bytes): class QRemoteDesktop(QWidget): """ - Qt RDP display widget + Qt RDP display widget. It is the widget directly responsible to display the "screen" of the + client in the RDP session being shown/replayed. """ + # This signal can be used by other objects to run code on the main thread. The argument is a callable. mainThreadHook = Signal(object) @@ -134,12 +139,22 @@ def __init__(self, width: int, height: int, parent: QWidget = None): :param parent: parent widget """ super().__init__(parent) + + self.ratio = 1 + """ Scale factor used to render the RDP session on the player.""" + + self.sessionWidth = width + self.sessionHeight = height + + self.scaleToWindow = False + + # Buffer image + self._buffer: QImage = QImage(width, height, QImage.Format_ARGB32_Premultiplied) + # Set correct size self.resize(width, height) # Bind mouse event self.setMouseTracking(True) - # Buffer image - self._buffer = QImage(width, height, QImage.Format_ARGB32_Premultiplied) self.mouseX = width // 2 self.mouseY = height // 2 @@ -151,7 +166,6 @@ def runOnMainThread(self, target: callable): @property def screen(self): return self._buffer - def notifyImage(self, x: int, y: int, qimage: QImage, width: int, height: int): """ Draw an image on the buffer. @@ -174,13 +188,26 @@ def setMousePosition(self, x: int, y: int): self.mouseY = y self.update() + def scale(self, scale): + """ + Rescale the current widget to a percentage of the height of the RDP session. + :param scale: Ex: 0.5 for 50% height and 50% width. + """ + self.ratio = scale + + def setScaleToWindow(self, status): + self.scaleToWindow = status > 0 + def resize(self, width: int, height: int): """ - Resize widget - :param width: new width of the widget - :param height: new height of the widget + Resize the image buffer. This is called when the clientData is parsed, which + contains the screen size used for the connection. + :param width: new width of the replay client's screen + :param height: new height of the replay client's screen. """ - self._buffer = QImage(width, height, QImage.Format_RGB32) + self._buffer = QImage(width, height, QImage.Format_ARGB32_Premultiplied) + self.sessionWidth = width + self.sessionHeight = height super().resize(width, height) def paintEvent(self, e: QEvent): @@ -188,12 +215,13 @@ def paintEvent(self, e: QEvent): Call when Qt renderer engine estimate that is needed :param e: the event """ + ratio = self.ratio if self.scaleToWindow else 1 qp = QPainter(self) - qp.drawImage(0, 0, self._buffer) + qp.drawImage(0, 0, self._buffer.scaled(self.sessionWidth * ratio, self.sessionHeight * ratio, aspectMode=Qt.KeepAspectRatio)) qp.setBrush(QColor.fromRgb(255, 255, 0, 180)) - qp.drawEllipse(QPoint(self.mouseX, self.mouseY), 5, 5) + qp.drawEllipse(QPoint(self.mouseX * ratio, self.mouseY * ratio), 5, 5) def clear(self): - self._buffer = QImage(self._buffer.width(), self._buffer.height(), QImage.Format_RGB32) + self._buffer = QImage(self._buffer.width(), self._buffer.height(), QImage.Format_ARGB32_Premultiplied) self.setMousePosition(self._buffer.width() // 2, self._buffer.height() // 2) self.repaint()