From f61a30726fb27fe136fda9afa73011af01a31da8 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 6 Aug 2026 23:54:23 +0200 Subject: [PATCH] Add app loading and window manager modules --- Modules/app-esp32-module/CMakeLists.txt | 11 + Modules/app-esp32-module/devicetree.yaml | 4 + .../include/app_esp32/module.h | 12 + .../source/app_esp32_loader_service.cpp | 141 +++++++ Modules/app-esp32-module/source/module.cpp | 30 ++ Modules/app-module/CMakeLists.txt | 12 + Modules/app-module/devicetree.yaml | 3 + Modules/app-module/include/app/event.h | 103 +++++ Modules/app-module/include/app/install.h | 50 +++ Modules/app-module/include/app/instance.h | 19 + Modules/app-module/include/app/loader.h | 59 +++ Modules/app-module/include/app/location.h | 20 + Modules/app-module/include/app/manager.h | 135 ++++++ Modules/app-module/include/app/manifest.h | 41 ++ Modules/app-module/include/app/metadata.h | 59 +++ Modules/app-module/include/app/module.h | 12 + .../private/app/private/app_ledger.h | 54 +++ .../private/app_metadata_parsing_internal.h | 30 ++ .../private/app/private/app_scheduler.h | 43 ++ Modules/app-module/source/app_install.cpp | 386 ++++++++++++++++++ .../app-module/source/app_internal_loader.cpp | 48 +++ .../source/app_metadata_parsing.cpp | 157 +++++++ .../source/app_metadata_parsing_v1.cpp | 95 +++++ .../source/app_metadata_parsing_v2.cpp | 95 +++++ Modules/app-module/source/app_scheduler.cpp | 190 +++++++++ Modules/app-module/source/event.cpp | 116 ++++++ Modules/app-module/source/manager.cpp | 199 +++++++++ Modules/app-module/source/module.cpp | 30 ++ Modules/lvgl-window-manager/CMakeLists.txt | 11 + Modules/lvgl-window-manager/devicetree.yaml | 2 + .../include/lvgl_window_manager/module.h | 12 + .../lvgl_window_manager/window_manager.h | 110 +++++ Modules/lvgl-window-manager/source/module.cpp | 19 + .../source/window_manager.cpp | 279 +++++++++++++ 34 files changed, 2587 insertions(+) create mode 100644 Modules/app-esp32-module/CMakeLists.txt create mode 100644 Modules/app-esp32-module/devicetree.yaml create mode 100644 Modules/app-esp32-module/include/app_esp32/module.h create mode 100644 Modules/app-esp32-module/source/app_esp32_loader_service.cpp create mode 100644 Modules/app-esp32-module/source/module.cpp create mode 100644 Modules/app-module/CMakeLists.txt create mode 100644 Modules/app-module/devicetree.yaml create mode 100644 Modules/app-module/include/app/event.h create mode 100644 Modules/app-module/include/app/install.h create mode 100644 Modules/app-module/include/app/instance.h create mode 100644 Modules/app-module/include/app/loader.h create mode 100644 Modules/app-module/include/app/location.h create mode 100644 Modules/app-module/include/app/manager.h create mode 100644 Modules/app-module/include/app/manifest.h create mode 100644 Modules/app-module/include/app/metadata.h create mode 100644 Modules/app-module/include/app/module.h create mode 100644 Modules/app-module/private/app/private/app_ledger.h create mode 100644 Modules/app-module/private/app/private/app_metadata_parsing_internal.h create mode 100644 Modules/app-module/private/app/private/app_scheduler.h create mode 100644 Modules/app-module/source/app_install.cpp create mode 100644 Modules/app-module/source/app_internal_loader.cpp create mode 100644 Modules/app-module/source/app_metadata_parsing.cpp create mode 100644 Modules/app-module/source/app_metadata_parsing_v1.cpp create mode 100644 Modules/app-module/source/app_metadata_parsing_v2.cpp create mode 100644 Modules/app-module/source/app_scheduler.cpp create mode 100644 Modules/app-module/source/event.cpp create mode 100644 Modules/app-module/source/manager.cpp create mode 100644 Modules/app-module/source/module.cpp create mode 100644 Modules/lvgl-window-manager/CMakeLists.txt create mode 100644 Modules/lvgl-window-manager/devicetree.yaml create mode 100644 Modules/lvgl-window-manager/include/lvgl_window_manager/module.h create mode 100644 Modules/lvgl-window-manager/include/lvgl_window_manager/window_manager.h create mode 100644 Modules/lvgl-window-manager/source/module.cpp create mode 100644 Modules/lvgl-window-manager/source/window_manager.cpp diff --git a/Modules/app-esp32-module/CMakeLists.txt b/Modules/app-esp32-module/CMakeLists.txt new file mode 100644 index 000000000..f1ff7b929 --- /dev/null +++ b/Modules/app-esp32-module/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*") + +tactility_add_module(app-esp32-module + SRCS ${SOURCE_FILES} + INCLUDE_DIRS include/ + REQUIRES TactilityKernel app-module service-module elf_loader +) diff --git a/Modules/app-esp32-module/devicetree.yaml b/Modules/app-esp32-module/devicetree.yaml new file mode 100644 index 000000000..4082c5863 --- /dev/null +++ b/Modules/app-esp32-module/devicetree.yaml @@ -0,0 +1,4 @@ +dependencies: + - TactilityKernel + - Modules/app-module + - Modules/service-module diff --git a/Modules/app-esp32-module/include/app_esp32/module.h b/Modules/app-esp32-module/include/app_esp32/module.h new file mode 100644 index 000000000..bc0b0443b --- /dev/null +++ b/Modules/app-esp32-module/include/app_esp32/module.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module app_esp32_module; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp new file mode 100644 index 000000000..ec3e6e1cd --- /dev/null +++ b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "../../../TactilityKernel/include/tactility/error.h" +#include "../../../TactilityKernel/include/tactility/filesystem/file_mutex.h" +#include "../../app-module/include/app/loader.h" +#include "../../app-module/include/app/location.h" + + +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace { + +/** load()-allocated state, passed back through run()/unload(). */ +struct Esp32AppRuntime { + esp_elf_t elf {}; + uint8_t* file_data = nullptr; +}; + +error_t read_file(const char* path, uint8_t** out_data, size_t* out_size) { + FileMutex mutex; + file_mutex_get(&mutex, path); + file_mutex_lock(&mutex); + + FILE* file = fopen(path, "rb"); + if (file == nullptr) { + file_mutex_unlock(&mutex); + return ERROR_NOT_FOUND; + } + + fseek(file, 0, SEEK_END); + long size = ftell(file); + fseek(file, 0, SEEK_SET); + if (size <= 0) { + fclose(file); + file_mutex_unlock(&mutex); + return ERROR_RESOURCE; + } + + auto* data = static_cast(malloc(static_cast(size))); + if (data == nullptr) { + fclose(file); + file_mutex_unlock(&mutex); + return ERROR_OUT_OF_MEMORY; + } + + size_t read = fread(data, 1, static_cast(size), file); + fclose(file); + file_mutex_unlock(&mutex); + + if (read != static_cast(size)) { + free(data); + return ERROR_RESOURCE; + } + + *out_data = data; + *out_size = static_cast(size); + return ERROR_NONE; +} + +error_t api_load(AppLocation location, AppRuntime* out_runtime) { + auto* runtime = new (std::nothrow) Esp32AppRuntime(); + if (runtime == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + + if (location.type != APP_LOCATION_PATH) { + return ERROR_NOT_SUPPORTED; + } + + size_t size = 0; + error_t read_result = read_file(static_cast(location.location), &runtime->file_data, &size); + if (read_result != ERROR_NONE) { + delete runtime; + return read_result; + } + + if (esp_elf_init(&runtime->elf) != ESP_OK) { + free(runtime->file_data); + delete runtime; + return ERROR_RESOURCE; + } + + if (esp_elf_relocate(&runtime->elf, runtime->file_data) != 0) { + esp_elf_deinit(&runtime->elf); + free(runtime->file_data); + delete runtime; + return ERROR_RESOURCE; + } + + *out_runtime = runtime; + return ERROR_NONE; +} + +int32_t api_run(AppRuntime runtime_ptr, uint32_t /*app_instance_id*/, int argc, char* argv[]) { + auto* runtime = static_cast(runtime_ptr); + // A side-loaded ELF's own main() only ever gets a real argc/argv from esp_elf_request()'s + // fixed signature - there's no slot for app_instance_id there, and side-loaded apps don't + // need one yet. + return esp_elf_request(&runtime->elf, 0, argc, argv); +} + +void api_unload(AppRuntime runtime_ptr) { + auto* runtime = static_cast(runtime_ptr); + esp_elf_deinit(&runtime->elf); + free(runtime->file_data); + delete runtime; +} + +AppLoaderApi loader_api = { + .load = api_load, + .run = api_run, + .unload = api_unload, +}; + +void* create_service(const ServiceManifest*) { + return &loader_api; +} + +void destroy_service(const ServiceManifest*, void*) { +} + +} // namespace + +extern ServiceManifest loader_service_manifest = { + .id = APP_LOADER_PATH_SERVICE_ID, + .create_service = create_service, + .destroy_service = destroy_service, + .on_start = nullptr, + .on_stop = nullptr, +}; diff --git a/Modules/app-esp32-module/source/module.cpp b/Modules/app-esp32-module/source/module.cpp new file mode 100644 index 000000000..cfd0e0160 --- /dev/null +++ b/Modules/app-esp32-module/source/module.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include +#include + +extern "C" { + +extern ServiceManifest loader_service_manifest; + +static error_t start() { + return service_manager_add(&loader_service_manifest, /*auto_start=*/true); +} + +static error_t stop() { + return service_manager_remove(loader_service_manifest.id); +} + +Module app_esp32_module = { + .name = "app-esp32", + .start = start, + .stop = stop, + .drivers = nullptr, + .symbols = nullptr, + .internal = nullptr +}; + +} diff --git a/Modules/app-module/CMakeLists.txt b/Modules/app-module/CMakeLists.txt new file mode 100644 index 000000000..916ed7039 --- /dev/null +++ b/Modules/app-module/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*") + +tactility_add_module(app-module + SRCS ${SOURCE_FILES} + PRIV_INCLUDE_DIRS private/ + INCLUDE_DIRS include/ + REQUIRES TactilityKernel service-module minitar +) diff --git a/Modules/app-module/devicetree.yaml b/Modules/app-module/devicetree.yaml new file mode 100644 index 000000000..0bd5002d1 --- /dev/null +++ b/Modules/app-module/devicetree.yaml @@ -0,0 +1,3 @@ +dependencies: + - TactilityKernel + - Modules/service-module diff --git a/Modules/app-module/include/app/event.h b/Modules/app-module/include/app/event.h new file mode 100644 index 000000000..090d0ffc3 --- /dev/null +++ b/Modules/app-module/include/app/event.h @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Identifies the kind of app-lifecycle event delivered through app_event_await(). */ +enum AppEventType { + APP_EVENT_RESULT, // struct AppResultEventData + APP_EVENT_CLOSE, // no data - terminate now, permanently +}; + +/** Data for APP_EVENT_RESULT. */ +struct AppResultEventData { + uint32_t launch_id; + /** The child app instance's own AppMainFn/AppLoaderApi::run() return value. By convention: + * 0 = Ok, 1 = Cancelled, 2 = Error. Apps that need to hand back more than this (e.g. picked + * text, a path) expose their own "get last result" getter instead - see e.g. + * tt::app::inputdialog::getLastText(). */ + int32_t result; +}; + +struct AppEvent { + enum AppEventType type; + /** Stamped by app_event_emit(); any value passed in by the caller is ignored. */ + uint64_t timestamp; + /** Valid only when type == APP_EVENT_RESULT. */ + struct AppResultEventData result; +}; + +/** + * Number of events that can be queued per subscription before app_event_emit() starts + * returning ERROR_RESOURCE (dropping the newest event, preserving FIFO order of what's + * already queued). Deliberately generous: app-module's scheduler is the only emitter and it + * serializes app-lifecycle transitions, so a given app can't realistically receive events + * faster than the scheduler produces them one at a time. + */ +#define APP_EVENT_QUEUE_CAPACITY 4 + +/** + * Caller-owned subscription node. Unlike TactilityKernel's system_event poll subscription + * (which coalesces to the latest value), this queues events by value (FIFO) since dropping an + * APP_EVENT_RESULT would be unacceptable. + * @warning Fields other than `app_instance_id` are for internal use only; do not read or write + * them directly. + */ +struct AppEventSubscription { + /** The app instance this subscription receives events for; set by the caller before app_event_subscribe(). */ + uint32_t app_instance_id; + + TaskHandle_t task; + + struct AppEvent queue[APP_EVENT_QUEUE_CAPACITY]; + uint8_t head; + uint8_t count; + + struct AppEventSubscription* next; +}; + +/** + * Register a subscription for events addressed to @a sub->app_instance_id. + * @warning Does not work in ISR context. + * @param[in,out] sub subscription to register; caller sets @a sub->app_instance_id beforehand, + * owns the storage, and must keep it alive (and stationary) until unsubscribed + * @return ERROR_NONE on success + */ +error_t app_event_subscribe(struct AppEventSubscription* sub); + +/** + * Remove a previously registered subscription. + * @warning Does not work in ISR context. + * @return ERROR_NONE on success, ERROR_NOT_FOUND if no matching subscription exists + */ +error_t app_event_unsubscribe(struct AppEventSubscription* sub); + +/** + * Deliver @a event to every subscription registered for @a app_instance_id (normally exactly one). + * @warning Does not work in ISR context. + * @retval ERROR_NONE delivered to at least one subscription + * @retval ERROR_NOT_FOUND no subscription is registered for @a app_instance_id + * @retval ERROR_RESOURCE at least one matching subscription's queue was full; the event was + * dropped for that subscription (still delivered to any other matching subscription) + */ +error_t app_event_emit(uint32_t app_instance_id, const struct AppEvent* event); + +/** + * Pop the next event for @a sub, blocking up to @a timeout if the queue is currently empty. + * @retval ERROR_NONE @a out_event was filled + * @retval ERROR_TIMEOUT no event arrived before the timeout elapsed + */ +error_t app_event_await(struct AppEventSubscription* sub, struct AppEvent* out_event, TickType_t timeout); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/install.h b/Modules/app-module/include/app/install.h new file mode 100644 index 000000000..d6819f072 --- /dev/null +++ b/Modules/app-module/include/app/install.h @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Computes the install directory for @a app_id (does not check whether anything is actually + * installed there). + * @param[out] path always NULL-terminated on return, even on failure (empty string if + * @a path_size == 0 - nothing is written in that case; otherwise at least "" is written) + * @retval ERROR_NONE on success + * @retval ERROR_BUFFER_OVERFLOW @a path_size is too small to hold the path (including the + * NULL terminator) + * @retval ERROR_NOT_FOUND the app install location isn't available (e.g. no SD card) + */ +error_t app_get_install_path(const char* app_id, char* path, size_t path_size); + +/** + * Installs an app from a tarball at @a source_path: extracts it into the app install directory, + * parses the extracted manifest.properties (see app/metadata.h) to determine its id, then + * registers it with app_manager_add() as an AppLocation{APP_LOCATION_PATH, } app. + * If an app with the same id is already installed (via a previous app_install() call), it is + * uninstalled first - stopped if running, its old install directory removed - before the new + * one takes its place. + * @param[in] source_path path to a tar file containing the app (must have manifest.properties + * at its root) + * @retval ERROR_NONE on success + * @retval ERROR_NOT_FOUND @a source_path doesn't exist / can't be read + * @retval ERROR_INVALID_ARGUMENT the tarball has no valid manifest.properties at its root + */ +error_t app_install(const char* source_path); + +/** + * Uninstalls a previously app_install()-ed app: stops it if currently running, deletes its + * install directory, and unregisters it (app_manager_remove()). + * @param[in] app_id the id the app was installed under (AppMetadata::app_id) + * @retval ERROR_NONE on success + * @retval ERROR_NOT_FOUND no such app was installed via app_install() + */ +error_t app_uninstall(const char* app_id); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/instance.h b/Modules/app-module/include/app/instance.h new file mode 100644 index 000000000..abd95f346 --- /dev/null +++ b/Modules/app-module/include/app/instance.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +/** Lifecycle state of a running (or previously running) app instance. Every app instance owns + * its own task for its entire lifetime - there is no "saved, task given up" state. */ +typedef enum { + APP_INSTANCE_STATE_STARTING, + APP_INSTANCE_STATE_ACTIVE, + APP_INSTANCE_STATE_STOPPING, + APP_INSTANCE_STATE_STOPPED, +} AppInstanceState; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/loader.h b/Modules/app-module/include/app/loader.h new file mode 100644 index 000000000..1ca8d0336 --- /dev/null +++ b/Modules/app-module/include/app/loader.h @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include "location.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/** service-module id the AppLoaderApi implementation for AppManifest::location.type == + * APP_LOCATION_MEMORY must register under. Implemented by app-module itself (source/app_internal_loader.cpp). */ +#define APP_LOADER_MEMORY_SERVICE_ID "app-loader-memory" + +/** service-module id the AppLoaderApi implementation for AppManifest::location.type == + * APP_LOCATION_PATH must register under. Implemented by a platform module (e.g. app-esp32-module). */ +#define APP_LOADER_PATH_SERVICE_ID "app-loader-path" + +/** + * Entry point signature for an APP_LOCATION_MEMORY app: a function linked directly into this + * firmware binary. Called on the dedicated task app-module's scheduler spawns for this instance, + * blocking for the app's whole lifetime - same contract as an external app's main(), plus + * @a app_instance_id identifying this running instance (use it with + * app_event_subscribe()/window_manager_create()/app_manager_finish()/etc.). + * AppManifest::location.location holds this cast to void*. + */ +typedef int32_t (*AppMainFn)(uint32_t app_instance_id, int argc, char* argv[]); + +typedef void* AppRuntime; + +/** + * Pluggable mechanism for loading and executing an app. + */ +struct AppLoaderApi { + /** + * Prepares an app instance for execution (e.g. read + relocate its binary). + * @param[in] location the location to load the elf from + * @param[out] out_runtime opaque handle to whatever load() allocated; passed back to run()/unload() + */ + error_t (*load)(struct AppLocation location, AppRuntime* out_runtime); + + /** + * Blocking: runs the app to completion. + * @param[in] runtime handle produced by load() + * @param[in] app_instance_id the running instance's id + * @param[in] argc the amount of arguments in @a argv + * @param[in] argv the array of string pointers (can be NULL) + */ + int32_t (*run)(AppRuntime runtime, uint32_t app_instance_id, int argc, char* argv[]); + + /** Releases whatever load() allocated. Called after run() returns. */ + void (*unload)(AppRuntime runtime); +}; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/location.h b/Modules/app-module/include/app/location.h new file mode 100644 index 000000000..721c07772 --- /dev/null +++ b/Modules/app-module/include/app/location.h @@ -0,0 +1,20 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +enum AppLocationType { + APP_LOCATION_MEMORY, + APP_LOCATION_PATH, +}; + +struct AppLocation { + enum AppLocationType type; + /** Meaning depends on `type`; see AppLocationType. */ + void* location; +}; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/manager.h b/Modules/app-module/include/app/manager.h new file mode 100644 index 000000000..3a18ad62c --- /dev/null +++ b/Modules/app-module/include/app/manager.h @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Identifies a running (or previously running) app instance. 0 is never a valid instance id. */ +typedef uint32_t AppInstanceId; + +/** + * Register an app manifest. + * @retval ERROR_INVALID_ARGUMENT a manifest with the same id is already registered + * @retval ERROR_NONE on success + */ +error_t app_manager_add(const struct AppManifest* manifest); + +/** + * Unregister a previously-added manifest. + * @retval ERROR_NOT_FOUND no manifest with this id is registered + * @retval ERROR_NONE on success + */ +error_t app_manager_remove(const char* id); + +/** @return the manifest, or NULL if not found. */ +const struct AppManifest* app_manager_find_manifest(const char* id); + +/** + * Calls @a visitor once for every registered manifest (e.g. for AppList/Settings to enumerate + * apps to show). Iteration order is unspecified. Safe to call app_manager_add()/_remove() from + * within @a visitor is NOT guaranteed - do not mutate the registry from inside the callback. + */ +typedef void (*AppManifestVisitorFn)(const struct AppManifest* manifest, void* context); +void app_manager_for_each_manifest(AppManifestVisitorFn visitor, void* context); + +/** + * Starts a new instance of the app registered under @a id. Every app instance gets its own + * dedicated task for its entire lifetime - starting an app never asks any other app to give up + * its task, and multiple instances (of the same or different apps) can be Active at once. + * @param[in] id the manifest id to start + * @param[out] out_app_instance_id the id of the new app instance + * @retval ERROR_NOT_FOUND no manifest with this id is registered, or no AppLoaderApi is registered + * @retval ERROR_NONE on success + */ +error_t app_manager_start(const char* id, AppInstanceId* out_app_instance_id); + +/** + * Same as app_manager_start(), but also passes @a argc/@a argv to the new instance's own main + * function (see app/loader.h's AppMainFn) - modelled on a C program's main(argc, argv). For + * regular (non-modal) navigations that need to pass data to the target app (e.g. "show details + * for this app id") without expecting a result back. + * @param[in] argv @a argc strings; app-module makes its own deep copy before returning, so + * @a argv and the strings it points to may be freed/go out of scope immediately after this call + * returns (e.g. safe to pass a stack-local array of a caller's own std::string::c_str()s). + */ +error_t app_manager_start_with_parameters(const char* id, int argc, const char* const argv[], AppInstanceId* out_app_instance_id); + +/** + * Starts @a id as a modal child of @a parent_instance_id, for the purpose of receiving a + * result. The parent keeps running (window_manager's own multi-window stack handles burying its + * window while the child is shown). + * + * When the child's task exits, an APP_EVENT_RESULT is delivered to @a parent_instance_id - + * result is whatever the child's AppMainFn/AppLoaderApi::run() returned - unless + * @a parent_instance_id is 0, in which case no result is delivered (fire-and-forget, for + * callers with no app_instance_id of their own). The parent is then responsible for calling + * app_manager_stop() on the child's instance id to fully reap it. Children that need to hand + * back more than an int32_t (e.g. picked text, a path) expose their own "get last result" + * getter for the parent to call after receiving the event - see e.g. + * tt::app::inputdialog::getLastText(). + * @param[in] argv @a argc strings; app-module makes its own deep copy before returning (same as + * app_manager_start_with_parameters()), so @a argv and the strings it points to may be + * freed/go out of scope immediately after this call returns. + * @retval ERROR_NOT_FOUND no manifest with this id is registered, or no AppLoaderApi is registered + * @retval ERROR_NONE on success + */ +error_t app_manager_start_for_result(const char* id, AppInstanceId parent_instance_id, int argc, const char* const argv[], AppInstanceId* out_app_instance_id); + +/** + * Stop an app instance permanently. Emits APP_EVENT_CLOSE and bound-waits for its task to exit + * if it was running. + * @warning Must not be called from the instance's own task (it bound-waits via thread_join(), + * which asserts against joining yourself) - an app closing itself must call app_manager_finish() + * instead, right before returning from its own AppMainFn/AppLoaderApi::run(). + */ +error_t app_manager_stop(AppInstanceId app_instance_id); + +/** + * Called by an app instance, from its own task, right before it returns in response to + * APP_EVENT_CLOSE - whether that close was self-initiated (e.g. its own back button) or came + * from someone else. Marks this instance Stopped immediately (rather than waiting for its task + * to actually exit) so app_manager_get_state()/app_manager_get_topmost_instance_id() reflect the + * closure as soon as the app has decided to close, not just once its task has fully unwound. + * @warning Does not join or free this instance's own task/ledger entry (can't - this runs on + * that very task); those are cleaned up on a later app_manager_stop() call, same as any + * self-terminating instance. + */ +error_t app_manager_finish(AppInstanceId app_instance_id); + +/** @return the instance's current state, or APP_INSTANCE_STATE_STOPPED if the id is unknown. */ +AppInstanceState app_manager_get_state(AppInstanceId app_instance_id); + +/** + * @param[out] out_app_instance_id set to the instance id of the topmost currently-Active app - + * the most recently started of whichever instances are Active (a modal child launched via + * app_manager_start_for_result() stays Active alongside its parent while shown, so this + * correctly picks the child, not the parent, while a dialog is up). + * @retval ERROR_NOT_FOUND no app is Active + * @retval ERROR_NONE on success + */ +error_t app_manager_get_topmost_instance_id(AppInstanceId* out_app_instance_id); + +/** + * Same as app_manager_get_topmost_instance_id(), but resolves straight to the topmost app's + * manifest id string. + * @param[out] buffer always NULL-terminated on return, even on failure (empty string if + * @a buffer_size == 0 - nothing is written in that case; otherwise at least "" is written) + * @retval ERROR_NOT_FOUND no app is Active + * @retval ERROR_BUFFER_OVERFLOW @a buffer_size is too small to hold the id (including the NULL + * terminator) + * @retval ERROR_NONE on success + */ +error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/manifest.h b/Modules/app-module/include/app/manifest.h new file mode 100644 index 000000000..ec393d74a --- /dev/null +++ b/Modules/app-module/include/app/manifest.h @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include "location.h" + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Broad classification of an app, used for grouping/launcher presentation. */ +enum AppCategory { + APP_CATEGORY_SYSTEM, + APP_CATEGORY_SETTINGS, + APP_CATEGORY_USER, +}; + +/** Bit flags for AppManifest::flags. */ +enum AppManifestFlags { + /** Excluded from generic app-browsing UIs (AppList, Settings) - for apps only ever reached + * by direct navigation (modal dialogs, detail views that require parameters, wizard/ + * bootstrap steps). */ + APP_MANIFEST_FLAG_HIDDEN = 0b00000001, +}; + +/** Describes a registrable app. One manifest exists per app id. */ +struct AppManifest { + /** Unique app identifier. Should never be NULL. */ + const char* id; + /** Human-readable name. Should never be NULL. */ + const char* name; + enum AppCategory category; + struct AppLocation location; + /** Bitmask of AppManifestFlags. Most apps should leave this 0. */ + uint8_t flags; +}; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/metadata.h b/Modules/app-module/include/app/metadata.h new file mode 100644 index 000000000..1f02ac6ea --- /dev/null +++ b/Modules/app-module/include/app/metadata.h @@ -0,0 +1,59 @@ +#pragma once + +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define APP_METADATA_TARGET_SDK_LENGTH 16 +#define APP_METADATA_APP_ID_LENGTH 32 +#define APP_METADATA_APP_NAME_LENGTH 32 +#define APP_METADATA_APP_VERSION_NAME_LENGTH 16 + +struct AppMetadata { + + /** + * The SDK version that was used to compile this app. (e.g. "0.6.0") + * Must be NULL-terminated. + */ + char target_sdk[APP_METADATA_TARGET_SDK_LENGTH + 1]; + + /** + * The identifier by which the app is launched by the system and other apps. + * Must be NULL-terminated. + */ + char app_id[APP_METADATA_APP_ID_LENGTH + 1]; + + /** + * The user-readable name of the app. Used in UI. + * Must be NULL-terminated. + */ + char app_name[APP_METADATA_APP_NAME_LENGTH + 1]; + + /** + * The version as it is displayed to the user (e.g. "1.2.0") + * Must be NULL-terminated. + */ + char app_version_name[APP_METADATA_APP_VERSION_NAME_LENGTH + 1]; + + /** The technical version (must be incremented with new releases of the app */ + uint64_t app_version_code = 0; +}; + +/** + * Parses a manifest.properties file at @a path into @a out_metadata, auto-detecting the V1 + * (sectioned, e.g. "[app]id=...") or V2 (flat dot-notation, e.g. "app.id=...") format from its + * first line. + * @retval ERROR_NONE on success + * @retval ERROR_NOT_FOUND the file doesn't exist / couldn't be opened + * @retval ERROR_INVALID_ARGUMENT the file isn't a valid manifest, or a field's value doesn't fit + * @a out_metadata's fixed-size buffers + */ +error_t app_metadata_parse(const char* path, struct AppMetadata* out_metadata); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/include/app/module.h b/Modules/app-module/include/app/module.h new file mode 100644 index 000000000..5bfd3ee67 --- /dev/null +++ b/Modules/app-module/include/app/module.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module app_module; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/private/app/private/app_ledger.h b/Modules/app-module/private/app/private/app_ledger.h new file mode 100644 index 000000000..3699706e6 --- /dev/null +++ b/Modules/app-module/private/app/private/app_ledger.h @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#include +#include + +#include +#include +#include + +/** A registered/running app instance, as tracked internally by app-module. */ +struct AppInstanceRecord { + uint32_t id; + const AppManifest* manifest; + AppInstanceState state; + /** The kernel thread currently executing AppLoaderApi::run() for this instance; NULL when not running. */ + Thread* thread; + + /** 0 for a top-level launch (app_manager_start()). Non-zero for a modal child launched via + * app_manager_start_for_result() - the instance that receives this child's APP_EVENT_RESULT. */ + uint32_t parent_id = 0; +}; + +struct AppLedger { + std::unordered_map manifests; + std::unordered_map instances; + uint32_t next_instance_id = 1; + Mutex mutex {}; + + AppLedger() { mutex_construct(&mutex); } + ~AppLedger() { mutex_destruct(&mutex); } +}; + +inline AppLedger& app_ledger() { + static AppLedger ledger; + return ledger; +} + +/** Frees a deep-copied argv previously built by app_manager_start_with_parameters()/ + * app_manager_start_for_result() (see app_scheduler.cpp's ThreadContext::argv) - each + * individually heap-allocated string, then the array itself. Safe to call with count == 0 / + * values == nullptr (no-op). */ +inline void app_ledger_free_arguments(int count, char** values) { + if (values == nullptr) { + return; + } + for (int i = 0; i < count; i++) { + delete[] values[i]; + } + delete[] values; +} diff --git a/Modules/app-module/private/app/private/app_metadata_parsing_internal.h b/Modules/app-module/private/app/private/app_metadata_parsing_internal.h new file mode 100644 index 000000000..f611b0a50 --- /dev/null +++ b/Modules/app-module/private/app/private/app_metadata_parsing_internal.h @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +/** Shared helpers + per-format parsers for app_metadata_parse() (source/app_metadata_parsing.cpp) + * - split out like the old tt::app manifest parser (AppManifestParsing/V1/V2.cpp) that this is + * modelled on, one file per format plus a shared dispatcher. */ + +bool app_metadata_get_value(const std::map& properties, const std::string& key, std::string& out_value); + +bool app_metadata_is_valid_format_version(const std::string& version); +bool app_metadata_is_valid_id(const std::string& id); +bool app_metadata_is_valid_name(const std::string& name); +bool app_metadata_is_valid_version_name(const std::string& version); +bool app_metadata_is_valid_version_code(const std::string& version); + +/** Copies @a value into @a dest (a fixed-size buffer of @a dest_size bytes, including the NULL + * terminator) if it fits. + * @retval false @a value doesn't fit in @a dest_size bytes - @a dest is left untouched */ +bool app_metadata_copy_bounded(char* dest, size_t dest_size, const std::string& value); + +/** Parses a V1 (sectioned INI, e.g. "[app]versionName=...") manifest map into @a out_metadata. */ +bool app_metadata_parse_v1(const std::map& properties, struct AppMetadata& out_metadata); + +/** Parses a V2 (flat dot-notation, e.g. "app.version.name=...") manifest map into @a out_metadata. */ +bool app_metadata_parse_v2(const std::map& properties, struct AppMetadata& out_metadata); diff --git a/Modules/app-module/private/app/private/app_scheduler.h b/Modules/app-module/private/app/private/app_scheduler.h new file mode 100644 index 000000000..e7d8cd3b1 --- /dev/null +++ b/Modules/app-module/private/app/private/app_scheduler.h @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include + +#include + +/** + * Owns per-app task lifecycle on behalf of app_manager_*(). AppLoaderApi implementations + * stay task-agnostic; all of thread_alloc_full()/thread_start()/thread_join() happen here. + * Every app instance gets its own dedicated task for its entire lifetime - no task is ever + * reused for a different instance. + */ + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Loads and starts an app instance: spawns a dedicated task that calls + * AppLoaderApi::load()/run(), marking the instance ACTIVE for the duration of run(). + * @param[in] app_instance_id id already allocated in the ledger for this instance + * @param[in] location the location of the app + * @param[in] argc the amount of arguments to pass to the app's main function + * @param[in] argv the array of arguments to pass to the app's main function - ownership is + * taken by the scheduler regardless of outcome (freed once the spawned task's run() returns, or + * immediately on a failure to start it) + */ +error_t app_scheduler_start(uint32_t app_instance_id, struct AppLocation location, int argc, char* argv[]); + +/** + * Permanently stops an app instance (APP_EVENT_CLOSE if it was running), bound-waits for its + * task to exit, and removes it from the ledger. + */ +error_t app_scheduler_stop(uint32_t app_instance_id, TickType_t join_timeout); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/source/app_install.cpp b/Modules/app-module/source/app_install.cpp new file mode 100644 index 000000000..f3ebe74c6 --- /dev/null +++ b/Modules/app-module/source/app_install.cpp @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +constexpr auto* TAG = "app_install"; + +namespace { + +// region Filesystem helpers (app-module may not depend upward on Tactility::file - see +// app_metadata_parsing.cpp for the same constraint applied to properties-file loading) + +std::string last_path_segment(const std::string& path) { + auto index = path.find_last_of('/'); + return index == std::string::npos ? path : path.substr(index + 1); +} + +bool is_directory(const std::string& path) { + struct stat result {}; + return stat(path.c_str(), &result) == 0 && S_ISDIR(result.st_mode); +} + +bool is_file(const std::string& path) { + struct stat result {}; + return stat(path.c_str(), &result) == 0 && S_ISREG(result.st_mode); +} + +// mkdir -p. +bool ensure_directory(const std::string& path) { + if (path.empty() || is_directory(path)) { + return true; + } + + FileMutex mutex {}; + file_mutex_get(&mutex, path.c_str()); + file_mutex_lock(&mutex); + bool created = mkdir(path.c_str(), 0777) == 0 || errno == EEXIST; + file_mutex_unlock(&mutex); + if (!created) { + return false; + } + + return is_directory(path); +} + +bool ensure_directory_recursive(const std::string& path) { + for (size_t index = path.find('/', 1); index != std::string::npos; index = path.find('/', index + 1)) { + if (!ensure_directory(path.substr(0, index))) { + return false; + } + } + return ensure_directory(path); +} + +bool delete_recursively(const std::string& path) { + if (path.empty() || path == "/" || path == "." || path == "..") { + return true; + } + + if (is_directory(path)) { + DIR* dir = opendir(path.c_str()); + if (dir == nullptr) { + LOG_E(TAG, "Failed to scan directory %s", path.c_str()); + return false; + } + + bool success = true; + struct dirent* entry; + while (success && (entry = readdir(dir)) != nullptr) { + if (std::strcmp(entry->d_name, ".") == 0 || std::strcmp(entry->d_name, "..") == 0) { + continue; + } + success = delete_recursively(path + "/" + entry->d_name); + } + closedir(dir); + + if (!success) { + return false; + } + + FileMutex mutex {}; + file_mutex_get(&mutex, path.c_str()); + file_mutex_lock(&mutex); + bool result = rmdir(path.c_str()) == 0; + file_mutex_unlock(&mutex); + return result; + } + + if (is_file(path)) { + FileMutex mutex {}; + file_mutex_get(&mutex, path.c_str()); + file_mutex_lock(&mutex); + bool result = remove(path.c_str()) == 0; + file_mutex_unlock(&mutex); + return result; + } + + // Doesn't exist - nothing to do. + return true; +} + +bool get_app_install_directory(std::string& out_path) { + char root[192]; + if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) { + return false; + } + out_path = std::string(root) + "/app"; + return true; +} + +// endregion + +// region Tar extraction (ported from the old Tactility::app AppInstall.cpp) + +bool untar_file(minitar* archive, const minitar_entry* entry, const std::string& destination_path) { + auto absolute_path = destination_path + "/" + entry->metadata.path; + if (!ensure_directory_recursive(destination_path)) { + LOG_E(TAG, "Can't find or create directory %s", destination_path.c_str()); + return false; + } + + if (!minitar_read_contents_to_file(archive, entry, absolute_path.c_str())) { + LOG_E(TAG, "Failed to write data to %s", absolute_path.c_str()); + return false; + } + + // Note: fchmod() doesn't exist on ESP-IDF and chmod() does nothing on that platform. + chmod(absolute_path.c_str(), entry->metadata.mode); + + return true; +} + +bool untar_directory(const minitar_entry* entry, const std::string& destination_path) { + return ensure_directory_recursive(destination_path + "/" + entry->metadata.path); +} + +bool untar(const std::string& tar_path, const std::string& destination_path) { + minitar archive {}; + if (minitar_open(tar_path.c_str(), &archive) != 0) { + LOG_E(TAG, "Failed to open %s", tar_path.c_str()); + return false; + } + + bool success = true; + minitar_entry entry {}; + while (minitar_read_entry(&archive, &entry) == 0) { + LOG_I(TAG, "Extracting %s", entry.metadata.path); + if (entry.metadata.type == MTAR_DIRECTORY) { + if (std::strcmp(entry.metadata.name, ".") == 0 || std::strcmp(entry.metadata.name, "..") == 0 || std::strcmp(entry.metadata.name, "/") == 0) { + continue; + } + success = untar_directory(&entry, destination_path); + } else if (entry.metadata.type == MTAR_REGULAR) { + success = untar_file(&archive, &entry, destination_path); + } else { + LOG_E(TAG, "Unsupported entry type: %d", static_cast(entry.metadata.type)); + success = false; + } + + if (!success) { + LOG_E(TAG, "Failed to extract %s", entry.metadata.path); + break; + } + } + + minitar_close(&archive); + return success; +} + +// endregion + +// region Installed-app registry: owns the AppManifest (and its id/name/path strings) that +// app_manager's ledger only keeps a non-owning pointer to (see app_manager_add()'s contract). + +struct InstalledAppRecord { + std::string id; + std::string name; + std::string path; + AppManifest manifest {}; +}; + +struct InstallRegistry { + std::unordered_map> apps; + Mutex mutex {}; + + InstallRegistry() { mutex_construct(&mutex); } +}; + +InstallRegistry& install_registry() { + static InstallRegistry registry; + return registry; +} + +// Stops every currently-running instance of @a manifest. Collects matching instance ids while +// holding the ledger lock, then calls app_manager_stop() on each after releasing it - that call +// bound-joins the instance's thread, which must not happen while the ledger mutex (also taken by +// the instance's own thread_main()) is held, or the two threads would deadlock each other. +void stop_all_instances_of(const AppManifest* manifest) { + std::vector instance_ids; + + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + for (const auto& [id, record]: ledger.instances) { + if (record.manifest == manifest) { + instance_ids.push_back(id); + } + } + mutex_unlock(&ledger.mutex); + + for (uint32_t id: instance_ids) { + app_manager_stop(id); + } +} + +// Takes install_registry().mutex - caller must not already hold it. +error_t uninstall_locked(const std::string& app_id) { + auto& registry = install_registry(); + auto iterator = registry.apps.find(app_id); + if (iterator == registry.apps.end()) { + return ERROR_NOT_FOUND; + } + + stop_all_instances_of(&iterator->second->manifest); + app_manager_remove(app_id.c_str()); + delete_recursively(iterator->second->path); + registry.apps.erase(iterator); + + return ERROR_NONE; +} + +// endregion + +} // namespace + +extern "C" { + +error_t app_get_install_path(const char* app_id, char* path, size_t path_size) { + if (path_size == 0) { + return ERROR_BUFFER_OVERFLOW; + } + path[0] = '\0'; + + std::string app_parent_path; + if (!get_app_install_directory(app_parent_path)) { + return ERROR_NOT_FOUND; + } + + int written = std::snprintf(path, path_size, "%s/%s", app_parent_path.c_str(), app_id); + if (written < 0 || static_cast(written) >= path_size) { + path[0] = '\0'; + return ERROR_BUFFER_OVERFLOW; + } + + return ERROR_NONE; +} + +error_t app_install(const char* source_path) { + LOG_I(TAG, "Installing app from %s", source_path); + + std::string app_parent_path; + if (!get_app_install_directory(app_parent_path)) { + return ERROR_NOT_FOUND; + } + + if (!ensure_directory_recursive(app_parent_path)) { + LOG_E(TAG, "Failed to create %s", app_parent_path.c_str()); + return ERROR_NOT_FOUND; + } + + // Extract to a staging directory named after the tarball first - the real app id (and so + // the final directory name) is only known once the manifest inside it is parsed. + auto staging_path = app_parent_path + "/" + last_path_segment(source_path); + delete_recursively(staging_path); + + FileMutex target_mutex {}; + file_mutex_get(&target_mutex, app_parent_path.c_str()); + FileMutex source_mutex {}; + file_mutex_get(&source_mutex, source_path); + + file_mutex_lock(&target_mutex); + file_mutex_lock(&source_mutex); + bool untar_success = untar(source_path, staging_path); + file_mutex_unlock(&source_mutex); + file_mutex_unlock(&target_mutex); + + if (!untar_success) { + LOG_E(TAG, "Failed to extract %s", source_path); + delete_recursively(staging_path); + return ERROR_NOT_FOUND; + } + + auto manifest_path = staging_path + "/manifest.properties"; + if (!is_file(manifest_path)) { + LOG_E(TAG, "Manifest not found at %s", manifest_path.c_str()); + delete_recursively(staging_path); + return ERROR_INVALID_ARGUMENT; + } + + AppMetadata metadata {}; + if (app_metadata_parse(manifest_path.c_str(), &metadata) != ERROR_NONE) { + LOG_W(TAG, "Invalid manifest"); + delete_recursively(staging_path); + return ERROR_INVALID_ARGUMENT; + } + + auto& registry = install_registry(); + mutex_lock(®istry.mutex); + + // Replace any previous install of this app id (mirrors the old install()'s "already + // running/present" handling). + uninstall_locked(metadata.app_id); + + auto final_path = app_parent_path + "/" + metadata.app_id; + delete_recursively(final_path); + + file_mutex_lock(&target_mutex); + bool rename_success = rename(staging_path.c_str(), final_path.c_str()) == 0; + file_mutex_unlock(&target_mutex); + + if (!rename_success) { + LOG_E(TAG, "Failed to rename \"%s\" to \"%s\"", staging_path.c_str(), final_path.c_str()); + delete_recursively(staging_path); + mutex_unlock(®istry.mutex); + return ERROR_NOT_FOUND; + } + + auto record = std::make_unique(); + record->id = metadata.app_id; + record->name = metadata.app_name; + record->path = final_path; + record->manifest = AppManifest { + .id = record->id.c_str(), + .name = record->name.c_str(), + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_PATH, const_cast(record->path.c_str()) }, + .flags = 0, + }; + + error_t add_result = app_manager_add(&record->manifest); + if (add_result != ERROR_NONE) { + // Only remaining failure mode is a duplicate id - can't happen, uninstall_locked() above + // already removed any previous registration for this exact id. + mutex_unlock(®istry.mutex); + return add_result; + } + + registry.apps[record->id] = std::move(record); + mutex_unlock(®istry.mutex); + + return ERROR_NONE; +} + +error_t app_uninstall(const char* app_id) { + LOG_I(TAG, "Uninstalling app %s", app_id); + + auto& registry = install_registry(); + mutex_lock(®istry.mutex); + error_t result = uninstall_locked(app_id); + mutex_unlock(®istry.mutex); + + return result; +} + +} // extern "C" diff --git a/Modules/app-module/source/app_internal_loader.cpp b/Modules/app-module/source/app_internal_loader.cpp new file mode 100644 index 000000000..d9175f188 --- /dev/null +++ b/Modules/app-module/source/app_internal_loader.cpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include +#include + +namespace { + +error_t api_load(AppLocation location, AppRuntime* out_runtime) { + if (location.type != APP_LOCATION_MEMORY) { + return ERROR_NOT_SUPPORTED; + } + + *out_runtime = location.location; + return ERROR_NONE; +} + +int32_t api_run(AppRuntime runtime, uint32_t app_instance_id, int argc, char* argv[]) { + auto entry = reinterpret_cast(runtime); + return entry(app_instance_id, argc, argv); +} + +void api_unload(AppRuntime /*unused*/) { +} + +AppLoaderApi memory_loader_api = { + .load = api_load, + .run = api_run, + .unload = api_unload, +}; + +void* create_service(const ServiceManifest*) { + return &memory_loader_api; +} + +void destroy_service(const ServiceManifest*, void*) { +} + +} // namespace + +extern ServiceManifest app_internal_loader_service_manifest = { + .id = APP_LOADER_MEMORY_SERVICE_ID, + .create_service = create_service, + .destroy_service = destroy_service, + .on_start = nullptr, + .on_stop = nullptr, +}; diff --git a/Modules/app-module/source/app_metadata_parsing.cpp b/Modules/app-module/source/app_metadata_parsing.cpp new file mode 100644 index 000000000..79849f44e --- /dev/null +++ b/Modules/app-module/source/app_metadata_parsing.cpp @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "tactility/filesystem/file_mutex.h" + + +#include + +#include + +#include + +#include +#include +#include +#include +#include + +constexpr auto* TAG = "app_metadata"; + +namespace { + +std::string trim(const std::string& value) { + constexpr auto* whitespace = " \t\r\n"; + auto start = value.find_first_not_of(whitespace); + if (start == std::string::npos) { + return ""; + } + auto end = value.find_last_not_of(whitespace); + return value.substr(start, end - start + 1); +} + +bool validate_string(const std::string& value, bool (*is_valid_char)(char)) { + for (char c: value) { + if (!is_valid_char(c)) { + return false; + } + } + return true; +} + +/** manifest.properties format: "key=value" lines, "[section]" lines prefix every following key + * until the next section, "#" lines are comments, blank lines are skipped. Deliberately a local, + * minimal re-implementation rather than depending on Tactility's file::loadPropertiesFile() - + * app-module (like every other kernel module) may not depend upward on the Tactility layer. */ +bool load_properties(const std::string& path, std::map& out_properties, std::string& out_first_line) { + FileMutex mutex; + file_mutex_get(&mutex, path.c_str()); + file_mutex_lock(&mutex); + + std::ifstream file(path); + if (!file.is_open()) { + file_mutex_unlock(&mutex); + return false; + } + + std::string line; + std::string section_prefix; + bool got_first_line = false; + while (std::getline(file, line)) { + auto trimmed_line = trim(line); + if (!got_first_line) { + out_first_line = trimmed_line; + got_first_line = true; + } + + if (trimmed_line.empty() || trimmed_line.starts_with("#")) { + continue; + } + + if (trimmed_line.starts_with("[")) { + section_prefix = trimmed_line; + continue; + } + + auto separator_index = trimmed_line.find('='); + if (separator_index == std::string::npos) { + LOG_E(TAG, "Failed to parse manifest line (skipped): %s", trimmed_line.c_str()); + continue; + } + + auto key = section_prefix + trim(trimmed_line.substr(0, separator_index)); + auto value = trim(trimmed_line.substr(separator_index + 1)); + out_properties[key] = value; + } + + file_mutex_unlock(&mutex); + return true; +} + +} // namespace + +bool app_metadata_get_value(const std::map& properties, const std::string& key, std::string& out_value) { + const auto iterator = properties.find(key); + if (iterator == properties.end()) { + LOG_E(TAG, "Failed to find %s in manifest", key.c_str()); + return false; + } + out_value = iterator->second; + return true; +} + +bool app_metadata_is_valid_format_version(const std::string& version) { + return !version.empty() && validate_string(version, [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == '.'; + }); +} + +bool app_metadata_is_valid_id(const std::string& id) { + return id.size() >= 5 && id.size() <= APP_METADATA_APP_ID_LENGTH && validate_string(id, [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == '.'; + }); +} + +bool app_metadata_is_valid_name(const std::string& name) { + return name.size() >= 2 && name.size() <= APP_METADATA_APP_NAME_LENGTH && validate_string(name, [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == ' ' || c == '-'; + }); +} + +bool app_metadata_is_valid_version_name(const std::string& version) { + return !version.empty() && version.size() <= APP_METADATA_APP_VERSION_NAME_LENGTH && validate_string(version, [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == '.' || c == '-' || c == '_'; + }); +} + +bool app_metadata_is_valid_version_code(const std::string& version) { + return !version.empty() && validate_string(version, [](char c) { + return std::isdigit(static_cast(c)) != 0; + }); +} + +bool app_metadata_copy_bounded(char* dest, size_t dest_size, const std::string& value) { + if (value.size() >= dest_size) { + return false; + } + memcpy(dest, value.c_str(), value.size() + 1); + return true; +} + +error_t app_metadata_parse(const char* path, struct AppMetadata* out_metadata) { + LOG_I(TAG, "Parsing manifest %s", path); + + std::map properties; + std::string first_line; + if (!load_properties(path, properties, first_line)) { + LOG_E(TAG, "Failed to load manifest at %s", path); + return ERROR_NOT_FOUND; + } + + // The V1 format's first line is always the literal "[manifest]" section header; V2 files are + // flat from the first line onward. + bool is_v1_format = first_line == "[manifest]"; + bool success = is_v1_format + ? app_metadata_parse_v1(properties, *out_metadata) + : app_metadata_parse_v2(properties, *out_metadata); + + return success ? ERROR_NONE : ERROR_INVALID_ARGUMENT; +} diff --git a/Modules/app-module/source/app_metadata_parsing_v1.cpp b/Modules/app-module/source/app_metadata_parsing_v1.cpp new file mode 100644 index 000000000..1f8d8c5f9 --- /dev/null +++ b/Modules/app-module/source/app_metadata_parsing_v1.cpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include + +constexpr auto* TAG = "app_metadata_v1"; + +bool app_metadata_parse_v1(const std::map& properties, AppMetadata& out_metadata) { + // [manifest] + + std::string format_version; + if (!app_metadata_get_value(properties, "[manifest]version", format_version)) { + return false; + } + + if (!app_metadata_is_valid_format_version(format_version)) { + LOG_E(TAG, "Invalid version"); + return false; + } + + // [app] + + std::string id; + if (!app_metadata_get_value(properties, "[app]id", id)) { + return false; + } + + if (!app_metadata_is_valid_id(id)) { + LOG_E(TAG, "Invalid app id"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_id, sizeof(out_metadata.app_id), id)) { + LOG_E(TAG, "App id too long"); + return false; + } + + std::string name; + if (!app_metadata_get_value(properties, "[app]name", name)) { + return false; + } + + if (!app_metadata_is_valid_name(name)) { + LOG_E(TAG, "Invalid app name"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_name, sizeof(out_metadata.app_name), name)) { + LOG_E(TAG, "App name too long"); + return false; + } + + std::string version_name; + if (!app_metadata_get_value(properties, "[app]versionName", version_name)) { + return false; + } + + if (!app_metadata_is_valid_version_name(version_name)) { + LOG_E(TAG, "Invalid app version name"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_version_name, sizeof(out_metadata.app_version_name), version_name)) { + LOG_E(TAG, "App version name too long"); + return false; + } + + std::string version_code_string; + if (!app_metadata_get_value(properties, "[app]versionCode", version_code_string)) { + return false; + } + + if (!app_metadata_is_valid_version_code(version_code_string)) { + LOG_E(TAG, "Invalid app version code"); + return false; + } + + out_metadata.app_version_code = std::stoull(version_code_string); + + // [target] + + std::string target_sdk; + if (!app_metadata_get_value(properties, "[target]sdk", target_sdk)) { + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.target_sdk, sizeof(out_metadata.target_sdk), target_sdk)) { + LOG_E(TAG, "Target sdk too long"); + return false; + } + + return true; +} diff --git a/Modules/app-module/source/app_metadata_parsing_v2.cpp b/Modules/app-module/source/app_metadata_parsing_v2.cpp new file mode 100644 index 000000000..a5facdd2e --- /dev/null +++ b/Modules/app-module/source/app_metadata_parsing_v2.cpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include + +constexpr auto* TAG = "app_metadata_v2"; + +bool app_metadata_parse_v2(const std::map& properties, AppMetadata& out_metadata) { + // manifest + + std::string format_version; + if (!app_metadata_get_value(properties, "manifest.version", format_version)) { + return false; + } + + if (!app_metadata_is_valid_format_version(format_version)) { + LOG_E(TAG, "Invalid version"); + return false; + } + + // app + + std::string id; + if (!app_metadata_get_value(properties, "app.id", id)) { + return false; + } + + if (!app_metadata_is_valid_id(id)) { + LOG_E(TAG, "Invalid app id"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_id, sizeof(out_metadata.app_id), id)) { + LOG_E(TAG, "App id too long"); + return false; + } + + std::string name; + if (!app_metadata_get_value(properties, "app.name", name)) { + return false; + } + + if (!app_metadata_is_valid_name(name)) { + LOG_E(TAG, "Invalid app name"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_name, sizeof(out_metadata.app_name), name)) { + LOG_E(TAG, "App name too long"); + return false; + } + + std::string version_name; + if (!app_metadata_get_value(properties, "app.version.name", version_name)) { + return false; + } + + if (!app_metadata_is_valid_version_name(version_name)) { + LOG_E(TAG, "Invalid app version name"); + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.app_version_name, sizeof(out_metadata.app_version_name), version_name)) { + LOG_E(TAG, "App version name too long"); + return false; + } + + std::string version_code_string; + if (!app_metadata_get_value(properties, "app.version.code", version_code_string)) { + return false; + } + + if (!app_metadata_is_valid_version_code(version_code_string)) { + LOG_E(TAG, "Invalid app version code"); + return false; + } + + out_metadata.app_version_code = std::stoull(version_code_string); + + // target + + std::string target_sdk; + if (!app_metadata_get_value(properties, "target.sdk", target_sdk)) { + return false; + } + + if (!app_metadata_copy_bounded(out_metadata.target_sdk, sizeof(out_metadata.target_sdk), target_sdk)) { + LOG_E(TAG, "Target sdk too long"); + return false; + } + + return true; +} diff --git a/Modules/app-module/source/app_scheduler.cpp b/Modules/app-module/source/app_scheduler.cpp new file mode 100644 index 000000000..0c5f711ea --- /dev/null +++ b/Modules/app-module/source/app_scheduler.cpp @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include +#include + +#include +#include + +#include +#include + +#include + +#define TAG "app_scheduler" + +namespace { + +struct ThreadContext { + const AppLoaderApi* loader; + void* runtime; + uint32_t app_instance_id; + int argc; + char** argv; +}; + +void set_state(uint32_t app_instance_id, AppInstanceState state) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + if (iterator != ledger.instances.end()) { + iterator->second.state = state; + } + mutex_unlock(&ledger.mutex); +} + +Thread* get_thread(uint32_t app_instance_id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + Thread* thread = (iterator != ledger.instances.end()) ? iterator->second.thread : nullptr; + mutex_unlock(&ledger.mutex); + return thread; +} + +void set_thread(uint32_t app_instance_id, Thread* thread) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + if (iterator != ledger.instances.end()) { + iterator->second.thread = thread; + } + mutex_unlock(&ledger.mutex); +} + +const char* loader_service_id_for(AppLocationType type) { + return (type == APP_LOCATION_MEMORY) ? APP_LOADER_MEMORY_SERVICE_ID : APP_LOADER_PATH_SERVICE_ID; +} + +const AppLoaderApi* find_loader_api(AppLocationType type) { + ServiceInstance* instance = service_manager_find_instance(loader_service_id_for(type)); + if (instance == nullptr) { + return nullptr; + } + return static_cast(service_instance_get_data(instance)); +} + +// If this instance was launched via app_manager_start_for_result(), delivers @a result (its +// own AppMainFn/AppLoaderApi::run() return value) to its parent. No-op for a top-level instance +// (parent_id == 0). +void deliver_result_to_parent_if_any(uint32_t app_instance_id, int32_t result) { + auto& ledger = app_ledger(); + + uint32_t parent_id; + AppEvent event { .type = APP_EVENT_RESULT, .timestamp = 0, .result = {} }; + + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + if (iterator == ledger.instances.end()) { + mutex_unlock(&ledger.mutex); + return; + } + parent_id = iterator->second.parent_id; + event.result.launch_id = app_instance_id; + event.result.result = result; + mutex_unlock(&ledger.mutex); + + if (parent_id != 0) { + app_event_emit(parent_id, &event); + } +} + +int32_t thread_main(void* context) { + auto* ctx = static_cast(context); + + set_state(ctx->app_instance_id, APP_INSTANCE_STATE_ACTIVE); + + int32_t result = ctx->loader->run(ctx->runtime, ctx->app_instance_id, ctx->argc, ctx->argv); + + ctx->loader->unload(ctx->runtime); + + deliver_result_to_parent_if_any(ctx->app_instance_id, result); + + // A safe default terminal marker for CLOSE (and any other exit): an app that calls + // app_manager_finish() already marked itself Stopped before returning, so this is a no-op + // for it - but it's still needed as the terminal marker for any other exit path. + set_state(ctx->app_instance_id, APP_INSTANCE_STATE_STOPPED); + + app_ledger_free_arguments(ctx->argc, ctx->argv); + delete ctx; + return result; +} + +} // namespace + +extern "C" { + +error_t app_scheduler_start(uint32_t app_instance_id, AppLocation location, int argc, char* argv[]) { + const AppLoaderApi* loader = find_loader_api(location.type); + if (loader == nullptr) { + LOG_E(TAG, "No app loader is registered (service '%s' not found)", loader_service_id_for(location.type)); + app_ledger_free_arguments(argc, argv); + return ERROR_NOT_FOUND; + } + + void* runtime = nullptr; + error_t load_result = loader->load(location, &runtime); + if (load_result != ERROR_NONE) { + app_ledger_free_arguments(argc, argv); + return load_result; + } + + auto* context = new (std::nothrow) ThreadContext { loader, runtime, app_instance_id, argc, argv }; + if (context == nullptr) { + loader->unload(runtime); + app_ledger_free_arguments(argc, argv); + return ERROR_OUT_OF_MEMORY; + } + + // -1 (no affinity) matches the FreeRTOS POSIX/simulator port; ESP-IDF's tskNO_AFFINITY is + // a numerically equivalent SMP-only constant not available in the plain FreeRTOS-Kernel port. + Thread* thread = thread_alloc_full("app", 8192, thread_main, context, -1); + if (thread == nullptr) { + delete context; + loader->unload(runtime); + app_ledger_free_arguments(argc, argv); + return ERROR_OUT_OF_MEMORY; + } + + set_thread(app_instance_id, thread); + + error_t start_result = thread_start(thread); + if (start_result != ERROR_NONE) { + set_thread(app_instance_id, nullptr); + thread_free(thread); + delete context; + loader->unload(runtime); + app_ledger_free_arguments(argc, argv); + return start_result; + } + + return ERROR_NONE; +} + +error_t app_scheduler_stop(uint32_t app_instance_id, TickType_t join_timeout) { + Thread* thread = get_thread(app_instance_id); + if (thread != nullptr) { + AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} }; + app_event_emit(app_instance_id, &event); + + if (thread_join(thread, join_timeout, pdMS_TO_TICKS(10)) != ERROR_NONE) { + LOG_W(TAG, "App instance %u did not stop in time", app_instance_id); + return ERROR_TIMEOUT; + } + thread_free(thread); + set_thread(app_instance_id, nullptr); + } + + set_state(app_instance_id, APP_INSTANCE_STATE_STOPPED); + + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + ledger.instances.erase(app_instance_id); + mutex_unlock(&ledger.mutex); + + return ERROR_NONE; +} + +} // extern "C" diff --git a/Modules/app-module/source/event.cpp b/Modules/app-module/source/event.cpp new file mode 100644 index 000000000..51f1a7549 --- /dev/null +++ b/Modules/app-module/source/event.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +// Intrusive singly-linked list of subscriptions, keyed by app_instance_id (not broadcast by +// type, unlike TactilityKernel's system_event) - an app should only ever see events addressed +// to it. Guarded by a single coarse-grained mutex, same tradeoff system_event.cpp makes for its +// poll-subscription list: notifying a subscriber here never invokes caller code (just a struct +// copy and an xTaskNotifyGive), so there is no reentrancy concern requiring a snapshot-then- +// unlock dance. +static AppEventSubscription* subscriptions = nullptr; + +struct AppEventMutex { + Mutex handle {}; + AppEventMutex() { mutex_construct(&handle); } + ~AppEventMutex() { mutex_destruct(&handle); } +}; + +static AppEventMutex subscriptions_mutex; + +extern "C" { + +error_t app_event_subscribe(AppEventSubscription* sub) { + sub->task = xTaskGetCurrentTaskHandle(); + sub->head = 0; + sub->count = 0; + + mutex_lock(&subscriptions_mutex.handle); + sub->next = subscriptions; + subscriptions = sub; + mutex_unlock(&subscriptions_mutex.handle); + + return ERROR_NONE; +} + +error_t app_event_unsubscribe(AppEventSubscription* sub) { + error_t result = ERROR_NOT_FOUND; + + mutex_lock(&subscriptions_mutex.handle); + for (AppEventSubscription** link = &subscriptions; *link != nullptr; link = &(*link)->next) { + if (*link == sub) { + *link = sub->next; + result = ERROR_NONE; + break; + } + } + mutex_unlock(&subscriptions_mutex.handle); + + return result; +} + +error_t app_event_emit(uint32_t app_instance_id, const AppEvent* event) { + AppEvent stamped_event = *event; + stamped_event.timestamp = get_micros_since_boot(); + + error_t result = ERROR_NOT_FOUND; + + mutex_lock(&subscriptions_mutex.handle); + for (AppEventSubscription* sub = subscriptions; sub != nullptr; sub = sub->next) { + if (sub->app_instance_id != app_instance_id) { + continue; + } + + if (sub->count >= APP_EVENT_QUEUE_CAPACITY) { + result = ERROR_RESOURCE; + continue; + } + + uint8_t tail = (sub->head + sub->count) % APP_EVENT_QUEUE_CAPACITY; + sub->queue[tail] = stamped_event; + sub->count++; + if (result != ERROR_RESOURCE) { + result = ERROR_NONE; + } + xTaskNotifyGive(sub->task); + } + mutex_unlock(&subscriptions_mutex.handle); + + return result; +} + +static bool try_pop(AppEventSubscription* sub, AppEvent* out_event) { + mutex_lock(&subscriptions_mutex.handle); + bool has_event = sub->count > 0; + if (has_event) { + *out_event = sub->queue[sub->head]; + sub->head = (sub->head + 1) % APP_EVENT_QUEUE_CAPACITY; + sub->count--; + } + mutex_unlock(&subscriptions_mutex.handle); + return has_event; +} + +error_t app_event_await(AppEventSubscription* sub, AppEvent* out_event, TickType_t timeout) { + if (try_pop(sub, out_event)) { + // Drain any notification credit this (or an earlier) push accumulated on this task's + // FreeRTOS notification value: each app_event_emit() calls xTaskNotifyGive() regardless + // of whether the consumer takes this fast path or the blocking path below, so without + // this the credit would carry over and cause a future ulTaskNotifyTake() below to + // return immediately for a notification that was already accounted for here. + ulTaskNotifyTake(pdTRUE, 0); + return ERROR_NONE; + } + + if (ulTaskNotifyTake(pdTRUE, timeout) == 0) { + return ERROR_TIMEOUT; + } + + // Single-consumer by design (one task per subscription), so a wakeup implies the event + // this call was notified for is still there for us to pop. + return try_pop(sub, out_event) ? ERROR_NONE : ERROR_TIMEOUT; +} + +} // extern "C" diff --git a/Modules/app-module/source/manager.cpp b/Modules/app-module/source/manager.cpp new file mode 100644 index 000000000..af26a60e7 --- /dev/null +++ b/Modules/app-module/source/manager.cpp @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include + +#include + +#include + +#define TAG "app_manager" + +extern "C" { + +error_t app_manager_add(const AppManifest* manifest) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + if (ledger.manifests.contains(manifest->id)) { + mutex_unlock(&ledger.mutex); + LOG_E(TAG, "Manifest with id '%s' is already registered", manifest->id); + return ERROR_INVALID_ARGUMENT; + } + ledger.manifests[manifest->id] = manifest; + mutex_unlock(&ledger.mutex); + + return ERROR_NONE; +} + +error_t app_manager_remove(const char* id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.manifests.find(id); + if (iterator == ledger.manifests.end()) { + mutex_unlock(&ledger.mutex); + return ERROR_NOT_FOUND; + } + ledger.manifests.erase(iterator); + mutex_unlock(&ledger.mutex); + + return ERROR_NONE; +} + +const AppManifest* app_manager_find_manifest(const char* id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.manifests.find(id); + const AppManifest* manifest = (iterator != ledger.manifests.end()) ? iterator->second : nullptr; + mutex_unlock(&ledger.mutex); + return manifest; +} + +void app_manager_for_each_manifest(AppManifestVisitorFn visitor, void* context) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + for (auto& [id, manifest] : ledger.manifests) { + visitor(manifest, context); + } + mutex_unlock(&ledger.mutex); +} + +namespace { + +// Deep-copies argv (argc <= 0 => NULL, matching "no parameters"). Caller passes the result to +// app_scheduler_start(), which takes ownership regardless of outcome. +char** copy_arguments(int argc, const char* const argv[]) { + if (argc <= 0) { + return nullptr; + } + auto* copy = new char*[argc + 1]; + for (int i = 0; i < argc; i++) { + size_t length = strlen(argv[i]); + copy[i] = new char[length + 1]; + memcpy(copy[i], argv[i], length + 1); + } + copy[argc] = nullptr; + return copy; +} + +// Takes ownership of argv (already a deep copy, or NULL/argc==0) regardless of outcome - +// app_scheduler_start() frees it on any failure path, and the spawned task frees it once its +// run() returns. +error_t start_internal(const char* id, AppInstanceId parent_instance_id, int argc, char* argv[], AppInstanceId* out_app_instance_id) { + const AppManifest* manifest = app_manager_find_manifest(id); + if (manifest == nullptr) { + app_ledger_free_arguments(argc, argv); + return ERROR_NOT_FOUND; + } + + auto& ledger = app_ledger(); + + mutex_lock(&ledger.mutex); + AppInstanceId target_id = ledger.next_instance_id++; + AppInstanceRecord record { target_id, manifest, APP_INSTANCE_STATE_STARTING, nullptr }; + record.parent_id = parent_instance_id; + ledger.instances[target_id] = record; + mutex_unlock(&ledger.mutex); + + error_t result = app_scheduler_start(target_id, manifest->location, argc, argv); + if (result != ERROR_NONE) { + mutex_lock(&ledger.mutex); + ledger.instances.erase(target_id); + mutex_unlock(&ledger.mutex); + return result; + } + + *out_app_instance_id = target_id; + return ERROR_NONE; +} + +} // namespace + +error_t app_manager_start(const char* id, AppInstanceId* out_app_instance_id) { + return start_internal(id, 0, 0, nullptr, out_app_instance_id); +} + +error_t app_manager_start_with_parameters(const char* id, int argc, const char* const argv[], AppInstanceId* out_app_instance_id) { + return start_internal(id, 0, argc, copy_arguments(argc, argv), out_app_instance_id); +} + +error_t app_manager_start_for_result(const char* id, AppInstanceId parent_instance_id, int argc, const char* const argv[], AppInstanceId* out_app_instance_id) { + return start_internal(id, parent_instance_id, argc, copy_arguments(argc, argv), out_app_instance_id); +} + +error_t app_manager_stop(AppInstanceId app_instance_id) { + return app_scheduler_stop(app_instance_id, pdMS_TO_TICKS(2000)); +} + +error_t app_manager_finish(AppInstanceId app_instance_id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + if (iterator != ledger.instances.end()) { + iterator->second.state = APP_INSTANCE_STATE_STOPPED; + } + mutex_unlock(&ledger.mutex); + return ERROR_NONE; +} + +AppInstanceState app_manager_get_state(AppInstanceId app_instance_id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(app_instance_id); + AppInstanceState state = (iterator != ledger.instances.end()) ? iterator->second.state : APP_INSTANCE_STATE_STOPPED; + mutex_unlock(&ledger.mutex); + return state; +} + +error_t app_manager_get_topmost_instance_id(AppInstanceId* out_app_instance_id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + AppInstanceId topmost_id = 0; + for (auto& [instance_id, record] : ledger.instances) { + // Instance ids are handed out in increasing order (AppLedger::next_instance_id), so + // the highest Active id is also the most recently started one. + if (record.state == APP_INSTANCE_STATE_ACTIVE && instance_id > topmost_id) { + topmost_id = instance_id; + } + } + mutex_unlock(&ledger.mutex); + + if (topmost_id == 0) { + return ERROR_NOT_FOUND; + } + *out_app_instance_id = topmost_id; + return ERROR_NONE; +} + +error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size) { + if (buffer_size == 0) { + return ERROR_BUFFER_OVERFLOW; + } + buffer[0] = '\0'; + + AppInstanceId topmost_id = 0; + error_t result = app_manager_get_topmost_instance_id(&topmost_id); + if (result != ERROR_NONE) { + return result; + } + + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.instances.find(topmost_id); + const char* app_id = (iterator != ledger.instances.end()) ? iterator->second.manifest->id : nullptr; + mutex_unlock(&ledger.mutex); + + if (app_id == nullptr) { + return ERROR_NOT_FOUND; + } + + size_t length = strlen(app_id); + if (length >= buffer_size) { + buffer[0] = '\0'; + return ERROR_BUFFER_OVERFLOW; + } + memcpy(buffer, app_id, length + 1); + return ERROR_NONE; +} + +} // extern "C" diff --git a/Modules/app-module/source/module.cpp b/Modules/app-module/source/module.cpp new file mode 100644 index 000000000..f4811265f --- /dev/null +++ b/Modules/app-module/source/module.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include +#include + +extern "C" { + +extern ServiceManifest app_internal_loader_service_manifest; + +static error_t start() { + return service_manager_add(&app_internal_loader_service_manifest, /*auto_start=*/true); +} + +static error_t stop() { + return service_manager_remove(app_internal_loader_service_manifest.id); +} + +Module app_module = { + .name = "app", + .start = start, + .stop = stop, + .drivers = nullptr, + .symbols = nullptr, + .internal = nullptr +}; + +} diff --git a/Modules/lvgl-window-manager/CMakeLists.txt b/Modules/lvgl-window-manager/CMakeLists.txt new file mode 100644 index 000000000..7067fcdb4 --- /dev/null +++ b/Modules/lvgl-window-manager/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*") + +tactility_add_module(lvgl-window-manager + SRCS ${SOURCE_FILES} + INCLUDE_DIRS include/ + REQUIRES TactilityKernel lvgl-module +) diff --git a/Modules/lvgl-window-manager/devicetree.yaml b/Modules/lvgl-window-manager/devicetree.yaml new file mode 100644 index 000000000..6bbb24367 --- /dev/null +++ b/Modules/lvgl-window-manager/devicetree.yaml @@ -0,0 +1,2 @@ +dependencies: + - TactilityKernel diff --git a/Modules/lvgl-window-manager/include/lvgl_window_manager/module.h b/Modules/lvgl-window-manager/include/lvgl_window_manager/module.h new file mode 100644 index 000000000..a4c2615db --- /dev/null +++ b/Modules/lvgl-window-manager/include/lvgl_window_manager/module.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module lvgl_window_manager_module; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-window-manager/include/lvgl_window_manager/window_manager.h b/Modules/lvgl-window-manager/include/lvgl_window_manager/window_manager.h new file mode 100644 index 000000000..71c7022ba --- /dev/null +++ b/Modules/lvgl-window-manager/include/lvgl_window_manager/window_manager.h @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef uint32_t WindowId; + +enum WindowState { + /** id is the current topmost window and has live widgets. */ + WINDOW_STATE_GRANTED, + /** id is not currently topmost - either buried under a newer window (its widgets don't + * exist right now, but it may resurface and get rebuilt if everything above it is removed) + * or it no longer exists at all (removed). */ + WINDOW_STATE_REVOKED, +}; + +/** + * Called once by window_manager_start(), given the real root widget (a raw, full-size + * container created directly under the default display's active screen). May add extra chrome + * (e.g. a statusbar) as children of @a root_widget. + * @param[in] root_widget the real root widget; owned by this module, deleted automatically + * (along with everything added under it) by window_manager_stop() + * @return the widget windows should actually be placed into - @a root_widget itself, or a + * child of it. Returning NULL falls back to @a root_widget. + * @warning Called on the LVGL task with the LVGL lock already held. + */ +typedef lv_obj_t* (*WindowManagerScreenInitFn)(lv_obj_t* root_widget); + +/** + * Configures the screen-init callback window_manager_start() invokes to build the root/content + * widgets. Pass NULL to restore the default (no chrome - the raw root widget is used directly). + * @warning Must be called before window_manager_start(); has no effect once already started. + */ +void window_manager_configure(WindowManagerScreenInitFn screen_init); + +/** + * Creates the root widget (under the default display's active screen) and, via the configured + * screen-init callback, whatever chrome/content widget it wants around it. Idempotent - a + * second call while already started is a no-op. + * @retval ERROR_RESOURCE no default display is active (lv_screen_active() returned NULL) + * @retval ERROR_NONE on success (including if already started) + */ +error_t window_manager_start(void); + +/** + * Deletes the root widget created by window_manager_start() (and everything under it - any + * chrome plus whatever the topmost window had drawn), removing it from the display, and drops + * every tracked window. Idempotent - a second call while already stopped is a no-op. + */ +error_t window_manager_stop(void); + +/** + * Called to populate a window's widgets: once by window_manager_create() when the window is + * first created, and again later by window_manager_remove() if this window resurfaces as the + * new topmost after whatever was above it is removed. Only the current topmost window ever has + * live widgets - everything below it in the stack exists as tracked state only. + * @param[in] root a fresh, full-size container created directly under the content widget for + * this window; deleted automatically once this window stops being topmost + * @param[in] user_data whatever was passed to window_manager_create() for this window + * @warning Called on the LVGL task with the LVGL lock already held. + * @warning May run on a different kernel thread than the one that called window_manager_create() + * for this window - the rebuild-on-remove path runs on whichever thread called + * window_manager_remove() for the window that used to be on top (e.g. a dialog's own thread as + * it closes). Do NOT rely on thread_local state set by this window's own app thread; use + * @a user_data instead. + */ +typedef void (*WindowCreateWidgetsFn)(lv_obj_t* root, void* user_data); + +/** + * Creates a new window on top of the stack (last created = topmost). Deletes the previously + * topmost window's widgets (if any) and builds this window's widgets immediately via + * @a create_widgets - only the topmost window ever has live widgets. + * @param[in] user_data opaque; passed back to @a create_widgets on every call, including a + * later rebuild triggered by window_manager_remove() - see its @warning about which thread that + * can run on. Typically the calling app's own Context*. + * @return the new window's id, or 0 if window_manager_start() hasn't been called + */ +WindowId window_manager_create(uint32_t app_instance_id, WindowCreateWidgetsFn create_widgets, void* user_data); + +/** + * Removes a window, wherever it is in the stack - not necessarily the topmost one. If it was + * topmost, its widgets are deleted and whichever window is now on top (if any) has its + * create_widgets called again to rebuild its widgets. + */ +void window_manager_remove(WindowId id); + +/** @return the current state of @a id; WINDOW_STATE_REVOKED if @a id is buried or doesn't exist. */ +enum WindowState window_manager_get_state(WindowId id); + +/** + * Blocks the calling task until @a id's state changes away from WINDOW_STATE_GRANTED, or + * @a timeout elapses. Returns immediately with WINDOW_STATE_REVOKED if @a id isn't currently + * topmost (nothing to wait for). + * @return the state after waking (or immediately, if there was nothing to wait for) + */ +enum WindowState window_manager_await_state_change(WindowId id, TickType_t timeout); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-window-manager/source/module.cpp b/Modules/lvgl-window-manager/source/module.cpp new file mode 100644 index 000000000..b93d5010f --- /dev/null +++ b/Modules/lvgl-window-manager/source/module.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include +#include + +extern "C" { + +Module lvgl_window_manager_module = { + .name = "lvgl-window-manager", + .start = window_manager_start, + .stop = window_manager_stop, + .drivers = nullptr, + .symbols = nullptr, + .internal = nullptr +}; + +} diff --git a/Modules/lvgl-window-manager/source/window_manager.cpp b/Modules/lvgl-window-manager/source/window_manager.cpp new file mode 100644 index 000000000..c2d1e91b3 --- /dev/null +++ b/Modules/lvgl-window-manager/source/window_manager.cpp @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include + +#include +#include + +namespace { + +struct WindowRecord { + WindowId id; + uint32_t app_instance_id; + WindowCreateWidgetsFn create_widgets; + void* user_data; +}; + +struct WindowManagerState { + Mutex mutex {}; + + bool started = false; + WindowManagerScreenInitFn screen_init = nullptr; + + /** The raw, full-size container window_manager_start() creates; owns (and deletion + * cascades to) whatever the screen-init callback added under it. */ + lv_obj_t* real_root_widget = nullptr; + /** The stable parent each window's own widget is created under - real_root_widget itself, + * unless the screen-init callback returned a nested content widget instead. */ + lv_obj_t* content_root_widget = nullptr; + + WindowId next_id = 1; + /** windows.back() is topmost; only it ever has a live widget (top_widget). */ + std::vector windows; + lv_obj_t* top_widget = nullptr; + + /** Task blocked in window_manager_await_state_change(), if any. */ + TaskHandle_t waiting_task = nullptr; + + WindowManagerState() { mutex_construct(&mutex); } +}; + +WindowManagerState& state() { + static WindowManagerState instance; + return instance; +} + +lv_obj_t* build_window_widget(lv_obj_t* content, WindowCreateWidgetsFn create_widgets, void* user_data) { + if (content == nullptr) { + return nullptr; + } + lvgl_lock(); + lv_obj_t* widget = lv_obj_create(content); + lv_obj_set_size(widget, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(widget, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(widget, 0, LV_STATE_DEFAULT); + lv_obj_set_style_radius(widget, 0, LV_STATE_DEFAULT); + if (create_widgets != nullptr) { + create_widgets(widget, user_data); + } + lvgl_unlock(); + return widget; +} + +void delete_widget(lv_obj_t* widget) { + if (widget == nullptr) { + return; + } + lvgl_lock(); + lv_obj_delete(widget); + lvgl_unlock(); +} + +} // namespace + +extern "C" { + +void window_manager_configure(WindowManagerScreenInitFn screen_init) { + auto& s = state(); + mutex_lock(&s.mutex); + s.screen_init = screen_init; + mutex_unlock(&s.mutex); +} + +error_t window_manager_start(void) { + auto& s = state(); + + mutex_lock(&s.mutex); + if (s.started) { + mutex_unlock(&s.mutex); + return ERROR_NONE; + } + WindowManagerScreenInitFn screen_init = s.screen_init; + mutex_unlock(&s.mutex); + + lv_obj_t* real_widget = nullptr; + lv_obj_t* content_widget = nullptr; + + lvgl_lock(); + lv_obj_t* screen = lv_screen_active(); + if (screen != nullptr) { + real_widget = lv_obj_create(screen); + lv_obj_set_size(real_widget, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_all(real_widget, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(real_widget, 0, LV_STATE_DEFAULT); + lv_obj_set_style_radius(real_widget, 0, LV_STATE_DEFAULT); + + content_widget = (screen_init != nullptr) ? screen_init(real_widget) : nullptr; + if (content_widget == nullptr) { + content_widget = real_widget; + } + } + lvgl_unlock(); + + if (real_widget == nullptr) { + return ERROR_RESOURCE; + } + + mutex_lock(&s.mutex); + s.real_root_widget = real_widget; + s.content_root_widget = content_widget; + s.started = true; + mutex_unlock(&s.mutex); + + return ERROR_NONE; +} + +error_t window_manager_stop(void) { + auto& s = state(); + + mutex_lock(&s.mutex); + if (!s.started) { + mutex_unlock(&s.mutex); + return ERROR_NONE; + } + lv_obj_t* widget = s.real_root_widget; + TaskHandle_t waiter = s.waiting_task; + s.real_root_widget = nullptr; + s.content_root_widget = nullptr; + s.top_widget = nullptr; + s.windows.clear(); + s.started = false; + s.waiting_task = nullptr; + mutex_unlock(&s.mutex); + + if (waiter != nullptr) { + xTaskNotifyGive(waiter); + } + + // Deleting the real widget cascades to everything under it - chrome and top_widget alike. + delete_widget(widget); + + return ERROR_NONE; +} + +WindowId window_manager_create(uint32_t app_instance_id, WindowCreateWidgetsFn create_widgets, void* user_data) { + auto& s = state(); + + mutex_lock(&s.mutex); + if (!s.started) { + mutex_unlock(&s.mutex); + return 0; + } + lv_obj_t* content = s.content_root_widget; + lv_obj_t* old_top_widget = s.top_widget; + TaskHandle_t waiter = s.waiting_task; + s.waiting_task = nullptr; + s.top_widget = nullptr; + WindowId new_id = s.next_id++; + s.windows.push_back(WindowRecord { new_id, app_instance_id, create_widgets, user_data }); + mutex_unlock(&s.mutex); + + if (waiter != nullptr) { + xTaskNotifyGive(waiter); + } + + delete_widget(old_top_widget); + lv_obj_t* new_widget = build_window_widget(content, create_widgets, user_data); + + mutex_lock(&s.mutex); + bool still_topmost = !s.windows.empty() && s.windows.back().id == new_id; + if (still_topmost) { + s.top_widget = new_widget; + new_widget = nullptr; // consumed + } + mutex_unlock(&s.mutex); + + // Something else became topmost while we were building (e.g. a concurrent create() from + // another app thread) - discard what we just made. + delete_widget(new_widget); + + return new_id; +} + +void window_manager_remove(WindowId id) { + auto& s = state(); + + mutex_lock(&s.mutex); + auto iterator = std::find_if(s.windows.begin(), s.windows.end(), + [id](const WindowRecord& window) { return window.id == id; }); + if (iterator == s.windows.end()) { + mutex_unlock(&s.mutex); + return; + } + bool was_topmost = (iterator + 1 == s.windows.end()); + s.windows.erase(iterator); + + lv_obj_t* content = s.content_root_widget; + lv_obj_t* old_widget = nullptr; + WindowCreateWidgetsFn next_create_widgets = nullptr; + void* next_user_data = nullptr; + WindowId next_id = 0; + bool has_next = false; + + if (was_topmost) { + old_widget = s.top_widget; + s.top_widget = nullptr; + if (!s.windows.empty()) { + next_create_widgets = s.windows.back().create_widgets; + next_user_data = s.windows.back().user_data; + next_id = s.windows.back().id; + has_next = true; + } + } + + TaskHandle_t waiter = s.waiting_task; + s.waiting_task = nullptr; + mutex_unlock(&s.mutex); + + if (waiter != nullptr) { + xTaskNotifyGive(waiter); + } + + if (!was_topmost) { + // A buried window was removed - the topmost window's widgets are unaffected. + return; + } + + delete_widget(old_widget); + lv_obj_t* new_widget = has_next ? build_window_widget(content, next_create_widgets, next_user_data) : nullptr; + + mutex_lock(&s.mutex); + bool still_topmost = has_next && !s.windows.empty() && s.windows.back().id == next_id; + if (still_topmost) { + s.top_widget = new_widget; + new_widget = nullptr; // consumed + } + mutex_unlock(&s.mutex); + + delete_widget(new_widget); +} + +WindowState window_manager_get_state(WindowId id) { + auto& s = state(); + mutex_lock(&s.mutex); + bool is_top = !s.windows.empty() && s.windows.back().id == id; + mutex_unlock(&s.mutex); + return is_top ? WINDOW_STATE_GRANTED : WINDOW_STATE_REVOKED; +} + +WindowState window_manager_await_state_change(WindowId id, TickType_t timeout) { + auto& s = state(); + + mutex_lock(&s.mutex); + bool is_top = !s.windows.empty() && s.windows.back().id == id; + if (!is_top) { + mutex_unlock(&s.mutex); + return WINDOW_STATE_REVOKED; + } + s.waiting_task = xTaskGetCurrentTaskHandle(); + mutex_unlock(&s.mutex); + + ulTaskNotifyTake(pdTRUE, timeout); + + return window_manager_get_state(id); +} + +} // extern "C"