def __init__(self, reader, writer, token): super().__init__(Platform.Battlenet, version, reader, writer, token) self.local_client = LocalClient(self._update_statuses) self.authentication_client = AuthenticatedHttpClient(self) self.backend_client = BackendClient(self, self.authentication_client) self.watched_running_games = set()
def __init__(self, reader, writer, token): super().__init__(Platform.Battlenet, version, reader, writer, token) log.info(f"Starting Battle.net plugin, version {version}") self.bnet_client = None self.local_client = LocalClient() self.authentication_client = AuthenticatedHttpClient(self) self.backend_client = BackendClient(self, self.authentication_client) self.social_features = SocialFeatures(self.authentication_client) self.error_state = False self.running_task = None self.database_parser = None self.config_parser = None self.uninstaller = None self.owned_games_cache = [] self._classic_games_thread = None self._battlenet_games_thread = None self._installed_battlenet_games = {} self._installed_battlenet_games_lock = Lock() self.installed_games = self._parse_local_data() self.watched_running_games = set() self.notifications_enabled = False loop = asyncio.get_event_loop() loop.create_task(self._register_local_data_watcher())
def __init__(self, reader, writer, token): super().__init__(Platform.Battlenet, version, reader, writer, token) self.local_client = LocalClient(self._update_statuses) self.authentication_client = AuthenticatedHttpClient(self) self.backend_client = BackendClient(self, self.authentication_client) self.social_features = SocialFeatures(self.authentication_client) self.owned_games_cache = [] self.watched_running_games = set() self.local_games_called = False
def __init__(self, reader, writer, token): super().__init__(Platform.Uplay, __version__, reader, writer, token) self.client = BackendClient(self) self.local_client = LocalClient() self.cached_game_statuses = {} self.games_collection = GamesCollection() self.process_watcher = ProcessWatcher() self.game_status_notifier = GameStatusNotifier(self.process_watcher) self.tick_count = 0 self.updating_games = False self.owned_games_sent = False self.parsing_club_games = False self.parsed_local_games = False
def run(self): """ Entry point for the server Runs the main server thread, creates a cleaner object, holds the server socket and creates local clients to communicate with remote clients """ logging.debug("Server:run(): running server on port " + str(self._parameters.port)) self._isAlive = True self._cleaner = Cleaner(self, self._cleaner_queue, self._working_list) self._cleaner.start() connection = None try: server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.bind(('', self._parameters.port)) logging.info("Server:run(): Socket bind complete") server_socket.listen(self._parameters.noof_sockets) logging.info("Server:run(): Socket now listening") while self._isAlive: connection, address = server_socket.accept() connection.settimeout(self._parameters.timeout_seconds) logging.debug("Server:run(): connected with " + str(address[0]) + ":" + str(address[1])) client = LocalClient(connection, self._working_list, self._cleaner_queue, self._parameters) # if working threads are greater then allowed wait a second while len(self._working_list) >= self._parameters.noof_threads: time.sleep(1) self._working_list.append(client) client.start() # logging.info("worksize 0: " + str(len(self._working_list))) # logging.info("clena 0: " + str(self._cleaner_queue.qsize())) except KeyboardInterrupt: logging.debug("Server:run(): Got KeyboardInterrupt in Server") pass except Exception as ex: logging.exception("Server:run(): Exception: " + str(ex)) finally: if connection: connection.close() if self._server_socket: self._server_socket.close()
def InitDB(schema=None, callback=None, verify_or_create=True): """Sets the db client instance. Initialize the local datastore if --localdb was specified. Callback is invoked with the verified table schemas if 'verify_or_create' is True; None otherwise. """ assert not hasattr(DBClient, "_instance"), 'instance already initialized' assert schema is not None if options.options.localdb: from local_client import LocalClient DBClient.SetInstance(LocalClient(schema, read_only=options.options.readonly_db)) else: from dynamodb_client import DynamoDBClient DBClient._instance = DynamoDBClient(schema, read_only=options.options.readonly_db) if verify_or_create: schema.VerifyOrCreate(DBClient.Instance(), callback) else: callback([])
def main(configuration): aio = Client(configuration['AIO']['username'], configuration['AIO']['key']) topic_data_mapper = { feed['topic']: feed['data'] for feed in configuration['feeds'] } feeds = generate_feeds(aio, topic_data_mapper.keys()) if configuration['mock_client']: local_client = MockLocalClient() else: local_client = LocalClient( client_id=configuration['local']['client_id'], host=configuration['local']['host'], port=configuration['local']['port'], subscription_paths=topic_data_mapper.keys()) local_client.start() while True: message = local_client.message_queue.get() if message is None: continue topic = message.topic payload = message.payload json_payload = json.loads(payload) print('[MAIN] received ' + str(topic) + ' ' + str(json_payload)) feed = feeds[topic] try: if topic in topic_data_mapper.keys(): aio.send_data(feed.key, json_payload['data'][topic_data_mapper[topic]]) print('[AIO_REMOTE] send ' + str(topic) + ': ' + str(json_payload['data'][topic_data_mapper[topic]])) else: print('[AIO_REMOTE] discard message ' + str(topic) + str(json_payload)) except ThrottlingError as e: print('[MAIN] Skipping value. Limit reached.') continue
class BNetPlugin(Plugin): PRODUCT_DB_PATH = pathlib.Path(AGENT_PATH) / 'product.db' CONFIG_PATH = CONFIG_PATH def __init__(self, reader, writer, token): super().__init__(Platform.Battlenet, version, reader, writer, token) log.info(f"Starting Battle.net plugin, version {version}") self.bnet_client = None self.local_client = LocalClient() self.authentication_client = AuthenticatedHttpClient(self) self.backend_client = BackendClient(self, self.authentication_client) self.social_features = SocialFeatures(self.authentication_client) self.error_state = False self.running_task = None self.database_parser = None self.config_parser = None self.uninstaller = None self.owned_games_cache = [] self._classic_games_thread = None self._battlenet_games_thread = None self._installed_battlenet_games = {} self._installed_battlenet_games_lock = Lock() self.installed_games = self._parse_local_data() self.watched_running_games = set() self.notifications_enabled = False loop = asyncio.get_event_loop() loop.create_task(self._register_local_data_watcher()) async def _register_local_data_watcher(self): async def ping(event, interval): while True: await asyncio.sleep(interval) if not self.watched_running_games: if not event.is_set(): event.set() parse_local_data_event = asyncio.Event() FileWatcher(self.CONFIG_PATH, parse_local_data_event, interval=1) FileWatcher(self.PRODUCT_DB_PATH, parse_local_data_event, interval=2.5) asyncio.create_task(ping(parse_local_data_event, 30)) while True: await parse_local_data_event.wait() refreshed_games = self._parse_local_data() if not self.notifications_enabled: self._update_statuses(refreshed_games, self.installed_games) self.installed_games = refreshed_games parse_local_data_event.clear() async def _notify_about_game_stop(self, game, starting_timeout): id_to_watch = game.info.id if id_to_watch in self.watched_running_games: log.debug(f'Game {id_to_watch} is already watched. Skipping') return try: self.watched_running_games.add(id_to_watch) await asyncio.sleep(starting_timeout) ProcessProvider().update_games_processes([game]) log.info(f'Setuping process watcher for {game._processes}') loop = asyncio.get_event_loop() await loop.run_in_executor(None, game.wait_until_game_stops) finally: self.update_local_game_status(LocalGame(id_to_watch, LocalGameState.Installed)) self.watched_running_games.remove(id_to_watch) def _update_statuses(self, refreshed_games, previous_games): for blizz_id, refr in refreshed_games.items(): prev = previous_games.get(blizz_id, None) if prev is None: if refr.playable: log.debug('Detected playable game') state = LocalGameState.Installed else: log.debug('Detected installation begin') state = LocalGameState.None_ elif refr.playable and not prev.playable: log.debug('Detected playable game') state = LocalGameState.Installed elif refr.last_played != prev.last_played: log.debug('Detected launched game') state = LocalGameState.Installed | LocalGameState.Running asyncio.create_task(self._notify_about_game_stop(refr, 5)) else: continue log.info(f'Changing game {blizz_id} state to {state}') self.update_local_game_status(LocalGame(blizz_id, state)) for blizz_id, prev in previous_games.items(): refr = refreshed_games.get(blizz_id, None) if refr is None: log.debug('Detected uninstalled game') state = LocalGameState.None_ self.update_local_game_status(LocalGame(blizz_id, state)) def _load_local_files(self): try: product_db = load_product_db(self.PRODUCT_DB_PATH) self.database_parser = DatabaseParser(product_db) except FileNotFoundError as e: log.warning(f"product.db not found: {repr(e)}") return False except WindowsError as e: # 5 WindowsError access denied if e.winerror == 5: log.warning(f"product.db not accessible: {repr(e)}") self.config_parser = ConfigParser(None) return False else: raise () except OSError as e: if e.errno == errno.EACCES: log.warning(f"product.db not accessible: {repr(e)}") self.config_parser = ConfigParser(None) return False else: raise () else: if self.local_client.is_installed != self.database_parser.battlenet_present: self.local_client.refresh() try: config = load_config(self.CONFIG_PATH) self.config_parser = ConfigParser(config) except FileNotFoundError as e: log.warning(f"config file not found: {repr(e)}") self.config_parser = ConfigParser(None) return False except WindowsError as e: # 5 WindowsError access denied if e.winerror == 5: log.warning(f"config file not accessible: {repr(e)}") self.config_parser = ConfigParser(None) return False else: raise () except OSError as e: if e.errno == errno.EACCES: log.warning(f"config file not accessible: {repr(e)}") self.config_parser = ConfigParser(None) return False else: raise () return True def _get_battlenet_installed_games(self): def _add_battlenet_game(config_game, db_game): if config_game.uninstall_tag != db_game.uninstall_tag: return None try: blizzard_game = Blizzard[config_game.uid] except KeyError: log.warning(f'[{config_game.uid}] is not known blizzard game. Skipping') return None try: log.info(f"Adding {blizzard_game.blizzard_id} {blizzard_game.name} to installed games") return InstalledGame( blizzard_game, config_game.uninstall_tag, db_game.version, config_game.last_played, db_game.install_path, db_game.playable ) except FileNotFoundError as e: log.warning(str(e) + '. Probably outdated product.db after uninstall. Skipping') return None games = {} for db_game in self.database_parser.games: for config_game in self.config_parser.games: installed_game = _add_battlenet_game(config_game, db_game) if installed_game: games[installed_game.info.id] = installed_game self._installed_battlenet_games_lock.acquire() self._installed_battlenet_games = games self._installed_battlenet_games_lock.release() def _parse_local_data(self): """Game is considered as installed when present in both config and product.db""" games = {} # give threads 4 seconds to finish join_timeout = 4 if not self._classic_games_thread or not self._classic_games_thread.isAlive(): self._classic_games_thread = Thread(target=self.local_client.find_classic_games, daemon=True) self._classic_games_thread.start() log.info("Started classic games thread") if not self._load_local_files(): self._classic_games_thread.join(join_timeout) if not self.local_client.classics_lock.acquire(False): return [] else: installed_classics = self.local_client.installed_classics self.local_client.classics_lock.release() return installed_classics try: if SYSTEM == pf.WINDOWS and self.uninstaller is None: uninstaller_path = pathlib.Path(AGENT_PATH) / 'Blizzard Uninstaller.exe' self.uninstaller = Uninstaller(uninstaller_path) except FileNotFoundError as e: log.warning('uninstaller not found' + str(e)) try: if self.local_client.is_installed != self.database_parser.battlenet_present: self.local_client.refresh() log.info(f"Games found in db {self.database_parser.games}") log.info(f"Games found in config {self.config_parser.games}") if not self._battlenet_games_thread or not self._battlenet_games_thread.isAlive(): self._battlenet_games_thread = Thread(target=self._get_battlenet_installed_games, daemon=True) self._battlenet_games_thread.start() log.info("Started classic games thread") except Exception as e: log.exception(str(e)) finally: self._classic_games_thread.join(join_timeout) self._battlenet_games_thread.join(join_timeout) if self.local_client.classics_lock.acquire(False): games = self.local_client.installed_classics self.local_client.classics_lock.release() if self._installed_battlenet_games_lock.acquire(False): games = {**self._installed_battlenet_games, **games} self._installed_battlenet_games_lock.release() return games def log_out(self): if self.backend_client: asyncio.create_task(self.authentication_client.shutdown()) self.authentication_client.user_details = None self.owned_games_cache = [] async def open_battlenet_browser(self): url = f"https://www.blizzard.com/apps/battle.net/desktop" log.info(f'Opening battle.net website: {url}') loop = asyncio.get_running_loop() await loop.run_in_executor(None, lambda x: webbrowser.open(x, autoraise=True), url) async def install_game(self, game_id): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() installed_game = self.installed_games.get(game_id, None) if installed_game and os.access(installed_game.install_path, os.F_OK): log.warning("Received install command on an already installed game") return await self.launch_game(game_id) if game_id in Blizzard.legacy_game_ids: if SYSTEM == pf.WINDOWS: platform = 'windows' elif SYSTEM == pf.MACOS: platform = 'macos' webbrowser.open(f"https://www.blizzard.com/download/confirmation?platform={platform}&locale=enUS&version=LIVE&id={game_id}") return try: self.local_client.refresh() log.info(f'Installing game of id {game_id}') self.local_client.install_game(game_id) except ClientNotInstalledError as e: log.warning(e) await self.open_battlenet_browser() except Exception as e: log.exception(f"Installing game {game_id} failed: {e}") def _open_battlenet_at_id(self, game_id): try: self.local_client.refresh() self.local_client.open_battlenet(game_id) except Exception as e: log.exception(f"Opening battlenet client on specific game_id {game_id} failed {e}") try: self.local_client.open_battlenet() except Exception as e: log.exception(f"Opening battlenet client failed {e}") async def uninstall_game(self, game_id): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() if game_id == 'wow_classic': # attempting to uninstall classic wow through protocol gives you a message that the game cannot # be uninstalled through protocol and you should use battle.net return self._open_battlenet_at_id(game_id) if SYSTEM == pf.MACOS: self._open_battlenet_at_id(game_id) else: try: installed_game = self.installed_games.get(game_id, None) if installed_game is None or not os.access(installed_game.install_path, os.F_OK): log.error(f'Cannot uninstall {Blizzard[game_id].uid}') self.update_local_game_status(LocalGame(game_id, LocalGameState.None_)) return if not isinstance(installed_game.info, ClassicGame): if self.uninstaller is None: raise FileNotFoundError('Uninstaller not found') uninstall_tag = installed_game.uninstall_tag client_lang = self.config_parser.locale_language self.uninstaller.uninstall_game(installed_game, uninstall_tag, client_lang) except Exception as e: log.exception(f'Uninstalling game {game_id} failed: {e}') async def launch_game(self, game_id): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() try: if self.installed_games is None: log.error(f'Launching game that is not installed: {game_id}') return await self.install_game(game_id) game = self.installed_games.get(game_id, None) if game is None: log.error(f'Launching game that is not installed: {game_id}') return await self.install_game(game_id) if isinstance(game.info, ClassicGame): log.info(f'Launching game of id: {game_id}, {game} at path {os.path.join(game.install_path, game.info.exe)}') if SYSTEM == pf.WINDOWS: subprocess.Popen(os.path.join(game.install_path, game.info.exe)) elif SYSTEM == pf.MACOS: if not game.info.bundle_id: log.warning(f"{game.name} has no bundle id, help by providing us bundle id of this game") subprocess.Popen(['open', '-b', game.info.bundle_id]) self.update_local_game_status(LocalGame(game_id, LocalGameState.Installed | LocalGameState.Running)) asyncio.create_task(self._notify_about_game_stop(game, 6)) return self.local_client.refresh() log.info(f'Launching game of id: {game_id}, {game}') await self.local_client.launch_game(game, wait_sec=60) self.update_local_game_status(LocalGame(game_id, LocalGameState.Installed | LocalGameState.Running)) self.local_client.close_window() asyncio.create_task(self._notify_about_game_stop(game, 3)) except ClientNotInstalledError as e: log.warning(e) await self.open_battlenet_browser() except TimeoutError as e: log.warning(str(e)) except Exception as e: log.exception(f"Launching game {game_id} failed: {e}") async def authenticate(self, stored_credentials=None): try: if stored_credentials: auth_data = self.authentication_client.process_stored_credentials(stored_credentials) try: await self.authentication_client.create_session() await self.backend_client.refresh_cookies() auth_status = await self.backend_client.validate_access_token(auth_data.access_token) except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e: raise e except Exception: raise InvalidCredentials() if self.authentication_client.validate_auth_status(auth_status): self.authentication_client.user_details = await self.backend_client.get_user_info() return self.authentication_client.parse_user_details() else: return self.authentication_client.authenticate_using_login() except Exception as e: raise e async def pass_login_credentials(self, step, credentials, cookies): if "logout&app=oauth" in credentials['end_uri']: # 2fa expired, repeat authentication return self.authentication_client.authenticate_using_login() if self.authentication_client.attempted_to_set_battle_tag: self.authentication_client.user_details = await self.backend_client.get_user_info() return self.authentication_client.parse_auth_after_setting_battletag() cookie_jar = self.authentication_client.parse_cookies(cookies) auth_data = await self.authentication_client.get_auth_data_login(cookie_jar, credentials) try: await self.authentication_client.create_session() await self.backend_client.refresh_cookies() except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e: raise e except Exception: raise InvalidCredentials() auth_status = await self.backend_client.validate_access_token(auth_data.access_token) if not ("authorities" in auth_status and "IS_AUTHENTICATED_FULLY" in auth_status["authorities"]): raise InvalidCredentials() self.authentication_client.user_details = await self.backend_client.get_user_info() self.authentication_client.set_credentials() return self.authentication_client.parse_battletag() async def get_friends(self): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() friends_list = await self.social_features.get_friends() return [FriendInfo(user_id=friend.id.low, user_name='') for friend in friends_list] async def get_owned_games(self): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() def _parse_classic_games(classic_games): for classic_game in classic_games["classicGames"]: log.info(f"looking for {classic_game} in classic games") try: blizzard_game = Blizzard[classic_game["localizedGameName"].replace(u'\xa0', ' ')] log.info(f"match! {blizzard_game}") classic_game["titleId"] = blizzard_game.uid classic_game["gameAccountStatus"] = "Good" except KeyError: continue return classic_games def _get_not_added_free_games(owned_games): owned_games_ids = [] for game in owned_games: if "titleId" in game: owned_games_ids.append(str(game["titleId"])) return [{"titleId": game.blizzard_id, "localizedGameName": game.name, "gameAccountStatus": "Free"} for game in Blizzard.free_games if game.blizzard_id not in owned_games_ids] try: games = await self.backend_client.get_owned_games() classic_games = _parse_classic_games(await self.backend_client.get_owned_classic_games()) owned_games = games["gameAccounts"] + classic_games["classicGames"] # Add wow classic if retail wow is present in owned games for owned_game in owned_games.copy(): if 'titleId' in owned_game: if owned_game['titleId'] == 5730135: owned_games.append({'titleId': 'wow_classic', 'localizedGameName': 'World of Warcraft Classic', 'gameAccountStatus': owned_game['gameAccountStatus']}) free_games_to_add = _get_not_added_free_games(owned_games) owned_games += free_games_to_add log.info(f"Owned games {owned_games} with free games") self.owned_games_cache = owned_games return [ Game( str(game["titleId"]), game["localizedGameName"], [], LicenseInfo(License_Map[game["gameAccountStatus"]]), ) for game in self.owned_games_cache if "titleId" in game ] except Exception as e: log.exception(f"failed to get owned games: {repr(e)}") raise async def get_local_games(self): try: local_games = [] running_games = ProcessProvider().update_games_processes(self.installed_games.values()) log.info(f"Installed games {self.installed_games.items()}") log.info(f"Running games {running_games}") for id_, game in self.installed_games.items(): if game.playable: state = LocalGameState.Installed if id_ in running_games: state |= LocalGameState.Running else: state = LocalGameState.None_ local_games.append(LocalGame(id_, state)) return local_games except Exception as e: log.exception(f"failed to get local games: {str(e)}") raise finally: self.enable_notifications = True async def _get_wow_achievements(self): achievements = [] try: characters_data = await self.backend_client.get_wow_character_data() characters_data = characters_data["characters"] wow_character_data = await asyncio.gather( *[ self.backend_client.get_wow_character_achievements(character["realm"], character["name"]) for character in characters_data ], return_exceptions=True, ) for data in wow_character_data: if isinstance(data, requests.Timeout) or isinstance(data, requests.ConnectionError): raise data wow_achievement_data = [ list( zip( data["achievements"]["achievementsCompleted"], data["achievements"]["achievementsCompletedTimestamp"], ) ) for data in wow_character_data if type(data) is dict ] already_in = set() for char_ach in wow_achievement_data: for ach in char_ach: if ach[0] not in already_in: achievements.append(Achievement(achievement_id=ach[0], unlock_time=int(ach[1] / 1000))) already_in.add(ach[0]) except (AccessTokenExpired, BackendError) as e: log.exception(str(e)) with open('wow.json', 'w') as f: f.write(json.dumps(achievements, cls=DataclassJSONEncoder)) return achievements async def _get_sc2_achievements(self): account_data = await self.backend_client.get_sc2_player_data(self.authentication_client.user_details["id"]) # TODO what if more sc2 accounts? assert len(account_data) == 1 account_data = account_data[0] profile_data = await self.backend_client.get_sc2_profile_data( account_data["regionId"], account_data["realmId"], account_data["profileId"] ) sc2_achievement_data = [ Achievement(achievement_id=achievement["achievementId"], unlock_time=achievement["completionDate"]) for achievement in profile_data["earnedAchievements"] if achievement["isComplete"] ] with open('sc2.json', 'w') as f: f.write(json.dumps(sc2_achievement_data, cls=DataclassJSONEncoder)) return sc2_achievement_data # async def get_unlocked_achievements(self, game_id): # if not self.website_client.is_authenticated(): # raise AuthenticationRequired() # try: # if game_id == "21298": # return await self._get_sc2_achievements() # elif game_id == "5730135": # return await self._get_wow_achievements() # else: # return [] # except requests.Timeout: # raise BackendTimeout() # except requests.ConnectionError: # raise NetworkError() # except Exception as e: # log.exception(str(e)) # return [] async def _tick_runner(self): if not self.bnet_client: return try: self.error_state = await self.bnet_client.tick() except Exception as e: self.error_state = True log.exception(f"error state: {str(e)}") raise def tick(self): if not self.error_state and (not self.running_task or self.running_task.done()): self.running_task = asyncio.create_task(self._tick_runner()) elif self.error_state: sys.exit(1) def shutdown(self): log.info("Plugin shutdown.") asyncio.create_task(self.authentication_client.shutdown())
class UplayPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Uplay, __version__, reader, writer, token) self.client = BackendClient(self) self.local_client = LocalClient() self.cached_game_statuses = {} self.games_collection = GamesCollection() self.process_watcher = ProcessWatcher() self.game_status_notifier = GameStatusNotifier(self.process_watcher) self.tick_count = 0 self.updating_games = False self.owned_games_sent = False self.parsing_club_games = False self.parsed_local_games = False def auth_lost(self): self.lost_authentication() async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", AUTH_PARAMS, cookies=COOKIES) else: try: user_data = await self.client.authorise_with_stored_credentials( stored_credentials) except (AccessDenied, AuthenticationRequired) as e: log.exception(repr(e)) raise InvalidCredentials() except Exception as e: log.exception(repr(e)) raise e else: self.local_client.initialize(user_data['userId']) self.client.set_auth_lost_callback(self.auth_lost) return Authentication(user_data['userId'], user_data['username']) async def pass_login_credentials(self, step, credentials, cookies): """Called just after CEF authentication (called as NextStep by authenticate)""" user_data = await self.client.authorise_with_cookies(cookies) self.local_client.initialize(user_data['userId']) self.client.set_auth_lost_callback(self.auth_lost) return Authentication(user_data['userId'], user_data['username']) async def get_owned_games(self): if not self.client.is_authenticated(): raise AuthenticationRequired() if SYSTEM == System.WINDOWS: self._parse_local_games() self._parse_local_game_ownership() await self._parse_club_games() try: await self._parse_subscription_games() except Exception as e: log.warning( f"Parsing subscriptions failed, most likely account without subscription {repr(e)}" ) self.owned_games_sent = True for game in self.games_collection: game.considered_for_sending = True return [ game.as_galaxy_game() for game in self.games_collection if game.owned ] async def _parse_subscription_games(self): subscription_games = [] sub_response = await self.client.get_subscription() if not sub_response: return for game in sub_response['games']: subscription_games.append( UbisoftGame(space_id='', launch_id=str(game['uplayGameId']), install_id=str(game['uplayGameId']), third_party_id='', name=game['name'], path='', type=GameType.New, special_registry_path='', exe='', status=GameStatus.Unknown, owned=game['ownership'], activation_id=str(game['id']))) self.games_collection.extend(subscription_games) async def _parse_club_games(self): if not self.parsing_club_games: try: self.parsing_club_games = True games = await self.client.get_club_titles() club_games = [] for game in games: if "platform" in game: if game["platform"] == "PC": log.info( f"Parsed game from Club Request {game['title']}" ) club_games.append( UbisoftGame(space_id=game['spaceId'], launch_id='', install_id='', third_party_id='', name=game['title'], path='', type=GameType.New, special_registry_path='', exe='', status=GameStatus.Unknown, owned=True)) else: log.debug( f"Skipped game from Club Request for {game['platform']}: {game['spaceId']}, {game['title']}" ) self.games_collection.extend(club_games) except ApplicationError as e: log.error( f"Encountered exception while parsing club games {repr(e)}" ) raise e except Exception as e: log.error( f"Encountered exception while parsing club games {repr(e)}" ) finally: self.parsing_club_games = False else: # Wait until club games get parsed if parsing is already in progress while self.parsing_club_games: await asyncio.sleep(0.2) def _parse_local_games(self): """Parsing local files should lead to every game having a launch id. A game in the games_collection which doesn't have a launch id probably means that a game was added through the get_club_titles request but its space id was not present in configuration file and we couldn't find a matching launch id for it.""" if self.local_client.configurations_accessible(): try: configuration_data = self.local_client.read_config() p = LocalParser() games = [] for game in p.parse_games(configuration_data): games.append(game) self.games_collection.extend(games) except scanner.ScannerError as e: log.error( f"Scanner error while parsing configuration, yaml is probably corrupted {repr(e)}" ) def _parse_local_game_ownership(self): if self.local_client.ownership_accessible(): ownership_data = self.local_client.read_ownership() p = LocalParser() ownership_records = p.get_owned_local_games(ownership_data) log.info(f"Ownership Records {ownership_records}") for game in self.games_collection: if game.install_id: if int(game.install_id) in ownership_records: game.owned = True if game.launch_id: if int(game.launch_id) in ownership_records: game.owned = True def _update_games(self): self.updating_games = True self._parse_local_games() self._parse_local_game_ownership() self.updating_games = False def _update_local_games_status(self): cached_statuses = self.cached_game_statuses if cached_statuses is None: return for game in self.games_collection: try: self.game_status_notifier.update_game(game) if game.status != cached_statuses[game.install_id]: log.info( f"Game {game.name} path changed: updating status from {cached_statuses[game.install_id]} to {game.status}" ) self.update_local_game_status(game.as_local_game()) self.cached_game_statuses[game.install_id] = game.status except KeyError: self.game_status_notifier.update_game(game) ''' If a game wasn't previously in a cache then and it appears with an installed or running status it most likely means that client was just installed ''' if game.status in [GameStatus.Installed, GameStatus.Running]: self.update_local_game_status(game.as_local_game()) self.cached_game_statuses[game.install_id] = game.status if SYSTEM == System.WINDOWS: async def get_local_games(self): self._parse_local_games() local_games = [] for game in self.games_collection: self.cached_game_statuses[game.launch_id] = game.status if game.status == GameStatus.Installed or game.status == GameStatus.Running: local_games.append(game.as_local_game()) self._update_local_games_status() self.parsed_local_games = True return local_games async def _add_new_games(self, games): await self._parse_club_games() self._parse_local_game_ownership() for game in games: if game.owned: self.add_game(game.as_galaxy_game()) async def prepare_game_times_context(self, game_ids): return await self.get_playtime(game_ids) async def get_game_time(self, game_id, context): game_time = context.get(game_id) if game_time is None: raise UnknownError("Game {} not owned".format(game_id)) return game_time async def get_playtime(self, game_ids): if not self.client.is_authenticated(): raise AuthenticationRequired() games_playtime = {} blacklist = json.loads( self.persistent_cache.get('games_without_stats', '{}')) current_time = int(time.time()) for game_id in game_ids: if not self.games_collection.get(game_id): await self.get_owned_games() break for game_id in game_ids: try: expire_in = blacklist.get(game_id, 0) - current_time if expire_in > 0: log.debug( f'Cache: No game stats for {game_id}. Recheck in {expire_in}s' ) games_playtime[game_id] = GameTime(game_id, None, None) continue game = self.games_collection[game_id] if not game.space_id: games_playtime[game_id] = GameTime(game_id, None, None) continue try: response = await self.client.get_game_stats(game.space_id) except ApplicationError as err: self._game_time_import_failure(game_id, err) continue statscards = response.get('Statscards', None) if statscards is None: blacklist[ game_id] = current_time + 3600 * 24 * 14 # two weeks games_playtime[game_id] = GameTime(game_id, None, None) continue playtime, last_played = find_times(statscards, game_id) if playtime == 0: playtime = None if last_played == 0: last_played = None log.info( f'Stats for {game.name}: playtime: {playtime}, last_played: {last_played}' ) games_playtime[game_id] = GameTime(game_id, playtime, last_played) except Exception as e: log.error( f"Getting game times for game {game_id} has crashed: " + repr(e)) self._game_time_import_failure(game_id, UnknownError()) self.persistent_cache['games_without_stats'] = json.dumps(blacklist) self.push_cache() return games_playtime async def get_unlocked_challenges(self, game_id): """Challenges are a unique uplay club feature and don't directly translate to achievements""" if not self.client.is_authenticated(): raise AuthenticationRequired() for game in self.games_collection: if game.space_id == game_id or game.launch_id == game_id: if not game.space_id: return [] challenges = await self.client.get_challenges(game.space_id) return [ Achievement(achievement_id=challenge["id"], achievement_name=challenge["name"], unlock_time=int( datetime.datetime.timestamp( dateutil.parser.parse( challenge["completionDate"])))) for challenge in challenges["actions"] if challenge["isCompleted"] and not challenge["isBadge"] ] if SYSTEM == System.WINDOWS: async def launch_game(self, game_id): if not self.parsed_local_games: await self.get_local_games() elif not self.user_can_perform_actions(): return for game in self.games_collection.get_local_games(): if (game.space_id == game_id or game.install_id == game_id or game.launch_id == game_id) and game.status == GameStatus.Installed: if game.type == GameType.Steam: if is_steam_installed(): url = f"start steam://rungameid/{game.third_party_id}" else: url = f"start uplay://open/game/{game.launch_id}" elif game.type == GameType.New or game.type == GameType.Legacy: log.debug('Launching game') self.game_status_notifier._legacy_game_launched = True url = f"start uplay://launch/{game.launch_id}" else: log.error(f"Unsupported game type {game.name}") self.open_uplay_client() return log.info( f"Launching game '{game.name}' by protocol: [{url}]") subprocess.Popen(url, shell=True) self.reset_tick_count() return for game in self.games_collection: if (game.space_id == game_id or game.install_id == game_id) and game.status in [ GameStatus.NotInstalled, GameStatus.Unknown ]: log.warning("Game is not installed, installing") return await self.install_game(game_id) log.info("Failed to launch game, launching client instead.") self.open_uplay_client() async def activate_game(self, activation_id): if not await self.client.activate_game(activation_id): log.info(f"Couldnt activate game with id {activation_id}") return log.info(f"Activated game with id {activation_id}") timeout = time.time() + 3 while timeout >= time.time(): if self.local_client.ownership_changed(): # Will refresh informations in collection about the game await self.get_owned_games() await asyncio.sleep(0.1) if SYSTEM == System.WINDOWS: async def install_game(self, game_id, retry=False): log.debug(self.games_collection) if not self.user_can_perform_actions(): return for game in self.games_collection: game_ids = [game.space_id, game.install_id, game.launch_id] if (game_id in game_ids) and game.owned and game.status in [ GameStatus.NotInstalled, GameStatus.Unknown ]: if game.install_id: log.info(f"Installing game: {game_id}, {game}") subprocess.Popen( f"start uplay://install/{game.install_id}", shell=True) return if (game_id in game_ids) and game.status == GameStatus.Installed: log.warning("Game already installed, launching") return await self.launch_game(game_id) if (game_id in game_ids ) and not game.owned and game.activation_id and not retry: log.warning("Activating game from subscription") if not self.local_client.is_running(): self.open_uplay_client() timeout = time.time() + 10 while not self.local_client.is_running( ) and time.time() <= timeout: await asyncio.sleep(0.1) await self.activate_game(game.activation_id) asyncio.create_task( self.install_game(game_id=game_id, retry=True)) # if launch_id is not known, try to launch local client instead self.open_uplay_client() log.info( f"Did not found game with game_id: {game_id}, proper launch_id and NotInstalled status, launching client." ) if SYSTEM == System.WINDOWS: async def uninstall_game(self, game_id): if not self.user_can_perform_actions(): return for game in self.games_collection.get_local_games(): if (game.space_id == game_id or game.launch_id == game_id) and game.status == GameStatus.Installed: subprocess.Popen( f"start uplay://uninstall/{game.launch_id}", shell=True) return self.open_uplay_client() log.info( f"Did not found game with game_id: {game_id}, proper launch_id and Installed status, launching client." ) def user_can_perform_actions(self): if not self.local_client.is_installed: self.open_uplay_browser() return False if not self.local_client.was_user_logged_in: self.open_uplay_client() return False return True def open_uplay_client(self): subprocess.Popen("start uplay://", shell=True) def open_uplay_browser(self): url = 'https://uplay.ubisoft.com' log.info(f"Opening uplay website: {url}") webbrowser.open(url, autoraise=True) def refresh_game_statuses(self): if not self.local_client.was_user_logged_in: return statuses = self.game_status_notifier.statuses new_games = [] for game in self.games_collection: try: if statuses[ game. install_id] == GameStatus.Installed and game.status in [ GameStatus.NotInstalled, GameStatus.Unknown ]: log.info( f"updating status for {game.name} to installed from not installed" ) game.status = GameStatus.Installed self.update_local_game_status(game.as_local_game()) elif statuses[ game. install_id] == GameStatus.Installed and game.status == GameStatus.Running: log.info( f"updating status for {game.name} to installed from running" ) game.status = GameStatus.Installed self.update_local_game_status(game.as_local_game()) asyncio.create_task(self.prevent_uplay_from_showing()) elif statuses[ game. install_id] == GameStatus.Running and game.status != GameStatus.Running: log.info(f"updating status for {game.name} to running") game.status = GameStatus.Running self.update_local_game_status(game.as_local_game()) elif statuses[game.install_id] in [ GameStatus.NotInstalled, GameStatus.Unknown ] and game.status not in [ GameStatus.NotInstalled, GameStatus.Unknown ]: log.info( f"updating status for {game.name} to not installed") game.status = GameStatus.NotInstalled self.update_local_game_status(game.as_local_game()) except KeyError: continue if self.owned_games_sent and not game.considered_for_sending: game.considered_for_sending = True new_games.append(game) if new_games: asyncio.create_task(self._add_new_games(new_games)) async def get_friends(self): friends = await self.client.get_friends() return [ FriendInfo(user_id=friend["pid"], user_name=friend["nameOnPlatform"]) for friend in friends["friends"] ] async def get_subscriptions(self) -> List[Subscription]: sub_status = await self.client.get_subscription() sub_status = True if sub_status else False return [ Subscription( subscription_name="Uplay+", end_time=None, owned=sub_status, subscription_discovery=SubscriptionDiscovery.AUTOMATIC) ] async def prepare_subscription_games_context( self, subscription_names: List[str]) -> Any: sub_games_response = await self.client.get_subscription() if sub_games_response: return [ SubscriptionGame(game_title=game['name'], game_id=str(game['uplayGameId'])) for game in sub_games_response["games"] ] return None async def get_subscription_games( self, subscription_name: str, context: Any) -> AsyncGenerator[List[SubscriptionGame], None]: yield context if SYSTEM == System.WINDOWS: async def launch_platform_client(self): if self.local_client.is_running(): log.info( "Launch platform client called but Uplay is already running" ) return url = "start uplay://" subprocess.Popen(url, shell=True) # Uplay tries to get focus a couple of times when being launched end_time = time.time() + 15 while time.time() <= end_time: await self.prevent_uplay_from_showing(kill_attempt=False) await asyncio.sleep(0.05) if SYSTEM == System.WINDOWS: async def shutdown_platform_client(self): if self.local_client.is_installed: subprocess.Popen("taskkill.exe /im \"upc.exe\"", shell=True) if SYSTEM == System.WINDOWS: async def prevent_uplay_from_showing(self, kill_attempt=True): if not self.local_client.is_installed: log.info("Local client not installed") return client_popup_wait_time = 5 check_frequency_delay = 0.02 end_time = time.time() + client_popup_wait_time hwnd = ctypes.windll.user32.FindWindowW(None, "Uplay") while not ctypes.windll.user32.IsWindowVisible(hwnd): if time.time() >= end_time: log.info("Timed out post close game uplay popup") break hwnd = ctypes.windll.user32.FindWindowW(None, "Uplay") await asyncio.sleep(check_frequency_delay) if kill_attempt: await self.shutdown_platform_client() else: ctypes.windll.user32.SetForegroundWindow(hwnd) ctypes.windll.user32.CloseWindow(hwnd) if SYSTEM == System.WINDOWS: async def prepare_game_library_settings_context(self, game_ids): if self.local_client.settings_accessible(): library_context = {} settings_data = self.local_client.read_settings() parser = LocalParser() favorite_games, hidden_games = parser.get_game_tags( settings_data) for game_id in game_ids: try: game = self.games_collection[game_id] except KeyError: continue library_context[game_id] = { 'favorite': game.launch_id in favorite_games, 'hidden': game.launch_id in hidden_games } return library_context return None async def get_game_library_settings(self, game_id, context): log.debug(f"Context {context}") if not context: # Unable to retrieve context return GameLibrarySettings(game_id, None, None) game_library_settings = context.get(game_id) if game_library_settings is None: # Able to retrieve context but game is not in its values -> It doesnt have any tags or hidden status set return GameLibrarySettings(game_id, [], False) return GameLibrarySettings( game_id, ['favorite'] if game_library_settings['favorite'] else [], game_library_settings['hidden']) def reset_tick_count(self): # Resetting tick count ensures that certain operations performed on tick will be made with a known delay. self.tick_count = 0 def tick(self): loop = asyncio.get_event_loop() if SYSTEM == System.WINDOWS: self.tick_count += 1 if self.tick_count % 1 == 0: self.refresh_game_statuses() if self.tick_count % 5 == 0: self.game_status_notifier.launcher_log_path = self.local_client.launcher_log_path if self.tick_count % 9 == 0: self._update_local_games_status() if self.local_client.ownership_changed(): if not self.updating_games: log.info( 'Ownership file has been changed or created. Reparsing.' ) loop.run_in_executor(None, self._update_games) return async def shutdown(self): log.info("Plugin shutdown.") await self.client.close()
class BNetPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Battlenet, version, reader, writer, token) self.local_client = LocalClient(self._update_statuses) self.authentication_client = AuthenticatedHttpClient(self) self.backend_client = BackendClient(self, self.authentication_client) self.watched_running_games = set() def handshake_complete(self): self.create_task(self.__delayed_handshake(), 'delayed handshake') async def __delayed_handshake(self): """ Adds some minimal delay on Galaxy start before registering local data watchers. Apparently Galaxy may be not ready to receive notifications even after handshake_complete. """ await asyncio.sleep(1) self.create_task(self.local_client.register_local_data_watcher(), 'local data watcher') self.create_task(self.local_client.register_classic_games_updater(), 'classic games updater') async def _notify_about_game_stop(self, game, starting_timeout): id_to_watch = game.info.uid if id_to_watch in self.watched_running_games: log.debug(f'Game {id_to_watch} is already watched. Skipping') return try: self.watched_running_games.add(id_to_watch) await asyncio.sleep(starting_timeout) ProcessProvider().update_games_processes([game]) log.info(f'Setuping process watcher for {game._processes}') loop = asyncio.get_event_loop() await loop.run_in_executor(None, game.wait_until_game_stops) finally: self.update_local_game_status( LocalGame(id_to_watch, LocalGameState.Installed)) self.watched_running_games.remove(id_to_watch) def _update_statuses(self, refreshed_games, previous_games): for blizz_id, refr in refreshed_games.items(): prev = previous_games.get(blizz_id, None) if prev is None: if refr.has_galaxy_installed_state: log.debug('Detected playable game') state = LocalGameState.Installed else: log.debug('Detected not-fully installed game') continue elif refr.has_galaxy_installed_state and not prev.has_galaxy_installed_state: log.debug('Detected playable game') state = LocalGameState.Installed elif refr.last_played != prev.last_played: log.debug('Detected launched game') state = LocalGameState.Installed | LocalGameState.Running self.create_task(self._notify_about_game_stop(refr, 5), 'game stop waiter') else: continue log.info(f'Changing game {blizz_id} state to {state}') self.update_local_game_status(LocalGame(blizz_id, state)) for blizz_id, prev in previous_games.items(): refr = refreshed_games.get(blizz_id, None) if refr is None: log.debug('Detected uninstalled game') state = LocalGameState.None_ self.update_local_game_status(LocalGame(blizz_id, state)) def log_out(self): if self.backend_client: asyncio.create_task(self.authentication_client.shutdown()) self.authentication_client.user_details = None async def open_battlenet_browser(self): url = self.authentication_client.blizzard_battlenet_download_url log.info(f'Opening battle.net website: {url}') loop = asyncio.get_running_loop() await loop.run_in_executor( None, lambda x: webbrowser.open(x, autoraise=True), url) async def install_game(self, game_id): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() installed_game = self.local_client.get_installed_games().get( game_id, None) if installed_game and os.access(installed_game.install_path, os.F_OK): log.warning( "Received install command on an already installed game") return await self.launch_game(game_id) if game_id in [classic.uid for classic in Blizzard.CLASSIC_GAMES]: if SYSTEM == pf.WINDOWS: platform = 'windows' elif SYSTEM == pf.MACOS: platform = 'macos' webbrowser.open( f"https://www.blizzard.com/download/confirmation?platform={platform}&locale=enUS&version=LIVE&id={game_id}" ) return try: self.local_client.refresh() log.info(f'Installing game of id {game_id}') self.local_client.install_game(game_id) except ClientNotInstalledError as e: log.warning(e) await self.open_battlenet_browser() except Exception as e: log.exception(f"Installing game {game_id} failed: {e}") def _open_battlenet_at_id(self, game_id): try: self.local_client.refresh() self.local_client.open_battlenet(game_id) except Exception as e: log.exception( f"Opening battlenet client on specific game_id {game_id} failed {e}" ) try: self.local_client.open_battlenet() except Exception as e: log.exception(f"Opening battlenet client failed {e}") async def uninstall_game(self, game_id): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() if game_id == 'wow_classic': # attempting to uninstall classic wow through protocol gives you a message that the game cannot # be uninstalled through protocol and you should use battle.net return self._open_battlenet_at_id(game_id) if SYSTEM == pf.MACOS: self._open_battlenet_at_id(game_id) else: try: installed_game = self.local_client.get_installed_games().get( game_id, None) if installed_game is None or not os.access( installed_game.install_path, os.F_OK): log.error(f'Cannot uninstall {game_id}') self.update_local_game_status( LocalGame(game_id, LocalGameState.None_)) return if not isinstance(installed_game.info, ClassicGame): if self.local_client.uninstaller is None: raise FileNotFoundError('Uninstaller not found') uninstall_tag = installed_game.uninstall_tag client_lang = self.local_client.config_parser.locale_language self.local_client.uninstaller.uninstall_game( installed_game, uninstall_tag, client_lang) except Exception as e: log.exception(f'Uninstalling game {game_id} failed: {e}') async def launch_game(self, game_id): try: game = self.local_client.get_installed_games().get(game_id, None) if game is None: log.error(f'Launching game that is not installed: {game_id}') return await self.install_game(game_id) if isinstance(game.info, ClassicGame): log.info( f'Launching game of id: {game_id}, {game} at path {os.path.join(game.install_path, game.info.exe)}' ) if SYSTEM == pf.WINDOWS: subprocess.Popen( os.path.join(game.install_path, game.info.exe)) elif SYSTEM == pf.MACOS: if not game.info.bundle_id: log.warning( f"{game.name} has no bundle id, help by providing us bundle id of this game" ) subprocess.Popen(['open', '-b', game.info.bundle_id]) self.update_local_game_status( LocalGame( game_id, LocalGameState.Installed | LocalGameState.Running)) asyncio.create_task(self._notify_about_game_stop(game, 6)) return self.local_client.refresh() log.info(f'Launching game of id: {game_id}, {game}') await self.local_client.launch_game(game, wait_sec=60) self.update_local_game_status( LocalGame(game_id, LocalGameState.Installed | LocalGameState.Running)) self.local_client.close_window() asyncio.create_task(self._notify_about_game_stop(game, 3)) except ClientNotInstalledError as e: log.warning(e) await self.open_battlenet_browser() except TimeoutError as e: log.warning(str(e)) except Exception as e: log.exception(f"Launching game {game_id} failed: {e}") async def authenticate(self, stored_credentials=None): try: if stored_credentials: auth_data = self.authentication_client.process_stored_credentials( stored_credentials) try: await self.authentication_client.create_session() await self.backend_client.refresh_cookies() auth_status = await self.backend_client.validate_access_token( auth_data.access_token) except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e: raise e except Exception: raise InvalidCredentials() if self.authentication_client.validate_auth_status( auth_status): self.authentication_client.user_details = await self.backend_client.get_user_info( ) return self.authentication_client.parse_user_details() else: return self.authentication_client.authenticate_using_login() except Exception as e: raise e async def pass_login_credentials(self, step, credentials, cookies): if "logout&app=oauth" in credentials['end_uri']: # 2fa expired, repeat authentication return self.authentication_client.authenticate_using_login() if self.authentication_client.attempted_to_set_battle_tag: self.authentication_client.user_details = await self.backend_client.get_user_info( ) return self.authentication_client.parse_auth_after_setting_battletag( ) cookie_jar = self.authentication_client.parse_cookies(cookies) auth_data = await self.authentication_client.get_auth_data_login( cookie_jar, credentials) try: await self.authentication_client.create_session() await self.backend_client.refresh_cookies() except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e: raise e except Exception: raise InvalidCredentials() auth_status = await self.backend_client.validate_access_token( auth_data.access_token) if not ("authorities" in auth_status and "IS_AUTHENTICATED_FULLY" in auth_status["authorities"]): raise InvalidCredentials() self.authentication_client.user_details = await self.backend_client.get_user_info( ) self.authentication_client.set_credentials() return self.authentication_client.parse_battletag() async def get_owned_games(self): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() def _parse_battlenet_games( standard_games: dict, cn: bool) -> Dict[BlizzardGame, LicenseType]: licenses = { None: LicenseType.Unknown, "Trial": LicenseType.OtherUserLicense, "Good": LicenseType.SinglePurchase, "Inactive": LicenseType.SinglePurchase, "Banned": LicenseType.SinglePurchase, "Free": LicenseType.FreeToPlay } games = {} for standard_game in standard_games["gameAccounts"]: title_id = standard_game['titleId'] try: game = Blizzard.game_by_title_id(title_id, cn) except KeyError: log.warning( f"Skipping unknown game with titleId: {title_id}") else: games[game] = licenses[standard_game.get( "gameAccountStatus")] # Add wow classic if retail wow is present in owned games wow_license = games.get(Blizzard['wow']) if wow_license is not None: games[Blizzard['wow_classic']] = wow_license return games def _parse_classic_games( classic_games: dict) -> Dict[ClassicGame, LicenseType]: games = {} for classic_game in classic_games["classicGames"]: sanitized_name = classic_game["localizedGameName"].replace( u'\xa0', ' ') for cg in Blizzard.CLASSIC_GAMES: if cg.name == sanitized_name: games[cg] = LicenseType.SinglePurchase break else: log.warning( f"Skipping unknown classic game with name: {sanitized_name}" ) return games cn = self.authentication_client.region == 'cn' battlenet_games = _parse_battlenet_games( await self.backend_client.get_owned_games(), cn) classic_games = _parse_classic_games( await self.backend_client.get_owned_classic_games()) owned_games: Dict[BlizzardGame, LicenseType] = { **battlenet_games, **classic_games } for game in Blizzard.try_for_free_games(cn): if game not in owned_games: owned_games[game] = LicenseType.FreeToPlay return [ Game(game.uid, game.name, None, LicenseInfo(license_type)) for game, license_type in owned_games.items() ] async def get_local_games(self): timeout = time.time() + 2 try: translated_installed_games = [] while not self.local_client.games_finished_parsing(): await asyncio.sleep(0.1) if time.time() >= timeout: break running_games = self.local_client.get_running_games() installed_games = self.local_client.get_installed_games() log.info(f"Installed games {installed_games.items()}") log.info(f"Running games {running_games}") for uid, game in installed_games.items(): if game.has_galaxy_installed_state: state = LocalGameState.Installed if uid in running_games: state |= LocalGameState.Running translated_installed_games.append(LocalGame(uid, state)) self.local_client.installed_games_cache = installed_games return translated_installed_games except Exception as e: log.exception(f"failed to get local games: {str(e)}") raise async def get_game_time(self, game_id, context): total_time = None last_played_time = None blizzard_game = Blizzard[game_id] if blizzard_game.name == "Overwatch": total_time = await self._get_overwatch_time() log.debug(f"Gametime for Overwatch is {total_time} minutes.") for config_info in self.local_client.config_parser.games: if config_info.uid == blizzard_game.uid: if config_info.last_played is not None: last_played_time = int(config_info.last_played) break return GameTime(game_id, total_time, last_played_time) async def _get_overwatch_time(self) -> Union[None, int]: log.debug("Fetching playtime for Overwatch...") player_data = await self.backend_client.get_ow_player_data() if 'message' in player_data: # user not found log.error('No Overwatch profile found.') return None if player_data['private'] == True: log.info('Unable to get data as Overwatch profile is private.') return None qp_time = player_data['playtime'].get('quickplay') if qp_time is None: # user has not played quick play return 0 if qp_time.count(':') == 1: # minutes and seconds match = re.search('(?:(?P<m>\\d+):)(?P<s>\\d+)', qp_time) if match: return int(match.group('m')) elif qp_time.count(':') == 2: # hours, minutes and seconds match = re.search('(?:(?P<h>\\d+):)(?P<m>\\d+)', qp_time) if match: return int(match.group('h')) * 60 + int(match.group('m')) raise UnknownBackendResponse( f'Unknown Overwatch API playtime format: {qp_time}') async def _get_wow_achievements(self): achievements = [] try: characters_data = await self.backend_client.get_wow_character_data( ) characters_data = characters_data["characters"] wow_character_data = await asyncio.gather( *[ self.backend_client.get_wow_character_achievements( character["realm"], character["name"]) for character in characters_data ], return_exceptions=True, ) for data in wow_character_data: if isinstance(data, requests.Timeout) or isinstance( data, requests.ConnectionError): raise data wow_achievement_data = [ list( zip( data["achievements"]["achievementsCompleted"], data["achievements"]["achievementsCompletedTimestamp"], )) for data in wow_character_data if type(data) is dict ] already_in = set() for char_ach in wow_achievement_data: for ach in char_ach: if ach[0] not in already_in: achievements.append( Achievement(achievement_id=ach[0], unlock_time=int(ach[1] / 1000))) already_in.add(ach[0]) except (AccessTokenExpired, BackendError) as e: log.exception(str(e)) with open('wow.json', 'w') as f: f.write(json.dumps(achievements, cls=DataclassJSONEncoder)) return achievements async def _get_sc2_achievements(self): account_data = await self.backend_client.get_sc2_player_data( self.authentication_client.user_details["id"]) # TODO what if more sc2 accounts? assert len(account_data) == 1 account_data = account_data[0] profile_data = await self.backend_client.get_sc2_profile_data( account_data["regionId"], account_data["realmId"], account_data["profileId"]) sc2_achievement_data = [ Achievement(achievement_id=achievement["achievementId"], unlock_time=achievement["completionDate"]) for achievement in profile_data["earnedAchievements"] if achievement["isComplete"] ] with open('sc2.json', 'w') as f: f.write(json.dumps(sc2_achievement_data, cls=DataclassJSONEncoder)) return sc2_achievement_data # async def get_unlocked_achievements(self, game_id): # if not self.website_client.is_authenticated(): # raise AuthenticationRequired() # try: # if game_id == "21298": # return await self._get_sc2_achievements() # elif game_id == "5730135": # return await self._get_wow_achievements() # else: # return [] # except requests.Timeout: # raise BackendTimeout() # except requests.ConnectionError: # raise NetworkError() # except Exception as e: # log.exception(str(e)) # return [] async def launch_platform_client(self): if self.local_client.is_running(): log.info( "Launch platform client called but client is already running") return self.local_client.open_battlenet() await self.local_client.prevent_battlenet_from_showing() async def shutdown_platform_client(self): await self.local_client.shutdown_platform_client() async def shutdown(self): log.info("Plugin shutdown.") await self.authentication_client.shutdown()
class BNetPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Battlenet, version, reader, writer, token) self.local_client = LocalClient(self._update_statuses) self.authentication_client = AuthenticatedHttpClient(self) self.backend_client = BackendClient(self, self.authentication_client) self.owned_games_cache = [] self.watched_running_games = set() self.local_games_called = False async def _notify_about_game_stop(self, game, starting_timeout): if not self.local_games_called: return id_to_watch = game.info.id if id_to_watch in self.watched_running_games: log.debug(f'Game {id_to_watch} is already watched. Skipping') return try: self.watched_running_games.add(id_to_watch) await asyncio.sleep(starting_timeout) ProcessProvider().update_games_processes([game]) log.info(f'Setuping process watcher for {game._processes}') loop = asyncio.get_event_loop() await loop.run_in_executor(None, game.wait_until_game_stops) finally: self.update_local_game_status( LocalGame(id_to_watch, LocalGameState.Installed)) self.watched_running_games.remove(id_to_watch) def _update_statuses(self, refreshed_games, previous_games): if not self.local_games_called: return for blizz_id, refr in refreshed_games.items(): prev = previous_games.get(blizz_id, None) if prev is None: if refr.playable: log.debug('Detected playable game') state = LocalGameState.Installed else: log.debug('Detected installation begin') state = LocalGameState.None_ elif refr.playable and not prev.playable: log.debug('Detected playable game') state = LocalGameState.Installed elif refr.last_played != prev.last_played: log.debug('Detected launched game') state = LocalGameState.Installed | LocalGameState.Running asyncio.create_task(self._notify_about_game_stop(refr, 5)) else: continue log.info(f'Changing game {blizz_id} state to {state}') self.update_local_game_status(LocalGame(blizz_id, state)) for blizz_id, prev in previous_games.items(): refr = refreshed_games.get(blizz_id, None) if refr is None: log.debug('Detected uninstalled game') state = LocalGameState.None_ self.update_local_game_status(LocalGame(blizz_id, state)) def log_out(self): if self.backend_client: asyncio.create_task(self.authentication_client.shutdown()) self.authentication_client.user_details = None self.owned_games_cache = [] async def open_battlenet_browser(self): url = self.authentication_client.blizzard_battlenet_download_url log.info(f'Opening battle.net website: {url}') loop = asyncio.get_running_loop() await loop.run_in_executor( None, lambda x: webbrowser.open(x, autoraise=True), url) async def install_game(self, game_id): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() installed_game = self.local_client.get_installed_games().get( game_id, None) if installed_game and os.access(installed_game.install_path, os.F_OK): log.warning( "Received install command on an already installed game") return await self.launch_game(game_id) if game_id in Blizzard.legacy_game_ids: if SYSTEM == pf.WINDOWS: platform = 'windows' elif SYSTEM == pf.MACOS: platform = 'macos' webbrowser.open( f"https://www.blizzard.com/download/confirmation?platform={platform}&locale=enUS&version=LIVE&id={game_id}" ) return try: self.local_client.refresh() log.info(f'Installing game of id {game_id}') self.local_client.install_game(game_id) except ClientNotInstalledError as e: log.warning(e) await self.open_battlenet_browser() except Exception as e: log.exception(f"Installing game {game_id} failed: {e}") def _open_battlenet_at_id(self, game_id): try: self.local_client.refresh() self.local_client.open_battlenet(game_id) except Exception as e: log.exception( f"Opening battlenet client on specific game_id {game_id} failed {e}" ) try: self.local_client.open_battlenet() except Exception as e: log.exception(f"Opening battlenet client failed {e}") async def uninstall_game(self, game_id): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() if game_id == 'wow_classic': # attempting to uninstall classic wow through protocol gives you a message that the game cannot # be uninstalled through protocol and you should use battle.net return self._open_battlenet_at_id(game_id) if SYSTEM == pf.MACOS: self._open_battlenet_at_id(game_id) else: try: installed_game = self.local_client.get_installed_games().get( game_id, None) if installed_game is None or not os.access( installed_game.install_path, os.F_OK): log.error(f'Cannot uninstall {Blizzard[game_id].uid}') self.update_local_game_status( LocalGame(game_id, LocalGameState.None_)) return if not isinstance(installed_game.info, ClassicGame): if self.local_client.uninstaller is None: raise FileNotFoundError('Uninstaller not found') uninstall_tag = installed_game.uninstall_tag client_lang = self.local_client.config_parser.locale_language self.local_client.uninstaller.uninstall_game( installed_game, uninstall_tag, client_lang) except Exception as e: log.exception(f'Uninstalling game {game_id} failed: {e}') async def launch_game(self, game_id): if not self.local_games_called: await self.get_local_games() try: if self.local_client.get_installed_games() is None: log.error(f'Launching game that is not installed: {game_id}') return await self.install_game(game_id) game = self.local_client.get_installed_games().get(game_id, None) if game is None: log.error(f'Launching game that is not installed: {game_id}') return await self.install_game(game_id) if isinstance(game.info, ClassicGame): log.info( f'Launching game of id: {game_id}, {game} at path {os.path.join(game.install_path, game.info.exe)}' ) if SYSTEM == pf.WINDOWS: subprocess.Popen( os.path.join(game.install_path, game.info.exe)) elif SYSTEM == pf.MACOS: if not game.info.bundle_id: log.warning( f"{game.name} has no bundle id, help by providing us bundle id of this game" ) subprocess.Popen(['open', '-b', game.info.bundle_id]) self.update_local_game_status( LocalGame( game_id, LocalGameState.Installed | LocalGameState.Running)) asyncio.create_task(self._notify_about_game_stop(game, 6)) return self.local_client.refresh() log.info(f'Launching game of id: {game_id}, {game}') await self.local_client.launch_game(game, wait_sec=60) self.update_local_game_status( LocalGame(game_id, LocalGameState.Installed | LocalGameState.Running)) self.local_client.close_window() asyncio.create_task(self._notify_about_game_stop(game, 3)) except ClientNotInstalledError as e: log.warning(e) await self.open_battlenet_browser() except TimeoutError as e: log.warning(str(e)) except Exception as e: log.exception(f"Launching game {game_id} failed: {e}") async def authenticate(self, stored_credentials=None): try: if stored_credentials: auth_data = self.authentication_client.process_stored_credentials( stored_credentials) try: await self.authentication_client.create_session() await self.backend_client.refresh_cookies() auth_status = await self.backend_client.validate_access_token( auth_data.access_token) except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e: raise e except Exception: raise InvalidCredentials() if self.authentication_client.validate_auth_status( auth_status): self.authentication_client.user_details = await self.backend_client.get_user_info( ) return self.authentication_client.parse_user_details() else: return self.authentication_client.authenticate_using_login() except Exception as e: raise e async def pass_login_credentials(self, step, credentials, cookies): if "logout&app=oauth" in credentials['end_uri']: # 2fa expired, repeat authentication return self.authentication_client.authenticate_using_login() if self.authentication_client.attempted_to_set_battle_tag: self.authentication_client.user_details = await self.backend_client.get_user_info( ) return self.authentication_client.parse_auth_after_setting_battletag( ) cookie_jar = self.authentication_client.parse_cookies(cookies) auth_data = await self.authentication_client.get_auth_data_login( cookie_jar, credentials) try: await self.authentication_client.create_session() await self.backend_client.refresh_cookies() except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e: raise e except Exception: raise InvalidCredentials() auth_status = await self.backend_client.validate_access_token( auth_data.access_token) if not ("authorities" in auth_status and "IS_AUTHENTICATED_FULLY" in auth_status["authorities"]): raise InvalidCredentials() self.authentication_client.user_details = await self.backend_client.get_user_info( ) self.authentication_client.set_credentials() return self.authentication_client.parse_battletag() async def get_owned_games(self): if not self.authentication_client.is_authenticated(): raise AuthenticationRequired() def _parse_classic_games(classic_games): for classic_game in classic_games["classicGames"]: log.info(f"looking for {classic_game} in classic games") try: blizzard_game = Blizzard[ classic_game["localizedGameName"].replace( u'\xa0', ' ')] log.info(f"match! {blizzard_game}") classic_game["titleId"] = blizzard_game.uid classic_game["gameAccountStatus"] = "Good" except KeyError: continue return classic_games def _get_not_added_free_games(owned_games): owned_games_ids = [] for game in owned_games: if "titleId" in game: owned_games_ids.append(str(game["titleId"])) return [{ "titleId": game.blizzard_id, "localizedGameName": game.name, "gameAccountStatus": "Free" } for game in Blizzard.free_games if game.blizzard_id not in owned_games_ids] try: games = await self.backend_client.get_owned_games() classic_games = _parse_classic_games( await self.backend_client.get_owned_classic_games()) owned_games = games["gameAccounts"] + classic_games["classicGames"] # Add wow classic if retail wow is present in owned games for owned_game in owned_games.copy(): if 'titleId' in owned_game: if owned_game['titleId'] == 5730135: owned_games.append({ 'titleId': 'wow_classic', 'localizedGameName': 'World of Warcraft Classic', 'gameAccountStatus': owned_game['gameAccountStatus'] }) free_games_to_add = _get_not_added_free_games(owned_games) owned_games += free_games_to_add self.owned_games_cache = owned_games return [ Game( str(game["titleId"]), game["localizedGameName"], [], LicenseInfo(License_Map[game["gameAccountStatus"]]), ) for game in self.owned_games_cache if "titleId" in game ] except Exception as e: log.exception(f"failed to get owned games: {repr(e)}") raise async def get_local_games(self): timeout = time.time() + 2 try: translated_installed_games = [] while not self.local_client.games_finished_parsing(): await asyncio.sleep(0.1) if time.time() >= timeout: break running_games = self.local_client.get_running_games() installed_games = self.local_client.get_installed_games() log.info(f"Installed games {installed_games.items()}") log.info(f"Running games {running_games}") for id_, game in installed_games.items(): if game.playable: state = LocalGameState.Installed if id_ in running_games: state |= LocalGameState.Running else: state = LocalGameState.None_ translated_installed_games.append(LocalGame(id_, state)) self.local_client.installed_games_cache = installed_games return translated_installed_games except Exception as e: log.exception(f"failed to get local games: {str(e)}") raise finally: self.local_games_called = True async def get_game_time(self, game_id, context): game_time_minutes = None if game_id == "5272175": game_time_minutes = await self._get_overwatch_time() log.debug( f"Gametime for Overwatch is {game_time_minutes} minutes.") return GameTime(game_id, game_time_minutes, None) async def _get_overwatch_time(self) -> Union[None, int]: log.debug("Fetching playtime for Overwatch...") player_data = await self.backend_client.get_ow_player_data() if 'message' in player_data: # user not found... unfortunately no 404 status code is returned :/ log.error('No Overwatch profile found.') return None if player_data['private'] == True: log.info('Unable to get data as Overwatch profile is private.') return None qp_time = player_data['playtime']['quickplay'] if qp_time is None: # user has not played quick play return 0 if qp_time.count(':') == 1: # minutes and seconds match = re.search('(?:(?P<m>\\d+):)(?P<s>\\d+)', qp_time) if match: return int(match.group('m')) elif qp_time.count(':') == 2: # hours, minutes and seconds match = re.search('(?:(?P<h>\\d+):)(?P<m>\\d+)', qp_time) if match: return int(match.group('h')) * 60 + int(match.group('m')) raise UnknownBackendResponse( f'Unknown Overwatch API playtime format: {qp_time}') async def _get_wow_achievements(self): achievements = [] try: characters_data = await self.backend_client.get_wow_character_data( ) characters_data = characters_data["characters"] wow_character_data = await asyncio.gather( *[ self.backend_client.get_wow_character_achievements( character["realm"], character["name"]) for character in characters_data ], return_exceptions=True, ) for data in wow_character_data: if isinstance(data, requests.Timeout) or isinstance( data, requests.ConnectionError): raise data wow_achievement_data = [ list( zip( data["achievements"]["achievementsCompleted"], data["achievements"]["achievementsCompletedTimestamp"], )) for data in wow_character_data if type(data) is dict ] already_in = set() for char_ach in wow_achievement_data: for ach in char_ach: if ach[0] not in already_in: achievements.append( Achievement(achievement_id=ach[0], unlock_time=int(ach[1] / 1000))) already_in.add(ach[0]) except (AccessTokenExpired, BackendError) as e: log.exception(str(e)) with open('wow.json', 'w') as f: f.write(json.dumps(achievements, cls=DataclassJSONEncoder)) return achievements async def _get_sc2_achievements(self): account_data = await self.backend_client.get_sc2_player_data( self.authentication_client.user_details["id"]) # TODO what if more sc2 accounts? assert len(account_data) == 1 account_data = account_data[0] profile_data = await self.backend_client.get_sc2_profile_data( account_data["regionId"], account_data["realmId"], account_data["profileId"]) sc2_achievement_data = [ Achievement(achievement_id=achievement["achievementId"], unlock_time=achievement["completionDate"]) for achievement in profile_data["earnedAchievements"] if achievement["isComplete"] ] with open('sc2.json', 'w') as f: f.write(json.dumps(sc2_achievement_data, cls=DataclassJSONEncoder)) return sc2_achievement_data # async def get_unlocked_achievements(self, game_id): # if not self.website_client.is_authenticated(): # raise AuthenticationRequired() # try: # if game_id == "21298": # return await self._get_sc2_achievements() # elif game_id == "5730135": # return await self._get_wow_achievements() # else: # return [] # except requests.Timeout: # raise BackendTimeout() # except requests.ConnectionError: # raise NetworkError() # except Exception as e: # log.exception(str(e)) # return [] async def launch_platform_client(self): if self.local_client.is_running(): log.info( "Launch platform client called but client is already running") return self.local_client.open_battlenet() await self.local_client.prevent_battlenet_from_showing() async def shutdown_platform_client(self): await self.local_client.shutdown_platform_client() async def shutdown(self): log.info("Plugin shutdown.") await self.authentication_client.shutdown()