From 8c54d1510ebdb221bcdd4b94ce890edc7837e679 Mon Sep 17 00:00:00 2001 From: JarbasAI <33701864+JarbasAl@users.noreply.github.com> Date: Tue, 5 Nov 2024 03:52:04 +0000 Subject: [PATCH 1/3] drop the gui bus upload of resources (#53) * refactor!:deprecate QML upload from bus never worked right, causes more issues than it helps * deprecate file server * docs * . * document protocol * tests * fix:phal config loading * tests * tests * rm dead code + handle homescreen edge case * fix: handle invalid GUI directories provided * fix: shell companion min version * fix: requirements.txt --- README.md | 46 +++-- ovos_gui/bus.py | 30 +-- ovos_gui/constants.py | 4 + ovos_gui/gui_file_server.py | 58 ------ ovos_gui/namespace.py | 195 ++---------------- ovos_gui/page.py | 65 ++---- .../res/gui/qt5/SYSTEM_AdditionalSettings.qml | 60 ------ protocol.md | 6 +- requirements/extras.txt | 2 +- requirements/requirements.txt | 4 +- test/unittests/test_bus.py | 72 +++---- test/unittests/test_gui_file_server.py | 15 -- test/unittests/test_namespace.py | 98 +++------ test/unittests/test_page.py | 91 -------- 14 files changed, 138 insertions(+), 608 deletions(-) create mode 100644 ovos_gui/constants.py delete mode 100644 ovos_gui/gui_file_server.py delete mode 100644 ovos_gui/res/gui/qt5/SYSTEM_AdditionalSettings.qml delete mode 100644 test/unittests/test_gui_file_server.py delete mode 100644 test/unittests/test_page.py diff --git a/README.md b/README.md index 45de849..cc560de 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,6 @@ GUI messagebus service, manages GUI state and implements the [gui protocol](./pr GUI clients (the application that actually draws the GUI) connect to this service -# Plugins - -plugins provide platform specific GUI functionality, such as determining when to show a homescreen or close a window - -you should usually not need any of these unless instructed to install it from a GUI client application - -- https://github.com/OpenVoiceOS/ovos-gui-plugin-shell-companion -- https://github.com/OpenVoiceOS/ovos-gui-plugin-mobile -- https://github.com/OpenVoiceOS/ovos-gui-plugin-plasmoid -- https://github.com/OpenVoiceOS/ovos-gui-plugin-bigscreen - # Configuration under mycroft.conf @@ -36,18 +25,8 @@ under mycroft.conf "homescreen_supported": false }, - // Optional file server support for remote clients - // "gui_file_server": true, - // "file_server_port": 8000, - - // Optional support for collecting GUI files for container support - // The ovos-gui container path for these files will be {XDG_CACHE_HOME}/ovos_gui_file_server. - // With the below configuration, the GUI client will have files prefixed with the configured host path, - // so the example below describes a situation where `{XDG_CACHE_HOME}/ovos_gui_file_server` maps - // to `/tmp/gui_files` on the filesystem where the GUI client is running. - // "gui_file_host_path": "/tmp/gui_files", - // Optionally specify a default qt version for connected clients that don't report it + // NOTE: currently only QT5 clients exist "default_qt_version": 5 }, @@ -60,3 +39,26 @@ under mycroft.conf } } ``` + +# Plugins + +plugins provide platform specific GUI functionality, such as determining when to show a homescreen or close a window + +you should usually not need any of these unless instructed to install it from a GUI client application + +- https://github.com/OpenVoiceOS/ovos-gui-plugin-shell-companion +- https://github.com/OpenVoiceOS/ovos-gui-plugin-mobile +- https://github.com/OpenVoiceOS/ovos-gui-plugin-plasmoid +- https://github.com/OpenVoiceOS/ovos-gui-plugin-bigscreen + + +# Limitations + +gui resources files are populated under `~/.cache/mycrot/ovos-gui` by skills and other OVOS components and are expectd to be accessible by GUI client applications + +This means GUI clients are expected to be running under the same machine or implement their own access to the resource files (resolving page names to uris is the client app responsibility) + +> TODO: new repository with the removed GUI file server, serve files from `~/.cache/mycrot/ovos-gui` to be handled by client apps + +In case of containers a shared volume should be mounted between ovos-gui, skills and gui client apps + diff --git a/ovos_gui/bus.py b/ovos_gui/bus.py index 0c82233..3d5b78b 100644 --- a/ovos_gui/bus.py +++ b/ovos_gui/bus.py @@ -124,22 +124,6 @@ def on_close(self): LOG.info('Closing {}'.format(id(self))) GUIWebsocketHandler.clients.remove(self) - def get_client_pages(self, namespace): - """ - Get a list of client page URLs for the given namespace - @param namespace: Namespace to get pages for - @return: list of page URIs for this GUI Client - """ - client_pages = [] - server_url = self.ns_manager.gui_file_server.url if \ - self.ns_manager.gui_file_server else \ - self.ns_manager.gui_file_host_path - for page in namespace.pages: - uri = page.get_uri(self.framework, server_url) - client_pages.append(uri) - - return client_pages - def synchronize(self): """ Upload namespaces, pages and data to the last connected client. @@ -155,11 +139,13 @@ def synchronize(self): "data": [{"skill_id": namespace.skill_id}] }) # Insert pages + # if uri (path) can not be resolved, it might exist client side + # if path doesn't exist in client side, client is responsible for resolving page by namespace/name self.send({"type": "mycroft.gui.list.insert", "namespace": namespace.skill_id, "position": 0, - "data": [{"url": url} for url in - self.get_client_pages(namespace)] + "data": [{"url": page.get_uri(self.framework), "page": page.name} + for page in namespace.pages] }) # Insert data for key, value in namespace.data.items(): @@ -260,16 +246,14 @@ def send_gui_pages(self, pages: List[GuiPage], namespace: str, @param namespace: namespace to put GuiPages in @param position: position to insert pages at """ - server_url = self.ns_manager.gui_file_server.url if \ - self.ns_manager.gui_file_server else \ - self.ns_manager.gui_file_host_path framework = self.framework - + # if uri (path) can not be resolved, it might exist client side + # if path doesn't exist in client side, client is responsible for resolving page by namespace/name message = { "type": "mycroft.gui.list.insert", "namespace": namespace, "position": position, - "data": [{"url": page.get_uri(framework, server_url)} + "data": [{"url": page.get_uri(framework), "page": page.name} for page in pages] } LOG.debug(f"Showing pages: {message['data']}") diff --git a/ovos_gui/constants.py b/ovos_gui/constants.py new file mode 100644 index 0000000..c3551f1 --- /dev/null +++ b/ovos_gui/constants.py @@ -0,0 +1,4 @@ +from ovos_config.locations import get_xdg_cache_save_path + +GUI_CACHE_PATH = get_xdg_cache_save_path('ovos_gui') + diff --git a/ovos_gui/gui_file_server.py b/ovos_gui/gui_file_server.py deleted file mode 100644 index 8b352c1..0000000 --- a/ovos_gui/gui_file_server.py +++ /dev/null @@ -1,58 +0,0 @@ -import http.server -import os -import shutil -import socketserver -from threading import Thread, Event - -from ovos_config import Configuration -from ovos_utils.file_utils import get_temp_path -from ovos_utils.log import LOG - -_HTTP_SERVER = None - - -class GuiFileHandler(http.server.SimpleHTTPRequestHandler): - def end_headers(self) -> None: - mimetype = self.guess_type(self.path) - is_file = not self.path.endswith('/') - if is_file and any([mimetype.startswith(prefix) for - prefix in ("text/", "application/octet-stream")]): - self.send_header('Content-Type', "text/plain") - self.send_header('Content-Disposition', 'inline') - super().end_headers() - - -def start_gui_http_server(qml_path: str, port: int = None): - """ - Start an http server to host GUI Resources - @param qml_path: Local file path to server - @param port: Host port to run file server on - @return: Initialized HTTP Server - """ - port = port or Configuration().get("gui", {}).get("file_server_port", 8089) - - if os.path.exists(qml_path): - shutil.rmtree(qml_path, ignore_errors=True) - os.makedirs(qml_path, exist_ok=True) - - started_event = Event() - http_daemon = Thread(target=_initialize_http_server, - args=(started_event, qml_path, port), - daemon=True) - http_daemon.start() - started_event.wait(30) - return _HTTP_SERVER - - -def _initialize_http_server(started: Event, directory: str, port: int): - global _HTTP_SERVER - os.chdir(directory) - handler = GuiFileHandler - http_server = socketserver.TCPServer(("", port), handler) - _HTTP_SERVER = http_server - _HTTP_SERVER.qml_path = directory - _HTTP_SERVER.url = \ - f"{_HTTP_SERVER.server_address[0]}:{_HTTP_SERVER.server_address[1]}" - LOG.info(f"GUI file server started: {_HTTP_SERVER.url}") - started.set() - http_server.serve_forever() diff --git a/ovos_gui/namespace.py b/ovos_gui/namespace.py index 3aeb009..5113aa8 100644 --- a/ovos_gui/namespace.py +++ b/ovos_gui/namespace.py @@ -40,13 +40,12 @@ over the GUI message bus. """ import shutil -from os import makedirs from os.path import join, dirname, isfile, exists from threading import Event, Lock, Timer from typing import List, Union, Optional, Dict from ovos_config.config import Configuration -from ovos_utils.log import LOG, log_deprecation +from ovos_utils.log import LOG from ovos_bus_client import Message, MessageBusClient from ovos_gui.bus import ( @@ -55,9 +54,8 @@ get_gui_websocket_config, send_message_to_gui, GUIWebsocketHandler ) -from ovos_gui.gui_file_server import start_gui_http_server from ovos_gui.page import GuiPage - +from ovos_gui.constants import GUI_CACHE_PATH namespace_lock = Lock() RESERVED_KEYS = ['__from', '__idle'] @@ -72,9 +70,9 @@ def _validate_page_message(message: Message) -> bool: @returns: True if request is valid, else False """ valid = ( - "page" in message.data + "page_names" in message.data and "__from" in message.data - and isinstance(message.data["page"], list) + and isinstance(message.data["page_names"], list) ) if not valid: if message.msg_type == "gui.page.show": @@ -296,7 +294,7 @@ def load_pages(self, pages: List[GuiPage], show_index: int = 0): target_page = pages[show_index] for page in pages: - if page.id not in [p.id for p in self.pages]: + if page.name not in [p.name for p in self.pages]: new_pages.append(page) self.pages.extend(new_pages) @@ -335,7 +333,7 @@ def focus_page(self, page): # set the index of the page in the self.pages list page_index = None for i, p in enumerate(self.pages): - if p.id == page.id: + if p.name == page.name: # save page index page_index = i break @@ -431,39 +429,16 @@ def __init__(self, core_bus: MessageBusClient): self.idle_display_skill = _get_idle_display_config() self.active_extension = _get_active_gui_extension() self._system_res_dir = join(dirname(__file__), "res", "gui") - self._ready_event = Event() - self.gui_file_server = None - self.gui_file_path = None # HTTP Server local path - self.gui_file_host_path = None # Docker host path - self._connected_frameworks: List[str] = list() self._init_gui_file_share() self._define_message_handlers() - @property - def _active_homescreen(self) -> str: - return Configuration().get('gui', {}).get('idle_display_skill') - def _init_gui_file_share(self): """ Initialize optional GUI file collection. if `gui_file_path` is defined, resources are assumed to be referenced outside this container. - If `gui_file_server` is defined, resources will be served via HTTP """ config = Configuration().get("gui", {}) - self.gui_file_host_path = config.get("gui_file_host_path") - - # Check for GUI file sharing via HTTP server or mounted host path - if config.get("gui_file_server") or self.gui_file_host_path: - from ovos_utils.xdg_utils import xdg_cache_home - if config.get("server_path"): - log_deprecation("`server_path` configuration is deprecated. " - "Files will always be saved to " - "XDG_CACHE_HOME/ovos_gui_file_server", "0.1.0") - self.gui_file_path = config.get("server_path") or \ - join(xdg_cache_home(), "ovos_gui_file_server") - if config.get("gui_file_server"): - self.gui_file_server = start_gui_http_server(self.gui_file_path) - self._upload_system_resources() + self._cache_system_resources() def _define_message_handlers(self): """ @@ -474,88 +449,12 @@ def _define_message_handlers(self): self.core_bus.on("gui.page.delete", self.handle_delete_page) self.core_bus.on("gui.page.delete.all", self.handle_delete_all_pages) self.core_bus.on("gui.page.show", self.handle_show_page) - self.core_bus.on("gui.page.upload", self.handle_receive_gui_pages) self.core_bus.on("gui.status.request", self.handle_status_request) self.core_bus.on("gui.value.set", self.handle_set_value) self.core_bus.on("mycroft.gui.connected", self.handle_client_connected) self.core_bus.on("gui.page_interaction", self.handle_page_interaction) self.core_bus.on("gui.page_gained_focus", self.handle_page_gained_focus) self.core_bus.on("mycroft.gui.screen.close", self.handle_namespace_global_back) - self.core_bus.on("gui.volunteer_page_upload", self.handle_gui_pages_available) - - # TODO - deprecate this, only needed for gui bus upload - # Bus is connected, check if the skills service is ready - resp = self.core_bus.wait_for_response( - Message("mycroft.skills.is_ready", - context={"source": "gui", "destination": ["skills"]})) - if resp and resp.data.get("status"): - LOG.debug("Skills service already running") - self._ready_event.set() - else: - self.core_bus.on("mycroft.skills.trained", self.handle_ready) - - def handle_ready(self, message): - self._ready_event.set() - - def handle_gui_pages_available(self, message: Message): - """ - Handle a skill or plugin advertising that it has GUI pages available to - upload. If there are connected clients, request pages for each connected - GUI framework. - @param message: `gui.volunteer_page_upload` message - """ - if not any((self.gui_file_host_path, self.gui_file_server)): - LOG.debug("No GUI file server running or host path configured") - return - - LOG.debug(f"Requesting resources for {self._connected_frameworks}") - for framework in self._connected_frameworks: - skill_id = message.data.get("skill_id") - self.core_bus.emit(message.reply("gui.request_page_upload", - {'skill_id': skill_id, - 'framework': framework}, - {"source": "gui", - "destination": ["skills", - "PHAL"]})) - - def handle_receive_gui_pages(self, message: Message): - """ - Handle GUI resources from a skill or plugin. Pages are written to - `self.server_path` which is accessible via a lightweight HTTP server and - may additionally be mounted to a host path/volume in container setups. - @param message: Message containing UI resource file contents and meta - message.data: - pages: dict page_filename to encoded bytes content; - paths are relative to the `framework` directory, so a page - for framework `all` could be `qt5/subdir/file.qml` and the - equivalent page for framework `qt5` would be - `subdir/file.qml` - framework: `all` if all GUI resources are included, else the - specific GUI framework (i.e. `qt5`, `qt6`) - __from: skill_id of module uploading GUI resources - """ - for page, contents in message.data["pages"].items(): - try: - if message.data.get("framework") == "all": - # All GUI resources are uploaded - resource_base_path = join(self.gui_file_path, - message.data['__from']) - else: - resource_base_path = join(self.gui_file_path, - message.data['__from'], - message.data.get('framework') or - "qt5") - byte_contents = bytes.fromhex(contents) - file_path = join(resource_base_path, page) - LOG.debug(f"writing UI file: {file_path}") - makedirs(dirname(file_path), exist_ok=True) - with open(file_path, 'wb+') as f: - f.write(byte_contents) - except Exception as e: - LOG.exception(f"Failed to write {page}: {e}") - if message.data["__from"] == self._active_homescreen: - # Configured home screen skill just uploaded pages, show it again - self.core_bus.emit(message.forward("homescreen.manager.show_active")) def handle_clear_namespace(self, message: Message): """ @@ -621,8 +520,7 @@ def handle_delete_page(self, message: Message): message_is_valid = _validate_page_message(message) if message_is_valid: namespace_name = message.data["__from"] - pages_to_remove = message.data.get("page_names") or \ - message.data.get("page") # backwards compat + pages_to_remove = message.data.get("page_names") LOG.debug(f"Got {namespace_name} request to delete: {pages_to_remove}") with namespace_lock: self._remove_pages(namespace_name, pages_to_remove) @@ -665,24 +563,6 @@ def _parse_persistence(persistence: Optional[Union[int, bool]]) -> \ # Defines default behavior as displaying for 30 seconds return False, 30 - def _legacy_show_page(self, message: Message) -> List[GuiPage]: - """ - Backwards-compat method to handle messages without ui_directories and - page_names. - @param message: message requesting to display pages - @return: list of GuiPage objects - """ - pages_to_show = message.data["page"] - LOG.info(f"Handling legacy page show request. pages={pages_to_show}") - - pages_to_load = list() - persist, duration = self._parse_persistence(message.data["__idle"]) - for page in pages_to_show: - name = page.split('/')[-1] - # check if persistence is type of int or bool - pages_to_load.append(GuiPage(page, name, persist, duration)) - return pages_to_load - def handle_show_page(self, message: Message): """ Handles a request to show one or more pages on the screen. @@ -695,39 +575,19 @@ def handle_show_page(self, message: Message): namespace_name = message.data["__from"] page_ids_to_show = message.data.get('page_names') - page_resource_dirs = message.data.get('ui_directories') persistence = message.data["__idle"] - show_index = message.data.get("index", None) + show_index = message.data.get("index", 0) LOG.debug(f"Got {namespace_name} request to show: {page_ids_to_show} at index: {show_index}") - if not page_resource_dirs and page_ids_to_show and \ - all((x.startswith("SYSTEM") for x in page_ids_to_show)): - page_resource_dirs = {"all": self._system_res_dir} - - if not all((page_ids_to_show, page_resource_dirs)): - LOG.warning(f"GUI resources have not yet been uploaded for namespace: {namespace_name}") - pages = self._legacy_show_page(message) - else: - pages = list() - persist, duration = self._parse_persistence(message.data["__idle"]) - for page in page_ids_to_show: - url = None - name = page - if isfile(page): - LOG.warning(f"Expected resource name but got file: {url}") - name = page.split('/')[-1] - url = f"file://{page}" - elif "://" in page: - LOG.warning(f"Expected resource name but got URI: {page}") - name = page.split('/')[-1] - url = page - pages.append(GuiPage(url, name, persist, duration, - page, namespace_name, page_resource_dirs)) + pages = list() + persist, duration = self._parse_persistence(message.data["__idle"]) + for page in page_ids_to_show: + pages.append(GuiPage(name=page, persistent=persist, duration=duration, + namespace=namespace_name)) if not pages: - LOG.error(f"Activated namespace '{namespace_name}' has no pages! " - f"Did you provide 'ui_directories' ?") + LOG.error(f"Activated namespace '{namespace_name}' has no pages!") LOG.error(f"Can't show page, bad message: {message.data}") return @@ -961,23 +821,9 @@ def handle_client_connected(self, message: Message): websocket_config = get_gui_websocket_config() port = websocket_config["base_port"] message = message.forward("mycroft.gui.port", - dict(port=port, gui_id=gui_id)) + dict(port=port, gui_id=gui_id, framework=framework)) self.core_bus.emit(message) - if self.gui_file_path or self.gui_file_host_path: - if not self._ready_event.wait(90): - LOG.warning("Not reported ready after 90s") - if framework not in self._connected_frameworks: - LOG.debug(f"Requesting page upload for {framework}") - self.core_bus.emit(Message("gui.request_page_upload", - {'framework': framework}, - {"source": "gui", - "destination": ["skills", "PHAL"]})) - - if framework not in self._connected_frameworks: - LOG.debug(f"Connecting framework: {framework}") - self._connected_frameworks.append(framework) - def handle_page_interaction(self, message: Message): """ Handles an event from the GUI indicating a page has been interacted with. @@ -1025,7 +871,8 @@ def handle_namespace_global_back(self, message: Optional[Message]): @param message: the event sent by the GUI """ if not self.active_namespaces: - LOG.error("received 'back' signal but there are no active namespaces") + LOG.debug("received 'back' signal but there are no active namespaces, attempting to show homescreen") + self.core_bus.emit(Message("homescreen.manager.show_active")) return namespace_name = self.active_namespaces[0].skill_id @@ -1046,13 +893,13 @@ def _del_namespace_in_remove_timers(self, namespace_name: str): if namespace_name in self.remove_namespace_timers: del self.remove_namespace_timers[namespace_name] - def _upload_system_resources(self): + def _cache_system_resources(self): """ Copy system GUI resources to the served file path """ - output_path = join(self.gui_file_path, "system") + output_path = f"{GUI_CACHE_PATH}/system" if exists(output_path): LOG.info(f"Removing existing system resources before updating") shutil.rmtree(output_path) shutil.copytree(self._system_res_dir, output_path) - LOG.debug(f"Copied system resources to {self.gui_file_path}") + LOG.debug(f"Copied system resources from {self._system_res_dir} to {output_path}") diff --git a/ovos_gui/page.py b/ovos_gui/page.py index b6eeef7..3420450 100644 --- a/ovos_gui/page.py +++ b/ovos_gui/page.py @@ -2,6 +2,7 @@ from typing import Union, Optional from dataclasses import dataclass from ovos_utils.log import LOG +from ovos_gui.constants import GUI_CACHE_PATH @dataclass @@ -10,28 +11,15 @@ class GuiPage: A GuiPage represents a single GUI Display within a given namespace. A Page can either be `persistent` or be removed after some `duration`. Note that a page is generally framework-independent - @param url: URI (local or network path) of the GUI Page @param name: Name of the page as shown in its namespace (could @param persistent: If True, page is displayed indefinitely @param duration: Number of seconds to display the page for @param namespace: Skill/component identifier - @param page_id: Page identifier - (file path relative to gui_framework directory with no extension) """ - url: Optional[str] # This param is left for backwards-compat. name: str persistent: bool duration: Union[int, bool] - page_id: Optional[str] = None namespace: Optional[str] = None - resource_dirs: Optional[dict] = None - - @property - def id(self): - """ - Get a unique identifier for this page. - """ - return self.page_id or self.url @staticmethod def get_file_extension(framework: str) -> str: @@ -44,45 +32,22 @@ def get_file_extension(framework: str) -> str: return "qml" return "" - def get_uri(self, framework: str = "qt5", server_url: str = None) -> str: + @property + def res_namespace(self): + return "system" if self.name.startswith("SYSTEM") else self.namespace + + def get_uri(self, framework: str = "qt5") -> Optional[str]: """ Get a valid URI for this Page. - @param framework: String GUI framework to get resources for - @param server_url: String server URL if available; this could be for a - web server (http://), or a container host path (file://) + @param framework: String GUI framework to get resources for (currently only 'qt5') @return: Absolute path to the requested resource """ - if self.url: - LOG.warning(f"Static URI: {self.url}") - return self.url - - res_filename = f"{self.page_id}.{self.get_file_extension(framework)}" - res_namespace = "system" if self.page_id.startswith("SYSTEM") else \ - self.namespace - if server_url: - if "://" not in server_url: - if server_url.startswith("/"): - LOG.debug(f"No schema in server_url, assuming 'file'") - server_url = f"file://{server_url}" - else: - LOG.debug(f"No schema in server_url, assuming 'http'") - server_url = f"http://{server_url}" - path = f"{server_url}/{res_namespace}/{framework}/{res_filename}" - LOG.info(f"Resolved server URI: {path}") + res_filename = f"{self.name}.{self.get_file_extension(framework)}" + path = f"{GUI_CACHE_PATH}/{self.res_namespace}/{framework}/{res_filename}" + LOG.debug(f"Resolved page URI: {path}") + if isfile(path): return path - base_path = self.resource_dirs.get(framework) - if not base_path and self.resource_dirs.get("all"): - file_path = join(self.resource_dirs.get('all'), framework, - res_filename) - else: - file_path = join(base_path, res_filename) - if isfile(file_path): - return file_path - # Check system resources - file_path = join(dirname(__file__), "res", "gui", framework, - res_filename) - if isfile(file_path): - return file_path - raise FileNotFoundError(f"Unable to resolve resource file for " - f"resource {res_filename} for framework " - f"{framework}") + LOG.warning(f"Unable to resolve resource file for " + f"resource {res_filename} for framework " + f"{framework}") + return None diff --git a/ovos_gui/res/gui/qt5/SYSTEM_AdditionalSettings.qml b/ovos_gui/res/gui/qt5/SYSTEM_AdditionalSettings.qml deleted file mode 100644 index 9f55318..0000000 --- a/ovos_gui/res/gui/qt5/SYSTEM_AdditionalSettings.qml +++ /dev/null @@ -1,60 +0,0 @@ -import QtQuick.Layouts 1.4 -import QtQuick 2.4 -import QtQuick.Controls 2.0 -import org.kde.kirigami 2.5 as Kirigami -import Mycroft 1.0 as Mycroft -import QtGraphicalEffects 1.12 - -Mycroft.Delegate { - id: mainLoaderView - property var pageToLoad: sessionData.state - property var idleScreenList: sessionData.idleScreenList - property var activeIdle: sessionData.selectedScreen - property var imageUrl - - function getCurrentWallpaper() { - Mycroft.MycroftController.sendRequest("ovos.wallpaper.manager.get.wallpaper", {}) - } - - Component.onCompleted: { - getCurrentWallpaper() - } - - Connections { - target: Mycroft.MycroftController - onIntentRecevied: { - if (type == "ovos.wallpaper.manager.get.wallpaper.response") { - imageUrl = data.url - } - if (type == "homescreen.wallpaper.set") { - imageUrl = data.url - } - } - } - - background: Item { - Image { - id: bgModelImage - anchors.fill: parent - source: Qt.resolvedUrl(mainLoaderView.imageUrl) - fillMode: Image.PreserveAspectCrop - } - - Rectangle { - anchors.fill: parent - color: Kirigami.Theme.backgroundColor - opacity: 0.6 - z: 1 - } - } - - contentItem: Loader { - id: rootLoader - z: 2 - } - - onPageToLoadChanged: { - console.log(sessionData.state) - rootLoader.setSource(sessionData.state + ".qml") - } -} diff --git a/protocol.md b/protocol.md index eae7a00..919c087 100644 --- a/protocol.md +++ b/protocol.md @@ -61,13 +61,17 @@ Each active skill is associated with a list of uris to the QML files of all gui Non QT GUIS get sent other file extensions such as .jsx or .html using the same message format +If a gui resource can not be resolved to a url (*url* may be `None`!), it might still exist client side, it is the clients responsibility to handle the namespace/page in that case + +> eg, a client could map namespaces/page to a remote http server url + ## Insert new page at position ```javascript { "type": "mycroft.gui.list.insert", "namespace": "mycroft.weather" "position": 2 - "values": [{"url": "file://..../currentWeather.qml"}, ...] //values must always be in array form + "values": [{"url": "file://..../currentWeather.qml", "page": "currentWeather"}, ...] //values must always be in array form } ``` diff --git a/requirements/extras.txt b/requirements/extras.txt index 893dd18..34b7479 100644 --- a/requirements/extras.txt +++ b/requirements/extras.txt @@ -1 +1 @@ -ovos-gui-plugin-shell-companion>=1.0.0,<2.0.0 +ovos-gui-plugin-shell-companion>=1.0.1,<2.0.0 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 185eda9..0523f10 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -1,5 +1,5 @@ -ovos_bus_client>=0.0.7,<1.0.0 +ovos_bus_client>=1.0.0,<2.0.0 ovos-utils>=0.0.37,<1.0.0 ovos-config>=0.0.12,<1.0.0 tornado~=6.0, >=6.0.3 -ovos-plugin-manager>=0.0.24,<1.0.0 +ovos-plugin-manager>=0.5.5,<1.0.0 \ No newline at end of file diff --git a/test/unittests/test_bus.py b/test/unittests/test_bus.py index c6ecd41..d8469e7 100644 --- a/test/unittests/test_bus.py +++ b/test/unittests/test_bus.py @@ -1,6 +1,6 @@ import unittest from unittest.mock import patch, Mock - +from typing import List import ovos_gui.bus @@ -80,6 +80,19 @@ def test_on_close(self): # TODO pass + def _get_client_pages(self, namespace) -> List[str]: + """ + Get a list of client page URLs for the given namespace + @param namespace: Namespace to get pages for + @return: list of page URIs for this GUI Client + """ + client_pages = [] + for page in namespace.pages: + # NOTE: in here page is resolved to a full URI (path) + uri = page.get_uri("qt5") + client_pages.append(uri) + return client_pages + def test_get_client_pages(self): from ovos_gui.namespace import Namespace test_namespace = Namespace("test") @@ -89,31 +102,11 @@ def test_get_client_pages(self): page_2.get_uri.return_value = "page_2_uri" test_namespace.pages = [page_1, page_2] - # Specify no host path mapping - self.handler.ns_manager.gui_file_host_path = None - - # Test no server_url - self.handler.ns_manager.gui_file_server = None - pages = self.handler.get_client_pages(test_namespace) - page_1.get_uri.assert_called_once_with(self.handler.framework, None) - page_2.get_uri.assert_called_once_with(self.handler.framework, None) + pages = self._get_client_pages(test_namespace) + page_1.get_uri.assert_called_once_with(self.handler.framework) + page_2.get_uri.assert_called_once_with(self.handler.framework) self.assertEqual(pages, ["page_1_uri", "page_2_uri"]) - # Test host path mapping - test_path = "/test/ovos-gui-file-server" - self.handler.ns_manager.gui_file_host_path = test_path - pages = self.handler.get_client_pages(test_namespace) - page_1.get_uri.assert_called_with(self.handler.framework, test_path) - page_2.get_uri.assert_called_with(self.handler.framework, test_path) - self.assertEqual(pages, ["page_1_uri", "page_2_uri"]) - - # Test with server_url - self.handler.ns_manager.gui_file_server = Mock() - self.handler.ns_manager.gui_file_server.url = "server_url" - pages = self.handler.get_client_pages(test_namespace) - page_1.get_uri.assert_called_with(self.handler.framework, "server_url") - page_2.get_uri.assert_called_with(self.handler.framework, "server_url") - self.assertEqual(pages, ["page_1_uri", "page_2_uri"]) def test_synchronize(self): # TODO @@ -134,47 +127,32 @@ def test_send_gui_pages(self): test_pos = 0 from ovos_gui.page import GuiPage - page_1 = GuiPage(None, "", False, False) + page_1 = GuiPage("p1", "", False, False) page_1.get_uri = Mock(return_value="page_1") - page_2 = GuiPage(None, "", False, False) + page_2 = GuiPage("p2", "", False, False) page_2.get_uri = Mock(return_value="page_2") - # Specify no host path mapping - self.handler.ns_manager.gui_file_host_path = None - - # Test no server_url - self.handler.ns_manager.gui_file_server = None self.handler._framework = "qt5" self.handler.send_gui_pages([page_1, page_2], test_ns, test_pos) - page_1.get_uri.assert_called_once_with("qt5", None) - page_2.get_uri.assert_called_once_with("qt5", None) + page_1.get_uri.assert_called_once_with("qt5") + page_2.get_uri.assert_called_once_with("qt5") self.handler.send.assert_called_once_with( {"type": "mycroft.gui.list.insert", "namespace": test_ns, "position": test_pos, - "data": [{"url": "page_1"}, {"url": "page_2"}]}) - - # Test host path mapping - test_path = "/test/ovos-gui-file-server" - self.handler.ns_manager.gui_file_host_path = test_path - self.handler.send_gui_pages([page_1, page_2], test_ns, test_pos) - page_1.get_uri.assert_called_with(self.handler.framework, test_path) - page_2.get_uri.assert_called_with(self.handler.framework, test_path) + "data": [{"url": "page_1", "page": "p1"}, {"url": "page_2", "page": "p2"}]}) - # Test with server_url - self.handler.ns_manager.gui_file_server = Mock() - self.handler.ns_manager.gui_file_server.url = "server_url" self.handler._framework = "qt6" test_pos = 3 self.handler.send_gui_pages([page_2, page_1], test_ns, test_pos) - page_1.get_uri.assert_called_with("qt6", "server_url") - page_2.get_uri.assert_called_with("qt6", "server_url") + page_1.get_uri.assert_called_with("qt6") + page_2.get_uri.assert_called_with("qt6") self.handler.send.assert_called_with( {"type": "mycroft.gui.list.insert", "namespace": test_ns, "position": test_pos, - "data": [{"url": "page_2"}, {"url": "page_1"}]}) + "data": [{"url": "page_2", "page": "p2"}, {"url": "page_1", "page": "p1"}]}) self.handler.send = real_send diff --git a/test/unittests/test_gui_file_server.py b/test/unittests/test_gui_file_server.py deleted file mode 100644 index bfaf81b..0000000 --- a/test/unittests/test_gui_file_server.py +++ /dev/null @@ -1,15 +0,0 @@ -import unittest - - -class TestGuiFileServer(unittest.TestCase): - def test_gui_file_handler(self): - from ovos_gui.gui_file_server import GuiFileHandler - # TODO - - def test_start_gui_http_server(self): - from ovos_gui.gui_file_server import start_gui_http_server - # TODO - - def test_initialize_http_server(self): - from ovos_gui.gui_file_server import _initialize_http_server - # TODO diff --git a/test/unittests/test_namespace.py b/test/unittests/test_namespace.py index e33a5cd..e340fef 100644 --- a/test/unittests/test_namespace.py +++ b/test/unittests/test_namespace.py @@ -13,8 +13,7 @@ # limitations under the License. # """Tests for the GUI namespace helper class.""" -from os import makedirs -from os.path import join, dirname, isdir, isfile +from os.path import join, isdir, isfile from shutil import rmtree from unittest import TestCase, mock from unittest.mock import Mock @@ -22,6 +21,7 @@ from ovos_bus_client.message import Message from ovos_utils.messagebus import FakeBus +from ovos_gui.constants import GUI_CACHE_PATH from ovos_gui.namespace import Namespace from ovos_gui.page import GuiPage @@ -64,11 +64,11 @@ def test_add(self): def test_activate(self): self.namespace.load_pages([ - GuiPage(name="foo", url="", persistent=False, duration=False), - GuiPage(name="bar", url="", persistent=False, duration=False), - GuiPage(name="foobar", url="", persistent=False, duration=False), - GuiPage(name="baz", url="", persistent=False, duration=False), - GuiPage(name="foobaz", url="", persistent=False, duration=False) + GuiPage(name="foo", persistent=False, duration=False), + GuiPage(name="bar", persistent=False, duration=False), + GuiPage(name="foobar", persistent=False, duration=False), + GuiPage(name="baz", persistent=False, duration=False), + GuiPage(name="foobaz", persistent=False, duration=False) ]) activate_namespace_message = { "type": "mycroft.session.list.move", @@ -129,9 +129,9 @@ def test_set_persistence_boolean(self): self.assertTrue(self.namespace.persistent) def test_load_pages_new(self): - self.namespace.pages = [GuiPage(name="foo", url="foo.qml", persistent=True, duration=0), - GuiPage(name="bar", url="bar.qml", persistent=False, duration=30)] - new_pages = [GuiPage(name="foobar", url="foobar.qml", persistent=False, duration=30)] + self.namespace.pages = [GuiPage(name="foo", persistent=True, duration=0), + GuiPage(name="bar", persistent=False, duration=30)] + new_pages = [GuiPage(name="foobar", persistent=False, duration=30)] load_page_message = dict( type="mycroft.events.triggered", namespace="foo", @@ -146,9 +146,9 @@ def test_load_pages_new(self): self.assertListEqual(self.namespace.pages, self.namespace.pages) def test_load_pages_existing(self): - self.namespace.pages = [GuiPage(name="foo", url="foo.qml", persistent=True, duration=0), - GuiPage(name="bar", url="bar.qml", persistent=False, duration=30)] - new_pages = [GuiPage(name="foo", url="foo.qml", persistent=True, duration=0)] + self.namespace.pages = [GuiPage(name="foo", persistent=True, duration=0), + GuiPage(name="bar", persistent=False, duration=30)] + new_pages = [GuiPage(name="foo", persistent=True, duration=0)] load_page_message = dict( type="mycroft.events.triggered", namespace="foo", @@ -171,9 +171,9 @@ def test_activate_page(self): pass def test_remove_pages(self): - self.namespace.pages = [GuiPage(name="foo", url="", persistent=False, duration=False), - GuiPage(name="bar", url="", persistent=False, duration=False), - GuiPage(name="foobar", url="", persistent=False, duration=False)] + self.namespace.pages = [GuiPage(name="foo", persistent=False, duration=False), + GuiPage(name="bar", persistent=False, duration=False), + GuiPage(name="foobar", persistent=False, duration=False)] remove_page_message = dict( type="mycroft.gui.list.remove", namespace="foo", @@ -217,18 +217,6 @@ def setUp(self): with mock.patch(PATCH_MODULE + ".create_gui_service"): self.namespace_manager = NamespaceManager(FakeBus()) - def test_init_gui_file_share(self): - # TODO - pass - - def test_handle_gui_pages_available(self): - # TODO - pass - - def test_handle_receive_gui_pages(self): - # TODO - pass - def test_handle_clear_namespace_active(self): namespace = Namespace("foo") namespace.remove = mock.Mock() @@ -264,12 +252,12 @@ def test_handle_send_event(self): def test_handle_delete_page_active_namespace(self): namespace = Namespace("foo") - namespace.pages = [GuiPage(name="bar", url="bar.qml", persistent=True, duration=0)] + namespace.pages = [GuiPage(name="bar", persistent=True, duration=0)] namespace.remove_pages = mock.Mock() self.namespace_manager.loaded_namespaces = dict(foo=namespace) self.namespace_manager.active_namespaces = [namespace] - message_data = {"__from": "foo", "page": ["bar"]} + message_data = {"__from": "foo", "page_names": ["bar"]} message = Message("gui.clear.namespace", data=message_data) self.namespace_manager.handle_delete_page(message) namespace.remove_pages.assert_called_with([0]) @@ -302,20 +290,10 @@ def test_parse_persistence(self): with self.assertRaises(ValueError): self.namespace_manager._parse_persistence(-10) - def test_legacy_show_page(self): - message = Message("gui.page.show", data={"__from": "foo", - "__idle": 10, - "page": ["bar", "test/baz"]}) - pages = self.namespace_manager._legacy_show_page(message) - self.assertEqual(pages, [GuiPage('bar', 'bar', False, 10), - GuiPage('test/baz', 'baz', False, 10)]) - def test_handle_show_page(self): - real_legacy_show_page = self.namespace_manager._legacy_show_page real_activate_namespace = self.namespace_manager._activate_namespace real_load_pages = self.namespace_manager._load_pages real_update_persistence = self.namespace_manager._update_namespace_persistence - self.namespace_manager._legacy_show_page = Mock(return_value=["pages"]) self.namespace_manager._activate_namespace = Mock() self.namespace_manager._load_pages = Mock() self.namespace_manager._update_namespace_persistence = Mock() @@ -323,11 +301,12 @@ def test_handle_show_page(self): # Legacy message message = Message("gui.page.show", data={"__from": "foo", "__idle": 10, - "page": ["bar", "test/baz"]}) + "page_names": ["bar", "test/baz"]}) self.namespace_manager.handle_show_page(message) - self.namespace_manager._legacy_show_page.assert_called_once_with(message) self.namespace_manager._activate_namespace.assert_called_with("foo") - self.namespace_manager._load_pages.assert_called_with(["pages"], None) + self.namespace_manager._load_pages.assert_called_with( + [GuiPage(name='bar', persistent=False, duration=10, namespace='foo'), + GuiPage(name='test/baz', persistent=False, duration=10, namespace='foo')], 0) self.namespace_manager._update_namespace_persistence. \ assert_called_with(10) @@ -336,15 +315,11 @@ def test_handle_show_page(self): message = Message("test", {"__from": "skill", "__idle": False, "index": 1, - "page": ["/gui/page_1", "/gui/test/page_2"], "page_names": ["page_1", "test/page_2"], "ui_directories": ui_directories}) self.namespace_manager.handle_show_page(message) - expected_page1 = GuiPage(None, "page_1", False, 0, "page_1", "skill", - ui_directories) - expected_page2 = GuiPage(None, "test/page_2", False, 0, "test/page_2", - "skill", ui_directories) - self.namespace_manager._legacy_show_page.assert_called_once() + expected_page1 = GuiPage("page_1", False, 0, "skill") + expected_page2 = GuiPage("test/page_2", False, 0, "skill") self.namespace_manager._activate_namespace.assert_called_with("skill") self.namespace_manager._load_pages.assert_called_with([expected_page1, expected_page2], @@ -359,10 +334,7 @@ def test_handle_show_page(self): "page": ["/gui/SYSTEM_TextFrame.qml"], "page_names": ["SYSTEM_TextFrame"]}) self.namespace_manager.handle_show_page(message) - expected_page = GuiPage(None, "SYSTEM_TextFrame", True, 0, - "SYSTEM_TextFrame", "skill_no_res", - {"all": self.namespace_manager._system_res_dir}) - self.namespace_manager._legacy_show_page.assert_called_once() + expected_page = GuiPage("SYSTEM_TextFrame", True, 0, "skill_no_res") self.namespace_manager._activate_namespace.assert_called_with( "skill_no_res") self.namespace_manager._load_pages.assert_called_with([expected_page], @@ -371,7 +343,6 @@ def test_handle_show_page(self): assert_called_with(True) # TODO: Test page_names with files and URIs - self.namespace_manager._legacy_show_page = real_legacy_show_page self.namespace_manager._activate_namespace = real_activate_namespace self.namespace_manager._load_pages = real_load_pages self.namespace_manager._update_namespace_persistence = \ @@ -455,16 +426,15 @@ def test_del_namespace_in_remove_timers(self): pass def test_upload_system_resources(self): - test_dir = join(dirname(__file__), "upload_test") - makedirs(test_dir, exist_ok=True) - self.namespace_manager.gui_file_path = test_dir - self.namespace_manager._upload_system_resources() - self.assertTrue(isdir(join(test_dir, "system", "qt5"))) - self.assertTrue(isfile(join(test_dir, "system", "qt5", + p = f"{GUI_CACHE_PATH}/system" + rmtree(p) + self.namespace_manager._cache_system_resources() + self.assertTrue(isdir(join(p, "qt5"))) + self.assertTrue(isfile(join(p, "qt5", "SYSTEM_TextFrame.qml"))) # Test repeated copy doesn't raise any exception - self.namespace_manager._upload_system_resources() - self.assertTrue(isdir(join(test_dir, "system", "qt5"))) - self.assertTrue(isfile(join(test_dir, "system", "qt5", + self.namespace_manager._cache_system_resources() + self.assertTrue(isdir(join(p, "qt5"))) + self.assertTrue(isfile(join(p, "qt5", "SYSTEM_TextFrame.qml"))) - rmtree(test_dir) + rmtree(p) diff --git a/test/unittests/test_page.py b/test/unittests/test_page.py deleted file mode 100644 index fe3abec..0000000 --- a/test/unittests/test_page.py +++ /dev/null @@ -1,91 +0,0 @@ -import unittest -from os.path import join, dirname, isfile -from ovos_gui.page import GuiPage - - -class TestGuiPage(unittest.TestCase): - def test_gui_page_legacy(self): - uri = __file__ - name = "test" - persistent = True - duration = 0 - page = GuiPage(uri, name, persistent, duration) - self.assertEqual(page.url, uri) - self.assertEqual(page.name, name) - self.assertEqual(page.persistent, persistent) - self.assertEqual(page.duration, 0) - self.assertEqual(page.id, page.url) - self.assertEqual(page.get_uri(), page.url) - self.assertEqual(page.get_uri("qt6", "http://0.0.0.0:80"), page.url) - self.assertEqual(page.get_uri("qt6", "/var/www/app"), page.url) - - def test_gui_page_from_server(self): - name = "test_page" - persistent = False - duration = 60 - page_id = "test_page" - namespace = "skill.test" - - page = GuiPage(None, name, persistent, duration, page_id, namespace) - qt5 = page.get_uri(server_url="localhost:80") - self.assertEqual(qt5, - f"http://localhost:80/{namespace}/qt5/{page_id}.qml") - - qt6 = page.get_uri(server_url="https://files.local") - self.assertEqual(qt6, - f"https://files.local/{namespace}/qt5/{page_id}.qml") - - def test_gui_page_from_mapped_path(self): - name = "test_page" - persistent = False - duration = 60 - page_id = "test_page" - namespace = "skill.test" - - page = GuiPage(None, name, persistent, duration, page_id, namespace) - qt5 = page.get_uri(server_url="/path/for/gui/client") - self.assertEqual(qt5, - f"file:///path/for/gui/client/{namespace}/qt5/{page_id}.qml") - - qt6 = page.get_uri(server_url="/path/for/gui/client") - self.assertEqual(qt6, - f"file:///path/for/gui/client/{namespace}/qt5/{page_id}.qml") - - def test_gui_page_from_local_path(self): - name = "test" - persistent = True - duration = True - page_id = "test" - namespace = "skill.test" - res_dirs = {"all": join(dirname(__file__), "mock_data", "gui")} - # Modern GUI File organization - page = GuiPage(None, name, persistent, duration, page_id, namespace, - res_dirs) - qt5 = page.get_uri("qt5") - qt6 = page.get_uri("qt6") - self.assertTrue(isfile(qt5)) - self.assertTrue(isfile(qt6)) - - qt6_only_name = "six" - qt6_page = GuiPage(None, qt6_only_name, persistent, duration, - qt6_only_name, namespace, res_dirs) - with self.assertRaises(FileNotFoundError): - qt6_page.get_uri("qt5") - qt6 = qt6_page.get_uri("qt6") - self.assertTrue(isfile(qt6)) - - # System page - system_page = GuiPage(None, "SYSTEM_ImageFrame", False, 30, - "SYSTEM_ImageFrame", namespace, res_dirs) - qt5 = system_page.get_uri("qt5") - self.assertTrue(isfile(qt5)) - - # Legacy GUI File organization - res_dirs = {"qt5": join(dirname(__file__), "mock_data", "gui", "qt5"), - "qt6": join(dirname(__file__), "mock_data", "gui", "qt6")} - page = GuiPage(None, name, persistent, duration, page_id, namespace, - res_dirs) - qt5 = page.get_uri("qt5") - qt6 = page.get_uri("qt6") - self.assertTrue(isfile(qt5)) - self.assertTrue(isfile(qt6)) From 40046f2d6c63eb19693462cc1e4930a4950f08a4 Mon Sep 17 00:00:00 2001 From: JarbasAl Date: Tue, 5 Nov 2024 03:52:19 +0000 Subject: [PATCH 2/3] Increment Version to 1.0.0a1 --- ovos_gui/version.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ovos_gui/version.py b/ovos_gui/version.py index e39ffda..cd3bcb6 100644 --- a/ovos_gui/version.py +++ b/ovos_gui/version.py @@ -1,6 +1,6 @@ # START_VERSION_BLOCK -VERSION_MAJOR = 0 -VERSION_MINOR = 2 -VERSION_BUILD = 3 -VERSION_ALPHA = 0 +VERSION_MAJOR = 1 +VERSION_MINOR = 0 +VERSION_BUILD = 0 +VERSION_ALPHA = 1 # END_VERSION_BLOCK From a1ffe1bc7e8d6a3d475250a9dbc0426690c3352c Mon Sep 17 00:00:00 2001 From: JarbasAl Date: Tue, 5 Nov 2024 03:52:50 +0000 Subject: [PATCH 3/3] Update Changelog --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f99f2..d7459fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,12 @@ # Changelog -## [0.2.3a1](https://github.com/OpenVoiceOS/ovos-gui/tree/0.2.3a1) (2024-10-21) +## [1.0.0a1](https://github.com/OpenVoiceOS/ovos-gui/tree/1.0.0a1) (2024-11-05) -[Full Changelog](https://github.com/OpenVoiceOS/ovos-gui/compare/0.2.2...0.2.3a1) +[Full Changelog](https://github.com/OpenVoiceOS/ovos-gui/compare/0.2.3...1.0.0a1) -**Merged pull requests:** +**Breaking changes:** -- fix:no wait for ready [\#56](https://github.com/OpenVoiceOS/ovos-gui/pull/56) ([JarbasAl](https://github.com/JarbasAl)) +- drop the gui bus upload of resources [\#53](https://github.com/OpenVoiceOS/ovos-gui/pull/53) ([JarbasAl](https://github.com/JarbasAl))