diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index c7eccd7b6c..6a7ce8a3cb 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -9,11 +9,18 @@ from .interfaces import ( ) from .base import ( + ProcessPreparationError, + ProcessContext, AYONAddon, AddonsManager, load_addons, ) +from .utils import ( + ensure_addons_are_process_context_ready, + ensure_addons_are_process_ready, +) + __all__ = ( "click_wrap", @@ -24,7 +31,12 @@ __all__ = ( "ITrayService", "IHostAddon", + "ProcessPreparationError", + "ProcessContext", "AYONAddon", "AddonsManager", "load_addons", + + "ensure_addons_are_process_context_ready", + "ensure_addons_are_process_ready", ) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 08dd2d6bbd..001ec5d534 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -10,6 +10,7 @@ import threading import collections from uuid import uuid4 from abc import ABC, abstractmethod +from typing import Optional import appdirs import ayon_api @@ -64,6 +65,56 @@ MOVED_ADDON_MILESTONE_VERSIONS = { } +class ProcessPreparationError(Exception): + """Exception that can be used when process preparation failed. + + The message is shown to user (either as UI dialog or printed). If + different error is raised a "generic" error message is shown to user + with option to copy error message to clipboard. + + """ + pass + + +class ProcessContext: + """Context of child process. + + Notes: + This class is used to pass context to child process. It can be used + to use different behavior of addon based on information in + the context. + The context can be enhanced in future versions. + + Args: + addon_name (Optional[str]): Addon name which triggered process. + addon_version (Optional[str]): Addon version which triggered process. + project_name (Optional[str]): Project name. Can be filled in case + process is triggered for specific project. Some addons can have + different behavior based on project. + headless (Optional[bool]): Is process running in headless mode. + + """ + def __init__( + self, + addon_name: Optional[str] = None, + addon_version: Optional[str] = None, + project_name: Optional[str] = None, + headless: Optional[bool] = None, + **kwargs, + ): + if headless is None: + # TODO use lib function to get headless mode + headless = os.getenv("AYON_HEADLESS_MODE") == "1" + self.addon_name: Optional[str] = addon_name + self.addon_version: Optional[str] = addon_version + self.project_name: Optional[str] = project_name + self.headless: bool = headless + + if kwargs: + unknown_keys = ", ".join([f'"{key}"' for key in kwargs.keys()]) + print(f"Unknown keys in ProcessContext: {unknown_keys}") + + # Inherit from `object` for Python 2 hosts class _ModuleClass(object): """Fake module class for storing AYON addons. @@ -584,7 +635,29 @@ class AYONAddon(ABC): Args: enabled_addons (list[AYONAddon]): Addons that are enabled. """ + pass + def ensure_is_process_ready( + self, process_context: ProcessContext + ): + """Make sure addon is prepared for a process. + + This method is called when some action makes sure that addon has set + necessary data. For example if user should be logged in + and filled credentials in environment variables this method should + ask user for credentials. + + Implementation of this method is optional. + + Note: + The logic can be similar to logic in tray, but tray does not require + to be logged in. + + Args: + process_context (ProcessContext): Context of child + process. + + """ pass def get_global_environments(self): diff --git a/client/ayon_core/addon/ui/process_ready_error.py b/client/ayon_core/addon/ui/process_ready_error.py new file mode 100644 index 0000000000..78e2b9b9bb --- /dev/null +++ b/client/ayon_core/addon/ui/process_ready_error.py @@ -0,0 +1,132 @@ +import sys +import json +from typing import Optional + +from qtpy import QtWidgets, QtCore + +from ayon_core.style import load_stylesheet +from ayon_core.tools.utils import get_ayon_qt_app + + +class DetailDialog(QtWidgets.QDialog): + def __init__(self, detail, parent): + super().__init__(parent) + + self.setWindowTitle("Detail") + + detail_input = QtWidgets.QPlainTextEdit(self) + detail_input.setPlainText(detail) + detail_input.setReadOnly(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(detail_input, 1) + + def showEvent(self, event): + self.resize(600, 400) + super().showEvent(event) + + +class ErrorDialog(QtWidgets.QDialog): + def __init__( + self, + message: str, + detail: Optional[str], + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent) + + self.setWindowTitle("Preparation failed") + self.setWindowFlags( + self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint + ) + + message_label = QtWidgets.QLabel(self) + + detail_wrapper = QtWidgets.QWidget(self) + + detail_label = QtWidgets.QLabel(detail_wrapper) + + detail_layout = QtWidgets.QVBoxLayout(detail_wrapper) + detail_layout.setContentsMargins(0, 0, 0, 0) + detail_layout.addWidget(detail_label) + + btns_wrapper = QtWidgets.QWidget(self) + + copy_detail_btn = QtWidgets.QPushButton("Copy detail", btns_wrapper) + show_detail_btn = QtWidgets.QPushButton("Show detail", btns_wrapper) + confirm_btn = QtWidgets.QPushButton("Close", btns_wrapper) + + btns_layout = QtWidgets.QHBoxLayout(btns_wrapper) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(copy_detail_btn, 0) + btns_layout.addWidget(show_detail_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(message_label, 0) + layout.addWidget(detail_wrapper, 1) + layout.addWidget(btns_wrapper, 0) + + copy_detail_btn.clicked.connect(self._on_copy_clicked) + show_detail_btn.clicked.connect(self._on_show_detail_clicked) + confirm_btn.clicked.connect(self._on_confirm_clicked) + + self._message_label = message_label + self._detail_wrapper = detail_wrapper + self._detail_label = detail_label + + self._copy_detail_btn = copy_detail_btn + self._show_detail_btn = show_detail_btn + self._confirm_btn = confirm_btn + + self._detail_dialog = None + + self._detail = detail + + self.set_message(message, detail) + + def showEvent(self, event): + self.setStyleSheet(load_stylesheet()) + self.resize(320, 140) + super().showEvent(event) + + def set_message(self, message, detail): + self._message_label.setText(message) + self._detail = detail + + for widget in ( + self._copy_detail_btn, + self._show_detail_btn, + ): + widget.setVisible(bool(detail)) + + def _on_copy_clicked(self): + if self._detail: + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(self._detail) + + def _on_show_detail_clicked(self): + if self._detail_dialog is None: + self._detail_dialog = DetailDialog(self._detail, self) + self._detail_dialog.show() + + def _on_confirm_clicked(self): + self.accept() + + +def main(): + json_path = sys.argv[-1] + with open(json_path, "r") as stream: + data = json.load(stream) + + message = data["message"] + detail = data["detail"] + app = get_ayon_qt_app() + dialog = ErrorDialog(message, detail) + dialog.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py new file mode 100644 index 0000000000..ac5ff25984 --- /dev/null +++ b/client/ayon_core/addon/utils.py @@ -0,0 +1,184 @@ +import os +import sys +import contextlib +import tempfile +import json +import traceback +from io import StringIO +from typing import Optional + +from ayon_core.lib import run_ayon_launcher_process + +from .base import AddonsManager, ProcessContext, ProcessPreparationError + + +def _handle_error( + process_context: ProcessContext, + message: str, + detail: Optional[str], +): + """Handle error in process ready preparation. + + Shows UI to inform user about the error, or prints the message + to stdout if running in headless mode. + + Todos: + Make this functionality with the dialog as unified function, so it can + be used elsewhere. + + Args: + process_context (ProcessContext): The context in which the + error occurred. + message (str): The message to show. + detail (Optional[str]): The detail message to show (usually + traceback). + + """ + if process_context.headless: + if detail: + print(detail) + print(f"{10*'*'}\n{message}\n{10*'*'}") + return + + current_dir = os.path.dirname(os.path.abspath(__file__)) + script_path = os.path.join(current_dir, "ui", "process_ready_error.py") + with tempfile.NamedTemporaryFile("w", delete=False) as tmp: + tmp_path = tmp.name + json.dump( + {"message": message, "detail": detail}, + tmp.file + ) + + try: + run_ayon_launcher_process( + "--skip-bootstrap", + script_path, + tmp_path, + add_sys_paths=True, + creationflags=0, + ) + + finally: + os.remove(tmp_path) + + +def _start_tray(): + from ayon_core.tools.tray import make_sure_tray_is_running + + make_sure_tray_is_running() + + +def ensure_addons_are_process_context_ready( + process_context: ProcessContext, + addons_manager: Optional[AddonsManager] = None, + exit_on_failure: bool = True, +) -> Optional[Exception]: + """Ensure all enabled addons are ready to be used in the given context. + + Call this method only in AYON launcher process and as first thing + to avoid possible clashes with preparation. For example 'QApplication' + should not be created. + + Args: + process_context (ProcessContext): The context in which the + addons should be prepared. + addons_manager (Optional[AddonsManager]): The addons + manager to use. If not provided, a new one will be created. + exit_on_failure (bool, optional): If True, the process will exit + if an error occurs. Defaults to True. + + Returns: + Optional[Exception]: The exception that occurred during the + preparation, if any. + + """ + if addons_manager is None: + addons_manager = AddonsManager() + + exception = None + message = None + failed = False + use_detail = False + # Wrap the output in StringIO to capture it for details on fail + # - but in case stdout was invalid on start of process also store + # the tracebacks + tracebacks = [] + output = StringIO() + with contextlib.redirect_stdout(output): + with contextlib.redirect_stderr(output): + for addon in addons_manager.get_enabled_addons(): + addon_failed = True + try: + addon.ensure_is_process_ready(process_context) + addon_failed = False + except ProcessPreparationError as exc: + exception = exc + message = str(exc) + print(f"Addon preparation failed: '{addon.name}'") + print(message) + + except BaseException as exc: + exception = exc + use_detail = True + message = "An unexpected error occurred." + formatted_traceback = "".join(traceback.format_exception( + *sys.exc_info() + )) + tracebacks.append(formatted_traceback) + print(f"Addon preparation failed: '{addon.name}'") + print(message) + # Print the traceback so it is in the stdout + print(formatted_traceback) + + if addon_failed: + failed = True + break + + output_str = output.getvalue() + # Print stdout/stderr to console as it was redirected + print(output_str) + if not failed: + if not process_context.headless: + _start_tray() + return None + + detail = None + if use_detail: + # In case stdout was not captured, use the tracebacks as detail + if not output_str: + output_str = "\n".join(tracebacks) + detail = output_str + + _handle_error(process_context, message, detail) + if not exit_on_failure: + return exception + sys.exit(1) + + +def ensure_addons_are_process_ready( + addons_manager: Optional[AddonsManager] = None, + exit_on_failure: bool = True, + **kwargs, +) -> Optional[Exception]: + """Ensure all enabled addons are ready to be used in the given context. + + Call this method only in AYON launcher process and as first thing + to avoid possible clashes with preparation. For example 'QApplication' + should not be created. + + Args: + addons_manager (Optional[AddonsManager]): The addons + manager to use. If not provided, a new one will be created. + exit_on_failure (bool, optional): If True, the process will exit + if an error occurs. Defaults to True. + kwargs: The keyword arguments to pass to the ProcessContext. + + Returns: + Optional[Exception]: The exception that occurred during the + preparation, if any. + + """ + context: ProcessContext = ProcessContext(**kwargs) + return ensure_addons_are_process_context_ready( + context, addons_manager, exit_on_failure + ) diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index e89c8f22ee..bc55c27bd8 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -179,7 +179,7 @@ def clean_envs_for_ayon_process(env=None): return env -def run_ayon_launcher_process(*args, **kwargs): +def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): """Execute AYON process with passed arguments and wait. Wrapper for 'run_process' which prepends AYON executable arguments @@ -209,6 +209,15 @@ def run_ayon_launcher_process(*args, **kwargs): # - fill more if you find more env = clean_envs_for_ayon_process(os.environ) + if add_sys_paths: + new_pythonpath = list(sys.path) + lookup_set = set(new_pythonpath) + for path in (env.get("PYTHONPATH") or "").split(os.pathsep): + if path and path not in lookup_set: + new_pythonpath.append(path) + lookup_set.add(path) + env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) + return run_subprocess(args, env=env, **kwargs) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index ad190482a8..fd84a9bd10 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -10,10 +10,15 @@ import signal import locale from typing import Optional, Dict, Tuple, Any -import ayon_api import requests +from ayon_api.utils import get_default_settings_variant -from ayon_core.lib import Logger, get_ayon_launcher_args, run_detached_process +from ayon_core.lib import ( + Logger, + get_ayon_launcher_args, + run_detached_process, + get_ayon_username, +) from ayon_core.lib.local_settings import get_ayon_appdirs @@ -34,7 +39,7 @@ def _get_default_server_url() -> str: def _get_default_variant() -> str: """Get default settings variant.""" - return ayon_api.get_default_settings_variant() + return get_default_settings_variant() def _get_server_and_variant( @@ -144,17 +149,6 @@ def get_tray_storage_dir() -> str: return get_ayon_appdirs("tray") -def _get_tray_information(tray_url: str) -> Optional[Dict[str, Any]]: - if not tray_url: - return None - try: - response = requests.get(f"{tray_url}/tray") - response.raise_for_status() - return response.json() - except (requests.HTTPError, requests.ConnectionError): - return None - - def _get_tray_info_filepath( server_url: Optional[str] = None, variant: Optional[str] = None @@ -165,6 +159,51 @@ def _get_tray_info_filepath( return os.path.join(hash_dir, filename) +def _get_tray_file_info( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> Tuple[Optional[Dict[str, Any]], Optional[float]]: + filepath = _get_tray_info_filepath(server_url, variant) + if not os.path.exists(filepath): + return None, None + file_modified = os.path.getmtime(filepath) + try: + with open(filepath, "r") as stream: + data = json.load(stream) + except Exception: + return None, file_modified + + return data, file_modified + + +def _remove_tray_server_url( + server_url: Optional[str], + variant: Optional[str], + file_modified: Optional[float], +): + """Remove tray information file. + + Called from tray logic, do not use on your own. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + file_modified (Optional[float]): File modified timestamp. Is validated + against current state of file. + + """ + filepath = _get_tray_info_filepath(server_url, variant) + if not os.path.exists(filepath): + return + + if ( + file_modified is not None + and os.path.getmtime(filepath) != file_modified + ): + return + os.remove(filepath) + + def get_tray_file_info( server_url: Optional[str] = None, variant: Optional[str] = None @@ -182,15 +221,156 @@ def get_tray_file_info( Optional[Dict[str, Any]]: Tray information. """ - filepath = _get_tray_info_filepath(server_url, variant) - if not os.path.exists(filepath): + file_info, _ = _get_tray_file_info(server_url, variant) + return file_info + + +def _get_tray_rest_information(tray_url: str) -> Optional[Dict[str, Any]]: + if not tray_url: return None try: - with open(filepath, "r") as stream: - data = json.load(stream) - except Exception: + response = requests.get(f"{tray_url}/tray") + response.raise_for_status() + return response.json() + except (requests.HTTPError, requests.ConnectionError): return None - return data + + +class TrayInfo: + def __init__( + self, + server_url: str, + variant: str, + timeout: Optional[int] = None + ): + self.server_url = server_url + self.variant = variant + + if timeout is None: + timeout = 10 + + self._timeout = timeout + + self._file_modified = None + self._file_info = None + self._file_info_cached = False + self._tray_info = None + self._tray_info_cached = False + self._file_state = None + self._state = None + + @classmethod + def new( + cls, + server_url: Optional[str] = None, + variant: Optional[str] = None, + timeout: Optional[int] = None, + wait_to_start: Optional[bool] = True + ) -> "TrayInfo": + server_url, variant = _get_server_and_variant(server_url, variant) + obj = cls(server_url, variant, timeout=timeout) + if wait_to_start: + obj.wait_to_start() + return obj + + def get_pid(self) -> Optional[int]: + file_info = self.get_file_info() + if file_info: + return file_info.get("pid") + return None + + def reset(self): + self._file_modified = None + self._file_info = None + self._file_info_cached = False + self._tray_info = None + self._tray_info_cached = False + self._state = None + self._file_state = None + + def get_file_info(self) -> Optional[Dict[str, Any]]: + if not self._file_info_cached: + file_info, file_modified = _get_tray_file_info( + self.server_url, self.variant + ) + self._file_info = file_info + self._file_modified = file_modified + self._file_info_cached = True + return self._file_info + + def get_file_url(self) -> Optional[str]: + file_info = self.get_file_info() + if file_info: + return file_info.get("url") + return None + + def get_tray_url(self) -> Optional[str]: + info = self.get_tray_info() + if info: + return self.get_file_url() + return None + + def get_tray_info(self) -> Optional[Dict[str, Any]]: + if self._tray_info_cached: + return self._tray_info + + tray_url = self.get_file_url() + tray_info = None + if tray_url: + tray_info = _get_tray_rest_information(tray_url) + + self._tray_info = tray_info + self._tray_info_cached = True + return self._tray_info + + def get_file_state(self) -> int: + if self._file_state is not None: + return self._file_state + + state = TrayState.NOT_RUNNING + file_info = self.get_file_info() + if file_info: + state = TrayState.STARTING + if file_info.get("started") is True: + state = TrayState.RUNNING + self._file_state = state + return self._file_state + + def get_state(self) -> int: + if self._state is not None: + return self._state + + state = self.get_file_state() + if state == TrayState.RUNNING and not self.get_tray_info(): + state = TrayState.NOT_RUNNING + pid = self.pid + if pid: + _kill_tray_process(pid) + # Remove the file as tray is not running anymore and update + # the state of this object. + _remove_tray_server_url( + self.server_url, self.variant, self._file_modified + ) + self.reset() + + self._state = state + return self._state + + def get_ayon_username(self) -> Optional[str]: + tray_info = self.get_tray_info() + if tray_info: + return tray_info.get("username") + return None + + def wait_to_start(self) -> bool: + _wait_for_starting_tray( + self.server_url, self.variant, self._timeout + ) + self.reset() + return self.get_file_state() == TrayState.RUNNING + + pid = property(get_pid) + state = property(get_state) def get_tray_server_url( @@ -214,25 +394,12 @@ def get_tray_server_url( Optional[str]: Tray server url. """ - data = get_tray_file_info(server_url, variant) - if data is None: - return None - - if data.get("started") is False: - data = _wait_for_starting_tray(server_url, variant, timeout) - if data is None: - return None - - url = data.get("url") - if not url: - return None - - if not validate: - return url - - if _get_tray_information(url): - return url - return None + tray_info = TrayInfo.new( + server_url, variant, timeout, wait_to_start=True + ) + if validate: + return tray_info.get_tray_url() + return tray_info.get_file_url() def set_tray_server_url(tray_url: Optional[str], started: bool): @@ -246,10 +413,13 @@ def set_tray_server_url(tray_url: Optional[str], started: bool): that tray is starting up. """ - file_info = get_tray_file_info() - if file_info and file_info["pid"] != os.getpid(): - if not file_info["started"] or _get_tray_information(file_info["url"]): - raise TrayIsRunningError("Tray is already running.") + info = TrayInfo.new(wait_to_start=False) + if ( + info.pid + and info.pid != os.getpid() + and info.state in (TrayState.RUNNING, TrayState.STARTING) + ): + raise TrayIsRunningError("Tray is already running.") filepath = _get_tray_info_filepath() os.makedirs(os.path.dirname(filepath), exist_ok=True) @@ -292,20 +462,21 @@ def remove_tray_server_url(force: Optional[bool] = False): def get_tray_information( server_url: Optional[str] = None, - variant: Optional[str] = None -) -> Optional[Dict[str, Any]]: + variant: Optional[str] = None, + timeout: Optional[int] = None, +) -> TrayInfo: """Get information about tray. Args: server_url (Optional[str]): AYON server url. variant (Optional[str]): Settings variant. + timeout (Optional[int]): Timeout for tray start-up. Returns: - Optional[Dict[str, Any]]: Tray information. + TrayInfo: Tray information. """ - tray_url = get_tray_server_url(server_url, variant) - return _get_tray_information(tray_url) + return TrayInfo.new(server_url, variant, timeout) def get_tray_state( @@ -322,20 +493,8 @@ def get_tray_state( int: Tray state. """ - file_info = get_tray_file_info(server_url, variant) - if file_info is None: - return TrayState.NOT_RUNNING - - if file_info.get("started") is False: - return TrayState.STARTING - - tray_url = file_info.get("url") - info = _get_tray_information(tray_url) - if not info: - # Remove the information as the tray is not running - remove_tray_server_url(force=True) - return TrayState.NOT_RUNNING - return TrayState.RUNNING + tray_info = get_tray_information(server_url, variant) + return tray_info.state def is_tray_running( @@ -392,6 +551,7 @@ def show_message_in_tray( def make_sure_tray_is_running( ayon_url: Optional[str] = None, variant: Optional[str] = None, + username: Optional[str] = None, env: Optional[Dict[str, str]] = None ): """Make sure that tray for AYON url and variant is running. @@ -399,17 +559,20 @@ def make_sure_tray_is_running( Args: ayon_url (Optional[str]): AYON server url. variant (Optional[str]): Settings variant. + username (Optional[str]): Username under which should be tray running. env (Optional[Dict[str, str]]): Environment variables for the process. """ - state = get_tray_state(ayon_url, variant) - if state == TrayState.RUNNING: - return + tray_info = TrayInfo.new( + ayon_url, variant, wait_to_start=False + ) + if tray_info.state == TrayState.STARTING: + tray_info.wait_to_start() - if state == TrayState.STARTING: - _wait_for_starting_tray(ayon_url, variant) - state = get_tray_state(ayon_url, variant) - if state == TrayState.RUNNING: + if tray_info.state == TrayState.RUNNING: + if not username: + username = get_ayon_username() + if tray_info.get_ayon_username() == username: return args = get_ayon_launcher_args("tray", "--force") @@ -435,38 +598,51 @@ def main(force=False): Logger.set_process_name("Tray") - state = get_tray_state() - if force and state in (TrayState.RUNNING, TrayState.STARTING): - file_info = get_tray_file_info() or {} - pid = file_info.get("pid") + tray_info = TrayInfo.new(wait_to_start=False) + + file_state = tray_info.get_file_state() + if force and file_state in (TrayState.RUNNING, TrayState.STARTING): + pid = tray_info.pid if pid is not None: _kill_tray_process(pid) remove_tray_server_url(force=True) - state = TrayState.NOT_RUNNING + file_state = TrayState.NOT_RUNNING - if state == TrayState.RUNNING: - show_message_in_tray( - "Tray is already running", - "Your AYON tray application is already running." - ) - print("Tray is already running.") - return + if file_state in (TrayState.RUNNING, TrayState.STARTING): + expected_username = get_ayon_username() + username = tray_info.get_ayon_username() + # TODO probably show some message to the user??? + if expected_username != username: + pid = tray_info.pid + if pid is not None: + _kill_tray_process(pid) + remove_tray_server_url(force=True) + file_state = TrayState.NOT_RUNNING - if state == TrayState.STARTING: + if file_state == TrayState.RUNNING: + if tray_info.get_state() == TrayState.RUNNING: + show_message_in_tray( + "Tray is already running", + "Your AYON tray application is already running." + ) + print("Tray is already running.") + return + file_state = tray_info.get_file_state() + + if file_state == TrayState.STARTING: print("Tray is starting. Waiting for it to start.") - _wait_for_starting_tray() - state = get_tray_state() - if state == TrayState.RUNNING: + tray_info.wait_to_start() + file_state = tray_info.get_file_state() + if file_state == TrayState.RUNNING: print("Tray started. Exiting.") return - if state == TrayState.STARTING: + if file_state == TrayState.STARTING: print( "Tray did not start in expected time." " Killing the process and starting new." ) - file_info = get_tray_file_info() or {} - pid = file_info.get("pid") + pid = tray_info.pid if pid is not None: _kill_tray_process(pid) remove_tray_server_url(force=True)