diff --git a/Makefile b/Makefile index c690812..31f6b79 100644 --- a/Makefile +++ b/Makefile @@ -8,10 +8,21 @@ DOCKER_RUN_TTY=docker run --interactive --tty -v $(shell pwd):/app $(DOCKER_NAME .ONESHELL: +.PHONY: run debug run-appimage debug-appimage + # Run script run: python3 -m virtscreen +debug: + QT_DEBUG_PLUGINS=1 QML_IMPORT_TRACE=1 python3 -m virtscreen --log=DEBUG + +run-appimage: package/appimage/VirtScreen-x86_64.AppImage + $< + +debug-appimage: package/appimage/VirtScreen-x86_64.AppImage + QT_DEBUG_PLUGINS=1 QML_IMPORT_TRACE=1 $< --log=DEBUG + # Docker tools .PHONY: docker docker-build @@ -36,14 +47,14 @@ wheel-clean: .PHONY: appimage-clean .SECONDARY: package/appimage/VirtScreen-x86_64.AppImage -package/appimage/%.AppImage: +package/appimage/VirtScreen-x86_64.AppImage: $(DOCKER_RUN) package/appimage/build.sh $(DOCKER_RUN) chown -R $(shell id -u):$(shell id -u) package/appimage appimage-clean: -rm -rf package/appimage/virtscreen.AppDir package/appimage/VirtScreen-x86_64.AppImage -# For Debian packaging, https://www.debian.org/doc/manuals/maint-guide/index.en.html +# For Debian packaging, https://www.debian.org/doc/manuals/maint-guide/index.en.html # https://www.debian.org/doc/manuals/debmake-doc/ch08.en.html#setup-py .PHONY: deb-contents deb-clean diff --git a/virtscreen/__main__.py b/virtscreen/__main__.py index 0117c66..ed9546d 100755 --- a/virtscreen/__main__.py +++ b/virtscreen/__main__.py @@ -7,6 +7,8 @@ import signal import json import shutil import argparse +import logging +from logging.handlers import RotatingFileHandler from typing import Callable import asyncio @@ -25,8 +27,12 @@ from quamash import QEventLoop from .display import DisplayProperty from .xrandr import XRandR from .qt_backend import Backend, Cursor, Network -from .path import HOME_PATH, ICON_PATH, MAIN_QML_PATH, CONFIG_PATH +from .path import HOME_PATH, ICON_PATH, MAIN_QML_PATH, CONFIG_PATH, LOGGING_PATH +def error(*args, **kwargs) -> None: + """Error printing""" + args = ('Error: ', *args) + print(*args, file=sys.stderr, **kwargs) def main() -> None: """Start main program""" @@ -63,22 +69,46 @@ def main() -> None: help='Portrait mode. Width and height of the screen are swapped') parser.add_argument('--hidpi', action='store_true', help='HiDPI mode. Width and height are doubled') + parser.add_argument('--log', type=str, + help='Python logging level, For example, --log=DEBUG.\n' + 'Only used for reporting bugs and debugging') # Add signal handler def on_exit(self, signum=None, frame=None): sys.exit(0) for sig in [signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT]: signal.signal(sig, on_exit) + + args = vars(parser.parse_args()) + # Enable logging + if args['log'] is None: + args['log'] = 'WARNING' + log_level = getattr(logging, args['log'].upper(), None) + if not isinstance(log_level, int): + error('Please choose a correct python logging level') + sys.exit(1) + # When logging level is INFO or lower, print logs in terminal + # Otherwise log to a file + log_to_file = True if log_level > logging.INFO else False + FORMAT = "[%(levelname)s:%(filename)s:%(lineno)s:%(funcName)s()] %(message)s" + logging.basicConfig(level=log_level, format=FORMAT, + **({'filename': LOGGING_PATH} if log_to_file else {})) + if log_to_file: + logger = logging.getLogger() + handler = RotatingFileHandler(LOGGING_PATH, mode='a', maxBytes=1024*4, backupCount=1) + logger.addHandler(handler) + logging.info('logging enabled') + del args['log'] + logging.info(f'{args}') # Start main - args = parser.parse_args() - if any(vars(args).values()): + if any(args.values()): main_cli(args) else: main_gui() - print('Program should not reach here.') + error('Program should not reach here.') sys.exit(1) def check_env(msg: Callable[[str], None]) -> None: - """Check enveironments before start""" + """Check environments before start""" if os.environ.get('XDG_SESSION_TYPE', '').lower() == 'wayland': msg("Currently Wayland is not supported") sys.exit(1) @@ -94,6 +124,7 @@ def check_env(msg: Callable[[str], None]) -> None: if not shutil.which('x11vnc'): msg("x11vnc is not installed.") sys.exit(1) + # Check if xrandr is correctly parsed. try: test = XRandR() except RuntimeError as e: @@ -105,7 +136,7 @@ def main_gui(): app = QApplication(sys.argv) loop = QEventLoop(app) asyncio.set_event_loop(loop) - + # Check environment first from PyQt5.QtWidgets import QMessageBox, QSystemTrayIcon def dialog(message: str) -> None: @@ -114,7 +145,7 @@ def main_gui(): dialog("Cannot detect system tray on this system.") sys.exit(1) check_env(dialog) - + app.setApplicationName("VirtScreen") app.setWindowIcon(QIcon(ICON_PATH)) os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" @@ -138,38 +169,36 @@ def main_gui(): def main_cli(args: argparse.Namespace): loop = asyncio.get_event_loop() - for key, value in vars(args).items(): - print(key, ": ", value) # Check the environment check_env(print) if not os.path.exists(CONFIG_PATH): - print("Configuration file does not exist.\n" + error("Configuration file does not exist.\n" "Configure a virtual screen using GUI first.") sys.exit(1) # By instantiating the backend, additional verifications of config - # file will be done. + # file will be done. backend = Backend() # Get settings with open(CONFIG_PATH, 'r') as f: config = json.load(f) # Override settings from arguments position = '' - if not args.auto: + if not args['auto']: args_virt = ['portrait', 'hidpi'] for prop in args_virt: - if vars(args)[prop]: + if args[prop]: config['virt'][prop] = True args_position = ['left', 'right', 'above', 'below'] - tmp_args = {k: vars(args)[k] for k in args_position} + tmp_args = {k: args[k] for k in args_position} if not any(tmp_args.values()): - print("Choose a position relative to the primary monitor. (e.g. --left)") + error("Choose a position relative to the primary monitor. (e.g. --left)") sys.exit(1) for key, value in tmp_args.items(): if value: position = key # Create virtscreen and Start VNC def handle_error(msg): - print('Error: ', msg) + error(msg) sys.exit(1) backend.onError.connect(handle_error) backend.createVirtScreen(config['virt']['device'], config['virt']['width'], diff --git a/virtscreen/path.py b/virtscreen/path.py index 1b5d070..3d7cabf 100644 --- a/virtscreen/path.py +++ b/virtscreen/path.py @@ -29,6 +29,7 @@ BASE_PATH = os.path.dirname(__file__) # Location of this script X11VNC_LOG_PATH = HOME_PATH + "/x11vnc_log.txt" X11VNC_PASSWORD_PATH = HOME_PATH + "/x11vnc_passwd" CONFIG_PATH = HOME_PATH + "/config.json" +LOGGING_PATH = HOME_PATH + "/log.txt" # Path in the program path ICON_PATH = BASE_PATH + "/icon/full_256x256.png" ASSETS_PATH = BASE_PATH + "/assets" diff --git a/virtscreen/process.py b/virtscreen/process.py index b3b197b..6d00040 100644 --- a/virtscreen/process.py +++ b/virtscreen/process.py @@ -5,6 +5,7 @@ import asyncio import signal import shlex import os +import logging class SubprocessWrapper: @@ -30,7 +31,7 @@ class _Protocol(asyncio.SubprocessProtocol): self.transport: asyncio.SubprocessTransport def connection_made(self, transport): - print("connectionMade!") + logging.info("connectionMade!") self.outer.connected() self.transport = transport transport.get_pipe_transport(0).close() # No more input @@ -47,14 +48,14 @@ class _Protocol(asyncio.SubprocessProtocol): def pipe_connection_lost(self, fd, exc): if fd == 0: # stdin - print("stdin is closed. (we probably did it)") + logging.info("stdin is closed. (we probably did it)") elif fd == 1: # stdout - print("The child closed their stdout.") + logging.info("The child closed their stdout.") elif fd == 2: # stderr - print("The child closed their stderr.") + logging.info("The child closed their stderr.") def connection_lost(self, exc): - print("Subprocess connection lost.") + logging.info("Subprocess connection lost.") def process_exited(self): if self.outer.logfile is not None: @@ -62,10 +63,10 @@ class _Protocol(asyncio.SubprocessProtocol): self.transport.close() return_code = self.transport.get_returncode() if return_code is None: - print("Unknown exit") + logging.error("Unknown exit") self.outer.ended(1) return - print("processEnded, status", return_code) + logging.info(f"processEnded, status {return_code}") self.outer.ended(return_code) diff --git a/virtscreen/qt_backend.py b/virtscreen/qt_backend.py index 70f01bd..84557b9 100644 --- a/virtscreen/qt_backend.py +++ b/virtscreen/qt_backend.py @@ -7,6 +7,7 @@ import os import shutil import atexit import time +import logging from PyQt5.QtCore import QObject, pyqtProperty, pyqtSlot, pyqtSignal, Q_ENUMS from PyQt5.QtGui import QCursor @@ -74,7 +75,7 @@ class Backend(QObject): p = SubprocessWrapper() arg = 'x11vnc -opts' ret = p.run(arg) - options = tuple(m.group(1) for m in re.finditer("\s*(-\w+)\s+", ret)) + options = tuple(m.group(1) for m in re.finditer(r"\s*(-\w+)\s+", ret)) # Set/unset available x11vnc options flags in config with open(CONFIG_PATH, 'r') as f, open(DATA_PATH, 'r') as f_data: config = json.load(f) @@ -93,6 +94,10 @@ class Backend(QObject): with open(CONFIG_PATH, 'w') as f: f.write(json.dumps(config, indent=4, sort_keys=True)) + def promptError(self, msg): + logging.error(msg) + self.onError.emit(msg) + # Qt properties @pyqtProperty(str, constant=True) def settings(self): @@ -118,7 +123,7 @@ class Backend(QObject): try: return QQmlListProperty(DisplayProperty, self, [DisplayProperty(x) for x in self.xrandr.screens]) except RuntimeError as e: - self.onError.emit(str(e)) + self.promptError(str(e)) return QQmlListProperty(DisplayProperty, self, []) @pyqtProperty(bool, notify=onVncUsePasswordChanged) @@ -148,28 +153,28 @@ class Backend(QObject): @pyqtSlot(str, int, int, bool, bool) def createVirtScreen(self, device, width, height, portrait, hidpi, pos=''): self.xrandr.virt_name = device - print("Creating a Virtual Screen...") + logging.info("Creating a Virtual Screen...") try: self.xrandr.create_virtual_screen(width, height, portrait, hidpi, pos) except subprocess.CalledProcessError as e: - self.onError.emit(str(e.cmd) + '\n' + e.stdout.decode('utf-8')) + self.promptError(str(e.cmd) + '\n' + e.stdout.decode('utf-8')) return except RuntimeError as e: - self.onError.emit(str(e)) + self.promptError(str(e)) return self.virtScreenCreated = True @pyqtSlot() def deleteVirtScreen(self): - print("Deleting the Virtual Screen...") + logging.info("Deleting the Virtual Screen...") if self.vncState is not self.VNCState.OFF: - self.onError.emit("Turn off the VNC server first") + self.promptError("Turn off the VNC server first") self.virtScreenCreated = True return try: self.xrandr.delete_virtual_screen() except RuntimeError as e: - self.onError.emit(str(e)) + self.promptError(str(e)) return self.virtScreenCreated = False @@ -181,11 +186,11 @@ class Backend(QObject): try: p.run(f"x11vnc -storepasswd {X11VNC_PASSWORD_PATH}", input=password, check=True) except subprocess.CalledProcessError as e: - self.onError.emit(str(e.cmd) + '\n' + e.stdout.decode('utf-8')) + self.promptError(str(e.cmd) + '\n' + e.stdout.decode('utf-8')) return self.vncUsePassword = True else: - self.onError.emit("Empty password") + self.promptError("Empty password") @pyqtSlot() def deleteVNCPassword(self): @@ -193,16 +198,16 @@ class Backend(QObject): os.remove(X11VNC_PASSWORD_PATH) self.vncUsePassword = False else: - self.onError.emit("Failed deleting the password file") + self.promptError("Failed deleting the password file") @pyqtSlot(int) def startVNC(self, port): # Check if a virtual screen created if not self.virtScreenCreated: - self.onError.emit("Virtual Screen not crated.") + self.promptError("Virtual Screen not crated.") return if self.vncState is not self.VNCState.OFF: - self.onError.emit("VNC Server is already running.") + self.promptError("VNC Server is already running.") return # regex used in callbacks patter_connected = re.compile(r"^.*Got connection from client.*$", re.M) @@ -210,27 +215,27 @@ class Backend(QObject): # define callbacks def _connected(): - print("VNC started.") + logging.info("VNC started.") self.vncState = self.VNCState.WAITING def _received(data): data = data.decode("utf-8") if (self._vncState is not self.VNCState.CONNECTED) and patter_connected.search(data): - print("VNC connected.") + logging.info("VNC connected.") self.vncState = self.VNCState.CONNECTED if (self._vncState is self.VNCState.CONNECTED) and patter_disconnected.search(data): - print("VNC disconnected.") + logging.info("VNC disconnected.") self.vncState = self.VNCState.WAITING def _ended(exitCode): if exitCode is not 0: self.vncState = self.VNCState.ERROR - self.onError.emit('X11VNC: Error occurred.\n' + self.promptError('X11VNC: Error occurred.\n' 'Double check if the port is already used.') self.vncState = self.VNCState.OFF # TODO: better handling error state else: self.vncState = self.VNCState.OFF - print("VNC Exited.") + logging.info("VNC Exited.") atexit.unregister(self.stopVNC) # load settings with open(CONFIG_PATH, 'r') as f: @@ -250,7 +255,7 @@ class Backend(QObject): try: virt = self.xrandr.get_virtual_screen() except RuntimeError as e: - self.onError.emit(str(e)) + self.promptError(str(e)) return clip = f"{virt.width}x{virt.height}+{virt.x_offset}+{virt.y_offset}" arg = f"x11vnc -rfbport {port} -clip {clip} {options}" @@ -264,20 +269,20 @@ class Backend(QObject): def openDisplaySetting(self, app: str = "arandr"): # define callbacks def _connected(): - print("External Display Setting opened.") + logging.info("External Display Setting opened.") def _received(data): pass def _ended(exitCode): - print("External Display Setting closed.") + logging.info("External Display Setting closed.") self.onDisplaySettingClosed.emit() if exitCode is not 0: - self.onError.emit(f'Error opening "{running_program}".') + self.promptError(f'Error opening "{running_program}".') with open(DATA_PATH, 'r') as f: data = json.load(f)['displaySettingApps'] if app not in data: - self.onError.emit('Wrong display settings program') + self.promptError('Wrong display settings program') return program_list = [data[app]['args'], "arandr"] program = AsyncSubprocess(_connected, _received, _received, _ended, None) @@ -288,12 +293,12 @@ class Backend(QObject): running_program = arg program.run(arg) return - self.onError.emit('Failed to find a display settings program.\n' - 'Please install ARandR package.\n' - '(e.g. sudo apt-get install arandr)\n' - 'Please issue a feature request\n' - 'if you wish to add a display settings\n' - 'program for your Desktop Environment.') + self.promptError('Failed to find a display settings program.\n' + 'Please install ARandR package.\n' + '(e.g. sudo apt-get install arandr)\n' + 'Please issue a feature request\n' + 'if you wish to add a display settings\n' + 'program for your Desktop Environment.') @pyqtSlot() def stopVNC(self, force=False): @@ -304,7 +309,7 @@ class Backend(QObject): if self._vncState in (self.VNCState.WAITING, self.VNCState.CONNECTED): self.vncServer.close() else: - self.onError.emit("stopVNC called while it is not running") + self.promptError("stopVNC called while it is not running") @pyqtSlot() def clearCache(self): diff --git a/virtscreen/xrandr.py b/virtscreen/xrandr.py index d794454..e0e5da7 100644 --- a/virtscreen/xrandr.py +++ b/virtscreen/xrandr.py @@ -3,6 +3,7 @@ import re import atexit import subprocess +import logging from typing import List from .display import Display @@ -53,9 +54,9 @@ class XRandR(SubprocessWrapper): screen.height = int(match.group(7)) screen.x_offset = int(match.group(8)) screen.y_offset = int(match.group(9)) - print("Display information:") + logging.info("Display information:") for s in self.screens: - print("\t", s) + logging.info(f"\t{s}") if self.primary_idx is None: raise RuntimeError("There is no primary screen detected.\n" "Go to display settings and set\n" @@ -108,7 +109,7 @@ class XRandR(SubprocessWrapper): def create_virtual_screen(self, width, height, portrait=False, hidpi=False, pos='') -> None: self._update_screens() - print("creating: ", self.virt) + logging.info(f"creating: {self.virt}") self._add_screen_mode(width, height, portrait, hidpi) arg_pos = ['left', 'right', 'above', 'below'] xrandr_pos = ['--left-of', '--right-of', '--above', '--below']