diff --git a/.gitignore b/.gitignore index 1f48108..46f649e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,20 @@ -# Pycharm +# IDE and text editor .idea +.vscode # Compiled files from Qt *.qmlc -*.xcf +# Python linter +.pylintrc + +# files & folders for development use +debug + +# Archive file +*.tar.gz + +################################################################################ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index cd9fe60..0000000 --- a/.pylintrc +++ /dev/null @@ -1,4 +0,0 @@ -[MASTER] -extension-pkg-whitelist= - PyQt5, - netifaces 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/.vscode/.gitignore b/.vscode/.gitignore deleted file mode 100644 index 55cf735..0000000 --- a/.vscode/.gitignore +++ /dev/null @@ -1 +0,0 @@ -tags \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index aa3de90..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Python: Current File", - "type": "python", - "request": "launch", - "program": "${file}", - "pythonPath": "${config:python.pythonPath}" - }, - { - "name": "Python: Terminal (integrated)", - "type": "python", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal", - "pythonPath": "${config:python.pythonPath}" - } - ] -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 0467ce7..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "python.venvPath": "${workspaceFolder}/ENV", - "[python]": { - "editor.formatOnSave": true - }, - "python.pythonPath": "${workspaceFolder}/ENV/bin/python" -} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8f882f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Or bionic +FROM ubuntu:bionic +LABEL author="Bumsik Kim " + +RUN apt-get update && \ + 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 + +# 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 92046bf..24eaf06 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,7 @@ include *.md # Include the license file include LICENSE.txt + +# Include data directories +include data/virtscreen.png +include virtscreen.desktop diff --git a/Makefile b/Makefile index ac8a759..9ccaa05 100644 --- a/Makefile +++ b/Makefile @@ -1,33 +1,131 @@ # 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: - python setup.py bdist_wheel --universal - -python-install: - python setup.py install --user - -pip-upload: - twine upload dist/* +PKG_APPIMAGE=package/appimage/VirtScreen.AppImage +PKG_DEBIAN=package/debian/virtscreen.deb +ARCHIVE=virtscreen-$(VERSION).tar.gz .ONESHELL: -arch-update: +.PHONY: run debug run-appimage debug-appimage + +all: package/pypi/*.whl $(ARCHIVE) $(PKG_APPIMAGE) $(PKG_DEBIAN) + +# Run script +run: + python3 -m virtscreen + +debug: + QT_DEBUG_PLUGINS=1 QML_IMPORT_TRACE=1 python3 -m virtscreen --log=DEBUG + +run-appimage: $(PKG_APPIMAGE) + $< + +debug-appimage: $(PKG_APPIMAGE) + QT_DEBUG_PLUGINS=1 QML_IMPORT_TRACE=1 $< --log=DEBUG + +# tar.gz +.PHONY: archive + +archive $(ARCHIVE): + git archive --format=tar.gz --prefix=virtscreen-$(VERSION)/ -o $@ HEAD + +# Docker tools +.PHONY: docker docker-build + +docker: + $(DOCKER_RUN_TTY) /bin/bash + +docker-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 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 +.PHONY: arch-upload arch-clean + +arch-upload: package/archlinux/.SRCINFO + cd package/archlinux + git clone ssh://aur@aur.archlinux.org/virtscreen.git + cp PKGBUILD virtscreen + cp .SRCINFO virtscreen + cd virtscreen + git add --all + git commit + git push + cd .. + rm -rf virtscreen + +package/archlinux/.SRCINFO: cd package/archlinux makepkg --printsrcinfo > .SRCINFO -arch-install: arch-update - cd package/archlinux - makepkg -si - 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 - rm -rf build dist virtscreen.egg-info +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 cd28092..cb22819 100644 --- a/README.md +++ b/README.md @@ -1,49 +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://github.com/kbumsik/VirtScreen/blob/d2387d3321bd4d110d890ca87703196df203dc89/icon/gif_example.gif?raw=true) +
+ + 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. -## Installation & running +## Features -### Installing dependancies +* 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 -You need [`x11vnc`](https://github.com/LibVNC/x11vnc) and `xrandr`. To install (example on Ubuntu): -```bash -$ sudo apt-get install x11vnc -``` +## How to use -### Installing package +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. -#### Using `pip` +### CLI-only option + +You can run VirtScreen with `virtscreen` (or `./VirtScreen.AppImage` if you use the AppImage package) with additional arguments. ```bash -$ pip install 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. ``` -#### From the Git repository directly +## 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) + +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 [`yaourt`](https://github.com/polygamma/aurman) AUR helper: ```bash -$ python setup.py install # add --user option if you have permission problem +yaourt virtscreen ``` +### Python `pip` -### How to run - -Simply run `virtscreen` after installation: +Although not recommended, you may install it using `pip`. In this case, you need to install the dependancy (`xrandr` and `x11vnc`) manually. ```bash -$ virtscreen +sudo pip install virtscreen ``` - -If you want to run it directly from the Git repository: - -```bash -$ ./launch.sh -``` - -Note that any files related to VirtScreen, including password and log, will be stored in `~/.virtscreen` directory. diff --git a/data/desktop_entry.png b/data/desktop_entry.png new file mode 100644 index 0000000..fadb219 Binary files /dev/null and b/data/desktop_entry.png differ diff --git a/virtscreen/icon/gif_example.gif b/data/gif_example.gif similarity index 100% rename from virtscreen/icon/gif_example.gif rename to data/gif_example.gif 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 new file mode 100644 index 0000000..03d75e9 Binary files /dev/null and b/data/virtscreen.png differ diff --git a/launch.sh b/launch.sh deleted file mode 100755 index 912ddcd..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 -python 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 d57ccfb..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.1 - 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.1.tar.gz - sha256sums = c584fe68ef296bced2ef5f3d88ffe81de1039c3062531c34547eeabd8c2f186d - -pkgname = virtscreen - diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD index 65654f3..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.1 +pkgver=0.3.1 pkgrel=1 pkgdesc="Make your iPad/tablet/computer as a secondary monitor on Linux" arch=("i686" "x86_64") url="https://github.com/kbumsik/VirtScreen" license=('GPL') groups=() -depends=('xorg-xrandr' 'x11vnc' 'python-pyqt5' 'python-twisted' 'python-netifaces' 'python-qt5reactor') -makedepends=('python-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,22 @@ 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=('c584fe68ef296bced2ef5f3d88ffe81de1039c3062531c34547eeabd8c2f186d') +md5sums=('SKIP') -build() { - echo "$pkgdir" - cd $_pkgname_camelcase-$pkgver - python 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 - python setup.py install --root="$pkgdir/" --optimize=1 --skip-build - install -Dm644 "$pkgname.desktop" "$pkgdir/usr/share/applications/$pkgname.desktop" - install -Dm644 "$pkgname/icon/icon.png" "$pkgdir/usr/share/pixmaps/$pkgname.png" + 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" } \ No newline at end of file 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/README.Debian b/package/debian/README.Debian new file mode 100644 index 0000000..e1e277f --- /dev/null +++ b/package/debian/README.Debian @@ -0,0 +1,8 @@ +virtscreen for Debian + +Please edit this to provide information specific to +this virtscreen Debian package. + + (Automatically generated by debmake Version 4.2.9) + + -- Bumsik Kim Fri, 25 May 2018 17:28:18 +0000 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/control b/package/debian/control new file mode 100644 index 0000000..f4874a0 --- /dev/null +++ b/package/debian/control @@ -0,0 +1,16 @@ +Source: virtscreen +Section: utils +Priority: optional +Maintainer: Bumsik Kim +Build-Depends: debhelper (>=9), python3-all +Standards-Version: 3.9.8 +Homepage: https://github.com/kbumsik/VirtScreen +X-Python3-Version: >= 3.5 + +Package: virtscreen +Architecture: all +Multi-Arch: foreign +Depends: ${misc:Depends}, x11vnc +Description: Make your iPad/tablet/computer as a secondary monitor on Linux + VirtScreen is an easy-to-use Linux GUI app that creates a virtual + secondary screen and shares it through VNC. diff --git a/package/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 49d597b..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.1.1', # 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 @@ -169,7 +167,7 @@ setup( # If using Python 2.6 or earlier, then these have to be included in # MANIFEST.in as well. package_data={ - 'virtscreen': ['icon/*.png', 'qml/*.qml', 'data/config.default.json'], + 'virtscreen': ['icon/*.png', 'assets/*.qml', 'assets/*.json'], }, # Although 'package_data' is the preferred approach, in some case you may @@ -177,8 +175,15 @@ setup( # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files # # In this case, 'data_file' will be installed into '/my_data' - - # data_files=[('my_data', ['data/data_file'])], # Optional + data_files=[ + # Desktop entries spec: + # https://www.freedesktop.org/wiki/Specifications/desktop-entry-spec/ + ('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']), + # ('share/man/man1', ['man/virtscreen.1']) + ], # Optional # To provide executable scripts, use entry points in preference to the # "scripts" keyword. Entry points provide cross-platform support and allow @@ -189,7 +194,7 @@ setup( # executes the function `main` from this package when invoked: entry_points={ # Optional 'console_scripts': [ - 'virtscreen = virtscreen.virtscreen:main', + 'virtscreen = virtscreen.__main__:main', ], }, diff --git a/virtscreen.desktop b/virtscreen.desktop index ac93627..3c46c4a 100755 --- a/virtscreen.desktop +++ b/virtscreen.desktop @@ -1,10 +1,8 @@ -#!/usr/bin/env xdg-open [Desktop Entry] -Encoding=UTF-8 Type=Application Name=VirtScreen Comment=Make your iPad/tablet/computer as a secondary monitor on Linux -Exec=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/qml/AppWindow.qml b/virtscreen/assets/AppWindow.qml similarity index 86% rename from virtscreen/qml/AppWindow.qml rename to virtscreen/assets/AppWindow.qml index ae63d88..adbad9f 100644 --- a/virtscreen/qml/AppWindow.qml +++ b/virtscreen/assets/AppWindow.qml @@ -10,7 +10,7 @@ ApplicationWindow { id: window visible: false flags: Qt.FramelessWindowHint - title: "Basic layouts" + title: "VirtScreen" property int theme_color: settings.theme_color Material.theme: Material.Light @@ -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 } - text: "VirtScreen" + 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 @@ -277,6 +284,28 @@ ApplicationWindow { } } + Loader { + id: displayOptionsLoader + active: false + source: "DisplayOptionsDialog.qml" + onLoaded: { + item.onClosed.connect(function() { + displayOptionsLoader.active = false; + }); + } + } + + Loader { + id: vncOptionsLoader + active: false + source: "VncOptionsDialog.qml" + onLoaded: { + item.onClosed.connect(function() { + vncOptionsLoader.active = false; + }); + } + } + SwipeView { anchors.top: tabBar.bottom anchors.bottom: parent.bottom diff --git a/virtscreen/assets/DisplayOptionsDialog.qml b/virtscreen/assets/DisplayOptionsDialog.qml new file mode 100644 index 0000000..b7847d5 --- /dev/null +++ b/virtscreen/assets/DisplayOptionsDialog.qml @@ -0,0 +1,71 @@ +import QtQuick 2.10 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.3 +import QtQuick.Layouts 1.3 + +Dialog { + title: "Display Options" + focus: true + modal: true + visible: true + standardButtons: Dialog.Ok + x: (window.width - width) / 2 + y: (window.width - height) / 2 + width: popupWidth + height: 250 + + ColumnLayout { + anchors.fill: parent + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + Label { id: deviceLabel; text: "Device"; } + ComboBox { + id: deviceComboBox + anchors.left: deviceLabel.right + anchors.right: parent.right + anchors.leftMargin: 100 + textRole: "name" + model: backend.screens + currentIndex: { + if (settings.virt.device) { + for (var i = 0; i < model.length; i++) { + if (model[i].name == settings.virt.device) { + return i; + } + } + } + settings.virt.device = ''; + return -1; + } + onActivated: function(index) { + settings.virt.device = model[index].name; + } + delegate: ItemDelegate { + width: deviceComboBox.width + text: modelData.name + font.weight: deviceComboBox.currentIndex === index ? Font.Bold : Font.Normal + enabled: modelData.connected ? false : true + } + } + } + + Text { + 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 " + + "card and its driver." + } + + RowLayout { + // Empty layout + Layout.fillHeight: true + } + } + onAccepted: {} + onRejected: {} +} diff --git a/virtscreen/qml/DisplayPage.qml b/virtscreen/assets/DisplayPage.qml similarity index 75% rename from virtscreen/qml/DisplayPage.qml rename to virtscreen/assets/DisplayPage.qml index e94ee20..0bb8ba3 100644 --- a/virtscreen/qml/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 { @@ -59,27 +59,14 @@ ColumnLayout { } } RowLayout { - anchors.left: parent.left - anchors.right: parent.right - Label { id: deviceLabel; text: "Device"; } - ComboBox { - id: deviceComboBox - anchors.left: deviceLabel.right - anchors.right: parent.right - anchors.leftMargin: 100 - textRole: "name" - model: backend.screens - currentIndex: backend.virtScreenIndex - onActivated: function(index) { - backend.virtScreenIndex = index - } - delegate: ItemDelegate { - width: deviceComboBox.width - text: modelData.name - font.weight: deviceComboBox.currentIndex === index ? Font.DemiBold : Font.Normal - highlighted: ListView.isCurrentItem - enabled: modelData.connected ? false : true - } + Layout.alignment: Qt.AlignRight + Button { + text: "Advanced" + font.capitalization: Font.MixedCase + onClicked: displayOptionsLoader.active = true; + background.opacity : 0 + onHoveredChanged: hovered ? background.opacity = 0.4 + :background.opacity = 0; } } } @@ -110,6 +97,7 @@ ColumnLayout { window.autoClose = false; if (backend.vncState != Backend.OFF) { console.log("vnc is running"); + stopVNC(); var restoreVNC = true; if (autostart) { autostart = false; @@ -123,11 +111,10 @@ ColumnLayout { autostart = true; } if (restoreVNC) { - backend.startVNC(settings.vnc.port); + startVNC(); } }); - backend.stopVNC(); - backend.openDisplaySetting(); + backend.openDisplaySetting(settings.displaySettingApp); } } } diff --git a/virtscreen/assets/VncOptionsDialog.qml b/virtscreen/assets/VncOptionsDialog.qml new file mode 100644 index 0000000..be50f76 --- /dev/null +++ b/virtscreen/assets/VncOptionsDialog.qml @@ -0,0 +1,81 @@ +import QtQuick 2.10 +import QtQuick.Controls 2.3 +import QtQuick.Controls.Material 2.3 +import QtQuick.Layouts 1.3 + +Dialog { + title: "VNC Options" + focus: true + modal: true + visible: true + standardButtons: Dialog.Ok + x: (window.width - width) / 2 + y: (window.width - height) / 2 + width: popupWidth + height: 350 + + Component.onCompleted: { + var request = new XMLHttpRequest(); + request.open('GET', 'data.json'); + request.onreadystatechange = function(event) { + if (request.readyState == XMLHttpRequest.DONE) { + var data = JSON.parse(request.responseText).x11vncOptions; + // merge data and settings + for (var key in data) { + Object.assign(data[key], settings.x11vncOptions[key]); + } + var repeater = vncOptionsRepeater; + repeater.model = Object.keys(data).map(function(k){return data[k]}); + } + }; + request.send(); + } + + ColumnLayout { + anchors.fill: parent + RowLayout { + TextField { + id: vncCustomArgsTextField + enabled: vncCustomArgsCheckbox.checked + Layout.fillWidth: true + placeholderText: "Custom x11vnc arguments" + onTextEdited: { + settings.customX11vncArgs.value = text; + } + 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 + } + } + onAccepted: {} + onRejected: {} +} diff --git a/virtscreen/qml/VncPage.qml b/virtscreen/assets/VncPage.qml similarity index 65% rename from virtscreen/qml/VncPage.qml rename to virtscreen/assets/VncPage.qml index 6f22ee9..ea66ed8 100644 --- a/virtscreen/qml/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" @@ -45,6 +49,17 @@ ColumnLayout { onClicked: passwordDialog.open() } } + RowLayout { + Layout.alignment: Qt.AlignRight + Button { + text: "Advanced" + font.capitalization: Font.MixedCase + onClicked: vncOptionsLoader.active = true; + background.opacity : 0 + onHoveredChanged: hovered ? background.opacity = 0.4 + :background.opacity = 0; + } + } } } RowLayout { @@ -64,7 +79,7 @@ ColumnLayout { autostart = checked; if ((checked == true) && (backend.vncState == Backend.OFF) && backend.virtScreenCreated) { - backend.startVNC(settings.vnc.port); + startVNC(); } } } @@ -73,28 +88,26 @@ ColumnLayout { GroupBox { title: "Available IP addresses" Layout.fillWidth: true - implicitHeight: 150 - ColumnLayout { + Layout.fillHeight: true + implicitHeight: 145 + 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 new file mode 100644 index 0000000..ae1978c --- /dev/null +++ b/virtscreen/assets/config.default.json @@ -0,0 +1,39 @@ +{ + "version": "0.3.1", + "x11vncVersion": "0.9.15", + "theme_color": 8, + "virt": { + "device": "VIRTUAL1", + "width": 1368, + "height": 1024, + "portrait": false, + "hidpi": false + }, + "vnc": { + "port": 5900, + "autostart": false + }, + "displaySettingApp": "arandr", + "x11vncOptions": { + "-ncache": { + "available": null, + "enabled": false, + "arg": 10 + }, + "-multiptr": { + "available": null, + "enabled": true, + "arg": null + }, + "-repeat": { + "available": null, + "enabled": true, + "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 new file mode 100644 index 0000000..ec975a5 --- /dev/null +++ b/virtscreen/assets/data.json @@ -0,0 +1,40 @@ +{ + "version": "0.3.1", + "x11vncOptions": { + "-ncache": { + "value": "-ncache", + "description": "Client side caching", + "long_description": "Enables cache" + }, + "-multiptr": { + "value": "-multiptr", + "description": "Show mouse pointer", + "long_description": "This also enables input per-client." + }, + "-repeat": { + "value": "-repeat", + "description": "Keyboard auto repeating", + "long_description": "Enables X server key auto repeat" + } + }, + "displaySettingApps": { + "gnome": { + "value": "gnome", + "name": "GNOME", + "args": "gnome-control-center display", + "XDG_CURRENT_DESKTOP": ["gnome", "unity"] + }, + "kde": { + "value": "kde", + "name": "KDE", + "args": "kcmshell5 kcm_kscreen", + "XDG_CURRENT_DESKTOP": ["kde"] + }, + "arandr": { + "value": "arandr", + "name": "ARandR", + "args": "arandr", + "XDG_CURRENT_DESKTOP": [""] + } + } +} \ No newline at end of file diff --git a/virtscreen/qml/main.qml b/virtscreen/assets/main.qml similarity index 79% rename from virtscreen/qml/main.qml rename to virtscreen/assets/main.qml index 99a4dbe..43a4fdf 100644 --- a/virtscreen/qml/main.qml +++ b/virtscreen/assets/main.qml @@ -4,15 +4,36 @@ 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, + settings.virt.hidpi); + } + + function startVNC () { + saveSettings(); + backend.startVNC(settings.vnc.port); + } + + function stopVNC () { + backend.stopVNC(); + } + function switchVNC () { if ((backend.vncState == Backend.OFF) && backend.virtScreenCreated) { - backend.startVNC(settings.vnc.port); + startVNC(); } } @@ -36,10 +57,18 @@ Item { } } + // virtscreen.py Cursor class. + Cursor { + id: cursor + } + // Timer object and function Timer { id: timer function setTimeout(cb, delayTime) { + if (timer.running) { + console.log('Timer is already running!'); + } timer.interval = delayTime; timer.repeat = false; timer.triggered.connect(cb); @@ -81,37 +110,31 @@ 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(); window.raise(); window.requestActivate(); - timer.setTimeout (function() { - sysTrayIcon.clicked = false; - }, 200); } } // 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 - onMessageClicked: console.log("Message clicked") + Component.onCompleted: { // without delay, the message appears in a wierd place timer.setTimeout (function() { @@ -127,6 +150,9 @@ Item { return; } sysTrayIcon.clicked = true; + timer.setTimeout (function() { + sysTrayIcon.clicked = false; + }, 200); mainLoader.active = true; } @@ -163,8 +189,7 @@ Item { // Give a very short delay to show busyDialog. timer.setTimeout (function() { if (!backend.virtScreenCreated) { - backend.createVirtScreen(settings.virt.width, settings.virt.height, - settings.virt.portrait, settings.virt.hidpi); + createVirtScreen(); } else { // If auto start enabled, stop VNC first then if (autostart && (backend.vncState != Backend.OFF)) { @@ -177,7 +202,7 @@ Item { autostart = true; } }); - backend.stopVNC(); + stopVNC(); } else { backend.deleteVirtScreen(); } @@ -191,7 +216,7 @@ Item { backend.vncState == Backend.OFF ? "Start VNC Server" : "Stop VNC Server" enabled: autostart ? false : backend.virtScreenCreated ? true : false - onTriggered: backend.vncState == Backend.OFF ? backend.startVNC(settings.vnc.port) : backend.stopVNC() + onTriggered: backend.vncState == Backend.OFF ? startVNC() : stopVNC() } MenuItem { separator: true @@ -204,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/qml/preferenceDialog.qml b/virtscreen/assets/preferenceDialog.qml similarity index 63% rename from virtscreen/qml/preferenceDialog.qml rename to virtscreen/assets/preferenceDialog.qml index 45400f7..8a1f792 100644 --- a/virtscreen/qml/preferenceDialog.qml +++ b/virtscreen/assets/preferenceDialog.qml @@ -13,8 +13,46 @@ Dialog { x: (window.width - width) / 2 y: (window.width - height) / 2 width: popupWidth + height: 250 + + Component.onCompleted: { + var request = new XMLHttpRequest(); + request.open('GET', 'data.json'); + request.onreadystatechange = function(event) { + if (request.readyState == XMLHttpRequest.DONE) { + var data = JSON.parse(request.responseText).displaySettingApps; + var combobox = displaySettingAppComboBox; + combobox.model = Object.keys(data).map(function(k){return data[k]}); + combobox.currentIndex = Object.keys(data).indexOf(settings.displaySettingApp); + } + }; + request.send(); + } + ColumnLayout { anchors.fill: parent + + RowLayout { + anchors.left: parent.left + anchors.right: parent.right + Label { id: displaySettingAppLabel; text: "Display setting program"; } + ComboBox { + id: displaySettingAppComboBox + anchors.left: displaySettingAppLabel.right + anchors.right: parent.right + anchors.leftMargin: 10 + textRole: "name" + onActivated: function(index) { + settings.displaySettingApp = model[index].value; + } + delegate: ItemDelegate { + width: parent.width + text: modelData.name + font.weight: displaySettingAppComboBox.currentIndex === index ? Font.Bold : Font.Normal + } + } + } + RowLayout { anchors.left: parent.left anchors.right: parent.right @@ -52,6 +90,11 @@ Dialog { } } } + + RowLayout { + // Empty layout + Layout.fillHeight: true + } } onAccepted: {} onRejected: {} diff --git a/virtscreen/data/config.default.json b/virtscreen/data/config.default.json deleted file mode 100644 index 328f007..0000000 --- a/virtscreen/data/config.default.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "version": 0.1, - "theme_color": 8, - "virt": { - "device": "VIRTUAL1", - "width": 1368, - "height": 1024, - "portrait": false, - "hidpi": false - }, - "vnc": { - "port": 5900, - "autostart": false - }, - "presets": [] -} \ No newline at end of file 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 198f50d..0000000 --- a/virtscreen/virtscreen.py +++ /dev/null @@ -1,678 +0,0 @@ -#!/usr/bin/env python - -import sys, os, subprocess, signal, re, atexit, time, json, shutil -from pathlib import Path -from enum import Enum -from typing import List, Dict - -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 - -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 -del os.environ['HOME'] # Delete $HOME env for security reason. This will make -# Path.home() to look up in the password directory (pwd module) -os.environ['PATH'] = os.confstr("CS_PATH") # Sanitize $PATH - -# Setting home path and base path -HOME_PATH = str(Path.home()) -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 -DEFAULT_CONFIG_PATH = BASE_PATH + "/data/config.default.json" -ICON_PATH = BASE_PATH + "/icon/icon.png" -QML_PATH = BASE_PATH + "/qml" -MAIN_QML_PATH = QML_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 - - def run(self, arg: str): - """Spawn a process - - Arguments: - arg {str} -- arguments in string - """ - - args = arg.split() - 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): - DEFAULT_VIRT_SCREEN = "VIRTUAL1" - 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_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.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_idx is None) and (screen.name == self.DEFAULT_VIRT_SCREEN): - 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.virt_idx == self.primary_idx: - raise RuntimeError("Virtual screen must be selected other than the primary screen") - if self.virt_idx is None: - for idx, screen in enumerate(self.screens): - if not screen.connected and not screen.active: - self.virt_idx = idx - break - if self.virt_idx is None: - raise RuntimeError("There is no available devices for virtual screen") - self.virt = self.screens[self.virt_idx] - self.primary = self.screens[self.primary_idx] - - def _add_screen_mode(self, width, height, portrait, hidpi) -> None: - # 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._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: - 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 - self._virtScreenIndex: int = self.xrandr.virt_idx - # 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 - - # Qt properties - @pyqtProperty(str, constant=True) - def settings(self): - try: - with open(CONFIG_PATH, "r") as f: - return f.read() - except FileNotFoundError: - with open(DEFAULT_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): - return QQmlListProperty(DisplayProperty, self, [DisplayProperty(x) for x in self.xrandr.screens]) - - @pyqtProperty(int, notify=onVirtScreenIndexChanged) - def virtScreenIndex(self): - return self._virtScreenIndex - - @virtScreenIndex.setter - def virtScreenIndex(self, virtScreenIndex): - print("Changing virt to ", virtScreenIndex) - self.xrandr.virt_idx = virtScreenIndex - self.xrandr.virt = self.xrandr.screens[self.xrandr.virt_idx] - self._virtScreenIndex = virtScreenIndex - - @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): - self._primaryProp = DisplayProperty(self.xrandr.get_primary_screen()) - 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(int, int, bool, bool) - def createVirtScreen(self, width, height, portrait, hidpi): - 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 - 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 - self.xrandr.delete_virtual_screen() - self.virtScreenCreated = False - - @pyqtSlot(str) - def createVNCPassword(self, password): - if password: - password += '\n' + password + '\n\n' # verify + confirm - p = SubprocessWrapper() - try: - p.run(f"x11vnc -storepasswd {X11VNC_PASSWORD_PATH}", input=password, check=True) - except subprocess.CalledProcessError as e: - self.onError.emit(str(e.cmd) + '\n' + e.stdout.decode('utf-8')) - return - self.vncUsePassword = True - else: - self.onError.emit("Empty password") - - @pyqtSlot() - def deleteVNCPassword(self): - if os.path.isfile(X11VNC_PASSWORD_PATH): - os.remove(X11VNC_PASSWORD_PATH) - self.vncUsePassword = False - else: - self.onError.emit("Failed deleting the password file") - - @pyqtSlot(int) - def startVNC(self, port): - # Check if a virtual screen created - if not self.virtScreenCreated: - self.onError.emit("Virtual Screen not crated.") - return - if self.vncState is not self.VNCState.OFF: - self.onError.emit("VNC Server is already running.") - return - # regex used in callbacks - patter_connected = re.compile(r"^.*Got connection from client.*$", re.M) - patter_disconnected = re.compile(r"^.*client_count: 0*$", re.M) - - # define callbacks - def _onConnected(): - print("VNC started.") - self.vncState = self.VNCState.WAITING - - def _onReceived(data): - data = data.decode("utf-8") - if (self._vncState is not self.VNCState.CONNECTED) and patter_connected.search(data): - print("VNC connected.") - self.vncState = self.VNCState.CONNECTED - if (self._vncState is self.VNCState.CONNECTED) and patter_disconnected.search(data): - print("VNC disconnected.") - self.vncState = self.VNCState.WAITING - - def _onEnded(exitCode): - if exitCode is not 0: - self.vncState = self.VNCState.ERROR - self.onError.emit('X11VNC: Error occurred.\nDouble 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) - virt = self.xrandr.get_virtual_screen() - clip = f"{virt.width}x{virt.height}+{virt.x_offset}+{virt.y_offset}" - arg = f"x11vnc -multiptr -repeat -rfbport {port} -clip {clip}" - if self.vncUsePassword: - arg += f" -rfbauth {X11VNC_PASSWORD_PATH}" - self.vncServer.run(arg) - # auto stop on exit - atexit.register(self.stopVNC, force=True) - - @pyqtSlot() - def openDisplaySetting(self): - # 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}".') - - program_list = ["gnome-control-center display", "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 ~/.virtscreen") - sys.exit(1) - - import qt5reactor # pylint: disable=E0401 - - qt5reactor.install() - from twisted.internet import utils, 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()