|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2010 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the client of the cooperation package. |
|
8 """ |
|
9 |
|
10 import collections |
|
11 |
|
12 from PyQt5.QtCore import QObject, pyqtSignal, QProcess |
|
13 from PyQt5.QtNetwork import ( |
|
14 QHostInfo, QHostAddress, QAbstractSocket, QNetworkInterface |
|
15 ) |
|
16 |
|
17 from .CooperationServer import CooperationServer |
|
18 from .Connection import Connection |
|
19 |
|
20 import Preferences |
|
21 |
|
22 |
|
23 class CooperationClient(QObject): |
|
24 """ |
|
25 Class implementing the client of the cooperation package. |
|
26 |
|
27 @signal newMessage(user, message) emitted after a new message has |
|
28 arrived (string, string) |
|
29 @signal newParticipant(nickname) emitted after a new participant joined |
|
30 (string) |
|
31 @signal participantLeft(nickname) emitted after a participant left (string) |
|
32 @signal connectionError(message) emitted when a connection error occurs |
|
33 (string) |
|
34 @signal cannotConnect() emitted, if the initial connection fails |
|
35 @signal editorCommand(hash, filename, message) emitted when an editor |
|
36 command has been received (string, string, string) |
|
37 """ |
|
38 newMessage = pyqtSignal(str, str) |
|
39 newParticipant = pyqtSignal(str) |
|
40 participantLeft = pyqtSignal(str) |
|
41 connectionError = pyqtSignal(str) |
|
42 cannotConnect = pyqtSignal() |
|
43 editorCommand = pyqtSignal(str, str, str) |
|
44 |
|
45 def __init__(self, parent=None): |
|
46 """ |
|
47 Constructor |
|
48 |
|
49 @param parent reference to the parent object (QObject) |
|
50 """ |
|
51 super().__init__(parent) |
|
52 |
|
53 self.__chatWidget = parent |
|
54 |
|
55 self.__servers = [] |
|
56 for networkInterface in QNetworkInterface.allInterfaces(): |
|
57 for addressEntry in networkInterface.addressEntries(): |
|
58 address = addressEntry.ip() |
|
59 # fix scope of link local addresses |
|
60 if address.toString().lower().startswith("fe80"): |
|
61 address.setScopeId(networkInterface.humanReadableName()) |
|
62 server = CooperationServer(address, self) |
|
63 server.newConnection.connect(self.__newConnection) |
|
64 self.__servers.append(server) |
|
65 |
|
66 self.__peers = collections.defaultdict(list) |
|
67 |
|
68 self.__initialConnection = None |
|
69 |
|
70 envVariables = ["USERNAME", "USERDOMAIN", "USER", |
|
71 "HOSTNAME", "DOMAINNAME"] |
|
72 environment = QProcess.systemEnvironment() |
|
73 found = False |
|
74 for envVariable in envVariables: |
|
75 for env in environment: |
|
76 if env.startswith(envVariable): |
|
77 envList = env.split("=") |
|
78 if len(envList) == 2: |
|
79 self.__username = envList[1].strip() |
|
80 found = True |
|
81 break |
|
82 |
|
83 if found: |
|
84 break |
|
85 |
|
86 if self.__username == "": |
|
87 self.__username = self.tr("unknown") |
|
88 |
|
89 self.__listening = False |
|
90 self.__serversErrorString = "" |
|
91 |
|
92 def chatWidget(self): |
|
93 """ |
|
94 Public method to get a reference to the chat widget. |
|
95 |
|
96 @return reference to the chat widget (ChatWidget) |
|
97 """ |
|
98 return self.__chatWidget |
|
99 |
|
100 def sendMessage(self, message): |
|
101 """ |
|
102 Public method to send a message. |
|
103 |
|
104 @param message message to be sent (string) |
|
105 """ |
|
106 if message == "": |
|
107 return |
|
108 |
|
109 for connectionList in self.__peers.values(): |
|
110 for connection in connectionList: |
|
111 connection.sendMessage(message) |
|
112 |
|
113 def nickName(self): |
|
114 """ |
|
115 Public method to get the nick name. |
|
116 |
|
117 @return nick name (string) |
|
118 """ |
|
119 return "{0}@{1}@{2}".format( |
|
120 self.__username, |
|
121 QHostInfo.localHostName(), |
|
122 self.__servers[0].serverPort() |
|
123 ) |
|
124 |
|
125 def hasConnection(self, senderIp, senderPort=-1): |
|
126 """ |
|
127 Public method to check for an existing connection. |
|
128 |
|
129 @param senderIp address of the sender (QHostAddress) |
|
130 @param senderPort port of the sender (integer) |
|
131 @return flag indicating an existing connection (boolean) |
|
132 """ |
|
133 if senderPort == -1: |
|
134 return senderIp in self.__peers |
|
135 |
|
136 if senderIp not in self.__peers: |
|
137 return False |
|
138 |
|
139 return any(connection.peerPort() == senderPort |
|
140 for connection in self.__peers[senderIp]) |
|
141 |
|
142 def hasConnections(self): |
|
143 """ |
|
144 Public method to check, if there are any connections established. |
|
145 |
|
146 @return flag indicating the presence of connections (boolean) |
|
147 """ |
|
148 return any(bool(connectionList) |
|
149 for connectionList in self.__peers.values()) |
|
150 |
|
151 def removeConnection(self, connection): |
|
152 """ |
|
153 Public method to remove a connection. |
|
154 |
|
155 @param connection reference to the connection to be removed |
|
156 (Connection) |
|
157 """ |
|
158 if (connection.peerAddress() in self.__peers and |
|
159 connection in self.__peers[connection.peerAddress()]): |
|
160 self.__peers[connection.peerAddress()].remove(connection) |
|
161 nick = connection.name() |
|
162 if nick != "": |
|
163 self.participantLeft.emit(nick) |
|
164 |
|
165 if connection.isValid(): |
|
166 connection.abort() |
|
167 |
|
168 def disconnectConnections(self): |
|
169 """ |
|
170 Public slot to disconnect from the chat network. |
|
171 """ |
|
172 for connectionList in self.__peers.values(): |
|
173 while connectionList: |
|
174 self.removeConnection(connectionList[0]) |
|
175 |
|
176 def __newConnection(self, connection): |
|
177 """ |
|
178 Private slot to handle a new connection. |
|
179 |
|
180 @param connection reference to the new connection (Connection) |
|
181 """ |
|
182 connection.setParent(self) |
|
183 connection.setClient(self) |
|
184 connection.setGreetingMessage(self.__username, |
|
185 self.__servers[0].serverPort()) |
|
186 |
|
187 connection.error.connect( |
|
188 lambda err: self.__connectionError(err, connection)) |
|
189 connection.disconnected.connect( |
|
190 lambda: self.__disconnected(connection)) |
|
191 connection.readyForUse.connect( |
|
192 lambda: self.__readyForUse(connection)) |
|
193 connection.rejected.connect(self.__connectionRejected) |
|
194 |
|
195 def __connectionRejected(self, msg): |
|
196 """ |
|
197 Private slot to handle the rejection of a connection. |
|
198 |
|
199 @param msg error message (string) |
|
200 """ |
|
201 self.connectionError.emit(msg) |
|
202 |
|
203 def __connectionError(self, socketError, connection): |
|
204 """ |
|
205 Private slot to handle a connection error. |
|
206 |
|
207 @param socketError reference to the error object |
|
208 @type QAbstractSocket.SocketError |
|
209 @param connection connection that caused the error |
|
210 @type Connection |
|
211 """ |
|
212 if socketError != QAbstractSocket.SocketError.RemoteHostClosedError: |
|
213 if connection.peerPort() != 0: |
|
214 msg = "* {0}:{1}\n{2}\n".format( |
|
215 connection.peerAddress().toString(), |
|
216 connection.peerPort(), |
|
217 connection.errorString() |
|
218 ) |
|
219 else: |
|
220 msg = "* {0}\n".format(connection.errorString()) |
|
221 self.connectionError.emit(msg) |
|
222 if connection == self.__initialConnection: |
|
223 self.cannotConnect.emit() |
|
224 self.removeConnection(connection) |
|
225 |
|
226 def __disconnected(self, connection): |
|
227 """ |
|
228 Private slot to handle the disconnection of a chat client. |
|
229 |
|
230 @param connection connection that was disconnected |
|
231 @type Connection |
|
232 """ |
|
233 self.removeConnection(connection) |
|
234 |
|
235 def __readyForUse(self, connection): |
|
236 """ |
|
237 Private slot to handle a connection getting ready for use. |
|
238 |
|
239 @param connection connection that got ready for use |
|
240 @type Connection |
|
241 """ |
|
242 if self.hasConnection(connection.peerAddress(), connection.peerPort()): |
|
243 return |
|
244 |
|
245 connection.newMessage.connect(self.newMessage) |
|
246 connection.getParticipants.connect( |
|
247 lambda: self.__getParticipants(connection)) |
|
248 connection.editorCommand.connect(self.editorCommand) |
|
249 |
|
250 self.__peers[connection.peerAddress()].append(connection) |
|
251 nick = connection.name() |
|
252 if nick != "": |
|
253 self.newParticipant.emit(nick) |
|
254 |
|
255 if connection == self.__initialConnection: |
|
256 connection.sendGetParticipants() |
|
257 self.__initialConnection = None |
|
258 |
|
259 def connectToHost(self, host, port): |
|
260 """ |
|
261 Public method to connect to a host. |
|
262 |
|
263 @param host host to connect to (string) |
|
264 @param port port to connect to (integer) |
|
265 """ |
|
266 self.__initialConnection = Connection(self) |
|
267 self.__newConnection(self.__initialConnection) |
|
268 self.__initialConnection.participants.connect( |
|
269 self.__processParticipants) |
|
270 self.__initialConnection.connectToHost(host, port) |
|
271 |
|
272 def __getParticipants(self, reqConnection): |
|
273 """ |
|
274 Private slot to handle the request for a list of participants. |
|
275 |
|
276 @param reqConnection reference to the connection to get |
|
277 participants for |
|
278 @type Connection |
|
279 """ |
|
280 participants = [] |
|
281 for connectionList in self.__peers.values(): |
|
282 for connection in connectionList: |
|
283 if connection != reqConnection: |
|
284 participants.append("{0}@{1}".format( |
|
285 connection.peerAddress().toString(), |
|
286 connection.serverPort())) |
|
287 reqConnection.sendParticipants(participants) |
|
288 |
|
289 def __processParticipants(self, participants): |
|
290 """ |
|
291 Private slot to handle the receipt of a list of participants. |
|
292 |
|
293 @param participants list of participants (list of strings of |
|
294 "host:port") |
|
295 """ |
|
296 for participant in participants: |
|
297 host, port = participant.split("@") |
|
298 port = int(port) |
|
299 |
|
300 if port == 0: |
|
301 msg = self.tr("Illegal address: {0}@{1}\n").format( |
|
302 host, port) |
|
303 self.connectionError.emit(msg) |
|
304 else: |
|
305 if not self.hasConnection(QHostAddress(host), port): |
|
306 connection = Connection(self) |
|
307 self.__newConnection(connection) |
|
308 connection.connectToHost(host, port) |
|
309 |
|
310 def sendEditorCommand(self, projectHash, filename, message): |
|
311 """ |
|
312 Public method to send an editor command. |
|
313 |
|
314 @param projectHash hash of the project (string) |
|
315 @param filename project relative universal file name of |
|
316 the sending editor (string) |
|
317 @param message editor command to be sent (string) |
|
318 """ |
|
319 for connectionList in self.__peers.values(): |
|
320 for connection in connectionList: |
|
321 connection.sendEditorCommand(projectHash, filename, message) |
|
322 |
|
323 def __findConnections(self, nick): |
|
324 """ |
|
325 Private method to get a list of connection given a nick name. |
|
326 |
|
327 @param nick nick name in the format of self.nickName() (string) |
|
328 @return list of references to the connection objects (list of |
|
329 Connection) |
|
330 """ |
|
331 if "@" not in nick: |
|
332 # nick given in wrong format |
|
333 return [] |
|
334 |
|
335 user, host, port = nick.split("@") |
|
336 senderIp = QHostAddress(host) |
|
337 |
|
338 if senderIp not in self.__peers: |
|
339 return [] |
|
340 |
|
341 return self.__peers[senderIp][:] |
|
342 |
|
343 def kickUser(self, nick): |
|
344 """ |
|
345 Public method to kick a user by its nick name. |
|
346 |
|
347 @param nick nick name in the format of self.nickName() (string) |
|
348 """ |
|
349 for connection in self.__findConnections(nick): |
|
350 connection.abort() |
|
351 |
|
352 def banUser(self, nick): |
|
353 """ |
|
354 Public method to ban a user by its nick name. |
|
355 |
|
356 @param nick nick name in the format of self.nickName() (string) |
|
357 """ |
|
358 Preferences.syncPreferences() |
|
359 user = nick.rsplit("@")[0] |
|
360 bannedUsers = Preferences.getCooperation("BannedUsers")[:] |
|
361 if user not in bannedUsers: |
|
362 bannedUsers.append(user) |
|
363 Preferences.setCooperation("BannedUsers", bannedUsers) |
|
364 |
|
365 def banKickUser(self, nick): |
|
366 """ |
|
367 Public method to ban and kick a user by its nick name. |
|
368 |
|
369 @param nick nick name in the format of self.nickName() (string) |
|
370 """ |
|
371 self.banUser(nick) |
|
372 self.kickUser(nick) |
|
373 |
|
374 def startListening(self, port=-1): |
|
375 """ |
|
376 Public method to start listening for new connections. |
|
377 |
|
378 @param port port to listen on (integer) |
|
379 @return tuple giving a flag indicating success (boolean) and |
|
380 the port the server listens on |
|
381 """ |
|
382 if self.__servers: |
|
383 # do first server and determine free port |
|
384 res, port = self.__servers[0].startListening(port, True) |
|
385 if res and len(self.__servers) > 1: |
|
386 for server in self.__servers[1:]: |
|
387 res, port = server.startListening(port, False) |
|
388 if not res: |
|
389 self.__serversErrorString = server.errorString() |
|
390 else: |
|
391 self.__serversErrorString = self.__servers[0].errorString() |
|
392 else: |
|
393 res = False |
|
394 self.__serversErrorString = self.tr("No servers present.") |
|
395 |
|
396 if res: |
|
397 self.__serversErrorString = "" |
|
398 self.__listening = res |
|
399 return res, port |
|
400 |
|
401 def isListening(self): |
|
402 """ |
|
403 Public method to check, if the client is listening for connections. |
|
404 |
|
405 @return flag indicating the listening state (boolean) |
|
406 """ |
|
407 return self.__listening |
|
408 |
|
409 def close(self): |
|
410 """ |
|
411 Public method to close all connections and stop listening. |
|
412 """ |
|
413 for server in self.__servers: |
|
414 server.close() |
|
415 self.__listening = False |
|
416 |
|
417 def errorString(self): |
|
418 """ |
|
419 Public method to get a human readable error message about the last |
|
420 server error. |
|
421 |
|
422 @return human readable error message about the last server error |
|
423 (string) |
|
424 """ |
|
425 return self.__serversErrorString |