├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── README.md ├── data ├── desktop_entry.png ├── gif_example.gif ├── icon_full.svg ├── systray_icon.svg └── virtscreen.png ├── package ├── appimage │ ├── .gitignore │ ├── AppRun │ └── build.sh ├── archlinux │ └── PKGBUILD ├── debian │ ├── .gitignore │ ├── Makefile │ ├── README.Debian │ ├── build.sh │ └── control └── pypi │ └── .gitignore ├── setup.py ├── virtscreen.desktop └── virtscreen ├── __init__.py ├── __main__.py ├── assets ├── AppWindow.qml ├── DisplayOptionsDialog.qml ├── DisplayPage.qml ├── VncOptionsDialog.qml ├── VncPage.qml ├── config.default.json ├── data.json ├── main.qml └── preferenceDialog.qml ├── display.py ├── icon ├── full_256x256.png ├── systray_no_tablet.png ├── systray_tablet_off.png └── systray_tablet_on.png ├── path.py ├── process.py ├── qt_backend.py └── xrandr.py /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE and text editor 2 | .idea 3 | .vscode 4 | 5 | # Compiled files from Qt 6 | *.qmlc 7 | 8 | # Python linter 9 | .pylintrc 10 | 11 | # files & folders for development use 12 | debug 13 | 14 | # Archive file 15 | *.tar.gz 16 | 17 | ################################################################################ 18 | # Byte-compiled / optimized / DLL files 19 | __pycache__/ 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | env/ 29 | build/ 30 | develop-eggs/ 31 | dist/ 32 | downloads/ 33 | eggs/ 34 | .eggs/ 35 | lib/ 36 | lib64/ 37 | parts/ 38 | sdist/ 39 | var/ 40 | wheels/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | 45 | # PyInstaller 46 | # Usually these files are written by a python script from a template 47 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 48 | *.manifest 49 | *.spec 50 | 51 | # Installer logs 52 | pip-log.txt 53 | pip-delete-this-directory.txt 54 | 55 | # Unit test / coverage reports 56 | htmlcov/ 57 | .tox/ 58 | .coverage 59 | .coverage.* 60 | .cache 61 | nosetests.xml 62 | coverage.xml 63 | *.cover 64 | .hypothesis/ 65 | 66 | # Translations 67 | *.mo 68 | *.pot 69 | 70 | # Django stuff: 71 | *.log 72 | local_settings.py 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # dotenv 100 | .env 101 | 102 | # virtualenv 103 | .venv 104 | venv/ 105 | ENV/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: python 3 | python: '3.6' 4 | services: 5 | - docker 6 | 7 | install: | 8 | docker pull kbumsik/virtscreen 9 | pip3 install . 10 | 11 | script: | 12 | echo No test scripts implemented yet. Travis is used only for deploy yet. 13 | 14 | before_deploy: | 15 | if [ -n "$TRAVIS_TAG" ]; then 16 | VERSION=$TRAVIS_TAG make override_version 17 | fi 18 | make package/pypi/*.whl 19 | make package/appimage/VirtScreen.AppImage 20 | make package/debian/virtscreen.deb 21 | 22 | deploy: 23 | - provider: releases 24 | api_key: 25 | 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= 26 | file: 27 | - package/debian/virtscreen.deb 28 | - package/appimage/VirtScreen.AppImage 29 | skip_cleanup: true 30 | on: 31 | tags: true 32 | repo: kbumsik/VirtScreen 33 | - provider: pypi 34 | user: kbumsik 35 | password: 36 | 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= 37 | distributions: "bdist_wheel" 38 | on: 39 | tags: true 40 | repo: kbumsik/VirtScreen 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Or bionic 2 | FROM ubuntu:bionic 3 | LABEL author="Bumsik Kim " 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y python3-all python3-pip python3-wheel fakeroot debmake debhelper fakeroot wget tar curl && \ 7 | apt-get autoremove -y && \ 8 | ln /usr/bin/python3 /usr/bin/python && \ 9 | ln /usr/bin/pip3 /usr/bin/pip && \ 10 | rm -rf /var/cache/apt/archives/*.deb && \ 11 | pip install virtualenv && \ 12 | pip install --upgrade pip setuptools 13 | 14 | # Get Miniconda and make it the main Python interpreter 15 | RUN wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda.sh && \ 16 | bash ~/miniconda.sh -b -p ~/miniconda && \ 17 | rm ~/miniconda.sh 18 | 19 | # AppImageKit 20 | WORKDIR /opt 21 | RUN wget https://github.com/AppImage/AppImageKit/releases/download/10/appimagetool-x86_64.AppImage && \ 22 | chmod a+x appimagetool-x86_64.AppImage && \ 23 | ./appimagetool-x86_64.AppImage --appimage-extract && \ 24 | mv squashfs-root appimagetool && \ 25 | rm appimagetool-x86_64.AppImage 26 | ENV PATH=/opt/appimagetool/usr/bin:$PATH 27 | 28 | WORKDIR /app 29 | CMD ["/bin/bash"] 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the README 2 | include *.md 3 | 4 | # Include the license file 5 | include LICENSE.txt 6 | 7 | # Include data directories 8 | include data/virtscreen.png 9 | include virtscreen.desktop 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # See https://packaging.python.org/tutorials/distributing-packages/#packaging-your-project 2 | # for python packaging reference. 3 | VERSION ?= 0.3.1 4 | 5 | DOCKER_NAME=kbumsik/virtscreen 6 | DOCKER_RUN=docker run --interactive --tty -v $(shell pwd):/app $(DOCKER_NAME) 7 | DOCKER_RUN_TTY=docker run --interactive --tty -v $(shell pwd):/app $(DOCKER_NAME) 8 | 9 | PKG_APPIMAGE=package/appimage/VirtScreen.AppImage 10 | PKG_DEBIAN=package/debian/virtscreen.deb 11 | ARCHIVE=virtscreen-$(VERSION).tar.gz 12 | 13 | .ONESHELL: 14 | 15 | .PHONY: run debug run-appimage debug-appimage 16 | 17 | all: package/pypi/*.whl $(ARCHIVE) $(PKG_APPIMAGE) $(PKG_DEBIAN) 18 | 19 | # Run script 20 | run: 21 | python3 -m virtscreen 22 | 23 | debug: 24 | QT_DEBUG_PLUGINS=1 QML_IMPORT_TRACE=1 python3 -m virtscreen --log=DEBUG 25 | 26 | run-appimage: $(PKG_APPIMAGE) 27 | $< 28 | 29 | debug-appimage: $(PKG_APPIMAGE) 30 | QT_DEBUG_PLUGINS=1 QML_IMPORT_TRACE=1 $< --log=DEBUG 31 | 32 | # tar.gz 33 | .PHONY: archive 34 | 35 | archive $(ARCHIVE): 36 | git archive --format=tar.gz --prefix=virtscreen-$(VERSION)/ -o $@ HEAD 37 | 38 | # Docker tools 39 | .PHONY: docker docker-build 40 | 41 | docker: 42 | $(DOCKER_RUN_TTY) /bin/bash 43 | 44 | docker-build: 45 | docker build -f Dockerfile -t $(DOCKER_NAME) . 46 | 47 | # Python wheel package for PyPI 48 | .PHONY: wheel-clean 49 | 50 | package/pypi/%.whl: 51 | python3 setup.py bdist_wheel --universal 52 | cp dist/* package/pypi 53 | -rm -rf build dist *.egg-info 54 | 55 | wheel-clean: 56 | -rm package/pypi/virtscreen*.whl 57 | 58 | # For AppImage packaging, https://github.com/AppImage/AppImageKit/wiki/Creating-AppImages 59 | .PHONY: appimage-clean 60 | .SECONDARY: $(PKG_APPIMAGE) 61 | 62 | $(PKG_APPIMAGE): 63 | $(DOCKER_RUN) package/appimage/build.sh 64 | $(DOCKER_RUN) mv package/appimage/VirtScreen-x86_64.AppImage $@ 65 | $(DOCKER_RUN) chown -R $(shell id -u):$(shell id -u) package/appimage 66 | 67 | appimage-clean: 68 | -rm -rf package/appimage/virtscreen.AppDir $(PKG_APPIMAGE) 69 | 70 | # For Debian packaging, https://www.debian.org/doc/manuals/maint-guide/index.en.html 71 | # https://www.debian.org/doc/manuals/debmake-doc/ch08.en.html#setup-py 72 | .PHONY: deb-contents deb-clean 73 | 74 | $(PKG_DEBIAN): $(PKG_APPIMAGE) $(ARCHIVE) 75 | $(DOCKER_RUN) package/debian/build.sh 76 | $(DOCKER_RUN) mv package/debian/*.deb $@ 77 | $(DOCKER_RUN) chown -R $(shell id -u):$(shell id -u) package/debian 78 | 79 | deb-contents: $(PKG_DEBIAN) 80 | $(DOCKER_RUN) dpkg -c $< 81 | 82 | deb-clean: 83 | rm -rf package/debian/build package/debian/*.deb package/debian/*.buildinfo \ 84 | package/debian/*.changes 85 | 86 | # For AUR: https://wiki.archlinux.org/index.php/Python_package_guidelines 87 | # and: https://wiki.archlinux.org/index.php/Creating_packages 88 | .PHONY: arch-upload arch-clean 89 | 90 | arch-upload: package/archlinux/.SRCINFO 91 | cd package/archlinux 92 | git clone ssh://aur@aur.archlinux.org/virtscreen.git 93 | cp PKGBUILD virtscreen 94 | cp .SRCINFO virtscreen 95 | cd virtscreen 96 | git add --all 97 | git commit 98 | git push 99 | cd .. 100 | rm -rf virtscreen 101 | 102 | package/archlinux/.SRCINFO: 103 | cd package/archlinux 104 | makepkg --printsrcinfo > .SRCINFO 105 | 106 | arch-clean: 107 | cd package/archlinux 108 | -rm -rf pkg src *.tar* .SRCINFO 109 | 110 | # Override version 111 | .PHONY: override-version 112 | 113 | override-version: 114 | # Update python setup.py 115 | perl -pi -e "s/version=\'\d+\.\d+\.\d+\'/version=\'$(VERSION)\'/" \ 116 | setup.py 117 | # Update .json files in the module 118 | perl -pi -e "s/\"version\"\s*\:\s*\"\d+\.\d+\.\d+\"/\"version\"\: \"$(VERSION)\"/" \ 119 | virtscreen/assets/data.json 120 | perl -pi -e "s/\"version\"\s*\:\s*\"\d+\.\d+\.\d+\"/\"version\"\: \"$(VERSION)\"/" \ 121 | virtscreen/assets/config.default.json 122 | # Arch AUR 123 | perl -pi -e "s/pkgver=\d+\.\d+\.\d+/pkgver=$(VERSION)/" \ 124 | package/archlinux/PKGBUILD 125 | # Debian 126 | perl -pi -e "s/PKGVER=\d+\.\d+\.\d+/PKGVER=$(VERSION)/" \ 127 | package/debian/build.sh 128 | 129 | # Clean packages 130 | clean: appimage-clean arch-clean deb-clean wheel-clean 131 | -rm -f $(ARCHIVE) 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | VirtScreen 5 |

6 | 7 |

8 | Make your iPad/tablet/computer as a secondary monitor on Linux. 9 |

