From 8556103eb155f416ae86ea294ffaf7b3b4aa3861 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Wed, 9 Sep 2026 20:05:04 +0200 Subject: [PATCH] Implemented multi-binary app packaging (#648) --- .github/actions/build-sdk-posix/action.yml | 6 +- .github/actions/build-sdk/action.yml | 4 +- .../source/app_esp32_loader_service.cpp | 17 +- Modules/app-module/CMakeLists.txt | 9 + Modules/app-module/include/app/install.h | 29 +- Modules/app-module/include/app/loader.h | 4 +- Modules/app-module/include/app/manager.h | 47 +- Modules/app-module/include/app/manifest.h | 17 +- Modules/app-module/include/app/metadata.h | 74 --- .../app-module/include/app/package_manifest.h | 88 ++++ .../private/app/private/binary_path.h | 22 + .../app-module/private/app/private/ledger.h | 12 + .../app/private/metadata_parsing_internal.h | 35 -- .../app/private/package_manifest_parsing.h | 56 +++ Modules/app-module/source/install.cpp | 168 ++++--- Modules/app-module/source/manager.cpp | 218 ++++++-- Modules/app-module/source/manifest.cpp | 21 +- .../app-module/source/metadata_parsing.cpp | 190 ------- .../app-module/source/metadata_parsing_v1.cpp | 107 ---- .../app-module/source/metadata_parsing_v2.cpp | 149 ------ Modules/app-module/source/module.cpp | 24 +- .../source/package_manifest_parsing.cpp | 201 ++++++++ .../source/package_manifest_parsing_v2.cpp | 172 +++++++ .../source/package_manifest_parsing_v3.cpp | 238 +++++++++ Modules/app-module/source/paths.cpp | 28 +- Modules/app-module/source/scheduler.cpp | 2 +- .../app-module/tests/source/stream_test.cpp | 6 +- .../source/app_posix_loader_service.cpp | 21 +- Modules/app-posix-module/tests/CMakeLists.txt | 15 +- .../tests/source/loader_test.cpp | 8 +- .../Tactility/app/appdetails/AppDetails.h | 9 - .../app/apppackagedetails/AppPackageDetails.h | 9 + Tactility/Source/DeprecatedPaths.cpp | 4 +- Tactility/Source/Tactility.cpp | 10 +- .../Source/app/alertdialog/AlertDialog.cpp | 2 +- .../app/apphubdetails/AppHubDetailsApp.cpp | 10 +- Tactility/Source/app/applist/AppList.cpp | 2 +- .../AppPackageDetails.cpp} | 114 +++-- .../AppPackageList.cpp} | 48 +- Tactility/Source/app/boot/Boot.cpp | 2 +- Tactility/Source/app/btmanage/BtManage.cpp | 2 +- .../app/btpeersettings/BtPeerSettings.cpp | 2 +- .../app/crashdiagnostics/CrashDiagnostics.cpp | 2 +- .../Source/app/development/Development.cpp | 2 +- .../app/fileselection/FileSelection.cpp | 2 +- .../Source/app/gpssettings/GpsSettings.cpp | 2 +- .../Source/app/i2cscanner/I2cScanner.cpp | 2 +- .../Source/app/imageviewer/ImageViewer.cpp | 2 +- .../Source/app/inputdialog/InputDialog.cpp | 2 +- Tactility/Source/app/launcher/Launcher.cpp | 2 +- Tactility/Source/app/notes/Notes.cpp | 2 +- .../app/selectiondialog/SelectionDialog.cpp | 2 +- Tactility/Source/app/settings/Settings.cpp | 2 +- Tactility/Source/app/setup/Setup.cpp | 2 +- .../app/timedatesettings/TimeDateSettings.cpp | 2 +- Tactility/Source/app/timezone/TimeZone.cpp | 2 +- .../app/wifiapsettings/WifiApSettings.cpp | 2 +- .../Source/app/wificonnect/WifiConnect.cpp | 2 +- .../Source/app/wifimanage/WifiManage.cpp | 2 +- .../development/DevelopmentService.cpp | 2 +- Tests/SdkIntegration/manifest.properties | 14 +- Tests/SdkIntegration/tactility.py | 475 +++++++++++++----- 62 files changed, 1746 insertions(+), 981 deletions(-) delete mode 100644 Modules/app-module/include/app/metadata.h create mode 100644 Modules/app-module/include/app/package_manifest.h create mode 100644 Modules/app-module/private/app/private/binary_path.h delete mode 100644 Modules/app-module/private/app/private/metadata_parsing_internal.h create mode 100644 Modules/app-module/private/app/private/package_manifest_parsing.h delete mode 100644 Modules/app-module/source/metadata_parsing.cpp delete mode 100644 Modules/app-module/source/metadata_parsing_v1.cpp delete mode 100644 Modules/app-module/source/metadata_parsing_v2.cpp create mode 100644 Modules/app-module/source/package_manifest_parsing.cpp create mode 100644 Modules/app-module/source/package_manifest_parsing_v2.cpp create mode 100644 Modules/app-module/source/package_manifest_parsing_v3.cpp delete mode 100644 Tactility/Private/Tactility/app/appdetails/AppDetails.h create mode 100644 Tactility/Private/Tactility/app/apppackagedetails/AppPackageDetails.h rename Tactility/Source/app/{appdetails/AppDetails.cpp => apppackagedetails/AppPackageDetails.cpp} (54%) rename Tactility/Source/app/{appsettings/AppSettings.cpp => apppackagelist/AppPackageList.cpp} (73%) diff --git a/.github/actions/build-sdk-posix/action.yml b/.github/actions/build-sdk-posix/action.yml index 27fa8703..5bab1cc2 100644 --- a/.github/actions/build-sdk-posix/action.yml +++ b/.github/actions/build-sdk-posix/action.yml @@ -22,19 +22,17 @@ runs: run: python Buildscripts/release-sdk-posix.py release/TactilitySDK - name: 'Test Integration Prep' shell: bash - # The manifest.properties of our integration test uses version 0.0.0 to indicate that it is not using a normal SDK - # This way, it only works with our custom build. That means we have to create a copy of the SDK with the correct folder structure: env: TACTILITY_ARCH: ${{ steps.arch.outputs.value }} run: | - TACTILITY_SDK_NAME="0.0.0-posix-$TACTILITY_ARCH" + TACTILITY_SDK_NAME="$(cat version.txt)-posix-$TACTILITY_ARCH" mkdir -p test_sdk/$TACTILITY_SDK_NAME cp -r release/TactilitySDK test_sdk/$TACTILITY_SDK_NAME - name: 'Test Integration' shell: bash env: TACTILITY_ARCH: ${{ steps.arch.outputs.value }} - run: cd Tests/SdkIntegration && TACTILITY_SDK_PATH=../../test_sdk python tactility.py build posix-$TACTILITY_ARCH --local-sdk + run: cd Tests/SdkIntegration && TACTILITY_SDK_PATH=../../test_sdk python tactility.py build -a posix-$TACTILITY_ARCH --local-sdk - name: 'Upload Artifact' uses: actions/upload-artifact@v4 with: diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index c5bfa575..53813014 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -35,7 +35,7 @@ runs: # The manifest.properties of our integration test uses version 0.0.0 to indicate that it is not using a normal SDK # This way, it only works with our custom build. That means we have to create a copy of the SDK with the correct folder structure: run: | - TACTILITY_SDK_NAME="0.0.0-${{ inputs.arch }}" + TACTILITY_SDK_NAME="$(cat version.txt)-${{ inputs.arch }}" mkdir -p test_sdk/$TACTILITY_SDK_NAME cp -r release/TactilitySDK test_sdk/$TACTILITY_SDK_NAME - name: 'Test Integration' @@ -43,7 +43,7 @@ runs: with: esp_idf_version: v5.5.2 target: ${{ inputs.arch }} - command: export TACTILITY_SDK_PATH=../../test_sdk && cd Tests/SdkIntegration && python tactility.py build ${{ inputs.arch }} --local-sdk + command: export TACTILITY_SDK_PATH=../../test_sdk && cd Tests/SdkIntegration && python tactility.py build -a ${{ inputs.arch }} --local-sdk - name: 'Upload Artifact' uses: actions/upload-artifact@v4 with: diff --git a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp index e8d4e709..6d8a5110 100644 --- a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp +++ b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp @@ -16,6 +16,8 @@ #include #include +#include + #include #include #include @@ -64,13 +66,22 @@ error_t read_file(const char* path, uint8_t** out_data, size_t* out_size) { return ERROR_NONE; } -// location.location can be either an app's install directory or the .elf file directly; the -// former resolves to the per-target binary at {dir}/elf/{CONFIG_IDF_TARGET}.elf. +bool is_regular_file(const std::string& path) { + struct stat path_stat {}; + return ::stat(path.c_str(), &path_stat) == 0 && S_ISREG(path_stat.st_mode); +} + +// location.location can be either an app's install directory or the .elf file directly. A +// "packaged" app's install directory always holds its single binary at the fixed path +// {dir}/bin/{CONFIG_IDF_TARGET}/app.elf - a "terminal" app has no such file (its several +// binaries keep their own names), so this correctly leaves it unresolvable - terminal apps +// aren't run through AppLoaderApi (see app/install.h). std::string resolve_elf_path(const std::string& path) { if (path.ends_with(".elf")) { return path; } - return path + "/elf/" + CONFIG_IDF_TARGET + ".elf"; + std::string candidate = path + "/bin/" CONFIG_IDF_TARGET "/app.elf"; + return is_regular_file(candidate) ? candidate : ""; } constexpr ElfRequirements EXECUTABLE_REQUIREMENTS = { diff --git a/Modules/app-module/CMakeLists.txt b/Modules/app-module/CMakeLists.txt index 5312b448..442d5811 100644 --- a/Modules/app-module/CMakeLists.txt +++ b/Modules/app-module/CMakeLists.txt @@ -9,6 +9,7 @@ tactility_add_module(app-module PRIV_INCLUDE_DIRS private/ INCLUDE_DIRS include/ REQUIRES TactilityKernel service-module minitar + PRIV_REQUIRES TactilityKernelCpp ) # Tells source/io.cpp its real-syscall fallback must go through __real_read/write/close() @@ -19,3 +20,11 @@ if (NOT APPLE) tactility_get_module_name(app-module MODULE_NAME) target_compile_definitions(${MODULE_NAME} PRIVATE TT_APP_IO_WRAPS_STDIO) endif () + +# install.cpp resolves an installed binary's fixed bin//.so path itself, so it +# needs the same platform-arch string app-posix-module's own CMakeLists.txt defines for its +# loader (the ESP32 equivalent, CONFIG_IDF_TARGET, comes for free from sdkconfig.h there). +if (NOT ESP_PLATFORM) + tactility_get_module_name(app-module MODULE_NAME) + target_compile_definitions(${MODULE_NAME} PRIVATE "TACTILITY_POSIX_ARCH=\"${CMAKE_SYSTEM_PROCESSOR}\"") +endif () diff --git a/Modules/app-module/include/app/install.h b/Modules/app-module/include/app/install.h index d6819f07..f3f7da41 100644 --- a/Modules/app-module/include/app/install.h +++ b/Modules/app-module/include/app/install.h @@ -12,8 +12,8 @@ extern "C" { /** * 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) + * @param[out] path always NULL-terminated on return, even on failure. Empty when @a path_size + * is 0, since nothing is written in that case. * @retval ERROR_NONE on success * @retval ERROR_BUFFER_OVERFLOW @a path_size is too small to hold the path (including the * NULL terminator) @@ -22,14 +22,15 @@ extern "C" { 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) + * Installs a package from a tarball at @a source_path: extracts it into the package's install + * directory, parses the extracted manifest.properties (see app/package_manifest.h) into a + * PackageManifest and one or more AppManifestBindings, then registers each with app_manager_add() + * as an AppLocation{APP_LOCATION_PATH, } app. + * If a package with the same id is already installed (via a previous app_install() call), it is + * uninstalled first: every one of its apps stopped if running, then its old install directory + * removed, before the new one takes its place. + * @param[in] source_path path to a tar file containing the package (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 @@ -37,11 +38,11 @@ error_t app_get_install_path(const char* app_id, char* path, size_t path_size); 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) + * Uninstalls a previously app_install()-ed package: stops every one of its apps if currently + * running, deletes its install directory, and unregisters all of them (app_manager_remove()). + * @param[in] app_id the package id it was installed under (PackageManifest::id) * @retval ERROR_NONE on success - * @retval ERROR_NOT_FOUND no such app was installed via app_install() + * @retval ERROR_NOT_FOUND no such package was installed via app_install() */ error_t app_uninstall(const char* app_id); diff --git a/Modules/app-module/include/app/loader.h b/Modules/app-module/include/app/loader.h index ec728fa0..9c74f082 100644 --- a/Modules/app-module/include/app/loader.h +++ b/Modules/app-module/include/app/loader.h @@ -1,11 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include "location.h" #include -#include #include #include -#include "location.h" +#include #ifdef __cplusplus extern "C" { diff --git a/Modules/app-module/include/app/manager.h b/Modules/app-module/include/app/manager.h index 3b92bb16..e4984f01 100644 --- a/Modules/app-module/include/app/manager.h +++ b/Modules/app-module/include/app/manager.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -44,6 +45,48 @@ error_t app_manager_find_manifest(const char* id, struct AppManifest* out_manife typedef void (*AppManifestVisitorFn)(const struct AppManifest* manifest, void* context); void app_manager_for_each_manifest(AppManifestVisitorFn visitor, void* context); +/** + * Registers a package for enumeration via app_manager_for_each_package(). Separate from + * registering its apps - the caller still calls app_manager_add() for each AppManifest. + * @param[in] app_ids the ids of the AppManifest(s) this package registered + * @retval ERROR_INVALID_ARGUMENT a package with the same id is already registered + * @retval ERROR_NONE on success + */ +error_t app_manager_add_package(const struct PackageManifest* package, const char* const* app_ids, size_t app_id_count); + +/** + * Unregisters a previously-added package. Does not touch its apps' own registrations. + * @retval ERROR_NOT_FOUND no package with this id is registered + * @retval ERROR_NONE on success + */ +error_t app_manager_remove_package(const char* package_id); + +/** + * @param[out] out_package set to a copy of the package on success + * @retval ERROR_NOT_FOUND no package with this id is registered + * @retval ERROR_NONE on success + */ +error_t app_manager_find_package(const char* package_id, struct PackageManifest* out_package); + +/** One registered package, handed to AppPackageVisitorFn - see app_manager_for_each_package(). */ +struct AppPackage { + struct PackageManifest package; + /** How many entries @a app_ids points to. */ + size_t app_id_count; + /** Valid only for the duration of the app_manager_for_each_package() call that produced + * this - copy out what's needed before returning from the visitor. */ + const char* const* app_ids; +}; + +typedef void (*AppPackageVisitorFn)(const struct AppPackage* pkg, void* context); + +/** + * Calls @a visitor once for every registered package. Iteration order is unspecified. + * @warning Same threading contract as app_manager_for_each_manifest(): runs with an internal + * lock held - do not call any app_manager_*() function from inside @a visitor. + */ +void app_manager_for_each_package(AppPackageVisitorFn visitor, void* context); + /** One fd-to-stream binding for app_start_with_streams() (app/start.h). Every field is passed * through to app_stream_subscribe() as-is; see its own doc for the ownership contracts. */ struct AppStreamBinding { @@ -90,8 +133,8 @@ error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size); /** * Registers @a path as a directory to scan for app manifests - each direct subdirectory of - * @a path is expected to hold a manifest.properties (see app/metadata.h), matching the layout - * app_install() creates ({install dir}/{app_id}/manifest.properties), though this is not + * @a path is expected to hold a manifest.properties (see app/package_manifest.h), matching the + * layout app_install() creates ({install dir}/{package id}/manifest.properties), though this is not * install/uninstall - it only ever adds/removes manifest registrations, never touches files on * disk or running instances. No-op if @a path is already registered. Does not scan immediately - * call app_manager_install_path_scan() to do that. diff --git a/Modules/app-module/include/app/manifest.h b/Modules/app-module/include/app/manifest.h index 5d8ea67e..e365d52f 100644 --- a/Modules/app-module/include/app/manifest.h +++ b/Modules/app-module/include/app/manifest.h @@ -11,7 +11,10 @@ extern "C" { #endif // Character count, excluding null terminator -#define APP_ID_LENGTH 32 +#define APP_MANIFEST_ID_LENGTH 32 + +// Character count, excluding null terminator +#define APP_MANIFEST_NAME_LENGTH 32 /** Broad classification of an app, used for grouping/launcher presentation. */ enum AppCategory { @@ -45,10 +48,10 @@ struct AppStackConfig { /** 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; + /** Unique app identifier. Must be NULL-terminated. */ + char id[APP_MANIFEST_ID_LENGTH + 1]; + /** Human-readable name. Must be NULL-terminated. */ + char name[APP_MANIFEST_NAME_LENGTH + 1]; enum AppCategory category; struct AppLocation location; /** Bitmask of AppManifestFlags. Most apps should leave this 0. */ @@ -57,7 +60,9 @@ struct AppManifest { struct AppStackConfig stack; }; -bool app_id_is_valid(const char* id); +bool app_manifest_id_is_valid(const char* id); +bool app_manifest_name_is_valid(const char* name); +bool app_manifest_stack_size_is_valid(const char* value); #ifdef __cplusplus } diff --git a/Modules/app-module/include/app/metadata.h b/Modules/app-module/include/app/metadata.h deleted file mode 100644 index 364dd2cf..00000000 --- a/Modules/app-module/include/app/metadata.h +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#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 -#define APP_METADATA_REQUIRES_DEVICE_ID_LENGTH 64 - -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; - - /** - * Comma-separated list of device ids the app is restricted to (e.g. "m5stack-tab5"), matching - * the folder names under Devices/. Empty means unrestricted. - * Must be NULL-terminated. - */ - char requires_device_id[APP_METADATA_REQUIRES_DEVICE_ID_LENGTH + 1]; - - /** - * Stack depth (in words) for the app's task. Optional; 0 means scheduler default. - * @warning Avoid default values: the default is conservative, which wastes memory. - */ - uint32_t stack_depth; -}; - -/** - * 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/package_manifest.h b/Modules/app-module/include/app/package_manifest.h new file mode 100644 index 00000000..ea0be1f8 --- /dev/null +++ b/Modules/app-module/include/app/package_manifest.h @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define PACKAGE_MANIFEST_TARGET_SDK_LENGTH 16 +#define PACKAGE_MANIFEST_ID_LENGTH 32 +#define PACKAGE_MANIFEST_VERSION_NAME_LENGTH 16 +#define PACKAGE_MANIFEST_REQUIRES_DEVICE_ID_LENGTH 64 + +/** Character count, excluding null terminator, for AppManifestBinding::binary. */ +#define APP_MANIFEST_BINARY_LENGTH 31 + +/** Largest number of AppManifest entries package_manifest_parse() can produce from a single + * manifest.properties file. */ +#define PACKAGE_MANIFEST_MAX_APP_MANIFESTS 32 + +/** A package-level manifest.properties: SDK/version metadata that applies to the whole package, + * not to any one of its (possibly several) apps. Not kept in memory at runtime - only used to + * create and cache the AppManifest(s) it describes. */ +struct PackageManifest { + /** + * The package identifier (e.g. the install directory name). Distinct from any of its own + * AppManifest ids. + * Must be NULL-terminated. + */ + char id[PACKAGE_MANIFEST_ID_LENGTH + 1]; + + /** + * The package version as it is displayed to the user (e.g. "1.2.0") + * Must be NULL-terminated. + */ + char version_name[PACKAGE_MANIFEST_VERSION_NAME_LENGTH + 1]; + + /** The package's technical version (must be incremented with new releases). */ + uint64_t version_code; + + /** + * The SDK version that was used to compile this package. (e.g. "0.6.0") + * Must be NULL-terminated. + */ + char target_sdk[PACKAGE_MANIFEST_TARGET_SDK_LENGTH + 1]; + + /** + * Comma-separated list of device ids the package is restricted to (e.g. "m5stack-tab5"), + * matching the folder names under Devices/. Empty means unrestricted. + * Must be NULL-terminated. + */ + char requires_device_id[PACKAGE_MANIFEST_REQUIRES_DEVICE_ID_LENGTH + 1]; + + /** How many AppManifest entries this package's manifest.properties declared. */ + uint32_t app_manifest_count; +}; + +/** Pairs a parsed AppManifest with the filename (without extension) it installs as under + * bin// - e.g. "main" resolves to bin/posix-x86_64/main.so. Not kept anywhere at + * runtime - same lifetime as PackageManifest, only used to hand parse results to the installer/ + * scanner, which resolve `binary` into AppManifest::location::location. */ +struct AppManifestBinding { + struct AppManifest manifest; + char binary[APP_MANIFEST_BINARY_LENGTH + 1]; +}; + +/** + * Parses a manifest.properties file at @a path (flat dot-notation, e.g. "app.id=...") into + * @a out_package and @a out_bindings. + * @param[out] out_bindings written with up to @a bindings_capacity entries (see + * PackageManifest::app_manifest_count for how many) + * @param[in] bindings_capacity the capacity of @a out_bindings + * @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 + * its fixed-size buffer + * @retval ERROR_BUFFER_OVERFLOW the manifest declares more apps than @a bindings_capacity + */ +error_t app_package_manifest_parse(const char* path, struct PackageManifest* out_package, struct AppManifestBinding* out_bindings, size_t bindings_capacity); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-module/private/app/private/binary_path.h b/Modules/app-module/private/app/private/binary_path.h new file mode 100644 index 00000000..18dd9c11 --- /dev/null +++ b/Modules/app-module/private/app/private/binary_path.h @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef ESP_PLATFORM +#include +#endif + +#include + +// Resolves an AppManifestBinding::binary filename (without extension) to its installed path +// under this fixed, predictable location, mirroring what each platform's own loader +// (app_posix_loader_service.cpp / app_esp32_loader_service.cpp) already resolves a plain +// ".so"/".elf" AppLocation::location straight through unchanged - so setting location.location +// to this exact path means neither loader needs to guess which of a package's several binaries +// an AppManifest refers to. +inline std::string app_resolve_binary_path(const std::string& install_dir, const std::string& binary) { +#ifdef ESP_PLATFORM + return install_dir + "/bin/" CONFIG_IDF_TARGET "/" + binary + ".elf"; +#else + return install_dir + "/bin/posix-" TACTILITY_POSIX_ARCH "/" + binary + ".so"; +#endif +} diff --git a/Modules/app-module/private/app/private/ledger.h b/Modules/app-module/private/app/private/ledger.h index eab9b3dc..eead3df1 100644 --- a/Modules/app-module/private/app/private/ledger.h +++ b/Modules/app-module/private/app/private/ledger.h @@ -3,8 +3,11 @@ #include #include +#include #include +#include + #include #include #include @@ -13,6 +16,7 @@ #include #include #include +#include /** * A dedicated completion signal(1) for one app instance's task, given as the @@ -57,8 +61,16 @@ struct AppInstanceRecord { AppFdTable fd_table {}; }; +/** A registered installed package - see app_manager_add_package() (app/manager.h). */ +struct AppPackageRecord { + struct PackageManifest package; + std::vector app_ids; +}; + struct AppLedger { std::unordered_map manifests; + // OptExternalAllocator: bigger entries than `manifests`, and unlike `instances` isn't on the app start/stop hot path. + std::unordered_map, std::equal_to, tt::OptExternalAllocator>> packages; std::unordered_map instances; uint32_t next_instance_id = 1; Mutex mutex {}; diff --git a/Modules/app-module/private/app/private/metadata_parsing_internal.h b/Modules/app-module/private/app/private/metadata_parsing_internal.h deleted file mode 100644 index 689abfa4..00000000 --- a/Modules/app-module/private/app/private/metadata_parsing_internal.h +++ /dev/null @@ -1,35 +0,0 @@ -// 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_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); -bool app_metadata_is_valid_stack_size(const std::string& value); - -/** Validates a comma-separated list of device ids (alphanumeric + '-' items, matching Devices/ folder names). */ -bool app_metadata_is_valid_device_id_list(const std::string& value); - -/** 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); - -bool app_metadata_validate_string(const std::string& value, bool (*is_valid_char)(char)); diff --git a/Modules/app-module/private/app/private/package_manifest_parsing.h b/Modules/app-module/private/app/private/package_manifest_parsing.h new file mode 100644 index 00000000..4223a825 --- /dev/null +++ b/Modules/app-module/private/app/private/package_manifest_parsing.h @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#include +#include +#include + +/** Shared helpers + parser for app_package_manifest_parse() (source/package_manifest_parsing.cpp). */ + +bool app_package_manifest_get_value(const std::map& properties, const std::string& key, std::string& out_value); + +bool app_package_manifest_is_valid_format_version(const std::string& version); +bool app_package_manifest_is_valid_version_name(const std::string& version); +bool app_package_manifest_is_valid_version_code(const std::string& version); +bool app_package_manifest_is_valid_bool(const std::string& value); + +/** Validates a comma-separated list of device ids (alphanumeric + '-' items, matching Devices/ folder names). */ +bool app_package_manifest_is_valid_device_id_list(const std::string& value); + +/** Validates a binary filename stem (AppManifestBinding::binary): alphanumeric plus '.', '_', '-'. */ +bool app_package_manifest_is_valid_binary_name(const std::string& value); + +/** 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_package_manifest_copy_bounded(char* dest, size_t dest_size, const std::string& value); + +/** Parses a V2 (flat dot-notation, e.g. "app.version.name=...") manifest map into @a out_package + * and (unless @a bindings_capacity is 0) its single AppManifestBinding, out_bindings[0]. + * @retval ERROR_NONE on success + * @retval ERROR_INVALID_ARGUMENT a required key is missing or a value doesn't fit/validate + * @retval ERROR_BUFFER_OVERFLOW @a bindings_capacity is nonzero but smaller than needed */ +error_t package_manifest_parse_v2(const std::map& properties, struct PackageManifest& out_package, struct AppManifestBinding* out_bindings, size_t bindings_capacity); + +/** Parses a V3 (flat dot-notation with 0-indexed "app.N.*" blocks) manifest map into + * @a out_package and up to @a bindings_capacity AppManifestBinding entries. + * @retval ERROR_NONE on success + * @retval ERROR_INVALID_ARGUMENT a required key is missing or a value doesn't fit/validate + * @retval ERROR_BUFFER_OVERFLOW @a bindings_capacity is nonzero but smaller than the + * manifest's app_manifest_count */ +error_t package_manifest_parse_v3(const std::map& properties, struct PackageManifest& out_package, struct AppManifestBinding* out_bindings, size_t bindings_capacity); + +bool app_package_manifest_validate_string(const std::string& value, bool (*is_valid_char)(char)); + +/** Convenience wrapper around app_package_manifest_parse(): parses @a path once to learn + * PackageManifest::app_manifest_count, then resizes @a out_bindings to fit exactly and parses + * again to fill it - so the caller never needs to pre-allocate a fixed maximum. + * @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 + * its fixed-size buffer */ +error_t app_package_manifest_parse_into(const char* path, struct PackageManifest& out_package, std::vector>& out_bindings); diff --git a/Modules/app-module/source/install.cpp b/Modules/app-module/source/install.cpp index e7235c01..d8bd7a26 100644 --- a/Modules/app-module/source/install.cpp +++ b/Modules/app-module/source/install.cpp @@ -2,10 +2,14 @@ #include #include -#include +#include +#include #include #include +#include + +#include #include #include @@ -29,7 +33,7 @@ constexpr auto* TAG = "app_install"; namespace { -// region Filesystem helpers (app-module may not depend upward on Tactility::file - see +// 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) { @@ -164,9 +168,9 @@ bool untar(const std::string& tar_path, const std::string& destination_path) { // endregion // region Staging-path lock: at most one caller may clean up/populate a given staging_path at a -// time, keyed by source basename. The HTTP server and app tasks can call app_install() -// concurrently, e.g. two uploads sharing a source basename - without this, one call's cleanup -// can delete or overwrite the staging directory another call is still extracting into. +// time, keyed by source basename. The HTTP server and app tasks can call app_install_package() +// concurrently, e.g. two uploads sharing a source basename; without this lock, one call's +// cleanup could delete or overwrite the staging directory another call is still extracting into. struct StagingLock { Mutex mutex {}; @@ -223,18 +227,19 @@ void release_staging_lock(const std::string& path) { // 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). +// region Installed-package registry: owns the AppManifests (and their backing location-path +// strings) that app_manager's ledger only keeps non-owning pointers to (see app_manager_add()'s +// contract). AppManifest::id/name own their own storage directly (fixed arrays), so this doesn't +// need to separately own those. -struct InstalledAppRecord { - std::string id; - std::string name; - std::string path; - AppManifest manifest {}; +struct InstalledPackageRecord { + std::string path; // install directory + std::vector manifests; // one per AppManifest the package declared + std::vector locations; // backs manifests[i].location.location, same indices }; struct InstallRegistry { - std::unordered_map> apps; + std::unordered_map> apps; Mutex mutex {}; InstallRegistry() { mutex_construct(&mutex); } @@ -245,46 +250,68 @@ InstallRegistry& install_registry() { return registry; } -// Registers @a app_dir_path (already confirmed to hold a valid manifest.properties, parsed into -// @a metadata) with app_manager_add(), taking ownership of its id/name/path strings. -// @warning Caller must hold install_registry().mutex, and must have already ensured -// @a metadata.app_id isn't already registered (app_manager_add() rejects duplicates, but the -// InstalledAppRecord for the earlier registration would leak since this always inserts fresh). -error_t register_installed_app_locked(const std::string& app_dir_path, const AppMetadata& metadata) { +// Registers every AppManifest in @a bindings (@a count of them) with app_manager_add(), taking +// ownership of their backing location strings, then registers @a package itself. +// @warning Caller must hold install_registry().mutex. +error_t register_installed_package_locked(const PackageManifest& package, const std::string& install_path, const AppManifestBinding* bindings, size_t count) { auto& registry = install_registry(); - auto record = std::make_unique(); - record->id = metadata.app_id; - record->name = metadata.app_name; - record->path = app_dir_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, - .stack = { .depth = static_cast(metadata.stack_depth), .desired_memory_capability = 0 }, - }; + auto record = std::make_unique(); + record->path = install_path; + // Sized once, up front: manifests[i].location.location points into locations[i].c_str(), + // which would dangle if either vector reallocated afterward. + record->manifests.resize(count); + record->locations.resize(count); - // Belt-and-braces: app_install()'s earlier app_manager_remove() call is meant to have - // already cleared any stale registration for this id (e.g. left over from - // app_manager_install_path_scan()'s separate registry), but that call happens before the - // tarball is even extracted - remove once more, right before add, so a duplicate id can - // never turn a filesystem-level install success into a reported failure. - app_manager_remove(record->id.c_str()); - - error_t add_result = app_manager_add(&record->manifest); - if (add_result != ERROR_NONE) { - LOG_E(TAG, "Failed to register app '%s': %s", record->id.c_str(), error_to_string(add_result)); - return add_result; + for (size_t i = 0; i < count; i++) { + record->manifests[i] = bindings[i].manifest; + record->locations[i] = app_resolve_binary_path(install_path, bindings[i].binary); + record->manifests[i].location = { APP_LOCATION_PATH, const_cast(record->locations[i].c_str()) }; } - registry.apps[record->id] = std::move(record); + for (size_t i = 0; i < count; i++) { + // The caller's earlier uninstall_locked() call is meant to have already cleared any stale registration for this id + // (e.g. left over from app_manager_install_path_scan()'s separate registry), + // but that call happens before the package is even extracted / the binaries moved into place; remove once more + // right before add, so a duplicate id can never turn a filesystem-level install success into a reported failure. + // Only if installed (APP_LOCATION_PATH) - never steal a built-in's id. + AppManifest existing {}; + if (app_manager_find_manifest(record->manifests[i].id, &existing) == ERROR_NONE && existing.location.type == APP_LOCATION_PATH) { + app_manager_remove(record->manifests[i].id); + } + + error_t add_result = app_manager_add(&record->manifests[i]); + if (add_result != ERROR_NONE) { + LOG_E(TAG, "Failed to register app '%s': %s", record->manifests[i].id, error_to_string(add_result)); + // All-or-nothing: unregister whatever this package already added before failing. + for (size_t j = 0; j < i; j++) { + app_manager_remove(record->manifests[j].id); + } + return add_result; + } + } + + std::vector app_id_ptrs; + app_id_ptrs.reserve(count); + for (size_t i = 0; i < count; i++) { + app_id_ptrs.push_back(record->manifests[i].id); + } + app_manager_remove_package(package.id); + error_t add_package_result = app_manager_add_package(&package, app_id_ptrs.data(), app_id_ptrs.size()); + if (add_package_result != ERROR_NONE) { + LOG_E(TAG, "Failed to register package '%s': %s", package.id, error_to_string(add_package_result)); + for (size_t i = 0; i < count; i++) { + app_manager_remove(record->manifests[i].id); + } + return add_package_result; + } + + registry.apps[package.id] = std::move(record); return ERROR_NONE; } // 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 +// 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) { @@ -305,20 +332,22 @@ void stop_all_instances_of(const AppManifest* manifest) { } // Caller must already hold install_registry().mutex -error_t uninstall_locked(const std::string& app_id) { +error_t uninstall_locked(const std::string& package_id) { auto& registry = install_registry(); - auto iterator = registry.apps.find(app_id); + auto iterator = registry.apps.find(package_id); if (iterator == registry.apps.end()) { return ERROR_NOT_FOUND; } - // Can't uninstall in-memory apps - if (iterator->second->manifest.location.type != APP_LOCATION_PATH) { - return ERROR_NOT_SUPPORTED; + for (const auto& manifest : iterator->second->manifests) { + // Can't uninstall in-memory apps + if (manifest.location.type != APP_LOCATION_PATH) { + continue; + } + stop_all_instances_of(&manifest); + app_manager_remove(manifest.id); } - - stop_all_instances_of(&iterator->second->manifest); - app_manager_remove(app_id.c_str()); + app_manager_remove_package(package_id.c_str()); delete_recursively(iterator->second->path); registry.apps.erase(iterator); @@ -352,7 +381,7 @@ error_t app_get_install_path(const char* app_id, char* path, size_t path_size) { } error_t app_install(const char* source_path) { - LOG_I(TAG, "Installing app from %s", source_path); + LOG_I(TAG, "Installing app package from %s", source_path); std::string app_parent_path; if (!get_app_install_directory(app_parent_path)) { @@ -389,8 +418,9 @@ error_t app_install(const char* source_path) { return ERROR_INVALID_ARGUMENT; } - AppMetadata metadata {}; - if (app_metadata_parse(manifest_path.c_str(), &metadata) != ERROR_NONE) { + PackageManifest package {}; + std::vector> app_bindings; + if (app_package_manifest_parse_into(manifest_path.c_str(), package, app_bindings) != ERROR_NONE) { LOG_E(TAG, "Install failed: invalid manifest"); delete_recursively(staging_path); release_staging_lock(staging_path); @@ -400,24 +430,13 @@ error_t app_install(const char* source_path) { 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() only clears app_install.cpp's own - // registry - the same app id may instead be registered by app_manager_install_path_scan() - // (manager.cpp's separate registry, scanning this same directory tree), which - // uninstall_locked() doesn't know about. Clear the app-manager registration unconditionally - // too, or app_manager_add() below rejects the re-add as a duplicate. - uninstall_locked(metadata.app_id); + // Replace any previous installation of this package - this also handles the app_manager + // registrations of its old AppManifests, so there's no separate app_manager_remove() needed + // here (register_installed_package_locked() below still defends against a stale registration + // per individual id, e.g. one left by app_manager_install_path_scan()'s separate registry). + uninstall_locked(package.id); - error_t remove_result = app_manager_remove(metadata.app_id); - if (remove_result != ERROR_NONE && remove_result != ERROR_NOT_FOUND) { - LOG_E(TAG, "Install failed: failed to remove existing installation"); - mutex_unlock(®istry.mutex); - delete_recursively(staging_path); - release_staging_lock(staging_path); - return ERROR_RESOURCE; - } - - auto final_path = app_parent_path + "/" + metadata.app_id; + auto final_path = app_parent_path + "/" + package.id; delete_recursively(final_path); if (rename(staging_path.c_str(), final_path.c_str()) != 0) { @@ -429,9 +448,8 @@ error_t app_install(const char* source_path) { } release_staging_lock(staging_path); - // Only remaining failure mode is a duplicate id - can't happen, uninstall_locked() above - // already removed any previous registration for this exact id. - error_t add_result = register_installed_app_locked(final_path, metadata); + // app_bindings.size(), not package.app_manifest_count - the safe bound to index by. + error_t add_result = register_installed_package_locked(package, final_path, app_bindings.data(), app_bindings.size()); mutex_unlock(®istry.mutex); return add_result; diff --git a/Modules/app-module/source/manager.cpp b/Modules/app-module/source/manager.cpp index c7614899..f9766312 100644 --- a/Modules/app-module/source/manager.cpp +++ b/Modules/app-module/source/manager.cpp @@ -1,13 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 #include -#include +#include #include +#include #include #include #include #include +#include #include +#include + #include #include #include @@ -72,6 +76,67 @@ void app_manager_for_each_manifest(AppManifestVisitorFn visitor, void* context) mutex_unlock(&ledger.mutex); } +error_t app_manager_add_package(const PackageManifest* package, const char* const* app_ids, size_t app_id_count) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + if (ledger.packages.contains(package->id)) { + mutex_unlock(&ledger.mutex); + LOG_E(TAG, "Package with id '%s' is already registered", package->id); + return ERROR_INVALID_ARGUMENT; + } + AppPackageRecord record { .package = *package }; + record.app_ids.reserve(app_id_count); + for (size_t i = 0; i < app_id_count; i++) { + record.app_ids.emplace_back(app_ids[i]); + } + ledger.packages[package->id] = std::move(record); + mutex_unlock(&ledger.mutex); + + return ERROR_NONE; +} + +error_t app_manager_remove_package(const char* package_id) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.packages.find(package_id); + if (iterator == ledger.packages.end()) { + mutex_unlock(&ledger.mutex); + return ERROR_NOT_FOUND; + } + ledger.packages.erase(iterator); + mutex_unlock(&ledger.mutex); + + return ERROR_NONE; +} + +error_t app_manager_find_package(const char* package_id, PackageManifest* out_package) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + auto iterator = ledger.packages.find(package_id); + if (iterator == ledger.packages.end()) { + mutex_unlock(&ledger.mutex); + return ERROR_NOT_FOUND; + } + *out_package = iterator->second.package; + mutex_unlock(&ledger.mutex); + return ERROR_NONE; +} + +void app_manager_for_each_package(AppPackageVisitorFn visitor, void* context) { + auto& ledger = app_ledger(); + mutex_lock(&ledger.mutex); + for (auto& [id, record] : ledger.packages) { + std::vector app_id_ptrs; + app_id_ptrs.reserve(record.app_ids.size()); + for (const auto& app_id : record.app_ids) { + app_id_ptrs.push_back(app_id.c_str()); + } + AppPackage pkg { .package = record.package, .app_id_count = app_id_ptrs.size(), .app_ids = app_id_ptrs.data() }; + visitor(&pkg, context); + } + mutex_unlock(&ledger.mutex); +} + error_t app_manager_start_internal(const AppManifest* manifest, AppLocation location, AppStackConfig stack, AppInstanceId parent_instance_id, int argc, const char* const argv_in[], const AppStreamBinding* bindings, size_t binding_count, AppInstanceId* out_app_instance_id) { char** argv = app_arguments_copy(argc, argv_in); if (argc > 0 && argv == nullptr) { @@ -200,19 +265,19 @@ error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size) { namespace { -// Owns the AppManifest (and its id/name/path strings) that app_manager_add() only keeps a -// non-owning pointer to. Separate from app_install.cpp's registry: scanning only -// adds/removes registrations, never touches disk or running instances. -struct ScannedAppManifest { - std::string id; - std::string name; - std::string path; - AppManifest manifest {}; +// Owns the AppManifests (and their backing location-path strings) that app_manager_add() only +// keeps non-owning pointers to. Separate from app_install.cpp's registry: scanning only +// adds/removes registrations, never touches disk or running instances. AppManifest::id/name own +// their own storage directly (fixed arrays), so this doesn't need to separately own those. +struct ScannedPackageManifest { + std::string path; // scanned directory + std::vector manifests; // one per AppManifest the package declared + std::vector locations; // backs manifests[i].location.location, same indices }; struct InstallPathRegistry { std::vector paths; - std::unordered_map> scanned; + std::unordered_map> scanned; Mutex mutex {}; InstallPathRegistry() { mutex_construct(&mutex); } @@ -249,50 +314,69 @@ void app_manager_install_path_scan(void) { app_fs_list_direct_subdirectories(root, found_app_dirs); } - // Snapshot once so the rest of the scan doesn't hold registry.mutex. + // Snapshot once so the rest of the scan doesn't hold registry.mutex. Keeps each known + // package's own manifest ids too, so a package whose directory has disappeared can have all + // of its (possibly several) app_manager registrations removed below, not just one. + struct KnownPackage { + std::string path; + std::vector manifest_ids; + }; mutex_lock(®istry.mutex); - std::unordered_map known_paths; + std::unordered_map known_packages; for (const auto& [id, record] : registry.scanned) { - known_paths.emplace(id, record->path); + KnownPackage known { .path = record->path }; + for (const auto& manifest : record->manifests) { + known.manifest_ids.emplace_back(manifest.id); + } + known_packages.emplace(id, std::move(known)); } mutex_unlock(®istry.mutex); // Parses without registry.mutex held; filesystem IO is slow. - std::vector> new_records; + std::vector> new_records; + std::vector new_package_ids; + std::vector new_packages; for (const auto& app_dir : found_app_dirs) { auto manifest_path = app_dir + "/manifest.properties"; if (!app_fs_is_file(manifest_path)) { continue; } - AppMetadata metadata {}; - if (app_metadata_parse(manifest_path.c_str(), &metadata) != ERROR_NONE) { + PackageManifest package {}; + // Heap-allocated (not a stack array - too large for a typical app task's stack; this + // function runs on whichever task calls app_manager_install_path_scan(), e.g. Boot's, via + // registerInstalledAppsFromFileSystems()) and sized to fit exactly, not pre-allocated to + // some fixed maximum (see app_package_manifest_parse_into()). OptExternalAllocator prefers + // PSRAM for this transient buffer, freeing up scarce internal RAM. + std::vector> app_bindings; + if (app_package_manifest_parse_into(manifest_path.c_str(), package, app_bindings) != ERROR_NONE) { LOG_W(TAG, "Invalid manifest at %s", manifest_path.c_str()); continue; } - if (known_paths.contains(metadata.app_id)) { + if (known_packages.contains(package.id)) { continue; } - auto record = std::make_unique(); - record->id = metadata.app_id; - record->name = metadata.app_name; + auto record = std::make_unique(); record->path = app_dir; - 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, - .stack = { .depth = static_cast(metadata.stack_depth), .desired_memory_capability = 0 }, - }; + // Sized once, up front: manifests[i].location.location points into locations[i].c_str(), + // which would dangle if either vector reallocated afterward. + record->manifests.resize(app_bindings.size()); + record->locations.resize(app_bindings.size()); + for (size_t i = 0; i < app_bindings.size(); i++) { + record->manifests[i] = app_bindings[i].manifest; + record->locations[i] = app_resolve_binary_path(app_dir, app_bindings[i].binary); + record->manifests[i].location = { APP_LOCATION_PATH, const_cast(record->locations[i].c_str()) }; + } + new_package_ids.emplace_back(package.id); + new_packages.push_back(package); new_records.push_back(std::move(record)); } std::vector missing_ids; - for (const auto& [id, path] : known_paths) { - if (!app_fs_is_directory(path)) { + for (const auto& [id, known] : known_packages) { + if (!app_fs_is_directory(known.path)) { missing_ids.push_back(id); } } @@ -301,23 +385,60 @@ void app_manager_install_path_scan(void) { // registry.mutex would fix a lock order an opposite-order caller could deadlock against. // registry.mutex is retaken afterward only to publish the in-memory results. for (const auto& id : missing_ids) { - app_manager_remove(id.c_str()); - } - std::vector> added_records; - for (auto& record : new_records) { - if (app_manager_add(&record->manifest) == ERROR_NONE) { - added_records.push_back(std::move(record)); - } else { - LOG_E(TAG, "Failed to register app %s (duplicate id?)", record->id.c_str()); + for (const auto& manifest_id : known_packages.at(id).manifest_ids) { + app_manager_remove(manifest_id.c_str()); } + app_manager_remove_package(id.c_str()); + } + std::vector> added_records; + std::vector added_package_ids; + for (size_t r = 0; r < new_records.size(); r++) { + auto& record = new_records[r]; + + // known_packages is only a pre-scan snapshot - two new directories can still share an id. + if (std::ranges::find(added_package_ids, new_package_ids[r]) != added_package_ids.end()) { + LOG_W(TAG, "Skipping duplicate package id %s found in this scan", new_package_ids[r].c_str()); + continue; + } + + size_t added_count = 0; + for (; added_count < record->manifests.size(); added_count++) { + if (app_manager_add(&record->manifests[added_count]) != ERROR_NONE) { + LOG_E(TAG, "Failed to register app %s (duplicate id?)", record->manifests[added_count].id); + break; + } + } + if (added_count != record->manifests.size()) { + // All-or-nothing: unregister whatever this package already added before failing. + for (size_t j = 0; j < added_count; j++) { + app_manager_remove(record->manifests[j].id); + } + continue; + } + + std::vector app_id_ptrs; + app_id_ptrs.reserve(record->manifests.size()); + for (const auto& app_manifest : record->manifests) { + app_id_ptrs.push_back(app_manifest.id); + } + if (app_manager_add_package(&new_packages[r], app_id_ptrs.data(), app_id_ptrs.size()) != ERROR_NONE) { + LOG_E(TAG, "Failed to register package %s (duplicate id?)", new_packages[r].id); + for (const auto& app_manifest : record->manifests) { + app_manager_remove(app_manifest.id); + } + continue; + } + + added_package_ids.push_back(new_package_ids[r]); + added_records.push_back(std::move(record)); } mutex_lock(®istry.mutex); for (const auto& id : missing_ids) { registry.scanned.erase(id); } - for (auto& record : added_records) { - registry.scanned[record->id] = std::move(record); + for (size_t i = 0; i < added_records.size(); i++) { + registry.scanned[added_package_ids[i]] = std::move(added_records[i]); } mutex_unlock(®istry.mutex); } @@ -332,7 +453,10 @@ error_t app_manager_install_path_uninstall(const char* app_id) { return ERROR_NOT_FOUND; } - const AppManifest* manifest = &iterator->second->manifest; + // Pointer, not a copy: the ledger's own AppInstanceRecord::manifest pointers (set by + // app_manager_add() from this exact vector) are compared against it by address below, same + // as the pre-existing single-manifest version of this function did. + const std::vector* manifests = &iterator->second->manifests; auto path = iterator->second->path; mutex_unlock(®istry.mutex); @@ -342,8 +466,11 @@ error_t app_manager_install_path_uninstall(const char* app_id) { auto& ledger = app_ledger(); mutex_lock(&ledger.mutex); for (const auto& [id, record] : ledger.instances) { - if (record.manifest == manifest) { - instance_ids.push_back(id); + for (const auto& manifest : *manifests) { + if (record.manifest == &manifest) { + instance_ids.push_back(id); + break; + } } } mutex_unlock(&ledger.mutex); @@ -354,7 +481,10 @@ error_t app_manager_install_path_uninstall(const char* app_id) { // app_manager_remove() takes ledger.mutex; call outside registry.mutex too, matching // the lock order in app_manager_install_path_scan(). - app_manager_remove(app_id); + for (const auto& manifest : *manifests) { + app_manager_remove(manifest.id); + } + app_manager_remove_package(app_id); // Delete before erasing the scan record, so a failed deletion still leaves the // entry discoverable for a retry. diff --git a/Modules/app-module/source/manifest.cpp b/Modules/app-module/source/manifest.cpp index 81adb02d..a844830c 100644 --- a/Modules/app-module/source/manifest.cpp +++ b/Modules/app-module/source/manifest.cpp @@ -1,15 +1,30 @@ #include -#include +#include #include #include extern "C" { -bool app_id_is_valid(const char* id) { +bool app_manifest_id_is_valid(const char* id) { auto size = strlen(id); - return size >= 5 && size <= APP_ID_LENGTH && app_metadata_validate_string(id, [](char c) { + return size >= 5 && size <= APP_MANIFEST_ID_LENGTH && app_package_manifest_validate_string(id, [](char c) { return std::isalnum(static_cast(c)) != 0 || c == '.'; }); } +bool app_manifest_name_is_valid(const char* name) { + auto size = strlen(name); + return size >= 2 && size <= APP_MANIFEST_NAME_LENGTH && app_package_manifest_validate_string(name, [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == ' ' || c == '-'; + }); +} + +bool app_manifest_stack_size_is_valid(const char* value) { + // 10 digits is the maximum decimal width of uint32_t. + auto size = strlen(value); + return size > 0 && size <= 10 && app_package_manifest_validate_string(value, [](char c) { + return std::isdigit(static_cast(c)) != 0; + }); +} + } diff --git a/Modules/app-module/source/metadata_parsing.cpp b/Modules/app-module/source/metadata_parsing.cpp deleted file mode 100644 index 2b44afd7..00000000 --- a/Modules/app-module/source/metadata_parsing.cpp +++ /dev/null @@ -1,190 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#include -#include - -#include - -#include -#include -#include -#include -#include - -constexpr auto* TAG = "app_metadata"; - -bool app_metadata_validate_string(const std::string& value, bool (*is_valid_char)(char)) { - for (char c: value) { - if (!is_valid_char(c)) { - return false; - } - } - return true; -} - -namespace { - -#define validate_string app_metadata_validate_string - -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); -} - -/** Validates a comma-separated list: non-empty, no leading/trailing/double commas (which would - * produce an empty item), and every item passing @a is_valid_item. */ -bool validate_csv_list(const std::string& value, bool (*is_valid_item)(const std::string&)) { - if (value.empty()) { - return false; - } - size_t start = 0; - while (true) { - auto comma = value.find(',', start); - auto end = comma == std::string::npos ? value.size() : comma; - if (end == start || !is_valid_item(value.substr(start, end - start))) { - return false; - } - if (comma == std::string::npos) { - return true; - } - start = comma + 1; - } -} - -/** 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) { - std::ifstream file(path); - if (!file.is_open()) { - 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 (trimmed_line.empty() || trimmed_line.starts_with("#")) { - continue; - } - - if (!got_first_line) { - out_first_line = trimmed_line; - got_first_line = true; - } - - 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; - } - - 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_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) { - // 20 digits is the maximum decimal width of uint64_t. - return !version.empty() && version.size() <= 20 && validate_string(version, [](char c) { - return std::isdigit(static_cast(c)) != 0; - }); -} - -bool app_metadata_is_valid_stack_size(const std::string& value) { - // 10 digits is the maximum decimal width of uint32_t. - return !value.empty() && value.size() <= 10 && validate_string(value, [](char c) { - return std::isdigit(static_cast(c)) != 0; - }); -} - -bool app_metadata_is_valid_device_id_list(const std::string& value) { - return validate_csv_list(value, [](const std::string& item) { - bool has_alnum = false; - for (char c: item) { - if (std::isalnum(static_cast(c)) != 0) { - has_alnum = true; - } else if (c != '-') { - return false; - } - } - return has_alnum; - }); -} - -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); - - // requires_device_id is optional in V2 and unwritten by V1; zeroing here (rather than relying - // on the caller) guarantees it reads back as empty ("unrestricted") either way. - *out_metadata = {}; - - 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/metadata_parsing_v1.cpp b/Modules/app-module/source/metadata_parsing_v1.cpp deleted file mode 100644 index d6f74200..00000000 --- a/Modules/app-module/source/metadata_parsing_v1.cpp +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#include "app/manifest.h" - - -#include -#include - -#include - -#include - -constexpr auto* TAG = "app_metadata_v1"; - -bool app_metadata_parse_v1(const std::map& properties, AppMetadata& out_metadata) { - // [manifest] - LOG_W(TAG, "This manifest version is deprecated. Replace it with the newer version."); - - 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_id_is_valid(id.c_str())) { - 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; - } - - uint64_t version_code = 0; - const auto* first = version_code_string.data(); - const auto* last = first + version_code_string.size(); - if (std::from_chars(first, last, version_code).ec != std::errc {}) { - LOG_E(TAG, "App version code out of range"); - return false; - } - out_metadata.app_version_code = version_code; // [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; - } - - out_metadata.stack_depth = 0; - - return true; -} diff --git a/Modules/app-module/source/metadata_parsing_v2.cpp b/Modules/app-module/source/metadata_parsing_v2.cpp deleted file mode 100644 index 7a897da8..00000000 --- a/Modules/app-module/source/metadata_parsing_v2.cpp +++ /dev/null @@ -1,149 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -#include "app/manifest.h" - - -#include -#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_id_is_valid(id.c_str())) { - 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; - } - - uint64_t version_code = 0; - const auto* first = version_code_string.data(); - const auto* last = first + version_code_string.size(); - if (std::from_chars(first, last, version_code).ec != std::errc {}) { - LOG_E(TAG, "App version code out of range"); - return false; - } - out_metadata.app_version_code = version_code; // [target] - - // 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; - } - - // requires.device.id (optional; if present, must be a non-empty comma-separated list) - - auto device_id_iterator = properties.find("requires.device.id"); - if (device_id_iterator != properties.end()) { - const std::string& device_id = device_id_iterator->second; - if (!app_metadata_is_valid_device_id_list(device_id)) { - LOG_E(TAG, "Invalid requires.device.id"); - return false; - } - if (!app_metadata_copy_bounded(out_metadata.requires_device_id, sizeof(out_metadata.requires_device_id), device_id)) { - LOG_E(TAG, "requires.device.id too long"); - return false; - } - } - - // app.stack.depth (optional; if present, must be a valid unsigned decimal fitting uint32_t) - - auto stack_size_iterator = properties.find("app.stack.depth"); - if (stack_size_iterator != properties.end()) { - const std::string& stack_size_string = stack_size_iterator->second; - if (!app_metadata_is_valid_stack_size(stack_size_string)) { - LOG_E(TAG, "Invalid app.stack.depth"); - return false; - } - - uint32_t stack_size = 0; - const auto* stack_size_first = stack_size_string.data(); - const auto* stack_size_last = stack_size_first + stack_size_string.size(); - if (std::from_chars(stack_size_first, stack_size_last, stack_size).ec != std::errc {}) { - LOG_E(TAG, "App stack depth out of range"); - return false; - } - - // Reject outright rather than truncating/clamping into AppStackConfig::depth (uint16_t) - - // a value like 1073741825 would otherwise silently narrow to 1, handing the app a - // catastrophically undersized stack instead of the huge one it declared. - if (stack_size > APP_STACK_SIZE_MAX) { - LOG_E(TAG, "App stack depth %u exceeds APP_STACK_SIZE_MAX(%u)", stack_size, APP_STACK_SIZE_MAX); - return false; - } - out_metadata.stack_depth = stack_size; - } - - return true; -} diff --git a/Modules/app-module/source/module.cpp b/Modules/app-module/source/module.cpp index fcdf041a..03544e53 100644 --- a/Modules/app-module/source/module.cpp +++ b/Modules/app-module/source/module.cpp @@ -5,7 +5,7 @@ #include #include #include -#include +#include #include #include #include @@ -47,20 +47,21 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(app_manager_for_each_manifest), DEFINE_MODULE_SYMBOL(app_manager_add), DEFINE_MODULE_SYMBOL(app_manager_remove), + DEFINE_MODULE_SYMBOL(app_manager_add_package), + DEFINE_MODULE_SYMBOL(app_manager_remove_package), + DEFINE_MODULE_SYMBOL(app_manager_find_package), + DEFINE_MODULE_SYMBOL(app_manager_for_each_package), DEFINE_MODULE_SYMBOL(app_manager_get_topmost_instance_id), DEFINE_MODULE_SYMBOL(app_manager_get_topmost_app_id), DEFINE_MODULE_SYMBOL(app_manager_install_path_add), DEFINE_MODULE_SYMBOL(app_manager_install_path_scan), DEFINE_MODULE_SYMBOL(app_manager_install_path_uninstall), - // app/start - DEFINE_MODULE_SYMBOL(app_start), - DEFINE_MODULE_SYMBOL(app_start_for_result), - DEFINE_MODULE_SYMBOL(app_start_with_streams), - DEFINE_MODULE_SYMBOL(app_start_for_result_with_streams), // app/manifest - DEFINE_MODULE_SYMBOL(app_id_is_valid), - // app/metadata - DEFINE_MODULE_SYMBOL(app_metadata_parse), + DEFINE_MODULE_SYMBOL(app_manifest_id_is_valid), + DEFINE_MODULE_SYMBOL(app_manifest_name_is_valid), + DEFINE_MODULE_SYMBOL(app_manifest_stack_size_is_valid), + // app/package_manifest + DEFINE_MODULE_SYMBOL(app_package_manifest_parse), // app/paths DEFINE_MODULE_SYMBOL(app_paths_get_user_data_directory), DEFINE_MODULE_SYMBOL(app_paths_get_user_data_path), @@ -68,6 +69,11 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(app_paths_get_assets_path), // app/scheduler DEFINE_MODULE_SYMBOL(app_scheduler_current_app_id), + // app/start + DEFINE_MODULE_SYMBOL(app_start), + DEFINE_MODULE_SYMBOL(app_start_for_result), + DEFINE_MODULE_SYMBOL(app_start_with_streams), + DEFINE_MODULE_SYMBOL(app_start_for_result_with_streams), // app/stream DEFINE_MODULE_SYMBOL(app_stream_subscribe), DEFINE_MODULE_SYMBOL(app_stream_unsubscribe), diff --git a/Modules/app-module/source/package_manifest_parsing.cpp b/Modules/app-module/source/package_manifest_parsing.cpp new file mode 100644 index 00000000..b2f9851b --- /dev/null +++ b/Modules/app-module/source/package_manifest_parsing.cpp @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +constexpr auto* TAG = "app_metadata"; + +bool app_package_manifest_validate_string(const std::string& value, bool (*is_valid_char)(char)) { + for (char c: value) { + if (!is_valid_char(c)) { + return false; + } + } + return true; +} + +namespace { + +#define validate_string app_package_manifest_validate_string + +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); +} + +/** Validates a comma-separated list: non-empty, no leading/trailing/double commas (which would + * produce an empty item), and every item passing @a is_valid_item. */ +bool validate_csv_list(const std::string& value, bool (*is_valid_item)(const std::string&)) { + if (value.empty()) { + return false; + } + size_t start = 0; + while (true) { + auto comma = value.find(',', start); + auto end = comma == std::string::npos ? value.size() : comma; + if (end == start || !is_valid_item(value.substr(start, end - start))) { + return false; + } + if (comma == std::string::npos) { + return true; + } + start = comma + 1; + } +} + +/** manifest.properties format: flat "key=value" lines, "#" 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::ifstream file(path); + if (!file.is_open()) { + return false; + } + + std::string line; + while (std::getline(file, line)) { + auto trimmed_line = trim(line); + + if (trimmed_line.empty() || trimmed_line.starts_with("#")) { + 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 = trim(trimmed_line.substr(0, separator_index)); + auto value = trim(trimmed_line.substr(separator_index + 1)); + out_properties[key] = value; + } + + return true; +} + +} // namespace + +bool app_package_manifest_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_package_manifest_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_package_manifest_is_valid_version_name(const std::string& version) { + return !version.empty() && version.size() <= PACKAGE_MANIFEST_VERSION_NAME_LENGTH && validate_string(version, [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == '.' || c == '-' || c == '_'; + }); +} + +bool app_package_manifest_is_valid_version_code(const std::string& version) { + // 20 digits is the maximum decimal width of uint64_t. + return !version.empty() && version.size() <= 20 && validate_string(version, [](char c) { + return std::isdigit(static_cast(c)) != 0; + }); +} + +bool app_package_manifest_is_valid_bool(const std::string& value) { + return value == "true" || value == "false"; +} + +bool app_package_manifest_is_valid_device_id_list(const std::string& value) { + return validate_csv_list(value, [](const std::string& item) { + bool has_alnum = false; + for (char c: item) { + if (std::isalnum(static_cast(c)) != 0) { + has_alnum = true; + } else if (c != '-') { + return false; + } + } + return has_alnum; + }); +} + +bool app_package_manifest_is_valid_binary_name(const std::string& value) { + return !value.empty() && value.size() <= APP_MANIFEST_BINARY_LENGTH && validate_string(value, [](char c) { + return std::isalnum(static_cast(c)) != 0 || c == '.' || c == '_' || c == '-'; + }); +} + +bool app_package_manifest_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_package_manifest_parse(const char* path, struct PackageManifest* out_package, struct AppManifestBinding* out_bindings, size_t bindings_capacity) { + LOG_I(TAG, "Parsing manifest %s", path); + + // requires_device_id is optional; zeroing here (rather than relying on the caller) guarantees + // it reads back as empty ("unrestricted") when the manifest omits it. + *out_package = {}; + for (size_t i = 0; i < bindings_capacity; i++) { + out_bindings[i] = {}; + } + + std::map properties; + if (!load_properties(path, properties)) { + LOG_E(TAG, "Failed to load manifest at %s", path); + return ERROR_NOT_FOUND; + } + + std::string format_version; + if (!app_package_manifest_get_value(properties, "manifest.version", format_version)) { + return ERROR_INVALID_ARGUMENT; + } + + if (format_version == "0.2") { + return package_manifest_parse_v2(properties, *out_package, out_bindings, bindings_capacity); + } else if (format_version == "0.3") { + return package_manifest_parse_v3(properties, *out_package, out_bindings, bindings_capacity); + } else { + LOG_E(TAG, "Unsupported manifest.version: %s", format_version.c_str()); + return ERROR_INVALID_ARGUMENT; + } +} + +error_t app_package_manifest_parse_into(const char* path, PackageManifest& out_package, std::vector>& out_bindings) { + error_t result = app_package_manifest_parse(path, &out_package, nullptr, 0); + if (result != ERROR_NONE) { + return result; + } + out_bindings.resize(out_package.app_manifest_count); + result = app_package_manifest_parse(path, &out_package, out_bindings.data(), out_bindings.size()); + if (result != ERROR_NONE) { + return result; + } + // Manifest could have changed between the two parses above. + if (out_package.app_manifest_count != out_bindings.size()) { + LOG_E(TAG, "Manifest at %s changed while being parsed", path); + return ERROR_INVALID_ARGUMENT; + } + return ERROR_NONE; +} diff --git a/Modules/app-module/source/package_manifest_parsing_v2.cpp b/Modules/app-module/source/package_manifest_parsing_v2.cpp new file mode 100644 index 00000000..0f083976 --- /dev/null +++ b/Modules/app-module/source/package_manifest_parsing_v2.cpp @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "app/manifest.h" + + +#include +#include + +#include + +#include + +constexpr auto* TAG = "app_metadata_v2"; + +error_t package_manifest_parse_v2(const std::map& properties, PackageManifest& out_package, AppManifestBinding* out_bindings, size_t bindings_capacity) { + // manifest + + std::string format_version; + if (!app_package_manifest_get_value(properties, "manifest.version", format_version)) { + return ERROR_INVALID_ARGUMENT; + } + + if (!app_package_manifest_is_valid_format_version(format_version)) { + LOG_E(TAG, "Invalid version"); + return ERROR_INVALID_ARGUMENT; + } + + // app + + std::string id; + if (!app_package_manifest_get_value(properties, "app.id", id)) { + return ERROR_INVALID_ARGUMENT; + } + + if (!app_manifest_id_is_valid(id.c_str())) { + LOG_E(TAG, "Invalid app id"); + return ERROR_INVALID_ARGUMENT; + } + + if (!app_package_manifest_copy_bounded(out_package.id, sizeof(out_package.id), id)) { + LOG_E(TAG, "App id too long"); + return ERROR_INVALID_ARGUMENT; + } + + std::string name; + if (!app_package_manifest_get_value(properties, "app.name", name)) { + return ERROR_INVALID_ARGUMENT; + } + + if (!app_manifest_name_is_valid(name.c_str())) { + LOG_E(TAG, "Invalid app name"); + return ERROR_INVALID_ARGUMENT; + } + + std::string version_name; + if (!app_package_manifest_get_value(properties, "app.version.name", version_name)) { + return ERROR_INVALID_ARGUMENT; + } + + if (!app_package_manifest_is_valid_version_name(version_name)) { + LOG_E(TAG, "Invalid app version name"); + return ERROR_INVALID_ARGUMENT; + } + + if (!app_package_manifest_copy_bounded(out_package.version_name, sizeof(out_package.version_name), version_name)) { + LOG_E(TAG, "App version name too long"); + return ERROR_INVALID_ARGUMENT; + } + + std::string version_code_string; + if (!app_package_manifest_get_value(properties, "app.version.code", version_code_string)) { + return ERROR_INVALID_ARGUMENT; + } + + if (!app_package_manifest_is_valid_version_code(version_code_string)) { + LOG_E(TAG, "Invalid app version code"); + return ERROR_INVALID_ARGUMENT; + } + + uint64_t version_code = 0; + const auto* first = version_code_string.data(); + const auto* last = first + version_code_string.size(); + if (std::from_chars(first, last, version_code).ec != std::errc {}) { + LOG_E(TAG, "App version code out of range"); + return ERROR_INVALID_ARGUMENT; + } + out_package.version_code = version_code; + + // target + + std::string target_sdk; + if (!app_package_manifest_get_value(properties, "target.sdk", target_sdk)) { + return ERROR_INVALID_ARGUMENT; + } + + if (!app_package_manifest_copy_bounded(out_package.target_sdk, sizeof(out_package.target_sdk), target_sdk)) { + LOG_E(TAG, "Target sdk too long"); + return ERROR_INVALID_ARGUMENT; + } + + // requires.device.id (optional; if present, must be a non-empty comma-separated list) + + auto device_id_iterator = properties.find("requires.device.id"); + if (device_id_iterator != properties.end()) { + const std::string& device_id = device_id_iterator->second; + if (!app_package_manifest_is_valid_device_id_list(device_id)) { + LOG_E(TAG, "Invalid requires.device.id"); + return ERROR_INVALID_ARGUMENT; + } + if (!app_package_manifest_copy_bounded(out_package.requires_device_id, sizeof(out_package.requires_device_id), device_id)) { + LOG_E(TAG, "requires.device.id too long"); + return ERROR_INVALID_ARGUMENT; + } + } + + // A v2 manifest always describes exactly one app, sharing the package's own id. + out_package.app_manifest_count = 1; + if (bindings_capacity == 0) { + return ERROR_NONE; + } + + AppManifestBinding& binding = out_bindings[0]; + AppManifest& manifest = binding.manifest; + + // v2 predates the "binary" key - the single app always installs as bin//app.{elf,so}. + if (!app_package_manifest_copy_bounded(binding.binary, sizeof(binding.binary), "app")) { + LOG_E(TAG, "Binary name too long"); + return ERROR_INVALID_ARGUMENT; + } + + if (!app_package_manifest_copy_bounded(manifest.id, sizeof(manifest.id), id)) { + LOG_E(TAG, "App id too long"); + return ERROR_INVALID_ARGUMENT; + } + if (!app_package_manifest_copy_bounded(manifest.name, sizeof(manifest.name), name)) { + LOG_E(TAG, "App name too long"); + return ERROR_INVALID_ARGUMENT; + } + + // app.stack.depth (optional; if present, must be a valid unsigned decimal fitting uint32_t) + + auto stack_size_iterator = properties.find("app.stack.depth"); + if (stack_size_iterator != properties.end()) { + const std::string& stack_size_string = stack_size_iterator->second; + if (!app_manifest_stack_size_is_valid(stack_size_string.c_str())) { + LOG_E(TAG, "Invalid app.stack.depth"); + return ERROR_INVALID_ARGUMENT; + } + + uint32_t stack_size = 0; + const auto* stack_size_first = stack_size_string.data(); + const auto* stack_size_last = stack_size_first + stack_size_string.size(); + if (std::from_chars(stack_size_first, stack_size_last, stack_size).ec != std::errc {}) { + LOG_E(TAG, "App stack depth out of range"); + return ERROR_INVALID_ARGUMENT; + } + + // Reject outright rather than truncating/clamping into AppStackConfig::depth (uint16_t) - + // a value like 1073741825 would otherwise silently narrow to 1, handing the app a + // catastrophically undersized stack instead of the huge one it declared. + if (stack_size > APP_STACK_SIZE_MAX) { + LOG_E(TAG, "App stack depth %u exceeds APP_STACK_SIZE_MAX(%u)", stack_size, APP_STACK_SIZE_MAX); + return ERROR_INVALID_ARGUMENT; + } + manifest.stack.depth = static_cast(stack_size); + } + + // v2 predates per-app visibility - always visible. + manifest.flags = 0; + manifest.category = APP_CATEGORY_USER; + + return ERROR_NONE; +} diff --git a/Modules/app-module/source/package_manifest_parsing_v3.cpp b/Modules/app-module/source/package_manifest_parsing_v3.cpp new file mode 100644 index 00000000..e03739ab --- /dev/null +++ b/Modules/app-module/source/package_manifest_parsing_v3.cpp @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "app/manifest.h" + + +#include +#include + +#include +#include +#include + +#include + +constexpr auto* TAG = "app_metadata_v3"; + +namespace { + +// Numeric index out of an "app.." key, or nullopt if it doesn't match that shape. +std::optional parse_app_key_index(const std::string& key) { + constexpr auto* prefix = "app."; + constexpr size_t prefix_len = 4; + if (!key.starts_with(prefix)) { + return std::nullopt; + } + auto dot = key.find('.', prefix_len); + if (dot == std::string::npos || dot == prefix_len) { + return std::nullopt; + } + size_t index = 0; + const auto* first = key.data() + prefix_len; + const auto* last = key.data() + dot; + auto result = std::from_chars(first, last, index); + if (result.ec != std::errc {} || result.ptr != last) { + return std::nullopt; + } + return index; +} + +// Parses one "app..*" block into @a out_binding. +error_t parse_app_manifest(const std::map& properties, size_t index, AppManifestBinding& out_binding) { + auto prefix = std::format("app.{}.", index); + AppManifest& out_manifest = out_binding.manifest; + + std::string id; + if (!app_package_manifest_get_value(properties, prefix + "id", id)) { + return ERROR_INVALID_ARGUMENT; + } + if (!app_manifest_id_is_valid(id.c_str())) { + LOG_E(TAG, "Invalid %sid", prefix.c_str()); + return ERROR_INVALID_ARGUMENT; + } + if (!app_package_manifest_copy_bounded(out_manifest.id, sizeof(out_manifest.id), id)) { + LOG_E(TAG, "%sid too long", prefix.c_str()); + return ERROR_INVALID_ARGUMENT; + } + + std::string binary; + if (!app_package_manifest_get_value(properties, prefix + "binary", binary)) { + return ERROR_INVALID_ARGUMENT; + } + if (!app_package_manifest_is_valid_binary_name(binary)) { + LOG_E(TAG, "Invalid %sbinary", prefix.c_str()); + return ERROR_INVALID_ARGUMENT; + } + // Filename (without extension) this app installs as under bin// - see + // app_package_manifest_parse()'s own doc. + if (!app_package_manifest_copy_bounded(out_binding.binary, sizeof(out_binding.binary), binary)) { + LOG_E(TAG, "%sbinary too long", prefix.c_str()); + return ERROR_INVALID_ARGUMENT; + } + + std::string name; + if (!app_package_manifest_get_value(properties, prefix + "name", name)) { + return ERROR_INVALID_ARGUMENT; + } + if (!app_manifest_name_is_valid(name.c_str())) { + LOG_E(TAG, "Invalid %sname", prefix.c_str()); + return ERROR_INVALID_ARGUMENT; + } + if (!app_package_manifest_copy_bounded(out_manifest.name, sizeof(out_manifest.name), name)) { + LOG_E(TAG, "%sname too long", prefix.c_str()); + return ERROR_INVALID_ARGUMENT; + } + + // .stack.depth (optional; if present, must be a valid unsigned decimal fitting uint32_t) + auto stack_size_iterator = properties.find(prefix + "stack.depth"); + if (stack_size_iterator != properties.end()) { + const std::string& stack_size_string = stack_size_iterator->second; + if (!app_manifest_stack_size_is_valid(stack_size_string.c_str())) { + LOG_E(TAG, "Invalid %sstack.depth", prefix.c_str()); + return ERROR_INVALID_ARGUMENT; + } + + uint32_t stack_size = 0; + const auto* first = stack_size_string.data(); + const auto* last = first + stack_size_string.size(); + if (std::from_chars(first, last, stack_size).ec != std::errc {}) { + LOG_E(TAG, "%sstack.depth out of range", prefix.c_str()); + return ERROR_INVALID_ARGUMENT; + } + // Reject outright rather than truncating/clamping into AppStackConfig::depth (uint16_t) - + // a value like 1073741825 would otherwise silently narrow to 1, handing the app a + // catastrophically undersized stack instead of the huge one it declared. + if (stack_size > APP_STACK_SIZE_MAX) { + LOG_E(TAG, "%sstack.depth %u exceeds APP_STACK_SIZE_MAX(%u)", prefix.c_str(), stack_size, APP_STACK_SIZE_MAX); + return ERROR_INVALID_ARGUMENT; + } + out_manifest.stack.depth = static_cast(stack_size); + } + + // .hidden (optional; defaults to false) + auto hidden_iterator = properties.find(prefix + "hidden"); + if (hidden_iterator != properties.end()) { + if (!app_package_manifest_is_valid_bool(hidden_iterator->second)) { + LOG_E(TAG, "Invalid %shidden", prefix.c_str()); + return ERROR_INVALID_ARGUMENT; + } + out_manifest.flags = hidden_iterator->second == "true" ? APP_MANIFEST_FLAG_HIDDEN : 0; + } + + out_manifest.category = APP_CATEGORY_USER; + + return ERROR_NONE; +} + +} // namespace + +error_t package_manifest_parse_v3(const std::map& properties, PackageManifest& out_package, AppManifestBinding* out_bindings, size_t bindings_capacity) { + // package-level fields: bare keys, no "app." prefix (that's reserved for the app..* + // blocks below). + + std::string id; + if (!app_package_manifest_get_value(properties, "id", id)) { + return ERROR_INVALID_ARGUMENT; + } + if (!app_manifest_id_is_valid(id.c_str())) { + LOG_E(TAG, "Invalid id"); + return ERROR_INVALID_ARGUMENT; + } + if (!app_package_manifest_copy_bounded(out_package.id, sizeof(out_package.id), id)) { + LOG_E(TAG, "id too long"); + return ERROR_INVALID_ARGUMENT; + } + + std::string version_name; + if (!app_package_manifest_get_value(properties, "version.name", version_name)) { + return ERROR_INVALID_ARGUMENT; + } + if (!app_package_manifest_is_valid_version_name(version_name)) { + LOG_E(TAG, "Invalid version.name"); + return ERROR_INVALID_ARGUMENT; + } + if (!app_package_manifest_copy_bounded(out_package.version_name, sizeof(out_package.version_name), version_name)) { + LOG_E(TAG, "version.name too long"); + return ERROR_INVALID_ARGUMENT; + } + + std::string version_code_string; + if (!app_package_manifest_get_value(properties, "version.code", version_code_string)) { + return ERROR_INVALID_ARGUMENT; + } + if (!app_package_manifest_is_valid_version_code(version_code_string)) { + LOG_E(TAG, "Invalid version.code"); + return ERROR_INVALID_ARGUMENT; + } + uint64_t version_code = 0; + const auto* first = version_code_string.data(); + const auto* last = first + version_code_string.size(); + if (std::from_chars(first, last, version_code).ec != std::errc {}) { + LOG_E(TAG, "version.code out of range"); + return ERROR_INVALID_ARGUMENT; + } + out_package.version_code = version_code; + + std::string target_sdk; + if (!app_package_manifest_get_value(properties, "target.sdk", target_sdk)) { + return ERROR_INVALID_ARGUMENT; + } + if (!app_package_manifest_copy_bounded(out_package.target_sdk, sizeof(out_package.target_sdk), target_sdk)) { + LOG_E(TAG, "target.sdk too long"); + return ERROR_INVALID_ARGUMENT; + } + + auto device_id_iterator = properties.find("requires.device.id"); + if (device_id_iterator != properties.end()) { + const std::string& device_id = device_id_iterator->second; + if (!app_package_manifest_is_valid_device_id_list(device_id)) { + LOG_E(TAG, "Invalid requires.device.id"); + return ERROR_INVALID_ARGUMENT; + } + if (!app_package_manifest_copy_bounded(out_package.requires_device_id, sizeof(out_package.requires_device_id), device_id)) { + LOG_E(TAG, "requires.device.id too long"); + return ERROR_INVALID_ARGUMENT; + } + } + + // app..* blocks: 0-indexed, contiguous - the first missing "app..id" ends the list. + size_t count = 0; + while (properties.contains(std::format("app.{}.id", count))) { + count++; + // Bounded here, not just against the caller's bindings_capacity below: a caller that + // sizes its own buffer off PackageManifest::app_manifest_count (see + // app_package_manifest_parse_into()) would otherwise let a malicious/malformed manifest + // demand an unbounded allocation. + if (count > PACKAGE_MANIFEST_MAX_APP_MANIFESTS) { + LOG_E(TAG, "Manifest declares more than %d apps", PACKAGE_MANIFEST_MAX_APP_MANIFESTS); + return ERROR_BUFFER_OVERFLOW; + } + } + + out_package.app_manifest_count = static_cast(count); + + // Catch a gap the count loop above would otherwise silently drop, e.g. app.0.* + app.3.*. + for (const auto& [key, value] : properties) { + auto index = parse_app_key_index(key); + if (index.has_value() && *index >= count) { + LOG_E(TAG, "Manifest declares %s but only %zu contiguous app(s) starting at app.0 were found", key.c_str(), count); + return ERROR_INVALID_ARGUMENT; + } + } + + if (count == 0 || bindings_capacity == 0) { + return ERROR_NONE; + } + if (count > bindings_capacity) { + LOG_E(TAG, "Manifest declares %zu apps, capacity is %zu", count, bindings_capacity); + return ERROR_BUFFER_OVERFLOW; + } + + for (size_t i = 0; i < count; i++) { + error_t result = parse_app_manifest(properties, i, out_bindings[i]); + if (result != ERROR_NONE) { + return result; + } + } + + return ERROR_NONE; +} diff --git a/Modules/app-module/source/paths.cpp b/Modules/app-module/source/paths.cpp index da7f0a4e..8d3586f5 100644 --- a/Modules/app-module/source/paths.cpp +++ b/Modules/app-module/source/paths.cpp @@ -6,6 +6,27 @@ #include #include +#include + +namespace { + +// manifest.location.location is the fully-resolved binary file path +// ({install_dir}/bin//.{elf,so} - see app_resolve_binary_path()), not the +// install directory itself - strip that fixed 3-segment suffix to recover it. +bool app_get_install_dir_from_binary_path(const char* binary_path, std::string& out_install_dir) { + std::string path = binary_path; + for (int i = 0; i < 3; i++) { + auto separator = path.find_last_of('/'); + if (separator == std::string::npos) { + return false; + } + path = path.substr(0, separator); + } + out_install_dir = path; + return true; +} + +} // namespace extern "C" { @@ -46,7 +67,12 @@ error_t app_paths_get_assets_directory(const char* app_id, char* out_path, size_ return ERROR_NOT_FOUND; } - int written = std::snprintf(out_path, out_path_size, "%s/assets", static_cast(manifest.location.location)); + std::string install_dir; + if (!app_get_install_dir_from_binary_path(static_cast(manifest.location.location), install_dir)) { + return ERROR_NOT_FOUND; + } + + int written = std::snprintf(out_path, out_path_size, "%s/assets", install_dir.c_str()); if (written < 0 || (size_t)written >= out_path_size) { return ERROR_BUFFER_OVERFLOW; } diff --git a/Modules/app-module/source/scheduler.cpp b/Modules/app-module/source/scheduler.cpp index 5155657d..a628f406 100644 --- a/Modules/app-module/source/scheduler.cpp +++ b/Modules/app-module/source/scheduler.cpp @@ -303,7 +303,7 @@ error_t app_scheduler_start(AppInstanceId app_instance_id, AppLocation location, return ERROR_OUT_OF_MEMORY; } - // Same bound app_metadata_parse() enforces on manifest.properties-declared depths - a + // Same bound package_manifest_parse() enforces on manifest.properties-declared depths - a // manifest built directly in C++ (not parsed from a file) must be held to it too. if (stack.depth > APP_STACK_SIZE_MAX) { LOG_E(TAG, "[instance %lu] stack depth %u exceeds APP_STACK_SIZE_MAX(%u)", app_instance_id, stack.depth, APP_STACK_SIZE_MAX); diff --git a/Modules/app-module/tests/source/stream_test.cpp b/Modules/app-module/tests/source/stream_test.cpp index 9d2cd4e0..5e7d6fc8 100644 --- a/Modules/app-module/tests/source/stream_test.cpp +++ b/Modules/app-module/tests/source/stream_test.cpp @@ -73,7 +73,11 @@ bool wait_for_state(AppInstanceId id, AppInstanceState target, uint32_t timeout_ AppInstanceId start_idle_app(const char* id) { ensure_memory_loader_registered(); - AppManifest manifest { id, id, APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast(idle_app_main) } }; + AppManifest manifest {}; + std::strncpy(manifest.id, id, sizeof(manifest.id) - 1); + std::strncpy(manifest.name, id, sizeof(manifest.name) - 1); + manifest.category = APP_CATEGORY_USER; + manifest.location = { APP_LOCATION_MEMORY, reinterpret_cast(idle_app_main) }; REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE); AppInstanceId instance_id = 0; REQUIRE_EQ(app_start(id, 0, nullptr, &instance_id), ERROR_NONE); diff --git a/Modules/app-posix-module/source/app_posix_loader_service.cpp b/Modules/app-posix-module/source/app_posix_loader_service.cpp index 0d66cd78..44b89252 100644 --- a/Modules/app-posix-module/source/app_posix_loader_service.cpp +++ b/Modules/app-posix-module/source/app_posix_loader_service.cpp @@ -58,19 +58,21 @@ bool is_executable_file(const std::string& resolved_path) { #endif } -// location.location can be either an app's install directory or the .so file directly; the -// former resolves to the per-architecture binary at {dir}/elf/posix-{TACTILITY_POSIX_ARCH}.so, -// mirroring app_esp32_loader_service.cpp's resolve_elf_path(). +// location.location can be either an app's install directory or the .so file directly, mirroring +// app_esp32_loader_service.cpp's resolve_elf_path(). A "packaged" app's install directory always +// holds its single binary at the fixed path {dir}/bin/posix-{TACTILITY_POSIX_ARCH}/app.so - a +// "terminal" app has no such file (its several binaries keep their own names), so this correctly +// leaves it unresolvable - terminal apps aren't run through AppLoaderApi (see app/install.h). error_t resolve_app_path(const std::string& path, std::string& resolvedPath) { if (path.ends_with(".so")) { resolvedPath = path; return ERROR_NONE; } - std::string shared_object_path = path + "/elf/posix-" TACTILITY_POSIX_ARCH ".so"; - if (!is_regular_file(shared_object_path)) { + std::string candidate = path + "/bin/posix-" TACTILITY_POSIX_ARCH "/app.so"; + if (!is_regular_file(candidate)) { return ERROR_NOT_FOUND; } - resolvedPath = shared_object_path; + resolvedPath = candidate; return ERROR_NONE; } @@ -94,9 +96,6 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) { LOG_I(TAG, "Loading %s", app_path.c_str()); - // RTLD_NOW: a missing symbol fails here, not mid-run(). RTLD_LOCAL: this app's own exported - // symbols (if any beyond its entry point) don't leak into the process's global scope and - // clash with a different app's. void* handle = dlopen(app_path.c_str(), RTLD_NOW | RTLD_LOCAL); if (handle == nullptr) { LOG_E(TAG, "dlopen(%s) failed: %s", app_path.c_str(), dlerror()); @@ -117,8 +116,8 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) { int32_t api_run(AppRuntime runtime_ptr, uint32_t /*app_instance_id*/, int argc, char* argv[]) { auto* runtime = static_cast(runtime_ptr); - dlerror(); // clear any pending error, per dlsym(3)'s own recommended idiom for telling a NULL - // symbol address apart from a real lookup failure + // Clear any pending error, per dlsym(3)'s own recommended idiom for telling a NULL symbol address apart from a real lookup failure + dlerror(); void* symbol = dlsym(runtime->handle, "main"); const char* lookup_error = dlerror(); if (symbol == nullptr || lookup_error != nullptr) { diff --git a/Modules/app-posix-module/tests/CMakeLists.txt b/Modules/app-posix-module/tests/CMakeLists.txt index 41f59cb0..fe1d031e 100644 --- a/Modules/app-posix-module/tests/CMakeLists.txt +++ b/Modules/app-posix-module/tests/CMakeLists.txt @@ -15,14 +15,27 @@ set_target_properties(app_posix_module_test_fixture PROPERTIES POSITION_INDEPEND set(NON_ELF_FIXTURE_PATH "${CMAKE_CURRENT_BINARY_DIR}/not-elf.so") file(WRITE "${NON_ELF_FIXTURE_PATH}" "not an elf file") +# An install-directory-shaped fixture: {dir}/bin/posix-{arch}/app.so, matching where +# app_posix_loader_service.cpp's resolve_app_path() looks for a "packaged" app's single binary. +set(INSTALL_DIR_FIXTURE_PATH "${CMAKE_CURRENT_BINARY_DIR}/install-dir-fixture") +set(INSTALL_DIR_FIXTURE_BIN_DIR "${INSTALL_DIR_FIXTURE_PATH}/bin/posix-${CMAKE_SYSTEM_PROCESSOR}") +add_custom_command( + OUTPUT "${INSTALL_DIR_FIXTURE_BIN_DIR}/app.so" + COMMAND ${CMAKE_COMMAND} -E make_directory "${INSTALL_DIR_FIXTURE_BIN_DIR}" + COMMAND ${CMAKE_COMMAND} -E copy "$" "${INSTALL_DIR_FIXTURE_BIN_DIR}/app.so" + DEPENDS app_posix_module_test_fixture +) +add_custom_target(install_dir_fixture DEPENDS "${INSTALL_DIR_FIXTURE_BIN_DIR}/app.so") + file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp) add_executable(AppPosixModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES}) -add_dependencies(AppPosixModuleTests app_posix_module_test_fixture) +add_dependencies(AppPosixModuleTests app_posix_module_test_fixture install_dir_fixture) target_include_directories(AppPosixModuleTests PRIVATE ${DOCTESTINC}) target_compile_definitions(AppPosixModuleTests PRIVATE FIXTURE_APP_PATH="$" FIXTURE_NON_ELF_PATH="${NON_ELF_FIXTURE_PATH}" + FIXTURE_INSTALL_DIR_PATH="${INSTALL_DIR_FIXTURE_PATH}" ) add_test(NAME AppPosixModuleTests COMMAND AppPosixModuleTests) diff --git a/Modules/app-posix-module/tests/source/loader_test.cpp b/Modules/app-posix-module/tests/source/loader_test.cpp index 9ab8953a..cacc0e16 100644 --- a/Modules/app-posix-module/tests/source/loader_test.cpp +++ b/Modules/app-posix-module/tests/source/loader_test.cpp @@ -148,6 +148,12 @@ TEST_CASE("app_is_executable() rejects a nonexistent path") { TEST_CASE("app_is_executable() rejects an install-directory-shaped path missing its per-arch .so") { ensure_path_loader_registered(); - // FIXTURE_DIR itself has no elf/posix-.so under it, so resolution fails. + // FIXTURE_DIR itself has no bin/posix-/app.so under it, so resolution fails. CHECK_FALSE(is_executable_path(FIXTURE_DIR.c_str())); } + +TEST_CASE("app_is_executable() accepts an install-directory-shaped path with bin//app.so") { + ensure_path_loader_registered(); + + CHECK(is_executable_path(FIXTURE_INSTALL_DIR_PATH)); +} diff --git a/Tactility/Private/Tactility/app/appdetails/AppDetails.h b/Tactility/Private/Tactility/app/appdetails/AppDetails.h deleted file mode 100644 index 8ddb22f9..00000000 --- a/Tactility/Private/Tactility/app/appdetails/AppDetails.h +++ /dev/null @@ -1,9 +0,0 @@ -#pragma once - -#include - -namespace tt::app::appdetails { - -void start(const std::string& appId); - -} // namespace diff --git a/Tactility/Private/Tactility/app/apppackagedetails/AppPackageDetails.h b/Tactility/Private/Tactility/app/apppackagedetails/AppPackageDetails.h new file mode 100644 index 00000000..40a9a326 --- /dev/null +++ b/Tactility/Private/Tactility/app/apppackagedetails/AppPackageDetails.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace tt::app::apppackagedetails { + +void start(const std::string& packageId); + +} // namespace diff --git a/Tactility/Source/DeprecatedPaths.cpp b/Tactility/Source/DeprecatedPaths.cpp index 14e3c8a5..c61a1539 100644 --- a/Tactility/Source/DeprecatedPaths.cpp +++ b/Tactility/Source/DeprecatedPaths.cpp @@ -68,12 +68,12 @@ std::string getUserHomePath() { } std::string getAppInstallPath(const std::string& appId) { - assert(app_id_is_valid(appId.c_str())); + assert(app_manifest_id_is_valid(appId.c_str())); return std::format("{}/{}", getAppInstallPath(), appId); } std::string getAppUserPath(const std::string& appId) { - assert(app_id_is_valid(appId.c_str())); + assert(app_manifest_id_is_valid(appId.c_str())); return std::format("{}/app/{}", getUserHomePath(), appId); } diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 95403594..d5d132ef 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -26,9 +26,9 @@ #include #include -#include #include #include +#include #include @@ -150,9 +150,9 @@ namespace app { namespace alertdialog { extern const ::AppManifest manifest; } namespace apphub { extern const ::AppManifest manifest; } namespace apphubdetails { extern const ::AppManifest manifest; } - namespace appdetails { extern const ::AppManifest manifest; } + namespace apppackagedetails { extern const ::AppManifest manifest; } namespace applist { extern const ::AppManifest manifest; } - namespace appsettings { extern const ::AppManifest manifest; } + namespace apppackagelist { extern const ::AppManifest manifest; } namespace audiosettings { extern const ::AppManifest manifest; } namespace boot { extern const ::AppManifest manifest; } namespace development { extern const ::AppManifest manifest; } @@ -212,11 +212,11 @@ static void registerInternalApps() { LOG_I(TAG, "Registering internal apps"); app_manager_add(&app::alertdialog::manifest); - app_manager_add(&app::appdetails::manifest); + app_manager_add(&app::apppackagedetails::manifest); app_manager_add(&app::apphub::manifest); app_manager_add(&app::apphubdetails::manifest); app_manager_add(&app::applist::manifest); - app_manager_add(&app::appsettings::manifest); + app_manager_add(&app::apppackagelist::manifest); if (service::audio::isAvailable()) { app_manager_add(&app::audiosettings::manifest); } diff --git a/Tactility/Source/app/alertdialog/AlertDialog.cpp b/Tactility/Source/app/alertdialog/AlertDialog.cpp index 4d7b9aa0..750703d4 100644 --- a/Tactility/Source/app/alertdialog/AlertDialog.cpp +++ b/Tactility/Source/app/alertdialog/AlertDialog.cpp @@ -2,9 +2,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp index 4d879d7d..cecffda1 100644 --- a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp +++ b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp @@ -7,11 +7,11 @@ #include #include -#include #include -#include #include +#include #include +#include #include @@ -204,9 +204,9 @@ void updateViews(Context* ctx) { if (is_installed) { std::string metadata_path = std::string(install_path) + "/manifest.properties"; - AppMetadata metadata; - if (app_metadata_parse(metadata_path.c_str(), &metadata) == ERROR_NONE - && metadata.app_version_code < ctx->entry.appVersionCode) { + PackageManifest package; + if (app_package_manifest_parse(metadata_path.c_str(), &package, nullptr, 0) == ERROR_NONE + && package.version_code < ctx->entry.appVersionCode) { ctx->updateButton = lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_DOWNLOAD, onUpdatePressed, ctx); lv_obj_remove_flag(ctx->updateLabel, LV_OBJ_FLAG_HIDDEN); } diff --git a/Tactility/Source/app/applist/AppList.cpp b/Tactility/Source/app/applist/AppList.cpp index 6ef4e636..2dff60da 100644 --- a/Tactility/Source/app/applist/AppList.cpp +++ b/Tactility/Source/app/applist/AppList.cpp @@ -1,8 +1,8 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/appdetails/AppDetails.cpp b/Tactility/Source/app/apppackagedetails/AppPackageDetails.cpp similarity index 54% rename from Tactility/Source/app/appdetails/AppDetails.cpp rename to Tactility/Source/app/apppackagedetails/AppPackageDetails.cpp index 4afd45d2..a901f993 100644 --- a/Tactility/Source/app/appdetails/AppDetails.cpp +++ b/Tactility/Source/app/apppackagedetails/AppPackageDetails.cpp @@ -1,9 +1,10 @@ #include -#include -#include -#include #include +#include +#include +#include #include +#include #include @@ -12,17 +13,15 @@ #include -#include #include -#include #include #include #include -constexpr auto* TAG = "AppDetails"; +constexpr auto* TAG = "AppPackageDetails"; -namespace tt::app::appdetails { +namespace tt::app::apppackagedetails { extern const ::AppManifest manifest; @@ -30,13 +29,34 @@ namespace { struct Context { uint32_t appInstanceId; - std::string targetAppId; - // findAppManifestById() returns the old-model registry's AppManifest type - AppDetails - // shows details for apps in that registry regardless of which system they run under. - AppManifest targetManifest = { }; + std::string targetPackageId; + PackageManifest targetPackage = {}; + std::vector appIds; uint32_t pendingUninstallDialogId = 0; }; +struct FindPackageContext { + const std::string* packageId; + PackageManifest* outPackage; + std::vector* outAppIds; + bool found = false; +}; + +void onVisitPackage(const ::AppPackage* pkg, void* context) { + auto* findContext = static_cast(context); + if (findContext->found || *findContext->packageId != pkg->package.id) { + return; + } + *findContext->outPackage = pkg->package; + findContext->outAppIds->assign(pkg->app_ids, pkg->app_ids + pkg->app_id_count); + findContext->found = true; +} + +bool findPackage(const std::string& packageId, PackageManifest& outPackage, std::vector& outAppIds) { + FindPackageContext findContext { &packageId, &outPackage, &outAppIds }; + app_manager_for_each_package(onVisitPackage, &findContext); + return findContext.found; +} void onPressUninstall(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); @@ -44,7 +64,7 @@ void onPressUninstall(lv_event_t* event) { ctx->pendingUninstallDialogId = alertdialog::start( ctx->appInstanceId, "Confirmation", - std::format("Uninstall {}?", ctx->targetManifest.name), + std::format("Uninstall {}?", ctx->targetPackage.id), choices ); } @@ -59,7 +79,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - auto title = std::format("{} details", ctx->targetManifest.name); + auto title = std::format("{} details", ctx->targetPackage.id); auto* toolbar = lvgl_toolbar_create(parent, title.c_str()); // The global toolbar nav callback only knows how to stop old-model apps. lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); @@ -71,35 +91,39 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT); lvgl::obj_set_style_bg_invisible(wrapper); - auto identifier = std::format("Identifier: {}", ctx->targetManifest.id); + auto identifier = std::format("Identifier: {}", ctx->targetPackage.id); auto* identifier_label = lv_label_create(wrapper); lv_label_set_text(identifier_label, identifier.c_str()); - auto* location_label = lv_label_create(wrapper); - std::string location; - bool is_internal = ctx->targetManifest.location.type == APP_LOCATION_MEMORY; - bool is_external = ctx->targetManifest.location.type == APP_LOCATION_PATH; - if (is_internal) { - location = "internal"; - } else if (is_external) { - if (!string::getPathParent(static_cast(ctx->targetManifest.location.location), location)) { - location = "external"; - } - } else { - LOG_E(TAG, "Unknown app location type %d", ctx->targetManifest.location.type); - return; - } - std::string location_label_text = std::format("Location: {}", location); - lv_label_set_text(location_label, location_label_text.c_str()); + auto version = std::format("Version: {} ({})", ctx->targetPackage.version_name, ctx->targetPackage.version_code); + auto* version_label = lv_label_create(wrapper); + lv_label_set_text(version_label, version.c_str()); - if (is_external) { - auto* uninstall_button = lv_button_create(wrapper); - lv_obj_set_width(uninstall_button, LV_PCT(100)); - lv_obj_add_event_cb(uninstall_button, onPressUninstall, LV_EVENT_SHORT_CLICKED, ctx); - auto* uninstall_label = lv_label_create(uninstall_button); - lv_obj_align(uninstall_label, LV_ALIGN_CENTER, 0, 0); - lv_label_set_text(uninstall_label, "Uninstall"); + char install_path[192]; + std::string location = "unknown"; + if (app_get_install_path(ctx->targetPackage.id, install_path, sizeof(install_path)) == ERROR_NONE) { + location = install_path; } + auto location_text = std::format("Location: {}", location); + auto* location_label = lv_label_create(wrapper); + lv_label_set_text(location_label, location_text.c_str()); + + std::string apps; + for (const auto& appId : ctx->appIds) { + AppManifest appManifest {}; + const char* label = app_manager_find_manifest(appId.c_str(), &appManifest) == ERROR_NONE ? appManifest.name : appId.c_str(); + apps += apps.empty() ? label : std::format(", {}", label); + } + auto apps_text = std::format("Apps: {}", apps); + auto* apps_label = lv_label_create(wrapper); + lv_label_set_text(apps_label, apps_text.c_str()); + + auto* uninstall_button = lv_button_create(wrapper); + lv_obj_set_width(uninstall_button, LV_PCT(100)); + lv_obj_add_event_cb(uninstall_button, onPressUninstall, LV_EVENT_SHORT_CLICKED, ctx); + auto* uninstall_label = lv_label_create(uninstall_button); + lv_obj_align(uninstall_label, LV_ALIGN_CENTER, 0, 0); + lv_label_set_text(uninstall_label, "Uninstall"); } int32_t appMain(int argc, char* argv[]) { @@ -107,9 +131,9 @@ int32_t appMain(int argc, char* argv[]) { Context ctx {}; ctx.appInstanceId = appInstanceId; - ctx.targetAppId = (argc > 0) ? argv[0] : std::string(); - if (app_manager_find_manifest(ctx.targetAppId.c_str(), &ctx.targetManifest) != ERROR_NONE) { - LOG_W(TAG, "App %s not found", ctx.targetAppId.c_str()); + ctx.targetPackageId = (argc > 0) ? argv[0] : std::string(); + if (!findPackage(ctx.targetPackageId, ctx.targetPackage, ctx.appIds)) { + LOG_W(TAG, "Package %s not found", ctx.targetPackageId.c_str()); return 0; } @@ -134,7 +158,7 @@ int32_t appMain(int argc, char* argv[]) { case APP_EVENT_RESULT: if (event.result.launch_id == ctx.pendingUninstallDialogId) { if (event.result.result == 0) { // 0 = Yes - app_uninstall(ctx.targetManifest.id); + app_uninstall(ctx.targetPackage.id); shouldClose = true; } app_manager_stop(event.result.launch_id); @@ -156,15 +180,15 @@ int32_t appMain(int argc, char* argv[]) { } // namespace -void start(const std::string& appId) { - const char* argv[] = { appId.c_str() }; +void start(const std::string& packageId) { + const char* argv[] = { packageId.c_str() }; uint32_t instanceId = 0; app_start(manifest.id, 1, argv, &instanceId); } extern const ::AppManifest manifest = { - .id = "tactility.appdetails", - .name = "App Details", + .id = "tactility.apppackagedetails", + .name = "Package Details", .category = APP_CATEGORY_SYSTEM, .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, .flags = APP_MANIFEST_FLAG_HIDDEN, diff --git a/Tactility/Source/app/appsettings/AppSettings.cpp b/Tactility/Source/app/apppackagelist/AppPackageList.cpp similarity index 73% rename from Tactility/Source/app/appsettings/AppSettings.cpp rename to Tactility/Source/app/apppackagelist/AppPackageList.cpp index acede4e3..d5c4d134 100644 --- a/Tactility/Source/app/appsettings/AppSettings.cpp +++ b/Tactility/Source/app/apppackagelist/AppPackageList.cpp @@ -1,11 +1,12 @@ #include #include -#include +#include #include #include #include +#include #include #include @@ -18,7 +19,7 @@ #include #include -namespace tt::app::appsettings { +namespace tt::app::apppackagelist { extern const ::AppManifest manifest; @@ -26,11 +27,13 @@ namespace { struct Context { uint32_t appInstanceId; + // Must outlive the widgets - button user-data points into this, not a createWidgets()-local vector. + std::vector packageIds; }; -void onAppPressed(lv_event_t* e) { - const auto* target_manifest = static_cast(lv_event_get_user_data(e)); - appdetails::start(target_manifest->id); +void onPackagePressed(lv_event_t* e) { + auto* packageId = static_cast(lv_event_get_user_data(e)); + apppackagedetails::start(packageId); } void onBackPressed(lv_event_t* event) { @@ -38,18 +41,16 @@ void onBackPressed(lv_event_t* event) { app_event_emit_close(ctx->appInstanceId); } -void createAppWidget(const ::AppManifest* target_manifest, lv_obj_t* list) { - // The new AppManifest has no per-app icon - use a shared generic one for every entry, same - // fallback AppList.cpp uses. - lv_obj_t* btn = lv_list_add_button(list, LVGL_ICON_SHARED_TOOLBAR, target_manifest->name); +void createPackageWidget(const char* packageId, lv_obj_t* list) { + lv_obj_t* btn = lv_list_add_button(list, LVGL_ICON_SHARED_TOOLBAR, packageId); lv_obj_t* image = lv_obj_get_child(btn, 0); lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN); - lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, const_cast<::AppManifest*>(target_manifest)); + lv_obj_add_event_cb(btn, &onPackagePressed, LV_EVENT_SHORT_CLICKED, const_cast(packageId)); } -void collectManifest(const ::AppManifest* manifest, void* context) { - auto* manifests = static_cast*>(context); - manifests->push_back(manifest); +void collectPackageId(const ::AppPackage* pkg, void* context) { + auto* packageIds = static_cast*>(context); + packageIds->emplace_back(pkg->package.id); } void createWidgets(lv_obj_t* parent, void* userData) { @@ -68,21 +69,16 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_set_width(list, LV_PCT(100)); lv_obj_set_flex_grow(list, 1); - std::vector manifests; - app_manager_for_each_manifest(collectManifest, &manifests); - std::ranges::sort(manifests, [](const ::AppManifest* a, const ::AppManifest* b) { - return strcmp(a->name, b->name) < 0; - }); + // createWidgets() can rerun for this same Context (window rebuild-on-remove). + ctx->packageIds.clear(); + app_manager_for_each_package(collectPackageId, &ctx->packageIds); + std::ranges::sort(ctx->packageIds); - size_t app_count = 0; - for (const auto* target_manifest: manifests) { - if (target_manifest->location.type == APP_LOCATION_PATH) { - app_count++; - createAppWidget(target_manifest, list); - } + for (const auto& packageId : ctx->packageIds) { + createPackageWidget(packageId.c_str(), list); } - if (app_count == 0) { + if (ctx->packageIds.empty()) { // lv_obj_align() is ignored for children of a flex-managed parent, so the empty-state // label needs its own flex-growing wrapper to center within; the (empty) list is hidden // rather than deleted so the wrapper can just take its place in the flex flow. @@ -139,7 +135,7 @@ int32_t appMain(int argc, char* argv[]) { } // namespace extern const ::AppManifest manifest = { - .id = "tactility.appsettings", + .id = "tactility.apppackagelist", .name = "Apps", .category = APP_CATEGORY_SETTINGS, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, diff --git a/Tactility/Source/app/boot/Boot.cpp b/Tactility/Source/app/boot/Boot.cpp index 983302dd..911751f0 100644 --- a/Tactility/Source/app/boot/Boot.cpp +++ b/Tactility/Source/app/boot/Boot.cpp @@ -10,9 +10,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/btmanage/BtManage.cpp b/Tactility/Source/app/btmanage/BtManage.cpp index 8b1a327a..eed479f2 100644 --- a/Tactility/Source/app/btmanage/BtManage.cpp +++ b/Tactility/Source/app/btmanage/BtManage.cpp @@ -5,9 +5,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/btpeersettings/BtPeerSettings.cpp b/Tactility/Source/app/btpeersettings/BtPeerSettings.cpp index c339332c..7a14a698 100644 --- a/Tactility/Source/app/btpeersettings/BtPeerSettings.cpp +++ b/Tactility/Source/app/btpeersettings/BtPeerSettings.cpp @@ -10,9 +10,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp b/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp index fdcd7ab5..ec9412b6 100644 --- a/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp +++ b/Tactility/Source/app/crashdiagnostics/CrashDiagnostics.cpp @@ -11,8 +11,8 @@ #include #include -#include #include +#include #include #include diff --git a/Tactility/Source/app/development/Development.cpp b/Tactility/Source/app/development/Development.cpp index 058b5e24..cb76f70b 100644 --- a/Tactility/Source/app/development/Development.cpp +++ b/Tactility/Source/app/development/Development.cpp @@ -7,9 +7,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/fileselection/FileSelection.cpp b/Tactility/Source/app/fileselection/FileSelection.cpp index 3e98e2c2..f1cb15c3 100644 --- a/Tactility/Source/app/fileselection/FileSelection.cpp +++ b/Tactility/Source/app/fileselection/FileSelection.cpp @@ -6,9 +6,9 @@ #include #include #include -#include #include #include +#include #include #include diff --git a/Tactility/Source/app/gpssettings/GpsSettings.cpp b/Tactility/Source/app/gpssettings/GpsSettings.cpp index f3798cd2..7602cdb7 100644 --- a/Tactility/Source/app/gpssettings/GpsSettings.cpp +++ b/Tactility/Source/app/gpssettings/GpsSettings.cpp @@ -8,9 +8,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/i2cscanner/I2cScanner.cpp b/Tactility/Source/app/i2cscanner/I2cScanner.cpp index 87fc23db..80229b2c 100644 --- a/Tactility/Source/app/i2cscanner/I2cScanner.cpp +++ b/Tactility/Source/app/i2cscanner/I2cScanner.cpp @@ -7,10 +7,10 @@ #include #include -#include #include #include #include +#include #include diff --git a/Tactility/Source/app/imageviewer/ImageViewer.cpp b/Tactility/Source/app/imageviewer/ImageViewer.cpp index ac64c298..59d1cca4 100644 --- a/Tactility/Source/app/imageviewer/ImageViewer.cpp +++ b/Tactility/Source/app/imageviewer/ImageViewer.cpp @@ -6,9 +6,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/inputdialog/InputDialog.cpp b/Tactility/Source/app/inputdialog/InputDialog.cpp index 13ea866d..f8754f65 100644 --- a/Tactility/Source/app/inputdialog/InputDialog.cpp +++ b/Tactility/Source/app/inputdialog/InputDialog.cpp @@ -2,9 +2,9 @@ #include #include -#include #include #include +#include #include #include diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index 63a9d986..3421f93f 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -1,8 +1,8 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/notes/Notes.cpp b/Tactility/Source/app/notes/Notes.cpp index 24c1fc65..4608624d 100644 --- a/Tactility/Source/app/notes/Notes.cpp +++ b/Tactility/Source/app/notes/Notes.cpp @@ -7,9 +7,9 @@ #include #include -#include #include #include +#include #include #include diff --git a/Tactility/Source/app/selectiondialog/SelectionDialog.cpp b/Tactility/Source/app/selectiondialog/SelectionDialog.cpp index 5f9927b1..f2d70f9a 100644 --- a/Tactility/Source/app/selectiondialog/SelectionDialog.cpp +++ b/Tactility/Source/app/selectiondialog/SelectionDialog.cpp @@ -2,9 +2,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/settings/Settings.cpp b/Tactility/Source/app/settings/Settings.cpp index f623618f..3fed7fc1 100644 --- a/Tactility/Source/app/settings/Settings.cpp +++ b/Tactility/Source/app/settings/Settings.cpp @@ -1,8 +1,8 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/setup/Setup.cpp b/Tactility/Source/app/setup/Setup.cpp index 42047b9b..e746252c 100644 --- a/Tactility/Source/app/setup/Setup.cpp +++ b/Tactility/Source/app/setup/Setup.cpp @@ -10,9 +10,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp index c52c486b..8e51db22 100644 --- a/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp +++ b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp @@ -5,9 +5,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/timezone/TimeZone.cpp b/Tactility/Source/app/timezone/TimeZone.cpp index f1085066..ad561554 100644 --- a/Tactility/Source/app/timezone/TimeZone.cpp +++ b/Tactility/Source/app/timezone/TimeZone.cpp @@ -8,9 +8,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/wifiapsettings/WifiApSettings.cpp b/Tactility/Source/app/wifiapsettings/WifiApSettings.cpp index 581b9106..4f801aa5 100644 --- a/Tactility/Source/app/wifiapsettings/WifiApSettings.cpp +++ b/Tactility/Source/app/wifiapsettings/WifiApSettings.cpp @@ -5,9 +5,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/wificonnect/WifiConnect.cpp b/Tactility/Source/app/wificonnect/WifiConnect.cpp index 3cc32460..0c6ff7b0 100644 --- a/Tactility/Source/app/wificonnect/WifiConnect.cpp +++ b/Tactility/Source/app/wificonnect/WifiConnect.cpp @@ -6,9 +6,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/app/wifimanage/WifiManage.cpp b/Tactility/Source/app/wifimanage/WifiManage.cpp index fdc97442..53d62b4a 100644 --- a/Tactility/Source/app/wifimanage/WifiManage.cpp +++ b/Tactility/Source/app/wifimanage/WifiManage.cpp @@ -6,9 +6,9 @@ #include #include -#include #include #include +#include #include diff --git a/Tactility/Source/service/development/DevelopmentService.cpp b/Tactility/Source/service/development/DevelopmentService.cpp index c88900c4..160c962f 100644 --- a/Tactility/Source/service/development/DevelopmentService.cpp +++ b/Tactility/Source/service/development/DevelopmentService.cpp @@ -190,7 +190,7 @@ error_t DevelopmentService::handleAppInstall(HttpServerRequest* request, void*) if ( name_entry == content_disposition_map.end() || filename_entry == content_disposition_map.end() || - name_entry->second != "elf" + name_entry->second != "app" ) { http_server_request_send_error(request, 400, "Multipart form error: name or filename parameter missing or mismatching"); return ERROR_UNDEFINED; diff --git a/Tests/SdkIntegration/manifest.properties b/Tests/SdkIntegration/manifest.properties index 2f57201b..86e69e68 100644 --- a/Tests/SdkIntegration/manifest.properties +++ b/Tests/SdkIntegration/manifest.properties @@ -1,7 +1,9 @@ -manifest.version=0.2 -target.sdk=0.0.0 +manifest.version=0.3 +target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 -app.id=tactility.sdktest -app.version.name=0.1.0 -app.version.code=1 -app.name=SDK Test +id=tactility.sdktest +version.name=0.1.0 +version.code=1 +app.0.id=sdktest +app.0.name=SDK Test +app.0.binary=main diff --git a/Tests/SdkIntegration/tactility.py b/Tests/SdkIntegration/tactility.py index 91d6c2f3..ef8c5f52 100644 --- a/Tests/SdkIntegration/tactility.py +++ b/Tests/SdkIntegration/tactility.py @@ -12,7 +12,7 @@ import tarfile from urllib.parse import urlparse ttbuild_path = ".tactility" -ttbuild_version = "5.0.1" +ttbuild_version = "6.0.0" ttbuild_cdn = "https://cdn.tactilityproject.org" ttbuild_sdk_json_validity = 3600 # seconds ttport = 6666 @@ -21,7 +21,7 @@ use_local_sdk = False local_base_path = None http_timeout_seconds = 10 # App install uploads the whole package over HTTP and the device only responds once it's -# fully received, extracted and registered - large packages (e.g. bundled fonts/assets) can +# fully received, extracted and registered; large packages (e.g. bundled fonts/assets) can # easily take well over http_timeout_seconds on a slow SD card, so give it a lot more room. install_timeout_seconds = 120 @@ -33,30 +33,55 @@ shell_color_cyan = "\033[36m" shell_color_reset = "\033[m" def print_help(): - print("Usage: python tactility.py [app_path] [action] [options]") + print("Usage: python tactility.py [action] [options]") print("") print("Actions:") - print(" build [platform] Build the app. Optionally specify a platform.") + print("") + print(" build Build the app. Optionally specify a platform.") print(" Supported platforms are lower case. Example: esp32s3") print(" Supported platforms are read from manifest.properties") + print(" Parameters:") + print(" (optional) --architecture [input], -a [input]") + print("") print(" clean Clean the build folders") + print("") print(" clearcache Clear the SDK cache") + print("") print(" updateself Update this tool") - print(" run [ip] Run the application") - print(" install [ip] Install the application") - print(" uninstall [ip] Uninstall the application") - print(" bir [ip] [platform] Build, install then run. Optionally specify a platform.") - print(" brrr [ip] [platform] Functionally the same as \"bir\", but \"app goes brrr\" meme variant.") + print("") + print(" run Run the application") + print(" Parameters:") + print(" (required) --host, -h [input]") + print("") + print(" install Install the application") + print(" Parameters:") + print(" (required) --host, -h [input]") + print("") + print(" uninstall Uninstall the application") + print(" Parameters:") + print(" (required) --host, -h [input]") + print("") + print(" bir Build, install then run.") + print(" Parameters:") + print(" (required) --host, -h [input]") + print(" (optional) --architecture, -a [input]") + print("") + print(" brrr Functionally the same as \"bir\", but \"app goes brrr\" meme variant.") + print(" Parameters:") + print(" (required) --host, -h [input]") + print(" (optional) --architecture, -a [input]") print("") print("Options:") + print(" -p, --path [input] Path to the app directory (defaults to current directory)") print(" --help Show this commandline info") print(" --local-sdk Use SDK specified by environment variable TACTILITY_SDK_PATH with platform subfolders matching target platforms.") print(" --skip-build Run everything except the idf.py/CMake commands") print(" --verbose Show extra console output") print("") print("Examples:") - print(" python tactility.py Apps/Snake build esp32s3 --verbose") - print(" python tactility.py Apps/Snake bir 192.168.1.50 esp32s3") + print(" python tactility.py build") + print(" python tactility.py build --path Apps/Snake --architecture esp32s3 --verbose") + print(" python tactility.py bir --path Apps/Snake --host 192.168.1.50 --architecture esp32s3") # region Core @@ -105,8 +130,8 @@ def exit_with_error(message): print_error(message) sys.exit(1) -def get_url(ip, path): - return f"http://{ip}:{ttport}{path}" +def get_url(host, path): + return f"http://{host}:{ttport}{path}" def read_properties_file(path): properties = {} @@ -123,6 +148,126 @@ def read_properties_file(path): #endregion Core +#region Versioning + +class SemanticVersion: + def __init__(self, major, minor, patch, tag=None): + self.major = major + self.minor = minor + self.patch = patch + self.tag = tag + + @staticmethod + def parse(version_string): + match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$", version_string) + if match is None: + exit_with_error(f"Invalid version format: {version_string}") + major, minor, patch, tag = match.groups() + return SemanticVersion(int(major), int(minor), int(patch), tag) + + def _numeric(self): + return (self.major, self.minor, self.patch) + + def __eq__(self, other): + return self._numeric() == other._numeric() and self.tag == other.tag + + def __lt__(self, other): + if self._numeric() != other._numeric(): + return self._numeric() < other._numeric() + # Same major.minor.patch: an untagged version outranks any tagged (pre-release) one. + if self.tag == other.tag: + return False + if self.tag is None: + return False + if other.tag is None: + return True + return self.tag < other.tag + + def __str__(self): + return f"{self.major}.{self.minor}.{self.patch}" + (f"-{self.tag}" if self.tag else "") + + # Ignores tags: a "-dev"/"-rc1"-tagged pre-release build of a version is still that version, + # not an older one, unlike __lt__'s full ordering (which ranks pre-releases below releases). + def is_older_release_of(self, other): + return self._numeric() < other._numeric() + +# Append a new entry whenever the tool drops support for older SDKs. +# The last entry is the currently effective minimum. +SDK_COMPATIBILITY_LEDGER = [ + SemanticVersion.parse("0.8.0"), +] + +def minimum_supported_sdk_version(): + return SDK_COMPATIBILITY_LEDGER[-1] + +def validate_sdk_compatibility(target_sdk_version): + minimum = minimum_supported_sdk_version() + if SemanticVersion.parse(target_sdk_version).is_older_release_of(minimum): + exit_with_error( + f"This tool requires SDK version {minimum} or newer " + f"(manifest.properties requests {target_sdk_version})" + ) + +#endregion Versioning + +#region Argument parsing + +# canonical name -> (short flag or None, long flag, takes_value) +ARG_SPECS = { + "path": ("-p", "--path", True), + "host": ("-h", "--host", True), + "platform": ("-a", "--architecture", True), + "verbose": (None, "--verbose", False), + "skip-build": (None, "--skip-build", False), + "local-sdk": (None, "--local-sdk", False), + "help": (None, "--help", False), +} + +class Arguments: + def __init__(self, values): + self.values = values + + def get(self, key, default=None): + return self.values.get(key, default) + + def has(self, key): + return key in self.values + +class ParsedCommand: + def __init__(self, action, args): + self.action = action + self.arguments = args + +def parse_command_line(argv): + token_to_spec = {} + for canonical, (short, long, takes_value) in ARG_SPECS.items(): + if short is not None: + token_to_spec[short] = (canonical, takes_value) + token_to_spec[long] = (canonical, takes_value) + + action = None + args = {} + index = 0 + while index < len(argv): + token = argv[index] + if token in token_to_spec: + canonical, takes_value = token_to_spec[token] + if takes_value: + index += 1 + if index >= len(argv): + exit_with_error(f"Missing value for {token}") + args[canonical] = argv[index] + else: + args[canonical] = "true" + elif action is None: + action = token + else: + exit_with_error(f"Unexpected argument: {token}") + index += 1 + return ParsedCommand(action, Arguments(args)) + +#endregion Argument parsing + #region SDK helpers def read_sdk_json(): @@ -232,7 +377,7 @@ def validate_self(sdk_json): exit_with_error("Server returned invalid SDK data format (toolDownloadUrl not found)") tool_version = sdk_json["toolVersion"] tool_compatibility = sdk_json["toolCompatibility"] - if tool_version != ttbuild_version: + if SemanticVersion.parse(ttbuild_version) < SemanticVersion.parse(tool_version): print_warning(f"New version available: {tool_version} (currently using {ttbuild_version})") print_warning(f"Run 'tactility.py updateself' to update.") if re.search(tool_compatibility, ttbuild_version) is None: @@ -247,10 +392,70 @@ def validate_self(sdk_json): def read_manifest(): return read_properties_file("manifest.properties") +def is_v3_manifest(manifest): + return manifest.get("manifest.version") == "0.3" + +# The package identifier: a v2 manifest has no package/app distinction, so its single "app.id" +# doubles as both; a v3 manifest's package id is the bare "id" key, distinct from any of its +# (possibly several) "app.N.id" app ids. +def get_package_id(manifest): + return manifest["id"] if is_v3_manifest(manifest) else manifest["app.id"] + +# The id of the app "run"/"install" operate on. A v2 manifest has exactly one; a v3 manifest may +# declare several, so this picks the first ("app.0.id") as the one a single-app dev workflow means. +def get_primary_app_id(manifest): + return manifest["app.0.id"] if is_v3_manifest(manifest) else manifest["app.id"] + def validate_manifest(manifest): - for key in ("manifest.version", "target.sdk", "target.platforms", "app.id", "app.version.name", "app.version.code", "app.name"): + for key in ("manifest.version", "target.sdk", "target.platforms"): if key not in manifest: exit_with_error(f"Invalid manifest format: {key} not found") + if manifest["manifest.version"] not in ("0.2", "0.3"): + exit_with_error(f"Unsupported manifest.version: {manifest['manifest.version']}") + if is_v3_manifest(manifest): + for key in ("id", "version.name", "version.code"): + if key not in manifest: + exit_with_error(f"Invalid manifest format: {key} not found") + if "app.0.id" not in manifest: + exit_with_error("Invalid manifest format: app.0.id not found") + index = 0 + while f"app.{index}.id" in manifest: + for suffix in ("name", "binary"): + key = f"app.{index}.{suffix}" + if key not in manifest: + exit_with_error(f"Invalid manifest format: {key} not found") + index += 1 + else: + for key in ("app.id", "app.version.name", "app.version.code", "app.name"): + if key not in manifest: + exit_with_error(f"Invalid manifest format: {key} not found") + validate_sdk_compatibility(manifest["target.sdk"]) + +# Maps each binary this app builds to the directory its own CMake project lives in. A v2 +# manifest always describes exactly one app, built at the app's own root (see +# package_intermediate_binaries() for its fixed "app.{elf,so}" package filename, matching +# package_manifest_parse_v2()'s single implicit binary). A v3 manifest declaring a single +# "app.0.*" block is the same case: built at the app's own root. Only when a v3 manifest +# declares more than one "app.N.*" block (0-indexed, contiguous - the first missing "app.N.id" +# ends the list, mirroring package_manifest_parse_v3()) does each get its own subdirectory, +# named after its own "app.N.binary" (the filename it installs as - see +# package_manifest_parse_v3()'s doc). +def get_binary_dirs(manifest): + if is_v3_manifest(manifest): + binaries = [] + index = 0 + while f"app.{index}.id" in manifest: + binary = manifest[f"app.{index}.binary"] + binaries.append((binary, binary)) + index += 1 + if len(binaries) == 1: + return [(binaries[0][0], ".")] + for binary, directory in binaries: + if not os.path.isdir(directory): + exit_with_error(f"Binary directory not found for '{binary}': {directory}") + return binaries + else: + return [(manifest["app.id"], ".")] def is_valid_manifest_platform(manifest, platform): manifest_platforms = manifest["target.platforms"].split(",") @@ -345,7 +550,7 @@ tactility_project_post(%(app_id)s) def cmakelists_version_marker(): return f"# tactility-cmakelists-version: {CMAKELISTS_VERSION}" -def ensure_cmakelists_up_to_date(manifest): +def ensure_cmakelists_up_to_date(project_id): marker = cmakelists_version_marker() if os.path.exists("CMakeLists.txt"): with open("CMakeLists.txt", "r") as file: @@ -353,7 +558,7 @@ def ensure_cmakelists_up_to_date(manifest): if first_line == marker: return print(f"Updating CMakeLists.txt to {marker}") - content = CMAKELISTS_TEMPLATE % {"version": CMAKELISTS_VERSION, "app_id": manifest["app.id"]} + content = CMAKELISTS_TEMPLATE % {"version": CMAKELISTS_VERSION, "app_id": project_id} with open("CMakeLists.txt", "w") as file: file.write(content) @@ -532,43 +737,58 @@ def package_intermediate_manifest(target_path): shutil.copy("manifest.properties", os.path.join(target_path, "manifest.properties")) return True -def package_intermediate_binaries(target_path, platforms): - elf_dir = os.path.join(target_path, "elf") - os.makedirs(elf_dir, exist_ok=True) - for platform in platforms: - elf_path = find_elf_file(platform) - if elf_path is None: - print_error(f"ELF file not found for {platform}") - return False - # app-posix-module's loader resolves an installed app to "elf/posix-.so", matching - # its own compile-time architecture, not "*.elf". - extension = ".so" if platform.startswith("posix") else ".elf" - shutil.copy(elf_path, os.path.join(elf_dir, f"{platform}{extension}")) +def get_artifact_extension(platform): + # POSIX apps are dlopen()ed shared objects (app-posix-module), not idf.py/elf_loader + # relocatable images, so they land as a plain ".so" instead of ".elf". + return ".so" if platform.startswith("posix") else ".elf" + +def package_intermediate_binaries(target_path, platforms, manifest): + # Each binary is built in its own directory (get_binary_dirs()), with its own + # build/cmake-build-{platform} tree; chdir into it so find_elf_file()/get_cmake_path() + # (both CWD-relative) resolve against the right one, then restore CWD for the next binary. + original_cwd = os.getcwd() + for binary, directory in get_binary_dirs(manifest): + os.chdir(directory) + try: + for platform in platforms: + elf_path = find_elf_file(platform) + if elf_path is None: + print_error(f"ELF file not found for '{binary}' on {platform}") + return False + # v2's single app always installs at the fixed path bin/{platform}/app.{elf,so} + # (package_manifest_parse_v2()); v3 apps install under their own declared + # app.N.binary name (package_manifest_parse_v3()). + artifact_name = binary if is_v3_manifest(manifest) else "app" + platform_dir = os.path.join(target_path, "bin", platform) + os.makedirs(platform_dir, exist_ok=True) + shutil.copy(elf_path, os.path.join(platform_dir, f"{artifact_name}{get_artifact_extension(platform)}")) + finally: + os.chdir(original_cwd) return True def package_intermediate_assets(target_path): if os.path.isdir("assets"): shutil.copytree("assets", os.path.join(target_path, "assets"), dirs_exist_ok=True) -def package_intermediate(platforms): - target_path = os.path.join("build", "package-intermediate") +def package_intermediate(platforms, manifest): + target_path = os.path.abspath(os.path.join("build", "package-intermediate")) if os.path.isdir(target_path): shutil.rmtree(target_path) os.makedirs(target_path, exist_ok=True) if not package_intermediate_manifest(target_path): return False - if not package_intermediate_binaries(target_path, platforms): + if not package_intermediate_binaries(target_path, platforms, manifest): return False package_intermediate_assets(target_path) return True def package_name(manifest): - return os.path.join("build", f"{manifest['app.id']}.app") + return os.path.join("build", f"{get_package_id(manifest)}.app") def package_all(manifest, platforms): status = f"Building package with {platforms}" print_status_busy(status) - if not package_intermediate(platforms): + if not package_intermediate(platforms, manifest): print_status_error("Building package failed: missing inputs") return False # Create build/something.app @@ -588,8 +808,9 @@ def setup_environment(): global ttbuild_path os.makedirs(ttbuild_path, exist_ok=True) -def build_action(manifest, platform_arg, skip_build): - ensure_cmakelists_up_to_date(manifest) +def build_action(manifest, arguments): + platform_arg = arguments.get("platform") + skip_build = arguments.has("skip-build") platforms_to_build = get_manifest_target_platforms(manifest, platform_arg) # Environment validation validate_environment(platforms_to_build) @@ -598,10 +819,10 @@ def build_action(manifest, platform_arg, skip_build): global local_base_path local_base_path = os.environ.get("TACTILITY_SDK_PATH") validate_local_sdks(platforms_to_build, manifest["target.sdk"]) - + if should_fetch_sdkconfig_files(platforms_to_build): fetch_sdkconfig_files(platforms_to_build) - + if not use_local_sdk: sdk_json = read_sdk_json() validate_self(sdk_json) @@ -610,19 +831,49 @@ def build_action(manifest, platform_arg, skip_build): if not use_local_sdk: if not sdk_download_all(sdk_version, platforms_to_build): exit_with_error("Failed to download one or more SDKs") - if not build_all(sdk_version, platforms_to_build, skip_build): # Environment validation - return False + + # A multi-binary app builds each of its binaries in its own subdirectory as an independent + # CMake project; a single-binary app has exactly one binary, built at the app root + # ("." from get_binary_dirs()), matching the tool's original single-binary behavior exactly. + original_cwd = os.getcwd() + for binary, directory in get_binary_dirs(manifest): + os.chdir(directory) + try: + ensure_cmakelists_up_to_date(binary if directory == "." else f"{get_package_id(manifest)}.{binary}") + if not build_all(sdk_version, platforms_to_build, skip_build): + return False + finally: + os.chdir(original_cwd) + if not skip_build: if not package_all(manifest, platforms_to_build): return False return True -def clean_action(): +def clean_action(manifest): + cleaned_any = False + # The app root always has its own build/ (package-intermediate + the final .app/.elf, + # regardless of binary count). A multi-binary app additionally has one build/ per binary + # subdirectory (get_binary_dirs()), which "." (the root, handled above) doesn't repeat. if os.path.exists("build"): print_status_busy("Removing build/") shutil.rmtree("build") print_status_success("Removed build/") - else: + cleaned_any = True + original_cwd = os.getcwd() + for _, directory in get_binary_dirs(manifest): + if directory == ".": + continue + os.chdir(directory) + try: + if os.path.exists("build"): + print_status_busy(f"Removing {directory}/build/") + shutil.rmtree("build") + print_status_success(f"Removed {directory}/build/") + cleaned_any = True + finally: + os.chdir(original_cwd) + if not cleaned_any: print("Nothing to clean") def clear_cache_action(): @@ -641,9 +892,9 @@ def update_self_action(): else: exit_with_error("Update failed") -def get_device_info(ip): +def get_device_info(host): print_status_busy(f"Requesting device info") - url = get_url(ip, "/info") + url = get_url(host, "/info") try: response = requests.get(url, timeout=http_timeout_seconds) if response.status_code != 200: @@ -654,10 +905,13 @@ def get_device_info(ip): except requests.RequestException as e: print_status_error(f"Device info request failed: {e}") -def run_action(manifest, ip): - app_id = manifest["app.id"] +def run_action(manifest, arguments): + host = arguments.get("host") + if host is None: + exit_with_error("Missing required argument: --host") + app_id = get_primary_app_id(manifest) print_status_busy("Running") - url = get_url(ip, "/app/run") + url = get_url(host, "/app/run") params = {'id': app_id} try: response = requests.post(url, params=params, timeout=http_timeout_seconds) @@ -668,21 +922,21 @@ def run_action(manifest, ip): except requests.RequestException as e: print_status_error(f"Running request failed: {e}") -def install_action(manifest, ip, platforms): - print_status_busy("Installing") - for platform in platforms: - elf_path = find_elf_file(platform) - if elf_path is None: - print_status_error(f"ELF file not built for {platform}") - return False +def install_action(manifest, arguments): + host = arguments.get("host") + if host is None: + exit_with_error("Missing required argument: --host") package_path = package_name(manifest) - # print(f"Installing {package_path} to {ip}") - url = get_url(ip, "/app/install") + if not os.path.isfile(package_path): + print_status_error(f"Package not found: {package_path} (run 'build' first)") + return False + print_status_busy("Installing") + url = get_url(host, "/app/install") try: # Prepare multipart form data with open(package_path, 'rb') as file: files = { - 'elf': file + 'app': file } response = requests.put(url, files=files, timeout=install_timeout_seconds) if response.status_code != 200: @@ -698,10 +952,13 @@ def install_action(manifest, ip, platforms): print_status_error(f"Install file error: {e}") return False -def uninstall_action(manifest, ip): - app_id = manifest["app.id"] +def uninstall_action(manifest, arguments): + host = arguments.get("host") + if host is None: + exit_with_error("Missing required argument: --host") + app_id = get_package_id(manifest) print_status_busy("Uninstalling") - url = get_url(ip, "/app/uninstall") + url = get_url(host, "/app/uninstall") params = {'id': app_id} try: response = requests.put(url, params=params, timeout=http_timeout_seconds) @@ -716,38 +973,34 @@ def uninstall_action(manifest, ip): if __name__ == "__main__": print(f"Tactility Build System v{ttbuild_version}") - if "--help" in sys.argv: + + # Anchor the cache to the invocation directory, before --path (below) can chdir into the app. + ttbuild_path = os.path.abspath(ttbuild_path) + + argv = sys.argv[1:] + if len(argv) == 0: + print_help() + sys.exit(1) + + parsed_command = parse_command_line(argv) + + if parsed_command.arguments.has("help"): print_help() sys.exit() - # Argument validation - if len(sys.argv) == 1: + if parsed_command.action is None: print_help() sys.exit(1) - if "--verbose" in sys.argv: - verbose = True - sys.argv.remove("--verbose") - skip_build = False - if "--skip-build" in sys.argv: - skip_build = True - sys.argv.remove("--skip-build") - if "--local-sdk" in sys.argv: - use_local_sdk = True - sys.argv.remove("--local-sdk") - - # Check if the first argument is a path to an app directory - if len(sys.argv) > 2: - potential_app_dir = sys.argv[1] - if os.path.isdir(potential_app_dir) and os.path.isfile(os.path.join(potential_app_dir, "manifest.properties")): - if verbose: - print_status_success(f"Switching to app directory: {potential_app_dir}") - os.chdir(potential_app_dir) - sys.argv = [sys.argv[0]] + sys.argv[2:] - - if len(sys.argv) < 2: - print_help() - sys.exit(1) - - action_arg = sys.argv[1] + + verbose = parsed_command.arguments.has("verbose") + use_local_sdk = parsed_command.arguments.has("local-sdk") + + app_path = parsed_command.arguments.get("path") + if app_path is not None: + if not os.path.isdir(app_path) or not os.path.isfile(os.path.join(app_path, "manifest.properties")): + exit_with_error(f"App path not found or missing manifest.properties: {app_path}") + if verbose: + print_status_success(f"Switching to app directory: {app_path}") + os.chdir(app_path) # Environment setup setup_environment() @@ -755,58 +1008,30 @@ if __name__ == "__main__": exit_with_error("manifest.properties not found") manifest = read_manifest() validate_manifest(manifest) - all_platform_targets = manifest["target.platforms"].split(",") # Update SDK cache (tool.json) if not use_local_sdk and should_update_tool_json() and not update_tool_json(): exit_with_error("Failed to retrieve SDK info") # Actions + action_arg = parsed_command.action if action_arg == "build": - if len(sys.argv) < 2: - print_help() - exit_with_error("Commandline parameter missing") - platform = None - if len(sys.argv) > 2: - platform = sys.argv[2] - if not build_action(manifest, platform, skip_build): + if not build_action(manifest, parsed_command.arguments): sys.exit(1) elif action_arg == "clean": - clean_action() + clean_action(manifest) elif action_arg == "clearcache": clear_cache_action() elif action_arg == "updateself": update_self_action() elif action_arg == "run": - if len(sys.argv) < 3: - print_help() - exit_with_error("Commandline parameter missing") - run_action(manifest, sys.argv[2]) + run_action(manifest, parsed_command.arguments) elif action_arg == "install": - if len(sys.argv) < 3: - print_help() - exit_with_error("Commandline parameter missing") - platform = None - platforms_to_install = all_platform_targets - if len(sys.argv) >= 4: - platform = sys.argv[3] - platforms_to_install = [platform] - install_action(manifest, sys.argv[2], platforms_to_install) + install_action(manifest, parsed_command.arguments) elif action_arg == "uninstall": - if len(sys.argv) < 3: - print_help() - exit_with_error("Commandline parameter missing") - uninstall_action(manifest, sys.argv[2]) + uninstall_action(manifest, parsed_command.arguments) elif action_arg == "bir" or action_arg == "brrr": - if len(sys.argv) < 3: - print_help() - exit_with_error("Commandline parameter missing") - platform = None - platforms_to_install = all_platform_targets - if len(sys.argv) >= 4: - platform = sys.argv[3] - platforms_to_install = [platform] - if build_action(manifest, platform, skip_build): - if install_action(manifest, sys.argv[2], platforms_to_install): - run_action(manifest, sys.argv[2]) + if build_action(manifest, parsed_command.arguments): + if install_action(manifest, parsed_command.arguments): + run_action(manifest, parsed_command.arguments) else: print_help() exit_with_error("Unknown commandline parameter")