From 4f9a47027cb8c621114278725473eb8f0cc9a51f Mon Sep 17 00:00:00 2001 From: miruka Date: Thu, 11 Apr 2019 13:22:43 -0400 Subject: [PATCH] matrix-nio backend start, QGuiApplication class - Started work on the matrix-nio backend, which will be used instead of matrix-python-sdk for greater control and cleaner design - Have an Application (QGuiApplication) class to habdle argument parsing and setting some Qt properties like application name --- harmonyqml/__about__.py | 9 +- harmonyqml/__init__.py | 14 +- harmonyqml/app.py | 26 ++++ harmonyqml/backend/list_model.py | 22 +-- harmonyqml/backend/matrix_nio/__init__.py | 4 + harmonyqml/backend/matrix_nio/backend.py | 27 ++++ harmonyqml/backend/matrix_nio/client.py | 85 ++++++++++ .../backend/matrix_nio/client_manager.py | 147 ++++++++++++++++++ harmonyqml/backend/matrix_nio/net.py | 95 +++++++++++ harmonyqml/components/UI.qml | 8 +- harmonyqml/components/Window.qml | 1 - harmonyqml/engine.py | 8 +- 12 files changed, 402 insertions(+), 44 deletions(-) create mode 100644 harmonyqml/app.py create mode 100644 harmonyqml/backend/matrix_nio/__init__.py create mode 100644 harmonyqml/backend/matrix_nio/backend.py create mode 100644 harmonyqml/backend/matrix_nio/client.py create mode 100644 harmonyqml/backend/matrix_nio/client_manager.py create mode 100644 harmonyqml/backend/matrix_nio/net.py diff --git a/harmonyqml/__about__.py b/harmonyqml/__about__.py index 5bc361d3..0c423059 100644 --- a/harmonyqml/__about__.py +++ b/harmonyqml/__about__.py @@ -3,10 +3,11 @@ """""" -__pkg_name__ = "harmonyqml" -__version__ = "0.1.0" -__status__ = "Development" -# __status__ = "Production" +__pkg_name__ = "harmonyqml" +__pretty_name__ = "Harmony QML" +__version__ = "0.1.0" +__status__ = "Development" +# __status__ = "Production" __author__ = "miruka" __email__ = "miruka@disroot.org" diff --git a/harmonyqml/__init__.py b/harmonyqml/__init__.py index d31fbb9a..bd8a6af1 100644 --- a/harmonyqml/__init__.py +++ b/harmonyqml/__init__.py @@ -3,20 +3,10 @@ import sys -from PyQt5.QtGui import QGuiApplication - -from .engine import Engine +from . import app # logging.basicConfig(level=logging.INFO) def run() -> None: - try: - sys.argv.index("--debug") - debug = True - except ValueError: - debug = False - - app = QGuiApplication(sys.argv) - engine = Engine(app, debug=debug) - engine.show_window() + _ = app.Application(sys.argv) diff --git a/harmonyqml/app.py b/harmonyqml/app.py new file mode 100644 index 00000000..966a7f2e --- /dev/null +++ b/harmonyqml/app.py @@ -0,0 +1,26 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under GPLv3. + +from typing import List, Optional + +from PyQt5.QtGui import QGuiApplication + +from . import __about__ + + +class Application(QGuiApplication): + def __init__(self, args: Optional[List[str]] = None) -> None: + try: + args.index("--debug") # type: ignore + debug = True + except (AttributeError, ValueError): + debug = False + + super().__init__(args or []) + + self.setApplicationName(__about__.__pkg_name__) + self.setApplicationDisplayName(__about__.__pretty_name__) + + from .engine import Engine + engine = Engine(self, debug=debug) + engine.show_window() diff --git a/harmonyqml/backend/list_model.py b/harmonyqml/backend/list_model.py index f8f51ede..a9417fa2 100644 --- a/harmonyqml/backend/list_model.py +++ b/harmonyqml/backend/list_model.py @@ -4,28 +4,19 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple, Union from namedlist import namedlist from PyQt5.QtCore import ( - QAbstractListModel, QModelIndex, Qt, pyqtProperty, pyqtSlot, pyqtSignal + QAbstractListModel, QModelIndex, Qt, pyqtProperty, pyqtSlot ) NewValue = Union[Mapping[str, Any], Sequence] class _QtListModel(QAbstractListModel): - updated = pyqtSignal() - def __init__(self) -> None: super().__init__() self._ref_namedlist = None self._roles: Tuple[str, ...] = () self._list: list = [] - self._update_counter: int = 0 - - for sig in (self.dataChanged, self.layoutChanged, self.modelReset, - self.rowsInserted, self.rowsMoved, self.rowsRemoved): - sig.connect(self.updated.emit) - - def roleNames(self) -> Dict[int, bytes]: return {Qt.UserRole + i: bytes(f, "utf-8") for i, f in enumerate(self._roles, 1)} @@ -70,7 +61,7 @@ class _QtListModel(QAbstractListModel): @pyqtSlot(int, result="QVariantMap") - def get(self, index: int) -> Optional[Dict[str, Any]]: + def get(self, index: int) -> Dict[str, Any]: return self._list[index]._asdict() @@ -82,7 +73,7 @@ class _QtListModel(QAbstractListModel): self.endInsertRows() - @pyqtProperty(int, constant=True) + @pyqtProperty(int) def count(self) -> int: return self.rowCount() @@ -151,13 +142,6 @@ class _QtListModel(QAbstractListModel): self.endRemoveRows() - @pyqtProperty(int, notify=updated) - def reloadThis(self) -> int: - # http://www.mardy.it/blog/2016/11/qml-trick-force-re-evaluation-of.html - self._update_counter += 1 - return self._update_counter - - class ListModel(MutableSequence): def __init__(self, initial_data: Optional[List[NewValue]] = None) -> None: super().__init__() diff --git a/harmonyqml/backend/matrix_nio/__init__.py b/harmonyqml/backend/matrix_nio/__init__.py new file mode 100644 index 00000000..7fc6b031 --- /dev/null +++ b/harmonyqml/backend/matrix_nio/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under GPLv3. + +from .backend import MatrixNioBackend diff --git a/harmonyqml/backend/matrix_nio/backend.py b/harmonyqml/backend/matrix_nio/backend.py new file mode 100644 index 00000000..5358badd --- /dev/null +++ b/harmonyqml/backend/matrix_nio/backend.py @@ -0,0 +1,27 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under GPLv3. + +from typing import Any, DefaultDict, Dict, NamedTuple, Optional + +from PyQt5.QtCore import QDateTime, QObject, pyqtProperty, pyqtSlot + +from matrix_client.user import User as MatrixUser + +from ..base import Backend, User +from .client_manager import ClientManager + + +class MatrixNioBackend(Backend): + def __init__(self) -> None: + super().__init__() + self._client_manager = ClientManager() + + # a = self._client_manager + # from PyQt5.QtCore import pyqtRemoveInputHook as PRI; import pdb; PRI(); pdb.set_trace() + + self._client_manager.configLoad() + + + @pyqtProperty("QVariant") + def clientManager(self): + return self._client_manager diff --git a/harmonyqml/backend/matrix_nio/client.py b/harmonyqml/backend/matrix_nio/client.py new file mode 100644 index 00000000..497d709a --- /dev/null +++ b/harmonyqml/backend/matrix_nio/client.py @@ -0,0 +1,85 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under GPLv3. + +import functools +from concurrent.futures import Future, ThreadPoolExecutor +from threading import Event +from typing import Callable, DefaultDict + +from PyQt5.QtCore import QObject, pyqtSlot + +import nio +import nio.responses as nr + +# One pool per hostname/remote server; +# multiple Client for different accounts on the same server can exist. +_POOLS: DefaultDict[str, ThreadPoolExecutor] = \ + DefaultDict(lambda: ThreadPoolExecutor(max_workers=6)) + + +def futurize(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args, **kwargs) -> Future: + return args[0].pool.submit(func, *args, **kwargs) # args[0] = self + return wrapper + + +class Client(QObject): + def __init__(self, hostname: str, username: str, device_id: str = "" + ) -> None: + super().__init__() + + host, *port = hostname.split(":") + self.host: str = host + self.port: int = int(port[0]) if port else 443 + + self.nio: nio.client.HttpClient = \ + nio.client.HttpClient(self.host, username, device_id) + + self.pool: ThreadPoolExecutor = _POOLS[self.host] + + from .net import NetworkManager + self.net: NetworkManager = NetworkManager(self) + + self._stop_sync: Event = Event() + + + def __repr__(self) -> str: + return "%s(host=%r, port=%r, user_id=%r)" % \ + (type(self).__name__, self.host, self.port, self.nio.user_id) + + + @pyqtSlot(str) + @pyqtSlot(str, str) + @futurize + def login(self, password: str, device_name: str = "") -> None: + self.net.write(self.nio.connect()) + self.net.talk(self.nio.login, password, device_name) + self.startSyncing() + + + @pyqtSlot(str, str, str) + @futurize + def resumeSession(self, user_id: str, token: str, device_id: str + ) -> None: + self.net.write(self.nio.connect()) + response = nr.LoginResponse(user_id, device_id, token) + self.nio.receive_response(response) + self.startSyncing() + + + @pyqtSlot() + @futurize + def logout(self) -> None: + self._stop_sync.set() + self.net.write(self.nio.disconnect()) + + + @futurize + def startSyncing(self) -> None: + while True: + print(self, self.net.talk(self.nio.sync, timeout=10)) + + if self._stop_sync.is_set(): + self._stop_sync.clear() + break diff --git a/harmonyqml/backend/matrix_nio/client_manager.py b/harmonyqml/backend/matrix_nio/client_manager.py new file mode 100644 index 00000000..817f4ca8 --- /dev/null +++ b/harmonyqml/backend/matrix_nio/client_manager.py @@ -0,0 +1,147 @@ +# Copyright 2018 miruka +# This file is part of harmonyqt, licensed under GPLv3. + +import hashlib +import json +import os +import platform +import threading +from concurrent.futures import Future +from typing import Dict, Optional + +from atomicfile import AtomicFile +from PyQt5.QtCore import QObject, QStandardPaths, pyqtProperty, pyqtSlot + +from harmonyqml import __about__ + +from .client import Client + +AccountConfig = Dict[str, Dict[str, str]] + +_CONFIG_LOCK = threading.Lock() +# _CRYPT_DB_LOCK = threading.Lock() + + +class ClientManager(QObject): + def __init__(self) -> None: + super().__init__() + self._clients: Dict[str, Client] = {} + + + def __repr__(self) -> str: + return f"{type(self).__name__}(clients={self.clients!r})" + + + @pyqtProperty("QVariantMap") + def clients(self): + return self._clients + + + @pyqtSlot() + def configLoad(self) -> None: + for user_id, info in self.configAccounts().items(): + cli = Client(info["hostname"], user_id) + + def on_done(_: Future, cli=cli) -> None: + self._clients[cli.nio.user_id] = cli + + cli.resumeSession(user_id, info["token"], info["device_id"])\ + .add_done_callback(on_done) + + + @pyqtSlot(str, str, str) + @pyqtSlot(str, str, str) + def new(self, hostname: str, username: str, password: str, + device_id: str = "") -> None: + + cli = Client(hostname, username, device_id) + + def on_done(_: Future, cli=cli) -> None: + self._clients[cli.nio.user_id] = cli + + cli.login(password, self.defaultDeviceName).add_done_callback(on_done) + + + @pyqtSlot(str) + def delete(self, user_id: str) -> None: + client = self._clients.pop(user_id, None) + if client: + client.logout() + + + @pyqtProperty(str) + def defaultDeviceName(self) -> str: # pylint: disable=no-self-use + os_ = f" on {platform.system()}".rstrip() + os_ = f"{os_} {platform.release()}".rstrip() if os_ != " on" else "" + return f"{__about__.__pretty_name__}{os}" + + + # Standard file paths + + @staticmethod + def _get_standard_path(kind: QStandardPaths.StandardLocation, + file: str, + initial_content: Optional[str] = None) -> str: + relative_path = file.replace("/", os.sep) + + path = QStandardPaths.locate(kind, relative_path) + if path: + return path + + base_dir = QStandardPaths.writableLocation(kind) + path = f"{base_dir}{os.sep}{relative_path}" + os.makedirs(os.path.split(path)[0], exist_ok=True) + + if initial_content is not None: + with AtomicFile(path, "w") as new: + new.write(initial_content) + + return path + + + def getAccountConfigPath(self) -> str: + return self._get_standard_path( + QStandardPaths.AppConfigLocation, "accounts.json", "[]" + ) + + + def getCryptDBPath(self, user_id: str) -> str: + safe_filename = hashlib.md5(user_id.encode("utf-8")).hexdigest() + return self._get_standard_path( + QStandardPaths.AppDataLocation, f"encryption/{safe_filename}.db" + ) + + + # Config file operations + + def configAccounts(self) -> AccountConfig: + with open(self.getAccountConfigPath(), "r") as file: + return json.loads(file.read().strip()) or {} + + + @pyqtSlot(Client) + def configAdd(self, client: Client) -> None: + self._write_config({ + **self.configAccounts(), + **{client.nio.user_id: { + "hostname": client.nio.host, + "token": client.nio.access_token, + "device_id": client.nio.device_id, + }} + }) + + + @pyqtSlot(str) + def configDelete(self, user_id: str) -> None: + self._write_config({ + uid: info + for uid, info in self.configAccounts().items() if uid != user_id + }) + + + def _write_config(self, accounts: AccountConfig) -> None: + js = json.dumps(accounts, indent=4, ensure_ascii=False, sort_keys=True) + + with _CONFIG_LOCK: + with AtomicFile(self.getAccountConfigPath(), "w") as new: + new.write(js) diff --git a/harmonyqml/backend/matrix_nio/net.py b/harmonyqml/backend/matrix_nio/net.py new file mode 100644 index 00000000..9495b492 --- /dev/null +++ b/harmonyqml/backend/matrix_nio/net.py @@ -0,0 +1,95 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under GPLv3. + +import logging +import socket +import ssl +import time +from typing import Callable, Optional, Tuple +from uuid import UUID + +import nio.responses as nr + +from .client import Client + +OptSock = Optional[ssl.SSLSocket] +NioRequestFunc = Callable[..., Tuple[UUID, bytes]] + + +class NioErrorResponse(Exception): + def __init__(self, response: nr.ErrorResponse) -> None: + self.response = response + super().__init__(str(response)) + + +class NetworkManager: + def __init__(self, client: Client) -> None: + self.client = client + self._ssl_context: ssl.SSLContext = ssl.create_default_context() + self._ssl_session: Optional[ssl.SSLSession] = None + + + def _get_socket(self) -> ssl.SSLSocket: + sock = self._ssl_context.wrap_socket( # type: ignore + socket.create_connection((self.client.host, self.client.port)), + server_hostname = self.client.host, + session = self._ssl_session, + ) + self._ssl_session = self._ssl_session or sock.session + return sock + + + @staticmethod + def _close_socket(sock: socket.socket) -> None: + sock.shutdown(how=socket.SHUT_RDWR) + sock.close() + + + def read(self, with_sock: OptSock = None) -> nr.Response: + sock = with_sock or self._get_socket() + + response = None + while not response: + self.client.nio.receive(sock.recv(4096)) + response = self.client.nio.next_response() + + if isinstance(response, nr.ErrorResponse): + raise NioErrorResponse(response) + + if not with_sock: + self._close_socket(sock) + + return response + + + def write(self, data: bytes, with_sock: OptSock = None) -> None: + sock = with_sock or self._get_socket() + sock.sendall(data) + + if not with_sock: + self._close_socket(sock) + + + def talk(self, nio_func: NioRequestFunc, *args, **kwargs) -> nr.Response: + while True: + to_send = nio_func(*args, **kwargs)[1] + sock = self._get_socket() + + try: + self.write(to_send, sock) + response = self.read(sock) + + except NioErrorResponse as err: + logging.error("read bad response: %s", err) + self._close_socket(sock) + time.sleep(10) + + except Exception as err: + logging.error("talk exception: %r", err) + break + + else: + break + + self._close_socket(sock) + return response diff --git a/harmonyqml/components/UI.qml b/harmonyqml/components/UI.qml index 1efbf2b4..a39a5b47 100644 --- a/harmonyqml/components/UI.qml +++ b/harmonyqml/components/UI.qml @@ -24,10 +24,10 @@ Controls1.SplitView { } id: "pageStack" - initialItem: Chat.Root { - user: Backend.accountsModel.get(0) - room: Backend.roomsModel[Backend.accountsModel.get(0).user_id].get(0) - } +// initialItem: Chat.Root { + //user: Backend.accountsModel.get(0) + //room: Backend.roomsModel[Backend.accountsModel.get(0).user_id].get(0) + //} onCurrentItemChanged: currentItem.forceActiveFocus() diff --git a/harmonyqml/components/Window.qml b/harmonyqml/components/Window.qml index 4d64a420..1560094f 100644 --- a/harmonyqml/components/Window.qml +++ b/harmonyqml/components/Window.qml @@ -5,7 +5,6 @@ ApplicationWindow { visible: true width: 640 height: 700 - title: "Harmony QML" Loader { anchors.fill: parent diff --git a/harmonyqml/engine.py b/harmonyqml/engine.py index 2314d94c..51785835 100644 --- a/harmonyqml/engine.py +++ b/harmonyqml/engine.py @@ -7,23 +7,23 @@ from pathlib import Path from typing import Generator, Optional from PyQt5.QtCore import QFileSystemWatcher, QObject, QTimer -from PyQt5.QtGui import QGuiApplication from PyQt5.QtQml import QQmlApplicationEngine from .__about__ import __doc__ -from .backend import DummyBackend +from .app import Application +from .backend.matrix_nio.backend import MatrixNioBackend as CurrentBackend # logging.basicConfig(level=logging.INFO) class Engine(QQmlApplicationEngine): def __init__(self, - app: QGuiApplication, + app: Application, debug: bool = False, parent: Optional[QObject] = None) -> None: super().__init__(parent) self.app = app - self.backend = DummyBackend() + self.backend = CurrentBackend() self.app_dir = Path(sys.argv[0]).resolve().parent # Set QML properties