10 | 11 |
12 | 13 | VirtScreen 14 | 15 |
16 | 17 | ## Description 18 | 19 | VirtScreen is an easy-to-use Linux GUI app that creates a virtual secondary screen and shares it through VNC. 20 | 21 | 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. 22 | 23 | ## Features 24 | 25 | * No more typing commands - create a second VNC screen with a few clicks from the GUI. 26 | * ...But there is also command-line only options for CLI lovers. 27 | * Highly configurable - resolutions, portrait mode, and HiDPI mode. 28 | * Works on any Linux Distro with Xorg 29 | * Lightweight 30 | * System Tray Icon 31 | 32 | ## How to use 33 | 34 | 1. Run the app. 35 | 2. Set options (resolution etc.) and enable the virtual screen. 36 | 3. Go to VNC tab and then start the VNC server. 37 | 4. Run your favorite VNC client app on your second device and connect it to the IP address appeared on the app. 38 | 39 | ### CLI-only option 40 | 41 | You can run VirtScreen with `virtscreen` (or `./VirtScreen.AppImage` if you use the AppImage package) with additional arguments. 42 | 43 | ```bash 44 | usage: virtscreen [-h] [--auto] [--left] [--right] [--above] [--below] 45 | [--portrait] [--hidpi] 46 | 47 | Make your iPad/tablet/computer as a secondary monitor on Linux. 48 | 49 | You can start VirtScreen in the following two modes: 50 | 51 | - GUI mode: A system tray icon will appear when no argument passed. 52 | You need to use this first to configure a virtual screen. 53 | - CLI mode: After configured the virtual screen, you can start VirtScreen 54 | in CLI mode if you do not want a GUI, by passing any arguments 55 | 56 | optional arguments: 57 | -h, --help show this help message and exit 58 | --auto create a virtual screen automatically using previous 59 | settings (from both GUI mode and CLI mode) 60 | --left a virtual screen will be created left to the primary 61 | monitor 62 | --right right to the primary monitor 63 | --above, --up above the primary monitor 64 | --below, --down below the primary monitor 65 | --portrait Portrait mode. Width and height of the screen are swapped 66 | --hidpi HiDPI mode. Width and height are doubled 67 | 68 | example: 69 | virtscreen # GUI mode. You need to use this first 70 | # to configure the screen 71 | virtscreen --auto # CLI mode. Scrren will be created using previous 72 | # settings (from both GUI mode and CLI mode) 73 | virtscreen --left # CLI mode. On the left to the primary monitor 74 | virtscreen --below # CLI mode. Below the primary monitor. 75 | virtscreen --below --portrait # Below, and portrait mode. 76 | virtscreen --below --portrait --hipdi # Below, portrait, HiDPI mode. 77 | ``` 78 | 79 | ## Installation 80 | 81 | ### Universal package (AppImage) 82 | 83 | Download a `.AppImage` package from [releases page](https://github.com/kbumsik/VirtScreen/releases). Then make it executable: 84 | 85 | ```shell 86 | chmod a+x VirtScreen.AppImage 87 | ``` 88 | 89 | Then you can run it by double click the file or `./VirtScreen.AppImage` in terminal. 90 | 91 | ### Debian (Ubuntu) 92 | 93 | Download a `.deb` package from [releases page](https://github.com/kbumsik/VirtScreen/releases). Then install it: 94 | 95 | ```shell 96 | sudo apt-get update 97 | sudo apt-get install x11vnc 98 | sudo dpkg -i virtscreen.deb 99 | rm virtscreen.deb 100 | ``` 101 | 102 | ### Arch Linux (AUR) 103 | 104 | 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: 105 | 106 | ```bash 107 | yaourt virtscreen 108 | ``` 109 | 110 | ### Python `pip` 111 | 112 | Although not recommended, you may install it using `pip`. In this case, you need to install the dependancy (`xrandr` and `x11vnc`) manually. 113 | 114 | ```bash 115 | sudo pip install virtscreen 116 | ``` 117 | -------------------------------------------------------------------------------- /data/desktop_entry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbumsik/VirtScreen/9637d628165cd46db0873dbd82af2bfd91d0b7d2/data/desktop_entry.png -------------------------------------------------------------------------------- /data/gif_example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbumsik/VirtScreen/9637d628165cd46db0873dbd82af2bfd91d0b7d2/data/gif_example.gif -------------------------------------------------------------------------------- /data/icon_full.svg: -------------------------------------------------------------------------------- 1 | 17 | 19 | 22 | 26 | 30 | 31 | 34 | 38 | 42 | 43 | 53 | 63 | 73 | 74 | 105 | 107 | 108 | 110 | image/svg+xml 111 | 113 | 114 | 115 | 116 | 117 | 122 | 127 | 133 | 140 | 145 | 147 | 149 | 154 | 159 | 160 | 162 | 170 | 178 | 179 | 180 | 181 | 182 | 183 | -------------------------------------------------------------------------------- /data/systray_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 18 | 20 | 23 | 27 | 31 | 32 | 35 | 39 | 43 | 44 | 54 | 64 | 74 | 75 | 106 | 108 | 109 | 111 | image/svg+xml 112 | 114 | 115 | 116 | 117 | 118 | 123 | 128 | 130 | 137 | 143 | 145 | 147 | 152 | 157 | 158 | 160 | 168 | 176 | 177 | 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /data/virtscreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbumsik/VirtScreen/9637d628165cd46db0873dbd82af2bfd91d0b7d2/data/virtscreen.png -------------------------------------------------------------------------------- /package/appimage/.gitignore: -------------------------------------------------------------------------------- 1 | *.AppImage 2 | *.AppDir 3 | -------------------------------------------------------------------------------- /package/appimage/AppRun: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This script is only for isolated miniconda environment 3 | # Used in AppImage package 4 | SCRIPTDIR=$(dirname $0) 5 | ENV=$SCRIPTDIR/usr/share/virtscreen/env 6 | 7 | export PYTHONPATH=$ENV/lib/python3.6 8 | export LD_LIBRARY_PATH=$ENV/lib 9 | export QT_PLUGIN_PATH=$ENV/lib/python3.6/site-packages/PyQt5/Qt/plugins 10 | export QML2_IMPORT_PATH=$ENV/lib/python3.6/site-packages/PyQt5/Qt/qml 11 | # export QT_QPA_FONTDIR=/usr/share/fonts 12 | # export QT_XKB_CONFIG_ROOT=/usr/share/X11/xkb 13 | 14 | $ENV/bin/python3 $ENV/bin/virtscreen $@ 15 | -------------------------------------------------------------------------------- /package/appimage/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Directory 4 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 5 | ROOT=$DIR/../.. 6 | 7 | cd $ROOT/package/appimage 8 | mkdir virtscreen.AppDir 9 | cd virtscreen.AppDir 10 | # Create virtualenv 11 | install -d usr/share/virtscreen 12 | source $HOME/miniconda/bin/activate && \ 13 | conda create -y --copy --prefix usr/share/virtscreen/env python=3.6 14 | # Install VirtScreen using pip 15 | source $HOME/miniconda/bin/activate && \ 16 | source activate usr/share/virtscreen/env && \ 17 | pip install $ROOT 18 | # Delete unnecessary installed files done by setup.py 19 | rm -rf usr/share/virtscreen/env/lib/python3.6/site-packages/usr 20 | # Copy desktop entry, icon, and AppRun 21 | install -m 644 -D $ROOT/virtscreen.desktop \ 22 | usr/share/applications/virtscreen.desktop 23 | install -m 644 -D $ROOT/virtscreen.desktop \ 24 | . 25 | install -m 644 -D $ROOT/data/virtscreen.png \ 26 | usr/share/pixmaps/virtscreen.png 27 | install -m 644 -D $ROOT/data/virtscreen.png \ 28 | . 29 | install -m 755 -D $ROOT/package/appimage/AppRun \ 30 | . 31 | cd .. 32 | appimagetool virtscreen.AppDir 33 | -------------------------------------------------------------------------------- /package/archlinux/PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Bumsik Kim 2 | _pkgname_camelcase=VirtScreen 3 | pkgname=virtscreen 4 | pkgver=0.3.1 5 | pkgrel=1 6 | pkgdesc="Make your iPad/tablet/computer as a secondary monitor on Linux" 7 | arch=("i686" "x86_64") 8 | url="https://github.com/kbumsik/VirtScreen" 9 | license=('GPL') 10 | groups=() 11 | depends=('xorg-xrandr' 'x11vnc' 'python-pyqt5' 'qt5-quickcontrols2' 'python-quamash-git' 'python-netifaces') 12 | makedepends=('python-pip' 'perl') 13 | optdepends=( 14 | 'arandr: for display settings option' 15 | ) 16 | provides=($pkgname) 17 | conflicts=() 18 | replaces=() 19 | backup=() 20 | options=() 21 | install= 22 | changelog= 23 | source=(src::git+https://github.com/kbumsik/$_pkgname_camelcase.git#tag=$pkgver) 24 | noextract=() 25 | md5sums=('SKIP') 26 | 27 | prepare() { 28 | cd $srcdir/src 29 | # Delete PyQt5 from install_requires because python-pyqt5 does not have PyPI metadata. 30 | # See https://bugs.archlinux.org/task/58887 31 | perl -pi -e "s/\'PyQt5>=\d+\.\d+\.\d+\',//" \ 32 | setup.py 33 | } 34 | 35 | package() { 36 | cd $srcdir/src 37 | PIP_CONFIG_FILE=/dev/null /usr/bin/pip install --isolated --root="$pkgdir" --ignore-installed --ignore-requires-python --no-deps . 38 | # These are already installed by setup.py 39 | # install -Dm644 "data/$pkgname.desktop" "$pkgdir/usr/share/applications/$pkgname.desktop" 40 | # install -Dm644 "data/icon.png" "$pkgdir/usr/share/pixmaps/$pkgname.png" 41 | } -------------------------------------------------------------------------------- /package/debian/.gitignore: -------------------------------------------------------------------------------- 1 | *.deb 2 | *.buildinfo 3 | *.changes 4 | -------------------------------------------------------------------------------- /package/debian/Makefile: -------------------------------------------------------------------------------- 1 | prefix = /usr 2 | 3 | all: 4 | : # do nothing 5 | 6 | SHELL = /bin/bash 7 | install: 8 | mkdir -p $(DESTDIR)$(prefix)/bin 9 | install -m 755 VirtScreen.AppImage \ 10 | $(DESTDIR)$(prefix)/bin/virtscreen 11 | # Copy desktop entry and icon 12 | install -m 644 -D virtscreen.desktop \ 13 | $(DESTDIR)$(prefix)/share/applications/virtscreen.desktop 14 | install -m 644 -D data/virtscreen.png \ 15 | $(DESTDIR)$(prefix)/share/pixmaps/virtscreen.png 16 | 17 | clean: 18 | : # do nothing 19 | 20 | distclean: clean 21 | 22 | uninstall: 23 | : # do nothing 24 | 25 | # override_dh_usrlocal: 26 | # : # do nothing 27 | 28 | .PHONY: all install clean distclean uninstall 29 | -------------------------------------------------------------------------------- /package/debian/README.Debian: -------------------------------------------------------------------------------- 1 | virtscreen for Debian 2 | 3 | Please edit this to provide information specific to 4 | this virtscreen Debian package. 5 | 6 | (Automatically generated by debmake Version 4.2.9) 7 | 8 | -- Bumsik Kim Fri, 25 May 2018 17:28:18 +0000 9 | -------------------------------------------------------------------------------- /package/debian/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PKGVER=0.3.1 4 | # Required for debmake 5 | DEBEMAIL="k.bumsik@gmail.com" 6 | DEBFULLNAME="Bumsik Kim" 7 | export PKGVER DEBEMAIL DEBFULLNAME 8 | 9 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 10 | ROOT=$SCRIPT_DIR/../.. 11 | 12 | # Generate necessary files for package building (generated by debmake) 13 | cd $ROOT/package/debian 14 | cp $ROOT/virtscreen-$PKGVER.tar.gz . 15 | tar -xzmf virtscreen-$PKGVER.tar.gz 16 | cp $ROOT/package/debian/Makefile \ 17 | $ROOT/package/debian/virtscreen-$PKGVER/Makefile 18 | cd $ROOT/package/debian/virtscreen-$PKGVER 19 | debmake --yes -b':sh' 20 | 21 | # copy files to build 22 | # debmake files 23 | mkdir -p $ROOT/package/debian/build 24 | cp -R $ROOT/package/debian/virtscreen-$PKGVER/debian \ 25 | $ROOT/package/debian/build/debian 26 | cp $ROOT/package/debian/Makefile \ 27 | $ROOT/package/debian/build/ 28 | cp $ROOT/package/debian/{control,README.Debian} \ 29 | $ROOT/package/debian/build/debian/ 30 | # binary and data files 31 | cp $ROOT/package/appimage/VirtScreen.AppImage \ 32 | $ROOT/package/debian/build/ 33 | cp $ROOT/virtscreen.desktop \ 34 | $ROOT/package/debian/build/ 35 | cp -R $ROOT/data \ 36 | $ROOT/package/debian/build/ 37 | 38 | # Build .deb package 39 | cd $ROOT/package/debian/build 40 | dpkg-buildpackage -b 41 | 42 | # cleanup 43 | rm -rf $ROOT/package/debian/virtscreen-$PKGVER \ 44 | $ROOT/package/debian/*.tar.gz 45 | -------------------------------------------------------------------------------- /package/debian/control: -------------------------------------------------------------------------------- 1 | Source: virtscreen 2 | Section: utils 3 | Priority: optional 4 | Maintainer: Bumsik Kim 5 | Build-Depends: debhelper (>=9), python3-all 6 | Standards-Version: 3.9.8 7 | Homepage: https://github.com/kbumsik/VirtScreen 8 | X-Python3-Version: >= 3.5 9 | 10 | Package: virtscreen 11 | Architecture: all 12 | Multi-Arch: foreign 13 | Depends: ${misc:Depends}, x11vnc 14 | Description: Make your iPad/tablet/computer as a secondary monitor on Linux 15 | VirtScreen is an easy-to-use Linux GUI app that creates a virtual 16 | secondary screen and shares it through VNC. 17 | -------------------------------------------------------------------------------- /package/pypi/.gitignore: -------------------------------------------------------------------------------- 1 | virtscreen*.whl 2 | *.tar.gz 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """A setuptools based setup module. 2 | 3 | See: 4 | https://packaging.python.org/en/latest/distributing.html 5 | https://github.com/pypa/sampleproject 6 | """ 7 | 8 | # Always prefer setuptools over distutils 9 | from setuptools import setup, find_packages 10 | # To use a consistent encoding 11 | from codecs import open 12 | from os import path 13 | 14 | here = path.abspath(path.dirname(__file__)) 15 | 16 | # Get the long description from the README file 17 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 18 | long_description = f.read() 19 | 20 | # Arguments marked as "Required" below must be included for upload to PyPI. 21 | # Fields marked as "Optional" may be commented out. 22 | 23 | setup( 24 | # This is the name of your project. The first time you publish this 25 | # package, this name will be registered for you. It will determine how 26 | # users can install this project, e.g.: 27 | # 28 | # $ pip install sampleproject 29 | # 30 | # And where it will live on PyPI: https://pypi.org/project/sampleproject/ 31 | # 32 | # There are some restrictions on what makes a valid project name 33 | # specification here: 34 | # https://packaging.python.org/specifications/core-metadata/#name 35 | name='virtscreen', # Required 36 | 37 | # Versions should comply with PEP 440: 38 | # https://www.python.org/dev/peps/pep-0440/ 39 | # 40 | # For a discussion on single-sourcing the version across setup.py and the 41 | # project code, see 42 | # https://packaging.python.org/en/latest/single_source_version.html 43 | version='0.3.1', # Required 44 | 45 | # This is a one-line description or tagline of what your project does. This 46 | # corresponds to the "Summary" metadata field: 47 | # https://packaging.python.org/specifications/core-metadata/#summary 48 | description='Make your iPad/tablet/computer as a secondary monitor on Linux.', # Required 49 | 50 | # This is an optional longer description of your project that represents 51 | # the body of text which users will see when they visit PyPI. 52 | # 53 | # Often, this is the same as your README, so you can just read it in from 54 | # that file directly (as we have already done above) 55 | # 56 | # This field corresponds to the "Description" metadata field: 57 | # https://packaging.python.org/specifications/core-metadata/#description-optional 58 | long_description=long_description, # Optional 59 | 60 | # Denotes that our long_description is in Markdown; valid values are 61 | # text/plain, text/x-rst, and text/markdown 62 | # 63 | # Optional if long_description is written in reStructuredText (rst) but 64 | # required for plain-text or Markdown; if unspecified, "applications should 65 | # attempt to render [the long_description] as text/x-rst; charset=UTF-8 and 66 | # fall back to text/plain if it is not valid rst" (see link below) 67 | # 68 | # This field corresponds to the "Description-Content-Type" metadata field: 69 | # https://packaging.python.org/specifications/core-metadata/#description-content-type-optional 70 | long_description_content_type='text/markdown; charset=UTF-8; variant=GFM', # Optional (see note above) 71 | 72 | # This should be a valid link to your project's main homepage. 73 | # 74 | # This field corresponds to the "Home-Page" metadata field: 75 | # https://packaging.python.org/specifications/core-metadata/#home-page-optional 76 | url='https://github.com/kbumsik/VirtScreen', # Optional 77 | 78 | # This should be your name or the name of the organization which owns the 79 | # project. 80 | author='Bumsik Kim', # Optional 81 | 82 | # This should be a valid email address corresponding to the author listed 83 | # above. 84 | author_email='k.bumsik@gmail.com', # Optional 85 | 86 | # Classifiers help users find your project by categorizing it. 87 | # 88 | # For a list of valid classifiers, see https://pypi.org/classifiers/ 89 | classifiers=[ # Optional 90 | # How mature is this project? Common values are 91 | # 3 - Alpha 92 | # 4 - Beta 93 | # 5 - Production/Stable 94 | 'Development Status :: 4 - Beta', 95 | 96 | # Indicate who your project is intended for 97 | 'Intended Audience :: Developers', 98 | 'Intended Audience :: End Users/Desktop', 99 | 'Topic :: Desktop Environment', 100 | 'Topic :: Office/Business', 101 | 'Topic :: System', 102 | 'Topic :: Utilities', 103 | 104 | # Pick your license as you wish 105 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 106 | 107 | # Specify the Python versions you support here. In particular, ensure 108 | # that you indicate whether you support Python 2, Python 3 or both. 109 | 'Programming Language :: Python :: 3', 110 | 'Programming Language :: Python :: 3.6', 111 | 'Programming Language :: Python :: 3.7', 112 | 113 | # Environment 114 | 'Environment :: X11 Applications', 115 | 'Environment :: X11 Applications :: Qt', 116 | 'Operating System :: POSIX :: Linux', 117 | 118 | # Framework used 119 | 'Framework :: AsyncIO', 120 | ], 121 | 122 | # This field adds keywords for your project which will appear on the 123 | # project page. What does your project relate to? 124 | # 125 | # Note that this is a string of words separated by whitespace, not a list. 126 | 127 | # keywords='sample setuptools development', # Optional 128 | 129 | # You can just specify package directories manually here if your project is 130 | # simple. Or you can use find_packages(). 131 | # 132 | # Alternatively, if you just want to distribute a single Python file, use 133 | # the `py_modules` argument instead as follows, which will expect a file 134 | # called `my_module.py` to exist: 135 | # 136 | # py_modules=["my_module"], 137 | # 138 | packages=find_packages(), # Required 139 | 140 | # This field lists other packages that your project depends on to run. 141 | # Any package you put here will be installed by pip when your project is 142 | # installed, so they must be valid existing projects. 143 | # 144 | # For an analysis of "install_requires" vs pip's requirements files see: 145 | # https://packaging.python.org/en/latest/requirements.html 146 | install_requires=['PyQt5>=5.10.1', 147 | 'Quamash>=0.6.0', 148 | 'netifaces>=0.10.6'], # Optional 149 | 150 | # List additional groups of dependencies here (e.g. development 151 | # dependencies). Users will be able to install these using the "extras" 152 | # syntax, for example: 153 | # 154 | # $ pip install sampleproject[dev] 155 | # 156 | # Similar to `install_requires` above, these must be valid existing 157 | # projects. 158 | 159 | # extras_require={ # Optional 160 | # 'dev': ['check-manifest'], 161 | # 'test': ['coverage'], 162 | # }, 163 | 164 | # If there are data files included in your packages that need to be 165 | # installed, specify them here. 166 | # 167 | # If using Python 2.6 or earlier, then these have to be included in 168 | # MANIFEST.in as well. 169 | package_data={ 170 | 'virtscreen': ['icon/*.png', 'assets/*.qml', 'assets/*.json'], 171 | }, 172 | 173 | # Although 'package_data' is the preferred approach, in some case you may 174 | # need to place data files outside of your packages. See: 175 | # http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files 176 | # 177 | # In this case, 'data_file' will be installed into '/my_data' 178 | data_files=[ 179 | # Desktop entries spec: 180 | # https://www.freedesktop.org/wiki/Specifications/desktop-entry-spec/ 181 | ('share/applications', ['virtscreen.desktop']), 182 | # $XDG_DATA_DIRS/icons 183 | # https://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html#directory_layout 184 | ('share/icons', ['data/virtscreen.png']), 185 | # ('share/man/man1', ['man/virtscreen.1']) 186 | ], # Optional 187 | 188 | # To provide executable scripts, use entry points in preference to the 189 | # "scripts" keyword. Entry points provide cross-platform support and allow 190 | # `pip` to create the appropriate form of executable for the target 191 | # platform. 192 | # 193 | # For example, the following would provide a command called `sample` which 194 | # executes the function `main` from this package when invoked: 195 | entry_points={ # Optional 196 | 'console_scripts': [ 197 | 'virtscreen = virtscreen.__main__:main', 198 | ], 199 | }, 200 | 201 | # List additional URLs that are relevant to your project as a dict. 202 | # 203 | # This field corresponds to the "Project-URL" metadata fields: 204 | # https://packaging.python.org/specifications/core-metadata/#project-url-multiple-use 205 | # 206 | # Examples listed include a pattern for specifying where the package tracks 207 | # issues, where the source is hosted, where to say thanks to the package 208 | # maintainers, and where to support the project financially. The key is 209 | # what's used to render the link text on PyPI. 210 | project_urls={ # Optional 211 | 'Bug Reports': 'https://github.com/kbumsik/VirtScreen/issues', 212 | # 'Funding': 'https://donate.pypi.org', 213 | # 'Say Thanks!': 'http://saythanks.io/to/example', 214 | 'Source': 'https://github.com/kbumsik/VirtScreen', 215 | 'Author Homepage': 'https://kbumsik.io', 216 | }, 217 | python_requires='>=3.6', 218 | ) 219 | -------------------------------------------------------------------------------- /virtscreen.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=VirtScreen 4 | Comment=Make your iPad/tablet/computer as a secondary monitor on Linux 5 | Exec=bash -c "export PATH=\\$PATH:\\$HOME/.local/bin; virtscreen" 6 | Icon=virtscreen 7 | Terminal=false 8 | StartupNotify=false 9 | Categories=Application; 10 | -------------------------------------------------------------------------------- /virtscreen/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbumsik/VirtScreen/9637d628165cd46db0873dbd82af2bfd91d0b7d2/virtscreen/__init__.py -------------------------------------------------------------------------------- /virtscreen/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # Python standard packages 4 | import sys 5 | import os 6 | import signal 7 | import json 8 | import shutil 9 | import argparse 10 | import logging 11 | from logging.handlers import RotatingFileHandler 12 | from typing import Callable 13 | import asyncio 14 | 15 | # Import OpenGL library for Nvidia driver 16 | # https://github.com/Ultimaker/Cura/pull/131#issuecomment-176088664 17 | import ctypes 18 | from ctypes.util import find_library 19 | ctypes.CDLL(find_library('GL'), ctypes.RTLD_GLOBAL) 20 | 21 | from PyQt5.QtWidgets import QApplication 22 | from PyQt5.QtQml import qmlRegisterType, QQmlApplicationEngine 23 | from PyQt5.QtGui import QIcon 24 | from PyQt5.QtCore import Qt, QUrl 25 | from quamash import QEventLoop 26 | 27 | from .display import DisplayProperty 28 | from .xrandr import XRandR 29 | from .qt_backend import Backend, Cursor, Network 30 | from .path import HOME_PATH, ICON_PATH, MAIN_QML_PATH, CONFIG_PATH, LOGGING_PATH 31 | 32 | def error(*args, **kwargs) -> None: 33 | """Error printing""" 34 | args = ('Error: ', *args) 35 | print(*args, file=sys.stderr, **kwargs) 36 | 37 | def main() -> None: 38 | """Start main program""" 39 | parser = argparse.ArgumentParser( 40 | formatter_class=argparse.RawTextHelpFormatter, 41 | description='Make your iPad/tablet/computer as a secondary monitor on Linux.\n\n' 42 | 'You can start VirtScreen in the following two modes:\n\n' 43 | ' - GUI mode: A system tray icon will appear when no argument passed.\n' 44 | ' You need to use this first to configure a virtual screen.\n' 45 | ' - CLI mode: After configured the virtual screen, you can start VirtScreen\n' 46 | ' in CLI mode if you do not want a GUI, by passing any arguments\n', 47 | epilog='example:\n' 48 | 'virtscreen # GUI mode. You need to use this first\n' 49 | ' to configure the screen\n' 50 | 'virtscreen --auto # CLI mode. Scrren will be created using previous\n' 51 | ' settings (from both GUI mode and CLI mode)\n' 52 | 'virtscreen --left # CLI mode. On the left to the primary monitor\n' 53 | 'virtscreen --below # CLI mode. Below the primary monitor.\n' 54 | 'virtscreen --below --portrait # Below, and portrait mode.\n' 55 | 'virtscreen --below --portrait --hipdi # Below, portrait, HiDPI mode.\n') 56 | parser.add_argument('--auto', action='store_true', 57 | help='create a virtual screen automatically using previous\n' 58 | 'settings (from both GUI mode and CLI mode)') 59 | parser.add_argument('--left', action='store_true', 60 | help='a virtual screen will be created left to the primary\n' 61 | 'monitor') 62 | parser.add_argument('--right', action='store_true', 63 | help='right to the primary monitor') 64 | parser.add_argument('--above', '--up', action='store_true', 65 | help='above the primary monitor') 66 | parser.add_argument('--below', '--down', action='store_true', 67 | help='below the primary monitor') 68 | parser.add_argument('--portrait', action='store_true', 69 | help='Portrait mode. Width and height of the screen are swapped') 70 | parser.add_argument('--hidpi', action='store_true', 71 | help='HiDPI mode. Width and height are doubled') 72 | parser.add_argument('--log', type=str, 73 | help='Python logging level, For example, --log=INFO.\n' 74 | 'Only used for reporting bugs and debugging') 75 | # Add signal handler 76 | def on_exit(self, signum=None, frame=None): 77 | sys.exit(0) 78 | for sig in [signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT]: 79 | signal.signal(sig, on_exit) 80 | 81 | args = vars(parser.parse_args()) 82 | cli_args = ['auto', 'left', 'right', 'above', 'below', 'portrait', 'hidpi'] 83 | # Start main 84 | if any((value and arg in cli_args) for arg, value in args.items()): 85 | main_cli(args) 86 | else: 87 | main_gui(args) 88 | error('Program should not reach here.') 89 | sys.exit(1) 90 | 91 | def check_env(args: argparse.Namespace, msg: Callable[[str], None]) -> None: 92 | """Check environments and arguments before start. This also enable logging""" 93 | if os.environ.get('XDG_SESSION_TYPE', '').lower() == 'wayland': 94 | msg("Currently Wayland is not supported") 95 | sys.exit(1) 96 | # Check ~/.config/virtscreen 97 | if not HOME_PATH: # This is set in path.py 98 | msg("Cannot detect home directory.") 99 | sys.exit(1) 100 | if not os.path.exists(HOME_PATH): 101 | try: 102 | os.makedirs(HOME_PATH) 103 | except: 104 | msg("Cannot create ~/.config/virtscreen") 105 | sys.exit(1) 106 | # Check x11vnc 107 | if not shutil.which('x11vnc'): 108 | msg("x11vnc is not installed.") 109 | sys.exit(1) 110 | # Enable logging 111 | if args['log'] is None: 112 | args['log'] = 'WARNING' 113 | log_level = getattr(logging, args['log'].upper(), None) 114 | if not isinstance(log_level, int): 115 | error('Please choose a correct python logging level') 116 | sys.exit(1) 117 | # When logging level is INFO or lower, print logs in terminal 118 | # Otherwise log to a file 119 | log_to_file = True if log_level > logging.INFO else False 120 | FORMAT = "[%(levelname)s:%(filename)s:%(lineno)s:%(funcName)s()] %(message)s" 121 | logging.basicConfig(level=log_level, format=FORMAT, 122 | **({'filename': LOGGING_PATH} if log_to_file else {})) 123 | if log_to_file: 124 | logger = logging.getLogger() 125 | handler = RotatingFileHandler(LOGGING_PATH, mode='a', maxBytes=1024*4, backupCount=1) 126 | logger.addHandler(handler) 127 | logging.info('logging enabled') 128 | del args['log'] 129 | logging.info(f'{args}') 130 | # Check if xrandr is correctly parsed. 131 | try: 132 | test = XRandR() 133 | except RuntimeError as e: 134 | msg(str(e)) 135 | sys.exit(1) 136 | 137 | def main_gui(args: argparse.Namespace): 138 | QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) 139 | app = QApplication(sys.argv) 140 | loop = QEventLoop(app) 141 | asyncio.set_event_loop(loop) 142 | 143 | # Check environment first 144 | from PyQt5.QtWidgets import QMessageBox, QSystemTrayIcon 145 | def dialog(message: str) -> None: 146 | QMessageBox.critical(None, "VirtScreen", message) 147 | if not QSystemTrayIcon.isSystemTrayAvailable(): 148 | dialog("Cannot detect system tray on this system.") 149 | sys.exit(1) 150 | check_env(args, dialog) 151 | 152 | app.setApplicationName("VirtScreen") 153 | app.setWindowIcon(QIcon(ICON_PATH)) 154 | os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" 155 | 156 | # Register the Python type. Its URI is 'People', it's v1.0 and the type 157 | # will be called 'Person' in QML. 158 | qmlRegisterType(DisplayProperty, 'VirtScreen.DisplayProperty', 1, 0, 'DisplayProperty') 159 | qmlRegisterType(Backend, 'VirtScreen.Backend', 1, 0, 'Backend') 160 | qmlRegisterType(Cursor, 'VirtScreen.Cursor', 1, 0, 'Cursor') 161 | qmlRegisterType(Network, 'VirtScreen.Network', 1, 0, 'Network') 162 | 163 | # Create a component factory and load the QML script. 164 | engine = QQmlApplicationEngine() 165 | engine.load(QUrl(MAIN_QML_PATH)) 166 | if not engine.rootObjects(): 167 | dialog("Failed to load QML") 168 | sys.exit(1) 169 | sys.exit(app.exec_()) 170 | with loop: 171 | loop.run_forever() 172 | 173 | def main_cli(args: argparse.Namespace): 174 | loop = asyncio.get_event_loop() 175 | # Check the environment 176 | check_env(args, print) 177 | if not os.path.exists(CONFIG_PATH): 178 | error("Configuration file does not exist.\n" 179 | "Configure a virtual screen using GUI first.") 180 | sys.exit(1) 181 | # By instantiating the backend, additional verifications of config 182 | # file will be done. 183 | backend = Backend(logger=print) 184 | # Get settings 185 | with open(CONFIG_PATH, 'r') as f: 186 | config = json.load(f) 187 | # Override settings from arguments 188 | position = '' 189 | if not args['auto']: 190 | args_virt = ['portrait', 'hidpi'] 191 | for prop in args_virt: 192 | if args[prop]: 193 | config['virt'][prop] = True 194 | args_position = ['left', 'right', 'above', 'below'] 195 | tmp_args = {k: args[k] for k in args_position} 196 | if not any(tmp_args.values()): 197 | error("Choose a position relative to the primary monitor. (e.g. --left)") 198 | sys.exit(1) 199 | for key, value in tmp_args.items(): 200 | if value: 201 | position = key 202 | # Create virtscreen and Start VNC 203 | def handle_error(msg): 204 | error(msg) 205 | sys.exit(1) 206 | backend.onError.connect(handle_error) 207 | backend.createVirtScreen(config['virt']['device'], config['virt']['width'], 208 | config['virt']['height'], config['virt']['portrait'], 209 | config['virt']['hidpi'], position) 210 | def handle_vnc_changed(state): 211 | if state is backend.VNCState.OFF: 212 | sys.exit(0) 213 | backend.onVncStateChanged.connect(handle_vnc_changed) 214 | backend.startVNC(config['vnc']['port']) 215 | loop.run_forever() 216 | 217 | if __name__ == '__main__': 218 | main() 219 | -------------------------------------------------------------------------------- /virtscreen/assets/AppWindow.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.10 2 | import QtQuick.Controls 2.3 3 | import QtQuick.Controls.Material 2.3 4 | import QtQuick.Layouts 1.3 5 | import QtQuick.Window 2.2 6 | 7 | import VirtScreen.Backend 1.0 8 | 9 | ApplicationWindow { 10 | id: window 11 | visible: false 12 | flags: Qt.FramelessWindowHint 13 | title: "VirtScreen" 14 | 15 | property int theme_color: settings.theme_color 16 | Material.theme: Material.Light 17 | Material.primary: theme_color 18 | Material.accent: theme_color 19 | // Material.background: Material.Grey 20 | 21 | width: 380 22 | height: 540 23 | property int margin: 10 24 | property int popupWidth: width - 26 25 | 26 | screen: Qt.application.screens[0] 27 | x: screen.virtualX 28 | y: screen.virtualY 29 | 30 | // hide screen when loosing focus 31 | property bool autoClose: true 32 | property bool ignoreCloseOnce: false 33 | onAutoCloseChanged: { 34 | // When setting auto close disabled and then enabled again, we need to 35 | // ignore focus change once. Otherwise the window always is closed one time 36 | // even when the mouse is clicked in the window. 37 | if (!autoClose) { 38 | ignoreCloseOnce = true; 39 | } 40 | } 41 | onActiveFocusItemChanged: { 42 | if (autoClose && !ignoreCloseOnce && !activeFocusItem && !sysTrayIcon.clicked) { 43 | this.hide(); 44 | } 45 | if (ignoreCloseOnce && autoClose) { 46 | ignoreCloseOnce = false; 47 | } 48 | } 49 | 50 | menuBar: ToolBar { 51 | id: toolbar 52 | font.weight: Font.Medium 53 | font.pixelSize: height * 0.34 54 | 55 | RowLayout { 56 | anchors.fill: parent 57 | anchors.leftMargin: margin + 10 58 | 59 | Label { 60 | id: vncStateLabel 61 | color: "white" 62 | text: vncStateText.text 63 | } 64 | 65 | ToolButton { 66 | id: menuButton 67 | Layout.alignment: Qt.AlignRight 68 | text: qsTr("⋮") 69 | contentItem: Text { 70 | text: parent.text 71 | font: parent.font 72 | color: "white" 73 | horizontalAlignment: Text.AlignHCenter 74 | verticalAlignment: Text.AlignVCenter 75 | elide: Text.ElideRight 76 | } 77 | 78 | onClicked: menu.open() 79 | 80 | Menu { 81 | id: menu 82 | y: toolbar.height 83 | 84 | MenuItem { 85 | text: qsTr("&Preference") 86 | onTriggered: { 87 | preferenceLoader.active = true; 88 | } 89 | } 90 | 91 | MenuItem { 92 | text: qsTr("&About") 93 | onTriggered: { 94 | aboutDialog.open(); 95 | } 96 | } 97 | 98 | MenuItem { 99 | text: qsTr("&Quit") 100 | onTriggered: quitAction.onTriggered() 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | header: TabBar { 108 | id: tabBar 109 | position: TabBar.Footer 110 | // Material.primary: Material.Teal 111 | 112 | currentIndex: 0 113 | 114 | TabButton { 115 | text: qsTr("Display") 116 | } 117 | 118 | TabButton { 119 | text: qsTr("VNC") 120 | } 121 | } 122 | 123 | footer: ProgressBar { 124 | z: 1 125 | indeterminate: backend.vncState == Backend.WAITING 126 | value: backend.vncState == Backend.CONNECTED ? 1 : 0 127 | } 128 | 129 | 130 | Popup { 131 | id: busyDialog 132 | modal: true 133 | closePolicy: Popup.NoAutoClose 134 | x: (parent.width - width) / 2 135 | y: parent.height / 2 - height 136 | BusyIndicator { 137 | anchors.fill: parent 138 | running: true 139 | } 140 | background: Rectangle { 141 | color: "transparent" 142 | implicitWidth: 100 143 | implicitHeight: 100 144 | // border.color: "#444" 145 | } 146 | } 147 | 148 | Dialog { 149 | id: aboutDialog 150 | focus: true 151 | x: (parent.width - width) / 2 152 | y: (parent.width - height) / 2 //(window.height) / 2 153 | width: popupWidth 154 | ColumnLayout { 155 | anchors.fill: parent 156 | Text { 157 | Layout.alignment: Qt.AlignHCenter 158 | horizontalAlignment: Text.AlignHCenter 159 | font { weight: Font.Bold; pixelSize: 20 } 160 | text: "VirtScreen" + " v" + settings.version 161 | } 162 | Text { 163 | Layout.alignment: Qt.AlignHCenter 164 | horizontalAlignment: Text.AlignHCenter 165 | font { pixelSize: 13 } 166 | text: "Make your iPad/tablet/computer
as a secondary monitor.
" 167 | } 168 | Text { 169 | font { pixelSize: 14 } 170 | text: "- Project Website" 171 | onLinkActivated: Qt.openUrlExternally(link) 172 | } 173 | Text { 174 | font { pixelSize: 14 } 175 | text: "- Issues & Bug Report" 176 | onLinkActivated: Qt.openUrlExternally(link) 177 | } 178 | Text { 179 | font { pixelSize: 14 } 180 | Layout.alignment: Qt.AlignHCenter 181 | horizontalAlignment: Text.AlignHCenter 182 | lineHeight: 0.7 183 | text: "
Copyright © 2018 Bumsik Kim Homepage
" 184 | onLinkActivated: Qt.openUrlExternally(link) 185 | } 186 | Text { 187 | font { pixelSize: 11 } 188 | Layout.alignment: Qt.AlignHCenter 189 | horizontalAlignment: Text.AlignHCenter 190 | text: "This program comes with absolutely no warranty.
" + 191 | "See the " + 192 | "GNU General Public License, version 3 for details." 193 | onLinkActivated: Qt.openUrlExternally(link) 194 | } 195 | } 196 | } 197 | 198 | Dialog { 199 | id: passwordDialog 200 | title: "New password" 201 | focus: true 202 | modal: true 203 | standardButtons: Dialog.Ok | Dialog.Cancel 204 | x: (parent.width - width) / 2 205 | y: (parent.width - height) / 2 //(window.height) / 2 206 | width: popupWidth 207 | ColumnLayout { 208 | anchors.fill: parent 209 | TextField { 210 | id: passwordFIeld 211 | focus: true 212 | Layout.fillWidth: true 213 | placeholderText: "New Password"; 214 | echoMode: TextInput.Password; 215 | } 216 | Keys.onPressed: { 217 | event.accepted = true; 218 | if (event.key == Qt.Key_Return || event.key == Qt.Key_Enter) { 219 | passwordDialog.accept(); 220 | } 221 | } 222 | } 223 | onAccepted: { 224 | backend.createVNCPassword(passwordFIeld.text); 225 | passwordFIeld.text = ""; 226 | } 227 | onRejected: passwordFIeld.text = "" 228 | } 229 | 230 | Dialog { 231 | id: errorDialog 232 | title: "Error" 233 | focus: true 234 | modal: true 235 | standardButtons: Dialog.Ok 236 | x: (parent.width - width) / 2 237 | y: (parent.width - height) / 2 //(window.height) / 2 238 | width: popupWidth 239 | height: 310 240 | ColumnLayout { 241 | anchors.fill: parent 242 | ScrollView { 243 | Layout.fillHeight: true 244 | Layout.fillWidth: true 245 | TextArea { 246 | // readOnly: true 247 | selectByMouse: true 248 | Layout.fillWidth: true 249 | // wrapMode: Text.WordWrap 250 | text: errorText.text 251 | onTextChanged: { 252 | if (text) { 253 | busyDialog.close(); 254 | errorDialog.open(); 255 | } 256 | } 257 | } 258 | ScrollBar.vertical: ScrollBar { 259 | // parent: ipListView.parent 260 | anchors.top: parent.top 261 | anchors.left: parent.right 262 | anchors.bottom: parent.bottom 263 | policy: ScrollBar.AlwaysOn 264 | } 265 | ScrollBar.horizontal: ScrollBar { 266 | // parent: ipListView.parent 267 | anchors.top: parent.bottom 268 | anchors.left: parent.left 269 | anchors.right: parent.right 270 | policy: ScrollBar.AlwaysOn 271 | } 272 | } 273 | } 274 | } 275 | 276 | Loader { 277 | id: preferenceLoader 278 | active: false 279 | source: "preferenceDialog.qml" 280 | onLoaded: { 281 | item.onClosed.connect(function() { 282 | preferenceLoader.active = false; 283 | }); 284 | } 285 | } 286 | 287 | Loader { 288 | id: displayOptionsLoader 289 | active: false 290 | source: "DisplayOptionsDialog.qml" 291 | onLoaded: { 292 | item.onClosed.connect(function() { 293 | displayOptionsLoader.active = false; 294 | }); 295 | } 296 | } 297 | 298 | Loader { 299 | id: vncOptionsLoader 300 | active: false 301 | source: "VncOptionsDialog.qml" 302 | onLoaded: { 303 | item.onClosed.connect(function() { 304 | vncOptionsLoader.active = false; 305 | }); 306 | } 307 | } 308 | 309 | SwipeView { 310 | anchors.top: tabBar.bottom 311 | anchors.bottom: parent.bottom 312 | anchors.left: parent.left 313 | anchors.right: parent.right 314 | anchors.margins: margin 315 | clip: true 316 | 317 | currentIndex: tabBar.currentIndex 318 | 319 | // in the same "qml" folder 320 | DisplayPage {} 321 | VncPage {} 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /virtscreen/assets/DisplayOptionsDialog.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.10 2 | import QtQuick.Controls 2.3 3 | import QtQuick.Controls.Material 2.3 4 | import QtQuick.Layouts 1.3 5 | 6 | Dialog { 7 | title: "Display Options" 8 | focus: true 9 | modal: true 10 | visible: true 11 | standardButtons: Dialog.Ok 12 | x: (window.width - width) / 2 13 | y: (window.width - height) / 2 14 | width: popupWidth 15 | height: 250 16 | 17 | ColumnLayout { 18 | anchors.fill: parent 19 | 20 | RowLayout { 21 | anchors.left: parent.left 22 | anchors.right: parent.right 23 | Label { id: deviceLabel; text: "Device"; } 24 | ComboBox { 25 | id: deviceComboBox 26 | anchors.left: deviceLabel.right 27 | anchors.right: parent.right 28 | anchors.leftMargin: 100 29 | textRole: "name" 30 | model: backend.screens 31 | currentIndex: { 32 | if (settings.virt.device) { 33 | for (var i = 0; i < model.length; i++) { 34 | if (model[i].name == settings.virt.device) { 35 | return i; 36 | } 37 | } 38 | } 39 | settings.virt.device = ''; 40 | return -1; 41 | } 42 | onActivated: function(index) { 43 | settings.virt.device = model[index].name; 44 | } 45 | delegate: ItemDelegate { 46 | width: deviceComboBox.width 47 | text: modelData.name 48 | font.weight: deviceComboBox.currentIndex === index ? Font.Bold : Font.Normal 49 | enabled: modelData.connected ? false : true 50 | } 51 | } 52 | } 53 | 54 | Text { 55 | Layout.fillWidth: true 56 | font { pixelSize: 14 } 57 | wrapMode: Text.WordWrap 58 | text: "Warning: Edit only if 'VIRTUAL1' is not available. " + 59 | "If so, please note that the virtual screen may be " + 60 | "unstable/unavailable depending on a graphic " + 61 | "card and its driver." 62 | } 63 | 64 | RowLayout { 65 | // Empty layout 66 | Layout.fillHeight: true 67 | } 68 | } 69 | onAccepted: {} 70 | onRejected: {} 71 | } 72 | -------------------------------------------------------------------------------- /virtscreen/assets/DisplayPage.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.10 2 | import QtQuick.Controls 2.3 3 | import QtQuick.Layouts 1.3 4 | 5 | import VirtScreen.Backend 1.0 6 | 7 | ColumnLayout { 8 | GroupBox { 9 | title: "Virtual Screen" 10 | Layout.fillWidth: true 11 | enabled: backend.virtScreenCreated ? false : true 12 | ColumnLayout { 13 | anchors.left: parent.left 14 | anchors.right: parent.right 15 | RowLayout { 16 | Label { text: "Width"; Layout.fillWidth: true } 17 | SpinBox { 18 | value: settings.virt.width 19 | from: 640 20 | to: 1920 21 | stepSize: 1 22 | editable: true 23 | onValueModified: { 24 | settings.virt.width = value; 25 | } 26 | textFromValue: function(value, locale) { return value; } 27 | } 28 | } 29 | RowLayout { 30 | Label { text: "Height"; Layout.fillWidth: true } 31 | SpinBox { 32 | value: settings.virt.height 33 | from: 360 34 | to: 1080 35 | stepSize : 1 36 | editable: true 37 | onValueModified: { 38 | settings.virt.height = value; 39 | } 40 | textFromValue: function(value, locale) { return value; } 41 | } 42 | } 43 | RowLayout { 44 | Label { text: "Portrait Mode"; Layout.fillWidth: true } 45 | Switch { 46 | checked: settings.virt.portrait 47 | onCheckedChanged: { 48 | settings.virt.portrait = checked; 49 | } 50 | } 51 | } 52 | RowLayout { 53 | Label { text: "HiDPI (2x resolution)"; Layout.fillWidth: true } 54 | Switch { 55 | checked: settings.virt.hidpi 56 | onCheckedChanged: { 57 | settings.virt.hidpi = checked; 58 | } 59 | } 60 | } 61 | RowLayout { 62 | Layout.alignment: Qt.AlignRight 63 | Button { 64 | text: "Advanced" 65 | font.capitalization: Font.MixedCase 66 | onClicked: displayOptionsLoader.active = true; 67 | background.opacity : 0 68 | onHoveredChanged: hovered ? background.opacity = 0.4 69 | :background.opacity = 0; 70 | } 71 | } 72 | } 73 | } 74 | ColumnLayout { 75 | Layout.margins: margin / 2 76 | Button { 77 | id: virtScreenButton 78 | Layout.fillWidth: true 79 | text: virtScreenAction.text 80 | highlighted: true 81 | enabled: virtScreenAction.enabled 82 | onClicked: { 83 | busyDialog.open(); 84 | virtScreenAction.onTriggered(); 85 | connectOnce(backend.onVirtScreenCreatedChanged, function(created) { 86 | busyDialog.close(); 87 | }); 88 | } 89 | } 90 | Button { 91 | id: displaySettingButton 92 | Layout.fillWidth: true 93 | text: "Open Display Setting" 94 | enabled: backend.virtScreenCreated ? true : false 95 | onClicked: { 96 | busyDialog.open(); 97 | window.autoClose = false; 98 | if (backend.vncState != Backend.OFF) { 99 | console.log("vnc is running"); 100 | stopVNC(); 101 | var restoreVNC = true; 102 | if (autostart) { 103 | autostart = false; 104 | var restoreAutoStart = true; 105 | } 106 | } 107 | connectOnce(backend.onDisplaySettingClosed, function() { 108 | window.autoClose = true; 109 | busyDialog.close(); 110 | if (restoreAutoStart) { 111 | autostart = true; 112 | } 113 | if (restoreVNC) { 114 | startVNC(); 115 | } 116 | }); 117 | backend.openDisplaySetting(settings.displaySettingApp); 118 | } 119 | } 120 | } 121 | RowLayout { 122 | // Empty layout 123 | Layout.fillHeight: true 124 | } 125 | } -------------------------------------------------------------------------------- /virtscreen/assets/VncOptionsDialog.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.10 2 | import QtQuick.Controls 2.3 3 | import QtQuick.Controls.Material 2.3 4 | import QtQuick.Layouts 1.3 5 | 6 | Dialog { 7 | title: "VNC Options" 8 | focus: true 9 | modal: true 10 | visible: true 11 | standardButtons: Dialog.Ok 12 | x: (window.width - width) / 2 13 | y: (window.width - height) / 2 14 | width: popupWidth 15 | height: 350 16 | 17 | Component.onCompleted: { 18 | var request = new XMLHttpRequest(); 19 | request.open('GET', 'data.json'); 20 | request.onreadystatechange = function(event) { 21 | if (request.readyState == XMLHttpRequest.DONE) { 22 | var data = JSON.parse(request.responseText).x11vncOptions; 23 | // merge data and settings 24 | for (var key in data) { 25 | Object.assign(data[key], settings.x11vncOptions[key]); 26 | } 27 | var repeater = vncOptionsRepeater; 28 | repeater.model = Object.keys(data).map(function(k){return data[k]}); 29 | } 30 | }; 31 | request.send(); 32 | } 33 | 34 | ColumnLayout { 35 | anchors.fill: parent 36 | RowLayout { 37 | TextField { 38 | id: vncCustomArgsTextField 39 | enabled: vncCustomArgsCheckbox.checked 40 | Layout.fillWidth: true 41 | placeholderText: "Custom x11vnc arguments" 42 | onTextEdited: { 43 | settings.customX11vncArgs.value = text; 44 | } 45 | text: vncCustomArgsCheckbox.checked ? settings.customX11vncArgs.value : "" 46 | } 47 | CheckBox { 48 | id: vncCustomArgsCheckbox 49 | checked: settings.customX11vncArgs.enabled 50 | onToggled: { 51 | settings.customX11vncArgs.enabled = checked; 52 | } 53 | } 54 | } 55 | ColumnLayout { 56 | enabled: !vncCustomArgsCheckbox.checked 57 | Repeater { 58 | id: vncOptionsRepeater 59 | RowLayout { 60 | enabled: modelData.available 61 | Label { 62 | Layout.fillWidth: true 63 | text: modelData.description + ' (' + modelData.value + ')' 64 | } 65 | Switch { 66 | checked: modelData.available ? modelData.enabled : false 67 | onCheckedChanged: { 68 | settings.x11vncOptions[modelData.value].enabled = checked; 69 | } 70 | } 71 | } 72 | } 73 | } 74 | RowLayout { 75 | // Empty layout 76 | Layout.fillHeight: true 77 | } 78 | } 79 | onAccepted: {} 80 | onRejected: {} 81 | } 82 | -------------------------------------------------------------------------------- /virtscreen/assets/VncPage.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.10 2 | import QtQuick.Controls 2.3 3 | import QtQuick.Layouts 1.3 4 | 5 | import VirtScreen.Backend 1.0 6 | import VirtScreen.Network 1.0 7 | 8 | ColumnLayout { 9 | // virtscreen.py Network interfaces backend. 10 | Network { 11 | id: network 12 | } 13 | 14 | GroupBox { 15 | title: "VNC Server" 16 | Layout.fillWidth: true 17 | enabled: backend.vncState == Backend.OFF ? true : false 18 | ColumnLayout { 19 | anchors.left: parent.left 20 | anchors.right: parent.right 21 | RowLayout { 22 | Label { text: "Port"; Layout.fillWidth: true } 23 | SpinBox { 24 | value: settings.vnc.port 25 | from: 1 26 | to: 65535 27 | stepSize: 1 28 | editable: true 29 | onValueModified: { 30 | settings.vnc.port = value; 31 | } 32 | textFromValue: function(value, locale) { return value; } 33 | } 34 | } 35 | RowLayout { 36 | Label { text: "Password"; Layout.fillWidth: true } 37 | Button { 38 | text: "Delete" 39 | font.capitalization: Font.MixedCase 40 | highlighted: false 41 | enabled: backend.vncUsePassword 42 | onClicked: backend.deleteVNCPassword() 43 | } 44 | Button { 45 | text: "New" 46 | font.capitalization: Font.MixedCase 47 | highlighted: true 48 | enabled: !backend.vncUsePassword 49 | onClicked: passwordDialog.open() 50 | } 51 | } 52 | RowLayout { 53 | Layout.alignment: Qt.AlignRight 54 | Button { 55 | text: "Advanced" 56 | font.capitalization: Font.MixedCase 57 | onClicked: vncOptionsLoader.active = true; 58 | background.opacity : 0 59 | onHoveredChanged: hovered ? background.opacity = 0.4 60 | :background.opacity = 0; 61 | } 62 | } 63 | } 64 | } 65 | RowLayout { 66 | Layout.fillWidth: true 67 | Layout.margins: margin / 2 68 | Button { 69 | id: vncButton 70 | Layout.fillWidth: true 71 | text: vncAction.text 72 | highlighted: true 73 | enabled: vncAction.enabled 74 | onClicked: vncAction.onTriggered() 75 | } 76 | CheckBox { 77 | checked: autostart 78 | onToggled: { 79 | autostart = checked; 80 | if ((checked == true) && (backend.vncState == Backend.OFF) && 81 | backend.virtScreenCreated) { 82 | startVNC(); 83 | } 84 | } 85 | } 86 | Label { text: "Auto"; } 87 | } 88 | GroupBox { 89 | title: "Available IP addresses" 90 | Layout.fillWidth: true 91 | Layout.fillHeight: true 92 | implicitHeight: 145 93 | ListView { 94 | id: ipListView 95 | anchors.fill: parent 96 | clip: true 97 | ScrollBar.vertical: ScrollBar { 98 | parent: ipListView.parent 99 | anchors.top: ipListView.top 100 | anchors.right: ipListView.right 101 | anchors.bottom: ipListView.bottom 102 | policy: ScrollBar.AlwaysOn 103 | } 104 | model: network.ipAddresses 105 | delegate: TextEdit { 106 | text: modelData 107 | readOnly: true 108 | selectByMouse: true 109 | anchors.horizontalCenter: parent.horizontalCenter 110 | font.pixelSize: 14 111 | } 112 | } 113 | } 114 | RowLayout { 115 | // Empty layout 116 | Layout.fillHeight: true 117 | } 118 | } -------------------------------------------------------------------------------- /virtscreen/assets/config.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.3.1", 3 | "x11vncVersion": "0.9.15", 4 | "theme_color": 8, 5 | "virt": { 6 | "device": "VIRTUAL1", 7 | "width": 1368, 8 | "height": 1024, 9 | "portrait": false, 10 | "hidpi": false 11 | }, 12 | "vnc": { 13 | "port": 5900, 14 | "autostart": false 15 | }, 16 | "displaySettingApp": "arandr", 17 | "x11vncOptions": { 18 | "-ncache": { 19 | "available": null, 20 | "enabled": false, 21 | "arg": 10 22 | }, 23 | "-multiptr": { 24 | "available": null, 25 | "enabled": true, 26 | "arg": null 27 | }, 28 | "-repeat": { 29 | "available": null, 30 | "enabled": true, 31 | "arg": null 32 | } 33 | }, 34 | "customX11vncArgs": { 35 | "enabled": false, 36 | "value": "" 37 | }, 38 | "presets": [] 39 | } -------------------------------------------------------------------------------- /virtscreen/assets/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.3.1", 3 | "x11vncOptions": { 4 | "-ncache": { 5 | "value": "-ncache", 6 | "description": "Client side caching", 7 | "long_description": "Enables cache" 8 | }, 9 | "-multiptr": { 10 | "value": "-multiptr", 11 | "description": "Show mouse pointer", 12 | "long_description": "This also enables input per-client." 13 | }, 14 | "-repeat": { 15 | "value": "-repeat", 16 | "description": "Keyboard auto repeating", 17 | "long_description": "Enables X server key auto repeat" 18 | } 19 | }, 20 | "displaySettingApps": { 21 | "gnome": { 22 | "value": "gnome", 23 | "name": "GNOME", 24 | "args": "gnome-control-center display", 25 | "XDG_CURRENT_DESKTOP": ["gnome", "unity"] 26 | }, 27 | "kde": { 28 | "value": "kde", 29 | "name": "KDE", 30 | "args": "kcmshell5 kcm_kscreen", 31 | "XDG_CURRENT_DESKTOP": ["kde"] 32 | }, 33 | "arandr": { 34 | "value": "arandr", 35 | "name": "ARandR", 36 | "args": "arandr", 37 | "XDG_CURRENT_DESKTOP": [""] 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /virtscreen/assets/main.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.10 2 | 3 | import Qt.labs.platform 1.0 4 | 5 | import VirtScreen.DisplayProperty 1.0 6 | import VirtScreen.Backend 1.0 7 | import VirtScreen.Cursor 1.0 8 | 9 | Item { 10 | property alias window: mainLoader.item 11 | property var settings: JSON.parse(backend.settings) 12 | property bool autostart: settings.vnc.autostart 13 | 14 | function saveSettings () { 15 | settings.vnc.autostart = autostart; 16 | backend.settings = JSON.stringify(settings, null, 4); 17 | } 18 | 19 | function createVirtScreen () { 20 | backend.createVirtScreen(settings.virt.device, settings.virt.width, 21 | settings.virt.height, settings.virt.portrait, 22 | settings.virt.hidpi); 23 | } 24 | 25 | function startVNC () { 26 | saveSettings(); 27 | backend.startVNC(settings.vnc.port); 28 | } 29 | 30 | function stopVNC () { 31 | backend.stopVNC(); 32 | } 33 | 34 | function switchVNC () { 35 | if ((backend.vncState == Backend.OFF) && backend.virtScreenCreated) { 36 | startVNC(); 37 | } 38 | } 39 | 40 | onAutostartChanged: { 41 | if (autostart) { 42 | backend.onVirtScreenCreatedChanged.connect(switchVNC); 43 | backend.onVncStateChanged.connect(switchVNC); 44 | } else { 45 | backend.onVirtScreenCreatedChanged.disconnect(switchVNC); 46 | backend.onVncStateChanged.disconnect(switchVNC); 47 | } 48 | } 49 | 50 | // virtscreen.py backend. 51 | Backend { 52 | id: backend 53 | onVncStateChanged: { 54 | if (backend.vncState == Backend.ERROR) { 55 | autostart = false; 56 | } 57 | } 58 | } 59 | 60 | // virtscreen.py Cursor class. 61 | Cursor { 62 | id: cursor 63 | } 64 | 65 | // Timer object and function 66 | Timer { 67 | id: timer 68 | function setTimeout(cb, delayTime) { 69 | if (timer.running) { 70 | console.log('Timer is already running!'); 71 | } 72 | timer.interval = delayTime; 73 | timer.repeat = false; 74 | timer.triggered.connect(cb); 75 | timer.triggered.connect(function() { 76 | timer.triggered.disconnect(cb); 77 | }); 78 | timer.start(); 79 | } 80 | } 81 | 82 | // One-shot signal connect 83 | function connectOnce (signal, slot) { 84 | var f = function() { 85 | slot.apply(this, arguments); 86 | signal.disconnect(f); 87 | } 88 | signal.connect(f); 89 | } 90 | 91 | Loader { 92 | id: mainLoader 93 | active: false 94 | source: "AppWindow.qml" 95 | 96 | onStatusChanged: { 97 | console.log("Loader Status Changed.", status); 98 | if (status == Loader.Null) { 99 | gc(); 100 | // This cause memory leak at this moment. 101 | // backend.clearCache(); 102 | } 103 | } 104 | 105 | onLoaded: { 106 | window.onVisibleChanged.connect(function(visible) { 107 | if (!visible) { 108 | console.log("Unloading ApplicationWindow..."); 109 | mainLoader.active = false; 110 | } 111 | }); 112 | // Move window to the corner of the primary display 113 | var cursor_x = (cursor.x / window.screen.devicePixelRatio) - window.screen.virtualX; 114 | var cursor_y = (cursor.y / window.screen.devicePixelRatio) - window.screen.virtualY; 115 | var x_mid = window.screen.width / 2; 116 | var y_mid = window.screen.height / 2; 117 | var x = window.screen.width - window.width; //(cursor_x > x_mid)? width - window.width : 0; 118 | var y = (cursor_y > y_mid)? window.screen.height - window.height : 0; 119 | x += window.screen.virtualX; 120 | y += window.screen.virtualY; 121 | window.x = x; 122 | window.y = y; 123 | window.show(); 124 | window.raise(); 125 | window.requestActivate(); 126 | } 127 | } 128 | 129 | // Sytray Icon 130 | SystemTrayIcon { 131 | id: sysTrayIcon 132 | iconSource: backend.vncState == Backend.CONNECTED ? "../icon/systray_tablet_on.png" : 133 | backend.virtScreenCreated ? "../icon/systray_tablet_off.png" : 134 | "../icon/systray_no_tablet.png" 135 | visible: true 136 | property bool clicked: false 137 | 138 | Component.onCompleted: { 139 | // without delay, the message appears in a wierd place 140 | timer.setTimeout (function() { 141 | showMessage("VirtScreen is running", 142 | "The program will keep running in the system tray.\n" + 143 | "To terminate the program, choose \"Quit\" in the \n" + 144 | "context menu of the system tray entry."); 145 | }, 1500); 146 | } 147 | 148 | onActivated: function(reason) { 149 | if (reason == SystemTrayIcon.Context) { 150 | return; 151 | } 152 | sysTrayIcon.clicked = true; 153 | timer.setTimeout (function() { 154 | sysTrayIcon.clicked = false; 155 | }, 200); 156 | mainLoader.active = true; 157 | } 158 | 159 | menu: Menu { 160 | MenuItem { 161 | id: vncStateText 162 | text: !backend.virtScreenCreated ? "Enable Virtual Screen first" : 163 | backend.vncState == Backend.OFF ? "Turn on VNC Server in the VNC tab" : 164 | backend.vncState == Backend.ERROR ? "Error occurred" : 165 | backend.vncState == Backend.WAITING ? "VNC Server is waiting for a client..." : 166 | backend.vncState == Backend.CONNECTED ? "Connected" : 167 | "Server state error!" 168 | } 169 | MenuItem { 170 | id: errorText 171 | visible: (text) 172 | text: "" 173 | Component.onCompleted : { 174 | backend.onError.connect(function(errMsg) { 175 | errorText.text = ""; // To trigger onTextChanged signal 176 | errorText.text = errMsg; 177 | }); 178 | } 179 | } 180 | MenuItem { 181 | separator: true 182 | } 183 | MenuItem { 184 | id: virtScreenAction 185 | text: backend.virtScreenCreated ? "Disable Virtual Screen" : "Enable Virtual Screen" 186 | enabled: autostart ? true : 187 | backend.vncState == Backend.OFF ? true : false 188 | onTriggered: { 189 | // Give a very short delay to show busyDialog. 190 | timer.setTimeout (function() { 191 | if (!backend.virtScreenCreated) { 192 | createVirtScreen(); 193 | } else { 194 | // If auto start enabled, stop VNC first then 195 | if (autostart && (backend.vncState != Backend.OFF)) { 196 | autostart = false; 197 | connectOnce(backend.onVncStateChanged, function() { 198 | console.log("autoOff called here", backend.vncState); 199 | if (backend.vncState == Backend.OFF) { 200 | console.log("Yes. Delete it"); 201 | backend.deleteVirtScreen(); 202 | autostart = true; 203 | } 204 | }); 205 | stopVNC(); 206 | } else { 207 | backend.deleteVirtScreen(); 208 | } 209 | } 210 | }, 200); 211 | } 212 | } 213 | MenuItem { 214 | id: vncAction 215 | text: autostart ? "Auto start enabled" : 216 | backend.vncState == Backend.OFF ? "Start VNC Server" : "Stop VNC Server" 217 | enabled: autostart ? false : 218 | backend.virtScreenCreated ? true : false 219 | onTriggered: backend.vncState == Backend.OFF ? startVNC() : stopVNC() 220 | } 221 | MenuItem { 222 | separator: true 223 | } 224 | MenuItem { 225 | text: "Open VirtScreen" 226 | onTriggered: sysTrayIcon.onActivated(SystemTrayIcon.Trigger) 227 | } 228 | MenuItem { 229 | id: quitAction 230 | text: qsTr("&Quit") 231 | onTriggered: { 232 | saveSettings(); 233 | backend.quitProgram(); 234 | } 235 | } 236 | } 237 | } 238 | } -------------------------------------------------------------------------------- /virtscreen/assets/preferenceDialog.qml: -------------------------------------------------------------------------------- 1 | import QtQuick 2.10 2 | import QtQuick.Controls 2.3 3 | import QtQuick.Controls.Material 2.3 4 | import QtQuick.Layouts 1.3 5 | 6 | Dialog { 7 | id: preferenceDialog 8 | title: "Preference" 9 | focus: true 10 | modal: true 11 | visible: true 12 | standardButtons: Dialog.Ok 13 | x: (window.width - width) / 2 14 | y: (window.width - height) / 2 15 | width: popupWidth 16 | height: 250 17 | 18 | Component.onCompleted: { 19 | var request = new XMLHttpRequest(); 20 | request.open('GET', 'data.json'); 21 | request.onreadystatechange = function(event) { 22 | if (request.readyState == XMLHttpRequest.DONE) { 23 | var data = JSON.parse(request.responseText).displaySettingApps; 24 | var combobox = displaySettingAppComboBox; 25 | combobox.model = Object.keys(data).map(function(k){return data[k]}); 26 | combobox.currentIndex = Object.keys(data).indexOf(settings.displaySettingApp); 27 | } 28 | }; 29 | request.send(); 30 | } 31 | 32 | ColumnLayout { 33 | anchors.fill: parent 34 | 35 | RowLayout { 36 | anchors.left: parent.left 37 | anchors.right: parent.right 38 | Label { id: displaySettingAppLabel; text: "Display setting program"; } 39 | ComboBox { 40 | id: displaySettingAppComboBox 41 | anchors.left: displaySettingAppLabel.right 42 | anchors.right: parent.right 43 | anchors.leftMargin: 10 44 | textRole: "name" 45 | onActivated: function(index) { 46 | settings.displaySettingApp = model[index].value; 47 | } 48 | delegate: ItemDelegate { 49 | width: parent.width 50 | text: modelData.name 51 | font.weight: displaySettingAppComboBox.currentIndex === index ? Font.Bold : Font.Normal 52 | } 53 | } 54 | } 55 | 56 | RowLayout { 57 | anchors.left: parent.left 58 | anchors.right: parent.right 59 | Label { id: themeColorLabel; text: "Theme Color"; } 60 | ComboBox { 61 | id: themeColorComboBox 62 | anchors.left: themeColorLabel.right 63 | anchors.right: parent.right 64 | anchors.leftMargin: 50 65 | Material.background: currentIndex 66 | Material.foreground: "white" 67 | textRole: "name" 68 | model: [{"value": Material.Red, "name": "Red"}, {"value": Material.Pink, "name": "Pink"}, 69 | {"value": Material.Purple, "name": "Purple"},{"value": Material.DeepPurple, "name": "DeepPurple"}, 70 | {"value": Material.Indigo, "name": "Indigo"}, {"value": Material.Blue, "name": "Blue"}, 71 | {"value": Material.LightBlue, "name": "LightBlue"}, {"value": Material.Cyan, "name": "Cyan"}, 72 | {"value": Material.Teal, "name": "Teal"}, {"value": Material.Green, "name": "Green"}, 73 | {"value": Material.LightGreen, "name": "LightGreen"}, {"value": Material.Lime, "name": "Lime"}, 74 | {"value": Material.Yellow, "name": "Yellow"}, {"value": Material.Amber, "name": "Amber"}, 75 | {"value": Material.Orange, "name": "Orange"}, {"value": Material.DeepOrange, "name": "DeepOrange"}, 76 | {"value": Material.Brown, "name": "Brown"}, {"value": Material.Grey, "name": "Grey"}, 77 | {"value": Material.BlueGrey, "name": "BlueGrey"}] 78 | currentIndex: settings.theme_color 79 | onActivated: function(index) { 80 | window.theme_color = index; 81 | settings.theme_color = index; 82 | } 83 | delegate: ItemDelegate { 84 | width: parent.width 85 | text: modelData.name + (themeColorComboBox.currentIndex === index ? " (Current)" : "") 86 | Material.foreground: "white" 87 | background: Rectangle { 88 | color: Material.color(modelData.value) 89 | } 90 | } 91 | } 92 | } 93 | 94 | RowLayout { 95 | // Empty layout 96 | Layout.fillHeight: true 97 | } 98 | } 99 | onAccepted: {} 100 | onRejected: {} 101 | } 102 | -------------------------------------------------------------------------------- /virtscreen/display.py: -------------------------------------------------------------------------------- 1 | """Display information data classes""" 2 | 3 | from PyQt5.QtCore import QObject, pyqtProperty 4 | 5 | 6 | class Display(object): 7 | """Display information""" 8 | __slots__ = ['name', 'primary', 'connected', 'active', 'width', 'height', 9 | 'x_offset', 'y_offset'] 10 | 11 | def __init__(self): 12 | self.name: str = None 13 | self.primary: bool = False 14 | self.connected: bool = False 15 | self.active: bool = False 16 | self.width: int = 0 17 | self.height: int = 0 18 | self.x_offset: int = 0 19 | self.y_offset: int = 0 20 | 21 | def __str__(self) -> str: 22 | ret = f"{self.name}" 23 | if self.connected: 24 | ret += " connected" 25 | else: 26 | ret += " disconnected" 27 | if self.primary: 28 | ret += " primary" 29 | if self.active: 30 | ret += f" {self.width}x{self.height}+{self.x_offset}+{self.y_offset}" 31 | else: 32 | ret += f" not active {self.width}x{self.height}" 33 | return ret 34 | 35 | 36 | class DisplayProperty(QObject): 37 | """Wrapper around Display class for Qt""" 38 | def __init__(self, display: Display, parent=None): 39 | super(DisplayProperty, self).__init__(parent) 40 | self._display = display 41 | 42 | @property 43 | def display(self): 44 | return self._display 45 | 46 | @pyqtProperty(str, constant=True) 47 | def name(self): 48 | return self._display.name 49 | 50 | @name.setter 51 | def name(self, name): 52 | self._display.name = name 53 | 54 | @pyqtProperty(bool, constant=True) 55 | def primary(self): 56 | return self._display.primary 57 | 58 | @primary.setter 59 | def primary(self, primary): 60 | self._display.primary = primary 61 | 62 | @pyqtProperty(bool, constant=True) 63 | def connected(self): 64 | return self._display.connected 65 | 66 | @connected.setter 67 | def connected(self, connected): 68 | self._display.connected = connected 69 | 70 | @pyqtProperty(bool, constant=True) 71 | def active(self): 72 | return self._display.active 73 | 74 | @active.setter 75 | def active(self, active): 76 | self._display.active = active 77 | 78 | @pyqtProperty(int, constant=True) 79 | def width(self): 80 | return self._display.width 81 | 82 | @width.setter 83 | def width(self, width): 84 | self._display.width = width 85 | 86 | @pyqtProperty(int, constant=True) 87 | def height(self): 88 | return self._display.height 89 | 90 | @height.setter 91 | def height(self, height): 92 | self._display.height = height 93 | 94 | @pyqtProperty(int, constant=True) 95 | def x_offset(self): 96 | return self._display.x_offset 97 | 98 | @x_offset.setter 99 | def x_offset(self, x_offset): 100 | self._display.x_offset = x_offset 101 | 102 | @pyqtProperty(int, constant=True) 103 | def y_offset(self): 104 | return self._display.y_offset 105 | 106 | @y_offset.setter 107 | def y_offset(self, y_offset): 108 | self._display.y_offset = y_offset 109 | -------------------------------------------------------------------------------- /virtscreen/icon/full_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbumsik/VirtScreen/9637d628165cd46db0873dbd82af2bfd91d0b7d2/virtscreen/icon/full_256x256.png -------------------------------------------------------------------------------- /virtscreen/icon/systray_no_tablet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbumsik/VirtScreen/9637d628165cd46db0873dbd82af2bfd91d0b7d2/virtscreen/icon/systray_no_tablet.png -------------------------------------------------------------------------------- /virtscreen/icon/systray_tablet_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbumsik/VirtScreen/9637d628165cd46db0873dbd82af2bfd91d0b7d2/virtscreen/icon/systray_tablet_off.png -------------------------------------------------------------------------------- /virtscreen/icon/systray_tablet_on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbumsik/VirtScreen/9637d628165cd46db0873dbd82af2bfd91d0b7d2/virtscreen/icon/systray_tablet_on.png -------------------------------------------------------------------------------- /virtscreen/path.py: -------------------------------------------------------------------------------- 1 | """File path definitions""" 2 | 3 | import os 4 | from pathlib import Path 5 | 6 | 7 | # Sanitize environment variables 8 | # https://wiki.sei.cmu.edu/confluence/display/c/ENV03-C.+Sanitize+the+environment+when+invoking+external+programs 9 | 10 | # Setting home path 11 | # Rewrite $HOME env for consistency. This will make 12 | # Path.home() to look up in the password directory (pwd module) 13 | try: 14 | os.environ['HOME'] = str(Path.home()) 15 | # os.environ['PATH'] = os.confstr("CS_PATH") # Sanitize $PATH, Deleted by Issue #19. 16 | 17 | # https://www.freedesktop.org/software/systemd/man/file-hierarchy.html 18 | # HOME_PATH will point to ~/.config/virtscreen by default 19 | if ('XDG_CONFIG_HOME' in os.environ) and len(os.environ['XDG_CONFIG_HOME']): 20 | HOME_PATH = os.environ['XDG_CONFIG_HOME'] 21 | else: 22 | HOME_PATH = os.environ['HOME'] + '/.config' 23 | HOME_PATH = HOME_PATH + "/virtscreen" 24 | except OSError: 25 | HOME_PATH = '' # This will be checked in _main_.py. 26 | # Setting base path 27 | BASE_PATH = os.path.dirname(__file__) # Location of this script 28 | # Path in ~/.virtscreen 29 | X11VNC_LOG_PATH = HOME_PATH + "/x11vnc_log.txt" 30 | X11VNC_PASSWORD_PATH = HOME_PATH + "/x11vnc_passwd" 31 | CONFIG_PATH = HOME_PATH + "/config.json" 32 | LOGGING_PATH = HOME_PATH + "/log.txt" 33 | # Path in the program path 34 | ICON_PATH = BASE_PATH + "/icon/full_256x256.png" 35 | ASSETS_PATH = BASE_PATH + "/assets" 36 | DATA_PATH = ASSETS_PATH + "/data.json" 37 | DEFAULT_CONFIG_PATH = ASSETS_PATH + "/config.default.json" 38 | MAIN_QML_PATH = ASSETS_PATH + "/main.qml" 39 | -------------------------------------------------------------------------------- /virtscreen/process.py: -------------------------------------------------------------------------------- 1 | """Subprocess wrapper""" 2 | 3 | import subprocess 4 | import asyncio 5 | import signal 6 | import shlex 7 | import os 8 | import logging 9 | 10 | 11 | class SubprocessWrapper: 12 | """Subprocess wrapper class""" 13 | def __init__(self): 14 | pass 15 | 16 | def check_output(self, arg) -> None: 17 | return subprocess.check_output(shlex.split(arg), stderr=subprocess.STDOUT).decode('utf-8') 18 | 19 | def run(self, arg: str, input: str = None, check=False) -> str: 20 | if input: 21 | input = input.encode('utf-8') 22 | return subprocess.run(shlex.split(arg), input=input, stdout=subprocess.PIPE, 23 | check=check, stderr=subprocess.STDOUT).stdout.decode('utf-8') 24 | 25 | 26 | class _Protocol(asyncio.SubprocessProtocol): 27 | """SubprocessProtocol implementation""" 28 | 29 | def __init__(self, outer): 30 | self.outer = outer 31 | self.transport: asyncio.SubprocessTransport 32 | 33 | def connection_made(self, transport): 34 | logging.info("connectionMade!") 35 | self.outer.connected() 36 | self.transport = transport 37 | transport.get_pipe_transport(0).close() # No more input 38 | 39 | def pipe_data_received(self, fd, data): 40 | if fd == 1: # stdout 41 | self.outer.out_recevied(data) 42 | if self.outer.logfile is not None: 43 | self.outer.logfile.write(data) 44 | elif fd == 2: # stderr 45 | self.outer.err_recevied(data) 46 | if self.outer.logfile is not None: 47 | self.outer.logfile.write(data) 48 | 49 | def pipe_connection_lost(self, fd, exc): 50 | if fd == 0: # stdin 51 | logging.info("stdin is closed. (we probably did it)") 52 | elif fd == 1: # stdout 53 | logging.info("The child closed their stdout.") 54 | elif fd == 2: # stderr 55 | logging.info("The child closed their stderr.") 56 | 57 | def connection_lost(self, exc): 58 | logging.info("Subprocess connection lost.") 59 | 60 | def process_exited(self): 61 | if self.outer.logfile is not None: 62 | self.outer.logfile.close() 63 | self.transport.close() 64 | return_code = self.transport.get_returncode() 65 | if return_code is None: 66 | logging.error("Unknown exit") 67 | self.outer.ended(1) 68 | return 69 | logging.info(f"processEnded, status {return_code}") 70 | self.outer.ended(return_code) 71 | 72 | 73 | class AsyncSubprocess(): 74 | """Asynchronous subprocess wrapper class""" 75 | 76 | def __init__(self, connected, out_recevied, err_recevied, ended, logfile=None): 77 | self.connected = connected 78 | self.out_recevied = out_recevied 79 | self.err_recevied = err_recevied 80 | self.ended = ended 81 | self.logfile = logfile 82 | self.transport: asyncio.SubprocessTransport 83 | self.protocol: _Protocol 84 | 85 | async def _run(self, arg: str, loop: asyncio.AbstractEventLoop): 86 | self.transport, self.protocol = await loop.subprocess_exec( 87 | lambda: _Protocol(self), *shlex.split(arg), env=os.environ) 88 | 89 | def run(self, arg: str): 90 | """Spawn a process. 91 | 92 | Arguments: 93 | arg {str} -- arguments in string 94 | """ 95 | loop = asyncio.get_event_loop() 96 | loop.create_task(self._run(arg, loop)) 97 | 98 | def close(self): 99 | """Kill a spawned process.""" 100 | self.transport.send_signal(signal.SIGINT) 101 | -------------------------------------------------------------------------------- /virtscreen/qt_backend.py: -------------------------------------------------------------------------------- 1 | """GUI backend""" 2 | 3 | import json 4 | import re 5 | import subprocess 6 | import os 7 | import shutil 8 | import atexit 9 | import time 10 | import logging 11 | from typing import Callable 12 | 13 | from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot, pyqtSignal, Q_ENUMS 14 | from PyQt5.QtGui import QCursor 15 | from PyQt5.QtQml import QQmlListProperty 16 | from PyQt5.QtWidgets import QApplication 17 | from netifaces import interfaces, ifaddresses, AF_INET 18 | 19 | from .display import DisplayProperty 20 | from .xrandr import XRandR 21 | from .process import AsyncSubprocess, SubprocessWrapper 22 | from .path import (DATA_PATH, CONFIG_PATH, DEFAULT_CONFIG_PATH, 23 | X11VNC_PASSWORD_PATH, X11VNC_LOG_PATH) 24 | 25 | 26 | class Backend(QObject): 27 | """ Backend class for QML frontend """ 28 | 29 | class VNCState: 30 | """ Enum to indicate a state of the VNC server """ 31 | OFF = 0 32 | ERROR = 1 33 | WAITING = 2 34 | CONNECTED = 3 35 | 36 | Q_ENUMS(VNCState) 37 | 38 | # Signals 39 | onVirtScreenCreatedChanged = pyqtSignal(bool) 40 | onVncUsePasswordChanged = pyqtSignal(bool) 41 | onVncStateChanged = pyqtSignal(VNCState) 42 | onDisplaySettingClosed = pyqtSignal() 43 | onError = pyqtSignal(str) 44 | 45 | def __init__(self, parent=None, logger=logging.info, error_logger=logging.error): 46 | super(Backend, self).__init__(parent) 47 | # Virtual screen properties 48 | self.xrandr: XRandR = XRandR() 49 | self._virtScreenCreated: bool = False 50 | # VNC server properties 51 | self._vncUsePassword: bool = False 52 | self._vncState: self.VNCState = self.VNCState.OFF 53 | # Primary screen and mouse posistion 54 | self.vncServer: AsyncSubprocess 55 | # Info/error logger 56 | self.log: Callable[[str], None] = logger 57 | self.log_error: Callable[[str], None] = error_logger 58 | # Check config file 59 | # and initialize if needed 60 | need_init = False 61 | if not os.path.exists(CONFIG_PATH): 62 | shutil.copy(DEFAULT_CONFIG_PATH, CONFIG_PATH) 63 | need_init = True 64 | # Version check 65 | file_match = True 66 | with open(CONFIG_PATH, 'r') as f_config, open(DATA_PATH, 'r') as f_data: 67 | config = json.load(f_config) 68 | data = json.load(f_data) 69 | if config['version'] != data['version']: 70 | file_match = False 71 | # Override config with default when version doesn't match 72 | if not file_match: 73 | shutil.copy(DEFAULT_CONFIG_PATH, CONFIG_PATH) 74 | need_init = True 75 | # initialize config file 76 | if need_init: 77 | # 1. Available x11vnc options 78 | # Get available x11vnc options from x11vnc first 79 | p = SubprocessWrapper() 80 | arg = 'x11vnc -opts' 81 | ret = p.run(arg) 82 | options = tuple(m.group(1) for m in re.finditer(r"\s*(-\w+)\s+", ret)) 83 | # Set/unset available x11vnc options flags in config 84 | with open(CONFIG_PATH, 'r') as f, open(DATA_PATH, 'r') as f_data: 85 | config = json.load(f) 86 | data = json.load(f_data) 87 | for key, value in config["x11vncOptions"].items(): 88 | if key in options: 89 | value["available"] = True 90 | else: 91 | value["available"] = False 92 | # Default Display settings app for a Desktop Environment 93 | desktop_environ = os.environ.get('XDG_CURRENT_DESKTOP', '').lower() 94 | for key, value in data['displaySettingApps'].items(): 95 | if desktop_environ in value['XDG_CURRENT_DESKTOP']: 96 | config["displaySettingApp"] = key 97 | # Save the new config 98 | with open(CONFIG_PATH, 'w') as f: 99 | f.write(json.dumps(config, indent=4, sort_keys=True)) 100 | 101 | def promptError(self, msg): 102 | self.log_error(msg) 103 | self.onError.emit(msg) 104 | 105 | # Qt properties 106 | @pyqtProperty(str, constant=True) 107 | def settings(self): 108 | with open(CONFIG_PATH, "r") as f: 109 | return f.read() 110 | 111 | @settings.setter 112 | def settings(self, json_str): 113 | with open(CONFIG_PATH, "w") as f: 114 | f.write(json_str) 115 | 116 | @pyqtProperty(bool, notify=onVirtScreenCreatedChanged) 117 | def virtScreenCreated(self): 118 | return self._virtScreenCreated 119 | 120 | @virtScreenCreated.setter 121 | def virtScreenCreated(self, value): 122 | self._virtScreenCreated = value 123 | self.onVirtScreenCreatedChanged.emit(value) 124 | 125 | @pyqtProperty(QQmlListProperty, constant=True) 126 | def screens(self): 127 | try: 128 | return QQmlListProperty(DisplayProperty, self, [DisplayProperty(x) for x in self.xrandr.screens]) 129 | except RuntimeError as e: 130 | self.promptError(str(e)) 131 | return QQmlListProperty(DisplayProperty, self, []) 132 | 133 | @pyqtProperty(bool, notify=onVncUsePasswordChanged) 134 | def vncUsePassword(self): 135 | if os.path.isfile(X11VNC_PASSWORD_PATH): 136 | self._vncUsePassword = True 137 | else: 138 | if self._vncUsePassword: 139 | self.vncUsePassword = False 140 | return self._vncUsePassword 141 | 142 | @vncUsePassword.setter 143 | def vncUsePassword(self, use): 144 | self._vncUsePassword = use 145 | self.onVncUsePasswordChanged.emit(use) 146 | 147 | @pyqtProperty(VNCState, notify=onVncStateChanged) 148 | def vncState(self): 149 | return self._vncState 150 | 151 | @vncState.setter 152 | def vncState(self, state): 153 | self._vncState = state 154 | self.onVncStateChanged.emit(self._vncState) 155 | 156 | # Qt Slots 157 | @pyqtSlot(str, int, int, bool, bool) 158 | def createVirtScreen(self, device, width, height, portrait, hidpi, pos=''): 159 | self.xrandr.virt_name = device 160 | self.log("Creating a Virtual Screen...") 161 | try: 162 | self.xrandr.create_virtual_screen(width, height, portrait, hidpi, pos) 163 | except subprocess.CalledProcessError as e: 164 | self.promptError(str(e.cmd) + '\n' + e.stdout.decode('utf-8')) 165 | return 166 | except RuntimeError as e: 167 | self.promptError(str(e)) 168 | return 169 | self.virtScreenCreated = True 170 | self.log("The Virtual Screen successfully created.") 171 | 172 | @pyqtSlot() 173 | def deleteVirtScreen(self): 174 | self.log("Deleting the Virtual Screen...") 175 | if self.vncState is not self.VNCState.OFF: 176 | self.promptError("Turn off the VNC server first") 177 | self.virtScreenCreated = True 178 | return 179 | try: 180 | self.xrandr.delete_virtual_screen() 181 | except RuntimeError as e: 182 | self.promptError(str(e)) 183 | return 184 | self.virtScreenCreated = False 185 | 186 | @pyqtSlot(str) 187 | def createVNCPassword(self, password): 188 | if password: 189 | password += '\n' + password + '\n\n' # verify + confirm 190 | p = SubprocessWrapper() 191 | try: 192 | p.run(f"x11vnc -storepasswd {X11VNC_PASSWORD_PATH}", input=password, check=True) 193 | except subprocess.CalledProcessError as e: 194 | self.promptError(str(e.cmd) + '\n' + e.stdout.decode('utf-8')) 195 | return 196 | self.vncUsePassword = True 197 | else: 198 | self.promptError("Empty password") 199 | 200 | @pyqtSlot() 201 | def deleteVNCPassword(self): 202 | if os.path.isfile(X11VNC_PASSWORD_PATH): 203 | os.remove(X11VNC_PASSWORD_PATH) 204 | self.vncUsePassword = False 205 | else: 206 | self.promptError("Failed deleting the password file") 207 | 208 | @pyqtSlot(int) 209 | def startVNC(self, port): 210 | # Check if a virtual screen created 211 | if not self.virtScreenCreated: 212 | self.promptError("Virtual Screen not crated.") 213 | return 214 | if self.vncState is not self.VNCState.OFF: 215 | self.promptError("VNC Server is already running.") 216 | return 217 | # regex used in callbacks 218 | patter_connected = re.compile(r"^.*Got connection from client.*$", re.M) 219 | patter_disconnected = re.compile(r"^.*client_count: 0*$", re.M) 220 | 221 | # define callbacks 222 | def _connected(): 223 | self.log(f"VNC started. Now connect a VNC client to port {port}.") 224 | self.vncState = self.VNCState.WAITING 225 | 226 | def _received(data): 227 | data = data.decode("utf-8") 228 | if (self._vncState is not self.VNCState.CONNECTED) and patter_connected.search(data): 229 | self.log("VNC connected.") 230 | self.vncState = self.VNCState.CONNECTED 231 | if (self._vncState is self.VNCState.CONNECTED) and patter_disconnected.search(data): 232 | self.log("VNC disconnected.") 233 | self.vncState = self.VNCState.WAITING 234 | 235 | def _ended(exitCode): 236 | if exitCode is not 0: 237 | self.vncState = self.VNCState.ERROR 238 | self.promptError('X11VNC: Error occurred.\n' 239 | 'Double check if the port is already used.') 240 | self.vncState = self.VNCState.OFF # TODO: better handling error state 241 | else: 242 | self.vncState = self.VNCState.OFF 243 | self.log("VNC Exited.") 244 | atexit.unregister(self.stopVNC) 245 | # load settings 246 | with open(CONFIG_PATH, 'r') as f: 247 | config = json.load(f) 248 | options = '' 249 | if config['customX11vncArgs']['enabled']: 250 | options = config['customX11vncArgs']['value'] 251 | else: 252 | for key, value in config['x11vncOptions'].items(): 253 | if value['available'] and value['enabled']: 254 | options += key + ' ' 255 | if value['arg'] is not None: 256 | options += str(value['arg']) + ' ' 257 | # Sart x11vnc, turn settings object into VNC arguments format 258 | logfile = open(X11VNC_LOG_PATH, "wb") 259 | self.vncServer = AsyncSubprocess(_connected, _received, _received, _ended, logfile) 260 | try: 261 | virt = self.xrandr.get_virtual_screen() 262 | except RuntimeError as e: 263 | self.promptError(str(e)) 264 | return 265 | clip = f"{virt.width}x{virt.height}+{virt.x_offset}+{virt.y_offset}" 266 | arg = f"x11vnc -rfbport {port} -clip {clip} {options}" 267 | if self.vncUsePassword: 268 | arg += f" -rfbauth {X11VNC_PASSWORD_PATH}" 269 | self.vncServer.run(arg) 270 | # auto stop on exit 271 | atexit.register(self.stopVNC, force=True) 272 | 273 | @pyqtSlot(str) 274 | def openDisplaySetting(self, app: str = "arandr"): 275 | # define callbacks 276 | def _connected(): 277 | self.log("External Display Setting opened.") 278 | 279 | def _received(data): 280 | pass 281 | 282 | def _ended(exitCode): 283 | self.log("External Display Setting closed.") 284 | self.onDisplaySettingClosed.emit() 285 | if exitCode is not 0: 286 | self.promptError(f'Error opening "{running_program}".') 287 | with open(DATA_PATH, 'r') as f: 288 | data = json.load(f)['displaySettingApps'] 289 | if app not in data: 290 | self.promptError('Wrong display settings program') 291 | return 292 | program_list = [data[app]['args'], "arandr"] 293 | program = AsyncSubprocess(_connected, _received, _received, _ended, None) 294 | running_program = '' 295 | for arg in program_list: 296 | if not shutil.which(arg.split()[0]): 297 | continue 298 | running_program = arg 299 | program.run(arg) 300 | return 301 | self.promptError('Failed to find a display settings program.\n' 302 | 'Please install ARandR package.\n' 303 | '(e.g. sudo apt-get install arandr)\n' 304 | 'Please issue a feature request\n' 305 | 'if you wish to add a display settings\n' 306 | 'program for your Desktop Environment.') 307 | 308 | @pyqtSlot() 309 | def stopVNC(self, force=False): 310 | if force: 311 | # Usually called from atexit(). 312 | self.vncServer.close() 313 | time.sleep(3) # Make sure X11VNC shutdown before execute next atexit(). 314 | if self._vncState in (self.VNCState.WAITING, self.VNCState.CONNECTED): 315 | self.vncServer.close() 316 | else: 317 | self.promptError("stopVNC called while it is not running") 318 | 319 | @pyqtSlot() 320 | def clearCache(self): 321 | # engine.clearComponentCache() 322 | pass 323 | 324 | @pyqtSlot() 325 | def quitProgram(self): 326 | self.blockSignals(True) # This will prevent invoking auto-restart or etc. 327 | QApplication.instance().quit() 328 | 329 | 330 | class Cursor(QObject): 331 | """ Global mouse cursor position """ 332 | 333 | def __init__(self, parent=None): 334 | super(Cursor, self).__init__(parent) 335 | 336 | @pyqtProperty(int) 337 | def x(self): 338 | cursor = QCursor().pos() 339 | return cursor.x() 340 | 341 | @pyqtProperty(int) 342 | def y(self): 343 | cursor = QCursor().pos() 344 | return cursor.y() 345 | 346 | 347 | class Network(QObject): 348 | """ Backend class for network interfaces """ 349 | onIPAddressesChanged = pyqtSignal() 350 | 351 | def __init__(self, parent=None): 352 | super(Network, self).__init__(parent) 353 | 354 | @pyqtProperty('QStringList', notify=onIPAddressesChanged) 355 | def ipAddresses(self): 356 | for interface in interfaces(): 357 | if interface == 'lo': 358 | continue 359 | addresses = ifaddresses(interface).get(AF_INET, None) 360 | if addresses is None: 361 | continue 362 | for link in addresses: 363 | if link is not None: 364 | yield link['addr'] 365 | -------------------------------------------------------------------------------- /virtscreen/xrandr.py: -------------------------------------------------------------------------------- 1 | """XRandr parser""" 2 | 3 | import re 4 | import atexit 5 | import subprocess 6 | import logging 7 | from typing import List 8 | 9 | from .display import Display 10 | from .process import SubprocessWrapper 11 | 12 | 13 | VIRT_SCREEN_SUFFIX = "_virt" 14 | 15 | 16 | class XRandR(SubprocessWrapper): 17 | """XRandr parser class""" 18 | 19 | def __init__(self): 20 | super(XRandR, self).__init__() 21 | self.mode_name: str 22 | self.screens: List[Display] = [] 23 | self.virt: Display() = None 24 | self.primary: Display() = None 25 | self.virt_name: str = '' 26 | self.virt_idx: int = None 27 | self.primary_idx: int = None 28 | # Primary display 29 | self._update_screens() 30 | 31 | def _update_screens(self) -> None: 32 | output = self.run("xrandr") 33 | self.primary = None 34 | self.virt = None 35 | self.screens = [] 36 | self.virt_idx = None 37 | self.primary_idx = None 38 | pattern = re.compile(r"^(\S*)\s+(connected|disconnected)\s+((primary)\s+)?" 39 | r"((\d+)x(\d+)\+(\d+)\+(\d+)\s+)?.*$", re.M) 40 | for idx, match in enumerate(pattern.finditer(output)): 41 | screen = Display() 42 | screen.name = match.group(1) 43 | if self.virt_name and screen.name == self.virt_name: 44 | self.virt_idx = idx 45 | screen.primary = True if match.group(4) else False 46 | if screen.primary: 47 | self.primary_idx = idx 48 | screen.connected = True if match.group(2) == "connected" else False 49 | screen.active = True if match.group(5) else False 50 | self.screens.append(screen) 51 | if not screen.active: 52 | continue 53 | screen.width = int(match.group(6)) 54 | screen.height = int(match.group(7)) 55 | screen.x_offset = int(match.group(8)) 56 | screen.y_offset = int(match.group(9)) 57 | logging.info("Display information:") 58 | for s in self.screens: 59 | logging.info(f"\t{s}") 60 | if self.primary_idx is None: 61 | raise RuntimeError("There is no primary screen detected.\n" 62 | "Go to display settings and set\n" 63 | "a primary screen\n") 64 | if self.virt_idx == self.primary_idx: 65 | raise RuntimeError("Virtual screen must be selected other than the primary screen") 66 | if self.virt_idx is not None: 67 | self.virt = self.screens[self.virt_idx] 68 | elif self.virt_name and self.virt_idx is None: 69 | raise RuntimeError("No virtual screen name found") 70 | self.primary = self.screens[self.primary_idx] 71 | 72 | def _add_screen_mode(self, width, height, portrait, hidpi) -> None: 73 | if not self.virt or not self.virt_name: 74 | raise RuntimeError("No virtual screen selected.\n" 75 | "Go to Display->Virtual Display->Advaced\n" 76 | "To select a device.") 77 | # Set virtual screen property first 78 | self.virt.width = width 79 | self.virt.height = height 80 | if portrait: 81 | self.virt.width = height 82 | self.virt.height = width 83 | if hidpi: 84 | self.virt.width *= 2 85 | self.virt.height *= 2 86 | self.mode_name = str(self.virt.width) + "x" + str(self.virt.height) + VIRT_SCREEN_SUFFIX 87 | # Then create using xrandr command 88 | args_addmode = f"xrandr --addmode {self.virt.name} {self.mode_name}" 89 | try: 90 | self.check_output(args_addmode) 91 | except subprocess.CalledProcessError: 92 | # When failed create mode and then add again 93 | output = self.run(f"cvt {self.virt.width} {self.virt.height}") 94 | mode = re.search(r"^.*Modeline\s*\".*\"\s*(.*)$", output, re.M).group(1) 95 | # Create new screen mode 96 | self.check_output(f"xrandr --newmode {self.mode_name} {mode}") 97 | # Add mode again 98 | self.check_output(args_addmode) 99 | # After adding mode the program should delete the mode automatically on exit 100 | atexit.register(self.delete_virtual_screen) 101 | 102 | def get_primary_screen(self) -> Display: 103 | self._update_screens() 104 | return self.primary 105 | 106 | def get_virtual_screen(self) -> Display: 107 | self._update_screens() 108 | return self.virt 109 | 110 | def create_virtual_screen(self, width, height, portrait=False, hidpi=False, pos='') -> None: 111 | self._update_screens() 112 | logging.info(f"creating: {self.virt}") 113 | self._add_screen_mode(width, height, portrait, hidpi) 114 | arg_pos = ['left', 'right', 'above', 'below'] 115 | xrandr_pos = ['--left-of', '--right-of', '--above', '--below'] 116 | if pos and pos in arg_pos: 117 | # convert pos for xrandr 118 | pos = xrandr_pos[arg_pos.index(pos)] 119 | pos += ' ' + self.primary.name 120 | elif not pos: 121 | pos = '--preferred' 122 | else: 123 | raise RuntimeError("Incorrect position option selected.") 124 | self.check_output(f"xrandr --output {self.virt.name} --mode {self.mode_name}") 125 | self.check_output("sleep 5") 126 | self.check_output(f"xrandr --output {self.virt.name} {pos}") 127 | self._update_screens() 128 | 129 | def delete_virtual_screen(self) -> None: 130 | self._update_screens() 131 | try: 132 | self.virt.name 133 | self.mode_name 134 | except AttributeError: 135 | return 136 | self.run(f"xrandr --output {self.virt.name} --off") 137 | self.run(f"xrandr --delmode {self.virt.name} {self.mode_name}") 138 | atexit.unregister(self.delete_virtual_screen) 139 | self._update_screens() 140 | --------------------------------------------------------------------------------