From ca7dcdf282088fdefbe55e33e070ed15bd2e0328 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Thu, 11 Sep 2025 23:14:46 +0200 Subject: [PATCH] App installation refactor WIP --- .gitmodules | 3 + CMakeLists.txt | 2 + Documentation/ideas.md | 4 + ExternalApps/HelloWorld/.gitignore | 2 +- ExternalApps/HelloWorld/assets/message.txt | 1 + ExternalApps/HelloWorld/manifest.properties | 13 + ExternalApps/HelloWorld/tactility.properties | 2 - ExternalApps/HelloWorld/tactility.py | 269 +++++++++++++----- Libraries/minitar/CMakeLists.txt | 22 ++ Libraries/minitar/minitar | 2 +- Tactility/CMakeLists.txt | 3 +- Tactility/Include/Tactility/app/App.h | 2 + Tactility/Source/app/App.cpp | 3 +- Tactility/Source/app/AppInstall.cpp | 147 ++++++++++ Tactility/Source/file/PropertiesFile.cpp | 17 +- .../development/DevelopmentService.cpp | 20 +- TactilityCore/Include/Tactility/file/File.h | 12 +- TactilityCore/Source/file/File.cpp | 53 +++- 18 files changed, 483 insertions(+), 94 deletions(-) create mode 100644 ExternalApps/HelloWorld/assets/message.txt create mode 100644 ExternalApps/HelloWorld/manifest.properties delete mode 100644 ExternalApps/HelloWorld/tactility.properties create mode 100644 Libraries/minitar/CMakeLists.txt create mode 100644 Tactility/Source/app/AppInstall.cpp diff --git a/.gitmodules b/.gitmodules index 748090d7..8ec5ac04 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "Libraries/SDL"] path = Libraries/SDL url = https://github.com/libsdl-org/SDL.git +[submodule "Libraries/minitar/minitar"] + path = Libraries/minitar/minitar + url = git@github.com:gabscoarnec/minitar.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 50938ae7..c22d6d45 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,7 @@ if (DEFINED ENV{ESP_IDF_VERSION}) "Libraries/elf_loader" "Libraries/lvgl" "Libraries/lv_screenshot" + "Libraries/minitar" "Libraries/minmea" "Libraries/QRCode" ) @@ -78,6 +79,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION}) add_subdirectory(Libraries/FreeRTOS-Kernel) add_subdirectory(Libraries/lv_screenshot) add_subdirectory(Libraries/QRCode) + add_subdirectory(Libraries/minitar) add_subdirectory(Libraries/minmea) target_compile_definitions(freertos_kernel PUBLIC "projCOVERAGE_TEST=0") target_include_directories(freertos_kernel diff --git a/Documentation/ideas.md b/Documentation/ideas.md index 9dfd0e65..f3abb34b 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -19,9 +19,12 @@ - Apps with update timer should check `lvgl::isStarted()` - CrowPanel Basic 3.5": check why GraphicsDemo fails - CrowPanel Basic 3.5": check why System Info doesn't show storage info +- Update to LVGL v9.3 stable ## Medium Priority +- Make WiFi setup app that starts an access point and hosts a webpage to set up the device. + This will be useful for devices without a screen, a small screen or a non-touch screen. - Unify the way displays are dimmed. Some implementations turn off the display when it's fully dimmed. Make this a separate functionality. - Try out ILI9342 https://github.com/jbrilha/esp_lcd_ili9342 - All drivers (e.g. display, touch, etc.) should call stop() in their destructor, or at least assert that they should not be running. @@ -63,6 +66,7 @@ - Capacity based on voltage: estimation for various devices uses a linear voltage curve, but it should use some sort of battery discharge curve. - Statusbar widget to show how much memory is in use? - Wrapper for lvgl slider widget that shows "+" and "-" buttons, and also the value in a label. + Note: consider Spinbox - Files app: copy/paste actions - On crash, try to save the current log to flash or SD card? (this is risky, though, so ask in Discord first) - Support more than 1 hardware keyboard (see lvgl::hardware_keyboard_set_indev()). LVGL init currently calls keyboard init, but that part should probably be done from the KeyboardDevice base class. diff --git a/ExternalApps/HelloWorld/.gitignore b/ExternalApps/HelloWorld/.gitignore index 89baa26e..eac918a0 100644 --- a/ExternalApps/HelloWorld/.gitignore +++ b/ExternalApps/HelloWorld/.gitignore @@ -1,2 +1,2 @@ -build*/ +build/ .tactility/ diff --git a/ExternalApps/HelloWorld/assets/message.txt b/ExternalApps/HelloWorld/assets/message.txt new file mode 100644 index 00000000..af5626b4 --- /dev/null +++ b/ExternalApps/HelloWorld/assets/message.txt @@ -0,0 +1 @@ +Hello, world! diff --git a/ExternalApps/HelloWorld/manifest.properties b/ExternalApps/HelloWorld/manifest.properties new file mode 100644 index 00000000..beff38c7 --- /dev/null +++ b/ExternalApps/HelloWorld/manifest.properties @@ -0,0 +1,13 @@ +[manifest] +version=0.1 +[target] +sdk=0.5.0 +platforms=esp32,esp32s3 +[app] +id=com.bytewelder.helloworld +version=0.1.0 +name=Hello World +description=A demonstration app that says hi +[author] +name=ByteWelder +website=https://bytewelder.com diff --git a/ExternalApps/HelloWorld/tactility.properties b/ExternalApps/HelloWorld/tactility.properties deleted file mode 100644 index 8543281b..00000000 --- a/ExternalApps/HelloWorld/tactility.properties +++ /dev/null @@ -1,2 +0,0 @@ -[sdk] -version = 0.5.0 diff --git a/ExternalApps/HelloWorld/tactility.py b/ExternalApps/HelloWorld/tactility.py index a1ede4ea..31c40bd6 100644 --- a/ExternalApps/HelloWorld/tactility.py +++ b/ExternalApps/HelloWorld/tactility.py @@ -8,22 +8,19 @@ import subprocess import time import urllib.request import zipfile - import requests +import tarfile +import shutil +import configparser -# Targetable platforms that represent a specific hardware target -platform_targets = ["esp32", "esp32s3"] -# All valid platform commandline arguments -platform_arguments = platform_targets.copy() -platform_arguments.append("all") ttbuild_path = ".tactility" -ttbuild_version = "1.2.1" -ttbuild_properties_file = "tactility.properties" +ttbuild_version = "2.0.0" ttbuild_cdn = "https://cdn.tactility.one" ttbuild_sdk_json_validity = 3600 # seconds ttport = 6666 verbose = False use_local_sdk = False +valid_platforms = ["esp32", "esp32s3"] spinner_pattern = [ "⠋", @@ -73,6 +70,8 @@ def print_help(): print(" --skip-build Run everything except the idf.py/CMake commands") print(" --verbose Show extra console output") +# region Core + def download_file(url, filepath): global verbose if verbose: @@ -108,25 +107,19 @@ def exit_with_error(message): def get_url(ip, path): return f"http://{ip}:{ttport}{path}" -def is_valid_platform_name(name): - global platform_arguments - return name in platform_arguments +def read_properties_file(path): + config = configparser.RawConfigParser() + config.read(path) + return config -def validate_environment(): - global ttbuild_properties_file, use_local_sdk - if os.environ.get("IDF_PATH") is None: - exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via $PATH_TO_IDF_SDK/export.sh") - if not os.path.exists(ttbuild_properties_file): - exit_with_error(f"{ttbuild_properties_file} file not found") - if use_local_sdk == False and os.environ.get("TACTILITY_SDK_PATH") is not None: - print_warning("TACTILITY_SDK_PATH is set, but will be ignored by this command.") - print_warning("If you want to use it, use the 'build local' parameters.") - elif use_local_sdk == True and os.environ.get("TACTILITY_SDK_PATH") is None: - exit_with_error("local build was requested, but TACTILITY_SDK_PATH environment variable is not set.") +#endregion Core -def setup_environment(): - global ttbuild_path - os.makedirs(ttbuild_path, exist_ok=True) +#region SDK helpers + +def read_sdk_json(): + json_file_path = os.path.join(ttbuild_path, "sdk.json") + json_file = open(json_file_path) + return json.load(json_file) def get_sdk_dir(version, platform): global use_local_sdk @@ -136,15 +129,6 @@ def get_sdk_dir(version, platform): global ttbuild_cdn return os.path.join(ttbuild_path, f"{version}-{platform}", "TactilitySDK") -def get_sdk_version(): - global ttbuild_properties_file - parser = configparser.RawConfigParser() - parser.read(ttbuild_properties_file) - sdk_dict = dict(parser.items("sdk")) - if not "version" in sdk_dict: - exit_with_error(f"Could not find 'version' in [sdk] section in {ttbuild_properties_file}") - return sdk_dict["version"] - def get_sdk_root_dir(version, platform): global ttbuild_cdn return os.path.join(ttbuild_path, f"{version}-{platform}") @@ -175,20 +159,34 @@ def update_sdk_json(): json_filepath = os.path.join(ttbuild_path, "sdk.json") return download_file(json_url, json_filepath) -def should_fetch_sdkconfig_files(): +def should_fetch_sdkconfig_files(platform_targets): for platform in platform_targets: sdkconfig_filename = f"sdkconfig.app.{platform}" if not os.path.exists(os.path.join(ttbuild_path, sdkconfig_filename)): return True return False -def fetch_sdkconfig_files(): +def fetch_sdkconfig_files(platform_targets): for platform in platform_targets: sdkconfig_filename = f"sdkconfig.app.{platform}" target_path = os.path.join(ttbuild_path, sdkconfig_filename) if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path): exit_with_error(f"Failed to download sdkconfig file for {platform}") +#endregion SDK helpers + +#region Validation + +def validate_environment(): + if os.environ.get("IDF_PATH") is None: + exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via $PATH_TO_IDF_SDK/export.sh") + if not os.path.exists("manifest.properties"): + exit_with_error("manifest.properties not found") + if use_local_sdk == False and os.environ.get("TACTILITY_SDK_PATH") is not None: + print_warning("TACTILITY_SDK_PATH is set, but will be ignored by this command.") + print_warning("If you want to use it, use the 'build local' parameters.") + elif use_local_sdk == True and os.environ.get("TACTILITY_SDK_PATH") is None: + exit_with_error("local build was requested, but TACTILITY_SDK_PATH environment variable is not set.") def validate_version_and_platforms(sdk_json, sdk_version, platforms_to_build): version_map = sdk_json["versions"] @@ -217,6 +215,64 @@ def validate_self(sdk_json): print_error("Run 'tactility.py updateself' to update.") sys.exit(1) +#endregion Validation + +#region Manifest + +def read_manifest(): + return read_properties_file("manifest.properties") + +def validate_manifest(manifest): + # [manifest] + if not "manifest" in manifest: + exit_with_error("Invalid manifest format: [manifest] not found") + if not "version" in manifest["manifest"]: + exit_with_error("Invalid manifest format: [manifest] version not found") + # [target] + if not "target" in manifest: + exit_with_error("Invalid manifest format: [target] not found") + if not "sdk" in manifest["target"]: + exit_with_error("Invalid manifest format: [target] sdk not found") + if not "platforms" in manifest["target"]: + exit_with_error("Invalid manifest format: [target] platforms not found") + # [app] + if not "app" in manifest: + exit_with_error("Invalid manifest format: [app] not found") + if not "id" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] id not found") + if not "version" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] version not found") + if not "name" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] name not found") + if not "description" in manifest["app"]: + exit_with_error("Invalid manifest format: [app] description not found") + # [author] + if not "author" in manifest: + exit_with_error("Invalid manifest format: [author] not found") + if not "name" in manifest["author"]: + exit_with_error("Invalid manifest format: [author] name not found") + if not "website" in manifest["author"]: + exit_with_error("Invalid manifest format: [author] website not found") + +def is_valid_manifest_platform(manifest, platform): + manifest_platforms = manifest["target"]["platforms"].split(",") + return platform in manifest_platforms + +def validate_manifest_platform(manifest, platform): + if not is_valid_manifest_platform(manifest, platform): + exit_with_error(f"Platform {platform} is not available in the manifest.") + +def get_manifest_target_platforms(manifest, requested_platform): + if requested_platform == "" or requested_platform is None: + return manifest["target"]["platforms"].split(",") + else: + validate_manifest_platform(manifest, requested_platform) + return [requested_platform] + +#endregion Manifest + +#region SDK download + def sdk_download(version, platform): sdk_root_dir = get_sdk_root_dir(version, platform) os.makedirs(sdk_root_dir, exist_ok=True) @@ -240,12 +296,19 @@ def sdk_download_all(version, platforms): print(f"Using cached download for SDK version {version} and platform {platform}") return True +#endregion SDK download + +#region Building + +def get_cmake_path(platform): + return os.path.join("build", f"cmake-build-{platform}") + def find_elf_file(platform): - build_dir = f"build-{platform}" - if os.path.exists(build_dir): - for file in os.listdir(build_dir): + cmake_dir = get_cmake_path(platform) + if os.path.exists(cmake_dir): + for file in os.listdir(cmake_dir): if file.endswith(".app.elf"): - return os.path.join(build_dir, file) + return os.path.join(cmake_dir, file) return None def build_all(version, platforms, skip_build): @@ -295,7 +358,8 @@ def build_first(version, platform, skip_build): if skip_build: return True print("Building first build") - with subprocess.Popen(["idf.py", "-B", f"build-{platform}", "build"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as process: + cmake_path = get_cmake_path(platform) + with subprocess.Popen(["idf.py", "-B", cmake_path, "build"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as process: build_output = wait_for_build(process, platform) # The return code is never expected to be 0 due to a bug in the elf cmake script, but we keep it just in case if process.returncode == 0: @@ -320,7 +384,8 @@ def build_consecutively(version, platform, skip_build): os.system(f"cp {sdkconfig_path} sdkconfig") if skip_build: return True - with subprocess.Popen(["idf.py", "-B", f"build-{platform}", "elf"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as process: + cmake_path = get_cmake_path(platform) + with subprocess.Popen(["idf.py", "-B", cmake_path, "elf"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) as process: build_output = wait_for_build(process, platform) if process.returncode == 0: print(f"{shell_color_green}Building for {platform} ✅{shell_color_reset}") @@ -331,41 +396,86 @@ def build_consecutively(version, platform, skip_build): print(f"{shell_color_red}Building for {platform} failed ❌{shell_color_reset}") return False -def read_sdk_json(): - json_file_path = os.path.join(ttbuild_path, "sdk.json") - json_file = open(json_file_path) - return json.load(json_file) +#endregion Building -def build_action(platform_arg): +#region Packaging + +def package_intermediate_manifest(target_path): + if not os.path.isfile("manifest.properties"): + print_error("manifest.properties not found") + return + shutil.copy("manifest.properties", os.path.join(target_path, "manifest.properties")) + +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 at {elf_path}") + return + shutil.copy(elf_path, os.path.join(elf_dir, f"{platform}.elf")) + +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") + if os.path.isdir(target_path): + shutil.rmtree(target_path) + os.makedirs(target_path, exist_ok=True) + package_intermediate_manifest(target_path) + package_intermediate_binaries(target_path, platforms) + package_intermediate_assets(target_path) + +def package_name(platforms): + elf_path = find_elf_file(platforms[0]) + elf_base_name = os.path.basename(elf_path).removesuffix(".app.elf") + return os.path.join("build", f"{elf_base_name}.app") + +def package_all(platforms): + print("Packaging app") + package_intermediate(platforms) + # Create build/something.app + tar_path = package_name(platforms) + tar = tarfile.open(tar_path, mode="w", format=tarfile.USTAR_FORMAT) + tar.add(os.path.join("build", "package-intermediate"), arcname="") + tar.close() + +#endregion Packaging + +def setup_environment(): + global ttbuild_path + os.makedirs(ttbuild_path, exist_ok=True) + +def build_action(manifest, platform_arg): # Environment validation validate_environment() - platforms_to_build = platform_targets if platform_arg == "all" else [platform_arg] - if not is_valid_platform_name(platform_arg): - print_help() - exit_with_error("Invalid platform name") + platforms_to_build = get_manifest_target_platforms(manifest, platform_arg) if not use_local_sdk: - if should_fetch_sdkconfig_files(): - fetch_sdkconfig_files() + if should_fetch_sdkconfig_files(platforms_to_build): + fetch_sdkconfig_files(platforms_to_build) sdk_json = read_sdk_json() validate_self(sdk_json) if not "versions" in sdk_json: exit_with_error("Version data not found in sdk.json") # Build - sdk_version = get_sdk_version() + sdk_version = manifest["target"]["sdk"] if not use_local_sdk: validate_version_and_platforms(sdk_json, sdk_version, platforms_to_build) if not sdk_download_all(sdk_version, platforms_to_build): exit_with_error("Failed to download one or more SDKs") - build_all(sdk_version, platforms_to_build, skip_build) # Environment validation + #TODO uncomment + #build_all(sdk_version, platforms_to_build, skip_build) # Environment validation + if not skip_build: + package_all(platforms_to_build) def clean_action(): - count = 0 - for path in os.listdir("."): - if path.startswith("build-"): - print(f"Removing {path}/") - shutil.rmtree(path) - count = count + 1 - if count == 0: + if os.path.exists("build"): + print(f"Removing build/") + shutil.rmtree("build") + else: print("Nothing to clean") def clear_cache_action(): @@ -396,7 +506,8 @@ def get_device_info(ip): except requests.RequestException as e: print(f"Request failed: {e}") -def run_action(ip, app_id): +def run_action(manifest, ip): + app_id = manifest["app"]["id"] print(f"Running {app_id} on {ip}") url = get_url(ip, "/app/run") params = {'id': app_id} @@ -409,16 +520,13 @@ def run_action(ip, app_id): except requests.RequestException as e: print(f"Request failed: {e}") -def install_action(ip, platform): - file_path = find_elf_file(platform) - if file_path is None: - print_error(f"File not found: {file_path}") - return - print(f"Installing {file_path} to {ip}") +def install_action(manifest, ip, platforms): + package_path = package_name(platforms) + print(f"Installing {package_path} to {ip}") url = get_url(ip, "/app/install") try: # Prepare multipart form data - with open(file_path, 'rb') as file: + with open(package_path, 'rb') as file: files = { 'elf': file } @@ -432,6 +540,7 @@ def install_action(ip, platform): except IOError as e: print_error(f"File error: {e}") +#region Main if __name__ == "__main__": print(f"Tactility Build System v{ttbuild_version}") @@ -448,15 +557,23 @@ if __name__ == "__main__": use_local_sdk = "--local-sdk" in sys.argv # Environment setup setup_environment() + if not os.path.isfile("manifest.properties"): + exit_with_error("manifest.properties not found") + manifest = read_manifest() + validate_manifest(manifest) + all_platform_targets = manifest["target"]["platforms"].split(",") # Update SDK cache (sdk.json) if should_update_sdk_json() and not update_sdk_json(): exit_with_error("Failed to retrieve SDK info") # Actions if action_arg == "build": - if len(sys.argv) < 3: + if len(sys.argv) < 2: print_help() exit_with_error("Commandline parameter missing") - build_action(sys.argv[2]) + platform = None + if len(sys.argv) > 2: + platform = sys.argv[2] + build_action(manifest, platform) elif action_arg == "clean": clean_action() elif action_arg == "clearcache": @@ -467,12 +584,14 @@ if __name__ == "__main__": if len(sys.argv) < 4: print_help() exit_with_error("Commandline parameter missing") - run_action(sys.argv[2], sys.argv[3]) + run_action(manifest, sys.argv[2], sys.argv[3]) elif action_arg == "install": - if len(sys.argv) < 4: + if len(sys.argv) < 3: print_help() exit_with_error("Commandline parameter missing") - install_action(sys.argv[2], sys.argv[3]) + install_action(manifest, sys.argv[2], all_platform_targets) else: print_help() exit_with_error("Unknown commandline parameter") + +#endregion Main diff --git a/Libraries/minitar/CMakeLists.txt b/Libraries/minitar/CMakeLists.txt new file mode 100644 index 00000000..f438afd2 --- /dev/null +++ b/Libraries/minitar/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.20) + +set(C_STANDARD 11) +set(CC_STANDARD_REQUIRED ON) + +if (DEFINED ENV{ESP_IDF_VERSION}) + idf_component_register( + SRC_DIRS "minitar/src/" + INCLUDE_DIRS "minitar/" + ) + +else() + file(GLOB SOURCES "minitar/src/*.*") + file(GLOB HEADERS "minitar/*.h") + + add_library(minitar STATIC) + + target_sources(minitar PRIVATE ${SOURCES}) + include_directories(minitar "minitar/src/") + target_include_directories(minitar PUBLIC "minitar/") + +endif() diff --git a/Libraries/minitar/minitar b/Libraries/minitar/minitar index 7fcd690a..78c254ba 160000 --- a/Libraries/minitar/minitar +++ b/Libraries/minitar/minitar @@ -1 +1 @@ -Subproject commit 7fcd690add04dda0af49741221d49c6d597c546b +Subproject commit 78c254ba114f6b66d888149d4ad0eff178dceb88 diff --git a/Tactility/CMakeLists.txt b/Tactility/CMakeLists.txt index db26fe3e..75e8b9c3 100644 --- a/Tactility/CMakeLists.txt +++ b/Tactility/CMakeLists.txt @@ -6,7 +6,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) if (DEFINED ENV{ESP_IDF_VERSION}) file(GLOB_RECURSE SOURCE_FILES Source/*.c*) - list(APPEND REQUIRES_LIST TactilityCore lvgl driver elf_loader lv_screenshot QRCode esp_lvgl_port minmea esp_wifi nvs_flash spiffs vfs fatfs lwip esp_http_server) + list(APPEND REQUIRES_LIST TactilityCore lvgl driver elf_loader lv_screenshot QRCode esp_lvgl_port minitar minmea esp_wifi nvs_flash spiffs vfs fatfs lwip esp_http_server) if ("${IDF_TARGET}" STREQUAL "esp32s3") list(APPEND REQUIRES_LIST esp_tinyusb) endif () @@ -55,6 +55,7 @@ else() PUBLIC lvgl PUBLIC lv_screenshot PUBLIC minmea + PUBLIC minitar ) endif() diff --git a/Tactility/Include/Tactility/app/App.h b/Tactility/Include/Tactility/app/App.h index e9808323..a6ad89a5 100644 --- a/Tactility/Include/Tactility/app/App.h +++ b/Tactility/Include/Tactility/app/App.h @@ -94,4 +94,6 @@ std::shared_ptr _Nullable getCurrentAppContext(); /** @return the currently running app (it is only ever null before the splash screen is shown) */ std::shared_ptr _Nullable getCurrentApp(); +bool install(const std::string& path); + } diff --git a/Tactility/Source/app/App.cpp b/Tactility/Source/app/App.cpp index 91ea12e3..806b2be5 100644 --- a/Tactility/Source/app/App.cpp +++ b/Tactility/Source/app/App.cpp @@ -1,5 +1,4 @@ -#include "Tactility/app/App.h" - +#include #include namespace tt::app { diff --git a/Tactility/Source/app/AppInstall.cpp b/Tactility/Source/app/AppInstall.cpp new file mode 100644 index 00000000..5fdf4904 --- /dev/null +++ b/Tactility/Source/app/AppInstall.cpp @@ -0,0 +1,147 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +constexpr auto* TAG = "App"; + +namespace tt::app { + +static int untar_file(const minitar_entry* entry, const void* buf, const std::string& destinationPath) { + auto absolute_path = destinationPath + "/" + entry->metadata.path; + if (!file::findOrCreateDirectory(destinationPath, 0777)) return 1; + + int fd = open(absolute_path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0644); + if (fd < 0) return 1; + + if (write(fd, buf, entry->metadata.size) < 0) return 1; + + // Note: fchmod() doesn't exist on ESP-IDF and chmod() does nothing on that platform + if (chmod(absolute_path.c_str(), entry->metadata.mode) < 0) return 1; + + return close(fd); +} + +static bool untar_directory(const minitar_entry* entry, const std::string& destinationPath) { + auto absolute_path = destinationPath + "/" + entry->metadata.path; + if (!file::findOrCreateDirectory(absolute_path, 0777)) return false; + return true; +} + +bool untar(const std::string& tarPath, const std::string& destinationPath) { + minitar mp; + if (minitar_open(tarPath.c_str(), &mp) != 0) { + perror(tarPath.c_str()); + return 1; + } + bool success = true; + minitar_entry entry; + + do { + if (minitar_read_entry(&mp, &entry) == 0) { + TT_LOG_I(TAG, "Extracting %s", entry.metadata.path); + if (entry.metadata.type == MTAR_DIRECTORY) { + if (!strcmp(entry.metadata.name, ".") || !strcmp(entry.metadata.name, "..") || !strcmp(entry.metadata.name, "/")) continue; + if (!untar_directory(&entry, destinationPath)) { + fprintf(stderr, "Failed to create directory %s/%s: %s\n", destinationPath.c_str(), entry.metadata.name, strerror(errno)); + success = false; + break; + } + } else if (entry.metadata.type == MTAR_REGULAR) { + auto ptr = static_cast(malloc(entry.metadata.size)); + if (!ptr) { + perror("malloc"); + success = false; + break; + } + + minitar_read_contents(&mp, &entry, ptr, entry.metadata.size); + + int status = untar_file(&entry, ptr, destinationPath); + + free(ptr); + + if (status != 0) { + fprintf(stderr, "Failed to extract file %s: %s\n", entry.metadata.path, strerror(errno)); + success = false; + break; + } + } else if (entry.metadata.type == MTAR_SYMLINK) { + TT_LOG_E(TAG, "SYMLINK not supported"); + } else if (entry.metadata.type == MTAR_HARDLINK) { + TT_LOG_E(TAG, "HARDLINK not supported"); + } else if (entry.metadata.type == MTAR_FIFO) { + TT_LOG_E(TAG, "FIFO not supported"); + } else if (entry.metadata.type == MTAR_BLKDEV) { + TT_LOG_E(TAG, "BLKDEV not supported"); + } else if (entry.metadata.type == MTAR_CHRDEV) { + TT_LOG_E(TAG, "CHRDEV not supported"); + } else { + fprintf(stderr, "error: unknown entry type: %d", entry.metadata.type); + success = false; + break; + } + } else break; + } while (true); + minitar_close(&mp); + return success; +} + +bool install(const std::string& path) { + auto filename = file::getLastPathSegment(path); + const std::string target_path = std::format("/data/apps/{}", filename); + if (file::isDirectory(target_path) && !file::deleteRecursively(target_path)) { + TT_LOG_W(TAG, "Failed to delete %s", target_path.c_str()); + } + + if (!file::findOrCreateDirectory(target_path, 0777)) { + TT_LOG_I(TAG, "Failed to create directory %s", target_path.c_str()); + return false; + } + + TT_LOG_I(TAG, "Extracting app from %s to %s", path.c_str(), target_path.c_str()); + if (!untar(path, target_path)) { + TT_LOG_E(TAG, "Failed to extract"); + return false; + } + + auto manifest_path = target_path + "/manifest.properties"; + if (!file::isFile(manifest_path)) { + TT_LOG_E(TAG, "Manifest not found at %s", manifest_path.c_str()); + return false; + } + + std::map properties; + if (!file::loadPropertiesFile(manifest_path, properties)) { + TT_LOG_E(TAG, "Failed to load manifest at %s", manifest_path.c_str()); + return false; + } + + auto app_id_iterator = properties.find("[app]id"); + if (app_id_iterator == properties.end()) { + TT_LOG_E(TAG, "Failed to find app id in manifest"); + return false; + } + + const std::string renamed_target_path = std::format("/data/apps/{}", app_id_iterator->second); + if (rename(target_path.c_str(), renamed_target_path.c_str()) != 0) { + TT_LOG_E(TAG, "Failed to rename %s to %s", target_path.c_str(), app_id_iterator->second.c_str()); + return false; + } + + return true; +} + +} // namespace \ No newline at end of file diff --git a/Tactility/Source/file/PropertiesFile.cpp b/Tactility/Source/file/PropertiesFile.cpp index 9a7a549d..01cac08d 100644 --- a/Tactility/Source/file/PropertiesFile.cpp +++ b/Tactility/Source/file/PropertiesFile.cpp @@ -22,17 +22,22 @@ bool loadPropertiesFile(const std::string& filePath, std::function(filePath, [&filePath, &callback] { TT_LOG_I(TAG, "Reading properties file %s", filePath.c_str()); uint16_t line_count = 0; - return readLines(filePath, true, [&line_count, &filePath, &callback](const std::string& line) { + std::string key_prefix = ""; + return readLines(filePath, true, [&key_prefix, &line_count, &filePath, &callback](const std::string& line) { line_count++; std::string key, value; auto trimmed_line = string::trim(line, " \t"); if (!trimmed_line.starts_with("#")) { - if (getKeyValuePair(trimmed_line, key, value)) { - std::string trimmed_key = string::trim(key, " \t"); - std::string trimmed_value = string::trim(value, " \t"); - callback(trimmed_key, trimmed_value); + if (trimmed_line.starts_with("[")) { + key_prefix = trimmed_line; } else { - TT_LOG_E(TAG, "Failed to parse line %d of %s", line_count, filePath.c_str()); + if (getKeyValuePair(trimmed_line, key, value)) { + std::string trimmed_key = key_prefix + string::trim(key, " \t"); + std::string trimmed_value = string::trim(value, " \t"); + callback(trimmed_key, trimmed_value); + } else { + TT_LOG_E(TAG, "Failed to parse line %d of %s", line_count, filePath.c_str()); + } } } }); diff --git a/Tactility/Source/service/development/DevelopmentService.cpp b/Tactility/Source/service/development/DevelopmentService.cpp index b4edf0f2..303d6a80 100644 --- a/Tactility/Source/service/development/DevelopmentService.cpp +++ b/Tactility/Source/service/development/DevelopmentService.cpp @@ -18,6 +18,7 @@ #include #include #include +#include namespace tt::service::development { @@ -154,6 +155,8 @@ void DevelopmentService::onNetworkDisconnected() { // region endpoints esp_err_t DevelopmentService::handleGetInfo(httpd_req_t* request) { + TT_LOG_I(TAG, "GET /device"); + if (httpd_resp_set_type(request, "application/json") != ESP_OK) { TT_LOG_W(TAG, "Failed to send header"); return ESP_FAIL; @@ -171,6 +174,8 @@ esp_err_t DevelopmentService::handleGetInfo(httpd_req_t* request) { } esp_err_t DevelopmentService::handleAppRun(httpd_req_t* request) { + TT_LOG_I(TAG, "POST /app/run"); + std::string query; if (!network::getQueryOrSendError(request, query)) { return ESP_FAIL; @@ -206,6 +211,8 @@ esp_err_t DevelopmentService::handleAppRun(httpd_req_t* request) { } esp_err_t DevelopmentService::handleAppInstall(httpd_req_t* request) { + TT_LOG_I(TAG, "PUT /app/install"); + std::string boundary; if (!network::getMultiPartBoundaryOrSendError(request, boundary)) { return false; @@ -250,8 +257,13 @@ esp_err_t DevelopmentService::handleAppInstall(httpd_req_t* request) { } content_left -= content_read; + if (!file::findOrCreateDirectory("/data/tmp", 0777)) { + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to save file"); + return ESP_FAIL; + } + // Write file - auto file_path = std::format("/data/{}", filename_entry->second); + auto file_path = std::format("/data/tmp/{}", filename_entry->second); auto* file = fopen(file_path.c_str(), "wb"); auto file_bytes_written = fwrite(buffer.get(), 1, file_size, file); fclose(file); @@ -270,9 +282,15 @@ esp_err_t DevelopmentService::handleAppInstall(httpd_req_t* request) { TT_LOG_W(TAG, "We have more bytes at the end of the request parsing?!"); } + if (!app::install(file_path)) { + httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to install"); + return ESP_FAIL; + } + TT_LOG_I(TAG, "[200] /app/install -> %s", file_path.c_str()); httpd_resp_send(request, nullptr, 0); + return ESP_OK; } diff --git a/TactilityCore/Include/Tactility/file/File.h b/TactilityCore/Include/Tactility/file/File.h index a96a9f0c..a900cc2c 100644 --- a/TactilityCore/Include/Tactility/file/File.h +++ b/TactilityCore/Include/Tactility/file/File.h @@ -67,7 +67,11 @@ bool writeString(const std::string& filepath, const std::string& content); * @param[in] mode the mode to use when creating directories * @return true when the specified path was found, or otherwise creates the directories recursively with the specified mode. */ -bool findOrCreateDirectory(std::string path, mode_t mode); +bool findOrCreateDirectory(const std::string& path, mode_t mode); + +bool findOrCreateParentDirectory(const std::string& path, mode_t mode); + +bool deleteRecursively(const std::string& path); /** * Concatenate a child path with a parent path, ensuring proper slash inbetween @@ -77,6 +81,8 @@ bool findOrCreateDirectory(std::string path, mode_t mode); */ std::string getChildPath(const std::string& basePath, const std::string& childPath); +std::string getLastPathSegment(const std::string& path); + typedef int (*ScandirFilter)(const dirent*); typedef bool (*ScandirSort)(const dirent&, const dirent&); @@ -106,8 +112,8 @@ bool isDirectory(const std::string& path); int scandir( const std::string& path, std::vector& outList, - ScandirFilter _Nullable filter, - ScandirSort _Nullable sort + ScandirFilter _Nullable filter = nullptr, + ScandirSort _Nullable sort = nullptr ); bool readLines(const std::string& filePath, bool stripNewLine, std::function callback); diff --git a/TactilityCore/Source/file/File.cpp b/TactilityCore/Source/file/File.cpp index b79fde56..e1c53cd5 100644 --- a/TactilityCore/Source/file/File.cpp +++ b/TactilityCore/Source/file/File.cpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace tt::hal::sdcard { class SdCardDevice; @@ -50,7 +51,7 @@ int scandir( dirent* current_entry; while ((current_entry = readdir(dir)) != nullptr) { - if (filterMethod(current_entry) == 0) { + if (filterMethod == nullptr || filterMethod(current_entry) == 0) { outList.push_back(*current_entry); } } @@ -184,7 +185,16 @@ static bool findOrCreateDirectoryInternal(std::string path, mode_t mode) { return true; } -bool findOrCreateDirectory(std::string path, mode_t mode) { +std::string getLastPathSegment(const std::string& path) { + auto index = path.find_last_of('/'); + if (index != std::string::npos) { + return path.substr(index + 1); + } else { + return ""; + } +} + +bool findOrCreateDirectory(const std::string& path, mode_t mode) { if (path.empty()) { return true; } @@ -213,6 +223,45 @@ bool findOrCreateDirectory(std::string path, mode_t mode) { return true; } +bool findOrCreateParentDirectory(const std::string& path, mode_t mode) { + std::string parent; + if (!string::getPathParent(path, parent)) { + return false; + } + return findOrCreateDirectory(parent, mode); +} + +bool deleteRecursively(const std::string& path) { + if (path.empty()) { + return true; + } + + if (isDirectory(path)) { + std::vector entries; + if (scandir(path, entries) < 0) { + TT_LOG_E(TAG, "Failed to scan directory %s", path.c_str()); + return false; + } + for (const auto& entry : entries) { + auto child_path = path + "/" + entry.d_name; + if (!deleteRecursively(child_path)) { + return false; + } + } + TT_LOG_I(TAG, "Deleting %s", path.c_str()); + return rmdir(path.c_str()) == 0; + } else if (isFile(path)) { + TT_LOG_I(TAG, "Deleting %s", path.c_str()); + return remove(path.c_str()) == 0; + } else if (path == "/" || path == "." || path == "..") { + // No-op + return true; + } else { + TT_LOG_E(TAG, "Failed to delete \"%s\": unknown type", path.c_str()); + return false; + } +} + bool isFile(const std::string& path) { return access(path.c_str(), F_OK) == 0; }