diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 5c98b42..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Default ignored files -/workspace.xml \ No newline at end of file diff --git a/.idea/galaxy-integration-wii.iml b/.idea/galaxy-integration-wii.iml deleted file mode 100644 index 6711606..0000000 --- a/.idea/galaxy-integration-wii.iml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 8656114..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index f4dbe2d..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/__pycache__/backend.cpython-37.pyc b/__pycache__/backend.cpython-37.pyc index 28fab37..1b62e0c 100644 Binary files a/__pycache__/backend.cpython-37.pyc and b/__pycache__/backend.cpython-37.pyc differ diff --git a/__pycache__/plugin.cpython-37.pyc b/__pycache__/plugin.cpython-37.pyc index a50eb77..2f7abe0 100644 Binary files a/__pycache__/plugin.cpython-37.pyc and b/__pycache__/plugin.cpython-37.pyc differ diff --git a/__pycache__/user_config.cpython-37.pyc b/__pycache__/user_config.cpython-37.pyc index 5f71183..d634fb0 100644 Binary files a/__pycache__/user_config.cpython-37.pyc and b/__pycache__/user_config.cpython-37.pyc differ diff --git a/__pycache__/version.cpython-37.pyc b/__pycache__/version.cpython-37.pyc index a21da5e..05d8b74 100644 Binary files a/__pycache__/version.cpython-37.pyc and b/__pycache__/version.cpython-37.pyc differ diff --git a/fuzzywuzzy/__pycache__/StringMatcher.cpython-37.pyc b/fuzzywuzzy/__pycache__/StringMatcher.cpython-37.pyc index e50a1ce..8df52c7 100644 Binary files a/fuzzywuzzy/__pycache__/StringMatcher.cpython-37.pyc and b/fuzzywuzzy/__pycache__/StringMatcher.cpython-37.pyc differ diff --git a/fuzzywuzzy/__pycache__/fuzz.cpython-37.pyc b/fuzzywuzzy/__pycache__/fuzz.cpython-37.pyc index f8acb76..47e4928 100644 Binary files a/fuzzywuzzy/__pycache__/fuzz.cpython-37.pyc and b/fuzzywuzzy/__pycache__/fuzz.cpython-37.pyc differ diff --git a/fuzzywuzzy/__pycache__/process.cpython-37.pyc b/fuzzywuzzy/__pycache__/process.cpython-37.pyc index 1cd477a..4b7ef87 100644 Binary files a/fuzzywuzzy/__pycache__/process.cpython-37.pyc and b/fuzzywuzzy/__pycache__/process.cpython-37.pyc differ diff --git a/galaxy/__init__.py b/galaxy/__init__.py index 97b69ed..1453276 100644 --- a/galaxy/__init__.py +++ b/galaxy/__init__.py @@ -1 +1 @@ -__path__: str = __import__('pkgutil').extend_path(__path__, __name__) +__path__: str = __import__('pkgutil').extend_path(__path__, __name__) # type: ignore diff --git a/galaxy/api/consts.py b/galaxy/api/consts.py index d636613..8881868 100644 --- a/galaxy/api/consts.py +++ b/galaxy/api/consts.py @@ -90,6 +90,7 @@ class Platform(Enum): Playfire = "playfire" Oculus = "oculus" Test = "test" + Rockstar = "rockstar" class Feature(Enum): @@ -110,6 +111,9 @@ class Feature(Enum): ImportFriends = "ImportFriends" ShutdownPlatformClient = "ShutdownPlatformClient" LaunchPlatformClient = "LaunchPlatformClient" + ImportGameLibrarySettings = "ImportGameLibrarySettings" + ImportOSCompatibility = "ImportOSCompatibility" + ImportUserPresence = "ImportUserPresence" class LicenseType(Enum): @@ -128,3 +132,20 @@ class LocalGameState(Flag): None_ = 0 Installed = 1 Running = 2 + + +class OSCompatibility(Flag): + """Possible game OS compatibility. + Use "bitwise or" to express multiple OSs compatibility, e.g. ``os=OSCompatibility.Windows|OSCompatibility.MacOS`` + """ + Windows = 0b001 + MacOS = 0b010 + Linux = 0b100 + + +class PresenceState(Enum): + """"Possible states of a user.""" + Unknown = "unknown" + Online = "online" + Offline = "offline" + Away = "away" diff --git a/galaxy/api/jsonrpc.py b/galaxy/api/jsonrpc.py index bd5ab64..be78969 100644 --- a/galaxy/api/jsonrpc.py +++ b/galaxy/api/jsonrpc.py @@ -8,6 +8,10 @@ from galaxy.reader import StreamLineReader from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JsonRpcError(Exception): def __init__(self, code, message, data=None): self.code = code @@ -25,7 +29,7 @@ def json(self): } if self.data is not None: - obj["error"]["data"] = self.data + obj["data"] = self.data return obj @@ -64,6 +68,7 @@ def __init__(self, data=None): super().__init__(0, "Unknown error", data) Request = namedtuple("Request", ["method", "params", "id"], defaults=[{}, None]) +Response = namedtuple("Response", ["id", "result", "error"], defaults=[None, {}, {}]) Method = namedtuple("Method", ["callback", "signature", "immediate", "sensitive_params"]) @@ -79,7 +84,7 @@ def anonymise_sensitive_params(params, sensitive_params): return params -class Server(): +class Connection(): def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._active = True self._reader = StreamLineReader(reader) @@ -88,6 +93,8 @@ def __init__(self, reader, writer, encoder=json.JSONEncoder()): self._methods = {} self._notifications = {} self._task_manager = TaskManager("jsonrpc server") + self._last_request_id = 0 + self._requests_futures = {} def register_method(self, name, callback, immediate, sensitive_params=False): """ @@ -113,6 +120,47 @@ def register_notification(self, name, callback, immediate, sensitive_params=Fals """ self._notifications[name] = Method(callback, inspect.signature(callback), immediate, sensitive_params) + async def send_request(self, method, params, sensitive_params): + """ + Send request + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + self._last_request_id += 1 + request_id = str(self._last_request_id) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._requests_futures[self._last_request_id] = (future, sensitive_params) + + logger.info( + "Sending request: id=%s, method=%s, params=%s", + request_id, method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_request(request_id, method, params) + return await future + + def send_notification(self, method, params, sensitive_params=False): + """ + Send notification + + :param method: + :param params: + :param sensitive_params: list of parameters that are anonymized before logging; \ + if False - no params are considered sensitive, if True - all params are considered sensitive + """ + + logger.info( + "Sending notification: method=%s, params=%s", + method, anonymise_sensitive_params(params, sensitive_params) + ) + + self._send_notification(method, params) + async def run(self): while self._active: try: @@ -124,37 +172,63 @@ async def run(self): self._eof() continue data = data.strip() - logging.debug("Received %d bytes of data", len(data)) + logger.debug("Received %d bytes of data", len(data)) self._handle_input(data) await asyncio.sleep(0) # To not starve task queue def close(self): - logging.info("Closing JSON-RPC server - not more messages will be read") - self._active = False + if self._active: + logger.info("Closing JSON-RPC server - not more messages will be read") + self._active = False async def wait_closed(self): await self._task_manager.wait() def _eof(self): - logging.info("Received EOF") + logger.info("Received EOF") self.close() def _handle_input(self, data): try: - request = self._parse_request(data) + message = self._parse_message(data) except JsonRpcError as error: self._send_error(None, error) return - if request.id is not None: - self._handle_request(request) - else: - self._handle_notification(request) + if isinstance(message, Request): + if message.id is not None: + self._handle_request(message) + else: + self._handle_notification(message) + elif isinstance(message, Response): + self._handle_response(message) + + def _handle_response(self, response): + request_future = self._requests_futures.get(int(response.id)) + if request_future is None: + response_type = "response" if response.result is not None else "error" + logger.warning("Received %s for unknown request: %s", response_type, response.id) + return + + future, sensitive_params = request_future + + if response.error: + error = JsonRpcError( + response.error.setdefault("code", 0), + response.error.setdefault("message", ""), + response.error.setdefault("data", None) + ) + self._log_error(response, error, sensitive_params) + future.set_exception(error) + return + + self._log_response(response, sensitive_params) + future.set_result(response.result) def _handle_notification(self, request): method = self._notifications.get(request.method) if not method: - logging.error("Received unknown notification: %s", request.method) + logger.error("Received unknown notification: %s", request.method) return callback, signature, immediate, sensitive_params = method @@ -171,12 +245,12 @@ def _handle_notification(self, request): try: self._task_manager.create_task(callback(*bound_args.args, **bound_args.kwargs), request.method) except Exception: - logging.exception("Unexpected exception raised in notification handler") + logger.exception("Unexpected exception raised in notification handler") def _handle_request(self, request): method = self._methods.get(request.method) if not method: - logging.error("Received unknown request: %s", request.method) + logger.error("Received unknown request: %s", request.method) self._send_error(request.id, MethodNotFound()) return @@ -203,33 +277,39 @@ async def handle(): except asyncio.CancelledError: self._send_error(request.id, Aborted()) except Exception as e: #pylint: disable=broad-except - logging.exception("Unexpected exception raised in plugin handler") + logger.exception("Unexpected exception raised in plugin handler") self._send_error(request.id, UnknownError(str(e))) self._task_manager.create_task(handle(), request.method) @staticmethod - def _parse_request(data): + def _parse_message(data): try: - jsonrpc_request = json.loads(data, encoding="utf-8") - if jsonrpc_request.get("jsonrpc") != "2.0": + jsonrpc_message = json.loads(data, encoding="utf-8") + if jsonrpc_message.get("jsonrpc") != "2.0": raise InvalidRequest() - del jsonrpc_request["jsonrpc"] - return Request(**jsonrpc_request) + del jsonrpc_message["jsonrpc"] + if "result" in jsonrpc_message.keys() or "error" in jsonrpc_message.keys(): + return Response(**jsonrpc_message) + else: + return Request(**jsonrpc_message) + except json.JSONDecodeError: raise ParseError() except TypeError: raise InvalidRequest() - def _send(self, data): + def _send(self, data, sensitive=True): try: line = self._encoder.encode(data) - logging.debug("Sending data: %s", line) data = (line + "\n").encode("utf-8") + if sensitive: + logger.debug("Sending %d bytes of data", len(data)) + else: + logging.debug("Sending data: %s", line) self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") except TypeError as error: - logging.error(str(error)) + logger.error(str(error)) def _send_response(self, request_id, result): response = { @@ -237,7 +317,7 @@ def _send_response(self, request_id, result): "id": request_id, "result": result } - self._send(response) + self._send(response, sensitive=False) def _send_error(self, request_id, error): response = { @@ -246,54 +326,41 @@ def _send_error(self, request_id, error): "error": error.json() } - self._send(response) + self._send(response, sensitive=False) - @staticmethod - def _log_request(request, sensitive_params): - params = anonymise_sensitive_params(request.params, sensitive_params) - if request.id is not None: - logging.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) - else: - logging.info("Handling notification: method=%s, params=%s", request.method, params) - -class NotificationClient(): - def __init__(self, writer, encoder=json.JSONEncoder()): - self._writer = writer - self._encoder = encoder - self._methods = {} - self._task_manager = TaskManager("notification client") - - def notify(self, method, params, sensitive_params=False): - """ - Send notification + def _send_request(self, request_id, method, params): + request = { + "jsonrpc": "2.0", + "method": method, + "id": request_id, + "params": params + } + self._send(request, sensitive=True) - :param method: - :param params: - :param sensitive_params: list of parameters that are anonymized before logging; \ - if False - no params are considered sensitive, if True - all params are considered sensitive - """ + def _send_notification(self, method, params): notification = { "jsonrpc": "2.0", "method": method, "params": params } - self._log(method, params, sensitive_params) - self._send(notification) + self._send(notification, sensitive=True) - async def close(self): - await self._task_manager.wait() + @staticmethod + def _log_request(request, sensitive_params): + params = anonymise_sensitive_params(request.params, sensitive_params) + if request.id is not None: + logger.info("Handling request: id=%s, method=%s, params=%s", request.id, request.method, params) + else: + logger.info("Handling notification: method=%s, params=%s", request.method, params) - def _send(self, data): - try: - line = self._encoder.encode(data) - data = (line + "\n").encode("utf-8") - logging.debug("Sending %d byte of data", len(data)) - self._writer.write(data) - self._task_manager.create_task(self._writer.drain(), "drain") - except TypeError as error: - logging.error("Failed to parse outgoing message: %s", str(error)) + @staticmethod + def _log_response(response, sensitive_params): + result = anonymise_sensitive_params(response.result, sensitive_params) + logger.info("Handling response: id=%s, result=%s", response.id, result) @staticmethod - def _log(method, params, sensitive_params): - params = anonymise_sensitive_params(params, sensitive_params) - logging.info("Sending notification: method=%s, params=%s", method, params) + def _log_error(response, error, sensitive_params): + data = anonymise_sensitive_params(error.data, sensitive_params) + logger.info("Handling error: id=%s, code=%s, description=%s, data=%s", + response.id, error.code, error.message, data + ) diff --git a/galaxy/api/plugin.py b/galaxy/api/plugin.py index e2330bb..e940540 100644 --- a/galaxy/api/plugin.py +++ b/galaxy/api/plugin.py @@ -2,17 +2,22 @@ import dataclasses import json import logging -import logging.handlers import sys from enum import Enum from typing import Any, Dict, List, Optional, Set, Union -from galaxy.api.consts import Feature +from galaxy.api.consts import Feature, OSCompatibility from galaxy.api.errors import ImportInProgress, UnknownError -from galaxy.api.jsonrpc import ApplicationError, NotificationClient, Server -from galaxy.api.types import Achievement, Authentication, FriendInfo, Game, GameTime, LocalGame, NextStep +from galaxy.api.jsonrpc import ApplicationError, Connection +from galaxy.api.types import ( + Achievement, Authentication, Game, GameLibrarySettings, GameTime, LocalGame, NextStep, UserInfo, UserPresence +) from galaxy.task_manager import TaskManager + +logger = logging.getLogger(__name__) + + class JSONEncoder(json.JSONEncoder): def default(self, o): # pylint: disable=method-hidden if dataclasses.is_dataclass(o): @@ -26,11 +31,74 @@ def dict_factory(elements): return super().default(o) +class Importer: + def __init__( + self, + task_manger, + name, + get, + prepare_context, + notification_success, + notification_failure, + notification_finished, + complete + ): + self._task_manager = task_manger + self._name = name + self._get = get + self._prepare_context = prepare_context + self._notification_success = notification_success + self._notification_failure = notification_failure + self._notification_finished = notification_finished + self._complete = complete + + self._import_in_progress = False + + async def start(self, ids): + if self._import_in_progress: + raise ImportInProgress() + + async def import_element(id_, context_): + try: + element = await self._get(id_, context_) + self._notification_success(id_, element) + except ApplicationError as error: + self._notification_failure(id_, error) + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Unexpected exception raised in %s importer", self._name) + self._notification_failure(id_, UnknownError()) + + async def import_elements(ids_, context_): + try: + imports = [import_element(id_, context_) for id_ in ids_] + await asyncio.gather(*imports) + self._notification_finished() + self._complete() + except asyncio.CancelledError: + logger.debug("Importing %s cancelled", self._name) + finally: + self._import_in_progress = False + + self._import_in_progress = True + try: + context = await self._prepare_context(ids) + self._task_manager.create_task( + import_elements(ids, context), + "{} import".format(self._name), + handle_exceptions=False + ) + except: + self._import_in_progress = False + raise + + class Plugin: """Use and override methods of this class to create a new platform integration.""" def __init__(self, platform, version, reader, writer, handshake_token): - logging.info("Creating plugin for platform %s, version %s", platform.value, version) + logger.info("Creating plugin for platform %s, version %s", platform.value, version) self._platform = platform self._version = version @@ -41,17 +109,64 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._handshake_token = handshake_token encoder = JSONEncoder() - self._server = Server(self._reader, self._writer, encoder) - self._notification_client = NotificationClient(self._writer, encoder) - - self._achievements_import_in_progress = False - self._game_times_import_in_progress = False + self._connection = Connection(self._reader, self._writer, encoder) self._persistent_cache = dict() self._internal_task_manager = TaskManager("plugin internal") self._external_task_manager = TaskManager("plugin external") + self._achievements_importer = Importer( + self._external_task_manager, + "achievements", + self.get_unlocked_achievements, + self.prepare_achievements_context, + self._game_achievements_import_success, + self._game_achievements_import_failure, + self._achievements_import_finished, + self.achievements_import_complete + ) + self._game_time_importer = Importer( + self._external_task_manager, + "game times", + self.get_game_time, + self.prepare_game_times_context, + self._game_time_import_success, + self._game_time_import_failure, + self._game_times_import_finished, + self.game_times_import_complete + ) + self._game_library_settings_importer = Importer( + self._external_task_manager, + "game library settings", + self.get_game_library_settings, + self.prepare_game_library_settings_context, + self._game_library_settings_import_success, + self._game_library_settings_import_failure, + self._game_library_settings_import_finished, + self.game_library_settings_import_complete + ) + self._os_compatibility_importer = Importer( + self._external_task_manager, + "os compatibility", + self.get_os_compatibility, + self.prepare_os_compatibility_context, + self._os_compatibility_import_success, + self._os_compatibility_import_failure, + self._os_compatibility_import_finished, + self.os_compatibility_import_complete + ) + self._user_presence_importer = Importer( + self._external_task_manager, + "users presence", + self.get_user_presence, + self.prepare_user_presence_context, + self._user_presence_import_success, + self._user_presence_import_failure, + self._user_presence_import_finished, + self.user_presence_import_complete + ) + # internal self._register_method("shutdown", self._shutdown, internal=True) self._register_method("get_capabilities", self._get_capabilities, internal=True, immediate=True) @@ -109,6 +224,15 @@ def __init__(self, platform, version, reader, writer, handshake_token): self._register_method("start_game_times_import", self._start_game_times_import) self._detect_feature(Feature.ImportGameTime, ["get_game_time"]) + self._register_method("start_game_library_settings_import", self._start_game_library_settings_import) + self._detect_feature(Feature.ImportGameLibrarySettings, ["get_game_library_settings"]) + + self._register_method("start_os_compatibility_import", self._start_os_compatibility_import) + self._detect_feature(Feature.ImportOSCompatibility, ["get_os_compatibility"]) + + self._register_method("start_user_presence_import", self._start_user_presence_import) + self._detect_feature(Feature.ImportUserPresence, ["get_user_presence"]) + async def __aenter__(self): return self @@ -149,7 +273,7 @@ def method(*args, **kwargs): result = handler(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, True, sensitive_params) + self._connection.register_method(name, method, True, sensitive_params) else: async def method(*args, **kwargs): if not internal: @@ -159,37 +283,47 @@ async def method(*args, **kwargs): result = await handler_(*args, **kwargs) return wrap_result(result) - self._server.register_method(name, method, False, sensitive_params) + self._connection.register_method(name, method, False, sensitive_params) def _register_notification(self, name, handler, internal=False, immediate=False, sensitive_params=False): if not internal and not immediate: handler = self._wrap_external_method(handler, name) - self._server.register_notification(name, handler, immediate, sensitive_params) + self._connection.register_notification(name, handler, immediate, sensitive_params) def _wrap_external_method(self, handler, name: str): async def wrapper(*args, **kwargs): return await self._external_task_manager.create_task(handler(*args, **kwargs), name, False) + return wrapper async def run(self): """Plugin's main coroutine.""" - await self._server.run() + await self._connection.run() + logger.debug("Plugin run loop finished") def close(self) -> None: if not self._active: return - logging.info("Closing plugin") - self._server.close() + logger.info("Closing plugin") + self._connection.close() self._external_task_manager.cancel() - self._internal_task_manager.create_task(self.shutdown(), "shutdown") + + async def shutdown(): + try: + await asyncio.wait_for(self.shutdown(), 30) + except asyncio.TimeoutError: + logging.warning("Plugin shutdown timed out") + + self._internal_task_manager.create_task(shutdown(), "shutdown") self._active = False async def wait_closed(self) -> None: + logger.debug("Waiting for plugin to close") await self._external_task_manager.wait() await self._internal_task_manager.wait() - await self._server.wait_closed() - await self._notification_client.close() + await self._connection.wait_closed() + logger.debug("Plugin closed") def create_task(self, coro, description): """Wrapper around asyncio.create_task - takes care of canceling tasks on shutdown""" @@ -200,11 +334,11 @@ async def _pass_control(self): try: self.tick() except Exception: - logging.exception("Unexpected exception raised in plugin tick") + logger.exception("Unexpected exception raised in plugin tick") await asyncio.sleep(1) async def _shutdown(self): - logging.info("Shutting down") + logger.info("Shutting down") self.close() await self._external_task_manager.wait() await self._internal_task_manager.wait() @@ -221,7 +355,7 @@ def _initialize_cache(self, data: Dict): try: self.handshake_complete() except Exception: - logging.exception("Unhandled exception during `handshake_complete` step") + logger.exception("Unhandled exception during `handshake_complete` step") self._internal_task_manager.create_task(self._pass_control(), "tick") @staticmethod @@ -252,9 +386,9 @@ async def pass_login_credentials(self, step, credentials, cookies): """ # temporary solution for persistent_cache vs credentials issue - self.persistent_cache['credentials'] = credentials # type: ignore + self.persistent_cache["credentials"] = credentials # type: ignore - self._notification_client.notify("store_credentials", credentials, sensitive_params=True) + self._connection.send_notification("store_credentials", credentials, sensitive_params=True) def add_game(self, game: Game) -> None: """Notify the client to add game to the list of owned games @@ -276,7 +410,7 @@ async def check_for_new_games(self): """ params = {"owned_game": game} - self._notification_client.notify("owned_game_added", params) + self._connection.send_notification("owned_game_added", params) def remove_game(self, game_id: str) -> None: """Notify the client to remove game from the list of owned games @@ -298,7 +432,7 @@ async def check_for_removed_games(self): """ params = {"game_id": game_id} - self._notification_client.notify("owned_game_removed", params) + self._connection.send_notification("owned_game_removed", params) def update_game(self, game: Game) -> None: """Notify the client to update the status of a game @@ -307,7 +441,7 @@ def update_game(self, game: Game) -> None: :param game: Game to update """ params = {"owned_game": game} - self._notification_client.notify("owned_game_updated", params) + self._connection.send_notification("owned_game_updated", params) def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: """Notify the client to unlock an achievement for a specific game. @@ -319,24 +453,24 @@ def unlock_achievement(self, game_id: str, achievement: Achievement) -> None: "game_id": game_id, "achievement": achievement } - self._notification_client.notify("achievement_unlocked", params) + self._connection.send_notification("achievement_unlocked", params) def _game_achievements_import_success(self, game_id: str, achievements: List[Achievement]) -> None: params = { "game_id": game_id, "unlocked_achievements": achievements } - self._notification_client.notify("game_achievements_import_success", params) + self._connection.send_notification("game_achievements_import_success", params) def _game_achievements_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_achievements_import_failure", params) + self._connection.send_notification("game_achievements_import_failure", params) def _achievements_import_finished(self) -> None: - self._notification_client.notify("achievements_import_finished", None) + self._connection.send_notification("achievements_import_finished", None) def update_local_game_status(self, local_game: LocalGame) -> None: """Notify the client to update the status of a local game. @@ -362,15 +496,15 @@ def tick(self): self._check_statuses_task = asyncio.create_task(self._check_statuses()) """ params = {"local_game": local_game} - self._notification_client.notify("local_game_status_changed", params) + self._connection.send_notification("local_game_status_changed", params) - def add_friend(self, user: FriendInfo) -> None: + def add_friend(self, user: UserInfo) -> None: """Notify the client to add a user to friends list of the currently authenticated user. - :param user: FriendInfo of a user that the client will add to friends list + :param user: UserInfo of a user that the client will add to friends list """ params = {"friend_info": user} - self._notification_client.notify("friend_added", params) + self._connection.send_notification("friend_added", params) def remove_friend(self, user_id: str) -> None: """Notify the client to remove a user from friends list of the currently authenticated user. @@ -378,7 +512,14 @@ def remove_friend(self, user_id: str) -> None: :param user_id: id of the user to remove from friends list """ params = {"user_id": user_id} - self._notification_client.notify("friend_removed", params) + self._connection.send_notification("friend_removed", params) + + def update_friend_info(self, user: UserInfo) -> None: + """Notify the client about the updated friend information. + + :param user: UserInfo of a friend whose info was updated + """ + self._connection.send_notification("friend_updated", params={"friend_info": user}) def update_game_time(self, game_time: GameTime) -> None: """Notify the client to update game time for a game. @@ -386,37 +527,110 @@ def update_game_time(self, game_time: GameTime) -> None: :param game_time: game time to update """ params = {"game_time": game_time} - self._notification_client.notify("game_time_updated", params) + self._connection.send_notification("game_time_updated", params) + + def update_user_presence(self, user_id: str, user_presence: UserPresence) -> None: + """Notify the client about the updated user presence information. + + :param user_id: the id of the user whose presence information is updated + :param user_presence: presence information of the specified user + """ + self._connection.send_notification( + "user_presence_updated", + { + "user_id": user_id, + "presence": user_presence + } + ) - def _game_time_import_success(self, game_time: GameTime) -> None: + def _game_time_import_success(self, game_id: str, game_time: GameTime) -> None: params = {"game_time": game_time} - self._notification_client.notify("game_time_import_success", params) + self._connection.send_notification("game_time_import_success", params) def _game_time_import_failure(self, game_id: str, error: ApplicationError) -> None: params = { "game_id": game_id, "error": error.json() } - self._notification_client.notify("game_time_import_failure", params) + self._connection.send_notification("game_time_import_failure", params) def _game_times_import_finished(self) -> None: - self._notification_client.notify("game_times_import_finished", None) + self._connection.send_notification("game_times_import_finished", None) + + def _game_library_settings_import_success(self, game_id: str, game_library_settings: GameLibrarySettings) -> None: + params = {"game_library_settings": game_library_settings} + self._connection.send_notification("game_library_settings_import_success", params) + + def _game_library_settings_import_failure(self, game_id: str, error: ApplicationError) -> None: + params = { + "game_id": game_id, + "error": error.json() + } + self._connection.send_notification("game_library_settings_import_failure", params) + + def _game_library_settings_import_finished(self) -> None: + self._connection.send_notification("game_library_settings_import_finished", None) + + def _os_compatibility_import_success(self, game_id: str, os_compatibility: Optional[OSCompatibility]) -> None: + self._connection.send_notification( + "os_compatibility_import_success", + { + "game_id": game_id, + "os_compatibility": os_compatibility + } + ) + + def _os_compatibility_import_failure(self, game_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "os_compatibility_import_failure", + { + "game_id": game_id, + "error": error.json() + } + ) + + def _os_compatibility_import_finished(self) -> None: + self._connection.send_notification("os_compatibility_import_finished", None) + + def _user_presence_import_success(self, user_id: str, user_presence: UserPresence) -> None: + self._connection.send_notification( + "user_presence_import_success", + { + "user_id": user_id, + "presence": user_presence + } + ) + + def _user_presence_import_failure(self, user_id: str, error: ApplicationError) -> None: + self._connection.send_notification( + "user_presence_import_failure", + { + "user_id": user_id, + "error": error.json() + } + ) + + def _user_presence_import_finished(self) -> None: + self._connection.send_notification("user_presence_import_finished", None) def lost_authentication(self) -> None: """Notify the client that integration has lost authentication for the current user and is unable to perform actions which would require it. """ - self._notification_client.notify("authentication_lost", None) + self._connection.send_notification("authentication_lost", None) def push_cache(self) -> None: """Push local copy of the persistent cache to the GOG Galaxy Client replacing existing one. """ - self._notification_client.notify( + self._connection.send_notification( "push_cache", params={"data": self._persistent_cache}, sensitive_params="data" ) + async def refresh_credentials(self, params: Dict[str, Any], sensitive_params) -> Dict[str, Any]: + return await self._connection.send_request("refresh_credentials", params, sensitive_params) + # handlers def handshake_complete(self) -> None: """This method is called right after the handshake with the GOG Galaxy Client is complete and @@ -481,10 +695,11 @@ async def authenticate(self, stored_credentials=None): async def pass_login_credentials(self, step: str, credentials: Dict[str, str], cookies: List[Dict[str, str]]) \ -> Union[NextStep, Authentication]: - """This method is called if we return galaxy.api.types.NextStep from authenticate or from pass_login_credentials. + """This method is called if we return :class:`~galaxy.api.types.NextStep` from :meth:`.authenticate` + or :meth:`.pass_login_credentials`. This method's parameters provide the data extracted from the web page navigation that previous NextStep finished on. - This method should either return galaxy.api.types.Authentication if the authentication is finished - or galaxy.api.types.NextStep if it requires going to another cef url. + This method should either return :class:`~galaxy.api.types.Authentication` if the authentication is finished + or :class:`~galaxy.api.types.NextStep` if it requires going to another cef url. This method is called by the GOG Galaxy Client. :param step: deprecated. @@ -529,36 +744,7 @@ async def get_owned_games(self): raise NotImplementedError() async def _start_achievements_import(self, game_ids: List[str]) -> None: - if self._achievements_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_achievements_context(game_ids) - - async def import_game_achievements(game_id, context_): - try: - achievements = await self.get_unlocked_achievements(game_id, context_) - self._game_achievements_import_success(game_id, achievements) - except ApplicationError as error: - self._game_achievements_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_achievements") - self._game_achievements_import_failure(game_id, UnknownError()) - - async def import_games_achievements(game_ids_, context_): - try: - imports = [import_game_achievements(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._achievements_import_finished() - self._achievements_import_in_progress = False - self.achievements_import_complete() - - self._external_task_manager.create_task( - import_games_achievements(game_ids, context), - "unlocked achievements import", - handle_exceptions=False - ) - self._achievements_import_in_progress = True + await self._achievements_importer.start(game_ids) async def prepare_achievements_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_unlocked_achievements. @@ -672,7 +858,7 @@ async def launch_platform_client(self) -> None: This method is called by the GOG Galaxy Client.""" raise NotImplementedError() - async def get_friends(self) -> List[FriendInfo]: + async def get_friends(self) -> List[UserInfo]: """Override this method to return the friends list of the currently authenticated user. This method is called by the GOG Galaxy Client. @@ -693,36 +879,7 @@ async def get_friends(self): raise NotImplementedError() async def _start_game_times_import(self, game_ids: List[str]) -> None: - if self._game_times_import_in_progress: - raise ImportInProgress() - - context = await self.prepare_game_times_context(game_ids) - - async def import_game_time(game_id, context_): - try: - game_time = await self.get_game_time(game_id, context_) - self._game_time_import_success(game_time) - except ApplicationError as error: - self._game_time_import_failure(game_id, error) - except Exception: - logging.exception("Unexpected exception raised in import_game_time") - self._game_time_import_failure(game_id, UnknownError()) - - async def import_game_times(game_ids_, context_): - try: - imports = [import_game_time(game_id, context_) for game_id in game_ids_] - await asyncio.gather(*imports) - finally: - self._game_times_import_finished() - self._game_times_import_in_progress = False - self.game_times_import_complete() - - self._external_task_manager.create_task( - import_game_times(game_ids, context), - "game times import", - handle_exceptions=False - ) - self._game_times_import_in_progress = True + await self._game_time_importer.start(game_ids) async def prepare_game_times_context(self, game_ids: List[str]) -> Any: """Override this method to prepare context for get_game_time. @@ -750,6 +907,87 @@ def game_times_import_complete(self) -> None: (like updating cache). """ + async def _start_game_library_settings_import(self, game_ids: List[str]) -> None: + await self._game_library_settings_importer.start(game_ids) + + async def prepare_game_library_settings_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_game_library_settings. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game library settings are imported + :return: context + """ + return None + + async def get_game_library_settings(self, game_id: str, context: Any) -> GameLibrarySettings: + """Override this method to return the game library settings for the game + identified by the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game library settings are imported + :param context: the value returned from :meth:`prepare_game_library_settings_context` + :return: GameLibrarySettings object + """ + raise NotImplementedError() + + def game_library_settings_import_complete(self) -> None: + """Override this method to handle operations after game library settings import is finished + (like updating cache). + """ + + async def _start_os_compatibility_import(self, game_ids: List[str]) -> None: + await self._os_compatibility_importer.start(game_ids) + + async def prepare_os_compatibility_context(self, game_ids: List[str]) -> Any: + """Override this method to prepare context for get_os_compatibility. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param game_ids: the ids of the games for which game os compatibility is imported + :return: context + """ + return None + + async def get_os_compatibility(self, game_id: str, context: Any) -> Optional[OSCompatibility]: + """Override this method to return the OS compatibility for the game with the provided game_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param game_id: the id of the game for which the game os compatibility is imported + :param context: the value returned from :meth:`prepare_os_compatibility_context` + :return: OSCompatibility flags indicating compatible OSs, or None if compatibility is not know + """ + raise NotImplementedError() + + def os_compatibility_import_complete(self) -> None: + """Override this method to handle operations after OS compatibility import is finished (like updating cache).""" + + async def _start_user_presence_import(self, user_id_list: List[str]) -> None: + await self._user_presence_importer.start(user_id_list) + + async def prepare_user_presence_context(self, user_id_list: List[str]) -> Any: + """Override this method to prepare context for get_user_presence. + This allows for optimizations like batch requests to platform API. + Default implementation returns None. + + :param user_id_list: the ids of the users for whom presence information is imported + :return: context + """ + return None + + async def get_user_presence(self, user_id: str, context: Any) -> UserPresence: + """Override this method to return presence information for the user with the provided user_id. + This method is called by import task initialized by GOG Galaxy Client. + + :param user_id: the id of the user for whom presence information is imported + :param context: the value returned from :meth:`prepare_user_presence_context` + :return: UserPresence presence information of the provided user + """ + raise NotImplementedError() + + def user_presence_import_complete(self) -> None: + """Override this method to handle operations after presence import is finished (like updating cache).""" + def create_and_run_plugin(plugin_class, argv): """Call this method as an entry point for the implemented integration. @@ -769,7 +1007,7 @@ def main(): main() """ if len(argv) < 3: - logging.critical("Not enough parameters, required: token, port") + logger.critical("Not enough parameters, required: token, port") sys.exit(1) token = argv[1] @@ -777,23 +1015,28 @@ def main(): try: port = int(argv[2]) except ValueError: - logging.critical("Failed to parse port value: %s", argv[2]) + logger.critical("Failed to parse port value: %s", argv[2]) sys.exit(2) if not (1 <= port <= 65535): - logging.critical("Port value out of range (1, 65535)") + logger.critical("Port value out of range (1, 65535)") sys.exit(3) if not issubclass(plugin_class, Plugin): - logging.critical("plugin_class must be subclass of Plugin") + logger.critical("plugin_class must be subclass of Plugin") sys.exit(4) async def coroutine(): reader, writer = await asyncio.open_connection("127.0.0.1", port) - extra_info = writer.get_extra_info("sockname") - logging.info("Using local address: %s:%u", *extra_info) - async with plugin_class(reader, writer, token) as plugin: - await plugin.run() + try: + extra_info = writer.get_extra_info("sockname") + logger.info("Using local address: %s:%u", *extra_info) + async with plugin_class(reader, writer, token) as plugin: + await plugin.run() + finally: + writer.close() + await writer.wait_closed() + try: if sys.platform == "win32": @@ -801,5 +1044,5 @@ async def coroutine(): asyncio.run(coroutine()) except Exception: - logging.exception("Error while running plugin") + logger.exception("Error while running plugin") sys.exit(5) diff --git a/galaxy/api/types.py b/galaxy/api/types.py index 37d55a3..0a0860a 100644 --- a/galaxy/api/types.py +++ b/galaxy/api/types.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import Dict, List, Optional + +from galaxy.api.consts import LicenseType, LocalGameState, PresenceState -from galaxy.api.consts import LicenseType, LocalGameState @dataclass -class Authentication(): +class Authentication: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to inform the client that authentication has successfully finished. @@ -14,8 +15,9 @@ class Authentication(): user_id: str user_name: str + @dataclass -class Cookie(): +class Cookie: """Cookie :param name: name of the cookie @@ -28,8 +30,9 @@ class Cookie(): domain: Optional[str] = None path: Optional[str] = None + @dataclass -class NextStep(): +class NextStep: """Return this from :meth:`.authenticate` or :meth:`.pass_login_credentials` to open client built-in browser with given url. For example: @@ -58,17 +61,20 @@ async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", PARAMS, cookies=COOKIES, js=JS) - :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} + :param auth_params: configuration options: {"window_title": :class:`str`, "window_width": :class:`str`, + "window_height": :class:`int`, "start_uri": :class:`int`, "end_uri_regex": :class:`str`} :param cookies: browser initial set of cookies - :param js: a map of the url regex patterns into the list of *js* scripts that should be executed on every document at given step of internal browser authentication. + :param js: a map of the url regex patterns into the list of *js* scripts that should be executed + on every document at given step of internal browser authentication. """ next_step: str auth_params: Dict[str, str] cookies: Optional[List[Cookie]] = None js: Optional[Dict[str, List[str]]] = None + @dataclass -class LicenseInfo(): +class LicenseInfo: """Information about the license of related product. :param license_type: type of license @@ -77,8 +83,9 @@ class LicenseInfo(): license_type: LicenseType owner: Optional[str] = None + @dataclass -class Dlc(): +class Dlc: """Downloadable content object. :param dlc_id: id of the dlc @@ -89,8 +96,9 @@ class Dlc(): dlc_title: str license_info: LicenseInfo + @dataclass -class Game(): +class Game: """Game object. :param game_id: unique identifier of the game, this will be passed as parameter for methods such as launch_game @@ -103,8 +111,9 @@ class Game(): dlcs: Optional[List[Dlc]] license_info: LicenseInfo + @dataclass -class Achievement(): +class Achievement: """Achievement, has to be initialized with either id or name. :param unlock_time: unlock time of the achievement @@ -119,8 +128,9 @@ def __post_init__(self): assert self.achievement_id or self.achievement_name, \ "One of achievement_id or achievement_name is required" + @dataclass -class LocalGame(): +class LocalGame: """Game locally present on the authenticated user's computer. :param game_id: id of the game @@ -129,25 +139,80 @@ class LocalGame(): game_id: str local_game_state: LocalGameState + +@dataclass +class FriendInfo: + """ + .. deprecated:: 0.56 + Use :class:`UserInfo`. + + Information about a friend of the currently authenticated user. + + :param user_id: id of the user + :param user_name: username of the user + """ + user_id: str + user_name: str + + @dataclass -class FriendInfo(): - """Information about a friend of the currently authenticated user. +class UserInfo: + """Information about a user of related user. :param user_id: id of the user :param user_name: username of the user + :param avatar_url: the URL of the user avatar + :param profile_url: the URL of the user profile """ user_id: str user_name: str + avatar_url: Optional[str] + profile_url: Optional[str] + @dataclass -class GameTime(): +class GameTime: """Game time of a game, defines the total time spent in the game and the last time the game was played. :param game_id: id of the related game :param time_played: the total time spent in the game in **minutes** - :param last_time_played: last time the game was played (**unix timestamp**) + :param last_played_time: last time the game was played (**unix timestamp**) """ game_id: str time_played: Optional[int] last_played_time: Optional[int] + + +@dataclass +class GameLibrarySettings: + """Library settings of a game, defines assigned tags and visibility flag. + + :param game_id: id of the related game + :param tags: collection of tags assigned to the game + :param hidden: indicates if the game should be hidden in GOG Galaxy client + """ + game_id: str + tags: Optional[List[str]] + hidden: Optional[bool] + + +@dataclass +class UserPresence: + """Presence information of a user. + + The GOG Galaxy client will prefer to generate user status basing on `game_id` (or `game_title`) + and `in_game_status` fields but if plugin is not capable of delivering it then the `full_status` will be used if + available + + :param presence_state: the state of the user + :param game_id: id of the game a user is currently in + :param game_title: name of the game a user is currently in + :param in_game_status: status set by the game itself e.x. "In Main Menu" + :param full_status: full user status e.x. "Playing : " + """ + presence_state: PresenceState + game_id: Optional[str] = None + game_title: Optional[str] = None + in_game_status: Optional[str] = None + full_status: Optional[str] = None diff --git a/galaxy/http.py b/galaxy/http.py index 615daa0..b68c7cd 100644 --- a/galaxy/http.py +++ b/galaxy/http.py @@ -44,6 +44,8 @@ async def _authorized_request(self, method, url, *args, **kwargs): ) +logger = logging.getLogger(__name__) + #: Default limit of the simultaneous connections for ssl connector. DEFAULT_LIMIT = 20 #: Default timeout in seconds used for client session. @@ -78,7 +80,8 @@ def create_tcp_connector(*args, **kwargs) -> aiohttp.TCPConnector: ssl_context.load_verify_locations(certifi.where()) kwargs.setdefault("ssl", ssl_context) kwargs.setdefault("limit", DEFAULT_LIMIT) - return aiohttp.TCPConnector(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.TCPConnector(*args, **kwargs) # type: ignore def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: @@ -103,7 +106,8 @@ def create_client_session(*args, **kwargs) -> aiohttp.ClientSession: kwargs.setdefault("connector", create_tcp_connector()) kwargs.setdefault("timeout", aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT)) kwargs.setdefault("raise_for_status", True) - return aiohttp.ClientSession(*args, **kwargs) # type: ignore due to https://github.com/python/mypy/issues/4001 + # due to https://github.com/python/mypy/issues/4001 + return aiohttp.ClientSession(*args, **kwargs) # type: ignore @contextmanager @@ -134,11 +138,11 @@ def handle_exception(): if error.status >= 500: raise BackendError() if error.status >= 400: - logging.warning( + logger.warning( "Got status %d while performing %s request for %s", error.status, error.request_info.method, str(error.request_info.url) ) raise UnknownError() except aiohttp.ClientError: - logging.exception("Caught exception while performing request") + logger.exception("Caught exception while performing request") raise UnknownError() diff --git a/galaxy/proc_tools.py b/galaxy/proc_tools.py index b0de0bc..4c2df33 100644 --- a/galaxy/proc_tools.py +++ b/galaxy/proc_tools.py @@ -3,7 +3,6 @@ from typing import Iterable, NewType, Optional, List, cast - ProcessId = NewType("ProcessId", int) diff --git a/galaxy/reader.py b/galaxy/reader.py index 551f803..732e658 100644 --- a/galaxy/reader.py +++ b/galaxy/reader.py @@ -12,7 +12,7 @@ async def readline(self): while True: # check if there is no unprocessed data in the buffer if not self._buffer or self._processed_buffer_it != 0: - chunk = await self._reader.read(1024) + chunk = await self._reader.read(1024*1024) if not chunk: return bytes() # EOF self._buffer += chunk diff --git a/galaxy/registry_monitor.py b/galaxy/registry_monitor.py new file mode 100644 index 0000000..1396535 --- /dev/null +++ b/galaxy/registry_monitor.py @@ -0,0 +1,99 @@ +import sys + + +if sys.platform == "win32": + import logging + import ctypes + from ctypes.wintypes import LONG, HKEY, LPCWSTR, DWORD, BOOL, HANDLE, LPVOID + + LPSECURITY_ATTRIBUTES = LPVOID + + RegOpenKeyEx = ctypes.windll.advapi32.RegOpenKeyExW + RegOpenKeyEx.restype = LONG + RegOpenKeyEx.argtypes = [HKEY, LPCWSTR, DWORD, DWORD, ctypes.POINTER(HKEY)] + + RegCloseKey = ctypes.windll.advapi32.RegCloseKey + RegCloseKey.restype = LONG + RegCloseKey.argtypes = [HKEY] + + RegNotifyChangeKeyValue = ctypes.windll.advapi32.RegNotifyChangeKeyValue + RegNotifyChangeKeyValue.restype = LONG + RegNotifyChangeKeyValue.argtypes = [HKEY, BOOL, DWORD, HANDLE, BOOL] + + CloseHandle = ctypes.windll.kernel32.CloseHandle + CloseHandle.restype = BOOL + CloseHandle.argtypes = [HANDLE] + + CreateEvent = ctypes.windll.kernel32.CreateEventW + CreateEvent.restype = BOOL + CreateEvent.argtypes = [LPSECURITY_ATTRIBUTES, BOOL, BOOL, LPCWSTR] + + WaitForSingleObject = ctypes.windll.kernel32.WaitForSingleObject + WaitForSingleObject.restype = DWORD + WaitForSingleObject.argtypes = [HANDLE, DWORD] + + ERROR_SUCCESS = 0x00000000 + + KEY_READ = 0x00020019 + KEY_QUERY_VALUE = 0x00000001 + + REG_NOTIFY_CHANGE_NAME = 0x00000001 + REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 + + WAIT_OBJECT_0 = 0x00000000 + WAIT_TIMEOUT = 0x00000102 + +class RegistryMonitor: + + def __init__(self, root, subkey): + self._root = root + self._subkey = subkey + self._event = CreateEvent(None, False, False, None) + + self._key = None + self._open_key() + if self._key: + self._set_key_update_notification() + + def close(self): + CloseHandle(self._event) + if self._key: + RegCloseKey(self._key) + self._key = None + + def is_updated(self): + wait_result = WaitForSingleObject(self._event, 0) + + # previously watched + if wait_result == WAIT_OBJECT_0: + self._set_key_update_notification() + return True + + # no changes or no key before + if wait_result != WAIT_TIMEOUT: + # unexpected error + logging.warning("Unexpected WaitForSingleObject result %s", wait_result) + return False + + if self._key is None: + self._open_key() + + if self._key is not None: + self._set_key_update_notification() + + return False + + def _set_key_update_notification(self): + filter_ = REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET + status = RegNotifyChangeKeyValue(self._key, True, filter_, self._event, True) + if status != ERROR_SUCCESS: + # key was deleted + RegCloseKey(self._key) + self._key = None + + def _open_key(self): + access = KEY_QUERY_VALUE | KEY_READ + self._key = HKEY() + rc = RegOpenKeyEx(self._root, self._subkey, 0, access, ctypes.byref(self._key)) + if rc != ERROR_SUCCESS: + self._key = None diff --git a/galaxy/task_manager.py b/galaxy/task_manager.py index 1f6d457..e7bb517 100644 --- a/galaxy/task_manager.py +++ b/galaxy/task_manager.py @@ -3,6 +3,10 @@ from collections import OrderedDict from itertools import count + +logger = logging.getLogger(__name__) + + class TaskManager: def __init__(self, name): self._name = name @@ -15,23 +19,23 @@ def create_task(self, coro, description, handle_exceptions=True): async def task_wrapper(task_id): try: result = await coro - logging.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: finished task %d (%s)", self._name, task_id, description) return result except asyncio.CancelledError: if handle_exceptions: - logging.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: canceled task %d (%s)", self._name, task_id, description) else: raise except Exception: if handle_exceptions: - logging.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) + logger.exception("Task manager %s: exception raised in task %d (%s)", self._name, task_id, description) else: raise finally: del self._tasks[task_id] task_id = next(self._task_counter) - logging.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) + logger.debug("Task manager %s: creating task %d (%s)", self._name, task_id, description) task = asyncio.create_task(task_wrapper(task_id)) self._tasks[task_id] = task return task diff --git a/galaxy/unittest/mock.py b/galaxy/unittest/mock.py index b439671..da2e033 100644 --- a/galaxy/unittest/mock.py +++ b/galaxy/unittest/mock.py @@ -21,11 +21,19 @@ def coroutine_mock(): corofunc.coro = coro return corofunc + async def skip_loop(iterations=1): for _ in range(iterations): await asyncio.sleep(0) async def async_return_value(return_value, loop_iterations_delay=0): - await skip_loop(loop_iterations_delay) + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) return return_value + + +async def async_raise(error, loop_iterations_delay=0): + if loop_iterations_delay > 0: + await skip_loop(loop_iterations_delay) + raise error diff --git a/plugin.py b/plugin.py index 13c22d7..b494543 100644 --- a/plugin.py +++ b/plugin.py @@ -22,7 +22,7 @@ def __init__(self, reader, writer, token): copyfile(os.path.dirname(os.path.realpath(__file__)) + r'\files\gametimes.xml', os.path.dirname(os.path.realpath(__file__)) + r'\gametimes.xml') self.game_times = self.get_the_game_times() self.local_games_cache = self.local_games_list() - self.runningGames = [] + self.runningGame = self.runningGame = {"game_id": "", "starting_time": 0, "dolphin_running": None, "launched": False} @@ -58,11 +58,8 @@ async def launch_game(self, game_id): if str(game[1]) == game_id: if user_config.retroarch is not True: openDolphin = subprocess.Popen([emu_path, "-b", "-e", game[0]]) - subprocess.Popen( - [os.path.dirname(os.path.realpath(__file__)) + r'\TimeTracker.exe', game_id, game_id]) - gameStartingTime = time.process_time() - running_game = {"game_id": game_id, "starting_time": gameStartingTime, "dolphin_running": openDolphin} - self.runningGames.append(running_game) + gameStartingTime = time.time() + self.runningGame = {"game_id": game_id, "starting_time": gameStartingTime, "dolphin_running": openDolphin} else: subprocess.Popen([user_config.retroarch_executable, "-L", user_config.core_path + r'\dolphin_libretro.dll', game[0]]) break @@ -103,22 +100,24 @@ async def update_local_games(): self.update_local_game_status(local_game_notify) file = ElementTree.parse(os.path.dirname(os.path.realpath(__file__)) + r'\gametimes.xml') - for runningGame in self.runningGames: - if runningGame["dolphin_running"].poll() is not None: - current_process_time = time.process_time() - current_time = round(time.time()) - runtime = current_process_time - runningGame["starting_time"] - games_xml = file.getroot() - for game in games_xml.iter('game'): - if str(game.find('id').text) == runningGame["game_id"]: - previous_time = int(game.find('time').text) - total_time = round(previous_time + runtime) - game.find('time').text = str(total_time) - game.find('lasttimeplayed').text = str(current_time) - total_time /= 60 - self.update_game_time(GameTime(runningGame["game_id"], total_time, current_time)) - file.write('gametimes.xml') - self.runningGames.remove(runningGame) + if self.runningGame["dolphin_running"] is not None: + if self.runningGame["dolphin_running"].poll() is None: + self.runningGame["launched"] = True + if self.runningGame["dolphin_running"].poll() is not None: + if self.runningGame["launched"]: + current_time = round(time.time()) + runtime = time.time() - self.runningGame["starting_time"] + games_xml = file.getroot() + for game in games_xml.iter('game'): + if str(game.find('id').text) == self.runningGame["game_id"]: + previous_time = int(game.find('time').text) + total_time = round(previous_time + runtime) + game.find('time').text = str(total_time) + game.find('lasttimeplayed').text = str(current_time) + self.update_game_time( + GameTime(self.runningGame["game_id"], int(total_time / 60), current_time)) + file.write(os.path.dirname(os.path.realpath(__file__)) + r'\gametimes.xml') + self.runningGame["launched"] = False asyncio.create_task(update_local_games()) diff --git a/user_config.py b/user_config.py index 221b611..ca9c38f 100644 --- a/user_config.py +++ b/user_config.py @@ -1,5 +1,5 @@ # Set your roms inside the string/quotes -roms_path = r"E:\Joshua's Stuff\roms\Wii" +roms_path = r"E:\Joshua's Stuff\Nextcloud\roms\Wii" # Set the path to your Dolphin.exe inside the string/quotes emu_path = r"C:\Program Files\Dolphin-x64\Dolphin.exe" #Enable to allow the best match algorithm instead of exact game name