diff --git a/.github/workflows/windows-binary.yml b/.github/workflows/windows-binary.yml new file mode 100644 index 0000000..fcb4ba0 --- /dev/null +++ b/.github/workflows/windows-binary.yml @@ -0,0 +1,45 @@ +name: Windows Binary Package + +on: + push: + tags: + - 'v*' + +jobs: + build-win-amd64: + runs-on: windows-latest + steps: + - uses: actions/checkout@v2 + - name: Get the version + id: get_version + shell: bash + run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\/v/} + - name: Set up Python 3.9 amd64 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + architecture: x64 + - name: Install dependencies + shell: cmd + run: | + python -m pip install --upgrade pip + python -m venv venv64 + venv64\Scripts\python -m pip install --upgrade pip wheel setuptools pyinstaller + venv64\Scripts\python -m pip install . + - name: Make package + shell: cmd + run: | + venv64\Scripts\pyinstaller -n rmview --collect-all rmview --icon=assets\rmview.ico -F ./src/rmview/__main__.py + md public + move dist\rmview.exe public\rmview.exe + - name: Package into zip + uses: papeloto/action-zip@v1 + with: + files: public/ + recursive: false + dest: rmview_win_amd64_${{ steps.get_version.outputs.VERSION }}.zip + - name: Release + uses: softprops/action-gh-release@v1 + with: + #body_path: doc/CHANGELOG-${{ steps.get_version.outputs.VERSION }}.txt + files: rmview_win_amd64_${{ steps.get_version.outputs.VERSION }}.zip diff --git a/README.md b/README.md index ce0da3e..213abe0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ * 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 +* Works with software version pre 2.8 * Compatible with ScreenShare (post 2.9) * Supports colors (tablet is grayscale but original colors are preserved in rmview) * UI for zooming, panning, rotating, inverting colors @@ -119,11 +119,12 @@ All the settings are optional. 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. +and `vncserver` with version 2.8 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. +This option is only available if using `"backend": "vncserver"`, which in turn is only supported for rM software version below 2.8. Connection parameters are provided as a dictionary with the following keys (all optional): diff --git a/setup.py b/setup.py index 3b0df23..8b81c4d 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def run(self): setup( name='rmview', - version='3.0', + version='3.1.3', url='https://github.com/bordaigorl/rmview', description='rMview: a fast live viewer for reMarkable', author='bordaigorl', diff --git a/src/rmview/connection.py b/src/rmview/connection.py index 7c92473..31b1a5b 100644 --- a/src/rmview/connection.py +++ b/src/rmview/connection.py @@ -2,6 +2,8 @@ from PyQt5.QtCore import * from PyQt5.QtWidgets import * +from .rmparams import timestamp_to_version + import paramiko import struct @@ -161,7 +163,20 @@ def _getVersion(self): def _getSwVersion(self): _, out, _ = self.client.exec_command("cat /etc/version") - return int(out.read().decode("utf-8")) + version_ts = int(out.read().decode("utf-8")) + version = timestamp_to_version(version_ts) + + try: + _, out, err = self.client.exec_command(r"grep 'REMARKABLE_RELEASE_VERSION=' /usr/share/remarkable/update.conf " + r"| sed -r 's/(REMARKABLE_RELEASE_VERSION=)([0-9a-zA-Z.]+)/\2/'") + config_version = tuple(int(v) for v in out.read().decode("utf-8").strip().split('.')) + if (len(config_version) < 4): config_version += (0,)*(len(config_version) - 4); + version = config_version + log.debug("Using update.conf as SW version authority") + except Exception as e: + log.debug("Using /etc/version as SW version authority") + + return version @pyqtSlot() @@ -179,6 +194,7 @@ def run(self): self.client.hostname = self.address self.client.deviceVersion, self.client.fullDeviceVersion = self._getVersion() self.client.softwareVersion = self._getSwVersion() + log.info("Detected SW version: {}".format('.'.join(str(v) for v in self.client.softwareVersion))) self.signals.onConnect.emit(self.client) except Exception as e: log.error("Could not connect to %s: %s", self.address, e) diff --git a/src/rmview/rfb.py b/src/rmview/rfb.py index e42b36c..3d7f1fa 100644 --- a/src/rmview/rfb.py +++ b/src/rmview/rfb.py @@ -119,47 +119,84 @@ def ord(x): return x KEY_SpaceBar= 0x0020 -# ZRLE helpers -def _zrle_next_bit(it, pixels_in_tile): - num_pixels = 0 - while True: - b = ord(next(it)) - - for n in range(8): - value = b >> (7 - n) - yield value & 1 - - num_pixels += 1 - if num_pixels == pixels_in_tile: - return - - -def _zrle_next_dibit(it, pixels_in_tile): - num_pixels = 0 - while True: - b = ord(next(it)) - - for n in range(0, 8, 2): - value = b >> (6 - n) - yield value & 3 - - num_pixels += 1 - if num_pixels == pixels_in_tile: - return - - -def _zrle_next_nibble(it, pixels_in_tile): - num_pixels = 0 - while True: - b = ord(next(it)) - - for n in range(0, 8, 4): - value = b >> (4 - n) - yield value & 15 +class ZRLEDataStream(): + + def __init__(self, data, bytes_per_pixel=1): + self._data = data + self._offset = 0 + self._bypp = bytes_per_pixel + + def __len__(self): + return len(self._data) - self._offset + + def nextByte(self): + b = ord(self._data[self._offset]) + self._offset += 1 + return b + + def nextChunk(self, length): + r = memoryview(self._data)[self._offset:self._offset+length] + self._offset += length + return r + + def nextPixel(self): + return self.nextChunk(self._bypp) + + def nextPixels(self, n=1): + return self.nextChunk(n*self._bypp) + + def nextRunLength(self): + run_length_next = self.nextByte() + run_length = run_length_next + while run_length_next == 255: + run_length_next = self.nextByte() + run_length += run_length_next + return run_length + 1 + + def nextPaletteIndices(self, palette_size, length=1): + if palette_size == 2: + return self._zrle_next_bit(length) + if palette_size == 3 or palette_size == 4: + return self._zrle_next_dibit(length) + else: + return self._zrle_next_nibble(length) + + def _zrle_next_bit(self, tot_pixels): + num_pixels = 0 + while num_pixels < tot_pixels: + b = self.nextByte() + for n in range(8): + value = b >> (7 - n) + yield value & 1 + + num_pixels += 1 + if num_pixels == tot_pixels: + return + + def _zrle_next_dibit(self, tot_pixels): + num_pixels = 0 + while num_pixels < tot_pixels: + b = self.nextByte() + for n in range(0, 8, 2): + value = b >> (6 - n) + yield value & 3 + + num_pixels += 1 + if num_pixels == tot_pixels: + return + + def _zrle_next_nibble(self, tot_pixels): + num_pixels = 0 + while num_pixels < tot_pixels: + b = self.nextByte() + for n in range(0, 8, 4): + value = b >> (4 - n) + yield value & 15 + + num_pixels += 1 + if num_pixels == tot_pixels: + return - num_pixels += 1 - if num_pixels == pixels_in_tile: - return class RFBClient(Protocol): @@ -181,6 +218,7 @@ def _handleInitial(self): buffer = b''.join(self._packet) if b'\n' in buffer: version = 3.8 + version_server = 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) @@ -585,22 +623,10 @@ def _handleDecodeZRLEdata(self, block, x, y, width, height): tx = x ty = y - data = self._zlib_stream.decompress(block) - it = iter(data) + data = ZRLEDataStream(self._zlib_stream.decompress(block), self.bypp) - def cpixel(i): - for j in range(self.bypp): - yield next(i) - # yield next(i) - # yield next(i) - # Alpha channel - # yield 0xff - - while True: - try: - subencoding = ord(next(it)) - except StopIteration: - break + while len(data) > 0: + subencoding = data.nextByte() # calc tile size tw = th = 64 @@ -612,71 +638,59 @@ def cpixel(i): pixels_in_tile = tw * th # decode next tile - num_pixels = 0 - pixel_data = bytearray() palette_size = subencoding & 127 if subencoding & 0x80: # RLE - - def do_rle(pixel): - run_length_next = ord(next(it)) - run_length = run_length_next - while run_length_next == 255: - run_length_next = ord(next(it)) - run_length += run_length_next - pixel_data.extend(pixel * (run_length + 1)) - return run_length + 1 + pixel_data = bytearray() if palette_size == 0: # plain RLE - while num_pixels < pixels_in_tile: - color = bytearray(cpixel(it)) - num_pixels += do_rle(color) - if num_pixels != pixels_in_tile: - raise ValueError("too many pixels") + while len(pixel_data) < pixels_in_tile: + color = bytes(data.nextPixel()) + run_len = data.nextRunLength() + pixel_data += color * run_len + if len(pixel_data) > pixels_in_tile: + raise ValueError("too many pixels") else: - palette = [bytearray(cpixel(it)) for p in range(palette_size)] + # Palette RLE + palette = [data.nextPixel() for _ in range(palette_size)] - while num_pixels < pixels_in_tile: - palette_index = ord(next(it)) + while len(pixel_data) < pixels_in_tile: + palette_index = data.nextByte() if palette_index & 0x80: palette_index &= 0x7F # run of length > 1, more bytes follow to determine run length - num_pixels += do_rle(palette[palette_index]) + run_len = data.nextRunLength() + pixel_data += palette[palette_index] * run_len else: # run of length 1 - pixel_data.extend(palette[palette_index]) - num_pixels += 1 - if num_pixels != pixels_in_tile: + pixel_data += palette[palette_index] + + if len(pixel_data) != pixels_in_tile: raise ValueError("too many pixels") - self.updateRectangle(tx, ty, tw, th, bytes(pixel_data)) + self.updateRectangle(tx, ty, tw, th, pixel_data) else: # No RLE if palette_size == 0: # Raw pixel data - pixel_data = b''.join(bytes(cpixel(it)) for _ in range(pixels_in_tile)) - self.updateRectangle(tx, ty, tw, th, bytes(pixel_data)) + pixel_data = data.nextPixels(pixels_in_tile) + self.updateRectangle(tx, ty, tw, th, pixel_data) elif palette_size == 1: # Fill tile with plain color - color = bytearray(cpixel(it)) - self.fillRectangle(tx, ty, tw, th, bytes(color)) + color = data.nextPixel() + self.fillRectangle(tx, ty, tw, th, color) else: if palette_size > 16: raise ValueError( "Palette of size {0} is not allowed".format(palette_size)) - palette = [bytearray(cpixel(it)) for _ in range(palette_size)] - if palette_size == 2: - next_index = _zrle_next_bit(it, pixels_in_tile) - elif palette_size == 3 or palette_size == 4: - next_index = _zrle_next_dibit(it, pixels_in_tile) - else: - next_index = _zrle_next_nibble(it, pixels_in_tile) - - for palette_index in next_index: - pixel_data.extend(palette[palette_index]) - self.updateRectangle(tx, ty, tw, th, bytes(pixel_data)) + palette = [data.nextPixel() for _ in range(palette_size)] + + pixel_data = bytearray() + for palette_index in data.nextPaletteIndices(palette_size, pixels_in_tile): + pixel_data += palette[palette_index] + self.updateRectangle(tx, ty, tw, th, pixel_data) # Next tile tx = tx + 64 diff --git a/src/rmview/rmparams.py b/src/rmview/rmparams.py index 3bb972a..f81c5ba 100644 --- a/src/rmview/rmparams.py +++ b/src/rmview/rmparams.py @@ -9,6 +9,20 @@ '2.9.1.236': 20210820111232 } +# This mapping was adapted from the above `SW_VER_TIMESTAMPS` dictionary. +# Newer versions do not use timestamp based versioning, and are likewise not represented here. +def timestamp_to_version(ts): + if ts < SW_VER_TIMESTAMPS["2.7"]: + return (2, 6, 0, 0) + elif ts < SW_VER_TIMESTAMPS["2.9"]: + return (2, 7, 0, 0) + elif ts < SW_VER_TIMESTAMPS["2.9.1.236"]: + return (2, 9, 0, 0) + elif ts == SW_VER_TIMESTAMPS["2.9.1.236"]: + return (2, 9, 1, 236) + else: + return (2, 9, 1, 9999) # Phony version number. Just needs to compare > 2.9.1.236 + # evtype_sync = 0 e_type_key = 1 diff --git a/src/rmview/rmview.py b/src/rmview/rmview.py index ab7b894..97d7353 100644 --- a/src/rmview/rmview.py +++ b/src/rmview/rmview.py @@ -1,3 +1,4 @@ +import pathlib from PyQt5.QtGui import * from PyQt5.QtWidgets import * from PyQt5.QtCore import * @@ -48,8 +49,9 @@ class rMViewApp(QApplication): def __init__(self, args): super(rMViewApp, self).__init__(args) - - self.CONFIG_DIR = QStandardPaths.standardLocations(QStandardPaths.ConfigLocation)[0] + path = QStandardPaths.standardLocations(QStandardPaths.ConfigLocation)[0] + pathlib.Path(path).mkdir(parents=True, exist_ok=True) + self.CONFIG_DIR = path self.DEFAULT_CONFIG = os.path.join(self.CONFIG_DIR, 'rmview.json') self.LOCAL_KNOWN_HOSTS = os.path.join(self.CONFIG_DIR, 'rmview_known_hosts') @@ -117,17 +119,17 @@ def __init__(self, args): ### self.leftAction = QAction('Emulate Left Button', self) self.leftAction.setShortcut('Ctrl+Left') - self.leftAction.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Left)) + self.leftAction.triggered.connect(lambda: self.emulateKeyEvent(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.rightAction.triggered.connect(lambda: self.emulateKeyEvent(KEY_Right)) self.viewer.addAction(self.rightAction) ### self.homeAction = QAction('Emulate Central Button', self) - self.homeAction.setShortcut(QKeySequence.Cancel) - self.homeAction.triggered.connect(lambda: self.fbworker.keyEvent(KEY_Escape)) + # self.homeAction.setShortcut(QKeySequence.Cancel) + self.homeAction.triggered.connect(lambda: self.emulateKeyEvent(KEY_Escape)) self.viewer.addAction(self.homeAction) @@ -183,6 +185,10 @@ def __init__(self, args): self.aboutToQuit.connect(self.joinWorkers) self.requestConnect() + def emulateKeyEvent(self, key): + if self.fbworker: + self.fbworker.keyEvent(key) + def disableAutoOrientation(self): self.orient = 0 @@ -300,7 +306,7 @@ def connected(self, ssh): self.ssh = ssh self.viewer.setWindowTitle("rMview - " + ssh.hostname) - log.info("Detected %s", ssh.fullDeviceVersion) + log.info("Detected device: %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") @@ -310,11 +316,11 @@ def connected(self, ssh): backend = self.config.get('backend', 'auto') if backend == 'auto': - if ssh.softwareVersion >= SW_VER_TIMESTAMPS['2.9']: + if ssh.softwareVersion >= (2, 9, 0, 0): backend = 'screenshare' else: backend = 'vncserver' - if ssh.softwareVersion >= SW_VER_TIMESTAMPS['2.7']: + if ssh.softwareVersion >= (2, 7, 0, 0): log.warning("Detected version 2.7 or 2.8. The server might not work with these versions.") log.info("Using backend '%s'", backend) @@ -447,6 +453,7 @@ def movePen(self, x, y): @pyqtSlot() def cloneViewer(self): + self.viewer.showNormal() img = self.viewer.image() img.detach() v = QtImageViewer() @@ -455,6 +462,7 @@ def cloneViewer(self): v.show() v.rotate(self.viewer._rotation) self.cloned_frames.add(v) + v.setWindowTitle("Cloned frame %d" % len(self.cloned_frames)) v.destroyed.connect(lambda: self.cloned_frames.discard(v)) @pyqtSlot() diff --git a/src/rmview/screenstream/screenshare.py b/src/rmview/screenstream/screenshare.py index f284af7..199bfa0 100644 --- a/src/rmview/screenstream/screenshare.py +++ b/src/rmview/screenstream/screenshare.py @@ -96,15 +96,28 @@ def stop(self): reads the usedId from deviceToken from the config file on the rm """ def get_userid(self): + files = ['/etc/remarkable.conf', '/home/root/.config/remarkable/xochitl.conf'] with self.ssh.open_sftp() as sftp: - with sftp.file('/etc/remarkable.conf') as f: + for f in files: + try: + if sftp.stat(f): + file = f + break + except Exception: + pass + if not file: + # Never should be here or unhealthy device + log.error('No remote config file detected on your Remarkable.') + + log.info('Remote config file detected: ' + file) + with sftp.file(file) as f: file_content = f.read().decode() cfg = configparser.ConfigParser(strict=False) cfg.read_string(file_content) offset = len('@ByteArray(') token = cfg.get('General', 'devicetoken')[offset:-1] - d = jwt.decode(token, options={"verify_signature": False}) + d = jwt.decode(token, options={"verify_signature": False, "verify_aud": False}) return(d["auth0-userid"]) @@ -134,7 +147,7 @@ def startVncClient(self, challenge=None): def run(self): log.info("Connecting to ScreenShare, make sure you enable it") try: - if self.ssh.softwareVersion > SW_VER_TIMESTAMPS['2.9.1.236']: + if self.ssh.softwareVersion > (2, 9, 1, 236): log.warning("Authenticating, please wait...") challengeReader = ChallengeReaderProtocol(self.runVnc) reactor.listenUDP(5901, challengeReader) diff --git a/src/rmview/viewer.py b/src/rmview/viewer.py index 4cb54db..ab4557f 100644 --- a/src/rmview/viewer.py +++ b/src/rmview/viewer.py @@ -79,6 +79,10 @@ def __init__(self): self.screenshotAction.triggered.connect(self.screenshot) self.addAction(self.screenshotAction) ### + self.screenshotToClipboardAction = QAction('Copy screenshot', self) + self.screenshotToClipboardAction.setShortcut(QKeySequence.Copy) + self.screenshotToClipboardAction.triggered.connect(self.screenshotToClipboard) + self.addAction(self.screenshotToClipboardAction) self.menu = QMenu(self) self.menu.addAction(self.fitAction) @@ -92,6 +96,8 @@ def __init__(self): self.menu.addAction(self.invertColorsAction) self.menu.addSeparator() # -------------------------- self.menu.addAction(self.screenshotAction) + self.menu.addAction(self.screenshotToClipboardAction) + self.menu.addSeparator() # -------------------------- def contextMenuEvent(self, event): self.fitAction.setChecked(self._fit) @@ -214,6 +220,12 @@ def screenshot(self): img = img.transformed(QTransform().rotate(self._rotation)) img.save(fileName) + def screenshotToClipboard(self): + img = self.image() + if img is not None: + img = img.transformed(QTransform().rotate(self._rotation)) + QApplication.clipboard().setImage(img) + def invertColors(self): self._invert_colors = not self._invert_colors if self._pixmap: