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 new file mode 100644 index 0000000..4e692cd --- /dev/null +++ b/.travis.yml @@ -0,0 +1,40 @@ +sudo: required +language: python +python: '3.6' +services: +- docker + +install: | + docker pull kbumsik/virtscreen + pip3 install . + +script: | + echo No test scripts implemented yet. Travis is used only for deploy yet. + +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/virtscreen.deb + - package/appimage/VirtScreen.AppImage + skip_cleanup: true + on: + tags: true + repo: kbumsik/VirtScreen + - provider: pypi + user: kbumsik + password: + secure: d7ozcWf9/j2mpyYX60o7yo/0dPnTkA/1FxPm6GV3bst264z1NVh4G4+J0o/jIpLKA9lEd5QbBUgnLnNIBGGBeEghYCeof/yZnekCntYd75tIAiaIkwBzaYu3n5wfxpEVUIDngTh+biH4EU4iq+Kxrg/KxMi+MetFWL6EVJgtIUarjr2wkBYmKAOEkNvyXWkIEJqUn0xuQSGmqGyNxRjoAPv+6i9QR7KnTCaEPOrEzwKyxhzOL33acBrmaymRFC7EznmaTIHMzGqBcaj3rljC6Kk5bnepSzncNTT8C4v8MuJZPF+oYPN5n16Xy4odAJlt1+pWsuAbhB6Gk/l5Z0zoKjIIuH2LkMWkm2MDO3qbmuu9qfEWg1Y+MmbhnVQf+1qRO7i0vMt9WP5X6IDPkBeXYibUiFZVwYY2AmBchRCD7XvIL1+0JEGQadtAR8EJWNPKCpRgl3p9WTyMVtGgob/UEzknRJWDAYk4u3R4yiMw+shqdc/osRyjoadVQZFZs/80QqLTBUFkR3XlBfNmyywtu3ux9PNnCEgoPO28K6EWj70UaujN87ByjFQ1b4n+wuWwFkp5PTJYLSHgXI8oR29VB9xk4mmKNU4MnAApokgbs4Gqb3jY6KHm5t/MIMqYcrOrqT8OYqwpvfie1FMLXvvtowcgVnUup7vOAaq9mafZpJI= + distributions: "bdist_wheel" + on: + tags: true + repo: kbumsik/VirtScreen diff --git a/package/debian/Dockerfile b/Dockerfile similarity index 50% rename from package/debian/Dockerfile rename to Dockerfile index 6d1e111..8f882f8 100644 --- a/package/debian/Dockerfile +++ b/Dockerfile @@ -1,23 +1,29 @@ # Or bionic -FROM ubuntu:latest +FROM ubuntu:bionic LABEL author="Bumsik Kim " -WORKDIR /app -CMD ["/bin/bash"] - RUN apt-get update && \ - apt-get install -y python3-all python3-pip fakeroot debmake debhelper fakeroot wget tar && \ + apt-get install -y python3-all python3-pip python3-wheel fakeroot debmake debhelper fakeroot wget tar curl && \ apt-get autoremove -y && \ ln /usr/bin/python3 /usr/bin/python && \ ln /usr/bin/pip3 /usr/bin/pip && \ rm -rf /var/cache/apt/archives/*.deb && \ pip install virtualenv && \ - pip install --upgrade pip setuptools && \ - useradd -c Builder -m -U builder - -USER builder + pip install --upgrade pip setuptools # Get Miniconda and make it the main Python interpreter RUN wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda.sh && \ bash ~/miniconda.sh -b -p ~/miniconda && \ rm ~/miniconda.sh + +# AppImageKit +WORKDIR /opt +RUN wget https://github.com/AppImage/AppImageKit/releases/download/10/appimagetool-x86_64.AppImage && \ + chmod a+x appimagetool-x86_64.AppImage && \ + ./appimagetool-x86_64.AppImage --appimage-extract && \ + mv squashfs-root appimagetool && \ + rm appimagetool-x86_64.AppImage +ENV PATH=/opt/appimagetool/usr/bin:$PATH + +WORKDIR /app +CMD ["/bin/bash"] diff --git a/MANIFEST.in b/MANIFEST.in index 8c6f031..24eaf06 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,4 +6,4 @@ include LICENSE.txt # Include data directories include data/virtscreen.png -include data/virtscreen.desktop +include virtscreen.desktop diff --git a/Makefile b/Makefile index 444adc3..9ccaa05 100644 --- a/Makefile +++ b/Makefile @@ -1,66 +1,93 @@ # See https://packaging.python.org/tutorials/distributing-packages/#packaging-your-project # for python packaging reference. +VERSION ?= 0.3.1 -.PHONY: +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) -python-wheel: - /usr/bin/python3 setup.py bdist_wheel --universal - -python-install: - /usr/bin/pip3 install . --user - -python-uninstall: - /usr/bin/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: -# For Debian packaging, https://www.debian.org/doc/manuals/debmake-doc/ch08.en.html#setup-py -deb-docker-build: - docker build -f package/debian/Dockerfile -t debmake . +.PHONY: run debug run-appimage debug-appimage -deb-docker: - docker run --privileged --interactive --tty -v $(shell pwd)/package/debian:/app debmake /bin/bash - -deb-docker-rm: - docker image rm -f debmake +all: package/pypi/*.whl $(ARCHIVE) $(PKG_APPIMAGE) $(PKG_DEBIAN) -deb-make: - docker run --privileged --interactive --tty --rm -v $(shell pwd)/package/debian:/app debmake /app/debmake.sh +# Run script +run: + python3 -m virtscreen -deb-build: deb-clean deb-make - package/debian/copy_debian.sh - docker run --privileged --interactive --tty --rm -v $(shell pwd)/package/debian:/app debmake /app/debuild.sh +debug: + QT_DEBUG_PLUGINS=1 QML_IMPORT_TRACE=1 python3 -m virtscreen --log=DEBUG -deb-contents: - docker run --privileged --interactive --tty --rm -v $(shell pwd)/package/debian:/app debmake /app/contents.sh +run-appimage: $(PKG_APPIMAGE) + $< -deb-env-make: - docker run --privileged --interactive --tty --rm -v $(shell pwd)/package/debian:/app debmake /app/debmake.sh virtualenv +debug-appimage: $(PKG_APPIMAGE) + QT_DEBUG_PLUGINS=1 QML_IMPORT_TRACE=1 $< --log=DEBUG -deb-env-build: deb-clean deb-env-make - package/debian/copy_debian.sh virtualenv - docker run --privileged --interactive --tty --rm -v $(shell pwd)/package/debian:/app debmake /app/debuild.sh virtualenv +# 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-build: + docker build -f Dockerfile -t $(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 +.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: + -rm -rf package/appimage/virtscreen.AppDir $(PKG_APPIMAGE) + +# 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 + +$(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: $(PKG_DEBIAN) + $(DOCKER_RUN) dpkg -c $< deb-clean: - rm -rf package/debian/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-upload: arch-update +arch-upload: package/archlinux/.SRCINFO cd package/archlinux git clone ssh://aur@aur.archlinux.org/virtscreen.git cp PKGBUILD virtscreen @@ -72,11 +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 -launch: - ./launch.sh +# Override version +.PHONY: override-version -clean: arch-clean deb-clean python-clean +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 8e1c127..cb22819 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,116 @@ -# 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) -A PPA package will be available soon. +Download a `.deb` package from [releases page](https://github.com/kbumsik/VirtScreen/releases). Then install it: + +```shell +sudo apt-get update +sudo apt-get install x11vnc +sudo dpkg -i virtscreen.deb +rm virtscreen.deb +``` ### Arch Linux (AUR) -There is [`virtscreen` AUR package](https://aur.archlinux.org/packages/virtscreen/) available. Though there are many ways to install the AUR package, one of the easiest way is to use [`aurman`](https://github.com/polygamma/aurman) AUR helper: +There is [`virtscreen` AUR package](https://aur.archlinux.org/packages/virtscreen/) available. Though there are many ways to install the AUR package, one of the easiest way is to use [`yaourt`](https://github.com/polygamma/aurman) AUR helper: ```bash -$ aurman -S virtscreen +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. - -#### Dependancies - -You need [`x11vnc`](https://github.com/LibVNC/x11vnc), `xrandr`, and PyQt5 libraries. To install (e.g. on Ubuntu): -```bash -$ sudo apt-get install x11vnc qtbase5-dev # On Debian/Ubuntu, xrandr is included. -``` - -#### Installing - -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 +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.sh b/launch.sh deleted file mode 100755 index fb45a18..0000000 --- a/launch.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -# Script to run virtscreen locally -# This is not intended to be included in the distributed package -virtscreen/virtscreen.py diff --git a/package/appimage/.gitignore b/package/appimage/.gitignore new file mode 100644 index 0000000..1e70004 --- /dev/null +++ b/package/appimage/.gitignore @@ -0,0 +1,2 @@ +*.AppImage +*.AppDir diff --git a/package/appimage/AppRun b/package/appimage/AppRun new file mode 100755 index 0000000..f8e633e --- /dev/null +++ b/package/appimage/AppRun @@ -0,0 +1,14 @@ +#!/bin/sh +# This script is only for isolated miniconda environment +# Used in AppImage package +SCRIPTDIR=$(dirname $0) +ENV=$SCRIPTDIR/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/build.sh b/package/appimage/build.sh new file mode 100755 index 0000000..9310785 --- /dev/null +++ b/package/appimage/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# Directory +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT=$DIR/../.. + +cd $ROOT/package/appimage +mkdir virtscreen.AppDir +cd virtscreen.AppDir +# Create virtualenv +install -d usr/share/virtscreen +source $HOME/miniconda/bin/activate && \ + conda create -y --copy --prefix usr/share/virtscreen/env python=3.6 +# Install VirtScreen using pip +source $HOME/miniconda/bin/activate && \ + source activate usr/share/virtscreen/env && \ + pip install $ROOT +# Delete unnecessary installed files done by setup.py +rm -rf usr/share/virtscreen/env/lib/python3.6/site-packages/usr +# Copy desktop entry, icon, and AppRun +install -m 644 -D $ROOT/virtscreen.desktop \ + usr/share/applications/virtscreen.desktop +install -m 644 -D $ROOT/virtscreen.desktop \ + . +install -m 644 -D $ROOT/data/virtscreen.png \ + usr/share/pixmaps/virtscreen.png +install -m 644 -D $ROOT/data/virtscreen.png \ + . +install -m 755 -D $ROOT/package/appimage/AppRun \ + . +cd .. +appimagetool virtscreen.AppDir diff --git a/package/archlinux/.SRCINFO b/package/archlinux/.SRCINFO deleted file mode 100644 index 50dcb89..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.1.3 - pkgrel = 1 - url = https://github.com/kbumsik/VirtScreen - arch = i686 - arch = x86_64 - license = GPL - makedepends = python-setuptools - 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.1.3.tar.gz - sha256sums = 79cd7a07fc5eb9d6034812cca39612cb1cbef109bd2c8e939a45e2186a82cac2 - -pkgname = virtscreen - diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD index 0ca52cc..b5ae795 100644 --- a/package/archlinux/PKGBUILD +++ b/package/archlinux/PKGBUILD @@ -1,15 +1,15 @@ # Maintainer: Bumsik Kim _pkgname_camelcase=VirtScreen pkgname=virtscreen -pkgver=0.1.3 +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-setuptools') +depends=('xorg-xrandr' 'x11vnc' 'python-pyqt5' 'qt5-quickcontrols2' 'python-quamash-git' 'python-netifaces') +makedepends=('python-pip' 'perl') optdepends=( 'arandr: for display settings option' ) @@ -20,19 +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=('79cd7a07fc5eb9d6034812cca39612cb1cbef109bd2c8e939a45e2186a82cac2') +md5sums=('SKIP') -build() { - echo "$pkgdir" - cd $_pkgname_camelcase-$pkgver - /usr/bin/python3 setup.py build +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 - /usr/bin/python3 setup.py install --root="$pkgdir/" --optimize=1 --skip-build + 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/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 3029ace..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 - virtualenv $(DESTDIR)$(prefix)/share/virtscreen/env --always-copy - source $(HOME)/miniconda/bin/activate && \ - conda create -y --copy --prefix $(DESTDIR)$(prefix)/share/virtscreen/env python=3.6 - 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 $(DESTDIR)$(prefix)/share/virtscreen/env/bin/virtscreen \ - $(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 data/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 88dcfa5..0000000 --- a/package/debian/_common.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -PKGVER=0.1.3 -# 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 2f87447..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, qtbase5-dev, 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 50e0dbb..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, qtbase5-dev -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 6adc95b..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 $DIR/control.virtualenv $DIR/build/virtscreen-$PKGVER/debian/control - cp $DIR/README.Debian $DIR/build/virtscreen-$PKGVER/debian/ -else - cp $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 0b262d0..0000000 --- a/package/debian/debmake.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -source _common.sh - -mkdir build -cd build -# Download -wget 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 ../../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 ae63654..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.0', # 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 @@ -180,7 +178,7 @@ setup( data_files=[ # Desktop entries spec: # https://www.freedesktop.org/wiki/Specifications/desktop-entry-spec/ - ('share/applications', ['data/virtscreen.desktop']), + ('share/applications', ['virtscreen.desktop']), # $XDG_DATA_DIRS/icons # https://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html#directory_layout ('share/icons', ['data/virtscreen.png']), @@ -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/data/virtscreen.desktop b/virtscreen.desktop similarity index 71% rename from data/virtscreen.desktop rename to virtscreen.desktop index 810fb1e..3c46c4a 100755 --- a/data/virtscreen.desktop +++ b/virtscreen.desktop @@ -1,9 +1,8 @@ [Desktop Entry] -Encoding=UTF-8 Type=Application Name=VirtScreen Comment=Make your iPad/tablet/computer as a secondary monitor on Linux -Exec=bash -c "export PATH=$PATH:$HOME/.local/bin; virtscreen" +Exec=bash -c "export PATH=\\$PATH:\\$HOME/.local/bin; virtscreen" Icon=virtscreen Terminal=false StartupNotify=false 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/AppWindow.qml b/virtscreen/assets/AppWindow.qml index da9f260..adbad9f 100644 --- a/virtscreen/assets/AppWindow.qml +++ b/virtscreen/assets/AppWindow.qml @@ -22,6 +22,10 @@ ApplicationWindow { height: 540 property int margin: 10 property int popupWidth: width - 26 + + screen: Qt.application.screens[0] + x: screen.virtualX + y: screen.virtualY // hide screen when loosing focus property bool autoClose: true @@ -46,7 +50,7 @@ ApplicationWindow { menuBar: ToolBar { id: toolbar font.weight: Font.Medium - font.pointSize: 11 //parent.font.pointSize + 1 + font.pixelSize: height * 0.34 RowLayout { anchors.fill: parent @@ -60,7 +64,7 @@ ApplicationWindow { ToolButton { id: menuButton - anchors.right: parent.right + Layout.alignment: Qt.AlignRight text: qsTr("⋮") contentItem: Text { text: parent.text @@ -150,35 +154,38 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent Text { - anchors.horizontalCenter: parent.horizontalCenter + Layout.alignment: Qt.AlignHCenter horizontalAlignment: Text.AlignHCenter - font { weight: Font.Bold; pointSize: 15 } + font { weight: Font.Bold; pixelSize: 20 } text: "VirtScreen" + " v" + settings.version } Text { - anchors.horizontalCenter: parent.horizontalCenter + Layout.alignment: Qt.AlignHCenter horizontalAlignment: Text.AlignHCenter + font { pixelSize: 13 } text: "Make your iPad/tablet/computer
as a secondary monitor.
" } Text { + font { pixelSize: 14 } text: "- Project Website" onLinkActivated: Qt.openUrlExternally(link) } Text { + font { pixelSize: 14 } text: "- Issues & Bug Report" onLinkActivated: Qt.openUrlExternally(link) } Text { - font { pointSize: 10 } - anchors.horizontalCenter: parent.horizontalCenter + font { pixelSize: 14 } + Layout.alignment: Qt.AlignHCenter horizontalAlignment: Text.AlignHCenter lineHeight: 0.7 text: "
Copyright © 2018 Bumsik Kim Homepage
" onLinkActivated: Qt.openUrlExternally(link) } Text { - font { pointSize: 9 } - anchors.horizontalCenter: parent.horizontalCenter + font { pixelSize: 11 } + Layout.alignment: Qt.AlignHCenter horizontalAlignment: Text.AlignHCenter text: "This program comes with absolutely no warranty.
" + "See the " + @@ -202,8 +209,7 @@ ApplicationWindow { TextField { id: passwordFIeld focus: true - anchors.left: parent.left - anchors.right: parent.right + Layout.fillWidth: true placeholderText: "New Password"; echoMode: TextInput.Password; } @@ -234,7 +240,8 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent ScrollView { - anchors.fill: parent + Layout.fillHeight: true + Layout.fillWidth: true TextArea { // readOnly: true selectByMouse: true diff --git a/virtscreen/assets/DisplayOptionsDialog.qml b/virtscreen/assets/DisplayOptionsDialog.qml index e242ee4..b7847d5 100644 --- a/virtscreen/assets/DisplayOptionsDialog.qml +++ b/virtscreen/assets/DisplayOptionsDialog.qml @@ -52,14 +52,15 @@ Dialog { } Text { - font { pointSize: 10 } + Layout.fillWidth: true + font { pixelSize: 14 } wrapMode: Text.WordWrap - text: "Warning: Edit only if 'VIRTUAL1' is not available
" + - "If so, please note that the virtual screen may be
" + - "unstable/unavailable depending on a graphic
" + + text: "Warning: Edit only if 'VIRTUAL1' is not available. " + + "If so, please note that the virtual screen may be " + + "unstable/unavailable depending on a graphic " + "card and its driver." } - + RowLayout { // Empty layout Layout.fillHeight: true diff --git a/virtscreen/assets/DisplayPage.qml b/virtscreen/assets/DisplayPage.qml index 393a00e..0bb8ba3 100644 --- a/virtscreen/assets/DisplayPage.qml +++ b/virtscreen/assets/DisplayPage.qml @@ -6,7 +6,7 @@ import VirtScreen.Backend 1.0 ColumnLayout { GroupBox { - title: "Virtual Display" + title: "Virtual Screen" Layout.fillWidth: true enabled: backend.virtScreenCreated ? false : true ColumnLayout { diff --git a/virtscreen/assets/VncOptionsDialog.qml b/virtscreen/assets/VncOptionsDialog.qml index e047a36..be50f76 100644 --- a/virtscreen/assets/VncOptionsDialog.qml +++ b/virtscreen/assets/VncOptionsDialog.qml @@ -12,7 +12,7 @@ Dialog { x: (window.width - width) / 2 y: (window.width - height) / 2 width: popupWidth - height: 300 + height: 350 Component.onCompleted: { var request = new XMLHttpRequest(); @@ -33,24 +33,44 @@ Dialog { ColumnLayout { anchors.fill: parent - - Repeater { - id: vncOptionsRepeater - RowLayout { - enabled: modelData.available - Label { - Layout.fillWidth: true - text: modelData.description + ' (' + modelData.value + ')' + RowLayout { + TextField { + id: vncCustomArgsTextField + enabled: vncCustomArgsCheckbox.checked + Layout.fillWidth: true + placeholderText: "Custom x11vnc arguments" + onTextEdited: { + settings.customX11vncArgs.value = text; } - Switch { - checked: modelData.available ? modelData.enabled : false - onCheckedChanged: { - settings.x11vncOptions[modelData.value].enabled = checked; + text: vncCustomArgsCheckbox.checked ? settings.customX11vncArgs.value : "" + } + CheckBox { + id: vncCustomArgsCheckbox + checked: settings.customX11vncArgs.enabled + onToggled: { + settings.customX11vncArgs.enabled = checked; + } + } + } + ColumnLayout { + enabled: !vncCustomArgsCheckbox.checked + Repeater { + id: vncOptionsRepeater + RowLayout { + enabled: modelData.available + Label { + Layout.fillWidth: true + text: modelData.description + ' (' + modelData.value + ')' + } + Switch { + checked: modelData.available ? modelData.enabled : false + onCheckedChanged: { + settings.x11vncOptions[modelData.value].enabled = checked; + } } } } } - RowLayout { // Empty layout Layout.fillHeight: true diff --git a/virtscreen/assets/VncPage.qml b/virtscreen/assets/VncPage.qml index 167f510..ea66ed8 100644 --- a/virtscreen/assets/VncPage.qml +++ b/virtscreen/assets/VncPage.qml @@ -3,8 +3,14 @@ import QtQuick.Controls 2.3 import QtQuick.Layouts 1.3 import VirtScreen.Backend 1.0 +import VirtScreen.Network 1.0 ColumnLayout { + // virtscreen.py Network interfaces backend. + Network { + id: network + } + GroupBox { title: "VNC Server" Layout.fillWidth: true @@ -27,8 +33,6 @@ ColumnLayout { } } RowLayout { - anchors.left: parent.left - anchors.right: parent.right Label { text: "Password"; Layout.fillWidth: true } Button { text: "Delete" @@ -84,28 +88,26 @@ ColumnLayout { GroupBox { title: "Available IP addresses" Layout.fillWidth: true + Layout.fillHeight: true implicitHeight: 145 - ColumnLayout { + ListView { + id: ipListView anchors.fill: parent - ListView { - id: ipListView - anchors.fill: parent - clip: true - ScrollBar.vertical: ScrollBar { - parent: ipListView.parent - anchors.top: ipListView.top - anchors.right: ipListView.right - anchors.bottom: ipListView.bottom - policy: ScrollBar.AlwaysOn - } - model: backend.ipAddresses - delegate: TextEdit { - text: modelData - readOnly: true - selectByMouse: true - anchors.horizontalCenter: parent.horizontalCenter - font.pointSize: 12 - } + clip: true + ScrollBar.vertical: ScrollBar { + parent: ipListView.parent + anchors.top: ipListView.top + anchors.right: ipListView.right + anchors.bottom: ipListView.bottom + policy: ScrollBar.AlwaysOn + } + model: network.ipAddresses + delegate: TextEdit { + text: modelData + readOnly: true + selectByMouse: true + anchors.horizontalCenter: parent.horizontalCenter + font.pixelSize: 14 } } } diff --git a/virtscreen/assets/config.default.json b/virtscreen/assets/config.default.json index f592c1e..ae1978c 100644 --- a/virtscreen/assets/config.default.json +++ b/virtscreen/assets/config.default.json @@ -1,5 +1,5 @@ { - "version": "0.2.0", + "version": "0.3.1", "x11vncVersion": "0.9.15", "theme_color": 8, "virt": { @@ -31,5 +31,9 @@ "arg": null } }, + "customX11vncArgs": { + "enabled": false, + "value": "" + }, "presets": [] } \ No newline at end of file diff --git a/virtscreen/assets/data.json b/virtscreen/assets/data.json index 30c5938..ec975a5 100644 --- a/virtscreen/assets/data.json +++ b/virtscreen/assets/data.json @@ -1,5 +1,5 @@ { - "version": "0.2.0", + "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 077f315..43a4fdf 100644 --- a/virtscreen/assets/main.qml +++ b/virtscreen/assets/main.qml @@ -4,12 +4,18 @@ import Qt.labs.platform 1.0 import VirtScreen.DisplayProperty 1.0 import VirtScreen.Backend 1.0 +import VirtScreen.Cursor 1.0 Item { property alias window: mainLoader.item property var settings: JSON.parse(backend.settings) property bool autostart: settings.vnc.autostart + function saveSettings () { + settings.vnc.autostart = autostart; + backend.settings = JSON.stringify(settings, null, 4); + } + function createVirtScreen () { backend.createVirtScreen(settings.virt.device, settings.virt.width, settings.virt.height, settings.virt.portrait, @@ -17,18 +23,8 @@ Item { } function startVNC () { - var options = ''; - var data = settings.x11vncOptions; - for (var key in data) { - if(data[key].available && data[key].enabled) { - options += key + ' '; - if(data[key].arg !== null) { - options += data[key].arg.toString() + ' '; - } - } - } - console.log('options: ', options); - backend.startVNC(settings.vnc.port, options); + saveSettings(); + backend.startVNC(settings.vnc.port); } function stopVNC () { @@ -61,6 +57,11 @@ Item { } } + // virtscreen.py Cursor class. + Cursor { + id: cursor + } + // Timer object and function Timer { id: timer @@ -109,17 +110,14 @@ Item { } }); // Move window to the corner of the primary display - var primary = backend.primary; - var width = primary.width; - var height = primary.height; - var cursor_x = backend.cursor_x - primary.x_offset; - var cursor_y = backend.cursor_y - primary.y_offset; - var x_mid = width / 2; - var y_mid = height / 2; - var x = width - window.width; //(cursor_x > x_mid)? width - window.width : 0; - var y = (cursor_y > y_mid)? height - window.height : 0; - x += primary.x_offset; - y += primary.y_offset; + var cursor_x = (cursor.x / window.screen.devicePixelRatio) - window.screen.virtualX; + var cursor_y = (cursor.y / window.screen.devicePixelRatio) - window.screen.virtualY; + var x_mid = window.screen.width / 2; + var y_mid = window.screen.height / 2; + var x = window.screen.width - window.width; //(cursor_x > x_mid)? width - window.width : 0; + var y = (cursor_y > y_mid)? window.screen.height - window.height : 0; + x += window.screen.virtualX; + y += window.screen.virtualY; window.x = x; window.y = y; window.show(); @@ -131,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 @@ -231,8 +229,7 @@ Item { id: quitAction text: qsTr("&Quit") onTriggered: { - settings.vnc.autostart = autostart; - backend.settings = JSON.stringify(settings, null, 4); + saveSettings(); backend.quitProgram(); } } 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 c6457c0..0000000 --- a/virtscreen/virtscreen.py +++ /dev/null @@ -1,764 +0,0 @@ -#!/usr/bin/python3 - -# Python standard packages -import sys, os, subprocess, signal, re, atexit, time, json, shutil -from pathlib import Path -from enum import Enum -from typing import List, Dict -# 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) - for sig in [signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT]: - signal.signal(sig, self._signal_handler) - - def _signal_handler(self, signum=None, frame=None) -> None: - self.delete_virtual_screen() - os._exit(0) - - 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) -> None: - print("creating: ", self.virt) - self._update_screens() - self._add_screen_mode(width, height, portrait, hidpi) - 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} --preferred") - 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) - onVirtScreenIndexChanged = pyqtSignal(int) - onVncUsePasswordChanged = pyqtSignal(bool) - onVncStateChanged = pyqtSignal(VNCState) - onIPAddressesChanged = pyqtSignal() - 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._primaryProp: DisplayProperty - 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 - # 2. 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) - - @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'] - - @pyqtProperty(DisplayProperty) - def primary(self): - try: - self._primaryProp = DisplayProperty(self.xrandr.get_primary_screen()) - except RuntimeError as e: - self.onError.emit(str(e)) - return - return self._primaryProp - - @pyqtProperty(int) - def cursor_x(self): - cursor = QCursor().pos() - return cursor.x() - - @pyqtProperty(int) - def cursor_y(self): - cursor = QCursor().pos() - return cursor.y() - - # Qt Slots - @pyqtSlot(str, int, int, bool, bool) - def createVirtScreen(self, device, width, height, portrait, hidpi): - self.xrandr.virt_name = device - print("Creating a Virtual Screen...") - try: - self.xrandr.create_virtual_screen(width, height, portrait, hidpi) - 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, str) - def startVNC(self, port, options=''): - # 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) - - 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(2) # 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() - - -# ------------------------------------------------------------------------------- -# Main Code -# ------------------------------------------------------------------------------- -def main(): - QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) - app = QApplication(sys.argv) - - from PyQt5.QtWidgets import QSystemTrayIcon, QMessageBox - - if not QSystemTrayIcon.isSystemTrayAvailable(): - QMessageBox.critical(None, "VirtScreen", - "Cannot detect system tray on this system.") - sys.exit(1) - - if os.environ['XDG_SESSION_TYPE'] == 'wayland': - QMessageBox.critical(None, "VirtScreen", - "Currently Wayland is not supported") - sys.exit(1) - if not HOME_PATH: - QMessageBox.critical(None, "VirtScreen", - "Cannot detect home directory.") - sys.exit(1) - if not os.path.exists(HOME_PATH): - try: - os.makedirs(HOME_PATH) - except: - QMessageBox.critical(None, "VirtScreen", - "Cannot create ~/.config/virtscreen") - sys.exit(1) - if not shutil.which('x11vnc'): - QMessageBox.critical(None, "VirtScreen", - "x11vnc is not installed.") - sys.exit(1) - try: - test = XRandR() - except RuntimeError as e: - QMessageBox.critical(None, "VirtScreen", str(e)) - sys.exit(1) - # 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') - - # Create a component factory and load the QML script. - engine = QQmlApplicationEngine() - engine.load(QUrl(MAIN_QML_PATH)) - if not engine.rootObjects(): - QMessageBox.critical(None, "VirtScreen", "Failed to load QML") - sys.exit(1) - sys.exit(app.exec_()) - 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()