diff --git a/.gitignore b/.gitignore index aa1cd9b..46f649e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ # files & folders for development use debug +# Archive file +*.tar.gz + ################################################################################ # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/.travis.yml b/.travis.yml index bed3cfc..4e692cd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,23 +4,28 @@ python: '3.6' services: - docker -install: -- make docker-pull -- pip3 install . +install: | + docker pull kbumsik/virtscreen + pip3 install . -script: -- echo No test scripts implemented yet. Travis is used only for deploy yet. +script: | + echo No test scripts implemented yet. Travis is used only for deploy yet. -before_deploy: -- package/build_all.sh $TRAVIS_TAG +before_deploy: | + if [ -n "$TRAVIS_TAG" ]; then + VERSION=$TRAVIS_TAG make override_version + fi + make package/pypi/*.whl + make package/appimage/VirtScreen.AppImage + make package/debian/virtscreen.deb deploy: - provider: releases api_key: secure: zFbsCIKcsvWU/Yc+9k294Qj8QY48VlkV8DSScP5gz6dQegeUSaSHI/YafherkFQ0B03bIY8yc7roMtDo7HAkEnPptjFhdUiOFI11+xDVb3s7Y8Ek2nV3znQzdtR4CR/94l3in6R3DH+eNA6+6Je/NIWLdVcvRX07RBSfBVdPmnsAyAD9KNTsl8Q4c20HgtLNxfWv2s5eCyD+heCTLYrErEZKZ5vYeeANmWomHvT2ED/4QerpBP8wkh59QXD1S79CF7oyq6X173ZJUQVxdBP+OSXt/mDBAoqf+TV6okawRZn48JluvCWAJ7BceX7t9emd1rVI/s8t3wCP+eMcmNn5g/6UJaCPnTJ5YplTuUWRc63UFSkE0AY8WYcRlrz+/OiXYgQ8LMXfN23aWgarHCbS2vHR3Afu9gpLCoKucr36hKhs3zfjJzVLFFW16mnbaTFcBzfDDRpkvOANB1aZwGVRFpTIWIMjkn0+lxWTC/moIJvQlfRPsC4dN5cDAilRQlguHzayebtGE8X0PuIe9A8bkET3V/y+KPnQiSJ7J+5PNoDSdqRAE4IKvVOLEyHtlqBVkvIHKnugUnWPIZ21gm5RemMEj9/YGa8Efwz7PIKtJJ3kFMGDYKVlIKyB+rg/TFWNdo6jjevnWM6y4SfVI3kFyjA+mp31o6nshrQy0zVQpd8= file: - - package/debian/build/virtscreen_$TRAVIS_TAG-1_all.deb - - package/appimage/VirtScreen-x86_64.AppImage + - package/debian/virtscreen.deb + - package/appimage/VirtScreen.AppImage skip_cleanup: true on: tags: true diff --git a/Makefile b/Makefile index d82dcdf..9ccaa05 100644 --- a/Makefile +++ b/Makefile @@ -1,94 +1,93 @@ # See https://packaging.python.org/tutorials/distributing-packages/#packaging-your-project # for python packaging reference. +VERSION ?= 0.3.1 DOCKER_NAME=kbumsik/virtscreen DOCKER_RUN=docker run --interactive --tty -v $(shell pwd):/app $(DOCKER_NAME) DOCKER_RUN_TTY=docker run --interactive --tty -v $(shell pwd):/app $(DOCKER_NAME) -DOCKER_RUN_DEB=docker run -v $(shell pwd)/package/debian:/app $(DOCKER_NAME) -.PHONY: - -python-wheel: - python3 setup.py bdist_wheel --universal - -python-install: - pip3 install . --user - -python-uninstall: - pip3 uninstall virtscreen - -python-clean: - rm -rf build dist virtscreen.egg-info virtscreen/qml/*.qmlc - -pip-upload: python-wheel - twine upload dist/* +PKG_APPIMAGE=package/appimage/VirtScreen.AppImage +PKG_DEBIAN=package/debian/virtscreen.deb +ARCHIVE=virtscreen-$(VERSION).tar.gz .ONESHELL: -# Docker -docker-build: - docker build -f Dockerfile -t $(DOCKER_NAME) . +.PHONY: run debug run-appimage debug-appimage + +all: package/pypi/*.whl $(ARCHIVE) $(PKG_APPIMAGE) $(PKG_DEBIAN) + +# Run script +run: + python3 -m virtscreen + +debug: + QT_DEBUG_PLUGINS=1 QML_IMPORT_TRACE=1 python3 -m virtscreen --log=DEBUG + +run-appimage: $(PKG_APPIMAGE) + $< + +debug-appimage: $(PKG_APPIMAGE) + QT_DEBUG_PLUGINS=1 QML_IMPORT_TRACE=1 $< --log=DEBUG + +# tar.gz +.PHONY: archive + +archive $(ARCHIVE): + git archive --format=tar.gz --prefix=virtscreen-$(VERSION)/ -o $@ HEAD + +# Docker tools +.PHONY: docker docker-build docker: $(DOCKER_RUN_TTY) /bin/bash - -docker-rm: - docker image rm -f $(DOCKER_NAME) -docker-pull: - docker pull $(DOCKER_NAME) +docker-build: + docker build -f Dockerfile -t $(DOCKER_NAME) . -docker-push: - docker login - docker push $(DOCKER_NAME) +# Python wheel package for PyPI +.PHONY: wheel-clean + +package/pypi/%.whl: + python3 setup.py bdist_wheel --universal + cp dist/* package/pypi + -rm -rf build dist *.egg-info + +wheel-clean: + -rm package/pypi/virtscreen*.whl # For AppImage packaging, https://github.com/AppImage/AppImageKit/wiki/Creating-AppImages -appimage-build: +.PHONY: appimage-clean +.SECONDARY: $(PKG_APPIMAGE) + +$(PKG_APPIMAGE): $(DOCKER_RUN) package/appimage/build.sh + $(DOCKER_RUN) mv package/appimage/VirtScreen-x86_64.AppImage $@ $(DOCKER_RUN) chown -R $(shell id -u):$(shell id -u) package/appimage appimage-clean: - $(DOCKER_RUN) rm -rf package/appimage/virtscreen.AppDir package/appimage/VirtScreen-x86_64.AppImage + -rm -rf package/appimage/virtscreen.AppDir $(PKG_APPIMAGE) -# For Debian packaging, https://www.debian.org/doc/manuals/debmake-doc/ch08.en.html#setup-py -deb-make: - $(DOCKER_RUN_DEB) /app/debmake.sh +# For Debian packaging, https://www.debian.org/doc/manuals/maint-guide/index.en.html +# https://www.debian.org/doc/manuals/debmake-doc/ch08.en.html#setup-py +.PHONY: deb-contents deb-clean -deb-build: deb-make - $(DOCKER_RUN_DEB) /app/copy_debian.sh - $(DOCKER_RUN_DEB) /app/debuild.sh +$(PKG_DEBIAN): $(PKG_APPIMAGE) $(ARCHIVE) + $(DOCKER_RUN) package/debian/build.sh + $(DOCKER_RUN) mv package/debian/*.deb $@ + $(DOCKER_RUN) chown -R $(shell id -u):$(shell id -u) package/debian -deb-contents: - $(DOCKER_RUN_DEB) /app/contents.sh - -deb-env-make: - $(DOCKER_RUN_DEB) /app/debmake.sh virtualenv - -deb-env-build: deb-env-make - $(DOCKER_RUN_DEB) /app/copy_debian.sh virtualenv - $(DOCKER_RUN_DEB) /app/debuild.sh virtualenv - -deb-chown: - $(DOCKER_RUN_DEB) chown -R $(shell id -u):$(shell id -u) /app/build +deb-contents: $(PKG_DEBIAN) + $(DOCKER_RUN) dpkg -c $< deb-clean: - $(DOCKER_RUN_DEB) rm -rf /app/build + rm -rf package/debian/build package/debian/*.deb package/debian/*.buildinfo \ + package/debian/*.changes # For AUR: https://wiki.archlinux.org/index.php/Python_package_guidelines # and: https://wiki.archlinux.org/index.php/Creating_packages -arch-update: - cd package/archlinux - makepkg --printsrcinfo > .SRCINFO +.PHONY: arch-upload arch-clean -arch-install: arch-update - cd package/archlinux - makepkg -si - -arch-build: arch-update - cd package/archlinux - makepkg - -arch-upload: arch-update +arch-upload: package/archlinux/.SRCINFO cd package/archlinux git clone ssh://aur@aur.archlinux.org/virtscreen.git cp PKGBUILD virtscreen @@ -100,8 +99,33 @@ arch-upload: arch-update cd .. rm -rf virtscreen +package/archlinux/.SRCINFO: + cd package/archlinux + makepkg --printsrcinfo > .SRCINFO + arch-clean: cd package/archlinux - rm -rf pkg src *.tar* + -rm -rf pkg src *.tar* .SRCINFO -clean: appimage-clean arch-clean deb-clean python-clean +# Override version +.PHONY: override-version + +override-version: + # Update python setup.py + perl -pi -e "s/version=\'\d+\.\d+\.\d+\'/version=\'$(VERSION)\'/" \ + setup.py + # Update .json files in the module + perl -pi -e "s/\"version\"\s*\:\s*\"\d+\.\d+\.\d+\"/\"version\"\: \"$(VERSION)\"/" \ + virtscreen/assets/data.json + perl -pi -e "s/\"version\"\s*\:\s*\"\d+\.\d+\.\d+\"/\"version\"\: \"$(VERSION)\"/" \ + virtscreen/assets/config.default.json + # Arch AUR + perl -pi -e "s/pkgver=\d+\.\d+\.\d+/pkgver=$(VERSION)/" \ + package/archlinux/PKGBUILD + # Debian + perl -pi -e "s/PKGVER=\d+\.\d+\.\d+/PKGVER=$(VERSION)/" \ + package/debian/build.sh + +# Clean packages +clean: appimage-clean arch-clean deb-clean wheel-clean + -rm -f $(ARCHIVE) diff --git a/README.md b/README.md index f588f79..cb22819 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,102 @@ -# VirtScreen +

+ +
+ VirtScreen +

-> Make your iPad/tablet/computer as a secondary monitor on Linux. +

+ Make your iPad/tablet/computer as a secondary monitor on Linux. +

