From 8f14d1c7fbb14d50e18880c5b17f3e62686ce2cc Mon Sep 17 00:00:00 2001
From: DarkCoderSc
Date: Sun, 18 Aug 2024 12:37:38 +0200
Subject: [PATCH 1/5] Progress on spreading Python type hinting everywhere
---
arcane_viewer/arcane/client.py | 18 +++----
arcane_viewer/arcane/constants.py | 6 +--
arcane_viewer/arcane/exceptions.py | 2 +-
arcane_viewer/arcane/protocol.py | 4 +-
arcane_viewer/arcane/screen.py | 6 +--
arcane_viewer/arcane/session.py | 14 +++---
arcane_viewer/arcane/threads/client_base.py | 8 ++--
arcane_viewer/arcane/threads/connect.py | 4 +-
arcane_viewer/arcane/threads/events.py | 12 ++---
arcane_viewer/arcane/threads/v_desktop.py | 10 ++--
arcane_viewer/main.py | 2 +-
.../ui/custom_widgets/tangeant_universe.py | 35 +++++++-------
arcane_viewer/ui/dialogs/about.py | 13 ++---
arcane_viewer/ui/dialogs/connecting.py | 13 +++--
arcane_viewer/ui/dialogs/options.py | 48 ++++++++++---------
.../server_certificate_add_or_edit.py | 17 +++++--
arcane_viewer/ui/dialogs/screen_selection.py | 12 +++--
.../ui/dialogs/server_certificate.py | 15 ++++--
arcane_viewer/ui/forms/connect.py | 20 ++++----
arcane_viewer/ui/forms/desktop.py | 34 ++++++-------
arcane_viewer/ui/utilities.py | 2 +-
setup.py | 2 +-
22 files changed, 164 insertions(+), 133 deletions(-)
diff --git a/arcane_viewer/arcane/client.py b/arcane_viewer/arcane/client.py
index 289036f..488caeb 100644
--- a/arcane_viewer/arcane/client.py
+++ b/arcane_viewer/arcane/client.py
@@ -38,7 +38,7 @@
class Client:
""" Client class to handle secure communication with remote server """
- def __init__(self, server_address: str, server_port: int, password: str):
+ def __init__(self, server_address: str, server_port: int, password: str) -> None:
logger.info("Connecting to remote server: `{}:{}`...".format(
server_address,
server_port
@@ -78,10 +78,10 @@ def __init__(self, server_address: str, server_port: int, password: str):
logger.info("Authentication successful")
- def __del__(self):
+ def __del__(self) -> None:
self.close()
- def read_line(self):
+ def read_line(self) -> str:
data = b""
while True:
@@ -99,19 +99,19 @@ def read_line(self):
return data.decode('utf-8').strip()
- def write_line(self, line: str):
+ def write_line(self, line: str) -> None:
self.conn.write(line.encode('utf-8') + b'\r\n')
- def read_json(self):
+ def read_json(self) -> dict:
try:
return json.loads(self.read_line())
except json.JSONDecodeError:
- return None
+ return {}
- def write_json(self, data: dict):
+ def write_json(self, data: dict) -> None:
self.write_line(json.dumps(data))
- def authenticate(self, password: str):
+ def authenticate(self, password: str) -> None:
logger.debug("Request challenge...")
challenge = self.read_line()
@@ -137,7 +137,7 @@ def authenticate(self, password: str):
arcane.ArcaneProtocolError.AuthenticationFailed
)
- def close(self):
+ def close(self) -> None:
if self.client is not None:
logger.info(f"[{self.conn.fileno()}] Closing connection...")
try:
diff --git a/arcane_viewer/arcane/constants.py b/arcane_viewer/arcane/constants.py
index 32d85b8..8fc2e57 100644
--- a/arcane_viewer/arcane/constants.py
+++ b/arcane_viewer/arcane/constants.py
@@ -20,19 +20,19 @@
if sys.version_info < (3, 9):
import pkg_resources
- def get_asset_file(asset_name):
+ def get_asset_file(asset_name: str) -> str:
return pkg_resources.resource_filename(ASSETS_IDENTIFIER, asset_name)
else:
import importlib.resources as resources
- def get_asset_file(asset_name):
+ def get_asset_file(asset_name: str) -> str:
with resources.files(ASSETS_IDENTIFIER) / asset_name as asset_path:
return str(asset_path)
# Application Information
-APP_VERSION = "1.0.4"
+APP_VERSION = "1.0.5"
APP_NAME = "Arcane"
APP_ORGANIZATION_NAME = "Phrozen"
APP_DISPLAY_NAME = f"{APP_NAME} {APP_VERSION} (βeta)"
diff --git a/arcane_viewer/arcane/exceptions.py b/arcane_viewer/arcane/exceptions.py
index bc1b829..d7007e1 100644
--- a/arcane_viewer/arcane/exceptions.py
+++ b/arcane_viewer/arcane/exceptions.py
@@ -25,7 +25,7 @@ class ArcaneProtocolError(Enum):
class ArcaneProtocolException(Exception):
- def __init__(self, reason: ArcaneProtocolError):
+ def __init__(self, reason: ArcaneProtocolError) -> None:
self.reason = reason
error_messages = {
diff --git a/arcane_viewer/arcane/protocol.py b/arcane_viewer/arcane/protocol.py
index c9efc26..2d50dec 100644
--- a/arcane_viewer/arcane/protocol.py
+++ b/arcane_viewer/arcane/protocol.py
@@ -98,7 +98,7 @@ class PacketSize(Enum):
Size16384 = 16384
@property
- def display_name(self):
+ def display_name(self) -> str:
return f"{self.value} bytes"
@@ -111,5 +111,5 @@ class BlockSize(Enum):
Size512 = 512
@property
- def display_name(self):
+ def display_name(self) -> str:
return f"{self.value}x{self.value}"
diff --git a/arcane_viewer/arcane/screen.py b/arcane_viewer/arcane/screen.py
index adaf25d..97c1892 100644
--- a/arcane_viewer/arcane/screen.py
+++ b/arcane_viewer/arcane/screen.py
@@ -16,7 +16,7 @@
class Screen:
""" Screen class to store screen information """
- def __init__(self, screen_information_json: dict):
+ def __init__(self, screen_information_json: dict) -> None:
if not all(k in screen_information_json for k in (
"Id",
"Name",
@@ -36,7 +36,7 @@ def __init__(self, screen_information_json: dict):
self.y = screen_information_json["Y"]
self.primary = screen_information_json["Primary"]
- def get_display_name(self):
+ def get_display_name(self) -> str:
return "#{} - {} ({}x{})".format(
self.id,
self.name,
@@ -44,5 +44,5 @@ def get_display_name(self):
self.height
)
- def size(self):
+ def size(self) -> QSize:
return QSize(self.width, self.height)
diff --git a/arcane_viewer/arcane/session.py b/arcane_viewer/arcane/session.py
index 8d9de63..e783d5d 100644
--- a/arcane_viewer/arcane/session.py
+++ b/arcane_viewer/arcane/session.py
@@ -13,6 +13,7 @@
import json
import logging
+from typing import Optional
from PyQt6.QtCore import QSettings
@@ -23,15 +24,16 @@
class Session:
""" Session class to handle remote session """
- def __init__(self, server_address: str, server_port: int, password: str):
+ def __init__(self, server_address: str, server_port: int, password: str) -> None:
self.server_address = server_address
self.server_port = server_port
self.__password = password
- self.session_id = None
- self.display_name = None
self.presentation = False
- self.server_fingerprint = None
+
+ self.session_id: Optional[str] = None
+ self.display_name: Optional[str] = None
+ self.server_fingerprint: Optional[str] = None
# Load settings (options)
settings = QSettings(arcane.APP_ORGANIZATION_NAME, arcane.APP_NAME)
@@ -46,7 +48,7 @@ def __init__(self, server_address: str, server_port: int, password: str):
self.request_session()
- def claim_client(self, worker_kind: arcane.WorkerKind = None):
+ def claim_client(self, worker_kind: Optional[arcane.WorkerKind] = None) -> arcane.Client:
""" Establish a new TLS connection to the remote server and authenticate. Optionally we can specify a worker
to be attached to the current session """
client = arcane.Client(self.server_address, self.server_port, self.__password)
@@ -73,7 +75,7 @@ def claim_client(self, worker_kind: arcane.WorkerKind = None):
return client
- def request_session(self):
+ def request_session(self) -> None:
""" Request a new session to the remote server """
client = self.claim_client()
try:
diff --git a/arcane_viewer/arcane/threads/client_base.py b/arcane_viewer/arcane/threads/client_base.py
index c7cfbf0..37e90d0 100644
--- a/arcane_viewer/arcane/threads/client_base.py
+++ b/arcane_viewer/arcane/threads/client_base.py
@@ -27,7 +27,7 @@ class ClientBaseThread(QThread):
"""`Destruction is a form of creation. So the fact they burn the money is ironic. They just want to see what happens
when they tear the world apart. They want to change things.`, Donnie Darko"""
- def __init__(self, session: arcane.Session, worker_kind: arcane.WorkerKind):
+ def __init__(self, session: arcane.Session, worker_kind: arcane.WorkerKind) -> None:
super().__init__()
self._running = True
@@ -36,7 +36,7 @@ def __init__(self, session: arcane.Session, worker_kind: arcane.WorkerKind):
self.client = None
self.worker_kind = worker_kind
- def run(self):
+ def run(self) -> None:
on_error = False
try:
self.client = self.session.claim_client(self.worker_kind)
@@ -60,11 +60,11 @@ def run(self):
self.thread_finished.emit(on_error)
@abstractmethod
- def client_execute(self):
+ def client_execute(self) -> None:
pass
@pyqtSlot()
- def stop(self):
+ def stop(self) -> None:
self._running = False
if self.client is not None:
diff --git a/arcane_viewer/arcane/threads/connect.py b/arcane_viewer/arcane/threads/connect.py
index f1379c0..447c4ee 100644
--- a/arcane_viewer/arcane/threads/connect.py
+++ b/arcane_viewer/arcane/threads/connect.py
@@ -27,14 +27,14 @@ class ConnectThread(QThread):
thread_finished = pyqtSignal(object)
session_error = pyqtSignal(str)
- def __init__(self, server_address: str, server_port: int, password: str):
+ def __init__(self, server_address: str, server_port: int, password: str) -> None:
super().__init__()
self.server_address = server_address
self.server_port = server_port
self.__password = password
- def run(self):
+ def run(self) -> None:
session = None
self.thread_started.emit()
diff --git a/arcane_viewer/arcane/threads/events.py b/arcane_viewer/arcane/threads/events.py
index 4e849ab..49098a4 100644
--- a/arcane_viewer/arcane/threads/events.py
+++ b/arcane_viewer/arcane/threads/events.py
@@ -27,10 +27,10 @@ class EventsThread(ClientBaseThread):
update_mouse_cursor = pyqtSignal(Qt.CursorShape)
update_clipboard = pyqtSignal(str)
- def __init__(self, session: arcane.Session):
+ def __init__(self, session: arcane.Session) -> None:
super().__init__(session, arcane.WorkerKind.Events)
- def client_execute(self):
+ def client_execute(self) -> None:
""" Execute the client thread """
while self._running:
try:
@@ -91,7 +91,7 @@ def client_execute(self):
self.update_clipboard.emit(event["Text"])
@pyqtSlot(int, int, arcane.MouseState, arcane.MouseButton)
- def send_mouse_event(self, x: int, y: int, state: arcane.MouseState, button: arcane.MouseButton):
+ def send_mouse_event(self, x: int, y: int, state: arcane.MouseState, button: arcane.MouseButton) -> None:
""" Send mouse event to the server """
if self.session.presentation:
return
@@ -108,7 +108,7 @@ def send_mouse_event(self, x: int, y: int, state: arcane.MouseState, button: arc
)
@pyqtSlot(str)
- def send_key_event(self, keys: str):
+ def send_key_event(self, keys: str) -> None:
""" Send keyboard event to the server """
if self.session.presentation:
return
@@ -122,7 +122,7 @@ def send_key_event(self, keys: str):
)
@pyqtSlot(int)
- def send_mouse_wheel_event(self, delta: int):
+ def send_mouse_wheel_event(self, delta: int) -> None:
""" Send mouse wheel event to the server """
if self.session.presentation:
return
@@ -136,7 +136,7 @@ def send_mouse_wheel_event(self, delta: int):
)
@pyqtSlot(str)
- def send_clipboard_text(self, text: str):
+ def send_clipboard_text(self, text: str) -> None:
""" Send clipboard text to the server """
if self.session.clipboard_mode in {
arcane.ClipboardMode.Disabled, arcane.ClipboardMode.Receive
diff --git a/arcane_viewer/arcane/threads/v_desktop.py b/arcane_viewer/arcane/threads/v_desktop.py
index 88c0f30..a6f5771 100644
--- a/arcane_viewer/arcane/threads/v_desktop.py
+++ b/arcane_viewer/arcane/threads/v_desktop.py
@@ -34,7 +34,7 @@ class VirtualDesktopThread(ClientBaseThread):
request_screen_selection = pyqtSignal(list)
chunk_received = pyqtSignal(QImage, int, int)
- def __init__(self, session: arcane.Session):
+ def __init__(self, session: arcane.Session) -> None:
super().__init__(session, arcane.WorkerKind.Desktop)
self.selected_screen = None
@@ -42,7 +42,7 @@ def __init__(self, session: arcane.Session):
"""`Destruction is a form of creation. So the fact they burn the money is ironic. They just want to see what happens
when they tear the world apart. They want to change things.`, Donnie Darko"""
- def client_execute(self):
+ def client_execute(self) -> None:
screens_obj = self.client.read_json()
screens = [arcane.Screen(screen) for screen in screens_obj["List"]]
logger.info(f"{len(screens)} screen(s) detected")
@@ -97,13 +97,13 @@ def client_execute(self):
y,
)
- def stop(self):
+ def stop(self) -> None:
super().stop()
if self.event_loop is not None:
self.event_loop.quit()
- def display_screen_selection_dialog(self, screens: List[arcane.Screen]):
+ def display_screen_selection_dialog(self, screens: List[arcane.Screen]) -> None:
self.event_loop = QEventLoop()
self.request_screen_selection.emit(screens)
@@ -111,7 +111,7 @@ def display_screen_selection_dialog(self, screens: List[arcane.Screen]):
self.event_loop.exec()
@pyqtSlot(arcane.Screen)
- def on_screen_selection_dialog_closed(self, screen: arcane.Screen):
+ def on_screen_selection_dialog_closed(self, screen: arcane.Screen) -> None:
if self.event_loop is not None:
self.event_loop.quit()
self.event_loop = None
diff --git a/arcane_viewer/main.py b/arcane_viewer/main.py
index d89243d..90c7cc1 100644
--- a/arcane_viewer/main.py
+++ b/arcane_viewer/main.py
@@ -27,7 +27,7 @@
import arcane_viewer.ui.forms as arcane_forms
-def main():
+def main() -> None:
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
app = QApplication(sys.argv)
diff --git a/arcane_viewer/ui/custom_widgets/tangeant_universe.py b/arcane_viewer/ui/custom_widgets/tangeant_universe.py
index f146a68..09edc22 100644
--- a/arcane_viewer/ui/custom_widgets/tangeant_universe.py
+++ b/arcane_viewer/ui/custom_widgets/tangeant_universe.py
@@ -21,10 +21,11 @@
from sys import platform
from PyQt6.QtCore import Qt, pyqtSlot
-from PyQt6.QtGui import QClipboard
+from PyQt6.QtGui import QClipboard, QKeyEvent, QMouseEvent, QWheelEvent
from PyQt6.QtWidgets import QApplication, QGraphicsScene, QGraphicsView
import arcane_viewer.arcane as arcane
+import arcane_viewer.arcane.threads as arcane_threads
logger = logging.getLogger(__name__)
@@ -35,7 +36,7 @@ class TangentUniverse(QGraphicsView):
diverging veil? When the cosmic mirror distorts, do you walk the ordained spiral or the fragmented loop of the
twilight realm?`"""
- def __init__(self):
+ def __init__(self) -> None:
super().__init__()
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
@@ -52,18 +53,18 @@ def __init__(self):
self.clipboard = QApplication.clipboard()
self.clipboard.dataChanged.connect(self.clipboard_data_changed)
- def set_event_thread(self, events_thread):
+ def set_event_thread(self, events_thread: arcane_threads.EventsThread) -> None:
""" Set the events thread """
self.events_thread = events_thread
self.events_thread.update_mouse_cursor.connect(self.update_mouse_cursor)
self.events_thread.update_clipboard.connect(self.update_clipboard)
- def set_screen(self, screen: arcane.Screen):
+ def set_screen(self, screen: arcane.Screen) -> None:
""" Set the captured screen original information """
self.screen = screen
- def fix_mouse_position(self, x, y):
+ def fix_mouse_position(self, x: int, y: int) -> tuple[int, int]:
""" Fix the virtual desktop mouse position to the original screen position """
if self.screen is None:
return x, y
@@ -82,7 +83,7 @@ def fix_mouse_position(self, x, y):
return (self.screen.x + (x * x_ratio),
self.screen.y + (y * y_ratio))
- def send_mouse_event(self, x: int, y: int, state: arcane.MouseState, button: arcane.MouseButton):
+ def send_mouse_event(self, x: int, y: int, state: arcane.MouseState, button: arcane.MouseButton) -> None:
""" Push mouse event to the events thread """
self.events_thread.send_mouse_event(
x,
@@ -91,7 +92,7 @@ def send_mouse_event(self, x: int, y: int, state: arcane.MouseState, button: arc
button
)
- def mouse_action_handler(self, event, is_pressed):
+ def mouse_action_handler(self, event: QMouseEvent, is_pressed: bool) -> None:
""" Handle mouse press and release events """
if self.events_thread is None:
@@ -110,24 +111,24 @@ def mouse_action_handler(self, event, is_pressed):
self.send_mouse_event(x, y, arcane.MouseState.Down if is_pressed else arcane.MouseState.Up, mouse_button)
- def mouse_click(self, event):
+ def mouse_click(self, event: QMouseEvent) -> None:
""" Simulate mouse click event """
self.mouse_action_handler(event, True)
self.mouse_action_handler(event, False)
- def mousePressEvent(self, event):
+ def mousePressEvent(self, event: QMouseEvent) -> None:
self.mouse_action_handler(event, True)
- def mouseReleaseEvent(self, event):
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None:
self.mouse_action_handler(event, False)
- def mouseDoubleClickEvent(self, event):
+ def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
""" Override mouseDoubleClickEvent method to simulate a remote double click event
Do something better than this is possible? (cross-platform) """
self.mouse_click(event)
self.mouse_click(event)
- def mouseMoveEvent(self, event):
+ def mouseMoveEvent(self, event: QMouseEvent) -> None:
""" Override mouseMoveEvent method to handle mouse move events """
if self.events_thread is None:
return
@@ -137,7 +138,7 @@ def mouseMoveEvent(self, event):
self.send_mouse_event(x, y, arcane.MouseState.Move, arcane.MouseButton.Void)
- def clipboard_data_changed(self):
+ def clipboard_data_changed(self) -> None:
""" Handle clipboard data changed event """
text = self.clipboard.text(QClipboard.Mode.Clipboard)
@@ -145,7 +146,7 @@ def clipboard_data_changed(self):
text
)
- def keyPressEvent(self, event):
+ def keyPressEvent(self, event: QKeyEvent) -> None:
""" Override keyPressEvent method to handle key press events """
if self.events_thread is None:
return
@@ -253,7 +254,7 @@ def keyPressEvent(self, event):
self.events_thread.send_key_event(key_text)
- def wheelEvent(self, event):
+ def wheelEvent(self, event: QWheelEvent) -> None:
""" Override wheelEvent method to handle mouse wheel events """
if self.events_thread is None:
return
@@ -263,9 +264,9 @@ def wheelEvent(self, event):
self.events_thread.send_mouse_wheel_event(delta)
@pyqtSlot(Qt.CursorShape)
- def update_mouse_cursor(self, cursor):
+ def update_mouse_cursor(self, cursor: Qt.CursorShape) -> None:
self.setCursor(cursor)
@pyqtSlot(str)
- def update_clipboard(self, text):
+ def update_clipboard(self, text: str) -> None:
self.clipboard.setText(text)
diff --git a/arcane_viewer/ui/dialogs/about.py b/arcane_viewer/ui/dialogs/about.py
index f5cf12c..d45003d 100644
--- a/arcane_viewer/ui/dialogs/about.py
+++ b/arcane_viewer/ui/dialogs/about.py
@@ -12,18 +12,19 @@
"""
import sys
+from typing import Optional, Union
from PyQt6.QtCore import QT_VERSION_STR, QSize, Qt
-from PyQt6.QtGui import QIcon
-from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPushButton,
- QVBoxLayout)
+from PyQt6.QtGui import QIcon, QShowEvent
+from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QMainWindow,
+ QPushButton, QVBoxLayout)
import arcane_viewer.arcane as arcane
import arcane_viewer.ui.utilities as utilities
class AboutWindow(QDialog, utilities.CenterWindow):
- def __init__(self, parent):
+ def __init__(self, parent: Optional[Union[QDialog, QMainWindow]] = None) -> None:
super().__init__(parent)
self.setWindowTitle(f"About {arcane.APP_DISPLAY_NAME}")
@@ -74,9 +75,9 @@ def __init__(self, parent):
self.adjust_size()
- def adjust_size(self):
+ def adjust_size(self) -> None:
self.setFixedSize(380, self.sizeHint().height())
- def showEvent(self, event):
+ def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
self.center_on_owner(self.parent())
diff --git a/arcane_viewer/ui/dialogs/connecting.py b/arcane_viewer/ui/dialogs/connecting.py
index c793dc1..c143eac 100644
--- a/arcane_viewer/ui/dialogs/connecting.py
+++ b/arcane_viewer/ui/dialogs/connecting.py
@@ -11,17 +11,20 @@
www.phrozen.io
"""
+from typing import Optional, Union
+
from PyQt6.QtCore import QSize, Qt
-from PyQt6.QtGui import QIcon
-from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QProgressBar,
- QSizePolicy, QSpacerItem, QVBoxLayout)
+from PyQt6.QtGui import QIcon, QShowEvent
+from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QMainWindow,
+ QProgressBar, QSizePolicy, QSpacerItem,
+ QVBoxLayout)
import arcane_viewer.arcane as arcane
import arcane_viewer.ui.utilities as utilities
class ConnectingWindow(QDialog, utilities.CenterWindow):
- def __init__(self, parent):
+ def __init__(self, parent: Optional[Union[QDialog, QMainWindow]] = None) -> None:
super().__init__(parent)
self.setWindowTitle("📡 Connecting...")
@@ -57,6 +60,6 @@ def __init__(self, parent):
spacer_bottom = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
info_layout.addItem(spacer_bottom)
- def showEvent(self, event):
+ def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
self.center_on_owner(self.parent())
diff --git a/arcane_viewer/ui/dialogs/options.py b/arcane_viewer/ui/dialogs/options.py
index 4bf1e8d..8c0fbc0 100644
--- a/arcane_viewer/ui/dialogs/options.py
+++ b/arcane_viewer/ui/dialogs/options.py
@@ -11,12 +11,14 @@
www.phrozen.io
"""
-from PyQt6.QtCore import QSettings, Qt
-from PyQt6.QtGui import QStandardItem, QStandardItemModel
+from typing import Optional, Union
+
+from PyQt6.QtCore import QModelIndex, QSettings, Qt
+from PyQt6.QtGui import QShowEvent, QStandardItem, QStandardItemModel
from PyQt6.QtWidgets import (QComboBox, QDialog, QGridLayout, QGroupBox,
- QHBoxLayout, QLabel, QMessageBox, QPushButton,
- QSizePolicy, QSpacerItem, QSpinBox, QTabWidget,
- QTreeView, QVBoxLayout, QWidget)
+ QHBoxLayout, QLabel, QMainWindow, QMessageBox,
+ QPushButton, QSizePolicy, QSpacerItem, QSpinBox,
+ QTabWidget, QTreeView, QVBoxLayout, QWidget)
import arcane_viewer.arcane as arcane
import arcane_viewer.ui.utilities as utilities
@@ -26,7 +28,7 @@
class RemoteDesktopOptionsTab(QWidget):
""" Remote Desktop Options Tab """
- def __init__(self, settings: QSettings):
+ def __init__(self, settings: QSettings) -> None:
super().__init__()
self.settings = settings
@@ -88,7 +90,7 @@ def __init__(self, settings: QSettings):
core_layout.addItem(QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding))
- def load_settings(self):
+ def load_settings(self) -> None:
""" Load remote desktop settings from the settings """
# Load Options
self.clipboard_sharing_combobox.setCurrentIndex(
@@ -112,7 +114,7 @@ def load_settings(self):
)
)
- def save_settings(self):
+ def save_settings(self) -> None:
""" Save remote desktop settings to the settings """
# Save Options
self.settings.setValue(arcane.SETTINGS_KEY_CLIPBOARD_MODE, self.clipboard_sharing_combobox.currentData())
@@ -125,7 +127,7 @@ def save_settings(self):
class TrustedCertificateModel(QStandardItemModel):
""" Trusted Certificate Model (Disables editing of the fingerprint) """
- def flags(self, index):
+ def flags(self, index: QModelIndex) -> Qt.ItemFlag:
if index.column() == 1: # Fingerprint
return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled
@@ -134,7 +136,7 @@ def flags(self, index):
class TrustedCertificatesOptionsTab(QWidget):
""" Trusted Certificates Options Tab """
- def __init__(self, settings: QSettings):
+ def __init__(self, settings: QSettings) -> None:
super().__init__()
self.settings = settings
@@ -173,7 +175,7 @@ def __init__(self, settings: QSettings):
action_buttons_layout.addItem(QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding))
- def add_or_edit_row(self, fingerprint: str, display_name: str, description: str):
+ def add_or_edit_row(self, fingerprint: str, display_name: str, description: str) -> None:
""" Add a certificate to the list """
# Edit row if it already exists
for i in range(self.model.rowCount()):
@@ -190,13 +192,13 @@ def add_or_edit_row(self, fingerprint: str, display_name: str, description: str)
QStandardItem(description),
])
- def tree_view_selection_changed(self):
+ def tree_view_selection_changed(self) -> None:
""" Update the state of the action buttons based on the selection """
b = self.tree_view.currentIndex().isValid()
self.edit_button.setEnabled(b)
self.remove_button.setEnabled(b)
- def remove_button_clicked(self):
+ def remove_button_clicked(self) -> None:
""" Remove the selected certificate from the list """
selected_index = self.tree_view.currentIndex()
if not selected_index.isValid():
@@ -204,7 +206,7 @@ def remove_button_clicked(self):
self.model.removeRow(selected_index.row())
- def add_or_edit_certificate(self, edit_selected: bool):
+ def add_or_edit_certificate(self, edit_selected: bool) -> None:
""" Add or edit a certificate """
fingerprint = None
if edit_selected and self.tree_view.currentIndex().isValid():
@@ -220,7 +222,7 @@ def add_or_edit_certificate(self, edit_selected: bool):
dialog.description_edit.toPlainText()
)
- def load_settings(self):
+ def load_settings(self) -> None:
""" Load trusted certificates from the settings """
# First we clear the list
self.model.clear()
@@ -253,7 +255,7 @@ def load_settings(self):
for i in range(self.model.columnCount()):
self.tree_view.resizeColumnToContents(i)
- def save_settings(self):
+ def save_settings(self) -> None:
""" Save trusted certificates to the settings """
certificates = []
for i in range(self.model.rowCount()):
@@ -279,7 +281,7 @@ def save_settings(self):
class OptionsDialog(QDialog, utilities.CenterWindow):
""" Arcane Options Dialog """
- def __init__(self, parent):
+ def __init__(self, parent: Optional[Union[QDialog, QMainWindow]] = None) -> None:
super().__init__(parent)
self.settings = QSettings(arcane.APP_ORGANIZATION_NAME, arcane.APP_NAME)
@@ -333,17 +335,17 @@ def __init__(self, parent):
self.adjust_size()
- def load_settings(self):
+ def load_settings(self) -> None:
self.remote_desktop_tab.load_settings()
self.trusted_certificates_tab.load_settings()
- def save_settings(self):
+ def save_settings(self) -> None:
self.remote_desktop_tab.save_settings()
self.trusted_certificates_tab.save_settings()
self.accept()
- def reset_settings(self):
+ def reset_settings(self) -> None:
if QMessageBox.question(
self,
"Reset Settings",
@@ -353,10 +355,12 @@ def reset_settings(self):
self.load_settings()
- def adjust_size(self):
+ def adjust_size(self) -> None:
self.setFixedSize(420, self.sizeHint().height())
- def showEvent(self, event):
+ def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
+ self.center_on_owner(self.parent())
+
self.load_settings()
diff --git a/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py b/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py
index 6e7c3b7..ffeaebf 100644
--- a/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py
+++ b/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py
@@ -12,18 +12,21 @@
"""
import re
+from typing import Optional, Union
from PyQt6.QtCore import QSettings
+from PyQt6.QtGui import QShowEvent
from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QLineEdit,
- QMessageBox, QPushButton, QSizePolicy,
- QSpacerItem, QTextEdit, QVBoxLayout)
+ QMainWindow, QMessageBox, QPushButton,
+ QSizePolicy, QSpacerItem, QTextEdit, QVBoxLayout)
import arcane_viewer.arcane as arcane
import arcane_viewer.ui.utilities as utilities
class ServerCertificateAddOrEditDialog(QDialog, utilities.CenterWindow):
- def __init__(self, parent, settings: QSettings, fingerprint: str = None):
+ def __init__(self, parent: Optional[Union[QDialog, QMainWindow]], settings: QSettings, fingerprint: str = None)\
+ -> None:
super().__init__(parent)
self.settings = settings
@@ -91,7 +94,7 @@ def __init__(self, parent, settings: QSettings, fingerprint: str = None):
self.adjust_size()
- def save_or_update_certificate(self):
+ def save_or_update_certificate(self) -> None:
""" Save or update the certificate information """
if self.fingerprint is None:
fingerprint = self.fingerprint_edit.text().upper()
@@ -108,5 +111,9 @@ def save_or_update_certificate(self):
self.accept()
- def adjust_size(self):
+ def adjust_size(self) -> None:
self.setFixedSize(400, self.sizeHint().height())
+
+ def showEvent(self, event: QShowEvent) -> None:
+ super().showEvent(event)
+ self.center_on_owner(self.parent())
diff --git a/arcane_viewer/ui/dialogs/screen_selection.py b/arcane_viewer/ui/dialogs/screen_selection.py
index 701680e..4fdb71e 100644
--- a/arcane_viewer/ui/dialogs/screen_selection.py
+++ b/arcane_viewer/ui/dialogs/screen_selection.py
@@ -11,16 +11,20 @@
www.phrozen.io
"""
+from typing import List, Optional, Union
+
from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QShowEvent
from PyQt6.QtWidgets import (QComboBox, QDialog, QHBoxLayout, QLabel,
- QPushButton, QVBoxLayout)
+ QMainWindow, QPushButton, QVBoxLayout)
+import arcane_viewer.arcane as arcane
import arcane_viewer.ui.utilities as utilities
class ScreenSelectionWindow(QDialog, utilities.CenterWindow):
""" Screen Selection Dialog """
- def __init__(self, parent, screens):
+ def __init__(self, parent: Optional[Union[QDialog, QMainWindow]], screens: List[arcane.Screen]) -> None:
super().__init__(parent)
self.screens = screens
@@ -70,10 +74,10 @@ def __init__(self, parent, screens):
self.setFixedSize(290, self.sizeHint().height())
- def showEvent(self, event):
+ def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
self.center_on_owner(self.parent())
- def get_selected_screen(self):
+ def get_selected_screen(self) -> arcane.Screen:
""" Get the user-choice selected screen """
return [screen for screen in self.screens if screen.name == self.screen_selection_combobox.currentData()][0]
diff --git a/arcane_viewer/ui/dialogs/server_certificate.py b/arcane_viewer/ui/dialogs/server_certificate.py
index 95ac750..139b6ba 100644
--- a/arcane_viewer/ui/dialogs/server_certificate.py
+++ b/arcane_viewer/ui/dialogs/server_certificate.py
@@ -11,15 +11,18 @@
www.phrozen.io
"""
+from typing import Optional, Union
+
from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QShowEvent
from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QLabel,
- QPushButton, QVBoxLayout)
+ QMainWindow, QPushButton, QVBoxLayout)
import arcane_viewer.ui.utilities as utilities
class ServerCertificateDialog(QDialog, utilities.CenterWindow):
- def __init__(self, parent, fingerprint: str):
+ def __init__(self, parent: Optional[Union[QDialog, QMainWindow]], fingerprint: str) -> None:
super().__init__(parent)
self.setWindowTitle("Unknown Server Certificate")
@@ -75,7 +78,7 @@ def __init__(self, parent, fingerprint: str):
self.adjust_size()
- def setup_fingerprint_layout(self, start: int, end: int):
+ def setup_fingerprint_layout(self, start: int, end: int) -> QHBoxLayout:
layout = QHBoxLayout()
line = [self.fingerprint[i:i + 2] for i in range(start, end, 2)]
@@ -92,8 +95,12 @@ def setup_fingerprint_layout(self, start: int, end: int):
return layout
- def adjust_size(self):
+ def adjust_size(self) -> None:
self.setFixedSize(
self.sizeHint().width(),
self.sizeHint().height()
)
+
+ def showEvent(self, event: QShowEvent) -> None:
+ super().showEvent(event)
+ self.center_on_owner(self.parent())
diff --git a/arcane_viewer/ui/forms/connect.py b/arcane_viewer/ui/forms/connect.py
index cc556a4..2acca49 100644
--- a/arcane_viewer/ui/forms/connect.py
+++ b/arcane_viewer/ui/forms/connect.py
@@ -16,7 +16,7 @@
import socket
from PyQt6.QtCore import QSettings, QSize, Qt, pyqtSlot
-from PyQt6.QtGui import QIcon
+from PyQt6.QtGui import QIcon, QShowEvent
from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QLineEdit,
QMainWindow, QMessageBox, QPushButton, QSpinBox,
QVBoxLayout, QWidget)
@@ -31,7 +31,7 @@
class ConnectWindow(QMainWindow, utilities.CenterWindow):
""" Connect Window to establish a connection to the server """
- def __init__(self):
+ def __init__(self) -> None:
super().__init__()
self.__connect_thread = None
@@ -131,11 +131,11 @@ def __init__(self):
self.adjust_size()
- def showEvent(self, event):
+ def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
self.center_on_owner()
- def read_default(self):
+ def read_default(self) -> None:
""" Read default settings from the default.json file """
if not os.path.isfile(arcane.DEFAULT_JSON):
return
@@ -158,7 +158,7 @@ def read_default(self):
if "server_password" in data:
self.password_input.setText(data["server_password"])
- def submit_form(self):
+ def submit_form(self) -> None:
""" Validate the form and submit it """
try:
# Check if the ip/hostname is valid
@@ -189,24 +189,24 @@ def submit_form(self):
except Exception as e:
QMessageBox.critical(self, "Form Error", str(e))
- def adjust_size(self):
+ def adjust_size(self) -> None:
self.setFixedSize(350, self.sizeHint().height())
- def show_about_dialog(self):
+ def show_about_dialog(self) -> None:
about_window = arcane_dialogs.AboutWindow(self)
about_window.exec()
@pyqtSlot(str)
- def session_error(self, error_message):
+ def session_error(self, error_message: str) -> None:
QMessageBox.critical(self, "Error", error_message)
@pyqtSlot()
- def connect_thread_started(self):
+ def connect_thread_started(self) -> None:
self.__connecting_form = arcane_dialogs.ConnectingWindow(self)
self.__connecting_form.exec()
@pyqtSlot(object)
- def connect_thread_finished(self, session: arcane.Session = None):
+ def connect_thread_finished(self, session: arcane.Session = None) -> None:
# Close the connecting form if it is still open
if self.__connecting_form is not None and self.__connecting_form.isVisible():
self.__connecting_form.close()
diff --git a/arcane_viewer/ui/forms/desktop.py b/arcane_viewer/ui/forms/desktop.py
index c581647..0f06ee6 100644
--- a/arcane_viewer/ui/forms/desktop.py
+++ b/arcane_viewer/ui/forms/desktop.py
@@ -16,11 +16,13 @@
"""
import logging
+from typing import List, Union
from PyQt6.QtCore import QRect, QSize, Qt, pyqtSlot
-from PyQt6.QtGui import QImage, QPainter, QPixmap, QTransform
-from PyQt6.QtWidgets import (QApplication, QGraphicsPixmapItem, QMainWindow,
- QMessageBox)
+from PyQt6.QtGui import (QCloseEvent, QImage, QPainter, QPixmap, QResizeEvent,
+ QShowEvent, QTransform)
+from PyQt6.QtWidgets import (QApplication, QDialog, QGraphicsPixmapItem,
+ QMainWindow, QMessageBox)
import arcane_viewer.arcane as arcane
import arcane_viewer.arcane.threads as arcane_threads
@@ -31,7 +33,7 @@
class DesktopWindow(QMainWindow):
- def __init__(self, parent, session):
+ def __init__(self, parent: Union[QDialog, QMainWindow], session: arcane.Session) -> None:
super().__init__()
self.tangent_universe = None
@@ -64,7 +66,7 @@ def __init__(self, parent, session):
self.start_desktop_thread()
- def thread_finished(self, on_error):
+ def thread_finished(self, on_error: bool) -> None:
""" Handle the thread finished event """
if on_error:
QMessageBox.critical(self, "Error", "Something went wrong, check console output for more information.")
@@ -75,7 +77,7 @@ def thread_finished(self, on_error):
self.close()
- def start_desktop_thread(self):
+ def start_desktop_thread(self) -> None:
""" Start the desktop thread to handle remote desktop streaming """
self.desktop_thread = arcane_threads.VirtualDesktopThread(self.session)
self.desktop_thread.chunk_received.connect(self.update_scene)
@@ -84,7 +86,7 @@ def start_desktop_thread(self):
self.desktop_thread.request_screen_selection.connect(self.display_screen_selection_dialog)
self.desktop_thread.start()
- def start_events_thread(self, screen):
+ def start_events_thread(self, screen: arcane.Screen) -> None:
""" Start the events thread to handle remote desktop events """
self.events_thread = arcane_threads.EventsThread(self.session)
self.events_thread.thread_finished.connect(self.thread_finished)
@@ -94,7 +96,7 @@ def start_events_thread(self, screen):
self.tangent_universe.set_event_thread(self.events_thread)
self.tangent_universe.set_screen(screen)
- def close_cellar_door(self):
+ def close_cellar_door(self) -> None:
""" Collapse Tangent Universe to Main Branch, We were able to save the world before 28:06:42:12 """
if self.desktop_thread is not None:
if self.desktop_thread.isRunning():
@@ -106,13 +108,13 @@ def close_cellar_door(self):
self.events_thread.stop()
self.events_thread.wait()
- def showEvent(self, event):
+ def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
if self.parent is not None:
self.parent.hide()
- def closeEvent(self, event):
+ def closeEvent(self, event: QCloseEvent) -> None:
""" Overridden close method to handle the cleanup
`I Hope That When The World Comes To An End, I Can Breathe A Sigh Of Relief Because There Will Be So Much To
Look Forward To.`"""
@@ -136,7 +138,7 @@ def closeEvent(self, event):
else:
event.ignore()
- def open_cellar_door(self, screen):
+ def open_cellar_door(self, screen: arcane.Screen) -> None:
""" Initialize the virtual desktop (Tangent Universe) """
self.v_desktop = QPixmap(screen.size())
self.v_desktop.fill(Qt.GlobalColor.black)
@@ -188,7 +190,7 @@ def open_cellar_door(self, screen):
# TODO: 0001
self.start_events_thread(screen)
- def fit_scene(self):
+ def fit_scene(self) -> None:
""" Fit the scene (Hacky Technique) to the view """
if self.tangent_universe is None or self.scene_pixmap is None:
return
@@ -206,7 +208,7 @@ def fit_scene(self):
transform.scale(scale_x, scale_y)
self.tangent_universe.setTransform(transform, False)
- def update_scene(self, chunk, x, y):
+ def update_scene(self, chunk: QImage, x: int, y: int) -> None:
""" Update the virtual desktop with the received chunk """
if self.v_desktop is None:
return
@@ -226,16 +228,16 @@ def update_scene(self, chunk, x, y):
self.fit_scene()
- def resizeEvent(self, event):
+ def resizeEvent(self, event: QResizeEvent) -> None:
""" Overridden resizeEvent method to fit the scene to the view """
self.fit_scene()
- def screen_selection_rejected(self):
+ def screen_selection_rejected(self) -> None:
self.universe_collapsed = True
self.close()
@pyqtSlot(list)
- def display_screen_selection_dialog(self, screens):
+ def display_screen_selection_dialog(self, screens: List[arcane.Screen]) -> None:
""" Display screen selection dialog """
screen_selection_dialog = arcane_dialogs.ScreenSelectionWindow(self, screens)
screen_selection_dialog.accepted.connect(lambda: self.desktop_thread.on_screen_selection_dialog_closed(
diff --git a/arcane_viewer/ui/utilities.py b/arcane_viewer/ui/utilities.py
index 349f677..5710955 100644
--- a/arcane_viewer/ui/utilities.py
+++ b/arcane_viewer/ui/utilities.py
@@ -21,7 +21,7 @@
class CenterWindow:
""" Mixin to center a window on the screen or on another window """
- def center_on_owner(self: Union[QDialog, QMainWindow], owner: Union[QDialog, QMainWindow] = None):
+ def center_on_owner(self: Union[QDialog, QMainWindow], owner: Union[QDialog, QMainWindow] = None) -> None:
if owner is None:
owner_geometry = self.screen().availableGeometry()
else:
diff --git a/setup.py b/setup.py
index 0e36550..f356233 100644
--- a/setup.py
+++ b/setup.py
@@ -7,7 +7,7 @@
setup(
name='arcane_viewer',
- version='1.0.4',
+ version='1.0.5',
packages=find_packages(),
include_package_data=True,
install_requires=[
From d14a4adac04a717fea6ec09622440c1b038c0467 Mon Sep 17 00:00:00 2001
From: DarkCoderSc
Date: Sun, 18 Aug 2024 14:07:58 +0200
Subject: [PATCH 2/5] More progress on type hinting, code improvement etc..
---
arcane_viewer/arcane/client.py | 13 ++-----
arcane_viewer/arcane/constants.py | 13 ++-----
arcane_viewer/arcane/exceptions.py | 9 +----
arcane_viewer/arcane/protocol.py | 9 +----
arcane_viewer/arcane/screen.py | 9 +----
arcane_viewer/arcane/session.py | 9 +----
arcane_viewer/arcane/threads/client_base.py | 9 +----
arcane_viewer/arcane/threads/connect.py | 9 +----
arcane_viewer/arcane/threads/events.py | 9 +----
arcane_viewer/arcane/threads/v_desktop.py | 9 +----
.../ui/custom_widgets/tangeant_universe.py | 9 +----
arcane_viewer/ui/dialogs/about.py | 17 ++-------
arcane_viewer/ui/dialogs/connecting.py | 17 ++-------
arcane_viewer/ui/dialogs/options.py | 27 ++++++--------
.../server_certificate_add_or_edit.py | 16 ++-------
arcane_viewer/ui/dialogs/screen_selection.py | 16 ++-------
.../ui/dialogs/server_certificate.py | 16 ++-------
arcane_viewer/ui/forms/connect.py | 21 +++--------
arcane_viewer/ui/forms/desktop.py | 22 +++++-------
arcane_viewer/ui/utilities.py | 35 ++++++++++---------
20 files changed, 69 insertions(+), 225 deletions(-)
diff --git a/arcane_viewer/arcane/client.py b/arcane_viewer/arcane/client.py
index 488caeb..7da5698 100644
--- a/arcane_viewer/arcane/client.py
+++ b/arcane_viewer/arcane/client.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
Description:
Client class that handles secure communication with a remote
@@ -118,13 +111,13 @@ def authenticate(self, password: str) -> None:
logger.debug(f"Received challenge: `{challenge}`, attempt to solve it with defined password...")
- challenge_solution = hashlib.pbkdf2_hmac(
+ challenge_solution_as_bytes = hashlib.pbkdf2_hmac(
"sha512",
password.encode("utf-8"),
challenge.encode("utf-8"),
1000
)
- challenge_solution = binascii.hexlify(challenge_solution)\
+ challenge_solution = binascii.hexlify(challenge_solution_as_bytes)\
.decode("utf-8").upper()
logger.debug(f"Challenge solved: `{challenge_solution}`, sending solution to server...")
diff --git a/arcane_viewer/arcane/constants.py b/arcane_viewer/arcane/constants.py
index 8fc2e57..8688784 100644
--- a/arcane_viewer/arcane/constants.py
+++ b/arcane_viewer/arcane/constants.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
import os
@@ -27,8 +20,8 @@ def get_asset_file(asset_name: str) -> str:
import importlib.resources as resources
def get_asset_file(asset_name: str) -> str:
- with resources.files(ASSETS_IDENTIFIER) / asset_name as asset_path:
- return str(asset_path)
+ asset_path = resources.files(ASSETS_IDENTIFIER) / asset_name
+ return str(asset_path)
# Application Information
diff --git a/arcane_viewer/arcane/exceptions.py b/arcane_viewer/arcane/exceptions.py
index d7007e1..a049701 100644
--- a/arcane_viewer/arcane/exceptions.py
+++ b/arcane_viewer/arcane/exceptions.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
from enum import Enum, auto
diff --git a/arcane_viewer/arcane/protocol.py b/arcane_viewer/arcane/protocol.py
index 2d50dec..7ccb438 100644
--- a/arcane_viewer/arcane/protocol.py
+++ b/arcane_viewer/arcane/protocol.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
from enum import Enum, auto
diff --git a/arcane_viewer/arcane/screen.py b/arcane_viewer/arcane/screen.py
index 97c1892..2810700 100644
--- a/arcane_viewer/arcane/screen.py
+++ b/arcane_viewer/arcane/screen.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
from PyQt6.QtCore import QSize
diff --git a/arcane_viewer/arcane/session.py b/arcane_viewer/arcane/session.py
index e783d5d..382216d 100644
--- a/arcane_viewer/arcane/session.py
+++ b/arcane_viewer/arcane/session.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
import json
diff --git a/arcane_viewer/arcane/threads/client_base.py b/arcane_viewer/arcane/threads/client_base.py
index 37e90d0..93693de 100644
--- a/arcane_viewer/arcane/threads/client_base.py
+++ b/arcane_viewer/arcane/threads/client_base.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
import logging
diff --git a/arcane_viewer/arcane/threads/connect.py b/arcane_viewer/arcane/threads/connect.py
index 447c4ee..540e3d1 100644
--- a/arcane_viewer/arcane/threads/connect.py
+++ b/arcane_viewer/arcane/threads/connect.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
import logging
diff --git a/arcane_viewer/arcane/threads/events.py b/arcane_viewer/arcane/threads/events.py
index 49098a4..a99083f 100644
--- a/arcane_viewer/arcane/threads/events.py
+++ b/arcane_viewer/arcane/threads/events.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
import logging
diff --git a/arcane_viewer/arcane/threads/v_desktop.py b/arcane_viewer/arcane/threads/v_desktop.py
index a6f5771..2614a1f 100644
--- a/arcane_viewer/arcane/threads/v_desktop.py
+++ b/arcane_viewer/arcane/threads/v_desktop.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
Todo:
- (0001) LogonUI Support
diff --git a/arcane_viewer/ui/custom_widgets/tangeant_universe.py b/arcane_viewer/ui/custom_widgets/tangeant_universe.py
index 09edc22..84194e8 100644
--- a/arcane_viewer/ui/custom_widgets/tangeant_universe.py
+++ b/arcane_viewer/ui/custom_widgets/tangeant_universe.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
Todo:
- (0001) : Find a way to correctly handle the Meta key from Viewer to Server especially from MacOs systems.
diff --git a/arcane_viewer/ui/dialogs/about.py b/arcane_viewer/ui/dialogs/about.py
index d45003d..f8414da 100644
--- a/arcane_viewer/ui/dialogs/about.py
+++ b/arcane_viewer/ui/dialogs/about.py
@@ -1,21 +1,14 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
import sys
from typing import Optional, Union
from PyQt6.QtCore import QT_VERSION_STR, QSize, Qt
-from PyQt6.QtGui import QIcon, QShowEvent
+from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QMainWindow,
QPushButton, QVBoxLayout)
@@ -23,7 +16,7 @@
import arcane_viewer.ui.utilities as utilities
-class AboutWindow(QDialog, utilities.CenterWindow):
+class AboutWindow(utilities.QCenteredDialog):
def __init__(self, parent: Optional[Union[QDialog, QMainWindow]] = None) -> None:
super().__init__(parent)
@@ -77,7 +70,3 @@ def __init__(self, parent: Optional[Union[QDialog, QMainWindow]] = None) -> None
def adjust_size(self) -> None:
self.setFixedSize(380, self.sizeHint().height())
-
- def showEvent(self, event: QShowEvent) -> None:
- super().showEvent(event)
- self.center_on_owner(self.parent())
diff --git a/arcane_viewer/ui/dialogs/connecting.py b/arcane_viewer/ui/dialogs/connecting.py
index c143eac..951eec9 100644
--- a/arcane_viewer/ui/dialogs/connecting.py
+++ b/arcane_viewer/ui/dialogs/connecting.py
@@ -1,20 +1,13 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
from typing import Optional, Union
from PyQt6.QtCore import QSize, Qt
-from PyQt6.QtGui import QIcon, QShowEvent
+from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QMainWindow,
QProgressBar, QSizePolicy, QSpacerItem,
QVBoxLayout)
@@ -23,7 +16,7 @@
import arcane_viewer.ui.utilities as utilities
-class ConnectingWindow(QDialog, utilities.CenterWindow):
+class ConnectingWindow(utilities.QCenteredDialog):
def __init__(self, parent: Optional[Union[QDialog, QMainWindow]] = None) -> None:
super().__init__(parent)
@@ -59,7 +52,3 @@ def __init__(self, parent: Optional[Union[QDialog, QMainWindow]] = None) -> None
spacer_bottom = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
info_layout.addItem(spacer_bottom)
-
- def showEvent(self, event: QShowEvent) -> None:
- super().showEvent(event)
- self.center_on_owner(self.parent())
diff --git a/arcane_viewer/ui/dialogs/options.py b/arcane_viewer/ui/dialogs/options.py
index 8c0fbc0..dee5fee 100644
--- a/arcane_viewer/ui/dialogs/options.py
+++ b/arcane_viewer/ui/dialogs/options.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
from typing import Optional, Union
@@ -28,9 +21,11 @@
class RemoteDesktopOptionsTab(QWidget):
""" Remote Desktop Options Tab """
- def __init__(self, settings: QSettings) -> None:
+ def __init__(self, parent: QDialog, settings: QSettings) -> None:
super().__init__()
+ self.parent = parent
+
self.settings = settings
core_layout = QVBoxLayout()
@@ -136,9 +131,11 @@ def flags(self, index: QModelIndex) -> Qt.ItemFlag:
class TrustedCertificatesOptionsTab(QWidget):
""" Trusted Certificates Options Tab """
- def __init__(self, settings: QSettings) -> None:
+ def __init__(self, parent: QDialog, settings: QSettings) -> None:
super().__init__()
+ self.parent = parent
+
self.settings = settings
core_layout = QHBoxLayout()
@@ -212,7 +209,7 @@ def add_or_edit_certificate(self, edit_selected: bool) -> None:
if edit_selected and self.tree_view.currentIndex().isValid():
fingerprint = self.model.item(self.tree_view.currentIndex().row(), 1).text()
- dialog = ServerCertificateAddOrEditDialog(self, self.settings, fingerprint)
+ dialog = ServerCertificateAddOrEditDialog(self.parent, self.settings, fingerprint)
dialog.exec()
if dialog.result() == QDialog.DialogCode.Accepted:
@@ -279,7 +276,7 @@ def save_settings(self) -> None:
self.settings.setValue(arcane.SETTINGS_KEY_TRUSTED_CERTIFICATES, certificates)
-class OptionsDialog(QDialog, utilities.CenterWindow):
+class OptionsDialog(utilities.QCenteredDialog):
""" Arcane Options Dialog """
def __init__(self, parent: Optional[Union[QDialog, QMainWindow]] = None) -> None:
super().__init__(parent)
@@ -303,11 +300,11 @@ def __init__(self, parent: Optional[Union[QDialog, QMainWindow]] = None) -> None
core_layout.addWidget(self.options_tab_widget)
# General Tab
- self.remote_desktop_tab = RemoteDesktopOptionsTab(self.settings)
+ self.remote_desktop_tab = RemoteDesktopOptionsTab(self, self.settings)
self.options_tab_widget.addTab(self.remote_desktop_tab, "Remote Desktop")
# Trusted Certificates Tab
- self.trusted_certificates_tab = TrustedCertificatesOptionsTab(self.settings)
+ self.trusted_certificates_tab = TrustedCertificatesOptionsTab(self, self.settings)
self.options_tab_widget.addTab(self.trusted_certificates_tab, "Trusted Certificates")
# Action Buttons
@@ -361,6 +358,4 @@ def adjust_size(self) -> None:
def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
- self.center_on_owner(self.parent())
-
self.load_settings()
diff --git a/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py b/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py
index ffeaebf..9f6d068 100644
--- a/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py
+++ b/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py
@@ -1,21 +1,13 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
import re
from typing import Optional, Union
from PyQt6.QtCore import QSettings
-from PyQt6.QtGui import QShowEvent
from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QLineEdit,
QMainWindow, QMessageBox, QPushButton,
QSizePolicy, QSpacerItem, QTextEdit, QVBoxLayout)
@@ -24,7 +16,7 @@
import arcane_viewer.ui.utilities as utilities
-class ServerCertificateAddOrEditDialog(QDialog, utilities.CenterWindow):
+class ServerCertificateAddOrEditDialog(utilities.QCenteredDialog):
def __init__(self, parent: Optional[Union[QDialog, QMainWindow]], settings: QSettings, fingerprint: str = None)\
-> None:
super().__init__(parent)
@@ -113,7 +105,3 @@ def save_or_update_certificate(self) -> None:
def adjust_size(self) -> None:
self.setFixedSize(400, self.sizeHint().height())
-
- def showEvent(self, event: QShowEvent) -> None:
- super().showEvent(event)
- self.center_on_owner(self.parent())
diff --git a/arcane_viewer/ui/dialogs/screen_selection.py b/arcane_viewer/ui/dialogs/screen_selection.py
index 4fdb71e..d3b9be8 100644
--- a/arcane_viewer/ui/dialogs/screen_selection.py
+++ b/arcane_viewer/ui/dialogs/screen_selection.py
@@ -1,20 +1,12 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
from typing import List, Optional, Union
from PyQt6.QtCore import Qt
-from PyQt6.QtGui import QShowEvent
from PyQt6.QtWidgets import (QComboBox, QDialog, QHBoxLayout, QLabel,
QMainWindow, QPushButton, QVBoxLayout)
@@ -22,7 +14,7 @@
import arcane_viewer.ui.utilities as utilities
-class ScreenSelectionWindow(QDialog, utilities.CenterWindow):
+class ScreenSelectionWindow(utilities.QCenteredDialog):
""" Screen Selection Dialog """
def __init__(self, parent: Optional[Union[QDialog, QMainWindow]], screens: List[arcane.Screen]) -> None:
super().__init__(parent)
@@ -74,10 +66,6 @@ def __init__(self, parent: Optional[Union[QDialog, QMainWindow]], screens: List[
self.setFixedSize(290, self.sizeHint().height())
- def showEvent(self, event: QShowEvent) -> None:
- super().showEvent(event)
- self.center_on_owner(self.parent())
-
def get_selected_screen(self) -> arcane.Screen:
""" Get the user-choice selected screen """
return [screen for screen in self.screens if screen.name == self.screen_selection_combobox.currentData()][0]
diff --git a/arcane_viewer/ui/dialogs/server_certificate.py b/arcane_viewer/ui/dialogs/server_certificate.py
index 139b6ba..48d5cd7 100644
--- a/arcane_viewer/ui/dialogs/server_certificate.py
+++ b/arcane_viewer/ui/dialogs/server_certificate.py
@@ -1,27 +1,19 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
from typing import Optional, Union
from PyQt6.QtCore import Qt
-from PyQt6.QtGui import QShowEvent
from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QLabel,
QMainWindow, QPushButton, QVBoxLayout)
import arcane_viewer.ui.utilities as utilities
-class ServerCertificateDialog(QDialog, utilities.CenterWindow):
+class ServerCertificateDialog(utilities.QCenteredDialog):
def __init__(self, parent: Optional[Union[QDialog, QMainWindow]], fingerprint: str) -> None:
super().__init__(parent)
@@ -100,7 +92,3 @@ def adjust_size(self) -> None:
self.sizeHint().width(),
self.sizeHint().height()
)
-
- def showEvent(self, event: QShowEvent) -> None:
- super().showEvent(event)
- self.center_on_owner(self.parent())
diff --git a/arcane_viewer/ui/forms/connect.py b/arcane_viewer/ui/forms/connect.py
index 2acca49..73a8323 100644
--- a/arcane_viewer/ui/forms/connect.py
+++ b/arcane_viewer/ui/forms/connect.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
import json
@@ -16,10 +9,10 @@
import socket
from PyQt6.QtCore import QSettings, QSize, Qt, pyqtSlot
-from PyQt6.QtGui import QIcon, QShowEvent
+from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QLineEdit,
- QMainWindow, QMessageBox, QPushButton, QSpinBox,
- QVBoxLayout, QWidget)
+ QMessageBox, QPushButton, QSpinBox, QVBoxLayout,
+ QWidget)
import arcane_viewer.arcane as arcane
import arcane_viewer.arcane.threads as arcane_threads
@@ -28,7 +21,7 @@
import arcane_viewer.ui.utilities as utilities
-class ConnectWindow(QMainWindow, utilities.CenterWindow):
+class ConnectWindow(utilities.QCenteredMainWindow):
""" Connect Window to establish a connection to the server """
def __init__(self) -> None:
@@ -131,10 +124,6 @@ def __init__(self) -> None:
self.adjust_size()
- def showEvent(self, event: QShowEvent) -> None:
- super().showEvent(event)
- self.center_on_owner()
-
def read_default(self) -> None:
""" Read default settings from the default.json file """
if not os.path.isfile(arcane.DEFAULT_JSON):
diff --git a/arcane_viewer/ui/forms/desktop.py b/arcane_viewer/ui/forms/desktop.py
index 0f06ee6..243806b 100644
--- a/arcane_viewer/ui/forms/desktop.py
+++ b/arcane_viewer/ui/forms/desktop.py
@@ -1,14 +1,7 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
Todo:
- (0001) : Implement remote screen resolution update, if it does, we will need to ensure it push an update of
@@ -16,7 +9,7 @@
"""
import logging
-from typing import List, Union
+from typing import List, Optional, Union
from PyQt6.QtCore import QRect, QSize, Qt, pyqtSlot
from PyQt6.QtGui import (QCloseEvent, QImage, QPainter, QPixmap, QResizeEvent,
@@ -25,9 +18,9 @@
QMainWindow, QMessageBox)
import arcane_viewer.arcane as arcane
-import arcane_viewer.arcane.threads as arcane_threads
import arcane_viewer.ui.custom_widgets as arcane_widgets
import arcane_viewer.ui.dialogs as arcane_dialogs
+from arcane_viewer.arcane.threads import EventsThread, VirtualDesktopThread
logger = logging.getLogger(__name__)
@@ -39,10 +32,11 @@ def __init__(self, parent: Union[QDialog, QMainWindow], session: arcane.Session)
self.tangent_universe = None
self.scene_pixmap = None
self.v_desktop = None
- self.desktop_thread = None
- self.events_thread = None
self.universe_collapsed = False
+ self.desktop_thread: Optional[VirtualDesktopThread] = None
+ self.events_thread: Optional[EventsThread] = None
+
self.session = session
# Instead of using QWidget parent property, we will use a custom attribute to store the parent window, this will
@@ -79,7 +73,7 @@ def thread_finished(self, on_error: bool) -> None:
def start_desktop_thread(self) -> None:
""" Start the desktop thread to handle remote desktop streaming """
- self.desktop_thread = arcane_threads.VirtualDesktopThread(self.session)
+ self.desktop_thread = VirtualDesktopThread(self.session)
self.desktop_thread.chunk_received.connect(self.update_scene)
self.desktop_thread.open_cellar_door.connect(self.open_cellar_door)
self.desktop_thread.thread_finished.connect(self.thread_finished)
@@ -88,7 +82,7 @@ def start_desktop_thread(self) -> None:
def start_events_thread(self, screen: arcane.Screen) -> None:
""" Start the events thread to handle remote desktop events """
- self.events_thread = arcane_threads.EventsThread(self.session)
+ self.events_thread = EventsThread(self.session)
self.events_thread.thread_finished.connect(self.thread_finished)
self.events_thread.start()
diff --git a/arcane_viewer/ui/utilities.py b/arcane_viewer/ui/utilities.py
index 5710955..3324257 100644
--- a/arcane_viewer/ui/utilities.py
+++ b/arcane_viewer/ui/utilities.py
@@ -1,31 +1,22 @@
"""
- Arcane - A secure remote desktop application for Windows with the
- particularity of having a server entirely written in PowerShell and
- a cross-platform client (Python/QT6).
-
Author: Jean-Pierre LESUEUR (@DarkCoderSc)
License: Apache License 2.0
- https://github.com/PhrozenIO
- https://github.com/DarkCoderSc
- https://twitter.com/DarkCoderSc
- www.phrozen.io
+ More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
-from typing import Union
-
-from PyQt6.QtGui import QFont
-from PyQt6.QtWidgets import QDialog, QMainWindow
+from PyQt6.QtGui import QFont, QShowEvent
+from PyQt6.QtWidgets import QDialog, QMainWindow, QWidget
MONOSPACE_FONTS = QFont("Consolas, 'Courier New', Monaco, 'DejaVu Sans Mono', 'Liberation Mono', monospace")
-class CenterWindow:
- """ Mixin to center a window on the screen or on another window """
- def center_on_owner(self: Union[QDialog, QMainWindow], owner: Union[QDialog, QMainWindow] = None) -> None:
- if owner is None:
+class CenteredWindowMixin(QWidget):
+ """ Mixin to center a widget on the screen or on another widget """
+ def center_on_owner(self) -> None:
+ if self.parent() is None:
owner_geometry = self.screen().availableGeometry()
else:
- owner_geometry = owner.frameGeometry()
+ owner_geometry = self.parent().frameGeometry()
subform_geometry = self.frameGeometry()
@@ -33,3 +24,13 @@ def center_on_owner(self: Union[QDialog, QMainWindow], owner: Union[QDialog, QMa
subform_geometry.moveCenter(center_point)
self.move(subform_geometry.topLeft())
+
+
+class QCenteredDialog(QDialog, CenteredWindowMixin):
+ def showEvent(self, event: QShowEvent) -> None:
+ self.center_on_owner()
+
+
+class QCenteredMainWindow(QMainWindow, CenteredWindowMixin):
+ def showEvent(self, event: QShowEvent) -> None:
+ self.center_on_owner()
From 384e75035c51fbfe060ee9eced387a80ca396822 Mon Sep 17 00:00:00 2001
From: DarkCoderSc
Date: Wed, 21 Aug 2024 18:18:14 +0200
Subject: [PATCH 3/5] Progress in refactoring, improving code, improving type
hinting etc...
---
arcane_viewer/arcane/client.py | 68 ++++++++++-----
arcane_viewer/arcane/threads/client_base.py | 20 +++--
arcane_viewer/arcane/threads/v_desktop.py | 15 +++-
arcane_viewer/main.py | 8 +-
.../ui/custom_widgets/tangeant_universe.py | 27 ++++--
arcane_viewer/ui/dialogs/__init__.py | 12 +--
arcane_viewer/ui/dialogs/about.py | 2 +-
arcane_viewer/ui/dialogs/connecting.py | 2 +-
arcane_viewer/ui/dialogs/options.py | 84 ++++++++++++++-----
arcane_viewer/ui/dialogs/screen_selection.py | 2 +-
arcane_viewer/ui/forms/connect.py | 21 ++---
arcane_viewer/ui/forms/desktop.py | 79 ++++++++---------
arcane_viewer/ui/utilities.py | 17 ++--
mypy.ini | 2 +
setup.py | 2 +-
15 files changed, 231 insertions(+), 130 deletions(-)
create mode 100644 mypy.ini
diff --git a/arcane_viewer/arcane/client.py b/arcane_viewer/arcane/client.py
index 7da5698..e3ee76e 100644
--- a/arcane_viewer/arcane/client.py
+++ b/arcane_viewer/arcane/client.py
@@ -30,16 +30,22 @@
class Client:
- """ Client class to handle secure communication with remote server """
+ """ Client class to handle secure communication with remote server
+ Things to note:
+ * It is possible to read and write to a socket at the same time, so one thread (for example the main thread) can
+ write to the socket while a secondary thread reads from it (at the same time).
+ """
def __init__(self, server_address: str, server_port: int, password: str) -> None:
- logger.info("Connecting to remote server: `{}:{}`...".format(
+ self.id = -1
+
+ self.info("Connecting to remote server: `{}:{}`...".format(
server_address,
server_port
))
self.client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- logger.debug("Creating and configure new SSL context...")
+ self.debug("Creating and configure new SSL context...")
self.context = ssl.create_default_context()
self.context.check_hostname = False
self.context.verify_mode = ssl.CERT_NONE
@@ -49,7 +55,9 @@ def __init__(self, server_address: str, server_port: int, password: str) -> None
server_hostname=server_address,
)
- logger.debug("Establishing connection to remote server...")
+ self.id = self.conn.fileno()
+
+ self.debug("Establishing connection to remote server...")
self.conn.settimeout(10)
self.conn.connect((server_address, server_port))
@@ -61,26 +69,40 @@ def __init__(self, server_address: str, server_port: int, password: str) -> None
)
self.server_fingerprint = hashlib.sha1(server_certificate).hexdigest().upper()
- logger.debug(f"Server certificate fingerprint: `{self.server_fingerprint}`")
+ self.debug(f"Server certificate fingerprint: `{self.server_fingerprint}`")
self.conn.settimeout(None)
- logger.info(f"[{self.conn.fileno()}] Connected! Authenticating with remote server...")
+ self.info("Connected! Authenticating with remote server...")
self.authenticate(password)
- logger.info("Authentication successful")
+ self.info("Authentication successful")
def __del__(self) -> None:
self.close()
+ def _log(self, level, message: str) -> None:
+ """ Log a message with the client ID as prefix
+ I prefer to use this method instead of directly configuring the logger which I find to be less straightforward.
+ Of course if we would need to reflect the same principle across different classes, we would not use this
+ method. having an id (uuid) prefix is really for debugging purposes and solely for the client class.
+ (Client Tracking: ON; OFF) """
+ logger.log(level, f"[{self.id}] {message}")
+
+ def debug(self, message: str) -> None:
+ self._log(logging.DEBUG, message)
+
+ def info(self, message: str) -> None:
+ self._log(logging.INFO, message)
+
def read_line(self) -> str:
data = b""
while True:
try:
b = self.conn.recv(1)
- except ssl.SSLError:
+ except (ssl.SSLError, OSError):
break
if not b:
@@ -93,7 +115,10 @@ def read_line(self) -> str:
return data.decode('utf-8').strip()
def write_line(self, line: str) -> None:
- self.conn.write(line.encode('utf-8') + b'\r\n')
+ try:
+ self.conn.write(line.encode('utf-8') + b'\r\n')
+ except (ssl.SSLError, OSError):
+ pass
def read_json(self) -> dict:
try:
@@ -105,11 +130,11 @@ def write_json(self, data: dict) -> None:
self.write_line(json.dumps(data))
def authenticate(self, password: str) -> None:
- logger.debug("Request challenge...")
+ self.debug("Request challenge...")
challenge = self.read_line()
- logger.debug(f"Received challenge: `{challenge}`, attempt to solve it with defined password...")
+ self.debug(f"Received challenge: `{challenge}`, attempt to solve it with defined password...")
challenge_solution_as_bytes = hashlib.pbkdf2_hmac(
"sha512",
@@ -120,7 +145,7 @@ def authenticate(self, password: str) -> None:
challenge_solution = binascii.hexlify(challenge_solution_as_bytes)\
.decode("utf-8").upper()
- logger.debug(f"Challenge solved: `{challenge_solution}`, sending solution to server...")
+ self.debug(f"Challenge solved: `{challenge_solution}`, sending solution to server...")
self.write_line(challenge_solution)
@@ -131,16 +156,17 @@ def authenticate(self, password: str) -> None:
)
def close(self) -> None:
- if self.client is not None:
- logger.info(f"[{self.conn.fileno()}] Closing connection...")
+ self.info("Closing connection...")
+ if self.conn is not None:
try:
- if self.conn is not None:
- self.conn.shutdown(socket.SHUT_RDWR)
- self.conn.close()
+ self.conn.shutdown(socket.SHUT_RDWR)
+ self.conn.close()
except OSError:
- self.client.close()
+ pass
+ if self.client is not None:
+ try:
+ self.client.close()
+ except OSError:
+ # Should not happen (very unlikely but not null)
pass
- finally:
- self.conn = None
- self.client = None
diff --git a/arcane_viewer/arcane/threads/client_base.py b/arcane_viewer/arcane/threads/client_base.py
index 93693de..c146e54 100644
--- a/arcane_viewer/arcane/threads/client_base.py
+++ b/arcane_viewer/arcane/threads/client_base.py
@@ -6,8 +6,9 @@
import logging
from abc import abstractmethod
+from typing import Optional
-from PyQt6.QtCore import QThread, pyqtSignal, pyqtSlot
+from PyQt6.QtCore import QMutex, QThread, pyqtSignal, pyqtSlot
import arcane_viewer.arcane as arcane
@@ -25,8 +26,10 @@ def __init__(self, session: arcane.Session, worker_kind: arcane.WorkerKind) -> N
self._running = True
self._connected = False
+ self._mutex = QMutex()
+
self.session = session
- self.client = None
+ self.client: Optional[arcane.Client] = None
self.worker_kind = worker_kind
def run(self) -> None:
@@ -47,8 +50,7 @@ def run(self) -> None:
logger.error(f"Thread `{self.__class__.__name__}` encountered an error: `{e}`")
on_error = True
finally:
- if self.client is not None:
- self.client.close()
+ self.stop()
self.thread_finished.emit(on_error)
@@ -58,7 +60,11 @@ def client_execute(self) -> None:
@pyqtSlot()
def stop(self) -> None:
- self._running = False
+ self._mutex.lock()
+ try:
+ if self.client is not None:
+ self.client.close()
- if self.client is not None:
- self.client.close()
+ self._running = False
+ finally:
+ self._mutex.unlock()
diff --git a/arcane_viewer/arcane/threads/v_desktop.py b/arcane_viewer/arcane/threads/v_desktop.py
index 2614a1f..2aa1fb4 100644
--- a/arcane_viewer/arcane/threads/v_desktop.py
+++ b/arcane_viewer/arcane/threads/v_desktop.py
@@ -36,6 +36,9 @@ def __init__(self, session: arcane.Session) -> None:
"""`Destruction is a form of creation. So the fact they burn the money is ironic. They just want to see what happens
when they tear the world apart. They want to change things.`, Donnie Darko"""
def client_execute(self) -> None:
+ if self.client is None:
+ return
+
screens_obj = self.client.read_json()
screens = [arcane.Screen(screen) for screen in screens_obj["List"]]
logger.info(f"{len(screens)} screen(s) detected")
@@ -45,6 +48,9 @@ def client_execute(self) -> None:
else:
self.display_screen_selection_dialog(screens)
+ if self.selected_screen is None:
+ return
+
logger.info(f"Screen: {self.selected_screen.name} "
f"({self.selected_screen.width}x{self.selected_screen.height})")
@@ -67,7 +73,10 @@ def client_execute(self) -> None:
packet_max_size = self.session.option_packet_size.value
while self._running:
- chunk_size, x, y = struct.unpack('III', self.client.conn.read(12))
+ try:
+ chunk_size, x, y = struct.unpack('III', self.client.conn.read(12))
+ except struct.error:
+ break
chunk_bytes = QByteArray()
bytes_read = 0
@@ -105,8 +114,8 @@ def display_screen_selection_dialog(self, screens: List[arcane.Screen]) -> None:
@pyqtSlot(arcane.Screen)
def on_screen_selection_dialog_closed(self, screen: arcane.Screen) -> None:
+ self.selected_screen = screen
+
if self.event_loop is not None:
self.event_loop.quit()
self.event_loop = None
-
- self.selected_screen = screen
diff --git a/arcane_viewer/main.py b/arcane_viewer/main.py
index 90c7cc1..b54c0a0 100644
--- a/arcane_viewer/main.py
+++ b/arcane_viewer/main.py
@@ -19,6 +19,7 @@
import logging
import sys
+import threading
from PyQt6.QtGui import QColor, QIcon, QPalette
from PyQt6.QtWidgets import QApplication
@@ -28,7 +29,10 @@
def main() -> None:
- logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+ logging.basicConfig(
+ level=logging.DEBUG,
+ format="%(asctime)s - %(name)s[%(thread)d] - %(levelname)s - %(message)s"
+ )
app = QApplication(sys.argv)
@@ -95,6 +99,8 @@ def main() -> None:
}
""")
+ logging.debug(f"Main thread ID: {threading.get_ident()}")
+
# Create and show the connect window
connect_window = arcane_forms.ConnectWindow()
connect_window.show()
diff --git a/arcane_viewer/ui/custom_widgets/tangeant_universe.py b/arcane_viewer/ui/custom_widgets/tangeant_universe.py
index 84194e8..d5ad627 100644
--- a/arcane_viewer/ui/custom_widgets/tangeant_universe.py
+++ b/arcane_viewer/ui/custom_widgets/tangeant_universe.py
@@ -12,6 +12,7 @@
import logging
from sys import platform
+from typing import Optional, Tuple
from PyQt6.QtCore import Qt, pyqtSlot
from PyQt6.QtGui import QClipboard, QKeyEvent, QMouseEvent, QWheelEvent
@@ -37,14 +38,15 @@ def __init__(self) -> None:
self.setMouseTracking(True)
- self.events_thread = None
- self.screen = None
+ self.events_thread: Optional[arcane_threads.EventsThread] = None
+ self.screen: Optional[arcane.Screen] = None
self.scene = QGraphicsScene()
self.setScene(self.scene)
self.clipboard = QApplication.clipboard()
- self.clipboard.dataChanged.connect(self.clipboard_data_changed)
+ if self.clipboard is not None:
+ self.clipboard.dataChanged.connect(self.clipboard_data_changed)
def set_event_thread(self, events_thread: arcane_threads.EventsThread) -> None:
""" Set the events thread """
@@ -57,7 +59,7 @@ def set_screen(self, screen: arcane.Screen) -> None:
""" Set the captured screen original information """
self.screen = screen
- def fix_mouse_position(self, x: int, y: int) -> tuple[int, int]:
+ def fix_mouse_position(self, x: int, y: int) -> Tuple[int, int]:
""" Fix the virtual desktop mouse position to the original screen position """
if self.screen is None:
return x, y
@@ -78,6 +80,9 @@ def fix_mouse_position(self, x: int, y: int) -> tuple[int, int]:
def send_mouse_event(self, x: int, y: int, state: arcane.MouseState, button: arcane.MouseButton) -> None:
""" Push mouse event to the events thread """
+ if self.events_thread is None:
+ return
+
self.events_thread.send_mouse_event(
x,
y,
@@ -133,15 +138,18 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None:
def clipboard_data_changed(self) -> None:
""" Handle clipboard data changed event """
+ if self.events_thread is None or self.clipboard is None:
+ return
+
text = self.clipboard.text(QClipboard.Mode.Clipboard)
self.events_thread.send_clipboard_text(
text
)
- def keyPressEvent(self, event: QKeyEvent) -> None:
+ def keyPressEvent(self, event: Optional[QKeyEvent]) -> None:
""" Override keyPressEvent method to handle key press events """
- if self.events_thread is None:
+ if self.events_thread is None or event is None:
return
if not event.isInputEvent():
@@ -247,9 +255,9 @@ def keyPressEvent(self, event: QKeyEvent) -> None:
self.events_thread.send_key_event(key_text)
- def wheelEvent(self, event: QWheelEvent) -> None:
+ def wheelEvent(self, event: Optional[QWheelEvent]) -> None:
""" Override wheelEvent method to handle mouse wheel events """
- if self.events_thread is None:
+ if self.events_thread is None or event is None:
return
delta = event.angleDelta().y()
@@ -262,4 +270,5 @@ def update_mouse_cursor(self, cursor: Qt.CursorShape) -> None:
@pyqtSlot(str)
def update_clipboard(self, text: str) -> None:
- self.clipboard.setText(text)
+ if self.clipboard is not None:
+ self.clipboard.setText(text)
diff --git a/arcane_viewer/ui/dialogs/__init__.py b/arcane_viewer/ui/dialogs/__init__.py
index d05927a..8919344 100644
--- a/arcane_viewer/ui/dialogs/__init__.py
+++ b/arcane_viewer/ui/dialogs/__init__.py
@@ -4,16 +4,16 @@
__copyright__ = "Copyright 2024, Phrozen"
__license__ = "Apache License 2.0"
-from .about import AboutWindow
-from .connecting import ConnectingWindow
+from .about import AboutDialog
+from .connecting import ConnectingDialog
from .options import OptionsDialog
-from .screen_selection import ScreenSelectionWindow
+from .screen_selection import ScreenSelectionDialog
from .server_certificate import ServerCertificateDialog
__all__ = [
- 'AboutWindow',
- 'ConnectingWindow',
- 'ScreenSelectionWindow',
+ 'AboutDialog',
+ 'ConnectingDialog',
+ 'ScreenSelectionDialog',
'ServerCertificateDialog',
'OptionsDialog',
]
diff --git a/arcane_viewer/ui/dialogs/about.py b/arcane_viewer/ui/dialogs/about.py
index f8414da..0d79b13 100644
--- a/arcane_viewer/ui/dialogs/about.py
+++ b/arcane_viewer/ui/dialogs/about.py
@@ -16,7 +16,7 @@
import arcane_viewer.ui.utilities as utilities
-class AboutWindow(utilities.QCenteredDialog):
+class AboutDialog(utilities.QCenteredDialog):
def __init__(self, parent: Optional[Union[QDialog, QMainWindow]] = None) -> None:
super().__init__(parent)
diff --git a/arcane_viewer/ui/dialogs/connecting.py b/arcane_viewer/ui/dialogs/connecting.py
index 951eec9..54926a8 100644
--- a/arcane_viewer/ui/dialogs/connecting.py
+++ b/arcane_viewer/ui/dialogs/connecting.py
@@ -16,7 +16,7 @@
import arcane_viewer.ui.utilities as utilities
-class ConnectingWindow(utilities.QCenteredDialog):
+class ConnectingDialog(utilities.QCenteredDialog):
def __init__(self, parent: Optional[Union[QDialog, QMainWindow]] = None) -> None:
super().__init__(parent)
diff --git a/arcane_viewer/ui/dialogs/options.py b/arcane_viewer/ui/dialogs/options.py
index dee5fee..39ea224 100644
--- a/arcane_viewer/ui/dialogs/options.py
+++ b/arcane_viewer/ui/dialogs/options.py
@@ -21,10 +21,10 @@
class RemoteDesktopOptionsTab(QWidget):
""" Remote Desktop Options Tab """
- def __init__(self, parent: QDialog, settings: QSettings) -> None:
+ def __init__(self, options_dialog: QDialog, settings: QSettings) -> None:
super().__init__()
- self.parent = parent
+ self.options_dialog = options_dialog
self.settings = settings
@@ -131,10 +131,13 @@ def flags(self, index: QModelIndex) -> Qt.ItemFlag:
class TrustedCertificatesOptionsTab(QWidget):
""" Trusted Certificates Options Tab """
- def __init__(self, parent: QDialog, settings: QSettings) -> None:
+ def __init__(self, options_dialog: QDialog, settings: QSettings) -> None:
super().__init__()
- self.parent = parent
+ # I'm using `options_dialog` property instead of parent property `super().__init__(parent)` because it seems
+ # that using the parent property is flawed in some cases, until I find a better solution, I will use this
+ # method.
+ self.options_dialog = options_dialog
self.settings = settings
@@ -149,7 +152,11 @@ def __init__(self, parent: QDialog, settings: QSettings) -> None:
self.tree_view.setModel(self.model)
self.tree_view.setRootIsDecorated(False)
- self.tree_view.selectionModel().selectionChanged.connect(self.tree_view_selection_changed)
+
+ selection_model = self.tree_view.selectionModel()
+ if selection_model is not None:
+ selection_model.selectionChanged.connect(self.tree_view_selection_changed)
+
self.tree_view.setSelectionBehavior(QTreeView.SelectionBehavior.SelectRows)
# Setup Action Buttons
@@ -174,11 +181,25 @@ def __init__(self, parent: QDialog, settings: QSettings) -> None:
def add_or_edit_row(self, fingerprint: str, display_name: str, description: str) -> None:
""" Add a certificate to the list """
+ if self.model is None:
+ return
+
# Edit row if it already exists
for i in range(self.model.rowCount()):
- if self.model.item(i, 1).text() == fingerprint:
- self.model.item(i, 0).setText(display_name)
- self.model.item(i, 2).setText(description)
+ col_display_name = self.model.item(i, 0)
+ col_fingerprint = self.model.item(i, 1)
+ col_description = self.model.item(i, 2)
+
+ if any(column is None for column in [
+ col_display_name,
+ col_fingerprint,
+ col_description
+ ]):
+ continue
+
+ if col_fingerprint.text() == fingerprint: # type: ignore[union-attr]
+ col_display_name.setText(display_name) # type: ignore[union-attr]
+ col_description.setText(description) # type: ignore[union-attr]
return
@@ -205,11 +226,18 @@ def remove_button_clicked(self) -> None:
def add_or_edit_certificate(self, edit_selected: bool) -> None:
""" Add or edit a certificate """
+ if self.model is None:
+ return
+
fingerprint = None
if edit_selected and self.tree_view.currentIndex().isValid():
- fingerprint = self.model.item(self.tree_view.currentIndex().row(), 1).text()
+ col_fingerprint = self.model.item(self.tree_view.currentIndex().row(), 1)
+ if col_fingerprint is None:
+ return
+
+ fingerprint = col_fingerprint.text()
- dialog = ServerCertificateAddOrEditDialog(self.parent, self.settings, fingerprint)
+ dialog = ServerCertificateAddOrEditDialog(self.options_dialog, self.settings, fingerprint)
dialog.exec()
if dialog.result() == QDialog.DialogCode.Accepted:
@@ -254,26 +282,38 @@ def load_settings(self) -> None:
def save_settings(self) -> None:
""" Save trusted certificates to the settings """
- certificates = []
+ if self.model is None:
+ return
+
+ fingerprints = []
for i in range(self.model.rowCount()):
- certificate = self.model.item(i, 1).text().upper().strip()
+ col_display_name = self.model.item(i, 0)
+ col_fingerprint = self.model.item(i, 1)
+ col_description = self.model.item(i, 2)
+
+ if any(column is None for column in [
+ col_display_name,
+ col_fingerprint,
+ col_description
+ ]):
+ continue
- certificates.append(certificate)
+ fingerprint = col_fingerprint.text().upper().strip() # type: ignore[union-attr]
- # Save extra information about the certificate
- certificate_information = {
- "display_name": self.model.item(i, 0).text(),
- "description": self.model.item(i, 2).text(),
- # Additional extra information fields to be placed here (if any are added in the future)
- }
+ fingerprints.append(fingerprint)
+ # Save extra information about the certificate
self.settings.setValue(
- f"{arcane.SETTINGS_KEY_TRUSTED_CERTIFICATES}.{certificate}",
- certificate_information
+ f"{arcane.SETTINGS_KEY_TRUSTED_CERTIFICATES}.{fingerprint}",
+ {
+ "display_name": col_display_name.text(), # type: ignore[union-attr]
+ "description": col_description.text(), # type: ignore[union-attr]
+ # Additional extra information fields to be placed here (if any are added in the future)
+ }
)
# Save the list of trusted certificates (Just the fingerprints)
- self.settings.setValue(arcane.SETTINGS_KEY_TRUSTED_CERTIFICATES, certificates)
+ self.settings.setValue(arcane.SETTINGS_KEY_TRUSTED_CERTIFICATES, fingerprints)
class OptionsDialog(utilities.QCenteredDialog):
diff --git a/arcane_viewer/ui/dialogs/screen_selection.py b/arcane_viewer/ui/dialogs/screen_selection.py
index d3b9be8..039830c 100644
--- a/arcane_viewer/ui/dialogs/screen_selection.py
+++ b/arcane_viewer/ui/dialogs/screen_selection.py
@@ -14,7 +14,7 @@
import arcane_viewer.ui.utilities as utilities
-class ScreenSelectionWindow(utilities.QCenteredDialog):
+class ScreenSelectionDialog(utilities.QCenteredDialog):
""" Screen Selection Dialog """
def __init__(self, parent: Optional[Union[QDialog, QMainWindow]], screens: List[arcane.Screen]) -> None:
super().__init__(parent)
diff --git a/arcane_viewer/ui/forms/connect.py b/arcane_viewer/ui/forms/connect.py
index 73a8323..2b572ca 100644
--- a/arcane_viewer/ui/forms/connect.py
+++ b/arcane_viewer/ui/forms/connect.py
@@ -7,6 +7,7 @@
import json
import os.path
import socket
+from typing import Optional
from PyQt6.QtCore import QSettings, QSize, Qt, pyqtSlot
from PyQt6.QtGui import QIcon
@@ -27,10 +28,10 @@ class ConnectWindow(utilities.QCenteredMainWindow):
def __init__(self) -> None:
super().__init__()
- self.__connect_thread = None
- self.__connecting_form = None
- self.desktop_window = None
- self.session = None
+ self.__connect_thread: Optional[arcane_threads.ConnectThread] = None
+ self.__connecting_dialog: Optional[arcane_dialogs.ConnectingDialog] = None
+ self.desktop_window: Optional[arcane_forms.DesktopWindow] = None
+ self.session: Optional[arcane.Session] = None
self.setWindowTitle(f"{arcane.APP_DISPLAY_NAME} :: Connect")
@@ -182,7 +183,7 @@ def adjust_size(self) -> None:
self.setFixedSize(350, self.sizeHint().height())
def show_about_dialog(self) -> None:
- about_window = arcane_dialogs.AboutWindow(self)
+ about_window = arcane_dialogs.AboutDialog(self)
about_window.exec()
@pyqtSlot(str)
@@ -191,14 +192,14 @@ def session_error(self, error_message: str) -> None:
@pyqtSlot()
def connect_thread_started(self) -> None:
- self.__connecting_form = arcane_dialogs.ConnectingWindow(self)
- self.__connecting_form.exec()
+ self.__connecting_dialog = arcane_dialogs.ConnectingDialog(self)
+ self.__connecting_dialog.exec()
@pyqtSlot(object)
- def connect_thread_finished(self, session: arcane.Session = None) -> None:
+ def connect_thread_finished(self, session: Optional[arcane.Session] = None) -> None:
# Close the connecting form if it is still open
- if self.__connecting_form is not None and self.__connecting_form.isVisible():
- self.__connecting_form.close()
+ if self.__connecting_dialog is not None and self.__connecting_dialog.isVisible():
+ self.__connecting_dialog.close()
if session is None:
return
diff --git a/arcane_viewer/ui/forms/desktop.py b/arcane_viewer/ui/forms/desktop.py
index 243806b..f86462c 100644
--- a/arcane_viewer/ui/forms/desktop.py
+++ b/arcane_viewer/ui/forms/desktop.py
@@ -13,35 +13,33 @@
from PyQt6.QtCore import QRect, QSize, Qt, pyqtSlot
from PyQt6.QtGui import (QCloseEvent, QImage, QPainter, QPixmap, QResizeEvent,
- QShowEvent, QTransform)
+ QScreen, QShowEvent, QTransform)
from PyQt6.QtWidgets import (QApplication, QDialog, QGraphicsPixmapItem,
QMainWindow, QMessageBox)
import arcane_viewer.arcane as arcane
+import arcane_viewer.arcane.threads as arcane_threads
import arcane_viewer.ui.custom_widgets as arcane_widgets
import arcane_viewer.ui.dialogs as arcane_dialogs
-from arcane_viewer.arcane.threads import EventsThread, VirtualDesktopThread
logger = logging.getLogger(__name__)
class DesktopWindow(QMainWindow):
- def __init__(self, parent: Union[QDialog, QMainWindow], session: arcane.Session) -> None:
+ def __init__(self, connect_window: Union[QDialog, QMainWindow], session: arcane.Session) -> None:
super().__init__()
- self.tangent_universe = None
- self.scene_pixmap = None
- self.v_desktop = None
- self.universe_collapsed = False
+ self.scene_pixmap: Optional[QGraphicsPixmapItem] = None
+ self.v_desktop: Optional[QPixmap] = None
- self.desktop_thread: Optional[VirtualDesktopThread] = None
- self.events_thread: Optional[EventsThread] = None
+ self.desktop_thread: Optional[arcane_threads.VirtualDesktopThread] = None
+ self.events_thread: Optional[arcane_threads.EventsThread] = None
self.session = session
- # Instead of using QWidget parent property, we will use a custom attribute to store the parent window, this will
- # prevent icon to disappear on Windows taskbar when a parent is set to a Window but parent is hidden.
- self.parent = parent
+ # Instead of using QWidget parent property, we will use a custom attribute to store the "parent" window, this
+ # will prevent icon to disappear on Windows taskbar when a parent is set to a Window but parent is hidden.
+ self.connect_window = connect_window
# Set Window Properties, Layout, Title, Icon and Size
self.setWindowTitle("🖥 {} ({}) :: {} {}".format(
@@ -65,15 +63,11 @@ def thread_finished(self, on_error: bool) -> None:
if on_error:
QMessageBox.critical(self, "Error", "Something went wrong, check console output for more information.")
- """ If one thread (desktop or events) failed, we will close the other one, it is considered as a critical
- error """
- self.universe_collapsed = True
-
self.close()
def start_desktop_thread(self) -> None:
""" Start the desktop thread to handle remote desktop streaming """
- self.desktop_thread = VirtualDesktopThread(self.session)
+ self.desktop_thread = arcane_threads.VirtualDesktopThread(self.session)
self.desktop_thread.chunk_received.connect(self.update_scene)
self.desktop_thread.open_cellar_door.connect(self.open_cellar_door)
self.desktop_thread.thread_finished.connect(self.thread_finished)
@@ -82,7 +76,7 @@ def start_desktop_thread(self) -> None:
def start_events_thread(self, screen: arcane.Screen) -> None:
""" Start the events thread to handle remote desktop events """
- self.events_thread = EventsThread(self.session)
+ self.events_thread = arcane_threads.EventsThread(self.session)
self.events_thread.thread_finished.connect(self.thread_finished)
self.events_thread.start()
@@ -105,32 +99,21 @@ def close_cellar_door(self) -> None:
def showEvent(self, event: QShowEvent) -> None:
super().showEvent(event)
- if self.parent is not None:
- self.parent.hide()
+ if self.connect_window is not None:
+ self.connect_window.hide()
- def closeEvent(self, event: QCloseEvent) -> None:
+ def closeEvent(self, event: Optional[QCloseEvent]) -> None:
""" Overridden close method to handle the cleanup
`I Hope That When The World Comes To An End, I Can Breathe A Sigh Of Relief Because There Will Be So Much To
Look Forward To.`"""
- if self.universe_collapsed:
- do_close = True
- else:
- do_close = QMessageBox.question(
- self,
- "Close",
- "Are you sure you want to close the remote desktop session?"
- ) == QMessageBox.StandardButton.Yes
-
- if do_close:
- self.close_cellar_door()
+ self.close_cellar_door()
+ if event is not None:
event.accept()
- if self.parent is not None:
- self.parent.show()
- else:
- event.ignore()
+ if self.connect_window is not None:
+ self.connect_window.show()
def open_cellar_door(self, screen: arcane.Screen) -> None:
""" Initialize the virtual desktop (Tangent Universe) """
@@ -141,7 +124,7 @@ def open_cellar_door(self, screen: arcane.Screen) -> None:
self.tangent_universe.scene.addItem(self.scene_pixmap)
# Initialize the size of virtual desktop window regarding our current monitor screen size
- local_screen = None
+ local_screen: Optional[QScreen] = None
for local_screen_candidate in QApplication.screens():
if local_screen_candidate.geometry().intersects(self.geometry()):
@@ -152,6 +135,13 @@ def open_cellar_door(self, screen: arcane.Screen) -> None:
if local_screen is None:
local_screen = QApplication.primaryScreen()
+ if local_screen is None:
+ logger.error("Unable to find a valid screen to initialize the virtual desktop window.")
+
+ self.close()
+
+ return
+
local_screen_size = local_screen.size()
remote_screen_width = int(screen.width / local_screen.devicePixelRatio())
remote_screen_height = int(screen.height / local_screen.devicePixelRatio())
@@ -186,7 +176,11 @@ def open_cellar_door(self, screen: arcane.Screen) -> None:
def fit_scene(self) -> None:
""" Fit the scene (Hacky Technique) to the view """
- if self.tangent_universe is None or self.scene_pixmap is None:
+ if (
+ self.tangent_universe is None or
+ self.scene_pixmap is None or
+ self.v_desktop is None
+ ):
return
# Instead of bellow code:
@@ -204,7 +198,7 @@ def fit_scene(self) -> None:
def update_scene(self, chunk: QImage, x: int, y: int) -> None:
""" Update the virtual desktop with the received chunk """
- if self.v_desktop is None:
+ if self.v_desktop is None or self.scene_pixmap is None:
return
if chunk is None or not isinstance(chunk, QImage):
@@ -227,15 +221,16 @@ def resizeEvent(self, event: QResizeEvent) -> None:
self.fit_scene()
def screen_selection_rejected(self) -> None:
- self.universe_collapsed = True
self.close()
@pyqtSlot(list)
def display_screen_selection_dialog(self, screens: List[arcane.Screen]) -> None:
""" Display screen selection dialog """
- screen_selection_dialog = arcane_dialogs.ScreenSelectionWindow(self, screens)
+ screen_selection_dialog = arcane_dialogs.ScreenSelectionDialog(self, screens)
+
screen_selection_dialog.accepted.connect(lambda: self.desktop_thread.on_screen_selection_dialog_closed(
screen_selection_dialog.get_selected_screen()
- ))
+ ) if self.desktop_thread is not None else None)
+
screen_selection_dialog.rejected.connect(self.screen_selection_rejected)
screen_selection_dialog.exec()
diff --git a/arcane_viewer/ui/utilities.py b/arcane_viewer/ui/utilities.py
index 3324257..5a290aa 100644
--- a/arcane_viewer/ui/utilities.py
+++ b/arcane_viewer/ui/utilities.py
@@ -4,6 +4,8 @@
More information about the LICENSE on the LICENSE file in the root directory of the project.
"""
+from typing import Optional
+
from PyQt6.QtGui import QFont, QShowEvent
from PyQt6.QtWidgets import QDialog, QMainWindow, QWidget
@@ -13,10 +15,15 @@
class CenteredWindowMixin(QWidget):
""" Mixin to center a widget on the screen or on another widget """
def center_on_owner(self) -> None:
- if self.parent() is None:
- owner_geometry = self.screen().availableGeometry()
+ parent = self.parent()
+ screen = self.screen()
+
+ if parent is not None:
+ owner_geometry = parent.frameGeometry()
+ elif screen is not None:
+ owner_geometry = screen.availableGeometry()
else:
- owner_geometry = self.parent().frameGeometry()
+ return
subform_geometry = self.frameGeometry()
@@ -27,10 +34,10 @@ def center_on_owner(self) -> None:
class QCenteredDialog(QDialog, CenteredWindowMixin):
- def showEvent(self, event: QShowEvent) -> None:
+ def showEvent(self, event: Optional[QShowEvent]) -> None:
self.center_on_owner()
class QCenteredMainWindow(QMainWindow, CenteredWindowMixin):
- def showEvent(self, event: QShowEvent) -> None:
+ def showEvent(self, event: Optional[QShowEvent]) -> None:
self.center_on_owner()
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 0000000..fecf03f
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,2 @@
+[mypy]
+exclude = venv
\ No newline at end of file
diff --git a/setup.py b/setup.py
index f356233..868b029 100644
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,6 @@
from pathlib import Path
-from setuptools import find_packages, setup
+from setuptools import find_packages, setup # type: ignore[import]
this_directory = Path(__file__).parent
long_description = (this_directory / "README.md").read_text()
From 39aceec9763f975c8b0abcc20f2157620b34214e Mon Sep 17 00:00:00 2001
From: DarkCoderSc
Date: Thu, 22 Aug 2024 09:48:22 +0200
Subject: [PATCH 4/5] More code improvements, bug fixs etc..
---
arcane_viewer/arcane/client.py | 4 +-
arcane_viewer/arcane/threads/client_base.py | 2 +
arcane_viewer/arcane/threads/events.py | 6 ++
arcane_viewer/arcane/threads/v_desktop.py | 9 +--
.../ui/custom_widgets/tangeant_universe.py | 60 ++++++++++++-------
arcane_viewer/ui/dialogs/options.py | 15 ++---
.../server_certificate_add_or_edit.py | 7 ++-
arcane_viewer/ui/forms/connect.py | 2 +-
arcane_viewer/ui/forms/desktop.py | 23 ++++++-
arcane_viewer/ui/utilities.py | 2 +-
build.sh | 40 +++++++++----
11 files changed, 116 insertions(+), 54 deletions(-)
diff --git a/arcane_viewer/arcane/client.py b/arcane_viewer/arcane/client.py
index e3ee76e..beccd47 100644
--- a/arcane_viewer/arcane/client.py
+++ b/arcane_viewer/arcane/client.py
@@ -102,7 +102,7 @@ def read_line(self) -> str:
while True:
try:
b = self.conn.recv(1)
- except (ssl.SSLError, OSError):
+ except (Exception, ):
break
if not b:
@@ -117,7 +117,7 @@ def read_line(self) -> str:
def write_line(self, line: str) -> None:
try:
self.conn.write(line.encode('utf-8') + b'\r\n')
- except (ssl.SSLError, OSError):
+ except (Exception, ):
pass
def read_json(self) -> dict:
diff --git a/arcane_viewer/arcane/threads/client_base.py b/arcane_viewer/arcane/threads/client_base.py
index c146e54..b1f2667 100644
--- a/arcane_viewer/arcane/threads/client_base.py
+++ b/arcane_viewer/arcane/threads/client_base.py
@@ -5,6 +5,7 @@
"""
import logging
+import traceback
from abc import abstractmethod
from typing import Optional
@@ -48,6 +49,7 @@ def run(self) -> None:
except Exception as e:
if self._running:
logger.error(f"Thread `{self.__class__.__name__}` encountered an error: `{e}`")
+ traceback.print_exc()
on_error = True
finally:
self.stop()
diff --git a/arcane_viewer/arcane/threads/events.py b/arcane_viewer/arcane/threads/events.py
index a99083f..d6e2cfe 100644
--- a/arcane_viewer/arcane/threads/events.py
+++ b/arcane_viewer/arcane/threads/events.py
@@ -5,6 +5,7 @@
"""
import logging
+import ssl
from json.decoder import JSONDecodeError
from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot
@@ -25,11 +26,16 @@ def __init__(self, session: arcane.Session) -> None:
def client_execute(self) -> None:
""" Execute the client thread """
+ if self.client is None:
+ return
+
while self._running:
try:
event = self.client.read_json()
except JSONDecodeError:
continue
+ except (OSError, ssl.SSLError, ssl.SSLEOFError):
+ break
if event is None or "Id" not in event:
continue
diff --git a/arcane_viewer/arcane/threads/v_desktop.py b/arcane_viewer/arcane/threads/v_desktop.py
index 2aa1fb4..127a809 100644
--- a/arcane_viewer/arcane/threads/v_desktop.py
+++ b/arcane_viewer/arcane/threads/v_desktop.py
@@ -9,7 +9,8 @@
import logging
import struct
-from typing import List # To support python <= 3.8
+from typing import List # To support python <= 3.8, we need to use `List`
+from typing import Optional
from PyQt6.QtCore import QByteArray, QEventLoop, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QImage
@@ -30,8 +31,8 @@ class VirtualDesktopThread(ClientBaseThread):
def __init__(self, session: arcane.Session) -> None:
super().__init__(session, arcane.WorkerKind.Desktop)
- self.selected_screen = None
- self.event_loop = None
+ self.selected_screen: Optional[arcane.Screen] = None
+ self.event_loop: Optional[QEventLoop] = None
"""`Destruction is a form of creation. So the fact they burn the money is ironic. They just want to see what happens
when they tear the world apart. They want to change things.`, Donnie Darko"""
@@ -75,7 +76,7 @@ def client_execute(self) -> None:
while self._running:
try:
chunk_size, x, y = struct.unpack('III', self.client.conn.read(12))
- except struct.error:
+ except (Exception, ):
break
chunk_bytes = QByteArray()
diff --git a/arcane_viewer/ui/custom_widgets/tangeant_universe.py b/arcane_viewer/ui/custom_widgets/tangeant_universe.py
index d5ad627..64a1630 100644
--- a/arcane_viewer/ui/custom_widgets/tangeant_universe.py
+++ b/arcane_viewer/ui/custom_widgets/tangeant_universe.py
@@ -12,7 +12,7 @@
import logging
from sys import platform
-from typing import Optional, Tuple
+from typing import Optional, Tuple, Union
from PyQt6.QtCore import Qt, pyqtSlot
from PyQt6.QtGui import QClipboard, QKeyEvent, QMouseEvent, QWheelEvent
@@ -39,10 +39,12 @@ def __init__(self) -> None:
self.setMouseTracking(True)
self.events_thread: Optional[arcane_threads.EventsThread] = None
- self.screen: Optional[arcane.Screen] = None
+ self.desktop_screen: Optional[arcane.Screen] = None
- self.scene = QGraphicsScene()
- self.setScene(self.scene)
+ # instead of doing a simple ``setScene(QGraphicsScene())``, we will keep a reference to the scene to be updated
+ # and avoid slight overhead when calling `.scene()` method repeatedly.
+ self.desktop_scene = QGraphicsScene()
+ self.setScene(self.desktop_scene)
self.clipboard = QApplication.clipboard()
if self.clipboard is not None:
@@ -57,28 +59,35 @@ def set_event_thread(self, events_thread: arcane_threads.EventsThread) -> None:
def set_screen(self, screen: arcane.Screen) -> None:
""" Set the captured screen original information """
- self.screen = screen
+ self.desktop_screen = screen
- def fix_mouse_position(self, x: int, y: int) -> Tuple[int, int]:
+ def fix_mouse_position(self, x: Union[int, float], y: Union[int, float]) -> Tuple[int, int]:
""" Fix the virtual desktop mouse position to the original screen position """
- if self.screen is None:
+ x = int(x)
+ y = int(y)
+
+ if self.desktop_screen is None:
return x, y
- if self.screen.width > self.width():
- x_ratio = self.screen.width / self.width()
+ if self.desktop_screen.width > self.width():
+ x_ratio = self.desktop_screen.width / self.width()
else:
- x_ratio = self.width() / self.screen.width
+ x_ratio = self.width() / self.desktop_screen.width
- if self.screen.height > self.height():
- y_ratio = self.screen.height / self.height()
+ if self.desktop_screen.height > self.height():
+ y_ratio = self.desktop_screen.height / self.height()
else:
- y_ratio = self.height() / self.screen.height
+ y_ratio = self.height() / self.desktop_screen.height
# We must take in account both virtual desktop size and original screen X, Y position.
- return (self.screen.x + (x * x_ratio),
- self.screen.y + (y * y_ratio))
+ return (self.desktop_screen.x + (x * x_ratio),
+ self.desktop_screen.y + (y * y_ratio))
+
+ def send_mouse_event(self, x: Union[int, float], y: Union[int, float], state: arcane.MouseState,
+ button: arcane.MouseButton) -> None:
+ x = int(x)
+ y = int(y)
- def send_mouse_event(self, x: int, y: int, state: arcane.MouseState, button: arcane.MouseButton) -> None:
""" Push mouse event to the events thread """
if self.events_thread is None:
return
@@ -114,21 +123,30 @@ def mouse_click(self, event: QMouseEvent) -> None:
self.mouse_action_handler(event, True)
self.mouse_action_handler(event, False)
- def mousePressEvent(self, event: QMouseEvent) -> None:
+ def mousePressEvent(self, event: Optional[QMouseEvent]) -> None:
+ if event is None:
+ return
+
self.mouse_action_handler(event, True)
- def mouseReleaseEvent(self, event: QMouseEvent) -> None:
+ def mouseReleaseEvent(self, event: Optional[QMouseEvent]) -> None:
+ if event is None:
+ return
+
self.mouse_action_handler(event, False)
- def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
+ def mouseDoubleClickEvent(self, event: Optional[QMouseEvent]) -> None:
""" Override mouseDoubleClickEvent method to simulate a remote double click event
Do something better than this is possible? (cross-platform) """
+ if event is None:
+ return
+
self.mouse_click(event)
self.mouse_click(event)
- def mouseMoveEvent(self, event: QMouseEvent) -> None:
+ def mouseMoveEvent(self, event: Optional[QMouseEvent]) -> None:
""" Override mouseMoveEvent method to handle mouse move events """
- if self.events_thread is None:
+ if self.events_thread is None or event is None:
return
pos = event.position()
diff --git a/arcane_viewer/ui/dialogs/options.py b/arcane_viewer/ui/dialogs/options.py
index 39ea224..4ab6342 100644
--- a/arcane_viewer/ui/dialogs/options.py
+++ b/arcane_viewer/ui/dialogs/options.py
@@ -36,11 +36,12 @@ def __init__(self, options_dialog: QDialog, settings: QSettings) -> None:
options_layout.setContentsMargins(0, 8, 0, 0)
core_layout.addLayout(options_layout)
+ # Clipboard Sharing Mode
clipboard_sharing_label = QLabel("Clipboard Sharing:")
self.clipboard_sharing_combobox = QComboBox()
- for value in arcane.ClipboardMode:
- self.clipboard_sharing_combobox.addItem(value.name, userData=value)
+ for clipboard_mode in arcane.ClipboardMode:
+ self.clipboard_sharing_combobox.addItem(clipboard_mode.name, userData=clipboard_mode)
options_layout.addWidget(clipboard_sharing_label, 0, 0)
options_layout.addWidget(self.clipboard_sharing_combobox, 0, 1)
@@ -64,14 +65,14 @@ def __init__(self, options_dialog: QDialog, settings: QSettings) -> None:
# Packet Size (Optimization)
packet_size_label = QLabel("Packet Size:")
self.packet_size_input = QComboBox()
- for value in arcane.PacketSize:
- self.packet_size_input.addItem(value.display_name, userData=value)
+ for packet_size in arcane.PacketSize:
+ self.packet_size_input.addItem(packet_size.display_name, userData=packet_size)
# Block Size (Optimization)
block_size_label = QLabel("Block Size:")
self.block_size_input = QComboBox()
- for value in arcane.BlockSize:
- self.block_size_input.addItem(value.display_name, userData=value)
+ for block_size in arcane.BlockSize:
+ self.block_size_input.addItem(block_size.display_name, userData=block_size)
# Place Inputs in our Grid Layout
desktop_capture_group_layout.addWidget(image_quality_label, 0, 0)
@@ -395,7 +396,7 @@ def reset_settings(self) -> None:
def adjust_size(self) -> None:
self.setFixedSize(420, self.sizeHint().height())
- def showEvent(self, event: QShowEvent) -> None:
+ def showEvent(self, event: Optional[QShowEvent]) -> None:
super().showEvent(event)
self.load_settings()
diff --git a/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py b/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py
index 9f6d068..7a4db23 100644
--- a/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py
+++ b/arcane_viewer/ui/dialogs/options_dialogs/server_certificate_add_or_edit.py
@@ -17,8 +17,11 @@
class ServerCertificateAddOrEditDialog(utilities.QCenteredDialog):
- def __init__(self, parent: Optional[Union[QDialog, QMainWindow]], settings: QSettings, fingerprint: str = None)\
- -> None:
+ def __init__(
+ self,
+ parent: Optional[Union[QDialog, QMainWindow]],
+ settings: QSettings, fingerprint: Optional[str] = None
+ ) -> None:
super().__init__(parent)
self.settings = settings
diff --git a/arcane_viewer/ui/forms/connect.py b/arcane_viewer/ui/forms/connect.py
index 2b572ca..a2871d4 100644
--- a/arcane_viewer/ui/forms/connect.py
+++ b/arcane_viewer/ui/forms/connect.py
@@ -201,7 +201,7 @@ def connect_thread_finished(self, session: Optional[arcane.Session] = None) -> N
if self.__connecting_dialog is not None and self.__connecting_dialog.isVisible():
self.__connecting_dialog.close()
- if session is None:
+ if session is None or session.server_fingerprint is None:
return
self.session = session
diff --git a/arcane_viewer/ui/forms/desktop.py b/arcane_viewer/ui/forms/desktop.py
index f86462c..3092644 100644
--- a/arcane_viewer/ui/forms/desktop.py
+++ b/arcane_viewer/ui/forms/desktop.py
@@ -9,6 +9,7 @@
"""
import logging
+import time
from typing import List, Optional, Union
from PyQt6.QtCore import QRect, QSize, Qt, pyqtSlot
@@ -56,6 +57,12 @@ def __init__(self, connect_window: Union[QDialog, QMainWindow], session: arcane.
self.tangent_universe = arcane_widgets.TangentUniverse()
self.setCentralWidget(self.tangent_universe)
+ """
+ # FPS Counter (Debugging)
+ self.FPS_counter = 0
+ self.FPS_Elapsed = time.time()
+ """
+
self.start_desktop_thread()
def thread_finished(self, on_error: bool) -> None:
@@ -96,7 +103,7 @@ def close_cellar_door(self) -> None:
self.events_thread.stop()
self.events_thread.wait()
- def showEvent(self, event: QShowEvent) -> None:
+ def showEvent(self, event: Optional[QShowEvent]) -> None:
super().showEvent(event)
if self.connect_window is not None:
@@ -121,7 +128,7 @@ def open_cellar_door(self, screen: arcane.Screen) -> None:
self.v_desktop.fill(Qt.GlobalColor.black)
self.scene_pixmap = QGraphicsPixmapItem(self.v_desktop)
- self.tangent_universe.scene.addItem(self.scene_pixmap)
+ self.tangent_universe.desktop_scene.addItem(self.scene_pixmap)
# Initialize the size of virtual desktop window regarding our current monitor screen size
local_screen: Optional[QScreen] = None
@@ -216,7 +223,17 @@ def update_scene(self, chunk: QImage, x: int, y: int) -> None:
self.fit_scene()
- def resizeEvent(self, event: QResizeEvent) -> None:
+ """
+ # FPS Counter (Debugging)
+ self.FPS_counter += 1
+ elapsed = time.time() - self.FPS_Elapsed
+ if elapsed >= 1.0:
+ logger.debug("FPS: {}".format(self.FPS_counter))
+ self.FPS_counter = 0
+ self.FPS_Elapsed = time.time()
+ """
+
+ def resizeEvent(self, event: Optional[QResizeEvent]) -> None:
""" Overridden resizeEvent method to fit the scene to the view """
self.fit_scene()
diff --git a/arcane_viewer/ui/utilities.py b/arcane_viewer/ui/utilities.py
index 5a290aa..5a50260 100644
--- a/arcane_viewer/ui/utilities.py
+++ b/arcane_viewer/ui/utilities.py
@@ -18,7 +18,7 @@ def center_on_owner(self) -> None:
parent = self.parent()
screen = self.screen()
- if parent is not None:
+ if parent is not None and isinstance(parent, QWidget):
owner_geometry = parent.frameGeometry()
elif screen is not None:
owner_geometry = screen.availableGeometry()
diff --git a/build.sh b/build.sh
index 907c14a..0566fb2 100755
--- a/build.sh
+++ b/build.sh
@@ -13,7 +13,7 @@ preflight_question() {
response=$(echo "$response" | tr '[:upper:]' '[:lower:]')
if [ "$response" != "y" ]; then
- echo "(!) Please do it!"
+ echo "(!) Do it!"
exit 1
fi
}
@@ -24,6 +24,9 @@ skip_tox=false
# arg: --skip-flake
skip_flake=false
+# arg: --skip-mypy
+skip_mypy=false
+
# Parse arguments
for arg in "$@"; do
case $arg in
@@ -36,6 +39,11 @@ for arg in "$@"; do
skip_flake=true
shift
;;
+
+ --skip-mypy)
+ skip_mypy=true
+ shift
+ ;;
*)
;;
esac
@@ -46,41 +54,47 @@ preflight_question "Have you updated the arcane_viewer.arcane.constants.APP_VERS
preflight_question "Is this version reflected on the setup.py"
# Clean up things
-echo "Cleaning up things..."
+echo "[+] Cleaning up things..."
rm -f dist/*.tar.gz
rm -f dist/*.whl
# Tox testing
if [ "$skip_tox" = false ]; then
- echo "Running Tox Testing..."
+ echo "[+] Tox..."
tox
if [ $? -ne 0 ]; then
- echo "(!) Tox Testing Failed!"
+ echo "(!) Failed!"
exit 1
fi
fi
-# Active Python 3.12 Virtual Environment
-source venv/312/bin/activate
-
# Run isort
-echo "Running isort..."
+echo "[+] isort..."
isort .
# Flake8 Testing
if [ "$skip_flake" = false ]; then
+ echo "[+] Flake8..."
flake8 .
if [ $? -ne 0 ]; then
- echo "(!) Flake8 Testing Failed!"
+ echo "(!) Failed!"
exit 2
fi
fi
+# mypy Testing
+if [ "$skip_mypy" = false ]; then
+ echo "[+] Mypy..."
+ mypy .
+ if [ $? -ne 0 ]; then
+ echo "(!) Failed!"
+ exit 3
+ fi
+fi
+
# Build Package
-echo "Building Package..."
+echo "[+] Building Package..."
python setup.py sdist bdist_wheel
python setup.py clean --all
-deactivate
-
-echo "Done."
\ No newline at end of file
+echo "[*] Done."
\ No newline at end of file
From 38753c224e34bede23828e3b4c0a03c97a71ce48 Mon Sep 17 00:00:00 2001
From: DarkCoderSc
Date: Thu, 22 Aug 2024 11:39:16 +0200
Subject: [PATCH 5/5] finalize version
---
README.md | 38 +++++++++++++++---
arcane_viewer/ui/forms/desktop.py | 1 -
.../images/screenshots/virtual_desktop.png | Bin 0 -> 3937606 bytes
3 files changed, 33 insertions(+), 6 deletions(-)
create mode 100644 resources/images/screenshots/virtual_desktop.png
diff --git a/README.md b/README.md
index c6df0ae..f2b22a4 100644
--- a/README.md
+++ b/README.md
@@ -39,14 +39,32 @@ The project was renamed to Arcane to avoid the generic nature of the previous na
## Version Table
+> ⓘ You can use any version of the viewer with any version of the server, as long as the protocol version matches. The protocol version ensures compatibility between the viewer and the server.
+
+> ⓘ It is recommended to always use the latest versions of both the viewer and the server whenever possible. This ensures compatibility between the two and provides the best experience.
+
+### Arcane Viewer Table
+
+| Version | Protocol Version | Release Date |
+|-----------------|------------------|----------------|
+| 1.0.0b1 (Beta) | 5.0.0b1 | 01 August 2024 |
+| 1.0.0b2 (Beta) | 5.0.0b1 | 05 August 2024 |
+| 1.0.3 (Beta) | 5.0.0b1 | 12 August 2024 |
+| 1.0.4 (Beta) | 5.0.1 | 15 August 2024 |
+| 🟢 1.0.5 (Beta) | 5.0.1 | 22 August 2024 |
+
+### Arcane Server Table
+
| Version | Protocol Version | Release Date |
|----------------|------------------|----------------|
| 1.0.0b1 (Beta) | 5.0.0b1 | 01 August 2024 |
| 1.0.0b2 (Beta) | 5.0.0b1 | 05 August 2024 |
-| 1.0.3 (Beta) | 5.0.0b1 | 12 August 2024 |
-| 1.0.4 (Beta) | 5.0.1 | 15 August 2024 |
+| 1.0.3 | 5.0.0b1 | 12 August 2024 |
+| 🟢 1.0.4 | 5.0.1 | 15 August 2024 |
+
+* 🟢 Head version
-> You can use any version of the viewer with any version of the server, as long as the protocol version matches. The protocol version ensures compatibility between the viewer and the server.
+> ⓘ Since version 1.0.4, the server version will only be updated when there are changes to the server code. It will no longer automatically reflect the latest viewer version as it did before.
## Components
@@ -201,6 +219,10 @@ You can then pass the output base64 certificate file to parameter `EncodedCertif
+
+
+
+
@@ -211,6 +233,10 @@ You can then pass the output base64 certificate file to parameter `EncodedCertif
## Change Log
+### Version 1.0.5 (Beta)
+
+This release focuses on improving the code structure through extensive refactoring and resolving infrequent bugs caused by previously unhandled edge cases. Type hinting has been fully implemented, and the code is now nearly ready for production deployment.
+
### Version 1.0.4 (Beta)
- [x] Clipboard synchronization has been implemented, allowing users to copy and paste text between the viewer and the server.
@@ -232,6 +258,8 @@ You can then pass the output base64 certificate file to parameter `EncodedCertif
---
-I’m dedicating this project to the amazing HackTheBox France Meetup community! 🇫🇷
-

+
+
+ I’m dedicating this project to the amazing HackTheBox France Meetup community! 🇫🇷
+
diff --git a/arcane_viewer/ui/forms/desktop.py b/arcane_viewer/ui/forms/desktop.py
index 3092644..b482bf8 100644
--- a/arcane_viewer/ui/forms/desktop.py
+++ b/arcane_viewer/ui/forms/desktop.py
@@ -9,7 +9,6 @@
"""
import logging
-import time
from typing import List, Optional, Union
from PyQt6.QtCore import QRect, QSize, Qt, pyqtSlot
diff --git a/resources/images/screenshots/virtual_desktop.png b/resources/images/screenshots/virtual_desktop.png
new file mode 100644
index 0000000000000000000000000000000000000000..a17ec8504525f0904539c809cd63bd66709f1fe2
GIT binary patch
literal 3937606
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
Qa@KtzbA
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