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