-![gif example](https://raw.githubusercontent.com/kbumsik/VirtScreen/master/data/gif_example.gif) +
+ + VirtScreen + +
+ +## Description VirtScreen is an easy-to-use Linux GUI app that creates a virtual secondary screen and shares it through VNC. -VirtScreen is based on [PyQt5](https://www.riverbankcomputing.com/software/pyqt/intro) and [Twisted](https://twistedmatrix.com) in Python side and uses [x11vnc](https://github.com/LibVNC/x11vnc) and XRandR. +VirtScreen is based on [PyQt5](https://www.riverbankcomputing.com/software/pyqt/intro) and [asyncio](https://docs.python.org/3/library/asyncio.html) in Python side and uses [x11vnc](https://github.com/LibVNC/x11vnc) and XRandR. + +## Features + +* No more typing commands - create a second VNC screen with a few clicks from the GUI. +* ...But there is also command-line only options for CLI lovers. +* Highly configurable - resolutions, portrait mode, and HiDPI mode. +* Works on any Linux Distro with Xorg +* Lightweight +* System Tray Icon ## How to use -Upon installation (see Installing section to install), there will be a desktop entry called `VirtScreen` +1. Run the app. +2. Set options (resolution etc.) and enable the virtual screen. +3. Go to VNC tab and then start the VNC server. +4. Run your favorite VNC client app on your second device and connect it to the IP address appeared on the app. -![desktop entry](https://raw.githubusercontent.com/kbumsik/VirtScreen/master/data/desktop_entry.png) +### CLI-only option -Or you can run it using a command line: +You can run VirtScreen with `virtscreen` (or `./VirtScreen.AppImage` if you use the AppImage package) with additional arguments. ```bash -$ virtscreen +usage: virtscreen [-h] [--auto] [--left] [--right] [--above] [--below] + [--portrait] [--hidpi] + +Make your iPad/tablet/computer as a secondary monitor on Linux. + +You can start VirtScreen in the following two modes: + + - GUI mode: A system tray icon will appear when no argument passed. + You need to use this first to configure a virtual screen. + - CLI mode: After configured the virtual screen, you can start VirtScreen + in CLI mode if you do not want a GUI, by passing any arguments + +optional arguments: + -h, --help show this help message and exit + --auto create a virtual screen automatically using previous + settings (from both GUI mode and CLI mode) + --left a virtual screen will be created left to the primary + monitor + --right right to the primary monitor + --above, --up above the primary monitor + --below, --down below the primary monitor + --portrait Portrait mode. Width and height of the screen are swapped + --hidpi HiDPI mode. Width and height are doubled + +example: +virtscreen # GUI mode. You need to use this first + # to configure the screen +virtscreen --auto # CLI mode. Scrren will be created using previous + # settings (from both GUI mode and CLI mode) +virtscreen --left # CLI mode. On the left to the primary monitor +virtscreen --below # CLI mode. Below the primary monitor. +virtscreen --below --portrait # Below, and portrait mode. +virtscreen --below --portrait --hipdi # Below, portrait, HiDPI mode. ``` ## Installation +### Universal package (AppImage) + +Download a `.AppImage` package from [releases page](https://github.com/kbumsik/VirtScreen/releases). Then make it executable: + +```shell +chmod a+x VirtScreen.AppImage +``` + +Then you can run it by double click the file or `./VirtScreen.AppImage` in terminal. + ### Debian (Ubuntu) -#### `.deb` package +Download a `.deb` package from [releases page](https://github.com/kbumsik/VirtScreen/releases). Then install it: -```bash +```shell sudo apt-get update -sudo apt-get install x11vnc qtbase5-dev -wget https://github.com/kbumsik/VirtScreen/releases/download/0.2.1/virtscreen_0.2.1-1_all.deb -sudo dpkg -i virtscreen_0.2.1-1_all.deb -rm virtscreen_0.2.1-1_all.deb +sudo apt-get install x11vnc +sudo dpkg -i virtscreen.deb +rm virtscreen.deb ``` ### Arch Linux (AUR) @@ -44,14 +109,7 @@ yaourt virtscreen ### Python `pip` -If your distro is none of above, you may install it using `pip`. In this case, you need to install the dependancies manually. - -You need [`x11vnc`](https://github.com/LibVNC/x11vnc), `xrandr`. To install (e.g. on Ubuntu): -```bash -sudo apt-get install x11vnc # On Debian/Ubuntu, xrandr is included. -``` - -After you install the dependancies then run: +Although not recommended, you may install it using `pip`. In this case, you need to install the dependancy (`xrandr` and `x11vnc`) manually. ```bash sudo pip install virtscreen diff --git a/data/icon.xcf b/data/icon.xcf deleted file mode 100644 index ba7b0fe..0000000 Binary files a/data/icon.xcf and /dev/null differ diff --git a/data/icon_full.svg b/data/icon_full.svg new file mode 100644 index 0000000..7e5732e --- /dev/null +++ b/data/icon_full.svg @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/systray_icon.svg b/data/systray_icon.svg new file mode 100644 index 0000000..3187b20 --- /dev/null +++ b/data/systray_icon.svg @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/virtscreen.png b/data/virtscreen.png index ff65365..03d75e9 100644 Binary files a/data/virtscreen.png and b/data/virtscreen.png differ diff --git a/launch_env.sh b/launch_env.sh deleted file mode 100755 index 12c9cc3..0000000 --- a/launch_env.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh -# This script is only for isolated miniconda environment -# Used in Debian & AppImage package -ENV=/usr/share/virtscreen/env - -export PYTHONPATH=$ENV/lib/python3.6 -export LD_LIBRARY_PATH=$ENV/lib -export QT_PLUGIN_PATH=$ENV/lib/python3.6/site-packages/PyQt5/Qt/plugins -export QML2_IMPORT_PATH=$ENV/lib/python3.6/site-packages/PyQt5/Qt/qml -# export QT_QPA_FONTDIR=/usr/share/fonts -# export QT_XKB_CONFIG_ROOT=/usr/share/X11/xkb - -$ENV/bin/python3 $ENV/bin/virtscreen diff --git a/package/appimage/.gitignore b/package/appimage/.gitignore index a874d89..1e70004 100644 --- a/package/appimage/.gitignore +++ b/package/appimage/.gitignore @@ -1,2 +1,2 @@ -VirtScreen-x86_64.AppImage -virtscreen.AppDir +*.AppImage +*.AppDir diff --git a/package/appimage/AppRun b/package/appimage/AppRun index 2512c2b..f8e633e 100755 --- a/package/appimage/AppRun +++ b/package/appimage/AppRun @@ -4,7 +4,6 @@ SCRIPTDIR=$(dirname $0) ENV=$SCRIPTDIR/usr/share/virtscreen/env -echo $SCRIPTDIR export PYTHONPATH=$ENV/lib/python3.6 export LD_LIBRARY_PATH=$ENV/lib export QT_PLUGIN_PATH=$ENV/lib/python3.6/site-packages/PyQt5/Qt/plugins @@ -12,4 +11,4 @@ export QML2_IMPORT_PATH=$ENV/lib/python3.6/site-packages/PyQt5/Qt/qml # export QT_QPA_FONTDIR=/usr/share/fonts # export QT_XKB_CONFIG_ROOT=/usr/share/X11/xkb -$ENV/bin/python3 $ENV/bin/virtscreen +$ENV/bin/python3 $ENV/bin/virtscreen $@ diff --git a/package/archlinux/.SRCINFO b/package/archlinux/.SRCINFO deleted file mode 100644 index 5a5310f..0000000 --- a/package/archlinux/.SRCINFO +++ /dev/null @@ -1,22 +0,0 @@ -pkgbase = virtscreen - pkgdesc = Make your iPad/tablet/computer as a secondary monitor on Linux - pkgver = 0.2.1 - pkgrel = 1 - url = https://github.com/kbumsik/VirtScreen - arch = i686 - arch = x86_64 - license = GPL - makedepends = python-pip - depends = xorg-xrandr - depends = x11vnc - depends = python-pyqt5 - depends = python-twisted - depends = python-netifaces - depends = python-qt5reactor - optdepends = arandr: for display settings option - provides = virtscreen - source = https://github.com/kbumsik/VirtScreen/archive/0.2.1.tar.gz - sha256sums = 9af568a73ff3523144bfbeacb7131d4fff9fc4fb8ee3fddb90d78f54b774acb7 - -pkgname = virtscreen - diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD index bbfc14f..b5ae795 100644 --- a/package/archlinux/PKGBUILD +++ b/package/archlinux/PKGBUILD @@ -1,15 +1,15 @@ # Maintainer: Bumsik Kim _pkgname_camelcase=VirtScreen pkgname=virtscreen -pkgver=0.2.1 +pkgver=0.3.1 pkgrel=1 pkgdesc="Make your iPad/tablet/computer as a secondary monitor on Linux" arch=("i686" "x86_64") url="https://github.com/kbumsik/VirtScreen" license=('GPL') groups=() -depends=('xorg-xrandr' 'x11vnc' 'python-pyqt5' 'python-twisted' 'python-netifaces' 'python-qt5reactor') -makedepends=('python-pip') +depends=('xorg-xrandr' 'x11vnc' 'python-pyqt5' 'qt5-quickcontrols2' 'python-quamash-git' 'python-netifaces') +makedepends=('python-pip' 'perl') optdepends=( 'arandr: for display settings option' ) @@ -20,13 +20,21 @@ backup=() options=() install= changelog= -source=(https://github.com/kbumsik/$_pkgname_camelcase/archive/$pkgver.tar.gz) +source=(src::git+https://github.com/kbumsik/$_pkgname_camelcase.git#tag=$pkgver) noextract=() -sha256sums=('9af568a73ff3523144bfbeacb7131d4fff9fc4fb8ee3fddb90d78f54b774acb7') +md5sums=('SKIP') + +prepare() { + cd $srcdir/src + # Delete PyQt5 from install_requires because python-pyqt5 does not have PyPI metadata. + # See https://bugs.archlinux.org/task/58887 + perl -pi -e "s/\'PyQt5>=\d+\.\d+\.\d+\',//" \ + setup.py +} package() { - cd $_pkgname_camelcase-$pkgver - PIP_CONFIG_FILE=/dev/null /usr/bin/pip install --isolated --root="$pkgdir" --ignore-installed --no-deps . + cd $srcdir/src + PIP_CONFIG_FILE=/dev/null /usr/bin/pip install --isolated --root="$pkgdir" --ignore-installed --ignore-requires-python --no-deps . # These are already installed by setup.py # install -Dm644 "data/$pkgname.desktop" "$pkgdir/usr/share/applications/$pkgname.desktop" # install -Dm644 "data/icon.png" "$pkgdir/usr/share/pixmaps/$pkgname.png" diff --git a/package/build_all.sh b/package/build_all.sh deleted file mode 100755 index c9ce382..0000000 --- a/package/build_all.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/bin/bash - -# Get parameters. Just return 0 if no parameter passed -if [ -n "$1" ]; then - VERSION=$1 -else - exit 0 -fi - -# Directory -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -ROOT=$DIR/.. - -override_version () { - # Update python setup.py - perl -pi -e "s/version=\'\d+\.\d+\.\d+\'/version=\'$VERSION\'/" \ - $ROOT/setup.py - # Update .json files in the module - perl -pi -e "s/\"version\"\s*\:\s*\"\d+\.\d+\.\d+\"/\"version\"\: \"$VERSION\"/" \ - $ROOT/virtscreen/assets/data.json - perl -pi -e "s/\"version\"\s*\:\s*\"\d+\.\d+\.\d+\"/\"version\"\: \"$VERSION\"/" \ - $ROOT/virtscreen/assets/config.default.json - # Arch AUR - perl -pi -e "s/pkgver=\d+\.\d+\.\d+/pkgver=$VERSION/" \ - $ROOT/package/archlinux/PKGBUILD - # Debian - perl -pi -e "s/PKGVER=\d+\.\d+\.\d+/PKGVER=$VERSION/" \ - $ROOT/package/debian/_common.sh -} - -build_pypi () { - make -C $ROOT python-wheel -} - -build_appimage () { - make -C $ROOT appimage-build -} - -build_arch () { - wget -q https://github.com/kbumsik/VirtScreen/archive/$VERSION.tar.gz - SHA256=$(sha256sum $VERSION.tar.gz | cut -d' ' -f1) - # Arch AUR - perl -pi -e "s/sha256sums=\('.*'\)/sha256sums=('$SHA256')/" \ - $ROOT/package/archlinux/PKGBUILD - rm $VERSION.tar.gz - make -C $ROOT arch-upload -} - -build_debian () { - make -C $ROOT deb-env-build - make -C $ROOT deb-chown -} - -override_version -# build_pypi -build_appimage -# build_arch -build_debian diff --git a/package/debian/.gitignore b/package/debian/.gitignore new file mode 100644 index 0000000..b006ca0 --- /dev/null +++ b/package/debian/.gitignore @@ -0,0 +1,3 @@ +*.deb +*.buildinfo +*.changes diff --git a/package/debian/Makefile b/package/debian/Makefile new file mode 100644 index 0000000..b9629da --- /dev/null +++ b/package/debian/Makefile @@ -0,0 +1,28 @@ +prefix = /usr + +all: + : # do nothing + +SHELL = /bin/bash +install: + mkdir -p $(DESTDIR)$(prefix)/bin + install -m 755 VirtScreen.AppImage \ + $(DESTDIR)$(prefix)/bin/virtscreen + # Copy desktop entry and icon + install -m 644 -D virtscreen.desktop \ + $(DESTDIR)$(prefix)/share/applications/virtscreen.desktop + install -m 644 -D data/virtscreen.png \ + $(DESTDIR)$(prefix)/share/pixmaps/virtscreen.png + +clean: + : # do nothing + +distclean: clean + +uninstall: + : # do nothing + +# override_dh_usrlocal: +# : # do nothing + +.PHONY: all install clean distclean uninstall diff --git a/package/debian/Makefile.virtualenv b/package/debian/Makefile.virtualenv deleted file mode 100644 index 02e1778..0000000 --- a/package/debian/Makefile.virtualenv +++ /dev/null @@ -1,40 +0,0 @@ -prefix = /usr - -all: - : # do nothing - -SHELL = /bin/bash -install: - # Create virtualenv - install -d $(DESTDIR)$(prefix)/share/virtscreen - source $(HOME)/miniconda/bin/activate && \ - conda create -y --copy --prefix $(DESTDIR)$(prefix)/share/virtscreen/env python=3.6 - # Install VirtScreen using pip - source $(HOME)/miniconda/bin/activate && \ - source activate $(DESTDIR)$(prefix)/share/virtscreen/env && \ - pip install . - # Fix hashbang and move executable - sed -i "1s:.*:#!$(prefix)/share/virtscreen/env/bin/python3:" \ - $(DESTDIR)$(prefix)/share/virtscreen/env/bin/virtscreen - install -D launch_env.sh \ - $(DESTDIR)$(prefix)/bin/virtscreen - # Delete unnecessary installed files done by setup.py - rm -rf $(DESTDIR)$(prefix)/share/virtscreen/env/lib/python3.6/site-packages/usr - # Copy desktop entry and icon - install -m 644 -D virtscreen.desktop \ - $(DESTDIR)$(prefix)/share/applications/virtscreen.desktop - install -m 644 -D data/virtscreen.png \ - $(DESTDIR)$(prefix)/share/pixmaps/virtscreen.png - -clean: - : # do nothing - -distclean: clean - -uninstall: - : # do nothing - -# override_dh_usrlocal: -# : # do nothing - -.PHONY: all install clean distclean uninstall diff --git a/package/debian/_common.sh b/package/debian/_common.sh deleted file mode 100644 index 566bc68..0000000 --- a/package/debian/_common.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -PKGVER=0.2.4 -# Required for debmake -DEBEMAIL="k.bumsik@gmail.com" -DEBFULLNAME="Bumsik Kim" -export PKGVER DEBEMAIL DEBFULLNAME \ No newline at end of file diff --git a/package/debian/build.sh b/package/debian/build.sh new file mode 100755 index 0000000..dea5e40 --- /dev/null +++ b/package/debian/build.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +PKGVER=0.3.1 +# Required for debmake +DEBEMAIL="k.bumsik@gmail.com" +DEBFULLNAME="Bumsik Kim" +export PKGVER DEBEMAIL DEBFULLNAME + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT=$SCRIPT_DIR/../.. + +# Generate necessary files for package building (generated by debmake) +cd $ROOT/package/debian +cp $ROOT/virtscreen-$PKGVER.tar.gz . +tar -xzmf virtscreen-$PKGVER.tar.gz +cp $ROOT/package/debian/Makefile \ + $ROOT/package/debian/virtscreen-$PKGVER/Makefile +cd $ROOT/package/debian/virtscreen-$PKGVER +debmake --yes -b':sh' + +# copy files to build +# debmake files +mkdir -p $ROOT/package/debian/build +cp -R $ROOT/package/debian/virtscreen-$PKGVER/debian \ + $ROOT/package/debian/build/debian +cp $ROOT/package/debian/Makefile \ + $ROOT/package/debian/build/ +cp $ROOT/package/debian/{control,README.Debian} \ + $ROOT/package/debian/build/debian/ +# binary and data files +cp $ROOT/package/appimage/VirtScreen.AppImage \ + $ROOT/package/debian/build/ +cp $ROOT/virtscreen.desktop \ + $ROOT/package/debian/build/ +cp -R $ROOT/data \ + $ROOT/package/debian/build/ + +# Build .deb package +cd $ROOT/package/debian/build +dpkg-buildpackage -b + +# cleanup +rm -rf $ROOT/package/debian/virtscreen-$PKGVER \ + $ROOT/package/debian/*.tar.gz diff --git a/package/debian/contents.sh b/package/debian/contents.sh deleted file mode 100755 index 4928a86..0000000 --- a/package/debian/contents.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash - -source _common.sh - -cd build -dpkg -c virtscreen_$PKGVER-1_all.deb diff --git a/package/debian/control b/package/debian/control index 6fbe87c..f4874a0 100644 --- a/package/debian/control +++ b/package/debian/control @@ -2,15 +2,15 @@ Source: virtscreen Section: utils Priority: optional Maintainer: Bumsik Kim -Build-Depends: debhelper (>=9), dh-python, python3-all +Build-Depends: debhelper (>=9), python3-all Standards-Version: 3.9.8 Homepage: https://github.com/kbumsik/VirtScreen -X-Python3-Version: >= 3.6 +X-Python3-Version: >= 3.5 Package: virtscreen Architecture: all Multi-Arch: foreign -Depends: ${misc:Depends}, ${python3:Depends}, x11vnc, python3-pyqt5, python3-twisted, python3-netifaces +Depends: ${misc:Depends}, x11vnc Description: Make your iPad/tablet/computer as a secondary monitor on Linux VirtScreen is an easy-to-use Linux GUI app that creates a virtual secondary screen and shares it through VNC. diff --git a/package/debian/control.virtualenv b/package/debian/control.virtualenv deleted file mode 100644 index f4874a0..0000000 --- a/package/debian/control.virtualenv +++ /dev/null @@ -1,16 +0,0 @@ -Source: virtscreen -Section: utils -Priority: optional -Maintainer: Bumsik Kim -Build-Depends: debhelper (>=9), python3-all -Standards-Version: 3.9.8 -Homepage: https://github.com/kbumsik/VirtScreen -X-Python3-Version: >= 3.5 - -Package: virtscreen -Architecture: all -Multi-Arch: foreign -Depends: ${misc:Depends}, x11vnc -Description: Make your iPad/tablet/computer as a secondary monitor on Linux - VirtScreen is an easy-to-use Linux GUI app that creates a virtual - secondary screen and shares it through VNC. diff --git a/package/debian/copy_debian.sh b/package/debian/copy_debian.sh deleted file mode 100755 index 91d1d33..0000000 --- a/package/debian/copy_debian.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -source $DIR/_common.sh - -if [ $1 = "virtualenv" ]; then - cp -f $DIR/control.virtualenv $DIR/build/virtscreen-$PKGVER/debian/control - cp -f $DIR/README.Debian $DIR/build/virtscreen-$PKGVER/debian/ -else - cp -f $DIR/{control,rules,README.Debian} $DIR/build/virtscreen-$PKGVER/debian -fi diff --git a/package/debian/debmake.sh b/package/debian/debmake.sh deleted file mode 100755 index 55d46a8..0000000 --- a/package/debian/debmake.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -source _common.sh - -mkdir build -cd build -# Download -wget -q https://github.com/kbumsik/VirtScreen/archive/$PKGVER.tar.gz -tar -xzmf $PKGVER.tar.gz -# rename packages -mv VirtScreen-$PKGVER virtscreen-$PKGVER -mv $PKGVER.tar.gz virtscreen-$PKGVER.tar.gz - -cd virtscreen-$PKGVER -if [ $1 = "virtualenv" ]; then - cp -f ../../Makefile.virtualenv Makefile - debmake -b':sh' -else - debmake -b':py3' -fi diff --git a/package/debian/debuild.sh b/package/debian/debuild.sh deleted file mode 100755 index 27e359e..0000000 --- a/package/debian/debuild.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -source _common.sh - -cd build -cd virtscreen-$PKGVER -if [ $1 = "virtualenv" ]; then - dpkg-buildpackage -b -else - debuild -fi diff --git a/package/debian/rules b/package/debian/rules deleted file mode 100755 index 3201ffa..0000000 --- a/package/debian/rules +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/make -f -# You must remove unused comment lines for the released package. -export DH_VERBOSE = 1 - -%: - dh $@ --with python3 --buildsystem=pybuild - -#override_dh_auto_install: -# dh_auto_install -- prefix=/usr - -#override_dh_install: -# dh_install --list-missing -X.pyc -X.pyo diff --git a/package/pypi/.gitignore b/package/pypi/.gitignore new file mode 100644 index 0000000..5b08461 --- /dev/null +++ b/package/pypi/.gitignore @@ -0,0 +1,2 @@ +virtscreen*.whl +*.tar.gz diff --git a/setup.py b/setup.py index f1355a7..36eeb37 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ setup( # For a discussion on single-sourcing the version across setup.py and the # project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='0.2.4', # Required + version='0.3.1', # Required # This is a one-line description or tagline of what your project does. This # corresponds to the "Summary" metadata field: @@ -107,8 +107,8 @@ setup( # Specify the Python versions you support here. In particular, ensure # that you indicate whether you support Python 2, Python 3 or both. 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', # Environment 'Environment :: X11 Applications', @@ -116,8 +116,7 @@ setup( 'Operating System :: POSIX :: Linux', # Framework used - 'Framework :: Twisted', - # 'Framework :: AsyncIO', + 'Framework :: AsyncIO', ], # This field adds keywords for your project which will appear on the @@ -136,7 +135,7 @@ setup( # # py_modules=["my_module"], # - packages=['virtscreen'], # Required + packages=find_packages(), # Required # This field lists other packages that your project depends on to run. # Any package you put here will be installed by pip when your project is @@ -145,8 +144,7 @@ setup( # For an analysis of "install_requires" vs pip's requirements files see: # https://packaging.python.org/en/latest/requirements.html install_requires=['PyQt5>=5.10.1', - 'Twisted>=17.9.0', - 'qt5reactor>=0.5', + 'Quamash>=0.6.0', 'netifaces>=0.10.6'], # Optional # List additional groups of dependencies here (e.g. development @@ -196,7 +194,7 @@ setup( # executes the function `main` from this package when invoked: entry_points={ # Optional 'console_scripts': [ - 'virtscreen = virtscreen.virtscreen:main', + 'virtscreen = virtscreen.__main__:main', ], }, diff --git a/virtscreen/__init__.py b/virtscreen/__init__.py index 8be907d..e69de29 100644 --- a/virtscreen/__init__.py +++ b/virtscreen/__init__.py @@ -1 +0,0 @@ -__all__ = ['virtscreen'] \ No newline at end of file diff --git a/virtscreen/__main__.py b/virtscreen/__main__.py new file mode 100755 index 0000000..5237741 --- /dev/null +++ b/virtscreen/__main__.py @@ -0,0 +1,218 @@ +#!/usr/bin/python3 + +# Python standard packages +import sys +import os +import signal +import json +import shutil +import argparse +import logging +from logging.handlers import RotatingFileHandler +from typing import Callable +import asyncio + +# Import OpenGL library for Nvidia driver +# https://github.com/Ultimaker/Cura/pull/131#issuecomment-176088664 +import ctypes +from ctypes.util import find_library +ctypes.CDLL(find_library('GL'), ctypes.RTLD_GLOBAL) + +from PyQt5.QtWidgets import QApplication +from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine +from PyQt5.QtGui import QIcon +from PyQt5.QtCore import Qt, QUrl +from quamash import QEventLoop + +from .display import DisplayProperty +from .xrandr import XRandR +from .qt_backend import Backend, Cursor, Network +from .path import HOME_PATH, ICON_PATH, MAIN_QML_PATH, CONFIG_PATH, LOGGING_PATH + +def error(*args, **kwargs) -> None: + """Error printing""" + args = ('Error: ', *args) + print(*args, file=sys.stderr, **kwargs) + +def main() -> None: + """Start main program""" + parser = argparse.ArgumentParser( + formatter_class=argparse.RawTextHelpFormatter, + description='Make your iPad/tablet/computer as a secondary monitor on Linux.\n\n' + 'You can start VirtScreen in the following two modes:\n\n' + ' - GUI mode: A system tray icon will appear when no argument passed.\n' + ' You need to use this first to configure a virtual screen.\n' + ' - CLI mode: After configured the virtual screen, you can start VirtScreen\n' + ' in CLI mode if you do not want a GUI, by passing any arguments\n', + epilog='example:\n' + 'virtscreen # GUI mode. You need to use this first\n' + ' to configure the screen\n' + 'virtscreen --auto # CLI mode. Scrren will be created using previous\n' + ' settings (from both GUI mode and CLI mode)\n' + 'virtscreen --left # CLI mode. On the left to the primary monitor\n' + 'virtscreen --below # CLI mode. Below the primary monitor.\n' + 'virtscreen --below --portrait # Below, and portrait mode.\n' + 'virtscreen --below --portrait --hipdi # Below, portrait, HiDPI mode.\n') + parser.add_argument('--auto', action='store_true', + help='create a virtual screen automatically using previous\n' + 'settings (from both GUI mode and CLI mode)') + parser.add_argument('--left', action='store_true', + help='a virtual screen will be created left to the primary\n' + 'monitor') + parser.add_argument('--right', action='store_true', + help='right to the primary monitor') + parser.add_argument('--above', '--up', action='store_true', + help='above the primary monitor') + parser.add_argument('--below', '--down', action='store_true', + help='below the primary monitor') + parser.add_argument('--portrait', action='store_true', + help='Portrait mode. Width and height of the screen are swapped') + parser.add_argument('--hidpi', action='store_true', + help='HiDPI mode. Width and height are doubled') + parser.add_argument('--log', type=str, + help='Python logging level, For example, --log=INFO.\n' + 'Only used for reporting bugs and debugging') + # Add signal handler + def on_exit(self, signum=None, frame=None): + sys.exit(0) + for sig in [signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT]: + signal.signal(sig, on_exit) + + args = vars(parser.parse_args()) + cli_args = ['auto', 'left', 'right', 'above', 'below', 'portrait', 'hidpi'] + # Start main + if any((value and arg in cli_args) for arg, value in args.items()): + main_cli(args) + else: + main_gui(args) + error('Program should not reach here.') + sys.exit(1) + +def check_env(args: argparse.Namespace, msg: Callable[[str], None]) -> None: + """Check environments and arguments before start. This also enable logging""" + if os.environ.get('XDG_SESSION_TYPE', '').lower() == 'wayland': + msg("Currently Wayland is not supported") + sys.exit(1) + # Check ~/.config/virtscreen + if not HOME_PATH: # This is set in path.py + msg("Cannot detect home directory.") + sys.exit(1) + if not os.path.exists(HOME_PATH): + try: + os.makedirs(HOME_PATH) + except: + msg("Cannot create ~/.config/virtscreen") + sys.exit(1) + # Check x11vnc + if not shutil.which('x11vnc'): + msg("x11vnc is not installed.") + sys.exit(1) + # Enable logging + if args['log'] is None: + args['log'] = 'WARNING' + log_level = getattr(logging, args['log'].upper(), None) + if not isinstance(log_level, int): + error('Please choose a correct python logging level') + sys.exit(1) + # When logging level is INFO or lower, print logs in terminal + # Otherwise log to a file + log_to_file = True if log_level > logging.INFO else False + FORMAT = "[%(levelname)s:%(filename)s:%(lineno)s:%(funcName)s()] %(message)s" + logging.basicConfig(level=log_level, format=FORMAT, + **({'filename': LOGGING_PATH} if log_to_file else {})) + if log_to_file: + logger = logging.getLogger() + handler = RotatingFileHandler(LOGGING_PATH, mode='a', maxBytes=1024*4, backupCount=1) + logger.addHandler(handler) + logging.info('logging enabled') + del args['log'] + logging.info(f'{args}') + # Check if xrandr is correctly parsed. + try: + test = XRandR() + except RuntimeError as e: + msg(str(e)) + sys.exit(1) + +def main_gui(args: argparse.Namespace): + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) + app = QApplication(sys.argv) + loop = QEventLoop(app) + asyncio.set_event_loop(loop) + + # Check environment first + from PyQt5.QtWidgets import QMessageBox, QSystemTrayIcon + def dialog(message: str) -> None: + QMessageBox.critical(None, "VirtScreen", message) + if not QSystemTrayIcon.isSystemTrayAvailable(): + dialog("Cannot detect system tray on this system.") + sys.exit(1) + check_env(args, dialog) + + app.setApplicationName("VirtScreen") + app.setWindowIcon(QIcon(ICON_PATH)) + os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" + + # Register the Python type. Its URI is 'People', it's v1.0 and the type + # will be called 'Person' in QML. + qmlRegisterType(DisplayProperty, 'VirtScreen.DisplayProperty', 1, 0, 'DisplayProperty') + qmlRegisterType(Backend, 'VirtScreen.Backend', 1, 0, 'Backend') + qmlRegisterType(Cursor, 'VirtScreen.Cursor', 1, 0, 'Cursor') + qmlRegisterType(Network, 'VirtScreen.Network', 1, 0, 'Network') + + # Create a component factory and load the QML script. + engine = QQmlApplicationEngine() + engine.load(QUrl(MAIN_QML_PATH)) + if not engine.rootObjects(): + dialog("Failed to load QML") + sys.exit(1) + sys.exit(app.exec_()) + with loop: + loop.run_forever() + +def main_cli(args: argparse.Namespace): + loop = asyncio.get_event_loop() + # Check the environment + check_env(args, print) + if not os.path.exists(CONFIG_PATH): + error("Configuration file does not exist.\n" + "Configure a virtual screen using GUI first.") + sys.exit(1) + # By instantiating the backend, additional verifications of config + # file will be done. + backend = Backend(logger=print) + # Get settings + with open(CONFIG_PATH, 'r') as f: + config = json.load(f) + # Override settings from arguments + position = '' + if not args['auto']: + args_virt = ['portrait', 'hidpi'] + for prop in args_virt: + if args[prop]: + config['virt'][prop] = True + args_position = ['left', 'right', 'above', 'below'] + tmp_args = {k: args[k] for k in args_position} + if not any(tmp_args.values()): + error("Choose a position relative to the primary monitor. (e.g. --left)") + sys.exit(1) + for key, value in tmp_args.items(): + if value: + position = key + # Create virtscreen and Start VNC + def handle_error(msg): + error(msg) + sys.exit(1) + backend.onError.connect(handle_error) + backend.createVirtScreen(config['virt']['device'], config['virt']['width'], + config['virt']['height'], config['virt']['portrait'], + config['virt']['hidpi'], position) + def handle_vnc_changed(state): + if state is backend.VNCState.OFF: + sys.exit(0) + backend.onVncStateChanged.connect(handle_vnc_changed) + backend.startVNC(config['vnc']['port']) + loop.run_forever() + +if __name__ == '__main__': + main() diff --git a/virtscreen/assets/config.default.json b/virtscreen/assets/config.default.json index bdf67b2..ae1978c 100644 --- a/virtscreen/assets/config.default.json +++ b/virtscreen/assets/config.default.json @@ -1,5 +1,5 @@ { - "version": "0.2.4", + "version": "0.3.1", "x11vncVersion": "0.9.15", "theme_color": 8, "virt": { diff --git a/virtscreen/assets/data.json b/virtscreen/assets/data.json index 4380a48..ec975a5 100644 --- a/virtscreen/assets/data.json +++ b/virtscreen/assets/data.json @@ -1,5 +1,5 @@ { - "version": "0.2.4", + "version": "0.3.1", "x11vncOptions": { "-ncache": { "value": "-ncache", @@ -34,7 +34,7 @@ "value": "arandr", "name": "ARandR", "args": "arandr", - "XDG_CURRENT_DESKTOP": [] + "XDG_CURRENT_DESKTOP": [""] } } } \ No newline at end of file diff --git a/virtscreen/assets/main.qml b/virtscreen/assets/main.qml index c2c1f74..43a4fdf 100644 --- a/virtscreen/assets/main.qml +++ b/virtscreen/assets/main.qml @@ -129,9 +129,9 @@ Item { // Sytray Icon SystemTrayIcon { id: sysTrayIcon - iconSource: backend.vncState == Backend.CONNECTED ? "../icon/icon_tablet_on.png" : - backend.virtScreenCreated ? "../icon/icon_tablet_off.png" : - "../icon/icon.png" + iconSource: backend.vncState == Backend.CONNECTED ? "../icon/systray_tablet_on.png" : + backend.virtScreenCreated ? "../icon/systray_tablet_off.png" : + "../icon/systray_no_tablet.png" visible: true property bool clicked: false diff --git a/virtscreen/display.py b/virtscreen/display.py new file mode 100644 index 0000000..6a162b2 --- /dev/null +++ b/virtscreen/display.py @@ -0,0 +1,108 @@ +"""Display information data classes""" + +from PyQt5.QtCore import QObject, pyqtProperty + + +class Display(object): + """Display information""" + __slots__ = ['name', 'primary', 'connected', 'active', 'width', 'height', + 'x_offset', 'y_offset'] + + def __init__(self): + self.name: str = None + self.primary: bool = False + self.connected: bool = False + self.active: bool = False + self.width: int = 0 + self.height: int = 0 + self.x_offset: int = 0 + self.y_offset: int = 0 + + def __str__(self) -> str: + ret = f"{self.name}" + if self.connected: + ret += " connected" + else: + ret += " disconnected" + if self.primary: + ret += " primary" + if self.active: + ret += f" {self.width}x{self.height}+{self.x_offset}+{self.y_offset}" + else: + ret += f" not active {self.width}x{self.height}" + return ret + + +class DisplayProperty(QObject): + """Wrapper around Display class for Qt""" + def __init__(self, display: Display, parent=None): + super(DisplayProperty, self).__init__(parent) + self._display = display + + @property + def display(self): + return self._display + + @pyqtProperty(str, constant=True) + def name(self): + return self._display.name + + @name.setter + def name(self, name): + self._display.name = name + + @pyqtProperty(bool, constant=True) + def primary(self): + return self._display.primary + + @primary.setter + def primary(self, primary): + self._display.primary = primary + + @pyqtProperty(bool, constant=True) + def connected(self): + return self._display.connected + + @connected.setter + def connected(self, connected): + self._display.connected = connected + + @pyqtProperty(bool, constant=True) + def active(self): + return self._display.active + + @active.setter + def active(self, active): + self._display.active = active + + @pyqtProperty(int, constant=True) + def width(self): + return self._display.width + + @width.setter + def width(self, width): + self._display.width = width + + @pyqtProperty(int, constant=True) + def height(self): + return self._display.height + + @height.setter + def height(self, height): + self._display.height = height + + @pyqtProperty(int, constant=True) + def x_offset(self): + return self._display.x_offset + + @x_offset.setter + def x_offset(self, x_offset): + self._display.x_offset = x_offset + + @pyqtProperty(int, constant=True) + def y_offset(self): + return self._display.y_offset + + @y_offset.setter + def y_offset(self, y_offset): + self._display.y_offset = y_offset diff --git a/virtscreen/icon/full_256x256.png b/virtscreen/icon/full_256x256.png new file mode 100644 index 0000000..03d75e9 Binary files /dev/null and b/virtscreen/icon/full_256x256.png differ diff --git a/virtscreen/icon/icon.png b/virtscreen/icon/icon.png deleted file mode 100644 index ff65365..0000000 Binary files a/virtscreen/icon/icon.png and /dev/null differ diff --git a/virtscreen/icon/icon_tablet_off.png b/virtscreen/icon/icon_tablet_off.png deleted file mode 100644 index 373fd8f..0000000 Binary files a/virtscreen/icon/icon_tablet_off.png and /dev/null differ diff --git a/virtscreen/icon/icon_tablet_on.png b/virtscreen/icon/icon_tablet_on.png deleted file mode 100644 index be9b72b..0000000 Binary files a/virtscreen/icon/icon_tablet_on.png and /dev/null differ diff --git a/virtscreen/icon/systray_no_tablet.png b/virtscreen/icon/systray_no_tablet.png new file mode 100644 index 0000000..92be6da Binary files /dev/null and b/virtscreen/icon/systray_no_tablet.png differ diff --git a/virtscreen/icon/systray_tablet_off.png b/virtscreen/icon/systray_tablet_off.png new file mode 100644 index 0000000..a6d850e Binary files /dev/null and b/virtscreen/icon/systray_tablet_off.png differ diff --git a/virtscreen/icon/systray_tablet_on.png b/virtscreen/icon/systray_tablet_on.png new file mode 100644 index 0000000..686202d Binary files /dev/null and b/virtscreen/icon/systray_tablet_on.png differ diff --git a/virtscreen/path.py b/virtscreen/path.py new file mode 100644 index 0000000..3d7cabf --- /dev/null +++ b/virtscreen/path.py @@ -0,0 +1,38 @@ +"""File path definitions""" + +import os +from pathlib import Path + + +# Sanitize environment variables +# https://wiki.sei.cmu.edu/confluence/display/c/ENV03-C.+Sanitize+the+environment+when+invoking+external+programs + +# Setting home path +# Rewrite $HOME env for consistency. This will make +# Path.home() to look up in the password directory (pwd module) +try: + os.environ['HOME'] = str(Path.home()) + # os.environ['PATH'] = os.confstr("CS_PATH") # Sanitize $PATH, Deleted by Issue #19. + + # https://www.freedesktop.org/software/systemd/man/file-hierarchy.html + # HOME_PATH will point to ~/.config/virtscreen by default + if ('XDG_CONFIG_HOME' in os.environ) and len(os.environ['XDG_CONFIG_HOME']): + HOME_PATH = os.environ['XDG_CONFIG_HOME'] + else: + HOME_PATH = os.environ['HOME'] + '/.config' + HOME_PATH = HOME_PATH + "/virtscreen" +except OSError: + HOME_PATH = '' # This will be checked in _main_.py. +# Setting base path +BASE_PATH = os.path.dirname(__file__) # Location of this script +# Path in ~/.virtscreen +X11VNC_LOG_PATH = HOME_PATH + "/x11vnc_log.txt" +X11VNC_PASSWORD_PATH = HOME_PATH + "/x11vnc_passwd" +CONFIG_PATH = HOME_PATH + "/config.json" +LOGGING_PATH = HOME_PATH + "/log.txt" +# Path in the program path +ICON_PATH = BASE_PATH + "/icon/full_256x256.png" +ASSETS_PATH = BASE_PATH + "/assets" +DATA_PATH = ASSETS_PATH + "/data.json" +DEFAULT_CONFIG_PATH = ASSETS_PATH + "/config.default.json" +MAIN_QML_PATH = ASSETS_PATH + "/main.qml" diff --git a/virtscreen/process.py b/virtscreen/process.py new file mode 100644 index 0000000..6d00040 --- /dev/null +++ b/virtscreen/process.py @@ -0,0 +1,100 @@ +"""Subprocess wrapper""" + +import subprocess +import asyncio +import signal +import shlex +import os +import logging + + +class SubprocessWrapper: + """Subprocess wrapper class""" + def __init__(self): + pass + + def check_output(self, arg) -> None: + return subprocess.check_output(shlex.split(arg), stderr=subprocess.STDOUT).decode('utf-8') + + def run(self, arg: str, input: str = None, check=False) -> str: + if input: + input = input.encode('utf-8') + return subprocess.run(shlex.split(arg), input=input, stdout=subprocess.PIPE, + check=check, stderr=subprocess.STDOUT).stdout.decode('utf-8') + + +class _Protocol(asyncio.SubprocessProtocol): + """SubprocessProtocol implementation""" + + def __init__(self, outer): + self.outer = outer + self.transport: asyncio.SubprocessTransport + + def connection_made(self, transport): + logging.info("connectionMade!") + self.outer.connected() + self.transport = transport + transport.get_pipe_transport(0).close() # No more input + + def pipe_data_received(self, fd, data): + if fd == 1: # stdout + self.outer.out_recevied(data) + if self.outer.logfile is not None: + self.outer.logfile.write(data) + elif fd == 2: # stderr + self.outer.err_recevied(data) + if self.outer.logfile is not None: + self.outer.logfile.write(data) + + def pipe_connection_lost(self, fd, exc): + if fd == 0: # stdin + logging.info("stdin is closed. (we probably did it)") + elif fd == 1: # stdout + logging.info("The child closed their stdout.") + elif fd == 2: # stderr + logging.info("The child closed their stderr.") + + def connection_lost(self, exc): + logging.info("Subprocess connection lost.") + + def process_exited(self): + if self.outer.logfile is not None: + self.outer.logfile.close() + self.transport.close() + return_code = self.transport.get_returncode() + if return_code is None: + logging.error("Unknown exit") + self.outer.ended(1) + return + logging.info(f"processEnded, status {return_code}") + self.outer.ended(return_code) + + +class AsyncSubprocess(): + """Asynchronous subprocess wrapper class""" + + def __init__(self, connected, out_recevied, err_recevied, ended, logfile=None): + self.connected = connected + self.out_recevied = out_recevied + self.err_recevied = err_recevied + self.ended = ended + self.logfile = logfile + self.transport: asyncio.SubprocessTransport + self.protocol: _Protocol + + async def _run(self, arg: str, loop: asyncio.AbstractEventLoop): + self.transport, self.protocol = await loop.subprocess_exec( + lambda: _Protocol(self), *shlex.split(arg), env=os.environ) + + def run(self, arg: str): + """Spawn a process. + + Arguments: + arg {str} -- arguments in string + """ + loop = asyncio.get_event_loop() + loop.create_task(self._run(arg, loop)) + + def close(self): + """Kill a spawned process.""" + self.transport.send_signal(signal.SIGINT) diff --git a/virtscreen/qt_backend.py b/virtscreen/qt_backend.py new file mode 100644 index 0000000..97988d3 --- /dev/null +++ b/virtscreen/qt_backend.py @@ -0,0 +1,364 @@ +"""GUI backend""" + +import json +import re +import subprocess +import os +import shutil +import atexit +import time +import logging +from typing import Callable + +from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot, pyqtSignal, Q_ENUMS +from PyQt5.QtGui import QCursor +from PyQt5.QtQml import QQmlListProperty +from PyQt5.QtWidgets import QApplication +from netifaces import interfaces, ifaddresses, AF_INET + +from .display import DisplayProperty +from .xrandr import XRandR +from .process import AsyncSubprocess, SubprocessWrapper +from .path import (DATA_PATH, CONFIG_PATH, DEFAULT_CONFIG_PATH, + X11VNC_PASSWORD_PATH, X11VNC_LOG_PATH) + + +class Backend(QObject): + """ Backend class for QML frontend """ + + class VNCState: + """ Enum to indicate a state of the VNC server """ + OFF = 0 + ERROR = 1 + WAITING = 2 + CONNECTED = 3 + + Q_ENUMS(VNCState) + + # Signals + onVirtScreenCreatedChanged = pyqtSignal(bool) + onVncUsePasswordChanged = pyqtSignal(bool) + onVncStateChanged = pyqtSignal(VNCState) + onDisplaySettingClosed = pyqtSignal() + onError = pyqtSignal(str) + + def __init__(self, parent=None, logger=logging.info, error_logger=logging.error): + super(Backend, self).__init__(parent) + # Virtual screen properties + self.xrandr: XRandR = XRandR() + self._virtScreenCreated: bool = False + # VNC server properties + self._vncUsePassword: bool = False + self._vncState: self.VNCState = self.VNCState.OFF + # Primary screen and mouse posistion + self.vncServer: AsyncSubprocess + # Info/error logger + self.log: Callable[[str], None] = logger + self.log_error: Callable[[str], None] = error_logger + # Check config file + # and initialize if needed + need_init = False + if not os.path.exists(CONFIG_PATH): + shutil.copy(DEFAULT_CONFIG_PATH, CONFIG_PATH) + need_init = True + # Version check + file_match = True + with open(CONFIG_PATH, 'r') as f_config, open(DATA_PATH, 'r') as f_data: + config = json.load(f_config) + data = json.load(f_data) + if config['version'] != data['version']: + file_match = False + # Override config with default when version doesn't match + if not file_match: + shutil.copy(DEFAULT_CONFIG_PATH, CONFIG_PATH) + need_init = True + # initialize config file + if need_init: + # 1. Available x11vnc options + # Get available x11vnc options from x11vnc first + p = SubprocessWrapper() + arg = 'x11vnc -opts' + ret = p.run(arg) + options = tuple(m.group(1) for m in re.finditer(r"\s*(-\w+)\s+", ret)) + # Set/unset available x11vnc options flags in config + with open(CONFIG_PATH, 'r') as f, open(DATA_PATH, 'r') as f_data: + config = json.load(f) + data = json.load(f_data) + for key, value in config["x11vncOptions"].items(): + if key in options: + value["available"] = True + else: + value["available"] = False + # Default Display settings app for a Desktop Environment + desktop_environ = os.environ.get('XDG_CURRENT_DESKTOP', '').lower() + for key, value in data['displaySettingApps'].items(): + if desktop_environ in value['XDG_CURRENT_DESKTOP']: + config["displaySettingApp"] = key + # Save the new config + with open(CONFIG_PATH, 'w') as f: + f.write(json.dumps(config, indent=4, sort_keys=True)) + + def promptError(self, msg): + self.log_error(msg) + self.onError.emit(msg) + + # Qt properties + @pyqtProperty(str, constant=True) + def settings(self): + with open(CONFIG_PATH, "r") as f: + return f.read() + + @settings.setter + def settings(self, json_str): + with open(CONFIG_PATH, "w") as f: + f.write(json_str) + + @pyqtProperty(bool, notify=onVirtScreenCreatedChanged) + def virtScreenCreated(self): + return self._virtScreenCreated + + @virtScreenCreated.setter + def virtScreenCreated(self, value): + self._virtScreenCreated = value + self.onVirtScreenCreatedChanged.emit(value) + + @pyqtProperty(QQmlListProperty, constant=True) + def screens(self): + try: + return QQmlListProperty(DisplayProperty, self, [DisplayProperty(x) for x in self.xrandr.screens]) + except RuntimeError as e: + self.promptError(str(e)) + return QQmlListProperty(DisplayProperty, self, []) + + @pyqtProperty(bool, notify=onVncUsePasswordChanged) + def vncUsePassword(self): + if os.path.isfile(X11VNC_PASSWORD_PATH): + self._vncUsePassword = True + else: + if self._vncUsePassword: + self.vncUsePassword = False + return self._vncUsePassword + + @vncUsePassword.setter + def vncUsePassword(self, use): + self._vncUsePassword = use + self.onVncUsePasswordChanged.emit(use) + + @pyqtProperty(VNCState, notify=onVncStateChanged) + def vncState(self): + return self._vncState + + @vncState.setter + def vncState(self, state): + self._vncState = state + self.onVncStateChanged.emit(self._vncState) + + # Qt Slots + @pyqtSlot(str, int, int, bool, bool) + def createVirtScreen(self, device, width, height, portrait, hidpi, pos=''): + self.xrandr.virt_name = device + self.log("Creating a Virtual Screen...") + try: + self.xrandr.create_virtual_screen(width, height, portrait, hidpi, pos) + except subprocess.CalledProcessError as e: + self.promptError(str(e.cmd) + '\n' + e.stdout.decode('utf-8')) + return + except RuntimeError as e: + self.promptError(str(e)) + return + self.virtScreenCreated = True + self.log("The Virtual Screen successfully created.") + + @pyqtSlot() + def deleteVirtScreen(self): + self.log("Deleting the Virtual Screen...") + if self.vncState is not self.VNCState.OFF: + self.promptError("Turn off the VNC server first") + self.virtScreenCreated = True + return + try: + self.xrandr.delete_virtual_screen() + except RuntimeError as e: + self.promptError(str(e)) + return + self.virtScreenCreated = False + + @pyqtSlot(str) + def createVNCPassword(self, password): + if password: + password += '\n' + password + '\n\n' # verify + confirm + p = SubprocessWrapper() + try: + p.run(f"x11vnc -storepasswd {X11VNC_PASSWORD_PATH}", input=password, check=True) + except subprocess.CalledProcessError as e: + self.promptError(str(e.cmd) + '\n' + e.stdout.decode('utf-8')) + return + self.vncUsePassword = True + else: + self.promptError("Empty password") + + @pyqtSlot() + def deleteVNCPassword(self): + if os.path.isfile(X11VNC_PASSWORD_PATH): + os.remove(X11VNC_PASSWORD_PATH) + self.vncUsePassword = False + else: + self.promptError("Failed deleting the password file") + + @pyqtSlot(int) + def startVNC(self, port): + # Check if a virtual screen created + if not self.virtScreenCreated: + self.promptError("Virtual Screen not crated.") + return + if self.vncState is not self.VNCState.OFF: + self.promptError("VNC Server is already running.") + return + # regex used in callbacks + patter_connected = re.compile(r"^.*Got connection from client.*$", re.M) + patter_disconnected = re.compile(r"^.*client_count: 0*$", re.M) + + # define callbacks + def _connected(): + self.log(f"VNC started. Now connect a VNC client to port {port}.") + self.vncState = self.VNCState.WAITING + + def _received(data): + data = data.decode("utf-8") + if (self._vncState is not self.VNCState.CONNECTED) and patter_connected.search(data): + self.log("VNC connected.") + self.vncState = self.VNCState.CONNECTED + if (self._vncState is self.VNCState.CONNECTED) and patter_disconnected.search(data): + self.log("VNC disconnected.") + self.vncState = self.VNCState.WAITING + + def _ended(exitCode): + if exitCode is not 0: + self.vncState = self.VNCState.ERROR + self.promptError('X11VNC: Error occurred.\n' + 'Double check if the port is already used.') + self.vncState = self.VNCState.OFF # TODO: better handling error state + else: + self.vncState = self.VNCState.OFF + self.log("VNC Exited.") + atexit.unregister(self.stopVNC) + # load settings + with open(CONFIG_PATH, 'r') as f: + config = json.load(f) + options = '' + if config['customX11vncArgs']['enabled']: + options = config['customX11vncArgs']['value'] + else: + for key, value in config['x11vncOptions'].items(): + if value['available'] and value['enabled']: + options += key + ' ' + if value['arg'] is not None: + options += str(value['arg']) + ' ' + # Sart x11vnc, turn settings object into VNC arguments format + logfile = open(X11VNC_LOG_PATH, "wb") + self.vncServer = AsyncSubprocess(_connected, _received, _received, _ended, logfile) + try: + virt = self.xrandr.get_virtual_screen() + except RuntimeError as e: + self.promptError(str(e)) + return + clip = f"{virt.width}x{virt.height}+{virt.x_offset}+{virt.y_offset}" + arg = f"x11vnc -rfbport {port} -clip {clip} {options}" + if self.vncUsePassword: + arg += f" -rfbauth {X11VNC_PASSWORD_PATH}" + self.vncServer.run(arg) + # auto stop on exit + atexit.register(self.stopVNC, force=True) + + @pyqtSlot(str) + def openDisplaySetting(self, app: str = "arandr"): + # define callbacks + def _connected(): + self.log("External Display Setting opened.") + + def _received(data): + pass + + def _ended(exitCode): + self.log("External Display Setting closed.") + self.onDisplaySettingClosed.emit() + if exitCode is not 0: + self.promptError(f'Error opening "{running_program}".') + with open(DATA_PATH, 'r') as f: + data = json.load(f)['displaySettingApps'] + if app not in data: + self.promptError('Wrong display settings program') + return + program_list = [data[app]['args'], "arandr"] + program = AsyncSubprocess(_connected, _received, _received, _ended, None) + running_program = '' + for arg in program_list: + if not shutil.which(arg.split()[0]): + continue + running_program = arg + program.run(arg) + return + self.promptError('Failed to find a display settings program.\n' + 'Please install ARandR package.\n' + '(e.g. sudo apt-get install arandr)\n' + 'Please issue a feature request\n' + 'if you wish to add a display settings\n' + 'program for your Desktop Environment.') + + @pyqtSlot() + def stopVNC(self, force=False): + if force: + # Usually called from atexit(). + self.vncServer.close() + time.sleep(3) # Make sure X11VNC shutdown before execute next atexit(). + if self._vncState in (self.VNCState.WAITING, self.VNCState.CONNECTED): + self.vncServer.close() + else: + self.promptError("stopVNC called while it is not running") + + @pyqtSlot() + def clearCache(self): + # engine.clearComponentCache() + pass + + @pyqtSlot() + def quitProgram(self): + self.blockSignals(True) # This will prevent invoking auto-restart or etc. + QApplication.instance().quit() + + +class Cursor(QObject): + """ Global mouse cursor position """ + + def __init__(self, parent=None): + super(Cursor, self).__init__(parent) + + @pyqtProperty(int) + def x(self): + cursor = QCursor().pos() + return cursor.x() + + @pyqtProperty(int) + def y(self): + cursor = QCursor().pos() + return cursor.y() + + +class Network(QObject): + """ Backend class for network interfaces """ + onIPAddressesChanged = pyqtSignal() + + def __init__(self, parent=None): + super(Network, self).__init__(parent) + + @pyqtProperty('QStringList', notify=onIPAddressesChanged) + def ipAddresses(self): + for interface in interfaces(): + if interface == 'lo': + continue + addresses = ifaddresses(interface).get(AF_INET, None) + if addresses is None: + continue + for link in addresses: + if link is not None: + yield link['addr'] diff --git a/virtscreen/virtscreen.py b/virtscreen/virtscreen.py deleted file mode 100755 index 8d49b77..0000000 --- a/virtscreen/virtscreen.py +++ /dev/null @@ -1,894 +0,0 @@ -#!/usr/bin/python3 - -# Python standard packages -import sys -import os -import subprocess -import signal -import re -import atexit -import time -import json -import shutil -import argparse -from pathlib import Path -from enum import Enum -from typing import List, Dict, Callable - -# Import OpenGL library for Nvidia driver -# https://github.com/Ultimaker/Cura/pull/131#issuecomment-176088664 -import ctypes -from ctypes.util import find_library -ctypes.CDLL(find_library('GL'), ctypes.RTLD_GLOBAL) - -# PyQt5 packages -from PyQt5.QtWidgets import QApplication -from PyQt5.QtCore import QObject, QUrl, Qt, pyqtProperty, pyqtSlot, pyqtSignal, Q_ENUMS -from PyQt5.QtGui import QIcon, QCursor -from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine, QQmlListProperty -# Twisted and netifaces -from twisted.internet import protocol, error -from netifaces import interfaces, ifaddresses, AF_INET - -# ------------------------------------------------------------------------------- -# file path definitions -# ------------------------------------------------------------------------------- -# Sanitize environment variables -# https://wiki.sei.cmu.edu/confluence/display/c/ENV03-C.+Sanitize+the+environment+when+invoking+external+programs - -# Delete $HOME env for security reason. This will make -# Path.home() to look up in the password directory (pwd module) -if 'HOME' in os.environ: - del os.environ['HOME'] -os.environ['HOME'] = str(Path.home()) -os.environ['PATH'] = os.confstr("CS_PATH") # Sanitize $PATH - -# Setting home path and base path -# https://www.freedesktop.org/software/systemd/man/file-hierarchy.html -# HOME_PATH will point to ~/.config/virtscreen by default -if 'XDG_CONFIG_HOME' in os.environ and os.environ['XDG_CONFIG_HOME']: - HOME_PATH = os.environ['XDG_CONFIG_HOME'] -else: - HOME_PATH = os.environ['HOME'] - if HOME_PATH is not None: - HOME_PATH = HOME_PATH + "/.config" -if HOME_PATH is not None: - HOME_PATH = HOME_PATH + "/virtscreen" -BASE_PATH = os.path.dirname(__file__) -# Path in ~/.virtscreen -X11VNC_LOG_PATH = HOME_PATH + "/x11vnc_log.txt" -X11VNC_PASSWORD_PATH = HOME_PATH + "/x11vnc_passwd" -CONFIG_PATH = HOME_PATH + "/config.json" -# Path in the program path -ICON_PATH = BASE_PATH + "/icon/icon.png" -ASSETS_PATH = BASE_PATH + "/assets" -DATA_PATH = ASSETS_PATH + "/data.json" -DEFAULT_CONFIG_PATH = ASSETS_PATH + "/config.default.json" -MAIN_QML_PATH = ASSETS_PATH + "/main.qml" - - -# ------------------------------------------------------------------------------- -# Subprocess wrapper -# ------------------------------------------------------------------------------- -class SubprocessWrapper: - def __init__(self): - pass - - def check_output(self, arg) -> None: - return subprocess.check_output(arg.split(), stderr=subprocess.STDOUT).decode('utf-8') - - def run(self, arg: str, input: str = None, check=False) -> str: - if input: - input = input.encode('utf-8') - return subprocess.run(arg.split(), input=input, stdout=subprocess.PIPE, - check=check, stderr=subprocess.STDOUT).stdout.decode('utf-8') - - -# ------------------------------------------------------------------------------- -# Twisted class -# ------------------------------------------------------------------------------- -class ProcessProtocol(protocol.ProcessProtocol): - def __init__(self, onConnected, onOutReceived, onErrRecevied, onProcessEnded, logfile=None): - self.onConnected = onConnected - self.onOutReceived = onOutReceived - self.onErrRecevied = onErrRecevied - self.onProcessEnded = onProcessEnded - self.logfile = logfile - # We cannot import this at the top of the file because qt5reactor should - # be installed in the main function first. - from twisted.internet import reactor # pylint: disable=E0401 - self.reactor = reactor - - def run(self, arg: str): - """Spawn a process - - Arguments: - arg {str} -- arguments in string - """ - - args = arg.split() - self.reactor.spawnProcess(self, args[0], args=args, env=os.environ) - - def kill(self): - """Kill a spawned process - """ - self.transport.signalProcess('INT') - - def connectionMade(self): - print("connectionMade!") - self.onConnected() - self.transport.closeStdin() # No more input - - def outReceived(self, data): - # print("outReceived! with %d bytes!" % len(data)) - self.onOutReceived(data) - if self.logfile is not None: - self.logfile.write(data) - - def errReceived(self, data): - # print("errReceived! with %d bytes!" % len(data)) - self.onErrRecevied(data) - if self.logfile is not None: - self.logfile.write(data) - - def inConnectionLost(self): - print("inConnectionLost! stdin is closed! (we probably did it)") - pass - - def outConnectionLost(self): - print("outConnectionLost! The child closed their stdout!") - pass - - def errConnectionLost(self): - print("errConnectionLost! The child closed their stderr.") - pass - - def processExited(self, reason): - exitCode = reason.value.exitCode - if exitCode is None: - print("Unknown exit") - return - print("processEnded, status", exitCode) - - def processEnded(self, reason): - if self.logfile is not None: - self.logfile.close() - exitCode = reason.value.exitCode - if exitCode is None: - print("Unknown exit") - self.onProcessEnded(1) - return - print("processEnded, status", exitCode) - print("quitting") - self.onProcessEnded(exitCode) - - -# ------------------------------------------------------------------------------- -# Display properties -# ------------------------------------------------------------------------------- -class Display(object): - __slots__ = ['name', 'primary', 'connected', 'active', 'width', 'height', 'x_offset', 'y_offset'] - - def __init__(self): - self.name: str = None - self.primary: bool = False - self.connected: bool = False - self.active: bool = False - self.width: int = 0 - self.height: int = 0 - self.x_offset: int = 0 - self.y_offset: int = 0 - - def __str__(self): - ret = f"{self.name}" - if self.connected: - ret += " connected" - else: - ret += " disconnected" - if self.primary: - ret += " primary" - if self.active: - ret += f" {self.width}x{self.height}+{self.x_offset}+{self.y_offset}" - else: - ret += f" not active {self.width}x{self.height}" - return ret - - -class DisplayProperty(QObject): - def __init__(self, display: Display, parent=None): - super(DisplayProperty, self).__init__(parent) - self._display = display - - @property - def display(self): - return self._display - - @pyqtProperty(str, constant=True) - def name(self): - return self._display.name - - @name.setter - def name(self, name): - self._display.name = name - - @pyqtProperty(bool, constant=True) - def primary(self): - return self._display.primary - - @primary.setter - def primary(self, primary): - self._display.primary = primary - - @pyqtProperty(bool, constant=True) - def connected(self): - return self._display.connected - - @connected.setter - def connected(self, connected): - self._display.connected = connected - - @pyqtProperty(bool, constant=True) - def active(self): - return self._display.active - - @active.setter - def active(self, active): - self._display.active = active - - @pyqtProperty(int, constant=True) - def width(self): - return self._display.width - - @width.setter - def width(self, width): - self._display.width = width - - @pyqtProperty(int, constant=True) - def height(self): - return self._display.height - - @height.setter - def height(self, height): - self._display.height = height - - @pyqtProperty(int, constant=True) - def x_offset(self): - return self._display.x_offset - - @x_offset.setter - def x_offset(self, x_offset): - self._display.x_offset = x_offset - - @pyqtProperty(int, constant=True) - def y_offset(self): - return self._display.y_offset - - @y_offset.setter - def y_offset(self, y_offset): - self._display.y_offset = y_offset - - -# ------------------------------------------------------------------------------- -# Screen adjustment class -# ------------------------------------------------------------------------------- -class XRandR(SubprocessWrapper): - VIRT_SCREEN_SUFFIX = "_virt" - - def __init__(self): - super(XRandR, self).__init__() - self.mode_name: str - self.screens: List[Display] = [] - self.virt: Display() = None - self.primary: Display() = None - self.virt_name: str = '' - self.virt_idx: int = None - self.primary_idx: int = None - # Primary display - self._update_screens() - - def _update_screens(self) -> None: - output = self.run("xrandr") - self.primary = None - self.virt = None - self.screens = [] - self.virt_idx = None - self.primary_idx = None - pattern = re.compile(r"^(\S*)\s+(connected|disconnected)\s+((primary)\s+)?" - r"((\d+)x(\d+)\+(\d+)\+(\d+)\s+)?.*$", re.M) - for idx, match in enumerate(pattern.finditer(output)): - screen = Display() - screen.name = match.group(1) - if self.virt_name and screen.name == self.virt_name: - self.virt_idx = idx - screen.primary = True if match.group(4) else False - if screen.primary: - self.primary_idx = idx - screen.connected = True if match.group(2) == "connected" else False - screen.active = True if match.group(5) else False - self.screens.append(screen) - if not screen.active: - continue - screen.width = int(match.group(6)) - screen.height = int(match.group(7)) - screen.x_offset = int(match.group(8)) - screen.y_offset = int(match.group(9)) - print("Display information:") - for s in self.screens: - print("\t", s) - if self.primary_idx is None: - raise RuntimeError("There is no primary screen detected.\n" - "Go to display settings and set\n" - "a primary screen\n") - if self.virt_idx == self.primary_idx: - raise RuntimeError("Virtual screen must be selected other than the primary screen") - if self.virt_idx is not None: - self.virt = self.screens[self.virt_idx] - elif self.virt_name and self.virt_idx is None: - raise RuntimeError("No virtual screen name found") - self.primary = self.screens[self.primary_idx] - - def _add_screen_mode(self, width, height, portrait, hidpi) -> None: - if not self.virt or not self.virt_name: - raise RuntimeError("No virtual screen selected.\n" - "Go to Display->Virtual Display->Advaced\n" - "To select a device.") - # Set virtual screen property first - self.virt.width = width - self.virt.height = height - if portrait: - self.virt.width = height - self.virt.height = width - if hidpi: - self.virt.width *= 2 - self.virt.height *= 2 - self.mode_name = str(self.virt.width) + "x" + str(self.virt.height) + self.VIRT_SCREEN_SUFFIX - # Then create using xrandr command - args_addmode = f"xrandr --addmode {self.virt.name} {self.mode_name}" - try: - self.check_output(args_addmode) - except subprocess.CalledProcessError: - # When failed create mode and then add again - output = self.run(f"cvt {self.virt.width} {self.virt.height}") - mode = re.search(r"^.*Modeline\s*\".*\"\s*(.*)$", output, re.M).group(1) - # Create new screen mode - self.check_output(f"xrandr --newmode {self.mode_name} {mode}") - # Add mode again - self.check_output(args_addmode) - # After adding mode the program should delete the mode automatically on exit - atexit.register(self.delete_virtual_screen) - - def get_primary_screen(self) -> Display: - self._update_screens() - return self.primary - - def get_virtual_screen(self) -> Display: - self._update_screens() - return self.virt - - def create_virtual_screen(self, width, height, portrait=False, hidpi=False, pos='') -> None: - self._update_screens() - print("creating: ", self.virt) - self._add_screen_mode(width, height, portrait, hidpi) - arg_pos = ['left', 'right', 'above', 'below'] - xrandr_pos = ['--left-of', '--right-of', '--above', '--below'] - if pos and pos in arg_pos: - # convert pos for xrandr - pos = xrandr_pos[arg_pos.index(pos)] - pos += ' ' + self.primary.name - elif not pos: - pos = '--preferred' - else: - raise RuntimeError("Incorrect position option selected.") - self.check_output(f"xrandr --output {self.virt.name} --mode {self.mode_name}") - self.check_output("sleep 5") - self.check_output(f"xrandr --output {self.virt.name} {pos}") - self._update_screens() - - def delete_virtual_screen(self) -> None: - self._update_screens() - try: - self.virt.name - self.mode_name - except AttributeError: - return - self.run(f"xrandr --output {self.virt.name} --off") - self.run(f"xrandr --delmode {self.virt.name} {self.mode_name}") - atexit.unregister(self.delete_virtual_screen) - self._update_screens() - - -# ------------------------------------------------------------------------------- -# QML Backend class -# ------------------------------------------------------------------------------- -class Backend(QObject): - """ Backend class for QML frontend """ - - class VNCState: - """ Enum to indicate a state of the VNC server """ - OFF = 0 - ERROR = 1 - WAITING = 2 - CONNECTED = 3 - - Q_ENUMS(VNCState) - - # Signals - onVirtScreenCreatedChanged = pyqtSignal(bool) - onVncUsePasswordChanged = pyqtSignal(bool) - onVncStateChanged = pyqtSignal(VNCState) - onDisplaySettingClosed = pyqtSignal() - onError = pyqtSignal(str) - - def __init__(self, parent=None): - super(Backend, self).__init__(parent) - # Virtual screen properties - self.xrandr: XRandR = XRandR() - self._virtScreenCreated: bool = False - # VNC server properties - self._vncUsePassword: bool = False - self._vncState: self.VNCState = self.VNCState.OFF - # Primary screen and mouse posistion - self.vncServer: ProcessProtocol - # Check config file - # and initialize if needed - need_init = False - if not os.path.exists(CONFIG_PATH): - shutil.copy(DEFAULT_CONFIG_PATH, CONFIG_PATH) - need_init = True - # Version check - file_match = True - with open(CONFIG_PATH, 'r') as f_config, open(DATA_PATH, 'r') as f_data: - config = json.load(f_config) - data = json.load(f_data) - if config['version'] != data['version']: - file_match = False - # Override config with default when version doesn't match - if not file_match: - shutil.copy(DEFAULT_CONFIG_PATH, CONFIG_PATH) - need_init = True - # initialize config file - if need_init: - # 1. Available x11vnc options - # Get available x11vnc options from x11vnc first - p = SubprocessWrapper() - arg = 'x11vnc -opts' - ret = p.run(arg) - options = tuple(m.group(1) for m in re.finditer("\s*(-\w+)\s+", ret)) - # Set/unset available x11vnc options flags in config - with open(CONFIG_PATH, 'r') as f, open(DATA_PATH, 'r') as f_data: - config = json.load(f) - data = json.load(f_data) - for key, value in config["x11vncOptions"].items(): - if key in options: - value["available"] = True - else: - value["available"] = False - # Default Display settings app for a Desktop Environment - desktop_environ = os.environ['XDG_CURRENT_DESKTOP'].lower() - for key, value in data['displaySettingApps'].items(): - for de in value['XDG_CURRENT_DESKTOP']: - if de in desktop_environ: - config["displaySettingApp"] = key - # Save the new config - with open(CONFIG_PATH, 'w') as f: - f.write(json.dumps(config, indent=4, sort_keys=True)) - - # Qt properties - @pyqtProperty(str, constant=True) - def settings(self): - with open(CONFIG_PATH, "r") as f: - return f.read() - - @settings.setter - def settings(self, json_str): - with open(CONFIG_PATH, "w") as f: - f.write(json_str) - - @pyqtProperty(bool, notify=onVirtScreenCreatedChanged) - def virtScreenCreated(self): - return self._virtScreenCreated - - @virtScreenCreated.setter - def virtScreenCreated(self, value): - self._virtScreenCreated = value - self.onVirtScreenCreatedChanged.emit(value) - - @pyqtProperty(QQmlListProperty, constant=True) - def screens(self): - try: - return QQmlListProperty(DisplayProperty, self, [DisplayProperty(x) for x in self.xrandr.screens]) - except RuntimeError as e: - self.onError.emit(str(e)) - return QQmlListProperty(DisplayProperty, self, []) - - @pyqtProperty(bool, notify=onVncUsePasswordChanged) - def vncUsePassword(self): - if os.path.isfile(X11VNC_PASSWORD_PATH): - self._vncUsePassword = True - else: - if self._vncUsePassword: - self.vncUsePassword = False - return self._vncUsePassword - - @vncUsePassword.setter - def vncUsePassword(self, use): - self._vncUsePassword = use - self.onVncUsePasswordChanged.emit(use) - - @pyqtProperty(VNCState, notify=onVncStateChanged) - def vncState(self): - return self._vncState - - @vncState.setter - def vncState(self, state): - self._vncState = state - self.onVncStateChanged.emit(self._vncState) - - # Qt Slots - @pyqtSlot(str, int, int, bool, bool) - def createVirtScreen(self, device, width, height, portrait, hidpi, pos=''): - self.xrandr.virt_name = device - print("Creating a Virtual Screen...") - try: - self.xrandr.create_virtual_screen(width, height, portrait, hidpi, pos) - except subprocess.CalledProcessError as e: - self.onError.emit(str(e.cmd) + '\n' + e.stdout.decode('utf-8')) - return - except RuntimeError as e: - self.onError.emit(str(e)) - return - self.virtScreenCreated = True - - @pyqtSlot() - def deleteVirtScreen(self): - print("Deleting the Virtual Screen...") - if self.vncState is not self.VNCState.OFF: - self.onError.emit("Turn off the VNC server first") - self.virtScreenCreated = True - return - try: - self.xrandr.delete_virtual_screen() - except RuntimeError as e: - self.onError.emit(str(e)) - return - self.virtScreenCreated = False - - @pyqtSlot(str) - def createVNCPassword(self, password): - if password: - password += '\n' + password + '\n\n' # verify + confirm - p = SubprocessWrapper() - try: - p.run(f"x11vnc -storepasswd {X11VNC_PASSWORD_PATH}", input=password, check=True) - except subprocess.CalledProcessError as e: - self.onError.emit(str(e.cmd) + '\n' + e.stdout.decode('utf-8')) - return - self.vncUsePassword = True - else: - self.onError.emit("Empty password") - - @pyqtSlot() - def deleteVNCPassword(self): - if os.path.isfile(X11VNC_PASSWORD_PATH): - os.remove(X11VNC_PASSWORD_PATH) - self.vncUsePassword = False - else: - self.onError.emit("Failed deleting the password file") - - @pyqtSlot(int) - def startVNC(self, port): - # Check if a virtual screen created - if not self.virtScreenCreated: - self.onError.emit("Virtual Screen not crated.") - return - if self.vncState is not self.VNCState.OFF: - self.onError.emit("VNC Server is already running.") - return - # regex used in callbacks - patter_connected = re.compile(r"^.*Got connection from client.*$", re.M) - patter_disconnected = re.compile(r"^.*client_count: 0*$", re.M) - - # define callbacks - def _onConnected(): - print("VNC started.") - self.vncState = self.VNCState.WAITING - - def _onReceived(data): - data = data.decode("utf-8") - if (self._vncState is not self.VNCState.CONNECTED) and patter_connected.search(data): - print("VNC connected.") - self.vncState = self.VNCState.CONNECTED - if (self._vncState is self.VNCState.CONNECTED) and patter_disconnected.search(data): - print("VNC disconnected.") - self.vncState = self.VNCState.WAITING - - def _onEnded(exitCode): - if exitCode is not 0: - self.vncState = self.VNCState.ERROR - self.onError.emit('X11VNC: Error occurred.\n' - 'Double check if the port is already used.') - self.vncState = self.VNCState.OFF # TODO: better handling error state - else: - self.vncState = self.VNCState.OFF - print("VNC Exited.") - atexit.unregister(self.stopVNC) - # load settings - with open(CONFIG_PATH, 'r') as f: - config = json.load(f) - options = '' - if config['customX11vncArgs']['enabled']: - options = config['customX11vncArgs']['value'] - else: - for key, value in config['x11vncOptions'].items(): - if value['available'] and value['enabled']: - options += key + ' ' - if value['arg'] is not None: - options += str(value['arg']) + ' ' - # Sart x11vnc, turn settings object into VNC arguments format - logfile = open(X11VNC_LOG_PATH, "wb") - self.vncServer = ProcessProtocol(_onConnected, _onReceived, _onReceived, _onEnded, logfile) - try: - virt = self.xrandr.get_virtual_screen() - except RuntimeError as e: - self.onError.emit(str(e)) - return - clip = f"{virt.width}x{virt.height}+{virt.x_offset}+{virt.y_offset}" - arg = f"x11vnc -rfbport {port} -clip {clip} {options}" - if self.vncUsePassword: - arg += f" -rfbauth {X11VNC_PASSWORD_PATH}" - self.vncServer.run(arg) - # auto stop on exit - atexit.register(self.stopVNC, force=True) - - @pyqtSlot(str) - def openDisplaySetting(self, app: str = "arandr"): - # define callbacks - def _onConnected(): - print("External Display Setting opened.") - - def _onReceived(data): - pass - - def _onEnded(exitCode): - print("External Display Setting closed.") - self.onDisplaySettingClosed.emit() - if exitCode is not 0: - self.onError.emit(f'Error opening "{running_program}".') - with open(DATA_PATH, 'r') as f: - data = json.load(f)['displaySettingApps'] - if app not in data: - self.onError.emit('Wrong display settings program') - return - program_list = [data[app]['args'], "arandr"] - program = ProcessProtocol(_onConnected, _onReceived, _onReceived, _onEnded, None) - running_program = '' - for arg in program_list: - if not shutil.which(arg.split()[0]): - continue - running_program = arg - program.run(arg) - return - self.onError.emit('Failed to find a display settings program.\n' - 'Please install ARandR package.\n' - '(e.g. sudo apt-get install arandr)\n' - 'Please issue a feature request\n' - 'if you wish to add a display settings\n' - 'program for your Desktop Environment.') - - @pyqtSlot() - def stopVNC(self, force=False): - if force: - # Usually called from atexit(). - self.vncServer.kill() - time.sleep(3) # Make sure X11VNC shutdown before execute next atexit(). - if self._vncState in (self.VNCState.WAITING, self.VNCState.CONNECTED): - self.vncServer.kill() - else: - self.onError.emit("stopVNC called while it is not running") - - @pyqtSlot() - def clearCache(self): - engine.clearComponentCache() - - @pyqtSlot() - def quitProgram(self): - self.blockSignals(True) # This will prevent invoking auto-restart or etc. - QApplication.instance().quit() - - -class Cursor(QObject): - """ Global mouse cursor position """ - - def __init__(self, parent=None): - super(Cursor, self).__init__(parent) - - @pyqtProperty(int) - def x(self): - cursor = QCursor().pos() - return cursor.x() - - @pyqtProperty(int) - def y(self): - cursor = QCursor().pos() - return cursor.y() - - -class Network(QObject): - """ Backend class for network interfaces """ - onIPAddressesChanged = pyqtSignal() - - def __init__(self, parent=None): - super(Network, self).__init__(parent) - - @pyqtProperty('QStringList', notify=onIPAddressesChanged) - def ipAddresses(self): - for interface in interfaces(): - if interface == 'lo': - continue - addresses = ifaddresses(interface).get(AF_INET, None) - if addresses is None: - continue - for link in addresses: - if link is not None: - yield link['addr'] - - -# ------------------------------------------------------------------------------- -# Main Code -# ------------------------------------------------------------------------------- -def main() -> None: - parser = argparse.ArgumentParser( - formatter_class=argparse.RawTextHelpFormatter, - description='Make your iPad/tablet/computer as a secondary monitor on Linux.\n\n' - 'You can start VirtScreen in the following two modes:\n\n' - ' - GUI mode: A system tray icon will appear when no argument passed.\n' - ' You need to use this first to configure a virtual screen.\n' - ' - CLI mode: After configured the virtual screen, you can start VirtScreen\n' - ' in CLI mode if you do not want a GUI, by passing any arguments\n', - epilog='example:\n' - 'virtscreen # GUI mode. You need to use this first\n' - ' to configure the screen\n' - 'virtscreen --auto # CLI mode. Scrren will be created using previous\n' - ' settings (from both GUI mode and CLI mode)\n' - 'virtscreen --left # CLI mode. On the left to the primary monitor\n' - 'virtscreen --below # CLI mode. Below the primary monitor.\n' - 'virtscreen --below --portrait # Below, and portrait mode.\n' - 'virtscreen --below --portrait --hipdi # Below, portrait, HiDPI mode.\n') - parser.add_argument('--auto', action='store_true', - help='create a virtual screen automatically using previous\n' - 'settings (from both GUI mode and CLI mode)') - parser.add_argument('--left', action='store_true', - help='a virtual screen will be created left to the primary\n' - 'monitor') - parser.add_argument('--right', action='store_true', - help='right to the primary monitor') - parser.add_argument('--above', '--up', action='store_true', - help='above the primary monitor') - parser.add_argument('--below', '--down', action='store_true', - help='below the primary monitor') - parser.add_argument('--portrait', action='store_true', - help='Portrait mode. Width and height of the screen are swapped') - parser.add_argument('--hidpi', action='store_true', - help='HiDPI mode. Width and height are doubled') - # Add signal handler - def on_exit(self, signum=None, frame=None): - sys.exit(0) - for sig in [signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT]: - signal.signal(sig, on_exit) - # Start main - args = parser.parse_args() - if any(vars(args).values()): - main_cli(args) - else: - main_gui() - print('Program should not reach here.') - sys.exit(1) - -def check_env(msg: Callable[[str], None]) -> None: - if os.environ['XDG_SESSION_TYPE'].lower() == 'wayland': - msg("Currently Wayland is not supported") - sys.exit(1) - if not HOME_PATH: - msg("Cannot detect home directory.") - sys.exit(1) - if not os.path.exists(HOME_PATH): - try: - os.makedirs(HOME_PATH) - except: - msg("Cannot create ~/.config/virtscreen") - sys.exit(1) - if not shutil.which('x11vnc'): - msg("x11vnc is not installed.") - sys.exit(1) - try: - test = XRandR() - except RuntimeError as e: - msg(str(e)) - sys.exit(1) - -def main_gui(): - QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) - app = QApplication(sys.argv) - - # Check environment first - from PyQt5.QtWidgets import QMessageBox, QSystemTrayIcon - def dialog(message: str) -> None: - QMessageBox.critical(None, "VirtScreen", message) - if not QSystemTrayIcon.isSystemTrayAvailable(): - dialog("Cannot detect system tray on this system.") - sys.exit(1) - check_env(dialog) - - # Replace Twisted reactor with qt5reactor - import qt5reactor # pylint: disable=E0401 - qt5reactor.install() - from twisted.internet import reactor # pylint: disable=E0401 - - app.setWindowIcon(QIcon(ICON_PATH)) - os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" - # os.environ["QT_QUICK_CONTROLS_STYLE"] = "Fusion" - - # Register the Python type. Its URI is 'People', it's v1.0 and the type - # will be called 'Person' in QML. - qmlRegisterType(DisplayProperty, 'VirtScreen.DisplayProperty', 1, 0, 'DisplayProperty') - qmlRegisterType(Backend, 'VirtScreen.Backend', 1, 0, 'Backend') - qmlRegisterType(Cursor, 'VirtScreen.Cursor', 1, 0, 'Cursor') - qmlRegisterType(Network, 'VirtScreen.Network', 1, 0, 'Network') - - # Create a component factory and load the QML script. - engine = QQmlApplicationEngine() - engine.load(QUrl(MAIN_QML_PATH)) - if not engine.rootObjects(): - dialog("Failed to load QML") - sys.exit(1) - sys.exit(app.exec_()) - reactor.run() - -def main_cli(args: argparse.Namespace): - for key, value in vars(args).items(): - print(key, ": ", value) - # Check the environment - check_env(print) - if not os.path.exists(CONFIG_PATH): - print("Configuration file does not exist.\n" - "Configure a virtual screen using GUI first.") - sys.exit(1) - # By instantiating the backend, additional verifications of config - # file will be done. - backend = Backend() - # Get settings - with open(CONFIG_PATH, 'r') as f: - config = json.load(f) - # Override settings from arguments - position = '' - if not args.auto: - args_virt = ['portrait', 'hidpi'] - for prop in args_virt: - if vars(args)[prop]: - config['virt'][prop] = True - args_position = ['left', 'right', 'above', 'below'] - tmp_args = {k: vars(args)[k] for k in args_position} - if not any(tmp_args.values()): - print("Choose a position relative to the primary monitor. (e.g. --left)") - sys.exit(1) - for key, value in tmp_args.items(): - if value: - position = key - # Create virtscreen and Start VNC - def handle_error(msg): - print('Error: ', msg) - sys.exit(1) - backend.onError.connect(handle_error) - backend.createVirtScreen(config['virt']['device'], config['virt']['width'], - config['virt']['height'], config['virt']['portrait'], - config['virt']['hidpi'], position) - def handle_vnc_changed(state): - if state is backend.VNCState.OFF: - sys.exit(0) - backend.onVncStateChanged.connect(handle_vnc_changed) - from twisted.internet import reactor # pylint: disable=E0401 - backend.startVNC(config['vnc']['port']) - reactor.run() - -if __name__ == '__main__': - main() diff --git a/virtscreen/xrandr.py b/virtscreen/xrandr.py new file mode 100644 index 0000000..e0e5da7 --- /dev/null +++ b/virtscreen/xrandr.py @@ -0,0 +1,139 @@ +"""XRandr parser""" + +import re +import atexit +import subprocess +import logging +from typing import List + +from .display import Display +from .process import SubprocessWrapper + + +VIRT_SCREEN_SUFFIX = "_virt" + + +class XRandR(SubprocessWrapper): + """XRandr parser class""" + + def __init__(self): + super(XRandR, self).__init__() + self.mode_name: str + self.screens: List[Display] = [] + self.virt: Display() = None + self.primary: Display() = None + self.virt_name: str = '' + self.virt_idx: int = None + self.primary_idx: int = None + # Primary display + self._update_screens() + + def _update_screens(self) -> None: + output = self.run("xrandr") + self.primary = None + self.virt = None + self.screens = [] + self.virt_idx = None + self.primary_idx = None + pattern = re.compile(r"^(\S*)\s+(connected|disconnected)\s+((primary)\s+)?" + r"((\d+)x(\d+)\+(\d+)\+(\d+)\s+)?.*$", re.M) + for idx, match in enumerate(pattern.finditer(output)): + screen = Display() + screen.name = match.group(1) + if self.virt_name and screen.name == self.virt_name: + self.virt_idx = idx + screen.primary = True if match.group(4) else False + if screen.primary: + self.primary_idx = idx + screen.connected = True if match.group(2) == "connected" else False + screen.active = True if match.group(5) else False + self.screens.append(screen) + if not screen.active: + continue + screen.width = int(match.group(6)) + screen.height = int(match.group(7)) + screen.x_offset = int(match.group(8)) + screen.y_offset = int(match.group(9)) + logging.info("Display information:") + for s in self.screens: + logging.info(f"\t{s}") + if self.primary_idx is None: + raise RuntimeError("There is no primary screen detected.\n" + "Go to display settings and set\n" + "a primary screen\n") + if self.virt_idx == self.primary_idx: + raise RuntimeError("Virtual screen must be selected other than the primary screen") + if self.virt_idx is not None: + self.virt = self.screens[self.virt_idx] + elif self.virt_name and self.virt_idx is None: + raise RuntimeError("No virtual screen name found") + self.primary = self.screens[self.primary_idx] + + def _add_screen_mode(self, width, height, portrait, hidpi) -> None: + if not self.virt or not self.virt_name: + raise RuntimeError("No virtual screen selected.\n" + "Go to Display->Virtual Display->Advaced\n" + "To select a device.") + # Set virtual screen property first + self.virt.width = width + self.virt.height = height + if portrait: + self.virt.width = height + self.virt.height = width + if hidpi: + self.virt.width *= 2 + self.virt.height *= 2 + self.mode_name = str(self.virt.width) + "x" + str(self.virt.height) + VIRT_SCREEN_SUFFIX + # Then create using xrandr command + args_addmode = f"xrandr --addmode {self.virt.name} {self.mode_name}" + try: + self.check_output(args_addmode) + except subprocess.CalledProcessError: + # When failed create mode and then add again + output = self.run(f"cvt {self.virt.width} {self.virt.height}") + mode = re.search(r"^.*Modeline\s*\".*\"\s*(.*)$", output, re.M).group(1) + # Create new screen mode + self.check_output(f"xrandr --newmode {self.mode_name} {mode}") + # Add mode again + self.check_output(args_addmode) + # After adding mode the program should delete the mode automatically on exit + atexit.register(self.delete_virtual_screen) + + def get_primary_screen(self) -> Display: + self._update_screens() + return self.primary + + def get_virtual_screen(self) -> Display: + self._update_screens() + return self.virt + + def create_virtual_screen(self, width, height, portrait=False, hidpi=False, pos='') -> None: + self._update_screens() + logging.info(f"creating: {self.virt}") + self._add_screen_mode(width, height, portrait, hidpi) + arg_pos = ['left', 'right', 'above', 'below'] + xrandr_pos = ['--left-of', '--right-of', '--above', '--below'] + if pos and pos in arg_pos: + # convert pos for xrandr + pos = xrandr_pos[arg_pos.index(pos)] + pos += ' ' + self.primary.name + elif not pos: + pos = '--preferred' + else: + raise RuntimeError("Incorrect position option selected.") + self.check_output(f"xrandr --output {self.virt.name} --mode {self.mode_name}") + self.check_output("sleep 5") + self.check_output(f"xrandr --output {self.virt.name} {pos}") + self._update_screens() + + def delete_virtual_screen(self) -> None: + self._update_screens() + try: + self.virt.name + self.mode_name + except AttributeError: + return + self.run(f"xrandr --output {self.virt.name} --off") + self.run(f"xrandr --delmode {self.virt.name} {self.mode_name}") + atexit.unregister(self.delete_virtual_screen) + self._update_screens()