File indexing completed on 2024-03-24 04:04:33
0001 # -*- coding: utf-8 -*- 0002 0003 """ 0004 Copyright (C) 2009-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de> 0005 0006 SPDX-License-Identifier: GPL-2.0 0007 0008 """ 0009 0010 import socket 0011 import subprocess 0012 import datetime 0013 import os 0014 import sys 0015 from itertools import chain 0016 0017 from twisted.spread import pb 0018 from twisted.cred import credentials 0019 from twisted.internet.defer import CancelledError 0020 from twisted.internet.task import deferLater 0021 import twisted.internet.error 0022 from twisted.python.failure import Failure 0023 0024 from qt import QDialog, QDialogButtonBox, QVBoxLayout, \ 0025 QLabel, QComboBox, QLineEdit, QFormLayout, \ 0026 QSizePolicy, QWidget 0027 0028 from kde import KUser, KDialog, KDialogButtonBox 0029 from mi18n import i18n, i18nc, english 0030 from dialogs import DeferredDialog, QuestionYesNo 0031 0032 from log import logWarning, logException, logInfo, logDebug, SERVERMARK 0033 from util import removeIfExists, which 0034 from common import Internal, Options, SingleshotOptions, Debug, isAlive 0035 from common import interpreterName 0036 from common import StrMixin 0037 from common import appdataDir, socketName 0038 from game import Players 0039 from query import Query 0040 from statesaver import StateSaver 0041 0042 from guiutil import ListComboBox, decorateWindow 0043 from rule import Ruleset 0044 0045 0046 class LoginAborted(Exception): 0047 0048 """the user aborted the login""" 0049 0050 0051 class Url(str, StrMixin): 0052 0053 """holds connection related attributes: host, port, socketname""" 0054 # pylint: disable=too-many-public-methods 0055 def __new__(cls, url): 0056 assert url 0057 host = None 0058 port = None 0059 urlParts = url.split(':') 0060 host = urlParts[0] 0061 if english(host) == Query.localServerName: 0062 host = '127.0.0.1' 0063 if len(urlParts) > 1: 0064 port = int(urlParts[1]) 0065 obj = str.__new__(cls, url) 0066 obj.host = host 0067 obj.port = port 0068 if Options.port: 0069 obj.port = int(Options.port) 0070 if obj.port is None and obj.isLocalHost and not obj.useSocket: 0071 obj.port = obj.findFreePort() 0072 if obj.port is None and not obj.isLocalHost: 0073 obj.port = Internal.defaultPort 0074 if Debug.connections: 0075 logDebug(repr(obj)) 0076 0077 return obj 0078 0079 def __str__(self): 0080 """show all info""" 0081 return socketName() if self.useSocket else '{}:{}'.format(self.host, self.port) 0082 0083 @property 0084 def useSocket(self): 0085 """do we use socket for current host?""" 0086 return ( 0087 self.host == '127.0.0.1' 0088 and os.name != 'nt' 0089 and not Options.port) 0090 0091 @property 0092 def isLocalGame(self): 0093 """Are we playing a local game not needing the network?""" 0094 return self.host == '127.0.0.1' 0095 0096 @property 0097 def isLocalHost(self): 0098 """do server and client run on the same host?""" 0099 return self.host in ('127.0.0.1', 'localhost') 0100 0101 def findFreePort(self): 0102 """find an unused port on the current system. 0103 used when we want to start a local server on windows""" 0104 assert self.isLocalHost 0105 for port in chain([Internal.defaultPort], range(2000, 19000)): 0106 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 0107 sock.settimeout(1) 0108 try: 0109 sock.connect((self.host, port)) 0110 sock.close() 0111 except socket.error: 0112 return port 0113 logException('cannot find a free port') 0114 return None 0115 0116 def startServer(self, result, waiting=0): 0117 """make sure we have a running local server or network connectivity""" 0118 if self.isLocalHost: 0119 # just wait for that server to appear 0120 if self.__serverListening(): 0121 return result 0122 if waiting == 0: 0123 self.__startLocalServer() 0124 elif waiting > 30: 0125 logDebug('Game %s: Server %s not available after 30 seconds, aborting' % ( 0126 SingleshotOptions.game, self)) 0127 raise CancelledError 0128 return deferLater(Internal.reactor, 1, self.startServer, result, waiting + 1) 0129 if which('qdbus'): 0130 try: 0131 stdoutdata, stderrdata = subprocess.Popen( 0132 ['qdbus', 0133 'org.kde.kded', 0134 '/modules/networkstatus', 0135 'org.kde.Solid.Networking.status'], 0136 stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate(timeout=1) 0137 except subprocess.TimeoutExpired as _: 0138 raise twisted.internet.error.ConnectError() from _ 0139 stdoutdata = stdoutdata.strip() 0140 stderrdata = stderrdata.strip() 0141 if stderrdata == '' and stdoutdata != '4': 0142 raise twisted.internet.error.ConnectError() 0143 # if we have stderrdata, qdbus probably does not provide the 0144 # service we want, so ignore it 0145 return result 0146 0147 @staticmethod 0148 def __findServerProgram(): 0149 """how should we start the server?""" 0150 result = [] 0151 if sys.argv[0].endswith('kajongg.py'): 0152 tryServer = sys.argv[0].replace('.py', 'server.py') 0153 if os.path.exists(tryServer): 0154 result = [interpreterName, tryServer] 0155 elif sys.argv[0].endswith('kajongg.pyw'): 0156 tryServer = sys.argv[0].replace('.pyw', 'server.py') 0157 if os.path.exists(tryServer): 0158 result = [interpreterName, tryServer] 0159 elif sys.argv[0].endswith('kajongg.exe'): 0160 tryServer = sys.argv[0].replace('.exe', 'server.exe') 0161 if os.path.exists(tryServer): 0162 result = [tryServer] 0163 else: 0164 result = ['kajonggserver'] 0165 if Debug.connections: 0166 logDebug(i18n('trying to start local server %1', result)) 0167 return result 0168 0169 def __startLocalServer(self): 0170 """start a local server""" 0171 try: 0172 args = self.__findServerProgram() 0173 if self.useSocket or os.name == 'nt': # for nt --socket tells the server to bind to 127.0.0.1 0174 args.append('--socket=%s' % socketName()) 0175 if removeIfExists(socketName()): 0176 logInfo( 0177 i18n('removed stale socket <filename>%1</filename>', socketName())) 0178 if not self.useSocket: 0179 args.append('--port=%d' % self.port) 0180 if self.isLocalGame: 0181 args.append( 0182 '--db={}'.format( 0183 os.path.normpath( 0184 os.path.join(appdataDir(), 'local3.db')))) 0185 if Debug.argString: 0186 args.append('--debug=%s' % Debug.argString) 0187 if os.name == 'nt': 0188 startupinfo = subprocess.STARTUPINFO() 0189 startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 0190 else: 0191 startupinfo = None 0192 process = subprocess.Popen( 0193 args, 0194 startupinfo=startupinfo) # , shell=os.name == 'nt') 0195 if Debug.connections: 0196 logDebug( 0197 i18n( 0198 'started the local kajongg server: pid=<numid>%1</numid> %2', 0199 process.pid, ' '.join(args))) 0200 except OSError as exc: 0201 exc.filename = ' '.join(args) 0202 logException(exc) 0203 0204 def __serverListening(self): 0205 """is the expected server listening?""" 0206 if self.useSocket: 0207 proto = socket.AF_UNIX 0208 param = socketName() 0209 if not os.path.exists(param): 0210 return False 0211 else: 0212 proto = socket.AF_INET 0213 param = (self.host, self.port) 0214 sock = socket.socket(proto, socket.SOCK_STREAM) 0215 sock.settimeout(1) 0216 try: 0217 sock.connect(param) 0218 sock.close() 0219 return True 0220 except socket.error: 0221 return False 0222 0223 def connect(self, factory): 0224 """return a twisted connector""" 0225 if self.useSocket: 0226 return Internal.reactor.connectUNIX(socketName(), factory, timeout=5) 0227 host = self.host 0228 return Internal.reactor.connectTCP(host, self.port, factory, timeout=5) 0229 0230 0231 class LoginDlg(QDialog): 0232 0233 """login dialog for server""" 0234 0235 def __init__(self): 0236 """self.servers is a list of tuples containing server and last playername""" 0237 QDialog.__init__(self, None) 0238 decorateWindow(self, i18nc('kajongg', 'Login')) 0239 self.setupUi() 0240 0241 localName = i18nc('kajongg name for local game server', Query.localServerName) 0242 self.servers = Query( 0243 'select url,lastname from server order by lasttime desc').records 0244 servers = [x[0] for x in self.servers if x[0] != Query.localServerName] 0245 # the first server combobox item should be default: either the last used server 0246 # or localName for autoPlay 0247 if localName not in servers: 0248 servers.append(localName) 0249 if 'kajongg.org' not in servers: 0250 servers.append('kajongg.org') 0251 if Internal.autoPlay: 0252 demoHost = Options.host or localName 0253 if demoHost in servers: 0254 servers.remove( 0255 demoHost) # we want a unique list, it will be re-used for all following games 0256 servers.insert(0, demoHost) 0257 # in this process but they will not be autoPlay 0258 self.cbServer.addItems(servers) 0259 self.passwords = Query('select url, p.name, passwords.password from passwords, player p ' 0260 'where passwords.player=p.id').records 0261 Players.load() 0262 self.cbServer.editTextChanged.connect(self.serverChanged) 0263 self.cbUser.editTextChanged.connect(self.userChanged) 0264 self.serverChanged() 0265 StateSaver(self) 0266 0267 def returns(self, unusedButton=None): 0268 """login data returned by this dialog""" 0269 return (Url(self.url), self.username, self.password, self.__defineRuleset()) 0270 0271 def setupUi(self): 0272 """create all Ui elements but do not fill them""" 0273 self.buttonBox = KDialogButtonBox(self) 0274 self.buttonBox.setStandardButtons( 0275 QDialogButtonBox.Cancel | QDialogButtonBox.Ok) 0276 # Ubuntu 11.10 unity is a bit strange - without this, it sets focus on 0277 # the cancel button (which it shows on the left). I found no obvious 0278 # way to use setDefault and setAutoDefault for fixing this. 0279 self.buttonBox.button(QDialogButtonBox.Ok).setFocus() 0280 self.buttonBox.accepted.connect(self.accept) 0281 self.buttonBox.rejected.connect(self.reject) 0282 vbox = QVBoxLayout(self) 0283 self.grid = QFormLayout() 0284 self.cbServer = QComboBox() 0285 self.cbServer.setEditable(True) 0286 self.grid.addRow(i18n('Game server:'), self.cbServer) 0287 self.cbUser = QComboBox() 0288 self.cbUser.setEditable(True) 0289 self.grid.addRow(i18n('Username:'), self.cbUser) 0290 self.edPassword = QLineEdit() 0291 self.edPassword.setEchoMode(QLineEdit.PasswordEchoOnEdit) 0292 self.grid.addRow(i18n('Password:'), self.edPassword) 0293 self.cbRuleset = ListComboBox() 0294 self.grid.addRow(i18nc('kajongg', 'Ruleset:'), self.cbRuleset) 0295 vbox.addLayout(self.grid) 0296 vbox.addWidget(self.buttonBox) 0297 pol = QSizePolicy() 0298 pol.setHorizontalPolicy(QSizePolicy.Expanding) 0299 self.cbUser.setSizePolicy(pol) 0300 self.__port = None 0301 0302 def serverChanged(self, unusedText=None): 0303 """the user selected a different server""" 0304 records = Query('select player.name from player, passwords ' 0305 'where passwords.url=? and passwords.player = player.id', (self.url,)).records 0306 players = [x[0] for x in records] 0307 preferPlayer = Options.player 0308 if preferPlayer: 0309 if preferPlayer in players: 0310 players.remove(preferPlayer) 0311 players.insert(0, preferPlayer) 0312 self.cbUser.clear() 0313 self.cbUser.addItems(players) 0314 if not self.cbUser.count(): 0315 user = KUser() if os.name == 'nt' else KUser(os.geteuid()) 0316 self.cbUser.addItem(user.fullName() or user.loginName()) 0317 if not preferPlayer: 0318 userNames = [x[1] for x in self.servers if x[0] == self.url] 0319 if userNames: 0320 userIdx = self.cbUser.findText(userNames[0]) 0321 if userIdx >= 0: 0322 self.cbUser.setCurrentIndex(userIdx) 0323 showPW = bool(self.url) and not Url(self.url).isLocalHost 0324 self.grid.labelForField(self.edPassword).setVisible(showPW) 0325 self.edPassword.setVisible(showPW) 0326 self.grid.labelForField( 0327 self.cbRuleset).setVisible( 0328 not showPW and not Options.ruleset) 0329 self.cbRuleset.setVisible(not showPW and not Options.ruleset) 0330 if not showPW: 0331 self.cbRuleset.clear() 0332 if Options.ruleset: 0333 self.cbRuleset.items = [Options.ruleset] 0334 else: 0335 self.cbRuleset.items = Ruleset.selectableRulesets(self.url) 0336 self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(bool(self.url)) 0337 0338 def __defineRuleset(self): 0339 """find out what ruleset to use""" 0340 if Options.ruleset: 0341 return Options.ruleset 0342 if Internal.autoPlay or bool(Options.host): 0343 return Ruleset.selectableRulesets()[0] 0344 return self.cbRuleset.current 0345 0346 def userChanged(self, text): 0347 """the username has been changed, lookup password""" 0348 if text == '': 0349 self.edPassword.clear() 0350 return 0351 passw = None 0352 for entry in self.passwords: 0353 if entry[0] == self.url and entry[1] == text: 0354 passw = entry[2] 0355 if passw: 0356 self.edPassword.setText(passw) 0357 else: 0358 self.edPassword.clear() 0359 0360 @property 0361 def url(self): 0362 """abstracts the url of the dialog""" 0363 return english(self.cbServer.currentText()) 0364 0365 @property 0366 def username(self): 0367 """abstracts the username of the dialog""" 0368 return self.cbUser.currentText() 0369 0370 @property 0371 def password(self): 0372 """abstracts the password of the dialog""" 0373 return self.edPassword.text() 0374 0375 @password.setter 0376 def password(self, password): 0377 """abstracts the password of the dialog""" 0378 self.edPassword.setText(password) 0379 0380 0381 class AddUserDialog(KDialog): 0382 0383 """add a user account on a server: This dialog asks for the needed attributes""" 0384 # pylint: disable=too-many-instance-attributes 0385 0386 def __init__(self, url, username, password): 0387 KDialog.__init__(self) 0388 decorateWindow(self, i18nc("@title:window", "Create User Account")) 0389 self.setButtons(KDialog.ButtonCode(KDialog.Ok | KDialog.Cancel)) 0390 vbox = QVBoxLayout() 0391 grid = QFormLayout() 0392 self.lbServer = QLabel() 0393 self.lbServer.setText(url) 0394 grid.addRow(i18n('Game server:'), self.lbServer) 0395 self.lbUser = QLabel() 0396 grid.addRow(i18n('Username:'), self.lbUser) 0397 self.edPassword = QLineEdit() 0398 self.edPassword.setEchoMode(QLineEdit.PasswordEchoOnEdit) 0399 grid.addRow(i18n('Password:'), self.edPassword) 0400 self.edPassword2 = QLineEdit() 0401 self.edPassword2.setEchoMode(QLineEdit.PasswordEchoOnEdit) 0402 grid.addRow(i18n('Repeat password:'), self.edPassword2) 0403 vbox.addLayout(grid) 0404 widget = QWidget(self) 0405 widget.setLayout(vbox) 0406 self.setMainWidget(widget) 0407 pol = QSizePolicy() 0408 pol.setHorizontalPolicy(QSizePolicy.Expanding) 0409 self.lbUser.setSizePolicy(pol) 0410 0411 self.edPassword.textChanged.connect(self.passwordChanged) 0412 self.edPassword2.textChanged.connect(self.passwordChanged) 0413 StateSaver(self) 0414 self.username = username 0415 self.password = password 0416 self.passwordChanged() 0417 self.edPassword2.setFocus() 0418 0419 def passwordChanged(self, unusedText=None): 0420 """password changed""" 0421 self.validate() 0422 0423 def validate(self): 0424 """does the dialog hold valid data?""" 0425 equal = self.edPassword.size( 0426 ) and self.edPassword.text( 0427 ) == self.edPassword2.text( 0428 ) 0429 self.button(KDialog.Ok).setEnabled(equal) 0430 0431 @property 0432 def username(self): 0433 """abstracts the username of the dialog""" 0434 return self.lbUser.text() 0435 0436 @username.setter 0437 def username(self, username): 0438 """abstracts the username of the dialog""" 0439 self.lbUser.setText(username) 0440 0441 @property 0442 def password(self): 0443 """abstracts the password of the dialog""" 0444 return self.edPassword.text() 0445 0446 @password.setter 0447 def password(self, password): 0448 """abstracts the password of the dialog""" 0449 self.edPassword.setText(password) 0450 0451 0452 class Connection: 0453 0454 """creates a connection to server""" 0455 0456 def __init__(self, client): 0457 self.client = client 0458 self.perspective = None 0459 self.connector = None 0460 self.url = None 0461 self.username = None 0462 self.password = None 0463 self.__ruleset = None 0464 self.dlg = LoginDlg() 0465 0466 @property 0467 def ruleset(self): 0468 """reader""" 0469 return self.__ruleset 0470 0471 @ruleset.setter 0472 def ruleset(self, value): 0473 """save changed ruleset as last used ruleset for this server""" 0474 if self.__ruleset != value: 0475 self.__ruleset = value 0476 if value: 0477 def write(): 0478 """write to database, returns 1 for success""" 0479 return Query('update server set lastruleset=? where url=?', (value.rulesetId, self.url)) 0480 value.save() 0481 # make sure we have a valid rulesetId for predefined 0482 # rulesets 0483 if not write(): 0484 self.__updateServerInfoInDatabase() 0485 write() 0486 0487 def login(self): 0488 """to be called from HumanClient""" 0489 result = DeferredDialog(self.dlg).addCallback(self.__haveLoginData) 0490 result.addCallback(self.__checkExistingConnections) 0491 result.addCallback(self.__startServer) 0492 result.addCallback(self.__loginToServer) 0493 result.addCallback(self.loggedIn) 0494 result.addErrback(self._loginReallyFailed) 0495 if Internal.autoPlay or SingleshotOptions.table or SingleshotOptions.join: 0496 result.clicked() 0497 return result 0498 0499 def __haveLoginData(self, arguments): 0500 """user entered login data, now try to login to server""" 0501 if not Internal.autoPlay and self.dlg.result() == 0: 0502 self._loginReallyFailed(Failure(CancelledError())) 0503 self.url, self.username, self.password, self.ruleset = arguments 0504 if self.url.isLocalHost: 0505 # we have localhost if we play a Local Game: client and server are identical, 0506 # we have no security concerns about creating a new account 0507 Players.createIfUnknown(self.dlg.cbUser.currentText()) 0508 0509 def __startServer(self, result): 0510 """if needed""" 0511 return self.url.startServer(result) 0512 0513 def __loginToServer(self, unused=None): 0514 """login to server""" 0515 return self.loginCommand(self.username).addErrback(self._loginFailed) 0516 0517 def loggedIn(self, perspective): 0518 """successful login on server""" 0519 assert perspective 0520 self.perspective = perspective 0521 self.perspective.notifyOnDisconnect(self.client.serverDisconnected) 0522 self.__updateServerInfoInDatabase() 0523 self.dlg = None 0524 self.pingLater() # not right now, client.connection is still None 0525 return self 0526 0527 def __updateServerInfoInDatabase(self): 0528 """we are online. Update table server.""" 0529 lasttime = datetime.datetime.now().replace(microsecond=0).isoformat() 0530 with Internal.db: 0531 serverKnown = Query( 0532 'update server set lastname=?,lasttime=? where url=?', 0533 (self.username, lasttime, self.url)).rowcount() == 1 0534 if not serverKnown: 0535 Query( 0536 'insert into server(url,lastname,lasttime) values(?,?,?)', 0537 (self.url, self.username, lasttime)) 0538 # needed if the server knows our name but our local data base does not: 0539 Players.createIfUnknown(self.username) 0540 playerId = Players.allIds[self.username] 0541 with Internal.db: 0542 if Query('update passwords set password=? where url=? and player=?', 0543 (self.password, self.url, playerId)).rowcount() == 0: 0544 Query('insert into passwords(url,player,password) values(?,?,?)', 0545 (self.url, playerId, self.password)) 0546 0547 def __checkExistingConnections(self, unused=None): 0548 """do we already have a connection to the wanted URL?""" 0549 for client in self.client.humanClients: 0550 if client.connection and client.connection.url == self.url: 0551 logWarning( 0552 i18n('You are already connected to server %1', self.url)) 0553 client.tableList.activateWindow() 0554 raise CancelledError 0555 0556 def loginCommand(self, username): 0557 """send a login command to server. That might be a normal login 0558 or adduser/deluser/change passwd encoded in the username""" 0559 factory = pb.PBClientFactory(unsafeTracebacks=True) 0560 self.connector = self.url.connect(factory) 0561 utf8Password = self.dlg.password.encode('utf-8') 0562 utf8Username = username.encode('utf-8') 0563 cred = credentials.UsernamePassword(utf8Username, utf8Password) 0564 return factory.login(cred, client=self.client) 0565 0566 def __adduser(self): 0567 """create a user account""" 0568 assert self.url is not None 0569 if not self.url.isLocalHost: 0570 if not AddUserDialog(self.url, 0571 self.dlg.username, 0572 self.dlg.password).exec_(): 0573 raise CancelledError 0574 Players.createIfUnknown(self.username) 0575 adduserCmd = SERVERMARK.join( 0576 ['adduser', self.dlg.username, self.dlg.password]) 0577 return self.loginCommand(adduserCmd) 0578 0579 def _loginFailed(self, failure): 0580 """login failed""" 0581 def answered(result): 0582 """user finally answered our question""" 0583 return self.__adduser() if result else Failure(CancelledError()) 0584 message = failure.getErrorMessage() 0585 if 'Wrong username' in message: 0586 if self.url.isLocalHost: 0587 return answered(True) 0588 msg = i18nc('USER is not known on SERVER', 0589 '%1 is not known on %2, do you want to open an account?', self.dlg.username, self.url.host) 0590 return QuestionYesNo(msg).addCallback(answered) 0591 return self._loginReallyFailed(failure) 0592 0593 def _loginReallyFailed(self, failure): 0594 """login failed, not fixable by adding missing user""" 0595 msg = None 0596 if not isAlive(Internal.mainWindow): 0597 raise CancelledError 0598 if failure.check(CancelledError): 0599 pass 0600 elif failure.check(twisted.internet.error.TimeoutError): 0601 msg = i18n('Server %1 did not answer', self.url) 0602 elif failure.check(twisted.internet.error.ConnectionRefusedError): 0603 msg = i18n('Server %1 refused connection', self.url) 0604 elif failure.check(twisted.internet.error.ConnectionLost): 0605 msg = i18n('Server %1 does not run a kajongg server', self.url) 0606 elif failure.check(twisted.internet.error.DNSLookupError): 0607 msg = i18n('Address for server %1 cannot be found', self.url) 0608 elif failure.check(twisted.internet.error.ConnectError): 0609 msg = i18n( 0610 'Login to server %1 failed: You have no network connection', 0611 self.url) 0612 else: 0613 msg = 'Login to server {} failed: {}/{} Callstack:{}'.format( 0614 self.url, failure.value.__class__.__name__, failure.getErrorMessage( 0615 ), 0616 failure.getTraceback()) 0617 # Maybe the server is running but something is wrong with it 0618 if self.url and self.url.useSocket: 0619 if removeIfExists(socketName()): 0620 logInfo( 0621 i18n('removed stale socket <filename>%1</filename>', socketName())) 0622 msg += '\n\n\n' + i18n('Please try again') 0623 self.dlg = None 0624 if msg: 0625 logWarning(msg) 0626 raise CancelledError 0627 0628 def pingLater(self, unusedResult=None): 0629 """ping the server every 5 seconds""" 0630 Internal.reactor.callLater(5, self.ping) 0631 0632 def ping(self): 0633 """regularly check if server is still there""" 0634 if self.client.connection: 0635 # when pinging starts, we do have a connection and when the 0636 # connection goes away, it does not come back 0637 self.client.callServer( 0638 'ping').addCallback( 0639 self.pingLater).addErrback( 0640 self.client.remote_serverDisconnects)