--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/RemoteServerInterface/EricServerInterface.py Mon Jan 29 19:50:44 2024 +0100 @@ -0,0 +1,680 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the interface to the eric remote server. +""" + +import json +import struct +import uuid +import zlib + +from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot +from PyQt6.QtGui import QAction, QKeySequence +from PyQt6.QtNetwork import QAbstractSocket, QTcpSocket +from PyQt6.QtWidgets import QDialog, QMenu, QToolBar, QToolButton + +from eric7 import Preferences, Utilities +from eric7.EricGui import EricPixmapCache +from eric7.EricGui.EricAction import EricAction +from eric7.EricWidgets import EricMessageBox +from eric7.RemoteServer.EricRequestCategory import EricRequestCategory + + +class EricServerInterface(QObject): + """ + Class implementing the interface to the eric remote server. + + @signal showMenu(name:str, menu:QMenu) emitted when a menu is about to be shown. + The name of the menu and a reference to the menu are given. + + @signal connectionStateChanged(state:bool) emitted to indicate a change of the + connection state + @signal remoteReply(category:int, request:str, params:dict) emitted to deliver the + reply of an unknown category + @signal remoteDebuggerReply(request:str, params:dict) emitted to deliver the reply + of a remote server debugger request + @signal remoteEchoReply(request:str, params:dict) emitted to deliver the reply of + a remote server echo request + @signal remoteFileSystemReply(request:str, params:dict) emitted to deliver the + reply of a remote server file system request + @signal remoteProjectReply(request:str, params:dict) emitted to deliver the reply + of a remote server project related request + @signal remoteServerReply(request:str, params:dict) emitted to deliver the reply + of a remote server control request + """ + + showMenu = pyqtSignal(str, QMenu) + + connectionStateChanged = pyqtSignal(bool) + + remoteReply = pyqtSignal(int, str, dict) + + remoteDebuggerReply = pyqtSignal(str, dict) + remoteEchoReply = pyqtSignal(str, dict) + remoteFileSystemReply = pyqtSignal(str, dict) + remoteProjectReply = pyqtSignal(str, dict) + remoteServerReply = pyqtSignal(str, dict) + + def __init__(self, parent=None): + """ + Constructor + + @param parent reference to the parent object (defaults to None) + @type QObject (optional) + """ + super().__init__(parent=parent) + + self.__ui = parent + + self.__categorySignalMapping = { + EricRequestCategory.Debugger: self.remoteDebuggerReply, + EricRequestCategory.Echo: self.remoteEchoReply, + EricRequestCategory.FileSystem: self.remoteFileSystemReply, + EricRequestCategory.Project: self.remoteProjectReply, + EricRequestCategory.Server: self.remoteServerReply, + } + + self.__connection = None + self.__callbacks = {} # callback references indexed by UUID + + self.connectionStateChanged.connect(self.__connectionStateChanged) + + ####################################################################### + ## Methods for handling the server connection. + ####################################################################### + + def connectToServer(self, host, port=None, timeout=None): + """ + Public method to connect to the given host and port + + @param host host name or IP address of the eric remote server + @type str + @param port port number to connect to (defaults to None) + @type int (optional) + @param timeout timeout im seconds for the connection attempt + (defaults to None) + @type int (optional) + """ + if not bool(port): # None or 0 + # use default port + port = 42024 + + if not bool(timeout): # None or 0 + # use configured default timeout + timeout = Preferences.getEricServer("ConnectionTimeout") + timeout = timeout * 1000 # convert to milliseconds + + if self.__connection is not None: + self.disconnectFromServer() + + self.__connection = QTcpSocket(self) + self.__connection.connectToHost(host, port) + if not self.__connection.waitForConnected(timeout): + EricMessageBox.critical( + None, + self.tr("Connect to eric-ide Server"), + self.tr( + "<p>The connection to the eric-ide server {0}:{1} could not be" + " established.</p><p>Reason: {2}</p>" + ).format( + host if ":" not in host else f"[{host}]", + port, + self.__connection.errorString(), + ), + ) + + self.__connection = None + return False + + self.__connection.readyRead.connect(self.__receiveJson) + self.__connection.disconnected.connect(self.__handleDisconnect) + + self.connectionStateChanged.emit(True) + + return True + + @pyqtSlot() + def disconnectFromServer(self): + """ + Public method to disconnect from the eric remote server. + """ + if self.__connection is not None and self.__connection.isValid(): + self.__connection.disconnectFromHost() + if self.__connection is not None: + # may have disconnected already + self.__connection.waitForDisconnected( + Preferences.getEricServer("ConnectionTimeout") * 1000 + ) + + self.connectionStateChanged.emit(False) + self.__connection = None + self.__callbacks.clear() + + def isServerConnected(self): + """ + Public method to check, if a connection to an eric-ide server has been + established. + + @return flag indicating the interface connection state + @rtype bool + """ + return ( + self.__connection is not None + and self.__connection.state() == QAbstractSocket.SocketState.ConnectedState + ) + + @pyqtSlot() + def __handleDisconnect(self): + """ + Private slot handling a disconnect of the client. + """ + if self.__connection is not None: + self.__connection.close() + + self.connectionStateChanged.emit(False) + self.__connection = None + self.__callbacks.clear() + + ####################################################################### + ## Methods for sending requests and receiving the replies. + ####################################################################### + + @pyqtSlot() + def __receiveJson(self): + """ + Private slot handling received data from the eric remote server. + + @param idString id of the connection + @type str + """ + while self.__connection and self.__connection.bytesAvailable(): + header = self.__connection.read(struct.calcsize(b"!II")) + length, datahash = struct.unpack(b"!II", header) + + data = bytearray() + while len(data) < length: + maxSize = length - len(data) + if self.__connection.bytesAvailable() < maxSize: + self.__connection.waitForReadyRead(50) + data += self.__connection.read(maxSize) + + if zlib.adler32(data) & 0xFFFFFFFF != datahash: + # corrupted data -> discard and continue + continue + + jsonString = data.decode("utf-8", "backslashreplace") + + # - print("Remote Server Interface: {0}".format(jsonString)) + # - this is for debugging only + + try: + serverDataDict = json.loads(jsonString.strip()) + except (TypeError, ValueError) as err: + EricMessageBox.critical( + None, + self.tr("JSON Protocol Error"), + self.tr( + """<p>The response received from the remote server""" + """ could not be decoded. Please report""" + """ this issue with the received data to the""" + """ eric bugs email address.</p>""" + """<p>Error: {0}</p>""" + """<p>Data:<br/>{1}</p>""" + ).format(str(err), Utilities.html_encode(jsonString.strip())), + EricMessageBox.Ok, + ) + return + + reqUuid = serverDataDict["uuid"] + try: + self.__callbacks[reqUuid]( + serverDataDict["reply"], serverDataDict["params"] + ) + del self.__callbacks[reqUuid] + except KeyError: + # no callback for this UUID exists, send a signal + try: + self.__categorySignalMapping[serverDataDict["category"]].emit( + serverDataDict["reply"], serverDataDict["params"] + ) + except KeyError: + if serverDataDict["category"] == EricRequestCategory.Error: + # handle server errors in here + self.__handleServerError( + serverDataDict["reply"], serverDataDict["params"] + ) + else: + self.remoteReply.emit( + serverDataDict["category"], + serverDataDict["reply"], + serverDataDict["params"], + ) + + def sendJson(self, category, request, params, callback=None, flush=False): + """ + Public method to send a single command to a client. + + @param category service category + @type EricRequestCategory + @param request request name to be sent + @type str + @param params dictionary of named parameters for the request + @type dict + @param callback callback function for the reply from the eric remote server + (defaults to None) + @type function (optional) + @param flush flag indicating to flush the data to the socket + (defaults to False) + @type bool (optional) + """ + reqUuid = str(uuid.uuid4()) + if callback: + self.__callbacks[reqUuid] = callback + + serviceDict = { + "jsonrpc": "2.0", + "category": category, + "request": request, + "params": params, + "uuid": reqUuid, + } + jsonString = json.dumps(serviceDict) + "\n" + + if self.__connection is not None: + data = jsonString.encode("utf8", "backslashreplace") + header = struct.pack(b"!II", len(data), zlib.adler32(data) & 0xFFFFFFFF) + self.__connection.write(header) + self.__connection.write(data) + if flush: + self.__connection.flush() + + def shutdownServer(self): + """ + Public method shutdown the currebtly connected eric-ide remote server. + """ + if self.__connection: + self.sendJson( + category=EricRequestCategory.Server, + request="Shutdown", + params={}, + ) + + @pyqtSlot() + def serverVersions(self): + """ + Public slot to request the eric-ide version of the server. + """ + if self.__connection: + self.sendJson( + category=EricRequestCategory.Server, + request="Versions", + params={}, + callback=self.__handleServerVersionReply, + ) + + ####################################################################### + ## Callback methods + ####################################################################### + + def __handleServerVersionReply(self, reply, params): + """ + Private method to handle the reply of a 'Version' request. + + @param reply name of the eric-ide server reply + @type str + @param params dictionary containing the reply data + @type dict + @exception ValueError raised in case of an unsupported reply + """ + if reply == "Versions": + versionText = self.tr("""<h2>Server Version Numbers</h2><table>""") + + # Python version + versionText += ( + """<tr><td><b>Python</b></td><td>{0}, {1}</td></tr>""" + ).format(params["python"], params["py_bitsize"]) + + # eric7 version + versionText += ( + """<tr><td><b>eric7_server</b></td><td>{0}</td></tr>""" + ).format(params["version"]) + + versionText += self.tr("""</table>""") + + EricMessageBox.about( + None, + self.tr("eric-ide Server Versions"), + versionText, + ) + + else: + raise ValueError(f"unsupported reply received ({reply})") + + ####################################################################### + ## Reply handler methods + ####################################################################### + + def __handleServerError(self, reply, params): + """ + Public method handling server error replies. + + @param reply name of the error reply + @type str + @param params dictionary containing the specific reply data + @type dict + """ + if reply == "ClientChecksumException": + self.__ui.appendToStderr( + self.tr("eric-ide Server Checksum Error\nError: {0}\nData:\n{1}\n") + .format(params["ExceptionValue"], params["ProtocolData"]) + ) + + elif reply == "ClientException": + self.__ui.appendToStderr( + self.tr("eric-ide Server Data Error\nError: {0}\nData:\n{1}\n") + .format(params["ExceptionValue"], params["ProtocolData"]) + ) + + elif reply == "UnsupportedServiceCategory": + self.__ui.appendToStderr( + self.tr( + "eric-ide Server Unsupported Category\n" + "Error: The server received the unsupported request category '{0}'." + ).format(params["Category"]) + ) + + ####################################################################### + ## User interface related methods + ####################################################################### + + def initActions(self): + """ + Public slot to initialize the eric-ide server actions. + """ + self.actions = [] + + self.connectServerAct = EricAction( + self.tr("Connect"), + EricPixmapCache.getIcon("linkConnect"), + self.tr("Connect..."), + QKeySequence(self.tr("Meta+Shift+C")), + 0, + self, + "remote_server_connect", + ) + self.connectServerAct.setStatusTip( + self.tr("Show a dialog to connect to an 'eric-ide' server") + ) + self.connectServerAct.setWhatsThis( + self.tr( + """<b>Connect...</b>""" + """<p>This opens a dialog to enter the connection parameters to""" + """ connect to a remote 'eric-ide' server.</p>""" + ) + ) + self.connectServerAct.triggered.connect(self.__connectToServer) + self.actions.append(self.connectServerAct) + + self.disconnectServerAct = EricAction( + self.tr("Disconnect"), + EricPixmapCache.getIcon("linkDisconnect"), + self.tr("Disconnect"), + QKeySequence(self.tr("Meta+Shift+D")), + 0, + self, + "remote_server_disconnect", + ) + self.disconnectServerAct.setStatusTip( + self.tr("Disconnect from the currently connected 'eric-ide' server") + ) + self.disconnectServerAct.setWhatsThis( + self.tr( + """<b>Disconnect</b>""" + """<p>This disconnects from the currently connected 'eric-ide'""" + """ server.</p>""" + ) + ) + self.disconnectServerAct.triggered.connect(self.disconnectFromServer) + self.actions.append(self.disconnectServerAct) + + self.stopServerAct = EricAction( + self.tr("Stop Server"), + EricPixmapCache.getIcon("stopScript"), + self.tr("Stop Server"), + QKeySequence(self.tr("Meta+Shift+S")), + 0, + self, + "remote_server_shutdown", + ) + self.stopServerAct.setStatusTip( + self.tr("Stop the currently connected 'eric-ide' server") + ) + self.stopServerAct.setWhatsThis( + self.tr( + """<b>Stop Server</b>""" + """<p>This stops the currently connected 'eric-ide server.</p>""" + ) + ) + self.stopServerAct.triggered.connect(self.__shutdownServer) + self.actions.append(self.stopServerAct) + + self.serverVersionsAct = EricAction( + self.tr("Show Server Versions"), + EricPixmapCache.getIcon("helpAbout"), + self.tr("Show Server Versions"), + 0, + 0, + self, + "remote_server_versions", + ) + self.serverVersionsAct.setStatusTip( + self.tr("Show the eric-ide server versions") + ) + self.serverVersionsAct.setWhatsThis( + self.tr( + """<b>Show Server Versions</b>""" + """<p>This opens a dialog to show the eric-ide server versions.</p>""" + ) + ) + self.serverVersionsAct.triggered.connect(self.serverVersions) + self.actions.append(self.serverVersionsAct) + + self.disconnectServerAct.setEnabled(False) + self.stopServerAct.setEnabled(False) + self.serverVersionsAct.setEnabled(False) + + def initMenu(self): + """ + Public slot to initialize the eric-ide server menu. + + @return the menu generated + @rtype QMenu + """ + self.__serverProfilesMenu = QMenu(self.tr("Connect to"))##, self.__ui) + self.__serverProfilesMenu.aboutToShow.connect(self.__showServerProfilesMenu) + self.__serverProfilesMenu.triggered.connect(self.__serverProfileTriggered) + + menu = QMenu(self.tr("eric-ide Server"), self.__ui) + menu.setTearOffEnabled(True) + menu.aboutToShow.connect(self.__showEricServerMenu) + menu.addAction(self.connectServerAct) + menu.addMenu(self.__serverProfilesMenu) + # TODO: add a 'Recent Connections' submenu + menu.addSeparator() + menu.addAction(self.disconnectServerAct) + menu.addSeparator() + menu.addAction(self.stopServerAct) + menu.addSeparator() + menu.addAction(self.serverVersionsAct) + + self.__menus = { + "Main": menu, + ##"Recent": self.recentMenu, + } + + + return menu + + def initToolbar(self, toolbarManager): + """ + Public slot to initialize the eric-ide server toolbar. + + @param toolbarManager reference to a toolbar manager object + @type EricToolBarManager + @return the toolbar generated + @rtype QToolBar + """ + self.__connectButton = QToolButton() + self.__connectButton.setIcon(self.connectServerAct.icon()) + self.__connectButton.setToolTip(self.connectServerAct.toolTip()) + self.__connectButton.setWhatsThis(self.connectServerAct.whatsThis()) + self.__connectButton.setPopupMode( + QToolButton.ToolButtonPopupMode.MenuButtonPopup + ) + self.__connectButton.setMenu(self.__serverProfilesMenu) + self.connectServerAct.enabledChanged.connect(self.__connectButton.setEnabled) + self.__connectButton.clicked.connect(self.connectServerAct.triggered) + + tb = QToolBar(self.tr("eric-ide Server"), self.__ui) + tb.setObjectName("EricServerToolbar") + tb.setToolTip(self.tr("eric-ide Server")) + + act = tb.addWidget(self.__connectButton) + act.setText(self.connectServerAct.iconText()) + act.setIcon(self.connectServerAct.icon()) + tb.addAction(self.disconnectServerAct) + tb.addSeparator() + tb.addAction(self.stopServerAct) + tb.addSeparator() + tb.addAction(self.serverVersionsAct) + + toolbarManager.addToolBar(tb, tb.windowTitle()) + + return tb + + @pyqtSlot() + def __showEricServerMenu(self): + """ + Private slot to display the server menu. + """ + connected = self.isServerConnected() + self.connectServerAct.setEnabled(not connected) + self.disconnectServerAct.setEnabled(connected) + self.stopServerAct.setEnabled(connected) + self.serverVersionsAct.setEnabled(connected) + + self.showMenu.emit("Main", self.__menus["Main"]) + + @pyqtSlot() + def __showServerProfilesMenu(self): + """ + Private slot to prepare the eric server profiles menu. + """ + profiles = Preferences.getEricServer("ConnectionProfiles") + + self.__serverProfilesMenu.clear() + + if not self.isServerConnected(): + for profile in sorted(profiles): + act = self.__serverProfilesMenu.addAction(profile) + act.setData(profiles[profile]) + self.__serverProfilesMenu.addSeparator() + + self.__serverProfilesMenu.addAction( + self.tr("Manage Server Connections"), self.__manageServerProfiles + ) + + @pyqtSlot(bool) + def __connectionStateChanged(self, connected): + """ + Private slot to handle the connection state change. + + @param connected flag indicating the connection state + @type bool + """ + self.connectServerAct.setEnabled(not connected) + self.disconnectServerAct.setEnabled(connected) + self.stopServerAct.setEnabled(connected) + self.serverVersionsAct.setEnabled(connected) + + if connected: + peerName = self.__connection.peerName() + EricMessageBox.information( + None, + self.tr("Connect to eric-ide Server"), + self.tr( + "<p>The eric-ide server at <b>{0}:{1}</b> was connected" + " successfully.</p>" + ).format( + f"[{peerName}]" if ":" in peerName else peerName, + self.__connection.peerPort(), + ), + ) + else: + EricMessageBox.information( + None, + self.tr("Disonnect from eric-ide Server"), + self.tr("""The eric-ide server was disconnected."""), + ) + + @pyqtSlot() + def __connectToServer(self): + """ + Private slot to connect to a remote eric-ide server. + """ + from .EricServerConnectionDialog import EricServerConnectionDialog + + dlg = EricServerConnectionDialog(parent=self.__ui) + if dlg.exec() == QDialog.DialogCode.Accepted: + hostname, port, timeout = dlg.getData() + self.connectToServer(hostname, port=port, timeout=timeout) + + @pyqtSlot() + def __shutdownServer(self): + """ + Private slot to shut down the currently connected eric-ide server. + """ + ok = EricMessageBox.yesNo( + None, + self.tr("Stop Server"), + self.tr( + "Do you really want to stop the currently connected eric-ide server?" + " No further connections will be possible without restarting the" + " server." + ), + ) + if ok: + self.shutdownServer() + + @pyqtSlot(QAction) + def __serverProfileTriggered(self, act): + """ + Private slot to handle the selection of a remote server connection. + + @param act reference to the triggered profile action + @type QAction + """ + data = act.data() + if data is not None: + # handle the connection + hostname, port, timeout = data + self.connectToServer(hostname, port=port, timeout=timeout) + + @pyqtSlot() + def __manageServerProfiles(self): + """ + Private slot to show a dialog to manage the eric-ide server connection + profiles. + """ + from .EricServerProfilesDialog import EricServerProfilesDialog + + dlg = EricServerProfilesDialog( + Preferences.getEricServer("ConnectionProfiles"), self.__ui + ) + if dlg.exec() == QDialog.DialogCode.Accepted: + profiles = dlg.getConnectionProfiles() + Preferences.setEricServer("ConnectionProfiles", profiles)