From 643cbc38062876e2b110fae1bd1e63d43d124638 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Mon, 31 Aug 2026 22:09:04 +0200 Subject: [PATCH] Implement posix app loading (#643) - Added POSIX desktop support for SDK builds, application packaging, and integration testing. - Added POSIX filesystem partitions and improved application path handling. - Added simulator support for loading and running applications dynamically. - Improved simulator task stack handling and display startup reliability. - Improved simulator display scaling, resizing, high-DPI support, and pointer accuracy. - Fixed LVGL timers and input polling on POSIX. (fixes simulator with Linux on some Intel graphics platforms) - Standardized data paths across platforms. - Logging now works via separate task: this allows apps to write to log without it affecting their stdout (for apps that output text as relevant date for other apps, like the File Selection app) - Fixes for app stdio --- .github/actions/build-sdk-posix/action.yml | 43 ++++ .github/actions/build-sdk/action.yml | 2 +- .github/workflows/build.yml | 14 +- .gitignore | 3 + ...litySDK.cmake => TactilitySDK.esp32.cmake} | 17 +- .../TactilitySDK/TactilitySDK.posix.cmake | 51 +++++ Buildscripts/module.cmake | 14 +- Buildscripts/release-sdk-current.py | 50 +++-- .../{release-sdk.py => release-sdk-esp32.py} | 100 ++------- Buildscripts/release-sdk-modules.txt | 7 + Buildscripts/release-sdk-posix.py | 101 +++++++++ Buildscripts/release-sdk-shared.py | 91 ++++++++ CMakeLists.txt | 1 + .../simulator/Source/drivers/sdl_display.cpp | 143 +++++++++---- .../simulator/Source/drivers/sdl_display.h | 18 ++ .../simulator/Source/drivers/sdl_input.cpp | 38 +++- Devices/simulator/Source/module.cpp | 2 +- .../lilygo-module/source/tdeck_keyboard.cpp | 6 +- .../source/tdeck_keyboard_backlight.cpp | 8 +- Modules/app-module/CMakeLists.txt | 9 + Modules/app-module/include/app/manager.h | 17 ++ Modules/app-module/source/io.cpp | 20 +- Modules/app-module/source/manager.cpp | 4 + Modules/app-module/source/module.cpp | 1 + Modules/app-module/source/scheduler.cpp | 7 + Modules/app-module/tests/CMakeLists.txt | 21 ++ Modules/app-posix-module/CMakeLists.txt | 17 ++ .../app-posix-module/LICENSE-Apache-2.0.md | 195 ++++++++++++++++++ .../include/app_posix/module.h | 12 ++ .../source/app_posix_loader_service.cpp | 124 +++++++++++ Modules/app-posix-module/source/module.cpp | 30 +++ Modules/app-posix-module/tests/CMakeLists.txt | 37 ++++ .../tests/fixtures/fixture_app.cpp | 12 ++ .../tests/source/loader_test.cpp | 113 ++++++++++ .../app-posix-module/tests/source/main.cpp | 54 +++++ Modules/lvgl-module/source/arch/lvgl_posix.c | 11 + .../source/module.cpp | 1 + .../source/pthread_stack_wrap.c | 25 +++ Tactility/CMakeLists.txt | 29 +++ .../app/fileselection/FileSelection.h | 31 +-- Tactility/Private/Tactility/PartitionsPosix.h | 11 + Tactility/Source/AppStdioWrap.cpp | 180 +++++++++++++++- Tactility/Source/DeprecatedPaths.cpp | 4 - Tactility/Source/MountPoints.cpp | 6 +- Tactility/Source/PartitionsPosix.cpp | 103 +++++++++ Tactility/Source/Tactility.cpp | 27 ++- Tactility/Source/app/files/View.cpp | 30 +-- .../app/fileselection/FileSelection.cpp | 68 +++--- Tactility/Source/app/fileselection/State.cpp | 6 +- Tactility/Source/app/fileselection/View.cpp | 11 +- Tactility/Source/app/notes/Notes.cpp | 34 ++- Tactility/Source/file/File.cpp | 2 - Tactility/Tests/CMakeLists.txt | 14 ++ TactilityKernel/private/tactility/log_queue.h | 40 ++++ TactilityKernel/source/kernel_init.cpp | 3 + TactilityKernel/source/log.cpp | 50 ++++- TactilityKernel/source/log_queue.cpp | 184 +++++++++++++++++ TactilityKernel/source/paths.cpp | 9 - TactilityKernel/tests/source/paths_test.cpp | 12 +- Tests/CMakeLists.txt | 2 + Tests/SdkIntegration/CMakeLists.txt | 17 +- Tests/SdkIntegration/main/CMakeLists.txt | 12 +- Tests/SdkIntegration/main/Source/main.c | 4 +- Tests/SdkIntegration/main/include/greeting.h | 5 + Tests/SdkIntegration/manifest.properties | 4 +- Tests/SdkIntegration/tactility.py | 158 +++++++++++--- 66 files changed, 2139 insertions(+), 336 deletions(-) create mode 100644 .github/actions/build-sdk-posix/action.yml rename Buildscripts/TactilitySDK/{TactilitySDK.cmake => TactilitySDK.esp32.cmake} (76%) create mode 100644 Buildscripts/TactilitySDK/TactilitySDK.posix.cmake rename Buildscripts/{release-sdk.py => release-sdk-esp32.py} (56%) create mode 100644 Buildscripts/release-sdk-modules.txt create mode 100755 Buildscripts/release-sdk-posix.py create mode 100644 Buildscripts/release-sdk-shared.py create mode 100644 Modules/app-posix-module/CMakeLists.txt create mode 100644 Modules/app-posix-module/LICENSE-Apache-2.0.md create mode 100644 Modules/app-posix-module/include/app_posix/module.h create mode 100644 Modules/app-posix-module/source/app_posix_loader_service.cpp create mode 100644 Modules/app-posix-module/source/module.cpp create mode 100644 Modules/app-posix-module/tests/CMakeLists.txt create mode 100644 Modules/app-posix-module/tests/fixtures/fixture_app.cpp create mode 100644 Modules/app-posix-module/tests/source/loader_test.cpp create mode 100644 Modules/app-posix-module/tests/source/main.cpp create mode 100644 Platforms/platform-posix/source/pthread_stack_wrap.c create mode 100644 Tactility/Private/Tactility/PartitionsPosix.h create mode 100644 Tactility/Source/PartitionsPosix.cpp create mode 100644 TactilityKernel/private/tactility/log_queue.h create mode 100644 TactilityKernel/source/log_queue.cpp create mode 100644 Tests/SdkIntegration/main/include/greeting.h diff --git a/.github/actions/build-sdk-posix/action.yml b/.github/actions/build-sdk-posix/action.yml new file mode 100644 index 00000000..27fa8703 --- /dev/null +++ b/.github/actions/build-sdk-posix/action.yml @@ -0,0 +1,43 @@ +name: Build + +runs: + using: "composite" + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + persist-credentials: false + - name: 'Detect architecture' + id: arch + shell: bash + run: echo "value=$(uname -m)" >> "$GITHUB_OUTPUT" + - name: 'Configure' + shell: bash + run: cmake -S ./ -B buildsim + - name: 'Build' + shell: bash + run: cmake --build buildsim --target TactilityKernel lvgl minitar minmea $(cat Buildscripts/release-sdk-modules.txt) + - name: 'Release' + shell: bash + 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" + 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 + - name: 'Upload Artifact' + uses: actions/upload-artifact@v4 + with: + name: TactilitySDK-posix-${{ steps.arch.outputs.value }} + path: release/TactilitySDK + retention-days: 30 diff --git a/.github/actions/build-sdk/action.yml b/.github/actions/build-sdk/action.yml index ac0bfbe0..c5bfa575 100644 --- a/.github/actions/build-sdk/action.yml +++ b/.github/actions/build-sdk/action.yml @@ -29,7 +29,7 @@ runs: env: # NOTE: Update with ESP-IDF! ESP_IDF_VERSION: '5.5.2' - run: python Buildscripts/release-sdk.py release/TactilitySDK + run: python Buildscripts/release-sdk-esp32.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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6f4c67ed..1c553df4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ on: permissions: read-all jobs: - BuildSdk: + BuildSdkEsp32: strategy: matrix: board: [ @@ -30,9 +30,17 @@ jobs: with: board_id: ${{ matrix.board.id }} arch: ${{ matrix.board.arch }} + BuildSdkPosix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: "Build SDK" + uses: ./.github/actions/build-sdk-posix GenerateDeviceMatrix: runs-on: ubuntu-latest - needs: [ BuildSdk ] + needs: [ BuildSdkEsp32 ] outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: @@ -57,7 +65,7 @@ jobs: arch: ${{ matrix.board.arch }} BundleArtifacts: runs-on: ubuntu-latest - needs: [ BuildFirmware ] + needs: [ BuildFirmware, BuildSdkPosix ] if: | (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) diff --git a/.gitignore b/.gitignore index 765f332b..c7642eff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .DS_Store build*/ +!.github/actions/build*/ cmake*/ CMakeCache.txt *.cbp @@ -25,3 +26,5 @@ sdkconfig.board.*.dev .caveman.json .ai/mcp + +__pycache__ \ No newline at end of file diff --git a/Buildscripts/TactilitySDK/TactilitySDK.cmake b/Buildscripts/TactilitySDK/TactilitySDK.esp32.cmake similarity index 76% rename from Buildscripts/TactilitySDK/TactilitySDK.cmake rename to Buildscripts/TactilitySDK/TactilitySDK.esp32.cmake index 19af8674..1492ce71 100644 --- a/Buildscripts/TactilitySDK/TactilitySDK.cmake +++ b/Buildscripts/TactilitySDK/TactilitySDK.esp32.cmake @@ -4,7 +4,12 @@ endfunction() function(_tactility_project) endfunction() -macro(tactility_project project_name) +macro(tactility_project_pre project_name) + include($ENV{IDF_PATH}/tools/cmake/project.cmake) + set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH} ${TACTILITY_SDK_PATH}/Modules) +endmacro() + +macro(tactility_project_post project_name) set(TACTILITY_SKIP_SPIFFS 1) # Tactility's PanicHandler.cpp needs s0 to stay a frame pointer to capture a callstack for @@ -45,3 +50,13 @@ macro(tactility_project project_name) ) endmacro() + +macro(tactility_component_register) + cmake_parse_arguments(TT_COMPONENT "" "" "SRCS;INCLUDE_DIRS;REQUIRES;PRIV_REQUIRES" ${ARGN}) + idf_component_register( + SRCS ${TT_COMPONENT_SRCS} + INCLUDE_DIRS ${TT_COMPONENT_INCLUDE_DIRS} + REQUIRES TactilitySDK ${TT_COMPONENT_REQUIRES} + PRIV_REQUIRES ${TT_COMPONENT_PRIV_REQUIRES} + ) +endmacro() diff --git a/Buildscripts/TactilitySDK/TactilitySDK.posix.cmake b/Buildscripts/TactilitySDK/TactilitySDK.posix.cmake new file mode 100644 index 00000000..68e4e63e --- /dev/null +++ b/Buildscripts/TactilitySDK/TactilitySDK.posix.cmake @@ -0,0 +1,51 @@ +function(tactility_project) +endfunction() + +function(_tactility_project) +endfunction() + +macro(tactility_project_pre project_name) +endmacro() + +macro(tactility_project_post project_name) + # The app's own library target is defined in a subdirectory (e.g. "main"); without this it + # would land nested under that subdirectory instead of directly in the build dir. + set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}) + + # Mirrors the ESP-IDF "TactilitySDK" component (Buildscripts/TactilitySDK/CMakeLists.txt): + # apps link against this single target instead of listing SDK include dirs themselves. + # Posix apps are dlopen()ed into a running Tactility process (see app-posix-module) and + # resolve symbols against Tactility's own copies at load time, so headers are all they need + # at compile time - no libraries to link. + add_library(TactilitySDK INTERFACE) + target_include_directories(TactilitySDK INTERFACE + ${TACTILITY_SDK_PATH}/Modules/app-module/include + ${TACTILITY_SDK_PATH}/Modules/crypt-module/include + ${TACTILITY_SDK_PATH}/Modules/gps-module/include + ${TACTILITY_SDK_PATH}/Modules/lvgl-module/include + ${TACTILITY_SDK_PATH}/Modules/lvgl-window-manager-module/include + ${TACTILITY_SDK_PATH}/Modules/service-module/include + ${TACTILITY_SDK_PATH}/Libraries/TactilityKernel/include + ${TACTILITY_SDK_PATH}/Libraries/lvgl/include + ${TACTILITY_SDK_PATH}/Libraries/FreeRTOS-Kernel/include + ${TACTILITY_SDK_PATH}/Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix + ${TACTILITY_SDK_PATH}/Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/utils + ) + target_compile_definitions(TactilitySDK INTERFACE LV_LVGL_H_INCLUDE_SIMPLE) + + # ESP-IDF's project() auto-discovers the "main" component; plain CMake doesn't. + add_subdirectory(main) +endmacro() + +macro(tactility_component_register) + cmake_parse_arguments(TT_COMPONENT "" "" "SRCS;INCLUDE_DIRS;REQUIRES;PRIV_REQUIRES" ${ARGN}) + # Must be a SHARED object, not a -pie executable: glibc's dlopen() unconditionally refuses + # any ET_DYN carrying the DF_1_PIE flag ("cannot dynamically load position-independent + # executable"), regardless of whether it has a dynamic-linker segment - verified empirically. + add_library(${PROJECT_NAME} SHARED ${TT_COMPONENT_SRCS}) + target_link_libraries(${PROJECT_NAME} PRIVATE TactilitySDK) + set_target_properties(${PROJECT_NAME} PROPERTIES POSITION_INDEPENDENT_CODE ON) + if (TT_COMPONENT_INCLUDE_DIRS) + target_include_directories(${PROJECT_NAME} PRIVATE ${TT_COMPONENT_INCLUDE_DIRS}) + endif () +endmacro() diff --git a/Buildscripts/module.cmake b/Buildscripts/module.cmake index a950ccc2..3bfb2d6b 100644 --- a/Buildscripts/module.cmake +++ b/Buildscripts/module.cmake @@ -2,6 +2,8 @@ if (COMMAND tactility_add_module) return() endif() +cmake_minimum_required(VERSION 3.24) + macro(tactility_get_module_name NAME OUT_NAME) if (DEFINED ENV{ESP_IDF_VERSION}) set(${OUT_NAME} ${COMPONENT_LIB}) @@ -16,8 +18,7 @@ macro(tactility_add_module NAME) # undefined reference to. Needed when this module provides symbols a component it depends on # (e.g. lvgl__lvgl's custom-allocator hooks) calls back into - a reverse reference a normal # single-pass static-archive link can't resolve, since that component is scanned after this - # one's archive has already been passed once. POSIX builds link everything as plain OBJECT - # libraries (no archive-pruning to begin with), so this is a no-op there. + # one's archive has already been passed once. set(options WHOLE_ARCHIVE) set(oneValueArgs) set(multiValueArgs SRCS INCLUDE_DIRS PRIV_INCLUDE_DIRS REQUIRES PRIV_REQUIRES) @@ -40,7 +41,7 @@ macro(tactility_add_module NAME) ${whole_archive_arg} ) else() - add_library(${NAME} OBJECT) + add_library(${NAME} STATIC) target_sources(${NAME} PRIVATE ${ARG_SRCS}) target_include_directories(${NAME} PRIVATE ${ARG_PRIV_INCLUDE_DIRS} @@ -48,5 +49,12 @@ macro(tactility_add_module NAME) ) target_link_libraries(${NAME} PUBLIC ${ARG_REQUIRES}) target_link_libraries(${NAME} PRIVATE ${ARG_PRIV_REQUIRES}) + if (ARG_WHOLE_ARCHIVE) + # A static archive only pulls in object files that already have a pending undefined + # reference at the point the archive is scanned, so a plain link drops ${NAME}'s + # reverse dependencies (see WHOLE_ARCHIVE comment above). Make whoever links ${NAME} + # whole-archive it instead of just archive-pruning it. + set_property(TARGET ${NAME} APPEND PROPERTY INTERFACE_LINK_LIBRARIES $) + endif() endif() endmacro() diff --git a/Buildscripts/release-sdk-current.py b/Buildscripts/release-sdk-current.py index ad0b5c68..041d516f 100755 --- a/Buildscripts/release-sdk-current.py +++ b/Buildscripts/release-sdk-current.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import os +import platform import shutil import subprocess import sys @@ -17,42 +18,53 @@ def get_idf_target(): return None return None -def main(): - # 1. Get idf_target - idf_target = get_idf_target() - if not idf_target: - print("Could not determine IDF target from sdkconfig") - sys.exit(1) - - # 2. Get version +def get_version(): try: with open("version.txt", "r") as f: - version = f.read().strip() + return f.read().strip() except FileNotFoundError: print("version.txt not found") sys.exit(1) - # 3. Construct sdk_path - # release/TactilitySDK/${version}-${idf_target}/TactilitySDK - sdk_path = os.path.join("release", "TactilitySDK", f"{version}-{idf_target}", "TactilitySDK") - - # 4. Cleanup sdk_path +def run_release_script(script_name, sdk_path): + # Cleanup sdk_path if os.path.exists(sdk_path): print(f"Cleaning up {sdk_path}") shutil.rmtree(sdk_path) - + os.makedirs(sdk_path, exist_ok=True) - # 5. Call release-sdk.py # Note: Using sys.executable to ensure we use the same python interpreter - script_path = os.path.join("Buildscripts", "release-sdk.py") + script_path = os.path.join("Buildscripts", script_name) print(f"Running {script_path} {sdk_path}") - + result = subprocess.run([sys.executable, script_path, sdk_path]) - + if result.returncode != 0: print(f"Error: {script_path} failed with return code {result.returncode}") sys.exit(result.returncode) +def main(): + version = get_version() + + # ESP_IDF_VERSION is only set once an ESP-IDF environment has been activated (export.sh / + # the Windows PowerShell profile - see building.md); same check release-sdk-esp32.py and + # release-sdk-posix.py themselves use to tell the two builds apart. + esp_idf_version = os.environ.get("ESP_IDF_VERSION", "") + + if esp_idf_version: + idf_target = get_idf_target() + if not idf_target: + print("Could not determine IDF target from sdkconfig") + sys.exit(1) + # release/TactilitySDK/${version}-${idf_target}/TactilitySDK + sdk_path = os.path.join("release", "TactilitySDK", f"{version}-{idf_target}", "TactilitySDK") + run_release_script("release-sdk-esp32.py", sdk_path) + else: + # release/TactilitySDK/${version}-posix-${arch}/TactilitySDK + arch = platform.machine() + sdk_path = os.path.join("release", "TactilitySDK", f"{version}-posix-{arch}", "TactilitySDK") + run_release_script("release-sdk-posix.py", sdk_path) + if __name__ == "__main__": main() diff --git a/Buildscripts/release-sdk.py b/Buildscripts/release-sdk-esp32.py similarity index 56% rename from Buildscripts/release-sdk.py rename to Buildscripts/release-sdk-esp32.py index 42ee3414..db7219c7 100644 --- a/Buildscripts/release-sdk.py +++ b/Buildscripts/release-sdk-esp32.py @@ -1,66 +1,16 @@ #!/usr/bin/env python3 import os -import shutil import glob import subprocess import sys +import importlib.util from textwrap import dedent -def map_copy(mappings, target_base): - """ - Helper function to map input files/directories to output files/directories. - mappings: list of dicts with 'src' (glob pattern) and 'dst' (relative to target_base or absolute) - 'src' can be a single file or a directory (if it ends with /). - """ - for mapping in mappings: - src_pattern = mapping['src'] - dst_rel = mapping['dst'] - dst_path = os.path.join(target_base, dst_rel) +_shared_spec = importlib.util.spec_from_file_location("release_sdk_shared", os.path.join("Buildscripts", "release-sdk-shared.py")) +shared = importlib.util.module_from_spec(_shared_spec) +_shared_spec.loader.exec_module(shared) - # To preserve directory structure, we need to know where the wildcard starts - # or have a way to determine the "base" of the search. - # We'll split the pattern into a fixed base and a pattern part. - - # Simple heuristic: find the first occurrence of '*' or '?' - wildcard_idx = -1 - for i, char in enumerate(src_pattern): - if char in '*?': - wildcard_idx = i - break - - if wildcard_idx != -1: - # Found a wildcard. The base is the directory containing it. - pattern_base = os.path.dirname(src_pattern[:wildcard_idx]) - else: - # No wildcard. If it's a directory, we might want to preserve its name? - # For now, let's treat no-wildcard as no relative structure needed. - pattern_base = None - - src_files = glob.glob(src_pattern, recursive=True) - if not src_files: - continue - - for src in src_files: - if os.path.isdir(src): - continue - - if pattern_base and src.startswith(pattern_base): - # Calculate relative path from the base of the glob pattern - rel_src = os.path.relpath(src, pattern_base) - # If dst_rel ends with /, it's a target directory - if dst_rel.endswith('/') or os.path.isdir(dst_path): - final_dst = os.path.join(dst_path, rel_src) - else: - # If dst_rel is a file, we can't really preserve structure - # unless we join it. But usually it's a dir if structure is preserved. - final_dst = dst_path - else: - final_dst = dst_path if not (dst_rel.endswith('/') or os.path.isdir(dst_path)) else os.path.join(dst_path, os.path.basename(src)) - - os.makedirs(os.path.dirname(final_dst), exist_ok=True) - shutil.copy2(src, final_dst) - def get_driver_mappings(driver_name): return [ {'src': f'Drivers/{driver_name}/include/**', 'dst': f'Drivers/{driver_name}/include/'}, @@ -82,11 +32,7 @@ def create_module_cmakelists(module_name): INCLUDE_DIRS "include" ) add_prebuilt_library({module_name} "binary/lib{module_name}.a") - '''.format(module_name=module_name)) - -def write_module_cmakelists(path, content): - with open(path, 'w') as f: - f.write(content) + ''') def driver_is_available(driver_name): """ @@ -101,28 +47,20 @@ def driver_is_available(driver_name): def add_driver(target_path, driver_name): mappings = get_driver_mappings(driver_name) - map_copy(mappings, target_path) + shared.map_copy(mappings, target_path) cmakelists_content = create_module_cmakelists(driver_name) - write_module_cmakelists(os.path.join(target_path, f"Drivers/{driver_name}/CMakeLists.txt"), cmakelists_content) + shared.write_module_cmakelists(os.path.join(target_path, f"Drivers/{driver_name}/CMakeLists.txt"), cmakelists_content) def add_module(target_path, module_name): mappings = get_module_mappings(module_name) - map_copy(mappings, target_path) + shared.map_copy(mappings, target_path) cmakelists_content = create_module_cmakelists(module_name) - write_module_cmakelists(os.path.join(target_path, f"Modules/{module_name}/CMakeLists.txt"), cmakelists_content) - -def generate_tactility_sdk_cmake(target_path): - src = os.path.join('Buildscripts', 'TactilitySDK', 'TactilitySDK.cmake') - shutil.copy2(src, os.path.join(target_path, 'TactilitySDK.cmake')) - -def generate_tactility_sdk_top_cmakelists(target_path): - src = os.path.join('Buildscripts', 'TactilitySDK', 'CMakeLists.txt') - shutil.copy2(src, os.path.join(target_path, 'CMakeLists.txt')) + shared.write_module_cmakelists(os.path.join(target_path, f"Modules/{module_name}/CMakeLists.txt"), cmakelists_content) def main(): if len(sys.argv) < 2: - print("Usage: release-sdk.py [target_path]") - print("Example: release-sdk.py release/TactilitySDK") + print("Usage: release-sdk-esp32.py [target_path]") + print("Example: release-sdk-esp32.py release/TactilitySDK") sys.exit(1) esp_idf_version = os.environ.get("ESP_IDF_VERSION", "") @@ -169,20 +107,16 @@ def main(): {'src': 'Libraries/minmea/COPYING', 'dst': 'Libraries/minmea/'}, ] - map_copy(mappings, target_path) + shared.map_copy(mappings, target_path) # Modules - add_module(target_path, "app-module") - add_module(target_path, "crypt-module") - add_module(target_path, "gps-module") - add_module(target_path, "http-module") - add_module(target_path, "lvgl-module") - add_module(target_path, "lvgl-window-manager-module") - add_module(target_path, "service-module") + module_names = shared.read_module_list(os.path.join('Buildscripts', 'release-sdk-modules.txt')) + for module_name in module_names: + add_module(target_path, module_name) # Final scripts - copied verbatim - generate_tactility_sdk_cmake(target_path) - generate_tactility_sdk_top_cmakelists(target_path) + shared.generate_tactility_sdk_cmake(target_path, 'esp32') + shared.generate_tactility_sdk_top_cmakelists(target_path) # Output ESP-IDF SDK version to file with open(os.path.join(target_path, "idf-version.txt"), "a") as f: diff --git a/Buildscripts/release-sdk-modules.txt b/Buildscripts/release-sdk-modules.txt new file mode 100644 index 00000000..bf39c26d --- /dev/null +++ b/Buildscripts/release-sdk-modules.txt @@ -0,0 +1,7 @@ +app-module +crypt-module +gps-module +http-module +lvgl-module +lvgl-window-manager-module +service-module diff --git a/Buildscripts/release-sdk-posix.py b/Buildscripts/release-sdk-posix.py new file mode 100755 index 00000000..54971aae --- /dev/null +++ b/Buildscripts/release-sdk-posix.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + +import os +import sys +import importlib.util +from textwrap import dedent + +_shared_spec = importlib.util.spec_from_file_location("release_sdk_shared", os.path.join("Buildscripts", "release-sdk-shared.py")) +shared = importlib.util.module_from_spec(_shared_spec) +_shared_spec.loader.exec_module(shared) + +def get_module_mappings(module_name): + return [ + {'src': f'Modules/{module_name}/include/**', 'dst': f'Modules/{module_name}/include/'}, + {'src': f'Modules/{module_name}/*.md', 'dst': f'Modules/{module_name}/'}, + {'src': f'buildsim/Modules/{module_name}/lib{module_name}.a', 'dst': f'Modules/{module_name}/binary/lib{module_name}.a'}, + ] + +def create_module_cmakelists(module_name): + return dedent(f''' + cmake_minimum_required(VERSION 3.20) + add_library({module_name} STATIC IMPORTED) + set_target_properties({module_name} PROPERTIES + IMPORTED_LOCATION "${{CMAKE_CURRENT_LIST_DIR}}/binary/lib{module_name}.a" + INTERFACE_INCLUDE_DIRECTORIES "${{CMAKE_CURRENT_LIST_DIR}}/include" + ) + ''') + +def add_module(target_path, module_name): + mappings = get_module_mappings(module_name) + shared.map_copy(mappings, target_path) + cmakelists_content = create_module_cmakelists(module_name) + shared.write_module_cmakelists(os.path.join(target_path, f"Modules/{module_name}/CMakeLists.txt"), cmakelists_content) + +def main(): + if len(sys.argv) < 2: + print("Usage: release-sdk-posix.py [target_path]") + print("Example: release-sdk-posix.py release/TactilitySDK") + sys.exit(1) + + esp_idf_version = os.environ.get("ESP_IDF_VERSION", "") + if esp_idf_version: + print("Error: ESP_IDF_VERSION environment variable is set - this script packages the POSIX/simulator build, run it outside an ESP-IDF environment") + sys.exit(1) + + target_path = os.path.abspath(sys.argv[1]) + os.makedirs(target_path, exist_ok=True) + + # Mapping logic + mappings = [ + {'src': 'version.txt', 'dst': ''}, + # TactilityFreeRtos + {'src': 'TactilityFreeRtos/Include/**', 'dst': 'Libraries/TactilityFreeRtos/Include/'}, + {'src': 'TactilityFreeRtos/CMakeLists.txt', 'dst': 'Libraries/TactilityFreeRtos/'}, + {'src': 'TactilityFreeRtos/LICENSE*.*', 'dst': 'Libraries/TactilityFreeRtos/'}, + # TactilityKernel + {'src': 'buildsim/TactilityKernel/libTactilityKernel.a', 'dst': 'Libraries/TactilityKernel/binary/'}, + {'src': 'TactilityKernel/include/**', 'dst': 'Libraries/TactilityKernel/include/'}, + {'src': 'TactilityKernel/CMakeLists.txt', 'dst': 'Libraries/TactilityKernel/'}, + {'src': 'TactilityKernel/*.md', 'dst': 'Libraries/TactilityKernel/'}, + # FreeRTOS-Kernel - TactilityKernel's public headers (tactility/freertos/*.h) include the + # real FreeRTOS.h/task.h/etc directly, unlike ESP32 where ESP-IDF's own "freertos" + # component and Kconfig-generated FreeRTOSConfig.h are already part of every project. + {'src': 'Libraries/FreeRTOS-Kernel/include/**', 'dst': 'Libraries/FreeRTOS-Kernel/include/'}, + {'src': 'Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/*.h', 'dst': 'Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/'}, + {'src': 'Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/utils/*.h', 'dst': 'Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/utils/'}, + {'src': 'Libraries/FreeRTOS-Kernel/LICENSE*.*', 'dst': 'Libraries/FreeRTOS-Kernel/'}, + {'src': 'Devices/simulator/Source/FreeRTOSConfig.h', 'dst': 'Libraries/FreeRTOS-Kernel/include/'}, + # lvgl (basics) + {'src': 'buildsim/Libraries/lvgl/lib/liblvgl.a', 'dst': 'Libraries/lvgl/binary/liblvgl.a'}, + {'src': 'Libraries/lvgl/lvgl.h', 'dst': 'Libraries/lvgl/include/'}, + {'src': 'Libraries/lvgl/lv_version.h', 'dst': 'Libraries/lvgl/include/'}, + {'src': 'Libraries/lvgl/LICENCE*.*', 'dst': 'Libraries/lvgl/'}, + {'src': 'lv_conf.h', 'dst': 'Libraries/lvgl/include/'}, + {'src': 'Libraries/lvgl/src/**/*.h', 'dst': 'Libraries/lvgl/include/src/'}, + # minitar + {'src': 'buildsim/Libraries/minitar/libminitar.a', 'dst': 'Libraries/minitar/binary/'}, + {'src': 'Libraries/minitar/minitar/minitar.h', 'dst': 'Libraries/minitar/include/'}, + {'src': 'Libraries/minitar/minitar/LICENSE*', 'dst': 'Libraries/minitar/'}, + # minmea + {'src': 'buildsim/Libraries/minmea/libminmea.a', 'dst': 'Libraries/minmea/binary/'}, + {'src': 'Libraries/minmea/Include/**', 'dst': 'Libraries/minmea/include/'}, + {'src': 'Libraries/minmea/CMakeLists.txt', 'dst': 'Libraries/minmea/'}, + {'src': 'Libraries/minmea/README.md', 'dst': 'Libraries/minmea/'}, + {'src': 'Libraries/minmea/LICENSE*.*', 'dst': 'Libraries/minmea/'}, + {'src': 'Libraries/minmea/COPYING', 'dst': 'Libraries/minmea/'}, + ] + + shared.map_copy(mappings, target_path) + + # Modules + module_names = shared.read_module_list(os.path.join('Buildscripts', 'release-sdk-modules.txt')) + for module_name in module_names: + add_module(target_path, module_name) + + # Final scripts - copied verbatim + shared.generate_tactility_sdk_cmake(target_path, 'posix') + shared.generate_tactility_sdk_top_cmakelists(target_path) + +if __name__ == "__main__": + main() diff --git a/Buildscripts/release-sdk-shared.py b/Buildscripts/release-sdk-shared.py new file mode 100644 index 00000000..b9d1d9fb --- /dev/null +++ b/Buildscripts/release-sdk-shared.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 + +# Functions shared between release-sdk-esp32.py and release-sdk-posix.py. Not runnable on its +# own; loaded by those scripts via importlib (its hyphenated filename isn't a valid Python +# module name for a plain "import"). + +import os +import shutil +import glob +import sys + +def map_copy(mappings, target_base): + """ + Helper function to map input files/directories to output files/directories. + mappings: list of dicts with 'src' (glob pattern) and 'dst' (relative to target_base or absolute) + 'src' can be a single file or a directory (if it ends with /). + """ + for mapping in mappings: + src_pattern = mapping['src'] + dst_rel = mapping['dst'] + dst_path = os.path.join(target_base, dst_rel) + + # To preserve directory structure, we need to know where the wildcard starts + # or have a way to determine the "base" of the search. + # We'll split the pattern into a fixed base and a pattern part. + + # Simple heuristic: find the first occurrence of '*' or '?' + wildcard_idx = -1 + for i, char in enumerate(src_pattern): + if char in '*?': + wildcard_idx = i + break + + if wildcard_idx != -1: + # Found a wildcard. The base is the directory containing it. + pattern_base = os.path.dirname(src_pattern[:wildcard_idx]) + else: + # No wildcard. If it's a directory, we might want to preserve its name? + # For now, let's treat no-wildcard as no relative structure needed. + pattern_base = None + + src_files = glob.glob(src_pattern, recursive=True) + if not src_files: + continue + + for src in src_files: + if os.path.isdir(src): + continue + + if pattern_base and src.startswith(pattern_base): + # Calculate relative path from the base of the glob pattern + rel_src = os.path.relpath(src, pattern_base) + # If dst_rel ends with /, it's a target directory + if dst_rel.endswith('/') or os.path.isdir(dst_path): + final_dst = os.path.join(dst_path, rel_src) + else: + # If dst_rel is a file, we can't really preserve structure + # unless we join it. But usually it's a dir if structure is preserved. + final_dst = dst_path + else: + final_dst = dst_path if not (dst_rel.endswith('/') or os.path.isdir(dst_path)) else os.path.join(dst_path, os.path.basename(src)) + + os.makedirs(os.path.dirname(final_dst), exist_ok=True) + shutil.copy2(src, final_dst) + +def write_module_cmakelists(path, content): + with open(path, 'w') as f: + f.write(content) + +def read_module_list(path): + """Reads a newline-separated module name list, skipping empty lines, and checks that each + named module actually exists under Modules/ - exits the process with an error if not.""" + with open(path, 'r') as f: + module_names = [line.strip() for line in f if line.strip()] + + for module_name in module_names: + if not os.path.isdir(os.path.join('Modules', module_name)): + print(f"Error: Modules/{module_name} does not exist (listed in {path})") + sys.exit(1) + + return module_names + +def generate_tactility_sdk_cmake(target_path, variant): + """variant selects Buildscripts/TactilitySDK/TactilitySDK.{variant}.cmake (e.g. "esp32" or + "posix") - always copied into the SDK as the platform-neutral name TactilitySDK.cmake.""" + src = os.path.join('Buildscripts', 'TactilitySDK', f'TactilitySDK.{variant}.cmake') + shutil.copy2(src, os.path.join(target_path, 'TactilitySDK.cmake')) + +def generate_tactility_sdk_top_cmakelists(target_path): + src = os.path.join('Buildscripts', 'TactilitySDK', 'CMakeLists.txt') + shutil.copy2(src, os.path.join(target_path, 'CMakeLists.txt')) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ead6975..1f29fc87 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -129,6 +129,7 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION}) add_subdirectory(Modules/pthread-module) add_subdirectory(Modules/service-module) add_subdirectory(Modules/app-module) + add_subdirectory(Modules/app-posix-module) add_subdirectory(Modules/lvgl-window-manager-module) add_subdirectory(Drivers/gps-generic-module) add_subdirectory(Drivers/gps-meshtastic-module) diff --git a/Devices/simulator/Source/drivers/sdl_display.cpp b/Devices/simulator/Source/drivers/sdl_display.cpp index ca39fbca..ebe4c5e1 100644 --- a/Devices/simulator/Source/drivers/sdl_display.cpp +++ b/Devices/simulator/Source/drivers/sdl_display.cpp @@ -15,50 +15,46 @@ constexpr auto* TAG = "SdlDisplay"; #define GET_CONFIG(device) (static_cast((device)->config)) struct SdlDisplayInternal { + bool initialized; + bool init_failed; SDL_Window* window; SDL_Renderer* renderer; SDL_Texture* texture; }; +// Only one sdl-display device exists in the simulator; sdl_input.cpp uses this to map window +// coordinates back to the fixed logical resolution SDL_RenderSetLogicalSize() scales to the window. +static SdlDisplayInternal* g_display_internal = nullptr; + +SDL_Renderer* sdl_display_get_renderer(void) { + return g_display_internal != nullptr ? g_display_internal->renderer : nullptr; +} + +// Re-blits the already-drawn texture at the renderer's current (possibly just-resized) scale. +// No new pixel data needed: the window resizing doesn't change what LVGL last rendered, only how +// large it should appear, and SDL only applies that until the next SDL_RenderPresent() call. +void sdl_display_present_now(void) { + if (g_display_internal == nullptr) { + return; + } + SDL_RenderClear(g_display_internal->renderer); + SDL_RenderCopy(g_display_internal->renderer, g_display_internal->texture, nullptr, nullptr); + SDL_RenderPresent(g_display_internal->renderer); +} + // region Driver lifecycle static error_t start(Device* device) { - const auto* config = GET_CONFIG(device); - auto* internal = static_cast(malloc(sizeof(SdlDisplayInternal))); if (internal == nullptr) { return ERROR_OUT_OF_MEMORY; } - if (SDL_InitSubSystem(SDL_INIT_VIDEO) != 0) { - LOG_E(TAG, "SDL_InitSubSystem failed: %s", SDL_GetError()); - free(internal); - return ERROR_RESOURCE; - } - - internal->window = SDL_CreateWindow( - "Tactility", - SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, - config->horizontal_resolution, config->vertical_resolution, - SDL_WINDOW_SHOWN - ); - internal->renderer = internal->window != nullptr - ? SDL_CreateRenderer(internal->window, -1, SDL_RENDERER_ACCELERATED) - : nullptr; - internal->texture = internal->renderer != nullptr - ? SDL_CreateTexture(internal->renderer, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STREAMING, - config->horizontal_resolution, config->vertical_resolution) - : nullptr; - - if (internal->window == nullptr || internal->renderer == nullptr || internal->texture == nullptr) { - LOG_E(TAG, "Failed to create SDL window: %s", SDL_GetError()); - if (internal->texture != nullptr) SDL_DestroyTexture(internal->texture); - if (internal->renderer != nullptr) SDL_DestroyRenderer(internal->renderer); - if (internal->window != nullptr) SDL_DestroyWindow(internal->window); - SDL_QuitSubSystem(SDL_INIT_VIDEO); - free(internal); - return ERROR_RESOURCE; - } + internal->initialized = false; + internal->init_failed = false; + internal->window = nullptr; + internal->renderer = nullptr; + internal->texture = nullptr; device_set_driver_data(device, internal); return ERROR_NONE; @@ -67,10 +63,16 @@ static error_t start(Device* device) { static error_t stop(Device* device) { auto* internal = static_cast(device_get_driver_data(device)); - SDL_DestroyTexture(internal->texture); - SDL_DestroyRenderer(internal->renderer); - SDL_DestroyWindow(internal->window); - SDL_QuitSubSystem(SDL_INIT_VIDEO); + if (internal->initialized) { + SDL_DestroyTexture(internal->texture); + SDL_DestroyRenderer(internal->renderer); + SDL_DestroyWindow(internal->window); + SDL_QuitSubSystem(SDL_INIT_VIDEO); + } + + if (g_display_internal == internal) { + g_display_internal = nullptr; + } free(internal); device_set_driver_data(device, nullptr); @@ -84,18 +86,83 @@ static error_t stop(Device* device) { static error_t sdl_display_reset(Device*) { return ERROR_NONE; } static error_t sdl_display_init(Device*) { return ERROR_NONE; } +static float sdl_display_get_dpi_scale() { + float hdpi = 96.0f; + if (SDL_GetDisplayDPI(0, nullptr, &hdpi, nullptr) != 0 || hdpi <= 0.0f) { + return 1.0f; + } + return hdpi / 96.0f; +} + +static bool sdl_display_lazy_init(Device* device, SdlDisplayInternal* internal) { + const auto* config = GET_CONFIG(device); + + if (SDL_InitSubSystem(SDL_INIT_VIDEO) != 0) { + LOG_E(TAG, "SDL_InitSubSystem failed: %s", SDL_GetError()); + return false; + } + + // Only the window's initial on-screen footprint scales here - the render/logical resolution + // (config->horizontal_resolution/vertical_resolution, used below for the texture and + // SDL_RenderSetLogicalSize()) is unaffected, same as any other resize the user does by hand. + const float dpi_scale = sdl_display_get_dpi_scale(); + const int initial_width = static_cast(config->horizontal_resolution * dpi_scale); + const int initial_height = static_cast(config->vertical_resolution * dpi_scale); + + internal->window = SDL_CreateWindow( + "Tactility", + SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, + initial_width, initial_height, + SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI + ); + internal->renderer = internal->window != nullptr + ? SDL_CreateRenderer(internal->window, -1, SDL_RENDERER_ACCELERATED) + : nullptr; + // Lets the window be resized freely while the renderer scales/letterboxes the fixed-resolution + // texture below to fit - LVGL keeps rendering at horizontal_resolution x vertical_resolution. + if (internal->renderer != nullptr) { + SDL_RenderSetLogicalSize(internal->renderer, config->horizontal_resolution, config->vertical_resolution); + } + internal->texture = internal->renderer != nullptr + ? SDL_CreateTexture(internal->renderer, SDL_PIXELFORMAT_RGB565, SDL_TEXTUREACCESS_STREAMING, + config->horizontal_resolution, config->vertical_resolution) + : nullptr; + + if (internal->window == nullptr || internal->renderer == nullptr || internal->texture == nullptr) { + LOG_E(TAG, "Failed to create SDL window: %s", SDL_GetError()); + if (internal->texture != nullptr) SDL_DestroyTexture(internal->texture); + if (internal->renderer != nullptr) SDL_DestroyRenderer(internal->renderer); + if (internal->window != nullptr) SDL_DestroyWindow(internal->window); + SDL_QuitSubSystem(SDL_INIT_VIDEO); + return false; + } + + g_display_internal = internal; + return true; +} + static error_t sdl_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) { auto* internal = static_cast(device_get_driver_data(device)); + if (internal->init_failed) { + return ERROR_RESOURCE; + } + + if (!internal->initialized) { + if (!sdl_display_lazy_init(device, internal)) { + internal->init_failed = true; + return ERROR_RESOURCE; + } + internal->initialized = true; + } + SDL_Rect rect = { x_start, y_start, x_end - x_start, y_end - y_start }; // RGB565 = 2 bytes/pixel. if (SDL_UpdateTexture(internal->texture, &rect, color_data, (x_end - x_start) * 2) != 0) { return ERROR_RESOURCE; } - SDL_RenderClear(internal->renderer); - SDL_RenderCopy(internal->renderer, internal->texture, nullptr, nullptr); - SDL_RenderPresent(internal->renderer); + sdl_display_present_now(); return ERROR_NONE; } diff --git a/Devices/simulator/Source/drivers/sdl_display.h b/Devices/simulator/Source/drivers/sdl_display.h index 9e91c20a..cb5a0c7b 100644 --- a/Devices/simulator/Source/drivers/sdl_display.h +++ b/Devices/simulator/Source/drivers/sdl_display.h @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include + #ifdef __cplusplus extern "C" { #endif @@ -12,6 +14,22 @@ struct SdlDisplayConfig { uint16_t vertical_resolution; }; +/** + * @return the display's renderer, or NULL if the display hasn't drawn its first frame yet + * (see sdl_display_lazy_init() in sdl_display.cpp). Used by sdl_input.cpp to map window + * coordinates back to the fixed logical resolution the renderer scales to fit the window. + */ +SDL_Renderer* sdl_display_get_renderer(void); + +/** + * @brief Re-presents the already-drawn frame at the renderer's current scale. Call this when the + * window is resized: the window size change alone doesn't make SDL re-blit the last frame at + * the new scale until something calls SDL_RenderPresent() again, and LVGL won't do that on + * its own since nothing it's tracking actually changed. No-op if the display hasn't drawn its + * first frame yet. + */ +void sdl_display_present_now(void); + #ifdef __cplusplus } #endif diff --git a/Devices/simulator/Source/drivers/sdl_input.cpp b/Devices/simulator/Source/drivers/sdl_input.cpp index 002a2d06..747f5544 100644 --- a/Devices/simulator/Source/drivers/sdl_input.cpp +++ b/Devices/simulator/Source/drivers/sdl_input.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 #include "sdl_input.h" +#include "sdl_display.h" #include @@ -34,6 +35,23 @@ void push_key(uint32_t key) { // all of these back to LVGL's own sentinels (CODEPOINT_ESCAPE/BACKSPACE/DELETE already equal their // LV_KEY_* counterpart numerically, so no translation is needed for those). Printable characters // arrive separately via SDL_TEXTINPUT. +// The window can be freely resized (see sdl_display.cpp's SDL_WINDOW_RESIZABLE/ +// SDL_RenderSetLogicalSize()), so raw SDL mouse coordinates are in window-pixel space, not the +// fixed logical resolution LVGL renders at. SDL_RenderWindowToLogical() is the renderer's own +// inverse of that scaling, accounting for both the scale factor and any letterbox offset. +void set_pointer_position(int32_t window_x, int32_t window_y) { + SDL_Renderer* renderer = sdl_display_get_renderer(); + if (renderer == nullptr) { + pointer_state.x = window_x; + pointer_state.y = window_y; + return; + } + float logical_x, logical_y; + SDL_RenderWindowToLogical(renderer, window_x, window_y, &logical_x, &logical_y); + pointer_state.x = static_cast(logical_x); + pointer_state.y = static_cast(logical_y); +} + uint32_t keycode_to_key(SDL_Keycode sdl_key, bool shift) { switch (sdl_key) { case SDLK_RIGHT: return CODEPOINT_ARROW_RIGHT; @@ -64,13 +82,17 @@ void sdl_input_pump() { while (SDL_PollEvent(&event)) { switch (event.type) { case SDL_MOUSEMOTION: - pointer_state.x = event.motion.x; - pointer_state.y = event.motion.y; + set_pointer_position(event.motion.x, event.motion.y); break; case SDL_MOUSEBUTTONDOWN: if (event.button.button == SDL_BUTTON_LEFT) { - pointer_state.x = event.button.x; - pointer_state.y = event.button.y; + // event.button.x/y can be stale immediately after a window resize (an + // SDL/X11 event-queue quirk - confirmed by comparing against a live + // SDL_GetWindowSize() at the same instant). SDL_GetMouseState() queries the + // OS for the current pointer position directly, sidestepping that entirely. + int live_x, live_y; + SDL_GetMouseState(&live_x, &live_y); + set_pointer_position(live_x, live_y); pointer_state.pressed = true; } break; @@ -86,6 +108,14 @@ void sdl_input_pump() { // ASCII only (first byte of event.text.text) - sufficient for a simulator keyboard. push_key(static_cast(event.text.text[0])); break; + case SDL_WINDOWEVENT: + // Resizing doesn't change what LVGL last rendered, only how large it should + // appear - re-present the existing frame at the new scale immediately, rather + // than leaving stale-looking content on screen until the next LVGL-driven flush. + if (event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) { + sdl_display_present_now(); + } + break; case SDL_QUIT: exit(0); default: diff --git a/Devices/simulator/Source/module.cpp b/Devices/simulator/Source/module.cpp index 393ba5d4..02319c92 100644 --- a/Devices/simulator/Source/module.cpp +++ b/Devices/simulator/Source/module.cpp @@ -29,7 +29,7 @@ static Driver* const simulator_drivers[] = { // These devices have no real bus to attach to (SDL has no notion of one), but every non-root // device is still expected to have a parent (see Device::parent) - they're parented to root once // it's available below. -static const SdlDisplayConfig sdl_display_config = { 320, 240 }; +static const SdlDisplayConfig sdl_display_config = { 640, 480 }; static Device sdl_display_device {}; static Device sdl_pointer_device {}; static Device sdl_keyboard_device {}; diff --git a/Drivers/lilygo-module/source/tdeck_keyboard.cpp b/Drivers/lilygo-module/source/tdeck_keyboard.cpp index c2690a18..0afd4c82 100644 --- a/Drivers/lilygo-module/source/tdeck_keyboard.cpp +++ b/Drivers/lilygo-module/source/tdeck_keyboard.cpp @@ -27,11 +27,7 @@ static error_t start(Device* device) { auto* parent = device_get_parent(device); check(device_get_type(parent) == &I2C_CONTROLLER_TYPE); - auto address = GET_CONFIG(device)->address; - if (i2c_controller_has_device_at_address(parent, address, I2C_TIMEOUT) != ERROR_NONE) { - LOG_E(TAG, "No device found on I2C bus at address 0x%02X", address); - return ERROR_RESOURCE; - } + // We don't check whether the device is present, because it doesn't respond reliably at boot auto* internal = static_cast(malloc(sizeof(TdeckKeyboardInternal))); if (internal == nullptr) { diff --git a/Drivers/lilygo-module/source/tdeck_keyboard_backlight.cpp b/Drivers/lilygo-module/source/tdeck_keyboard_backlight.cpp index 3d00d4a0..fdc89785 100644 --- a/Drivers/lilygo-module/source/tdeck_keyboard_backlight.cpp +++ b/Drivers/lilygo-module/source/tdeck_keyboard_backlight.cpp @@ -28,11 +28,7 @@ static error_t start(Device* device) { auto* parent = device_get_parent(device); check(device_get_type(parent) == &I2C_CONTROLLER_TYPE); - auto address = GET_CONFIG(device)->address; - if (i2c_controller_has_device_at_address(parent, address, I2C_TIMEOUT) != ERROR_NONE) { - LOG_E(TAG, "No device found on I2C bus at address 0x%02X", address); - return ERROR_RESOURCE; - } + // We don't check whether the device is present, because it doesn't respond reliably at boot auto* internal = static_cast(malloc(sizeof(TdeckKeyboardBacklightInternal))); if (internal == nullptr) { @@ -43,6 +39,8 @@ static error_t start(Device* device) { auto brightness_default = GET_CONFIG(device)->brightness_default; + auto address = GET_CONFIG(device)->address; + // Configures the keyboard controller's own persisted default, used by its onboard ALT+B toggle. if (i2c_controller_write_register(parent, address, CMD_DEFAULT_BRIGHTNESS, &brightness_default, 1, I2C_TIMEOUT) != ERROR_NONE) { LOG_E(TAG, "Failed to set default brightness"); diff --git a/Modules/app-module/CMakeLists.txt b/Modules/app-module/CMakeLists.txt index 916ed703..5312b448 100644 --- a/Modules/app-module/CMakeLists.txt +++ b/Modules/app-module/CMakeLists.txt @@ -10,3 +10,12 @@ tactility_add_module(app-module INCLUDE_DIRS include/ REQUIRES TactilityKernel service-module minitar ) + +# Tells source/io.cpp its real-syscall fallback must go through __real_read/write/close() +# rather than calling ::read/::write/::close() directly, on every platform whose build wraps +# those symbols (ESP-IDF always; POSIX except macOS, whose linker doesn't support --wrap) - +# see Tactility/CMakeLists.txt and the top-level CMakeLists.txt for where that's applied. +if (NOT APPLE) + tactility_get_module_name(app-module MODULE_NAME) + target_compile_definitions(${MODULE_NAME} PRIVATE TT_APP_IO_WRAPS_STDIO) +endif () diff --git a/Modules/app-module/include/app/manager.h b/Modules/app-module/include/app/manager.h index 6b4de946..00262929 100644 --- a/Modules/app-module/include/app/manager.h +++ b/Modules/app-module/include/app/manager.h @@ -111,6 +111,23 @@ struct AppStreamBinding { */ error_t app_manager_start_with_streams(const char* id, const struct AppStreamBinding* bindings, size_t binding_count, AppInstanceId* out_app_instance_id); +/** + * Combines app_manager_start_for_result() and app_manager_start_with_streams(): starts @a id as + * a modal child of @a parent_instance_id (see app_manager_start_for_result()'s own doc for the + * result-delivery contract) with @a bindings installed into its fd table before its task begins + * executing (see app_manager_start_with_streams()'s own doc for stream ownership). For a child + * that needs to hand back more than an int32_t (e.g. a path) via its own stdout instead of the + * "get last result" getter pattern (see app_manager_start_for_result()) - see e.g. + * tt::app::fileselection::startForExistingFile(). + * @param[in] argv see app_manager_start_for_result(). + * @param[in] bindings see app_manager_start_with_streams(). + * @retval ERROR_NOT_FOUND no manifest with this id is registered, or no AppLoaderApi is registered + * @retval ERROR_OUT_OF_RANGE a binding's producer_fd is out of range + * @retval ERROR_RESOURCE a binding's event_group has no free bits left to claim + * @retval ERROR_NONE on success + */ +error_t app_manager_start_for_result_with_streams(const char* id, AppInstanceId parent_instance_id, int argc, const char* const argv[], const struct AppStreamBinding* bindings, size_t binding_count, AppInstanceId* out_app_instance_id); + /** * Stop an app instance permanently. Emits APP_EVENT_CLOSE and bound-waits for its task to exit * if it was running. diff --git a/Modules/app-module/source/io.cpp b/Modules/app-module/source/io.cpp index ff2aafdb..0d7570cf 100644 --- a/Modules/app-module/source/io.cpp +++ b/Modules/app-module/source/io.cpp @@ -7,7 +7,7 @@ #include -#ifdef ESP_PLATFORM +#if defined(TT_APP_IO_WRAPS_STDIO) extern "C" { ssize_t __real_read(int fd, void* buffer, size_t size); ssize_t __real_write(int fd, const void* buffer, size_t size); @@ -56,7 +56,7 @@ ssize_t app_io_read(int fd, void* buffer, size_t size) { errno = EBADF; return -1; } -#ifdef ESP_PLATFORM +#if defined(TT_APP_IO_WRAPS_STDIO) return __real_read(fd, buffer, size); #else return ::read(fd, buffer, size); @@ -71,13 +71,25 @@ ssize_t app_io_write(int fd, const void* buffer, size_t size) { if (file.ops->release != nullptr) { file.ops->release(file.object); } + // Tee to the real fd too: a bound stream only exists because a parent explicitly asked + // to capture this app instance's own output (see AppStreamBinding), but generic code + // running on that same instance's thread - most commonly the platform's own logging + // (LOG_I/etc, which calls write() the same as anything else) - has no way to know its + // output is currently being intercepted. Without this, a log line emitted while any app + // instance has its stdout captured would vanish from the console entirely instead of + // just also being visible to the capturing parent. +#if defined(TT_APP_IO_WRAPS_STDIO) + __real_write(fd, buffer, size); +#else + ::write(fd, buffer, size); +#endif return result; } if (table != nullptr && app_fd_table_is_app_owned(table, fd)) { errno = EBADF; return -1; } -#ifdef ESP_PLATFORM +#if defined(TT_APP_IO_WRAPS_STDIO) return __real_write(fd, buffer, size); #else return ::write(fd, buffer, size); @@ -96,7 +108,7 @@ int app_io_close(int fd) { return -1; } } -#ifdef ESP_PLATFORM +#if defined(TT_APP_IO_WRAPS_STDIO) return __real_close(fd); #else return ::close(fd); diff --git a/Modules/app-module/source/manager.cpp b/Modules/app-module/source/manager.cpp index 5facfea8..020b0d3f 100644 --- a/Modules/app-module/source/manager.cpp +++ b/Modules/app-module/source/manager.cpp @@ -178,6 +178,10 @@ error_t app_manager_start_with_streams(const char* id, const AppStreamBinding* b return start_internal(id, 0, 0, nullptr, bindings, binding_count, out_app_instance_id); } +error_t app_manager_start_for_result_with_streams(const char* id, AppInstanceId parent_instance_id, int argc, const char* const argv[], const AppStreamBinding* bindings, size_t binding_count, AppInstanceId* out_app_instance_id) { + return start_internal(id, parent_instance_id, argc, copy_arguments(argc, argv), bindings, binding_count, out_app_instance_id); +} + error_t app_manager_stop(AppInstanceId app_instance_id) { return app_scheduler_stop(app_instance_id, pdMS_TO_TICKS(2000)); } diff --git a/Modules/app-module/source/module.cpp b/Modules/app-module/source/module.cpp index 7000288e..9c3fb2f9 100644 --- a/Modules/app-module/source/module.cpp +++ b/Modules/app-module/source/module.cpp @@ -38,6 +38,7 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(app_manager_start_with_parameters), DEFINE_MODULE_SYMBOL(app_manager_start_for_result), DEFINE_MODULE_SYMBOL(app_manager_start_with_streams), + DEFINE_MODULE_SYMBOL(app_manager_start_for_result_with_streams), DEFINE_MODULE_SYMBOL(app_manager_stop), DEFINE_MODULE_SYMBOL(app_manager_get_state), DEFINE_MODULE_SYMBOL(app_manager_find_manifest), diff --git a/Modules/app-module/source/scheduler.cpp b/Modules/app-module/source/scheduler.cpp index 8a838df9..6bbc3fc7 100644 --- a/Modules/app-module/source/scheduler.cpp +++ b/Modules/app-module/source/scheduler.cpp @@ -199,12 +199,19 @@ void app_task_main(void* context) { check(pvTaskGetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX) == nullptr); vTaskSetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX, reinterpret_cast(static_cast(ctx->app_instance_id))); + // Debug logging so it's invisible by default + // When logging happens, it can distort the application stdout, which breaks apps that use + // stdout to output important information, such as the file selection dialog app. LOG_I(TAG, "[instance %lu] Task started", ctx->app_instance_id); set_state(ctx->app_instance_id, APP_INSTANCE_STATE_ACTIVE); int32_t result = ctx->loader->run(ctx->runtime, ctx->app_instance_id, ctx->argc, ctx->argv); + // The platform might buffer stdout (e.g. esp-idf with newlib) + // Do a manual flush to ensure data has been written: + fflush(stdout); + vTaskSetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX, nullptr); ctx->loader->unload(ctx->runtime); diff --git a/Modules/app-module/tests/CMakeLists.txt b/Modules/app-module/tests/CMakeLists.txt index 4ef36418..de1c477e 100644 --- a/Modules/app-module/tests/CMakeLists.txt +++ b/Modules/app-module/tests/CMakeLists.txt @@ -6,10 +6,31 @@ enable_language(C CXX ASM) file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*.cpp) add_executable(AppModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES}) +if (NOT APPLE) + # Provides __wrap_read/write/close for the -Wl,--wrap= below (see Tactility/CMakeLists.txt + # for the canonical pairing of this file with those flags). + target_sources(AppModuleTests PRIVATE ${CMAKE_CURRENT_LIST_DIR}/../../../Tactility/Source/AppStdioWrap.cpp) +endif () + target_include_directories(AppModuleTests PRIVATE ${DOCTESTINC}) add_test(NAME AppModuleTests COMMAND AppModuleTests) +# Matches app-module's own TT_APP_IO_WRAPS_STDIO (see Modules/app-module/CMakeLists.txt): +# io.cpp calls __real_read/write/close() on non-Apple POSIX, so any final link of it needs +# these wraps too, or those symbols go unresolved. The printf-family/getc-family flags are +# needed for the same reason: AppStdioWrap.cpp compiles those __wrap_* functions unconditionally +# on this platform (see its own #if guard), and they reference __real_vfprintf/fputs/fputc/ +# fgetc/fgets - those only exist once the matching --wrap flag is passed. +if (NOT APPLE) + target_link_options(AppModuleTests PRIVATE + "-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close" + "-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf" + "-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc" + "-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets" + ) +endif () + target_link_libraries(AppModuleTests PUBLIC TactilityKernel app-module diff --git a/Modules/app-posix-module/CMakeLists.txt b/Modules/app-posix-module/CMakeLists.txt new file mode 100644 index 00000000..f818c9e7 --- /dev/null +++ b/Modules/app-posix-module/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*") + +tactility_add_module(app-posix-module + SRCS ${SOURCE_FILES} + INCLUDE_DIRS include/ + REQUIRES TactilityKernel app-module service-module + PRIV_REQUIRES ${CMAKE_DL_LIBS} +) + +# Baked in at compile time, mirroring ESP32's CONFIG_IDF_TARGET - names the installed-app binary +# (e.g. "elf/posix-x86_64.so") this Tactility process's own architecture can dlopen(), letting one +# .app package bundle a variant per posix architecture, same as one .app bundles a .elf per chip. +target_compile_definitions(app-posix-module PRIVATE "TACTILITY_POSIX_ARCH=\"${CMAKE_SYSTEM_PROCESSOR}\"") diff --git a/Modules/app-posix-module/LICENSE-Apache-2.0.md b/Modules/app-posix-module/LICENSE-Apache-2.0.md new file mode 100644 index 00000000..f5f4b8b5 --- /dev/null +++ b/Modules/app-posix-module/LICENSE-Apache-2.0.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Modules/app-posix-module/include/app_posix/module.h b/Modules/app-posix-module/include/app_posix/module.h new file mode 100644 index 00000000..04f55c38 --- /dev/null +++ b/Modules/app-posix-module/include/app_posix/module.h @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module app_posix_module; + +#ifdef __cplusplus +} +#endif diff --git a/Modules/app-posix-module/source/app_posix_loader_service.cpp b/Modules/app-posix-module/source/app_posix_loader_service.cpp new file mode 100644 index 00000000..9103a821 --- /dev/null +++ b/Modules/app-posix-module/source/app_posix_loader_service.cpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include +#include + +#include + +#include +#include + +#include +#include + +constexpr auto* TAG = "app_posix_loader"; + +namespace { + +/** load()-allocated state, passed back through run()/unload(). */ +struct PosixAppRuntime { + void* handle = nullptr; +}; + +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 .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(). +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)) { + return ERROR_NOT_FOUND; + } + resolvedPath = shared_object_path; + return ERROR_NONE; +} + +error_t api_load(AppLocation location, AppRuntime* out_runtime) { + if (location.type != APP_LOCATION_PATH) { + LOG_E(TAG, "Unsupported location type"); + return ERROR_NOT_SUPPORTED; + } + + std::string app_path; + auto error = resolve_app_path(static_cast(location.location), app_path); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to resolve app path: %s", location.location); + return error; + } + + 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()); + return ERROR_NOT_FOUND; + } + + auto* runtime = new (std::nothrow) PosixAppRuntime { .handle = handle }; + if (runtime == nullptr) { + LOG_E(TAG, "Out of memory"); + dlclose(handle); + return ERROR_OUT_OF_MEMORY; + } + + *out_runtime = runtime; + return ERROR_NONE; +} + +int32_t api_run(AppRuntime runtime_ptr, uint32_t /*app_instance_id*/, int argc, char* argv[]) { + auto* runtime = static_cast(runtime_ptr); + + dlerror(); // clear any pending error, per dlsym(3)'s own recommended idiom for telling a NULL + // symbol address apart from a real lookup failure + void* symbol = dlsym(runtime->handle, "main"); + const char* lookup_error = dlerror(); + if (symbol == nullptr || lookup_error != nullptr) { + LOG_E(TAG, "dlsym(\"main\") failed: %s", lookup_error != nullptr ? lookup_error : "not found"); + return -1; + } + + auto* main_fn = reinterpret_cast(symbol); + return main_fn(argc, argv); +} + +void api_unload(AppRuntime runtime_ptr) { + auto* runtime = static_cast(runtime_ptr); + dlclose(runtime->handle); + delete runtime; +} + +AppLoaderApi loader_api = { + .load = api_load, + .run = api_run, + .unload = api_unload, +}; + +void* create_service(const ServiceManifest*) { + return &loader_api; +} + +void destroy_service(const ServiceManifest*, void*) { +} + +} // namespace + +ServiceManifest loader_service_manifest = { + .id = APP_LOADER_PATH_SERVICE_ID, + .create_service = create_service, + .destroy_service = destroy_service, + .on_start = nullptr, + .on_stop = nullptr, +}; diff --git a/Modules/app-posix-module/source/module.cpp b/Modules/app-posix-module/source/module.cpp new file mode 100644 index 00000000..80e061ca --- /dev/null +++ b/Modules/app-posix-module/source/module.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +#include +#include + +extern "C" { + +extern ServiceManifest loader_service_manifest; + +static error_t start() { + return service_manager_add(&loader_service_manifest, /*auto_start=*/true); +} + +static error_t stop() { + return service_manager_remove(loader_service_manifest.id); +} + +Module app_posix_module = { + .name = "app-posix", + .start = start, + .stop = stop, + .drivers = nullptr, + .symbols = nullptr, + .internal = nullptr, +}; + +} diff --git a/Modules/app-posix-module/tests/CMakeLists.txt b/Modules/app-posix-module/tests/CMakeLists.txt new file mode 100644 index 00000000..11d2d665 --- /dev/null +++ b/Modules/app-posix-module/tests/CMakeLists.txt @@ -0,0 +1,37 @@ +project(AppPosixModuleTests) + +enable_language(C CXX ASM) + +# Fixture: a tiny shared object with a main() the test dlopen()s through app-posix-module's real +# loader service. Deliberately not linked against app-module, so its call into +# app_scheduler_current_app_id() stays undefined until dlopen() resolves it against +# AppPosixModuleTests's own copy, exactly like a real Tactility-embedded app would resolve +# against the running Tactility binary. +add_library(app_posix_module_test_fixture SHARED EXCLUDE_FROM_ALL ${CMAKE_CURRENT_LIST_DIR}/fixtures/fixture_app.cpp) +target_include_directories(app_posix_module_test_fixture PRIVATE ${CMAKE_SOURCE_DIR}/Modules/app-module/include) +set_target_properties(app_posix_module_test_fixture PROPERTIES POSITION_INDEPENDENT_CODE ON) + +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) + +target_include_directories(AppPosixModuleTests PRIVATE ${DOCTESTINC}) +target_compile_definitions(AppPosixModuleTests PRIVATE + FIXTURE_APP_PATH="$" +) + +add_test(NAME AppPosixModuleTests COMMAND AppPosixModuleTests) + +target_link_libraries(AppPosixModuleTests PUBLIC + TactilityKernel + app-module + app-posix-module + service-module + platform-posix + freertos_kernel + ${CMAKE_DL_LIBS} +) +# So the fixture's undefined app_scheduler_current_app_id() reference can resolve against this +# test binary's own copy at dlopen() time. See app-posix-module's own ENABLE_EXPORTS comment on +# the real Tactility executable for why this is needed. +set_target_properties(AppPosixModuleTests PROPERTIES ENABLE_EXPORTS ON) diff --git a/Modules/app-posix-module/tests/fixtures/fixture_app.cpp b/Modules/app-posix-module/tests/fixtures/fixture_app.cpp new file mode 100644 index 00000000..4120ac2b --- /dev/null +++ b/Modules/app-posix-module/tests/fixtures/fixture_app.cpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include + +// Deliberately not linked against app-module: app_scheduler_current_app_id() stays an undefined +// symbol in this .so, resolved at dlopen() time against the loading process's own copy. Proves +// app-posix-module's loader lets a loaded app call straight back into Tactility without linking +// its own copy of it. +extern "C" int32_t main(int, char*[]) { + return static_cast(app_scheduler_current_app_id()); +} diff --git a/Modules/app-posix-module/tests/source/loader_test.cpp b/Modules/app-posix-module/tests/source/loader_test.cpp new file mode 100644 index 00000000..03e85dc7 --- /dev/null +++ b/Modules/app-posix-module/tests/source/loader_test.cpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "doctest.h" + +#include +#include +#include +#include + +#include + +#include + +#include + +extern ServiceManifest loader_service_manifest; // app-posix-module's own +extern ServiceManifest app_internal_loader_service_manifest; // app-module's real memory loader + +namespace { + +void ensure_path_loader_registered() { + if (service_manager_find_instance(APP_LOADER_PATH_SERVICE_ID) == nullptr) { + service_manager_add(&loader_service_manifest, /*auto_start=*/true); + } +} + +void ensure_memory_loader_registered() { + if (service_manager_find_instance(APP_LOADER_MEMORY_SERVICE_ID) == nullptr) { + service_manager_add(&app_internal_loader_service_manifest, /*auto_start=*/true); + } +} + +bool wait_for_state(AppInstanceId id, AppInstanceState target, uint32_t timeout_ms) { + uint32_t waited = 0; + while (waited < timeout_ms) { + if (app_manager_get_state(id) == target) { + return true; + } + delay_millis(10); + waited += 10; + } + return app_manager_get_state(id) == target; +} + +std::atomic g_fixture_result { -1 }; +std::atomic g_fixture_result_received { false }; + +// Starts the dlopen()ed fixture as its own modal child and stashes its returned result, so the +// test can inspect that result from the (in-process, directly readable) parent's own task. +int32_t parent_app_main(int, char*[]) { + TaskEventGroup event_group {}; + task_event_group_construct(&event_group); + + AppEventSubscription sub {}; + app_event_subscribe(&sub, &event_group); + + AppInstanceId self_id = app_scheduler_current_app_id(); + + AppManifest fixture_manifest { + "test.posix.fixture", "Fixture", APP_CATEGORY_USER, + { APP_LOCATION_PATH, const_cast(FIXTURE_APP_PATH) } + }; + app_manager_add(&fixture_manifest); + + AppInstanceId fixture_id = 0; + app_manager_start_for_result("test.posix.fixture", self_id, 0, nullptr, &fixture_id); + + while (true) { + if (task_event_group_wait_any(&event_group, nullptr, pdMS_TO_TICKS(5000)) != ERROR_NONE) { + break; // safety net so a bug here can't hang the test suite + } + AppEvent event {}; + bool got_result = false; + while (app_event_poll(&sub, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_RESULT && event.result.launch_id == fixture_id) { + g_fixture_result.store(event.result.result, std::memory_order_release); + got_result = true; + } + } + if (got_result) { + break; + } + } + g_fixture_result_received.store(true, std::memory_order_release); + + app_manager_remove("test.posix.fixture"); + app_event_unsubscribe(&sub); + task_event_group_destruct(&event_group); + return 0; +} + +} // namespace + +TEST_CASE("app-posix-module's loader-path service dlopen()s a .so and calls its main(), which resolves a real Tactility symbol against the host") { + ensure_path_loader_registered(); + ensure_memory_loader_registered(); + g_fixture_result.store(-1, std::memory_order_relaxed); + g_fixture_result_received.store(false, std::memory_order_relaxed); + + AppManifest parent_manifest { "test.posix.parent", "Parent", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast(parent_app_main) } }; + REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE); + + AppInstanceId parent_id = 0; + REQUIRE_EQ(app_manager_start("test.posix.parent", &parent_id), ERROR_NONE); + REQUIRE(wait_for_state(parent_id, APP_INSTANCE_STATE_STOPPED, 3000)); + + CHECK(g_fixture_result_received.load(std::memory_order_acquire)); + // A positive AppInstanceId proves the fixture's dlopen()ed main() actually resolved and + // called app_scheduler_current_app_id() against the host process, not just "ran and returned + // a hardcoded value" - 0 would mean it thought it wasn't running as an app instance at all. + CHECK_GT(g_fixture_result.load(std::memory_order_acquire), 0); + + app_manager_remove("test.posix.parent"); +} diff --git a/Modules/app-posix-module/tests/source/main.cpp b/Modules/app-posix-module/tests/source/main.cpp new file mode 100644 index 00000000..731b8be7 --- /dev/null +++ b/Modules/app-posix-module/tests/source/main.cpp @@ -0,0 +1,54 @@ +#define DOCTEST_CONFIG_IMPLEMENT +#include "doctest.h" +#include + +#include "FreeRTOS.h" +#include "task.h" + +typedef struct { + int argc; + char** argv; + int result; +} TestTaskData; + +void test_task(void* parameter) { + auto* data = (TestTaskData*)parameter; + + doctest::Context context; + + context.applyCommandLine(data->argc, data->argv); + + // overrides + context.setOption("no-breaks", true); // don't break in the debugger when assertions fail + + data->result = context.run(); + + vTaskEndScheduler(); + + vTaskDelete(nullptr); +} + +int main(int argc, char** argv) { + TestTaskData data = { + .argc = argc, + .argv = argv, + .result = 0 + }; + + BaseType_t task_result = xTaskCreate( + test_task, + "test_task", + 8192, + &data, + 1, + nullptr + ); + + if (task_result != pdPASS) { + return 1; + } + + vTaskStartScheduler(); + + return data.result; +} diff --git a/Modules/lvgl-module/source/arch/lvgl_posix.c b/Modules/lvgl-module/source/arch/lvgl_posix.c index 119dab39..21a28514 100644 --- a/Modules/lvgl-module/source/arch/lvgl_posix.c +++ b/Modules/lvgl-module/source/arch/lvgl_posix.c @@ -28,6 +28,11 @@ static uint32_t task_max_sleep_ms = 10; static TaskHandle_t lvgl_task_handle = NULL; static bool lvgl_task_interrupt_requested = false; +// ESP32 gets LVGL's tick driven for free by esp_lvgl_port; POSIX has no such +// helper, so lv_tick_get() would otherwise stay at 0 forever and every timer +// (indev polling included) would look permanently "not due yet". +static uint32_t lvgl_last_tick_millis = 0; + #define LVGL_STOP_POLL_INTERVAL 10 #define LVGL_STOP_TIMEOUT 5000 @@ -80,7 +85,13 @@ static void lvgl_task(void* arg) { // on_start must be called from the task, otherwise the display doesn't work if (lvgl_module_config.on_start) lvgl_module_config.on_start(); + lvgl_last_tick_millis = (uint32_t)get_millis(); + while (!lvgl_task_is_interrupt_requested()) { + uint32_t now_millis = (uint32_t)get_millis(); + lv_tick_inc(now_millis - lvgl_last_tick_millis); + lvgl_last_tick_millis = now_millis; + if (lvgl_try_lock(10)) { task_delay_ms = lv_timer_handler(); lvgl_unlock(); diff --git a/Modules/lvgl-window-manager-module/source/module.cpp b/Modules/lvgl-window-manager-module/source/module.cpp index 116fd541..49eae496 100644 --- a/Modules/lvgl-window-manager-module/source/module.cpp +++ b/Modules/lvgl-window-manager-module/source/module.cpp @@ -8,6 +8,7 @@ extern "C" { static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(window_manager_create), + DEFINE_MODULE_SYMBOL(window_manager_create_ext), DEFINE_MODULE_SYMBOL(window_manager_remove), DEFINE_MODULE_SYMBOL(window_manager_get_state), DEFINE_MODULE_SYMBOL(window_manager_await_state_change), diff --git a/Platforms/platform-posix/source/pthread_stack_wrap.c b/Platforms/platform-posix/source/pthread_stack_wrap.c new file mode 100644 index 00000000..01ff6ca8 --- /dev/null +++ b/Platforms/platform-posix/source/pthread_stack_wrap.c @@ -0,0 +1,25 @@ +#include +#include + +/** + * Linked in with -Wl,--wrap=pthread_attr_setstack (see Tactility/CMakeLists.txt). + * + * FreeRTOS's POSIX port (Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/port.c) + * hands every task a stack carved out of its own heap (pvPortMalloc) via this call. + * pthread_attr_setstack requires page alignment, which pvPortMalloc doesn't guarantee; + * when it happens to succeed anyway (allocator alignment can vary run to run), the + * task's real pthread stack ends up living inside that small FreeRTOS heap region - + * fine for typical embedded task code, but a desktop GL driver doing on-the-fly shader + * compilation on that thread (e.g. Mesa on first frame present) can overflow it and + * silently corrupt adjacent heap_4 objects. + * + * Wrapping the call out entirely (rather than patching the vendored port.c) leaves every + * task's pthread_attr_t at its pthread_attr_init() default, so pthread_create() always + * gives it a real, properly allocated default-size stack instead. + */ +int __wrap_pthread_attr_setstack(pthread_attr_t* attr, void* stackaddr, size_t stacksize) { + (void)attr; + (void)stackaddr; + (void)stacksize; + return 0; +} diff --git a/Tactility/CMakeLists.txt b/Tactility/CMakeLists.txt index 4ef0d2ed..ad3b563f 100644 --- a/Tactility/CMakeLists.txt +++ b/Tactility/CMakeLists.txt @@ -88,6 +88,7 @@ else () list(APPEND REQUIRES_LIST platform-posix + app-posix-module freertos_kernel cJSON lvgl @@ -181,6 +182,34 @@ else () target_include_directories(Tactility PUBLIC Include/) target_include_directories(Tactility PRIVATE Private/) target_link_libraries(Tactility PRIVATE ${TACTILITY_REQUIRES_LIST}) + # Exports Tactility's own symbols (-rdynamic) so a dlopen()ed app-posix-module app can resolve + # calls back into it - the OS-native equivalent of app-esp32-module's custom symbol resolver. + set_target_properties(Tactility PROPERTIES ENABLE_EXPORTS ON) + # Routes every pthread_attr_setstack() call (FreeRTOS's POSIX port hands each task a stack + # carved out of its own heap through this) to __wrap_pthread_attr_setstack() in + # Platforms/platform-posix/source/pthread_stack_wrap.c, which no-ops it - see that file for why. + # --wrap is a GNU ld option; Apple's linker doesn't support it. + if (NOT APPLE) + target_link_options(Tactility PRIVATE "-Wl,--wrap=pthread_attr_setstack") + # Routes every read()/write()/close() call to Tactility/Source/AppStdioWrap.cpp's + # __wrap_read/write/close(), which forward into app_io_read/write/close() - the fd-table + # dispatch that lets an app's own stdio (e.g. a fileselection dialog's printf'd result + # path) reach an AppStream a parent bound via app_manager_start_with_streams(). Mirrors + # what ESP-IDF's build already does for the ESP32 target (see top-level CMakeLists.txt). + target_link_options(Tactility PRIVATE "-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close") + # glibc's printf/fprintf/etc don't call the public write() symbol internally (they're + # already compiled into libc.so, out of --wrap's reach), so the read/write/close wrap + # above can't see them. Newlib (ESP-IDF) doesn't have this gap - its stdio does call the + # wrappable syscall stubs - so this block is POSIX-only. Wrapping these symbols instead + # redirects OUR OWN calls to them (the only ones --wrap can rewrite) through + # AppStdioWrap.cpp's __wrap_* functions, which check the target stream (stdout/stdin) and + # fall back to the real libc function for any other FILE*. + target_link_options(Tactility PRIVATE + "-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf" + "-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc" + "-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets" + ) + endif () endif () # diff --git a/Tactility/Include/Tactility/app/fileselection/FileSelection.h b/Tactility/Include/Tactility/app/fileselection/FileSelection.h index af9af170..2111b360 100644 --- a/Tactility/Include/Tactility/app/fileselection/FileSelection.h +++ b/Tactility/Include/Tactility/app/fileselection/FileSelection.h @@ -1,29 +1,36 @@ #pragma once +#include #include -#include + +#include +#include namespace tt::app::fileselection { /** * Show a file selection dialog that allows the user to select an existing file, as a modal - * child of @a callerAppInstanceId (see app_manager_start_for_result()). Result (0 = Ok, - * 1 = Cancelled) is delivered back via APP_EVENT_RESULT once this app's thread exits - call - * getLastPath() right after receiving it, on result == 0. The caller must call - * app_manager_stop() on the returned instance id once that event arrives, to fully reap this - * instance. + * child of @a callerAppInstanceId (see app_manager_start_for_result_with_streams()). Result + * (0 = Ok, 1 = Cancelled) is delivered back via APP_EVENT_RESULT once this app's thread exits. + * On result == 0, read the picked path with app_stream_read(&stream, ...) then + * app_stream_unsubscribe(&stream); on any other result, just app_stream_unsubscribe(&stream). + * The caller must call app_manager_stop() on the returned instance id once that event arrives, + * to fully reap this instance. + * @param[in,out] stream caller-owned storage bound to the started app's stdout; must stay valid + * until app_stream_unsubscribe() is called on it (see above). + * @param[in] buffer caller-owned backing storage for @a stream's ring buffer; must stay valid + * for the same duration as @a stream. + * @param[in] bufferCapacity size of @a buffer in bytes. + * @param[in] eventGroup the caller's own event group, reused for the stream's readiness bits + * (see app_stream_subscribe()) - the caller isn't required to actually wait on them itself. * @return the new app instance id */ -uint32_t startForExistingFile(uint32_t callerAppInstanceId); +uint32_t startForExistingFile(uint32_t callerAppInstanceId, AppStream& stream, void* buffer, size_t bufferCapacity, TaskEventGroup* eventGroup); /** * Same as startForExistingFile(), but also allows picking a path that doesn't exist yet (for * "save as"-style flows). */ -uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId); - -/** @return the path picked by the last FileSelection dialog that closed with result == Ok. Only - * one dialog is expected to be open at a time. */ -std::string getLastPath(); +uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId, AppStream& stream, void* buffer, size_t bufferCapacity, TaskEventGroup* eventGroup); } // namespace diff --git a/Tactility/Private/Tactility/PartitionsPosix.h b/Tactility/Private/Tactility/PartitionsPosix.h new file mode 100644 index 00000000..31e6fc0a --- /dev/null +++ b/Tactility/Private/Tactility/PartitionsPosix.h @@ -0,0 +1,11 @@ +#pragma once + +#ifndef ESP_PLATFORM + +namespace tt { + +bool initPartitionsPosix(); + +} // namespace + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/AppStdioWrap.cpp b/Tactility/Source/AppStdioWrap.cpp index 464c6a74..9937b4b8 100644 --- a/Tactility/Source/AppStdioWrap.cpp +++ b/Tactility/Source/AppStdioWrap.cpp @@ -1,6 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -#ifdef ESP_PLATFORM +// Paired with -Wl,--wrap=read/write/close - see Tactility/CMakeLists.txt (POSIX) and the +// top-level CMakeLists.txt (ESP32) for where that's applied. On a platform where it isn't +// (currently: macOS, whose linker doesn't support --wrap), these are simply never called - real +// read()/write()/close() calls go straight through unredirected. #include #include @@ -21,4 +24,177 @@ int __wrap_close(int fd) { } -#endif // ESP_PLATFORM +// region glibc stdio wraps +// +// glibc's printf/fprintf/etc are compiled into libc.so and call an internal, non-exported write() +// alias - --wrap=write (above) can't reach that call, only calls WE make to the public symbol. +// These wraps instead redirect calls WE make to printf/fprintf/etc, the same trick as read/write/ +// close above. Newlib (ESP-IDF) doesn't have this gap - its stdio does call the wrappable syscall +// stubs - so Tactility/CMakeLists.txt only applies the matching -Wl,--wrap= flags on POSIX. +// +// Scoped to the printf/getc families only: fread/fwrite take an arbitrary FILE* and are already +// used sitewide for real file I/O (e.g. File.cpp's readBinaryInternal), so wrapping them would +// route every such call through this file's stdin/stdout check - a correctness risk for unrelated +// code that isn't worth taking here. putc/getc are excluded too since glibc defines them as +// macros, not real calls, so wrapping those symbols wouldn't reliably intercept them. + +#if !defined(ESP_PLATFORM) && !defined(__APPLE__) + +#include +#include +#include +#include +#include + +extern "C" { +int __real_vfprintf(FILE* stream, const char* format, va_list args); +int __real_fputs(const char* s, FILE* stream); +int __real_fputc(int c, FILE* stream); +int __real_fgetc(FILE* stream); +char* __real_fgets(char* buffer, int size, FILE* stream); +} + +namespace { + +void writeAllToStdout(const void* data, size_t size) { + const auto* bytes = static_cast(data); + size_t remaining = size; + while (remaining > 0) { + ssize_t written = app_io_write(STDOUT_FILENO, bytes, remaining); + if (written <= 0) { + break; + } + bytes += written; + remaining -= static_cast(written); + } +} + +// Formats into stdout via app_io_write() rather than through a FILE*'s own buffering, since that +// buffering is exactly what glibc's internal write() call sidesteps --wrap for in the first place. +int formatToStdout(const char* format, va_list args) { + char stackBuffer[256]; + va_list argsForStack; + va_copy(argsForStack, args); + int needed = vsnprintf(stackBuffer, sizeof(stackBuffer), format, argsForStack); + va_end(argsForStack); + if (needed < 0) { + return needed; + } + if (static_cast(needed) < sizeof(stackBuffer)) { + writeAllToStdout(stackBuffer, static_cast(needed)); + return needed; + } + auto heapBuffer = std::make_unique(static_cast(needed) + 1); + va_list argsForHeap; + va_copy(argsForHeap, args); + vsnprintf(heapBuffer.get(), static_cast(needed) + 1, format, argsForHeap); + va_end(argsForHeap); + writeAllToStdout(heapBuffer.get(), static_cast(needed)); + return needed; +} + +int readOneFromStdin(char& out) { + return static_cast(app_io_read(STDIN_FILENO, &out, 1)); +} + +} // namespace + +extern "C" { + +int __wrap_vprintf(const char* format, va_list args) { + return formatToStdout(format, args); +} + +int __wrap_printf(const char* format, ...) { + va_list args; + va_start(args, format); + int result = formatToStdout(format, args); + va_end(args); + return result; +} + +int __wrap_vfprintf(FILE* stream, const char* format, va_list args) { + if (stream == stdout) { + return formatToStdout(format, args); + } + return __real_vfprintf(stream, format, args); +} + +int __wrap_fprintf(FILE* stream, const char* format, ...) { + va_list args; + va_start(args, format); + int result = (stream == stdout) ? formatToStdout(format, args) : __real_vfprintf(stream, format, args); + va_end(args); + return result; +} + +int __wrap_puts(const char* s) { + writeAllToStdout(s, strlen(s)); + writeAllToStdout("\n", 1); + return 0; +} + +int __wrap_fputs(const char* s, FILE* stream) { + if (stream == stdout) { + writeAllToStdout(s, strlen(s)); + return 0; + } + return __real_fputs(s, stream); +} + +int __wrap_putchar(int c) { + auto ch = static_cast(c); + writeAllToStdout(&ch, 1); + return c; +} + +int __wrap_fputc(int c, FILE* stream) { + if (stream == stdout) { + return __wrap_putchar(c); + } + return __real_fputc(c, stream); +} + +int __wrap_getchar() { + char c; + return readOneFromStdin(c) == 1 ? static_cast(c) : EOF; +} + +int __wrap_fgetc(FILE* stream) { + if (stream == stdin) { + return __wrap_getchar(); + } + return __real_fgetc(stream); +} + +char* __wrap_fgets(char* buffer, int size, FILE* stream) { + if (stream != stdin) { + return __real_fgets(buffer, size, stream); + } + if (size <= 0) { + return nullptr; + } + int i = 0; + for (; i < size - 1; ++i) { + char c; + if (readOneFromStdin(c) != 1) { + break; + } + buffer[i] = c; + if (c == '\n') { + ++i; + break; + } + } + if (i == 0) { + return nullptr; + } + buffer[i] = '\0'; + return buffer; +} + +} + +#endif // !ESP_PLATFORM && !__APPLE__ + +// endregion diff --git a/Tactility/Source/DeprecatedPaths.cpp b/Tactility/Source/DeprecatedPaths.cpp index 1d63a982..14e3c8a5 100644 --- a/Tactility/Source/DeprecatedPaths.cpp +++ b/Tactility/Source/DeprecatedPaths.cpp @@ -52,11 +52,7 @@ std::string getUserDataRootPath() { } std::string getDataPath() { -#ifdef ESP_PLATFORM return getUserDataRootPath() + "/tactility"; -#else - return "data"; -#endif } std::string getTempPath() { diff --git a/Tactility/Source/MountPoints.cpp b/Tactility/Source/MountPoints.cpp index 4f79967d..7653400d 100644 --- a/Tactility/Source/MountPoints.cpp +++ b/Tactility/Source/MountPoints.cpp @@ -18,7 +18,11 @@ std::vector getFileSystemDirents() { if (!file_system_is_mounted(fs)) return true; char path[128]; if (file_system_get_path(fs, path, sizeof(path)) != ERROR_NONE) return true; - auto mount_name = std::string(path).substr(1); + // ESP32 mount paths are short names ("/system"); POSIX file systems can return a full + // absolute host path instead, so take the last path component either way. + auto path_str = std::string(path); + auto slash_pos = path_str.find_last_of('/'); + auto mount_name = slash_pos == std::string::npos ? path_str : path_str.substr(slash_pos + 1); if (!config::SHOW_SYSTEM_PARTITION && mount_name.starts_with(SYSTEM_PARTITION_NAME)) return true; auto dir_entry = dirent { .d_ino = 2, diff --git a/Tactility/Source/PartitionsPosix.cpp b/Tactility/Source/PartitionsPosix.cpp new file mode 100644 index 00000000..d1ac1645 --- /dev/null +++ b/Tactility/Source/PartitionsPosix.cpp @@ -0,0 +1,103 @@ +#ifndef ESP_PLATFORM + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace tt { + +constexpr auto* TAG = "Partitions"; + +// region file_system stub + +// A plain host directory has no real mount/unmount step to perform - "mounted" here just tracks +// whether file_system_remove()'s precondition (must be unmounted first) has been satisfied. +struct DirectoryFsData { + char path[PATH_MAX]; + bool mounted; +}; + +static DirectoryFsData system_fs_data; +static DirectoryFsData data_fs_data; +static FileSystem* system_fs = nullptr; +static FileSystem* data_fs = nullptr; + +static error_t mount(void* data) { + static_cast(data)->mounted = true; + return ERROR_NONE; +} + +static error_t unmount(void* data) { + static_cast(data)->mounted = false; + return ERROR_NONE; +} + +static bool is_mounted(void* data) { + return static_cast(data)->mounted; +} + +static error_t get_path(void* data, char* out_path, size_t out_path_size) { + auto* fs_data = static_cast(data); + if (strlen(fs_data->path) >= out_path_size) { + return ERROR_BUFFER_OVERFLOW; + } + strcpy(out_path, fs_data->path); + return ERROR_NONE; +} + +static const FileSystemApi directory_fs_api = { + .mount = mount, + .unmount = unmount, + .is_mounted = is_mounted, + .get_path = get_path, +}; + +// endregion file_system stub + +// relative_path is resolved against the process' current working directory (the simulator is +// expected to run with Data/ as its working directory, so file::SYSTEM_PARTITION_NAME/ +// DATA_PARTITION_NAME here match file::MOUNT_POINT_SYSTEM/MOUNT_POINT_DATA). +static FileSystem* registerDirectoryFs(const char* relativePath, DirectoryFsData* outData) { + if (realpath(relativePath, outData->path) == nullptr) { + LOG_E(TAG, "Failed to resolve '%s' to an absolute path: %s", relativePath, strerror(errno)); + return nullptr; + } + outData->mounted = true; + return file_system_add(&directory_fs_api, outData); +} + +static void unregisterDirectoryFs(FileSystem* fs) { + if (fs == nullptr) { + return; + } + file_system_unmount(fs); + file_system_remove(fs); +} + +bool initPartitionsPosix() { + system_fs = registerDirectoryFs(file::SYSTEM_PARTITION_NAME, &system_fs_data); + if (system_fs == nullptr) { + return false; + } + + data_fs = registerDirectoryFs(file::DATA_PARTITION_NAME, &data_fs_data); + if (data_fs == nullptr) { + unregisterDirectoryFs(system_fs); + system_fs = nullptr; + return false; + } + + return true; +} + +} // namespace + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 6c51c5a2..d2e30b1f 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -4,13 +4,27 @@ #include #endif +#if __has_include() && not defined(ESP_PLATFORM) +#define TT_IS_POSIX 1 +#else +#define TT_IS_POSIX 0 +#endif + +#if TT_IS_POSIX or defined(ESP_PLATFORM) // esp-idf supports certain posix symbols +#include +#endif + +#if TT_IS_POSIX +#include +#include +#endif + #include #include #include #include #include -#include #include #include #include @@ -19,7 +33,6 @@ #include #include -#include #include #include #include @@ -35,7 +48,10 @@ #include #include #include + +#ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED #include +#endif #include #include @@ -47,7 +63,6 @@ #include #include #include -#include #include #include @@ -501,7 +516,9 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) { // C/C++/Posix symbols check(module_ensure_started(&c_symbols_module) == ERROR_NONE); +#if TT_IS_POSIX or defined(ESP_PLATFORM) // esp-idf supports certain posix symbols check(module_ensure_started(&posix_symbols_module) == ERROR_NONE); +#endif check(module_ensure_started(&cpp_symbols_module) == ERROR_NONE); // OS level symbols check(module_ensure_started(&freertos_module) == ERROR_NONE); @@ -516,10 +533,14 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) { check(module_ensure_started(&gps_meshtastic_module) == ERROR_NONE); #ifdef ESP_PLATFORM check(module_ensure_started(&app_esp32_module) == ERROR_NONE); +#elif TT_IS_POSIX + check(module_ensure_started(&app_posix_module) == ERROR_NONE); #endif #ifdef ESP_PLATFORM initEsp(); +#elif TT_IS_POSIX + check(initPartitionsPosix(), "Failed to init partitions"); #endif settings::initTimeZone(); diff --git a/Tactility/Source/app/files/View.cpp b/Tactility/Source/app/files/View.cpp index 51d6ca3d..7512be50 100644 --- a/Tactility/Source/app/files/View.cpp +++ b/Tactility/Source/app/files/View.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include namespace tt::app::files { @@ -174,43 +175,22 @@ static bool copyRecursive(const std::string& src, const std::string& dst) { void View::viewFile(const std::string& path, const std::string& filename) { std::string file_path = path + "/" + filename; - - // For PC we need to make the path relative to the current work directory, - // because that's how LVGL maps its 'drive letter' to the file system. - std::string processed_filepath; - if (kernel::getPlatform() == kernel::PlatformSimulator) { - char cwd[PATH_MAX]; - if (getcwd(cwd, sizeof(cwd)) == nullptr) { - LOG_E(TAG, "Failed to get current working directory"); - return; - } - if (!file_path.starts_with(cwd)) { - LOG_E(TAG, "Can only work with files in working directory %s", cwd); - return; - } - processed_filepath = file_path.substr(strlen(cwd)); - } else { - processed_filepath = file_path; - } - LOG_I(TAG, "Clicked %s", file_path.c_str()); if (isSupportedAppFile(filename)) { -#ifdef ESP_PLATFORM // install(filename); auto message = std::format("Do you want to install {}?", filename); - installAppPath = processed_filepath; + installAppPath = file_path; auto choices = std::vector {"Yes", "No"}; installDialogId = alertdialog::start(appInstanceId, "Install?", message, choices); -#endif } else if (isSupportedImageFile(filename)) { - imageviewer::start(processed_filepath); + imageviewer::start(file_path); } else if (isSupportedTextFile(filename)) { if (kernel::getPlatform() == kernel::PlatformEsp) { - notes::start(processed_filepath); + notes::start(file_path); } else { // Remove forward slash, because we need a relative path - notes::start(processed_filepath.substr(1)); + notes::start(file_path.substr(1)); } } else { LOG_W(TAG, "Opening files of this type is not supported"); diff --git a/Tactility/Source/app/fileselection/FileSelection.cpp b/Tactility/Source/app/fileselection/FileSelection.cpp index be89e961..dd28b523 100644 --- a/Tactility/Source/app/fileselection/FileSelection.cpp +++ b/Tactility/Source/app/fileselection/FileSelection.cpp @@ -1,18 +1,23 @@ +#include "Tactility/app/fileselection/FileSelection.h" #include "Tactility/app/fileselection/FileSelectionPrivate.h" #include "Tactility/app/fileselection/View.h" #include "Tactility/app/fileselection/State.h" #include +#include #include #include #include +#include #include #include +#include #include #include +#include namespace tt::app::fileselection { @@ -27,20 +32,11 @@ struct Context { Mode mode; std::shared_ptr state; std::unique_ptr view; - // The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this - // is a plain (non-atomic) field safely shared between the LVGL thread (writer, before - // emitting APP_EVENT_CLOSE) and this app's own thread (reader, after waking from it). - int32_t result = 1; // Cancelled - safety-net default if closed without picking a file + std::string resultPath; + int32_t resultCode = 1; // 1 means Cancelled }; -// The last picked path. Static rather than per-instance: simple, and in practice only one -// FileSelection dialog is ever open at a time. Written on the LVGL thread (View's select-button -// callback, before emitting APP_EVENT_CLOSE); read by the parent via getLastPath() after -// receiving that event - safe without a lock for the same reason Context::result is (see -// AlertDialog.cpp). -std::string lastPath; - void createWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); ctx->view->init(parent, ctx->mode); @@ -52,16 +48,11 @@ int32_t appMain(int argc, char* argv[]) { Context ctx {}; ctx.appInstanceId = appInstanceId; - ctx.mode = (argc > 0 && std::string(argv[0]) == "existing_or_new") ? Mode::ExistingOrNew : Mode::Existing; + ctx.mode = (argc > 0 && std::string(argv[0]) == "--existing-or-new") ? Mode::ExistingOrNew : Mode::Existing; ctx.state = std::make_shared(); ctx.view = std::make_unique(appInstanceId, ctx.state, [&ctx, appInstanceId](const std::string& path) { - // Runs on the LVGL task (View::onSelectButtonPressed) - must NOT call app_manager_stop() - // here: that bound-waits (thread_join) for this app's own thread to finish, which needs - // the LVGL lock (window_manager_remove()) - but this callback runs ON the LVGL task, - // which would deadlock against itself. The caller reaps this instance via - // app_manager_stop() after it receives the APP_EVENT_RESULT instead. - lastPath = path; - ctx.result = 0; + ctx.resultPath = path; + ctx.resultCode = 0; app_event_emit_close(appInstanceId); }); @@ -91,27 +82,42 @@ int32_t appMain(int argc, char* argv[]) { check(app_event_unsubscribe(&sub) == ERROR_NONE); task_event_group_destruct(&event_group); - return ctx.result; + if (ctx.resultCode == 0) { + // The parent captures this via an AppStream bound to our stdout (see startWithMode()) - + // see AppStdioWrap.cpp for how printf() itself gets routed there on POSIX. + LOG_I(TAG, "Result: %s", ctx.resultPath.c_str()); + printf("%s", ctx.resultPath.c_str()); + } + + return ctx.resultCode; } } // namespace -std::string getLastPath() { - return lastPath; -} +namespace { -uint32_t startForExistingFile(uint32_t callerAppInstanceId) { - const char* argv[] = { "existing" }; +uint32_t startWithMode(const char* modeArg, uint32_t callerAppInstanceId, AppStream& stream, void* buffer, size_t bufferCapacity, TaskEventGroup* eventGroup) { + const char* argv[] = { modeArg }; + AppStreamBinding binding = { + .producer_fd = STDOUT_FILENO, + .stream = &stream, + .buffer = buffer, + .buffer_capacity = bufferCapacity, + .event_group = eventGroup, + }; uint32_t instanceId = 0; - app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId); + app_manager_start_for_result_with_streams(manifest.id, callerAppInstanceId, 1, argv, &binding, 1, &instanceId); return instanceId; } -uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId) { - const char* argv[] = { "existing_or_new" }; - uint32_t instanceId = 0; - app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId); - return instanceId; +} // namespace + +uint32_t startForExistingFile(uint32_t callerAppInstanceId, AppStream& stream, void* buffer, size_t bufferCapacity, TaskEventGroup* eventGroup) { + return startWithMode("--existing", callerAppInstanceId, stream, buffer, bufferCapacity, eventGroup); +} + +uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId, AppStream& stream, void* buffer, size_t bufferCapacity, TaskEventGroup* eventGroup) { + return startWithMode("--existing-or-new", callerAppInstanceId, stream, buffer, bufferCapacity, eventGroup); } extern const ::AppManifest manifest = { diff --git a/Tactility/Source/app/fileselection/State.cpp b/Tactility/Source/app/fileselection/State.cpp index 5f4f92ea..73a72c83 100644 --- a/Tactility/Source/app/fileselection/State.cpp +++ b/Tactility/Source/app/fileselection/State.cpp @@ -33,7 +33,7 @@ std::string State::getSelectedChildPath() const { } bool State::setEntriesForPath(const std::string& path) { - LOG_I(TAG, "Changing path: %s -> %s", current_path.c_str(), path.c_str()); + LOG_D(TAG, "Changing path: %s -> %s", current_path.c_str(), path.c_str()); auto lock = mutex.asScopedLock(); if (!lock.lock(100)) { @@ -47,7 +47,6 @@ bool State::setEntriesForPath(const std::string& path) { */ bool show_custom_root = (kernel::getPlatform() == kernel::PlatformEsp) && (path == "/"); if (show_custom_root) { - LOG_I(TAG, "Setting custom root"); dir_entries = file::getFileSystemDirents(); current_path = path; selected_child_entry = ""; @@ -56,7 +55,6 @@ bool State::setEntriesForPath(const std::string& path) { dir_entries.clear(); int count = file::scandir(path, dir_entries, &file::direntFilterDotEntries, file::direntSortAlphaAndType); if (count >= 0) { - LOG_I(TAG, "%s has %d entries", path.c_str(), count); current_path = path; selected_child_entry = ""; return true; @@ -69,7 +67,7 @@ bool State::setEntriesForPath(const std::string& path) { bool State::setEntriesForChildPath(const std::string& childPath) { auto path = file::getChildPath(current_path, childPath); - LOG_I(TAG, "Navigating from %s to %s", current_path.c_str(), path.c_str()); + LOG_D(TAG, "Navigating from %s to %s", current_path.c_str(), path.c_str()); return setEntriesForPath(path); } diff --git a/Tactility/Source/app/fileselection/View.cpp b/Tactility/Source/app/fileselection/View.cpp index a5776bad..5fc91d51 100644 --- a/Tactility/Source/app/fileselection/View.cpp +++ b/Tactility/Source/app/fileselection/View.cpp @@ -60,12 +60,15 @@ void View::onTapFile(const std::string& path, const std::string& filename) { LOG_E(TAG, "Can only work with files in working directory %s", cwd); return; } - processed_filepath = file_path.substr(strlen(cwd)); + // MountPoints.h's MOUNT_POINT_DATA/MOUNT_POINT_SYSTEM have no leading slash on POSIX + // (fopen() resolves relative to cwd there), unlike ESP32's real VFS mount points - so + // strip the separator too, not just cwd itself. + processed_filepath = file_path.substr(strlen(cwd) + 1); } else { processed_filepath = file_path; } - LOG_I(TAG, "Clicked %s", processed_filepath.c_str()); + LOG_D(TAG, "Clicked %s", processed_filepath.c_str()); lv_textarea_set_text(path_textarea, processed_filepath.c_str()); } @@ -73,7 +76,7 @@ void View::onTapFile(const std::string& path, const std::string& filename) { void View::onDirEntryPressed(uint32_t index) { dirent dir_entry; if (state->getDirent(index, dir_entry)) { - LOG_I(TAG, "Pressed %s %d", dir_entry.d_name, (int)dir_entry.d_type); + LOG_D(TAG, "Pressed %s %d", dir_entry.d_name, (int)dir_entry.d_type); state->setSelectedChildEntry(dir_entry.d_name); using namespace tt::file; switch (dir_entry.d_type) { @@ -146,7 +149,7 @@ void View::createDirEntryWidget(lv_obj_t* list, dirent& dir_entry) { void View::onNavigateUpPressed() { if (state->getCurrentPath() != "/") { - LOG_I(TAG, "Navigating upwards"); + LOG_D(TAG, "Navigating upwards"); std::string new_absolute_path; if (string::getPathParent(state->getCurrentPath(), new_absolute_path)) { state->setEntriesForPath(new_absolute_path); diff --git a/Tactility/Source/app/notes/Notes.cpp b/Tactility/Source/app/notes/Notes.cpp index 8bf5bed5..381d618a 100644 --- a/Tactility/Source/app/notes/Notes.cpp +++ b/Tactility/Source/app/notes/Notes.cpp @@ -1,4 +1,7 @@ #include + +#include "Tactility/app/alertdialog/AlertDialog.h" + #include #include @@ -6,6 +9,7 @@ #include #include #include +#include #include @@ -25,6 +29,7 @@ namespace { struct Context { uint32_t appInstanceId; + TaskEventGroup* eventGroup = nullptr; lv_obj_t* uiCurrentFileName = nullptr; lv_obj_t* uiDropDownMenu = nullptr; @@ -35,6 +40,10 @@ struct Context { uint32_t loadFileLaunchId = 0; uint32_t saveFileLaunchId = 0; + AppStream loadResultStream {}; + uint8_t loadResultBuffer[256] {}; + AppStream saveResultStream {}; + uint8_t saveResultBuffer[256] {}; }; @@ -90,11 +99,11 @@ void appNotesEventCb(lv_event_t* e) { lvgl_lock(); ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText); lvgl_unlock(); - ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId); + ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId, ctx->saveResultStream, ctx->saveResultBuffer, sizeof(ctx->saveResultBuffer), ctx->eventGroup); LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId); break; case 3: // Load - ctx->loadFileLaunchId = fileselection::startForExistingFile(ctx->appInstanceId); + ctx->loadFileLaunchId = fileselection::startForExistingFile(ctx->appInstanceId, ctx->loadResultStream, ctx->loadResultBuffer, sizeof(ctx->loadResultBuffer), ctx->eventGroup); LOG_I(TAG, "launched with id %u", ctx->loadFileLaunchId); break; } @@ -102,7 +111,7 @@ void appNotesEventCb(lv_event_t* e) { auto* cont = lv_event_get_current_target_obj(e); if (obj == cont) return; if (lv_obj_get_child(cont, 1)) { - ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId); + ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId, ctx->saveResultStream, ctx->saveResultBuffer, sizeof(ctx->saveResultBuffer), ctx->eventGroup); LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId); } else { //Reset resetFileContent(ctx); @@ -189,6 +198,7 @@ int32_t appMain(int argc, char* argv[]) { TaskEventGroup event_group {}; task_event_group_construct(&event_group); + ctx.eventGroup = &event_group; AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); @@ -206,23 +216,35 @@ int32_t appMain(int argc, char* argv[]) { shouldClose = true; break; case APP_EVENT_RESULT: - LOG_I(TAG, "Result for launch id %u", event.result.launch_id); + LOG_I(TAG, "Result for launch id %u = %u", event.result.launch_id, event.result.result); if (event.result.launch_id == ctx.loadFileLaunchId) { ctx.loadFileLaunchId = 0; if (event.result.result == 0 /* Ok */) { - auto path = fileselection::getLastPath(); + char destination[sizeof(ctx.loadResultBuffer)]; + size_t length = app_stream_read(&ctx.loadResultStream, destination, sizeof(destination)); + app_stream_unsubscribe(&ctx.loadResultStream); + auto path = std::string(destination, length); + LOG_I(TAG, "Path: '%s'", path.c_str()); if (!path.empty()) { openFile(&ctx, path); } + } else { + app_stream_unsubscribe(&ctx.loadResultStream); } } else if (event.result.launch_id == ctx.saveFileLaunchId) { ctx.saveFileLaunchId = 0; if (event.result.result == 0 /* Ok */) { - auto path = fileselection::getLastPath(); + char destination[sizeof(ctx.saveResultBuffer)]; + size_t length = app_stream_read(&ctx.saveResultStream, destination, sizeof(destination)); + app_stream_unsubscribe(&ctx.saveResultStream); + auto path = std::string(destination, length); // Must re-open file, because the UI was cleared after opening the dialog. + LOG_I(TAG, "Path: '%s'", path.c_str()); if (!path.empty() && saveFile(&ctx, path)) { openFile(&ctx, path); } + } else { + app_stream_unsubscribe(&ctx.saveResultStream); } } app_manager_stop(event.result.launch_id); diff --git a/Tactility/Source/file/File.cpp b/Tactility/Source/file/File.cpp index 614cda93..7de422a6 100644 --- a/Tactility/Source/file/File.cpp +++ b/Tactility/Source/file/File.cpp @@ -66,7 +66,6 @@ int scandir( ScandirFilter filterMethod, ScandirSort sortMethod ) { - LOG_I(TAG, "scandir start"); DIR* dir = opendir(path.c_str()); if (dir == nullptr) { LOG_E(TAG, "Failed to open dir %s", path.c_str()); @@ -86,7 +85,6 @@ int scandir( std::ranges::sort(outList, sortMethod); } - LOG_I(TAG, "scandir finish"); return outList.size(); } diff --git a/Tactility/Tests/CMakeLists.txt b/Tactility/Tests/CMakeLists.txt index ca512fd8..1a060ec2 100644 --- a/Tactility/Tests/CMakeLists.txt +++ b/Tactility/Tests/CMakeLists.txt @@ -19,11 +19,25 @@ target_include_directories(TactilityTests PRIVATE ${CMAKE_CURRENT_LIST_DIR}/../P add_test(NAME TactilityTests COMMAND TactilityTests) +# Matches Tactility/CMakeLists.txt's own set of --wrap flags: this binary also compiles +# AppStdioWrap.cpp and links app-module (whose io.cpp calls __real_read/write/close() on +# non-Apple POSIX - see TT_APP_IO_WRAPS_STDIO in Modules/app-module/CMakeLists.txt), so it +# needs the same wraps applied or those go unresolved. +if (NOT APPLE) + target_link_options(TactilityTests PRIVATE + "-Wl,--wrap=pthread_attr_setstack" "-Wl,--wrap=read" "-Wl,--wrap=write" "-Wl,--wrap=close" + "-Wl,--wrap=printf" "-Wl,--wrap=fprintf" "-Wl,--wrap=vprintf" "-Wl,--wrap=vfprintf" + "-Wl,--wrap=puts" "-Wl,--wrap=fputs" "-Wl,--wrap=putchar" "-Wl,--wrap=fputc" + "-Wl,--wrap=getchar" "-Wl,--wrap=fgetc" "-Wl,--wrap=fgets" + ) +endif () + target_link_libraries(TactilityTests PRIVATE TactilityKernel TactilityKernelCpp TactilityFreeRtos platform-posix + app-posix-module lvgl-module lvgl-window-manager-module app-module diff --git a/TactilityKernel/private/tactility/log_queue.h b/TactilityKernel/private/tactility/log_queue.h new file mode 100644 index 00000000..9b8482b1 --- /dev/null +++ b/TactilityKernel/private/tactility/log_queue.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Fixed capacity of one queued log line (256 bytes * 24-deep queue = 6KB, negligible against the +// hundreds of KB of free heap typically available - generous enough to hold a full log line with +// color/timestamp/tag prefix plus a realistic message, e.g. a full SD-card path in an error log). +#define LOG_QUEUE_MESSAGE_MAX_LENGTH 256U + +/** + * Starts the shared log queue and its dedicated drain (writer) task. Idempotent - safe to call + * more than once (only the first call has any effect). Must be called as early as possible in + * boot - see kernel_init.cpp, which calls this as its very first statement. + */ +void log_queue_init(void); + +/** + * Enqueues an already-fully-formatted, ready-to-write log line (NOT a printf-style format + * string) so the dedicated drain task can write it via a path that never goes through + * app-module's fd-table redirection (see Modules/app-module/source/io.cpp's app_io_write()) - + * this is the ONLY function log-producing code should call to reach the console; it must never + * itself call write()/printf()/etc, since those would be intercepted whenever called from an app + * instance's own task, which is exactly the bug this exists to structurally prevent. + * + * Non-blocking: if the queue is full, the message is dropped and a counter is incremented; the + * drain task prepends a "N messages dropped" notice before its next write. If called before + * log_queue_init() has run, performs a direct synchronous write instead of enqueueing - safe, + * since no app instance (and so no fd-table redirection) can exist yet that early in boot. + * Truncates messages longer than LOG_QUEUE_MESSAGE_MAX_LENGTH - 1. + */ +void log_queue_write(const char* data, size_t length); + +#ifdef __cplusplus +} +#endif diff --git a/TactilityKernel/source/kernel_init.cpp b/TactilityKernel/source/kernel_init.cpp index 359b5596..c31cc853 100644 --- a/TactilityKernel/source/kernel_init.cpp +++ b/TactilityKernel/source/kernel_init.cpp @@ -2,6 +2,7 @@ #include #include +#include #ifdef __cplusplus extern "C" { @@ -46,6 +47,8 @@ Module kernel_module = { }; error_t kernel_init(Module* const dts_modules[], const DtsDevice dts_devices[]) { + log_queue_init(); + LOG_I(TAG, "init"); if (module_construct_add_start(&kernel_module) != ERROR_NONE) { diff --git a/TactilityKernel/source/log.cpp b/TactilityKernel/source/log.cpp index 84ac4371..adf784ed 100644 --- a/TactilityKernel/source/log.cpp +++ b/TactilityKernel/source/log.cpp @@ -3,6 +3,7 @@ #ifndef ESP_PLATFORM #include +#include #include #include @@ -11,7 +12,11 @@ #include #include -static const char* get_log_color(LogLevel level) { +namespace { + +constexpr auto MINIMUM_LOG_LEVEL = LOG_LEVEL_DEBUG; + +const char* get_log_color(LogLevel level) { using enum LogLevel; switch (level) { case LOG_LEVEL_ERROR: @@ -29,7 +34,7 @@ static const char* get_log_color(LogLevel level) { } } -static inline char get_log_prefix(LogLevel level) { +inline char get_log_prefix(LogLevel level) { using enum LogLevel; switch (level) { case LOG_LEVEL_ERROR: @@ -47,7 +52,7 @@ static inline char get_log_prefix(LogLevel level) { } } -static uint64_t get_log_timestamp() { +uint64_t get_log_timestamp() { static uint64_t base = 0U; static std::once_flag init_flag; std::call_once(init_flag, []() { @@ -61,15 +66,42 @@ static uint64_t get_log_timestamp() { return now - base; } +} + extern "C" { void log_generic(enum LogLevel level, const char* tag, const char* format, ...) { - va_list args; - va_start(args, format); - printf("%s %c (%" PRIu64 ") %s ", get_log_color(level), get_log_prefix(level), get_log_timestamp(), tag); - vprintf(format, args); - printf("\033[0m\n"); - va_end(args); + if (MINIMUM_LOG_LEVEL >= level) { + char buffer[LOG_QUEUE_MESSAGE_MAX_LENGTH]; + size_t offset = 0; + + int prefix_len = snprintf(buffer, sizeof(buffer), "%s %c (%" PRIu64 ") %s ", + get_log_color(level), get_log_prefix(level), get_log_timestamp(), tag); + if (prefix_len > 0) { + offset = static_cast(prefix_len) < sizeof(buffer) ? static_cast(prefix_len) : sizeof(buffer) - 1; + } + + if (offset < sizeof(buffer)) { + va_list args; + va_start(args, format); + int written = vsnprintf(buffer + offset, sizeof(buffer) - offset, format, args); + va_end(args); + if (written > 0) { + size_t remaining = sizeof(buffer) - offset; + offset += static_cast(written) < remaining ? static_cast(written) : remaining - 1; + } + } + + if (offset < sizeof(buffer)) { + int tail_len = snprintf(buffer + offset, sizeof(buffer) - offset, "\033[0m\n"); + if (tail_len > 0) { + size_t remaining = sizeof(buffer) - offset; + offset += static_cast(tail_len) < remaining ? static_cast(tail_len) : remaining - 1; + } + } + + log_queue_write(buffer, offset); + } } } diff --git a/TactilityKernel/source/log_queue.cpp b/TactilityKernel/source/log_queue.cpp new file mode 100644 index 00000000..0ffeef5e --- /dev/null +++ b/TactilityKernel/source/log_queue.cpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include +#include + +#include +#include +#include +#include + +#if defined(ESP_PLATFORM) +#include +#endif + +namespace { + +constexpr size_t LOG_QUEUE_DEPTH = 8; +constexpr size_t DRAIN_TASK_STACK_DEPTH = 4096 / sizeof(StackType_t); + +struct LogQueueMessage { + uint16_t length; + char text[LOG_QUEUE_MESSAGE_MAX_LENGTH]; +}; + +std::atomic g_queue { nullptr }; + +// The one and only place a real console write happens for log output - deliberately never +// through app_io_write()'s fd-table redirection (Modules/app-module/source/io.cpp). That +// redirection only ever triggers for a task app_task_main() (Modules/app-module/source/ +// scheduler.cpp) set up as a running app instance; this drain task is never that, so +// app_scheduler_current_app_id() always reads 0 on it and app_io_write()'s lookup is skipped +// unconditionally - a plain write() already reaches the real syscall, on every platform, with no +// wrap-bypassing needed here. +void write_real(const char* data, size_t length) { + if (length == 0) { + return; + } +#if defined(ESP_PLATFORM) + constexpr int fd = 1; // ESP-IDF's default console fd +#else + constexpr int fd = 2; // matches log_generic()'s stderr choice +#endif + ::write(fd, data, length); +} + +void drain_task_main(void* context) { + // The queue handle is already valid by the time this task starts (it's created before the + // task is), but reading it back from g_queue here would race: g_queue is only published + // (log_queue_init()'s g_queue.store()) AFTER task creation returns, so this task can start + // running before that store happens. Take it directly as the task's own argument instead. + auto queue = static_cast(context); + LogQueueMessage message; + while (true) { + if (xQueueReceive(queue, &message, portMAX_DELAY) == pdTRUE) { + write_real(message.text, message.length); + } + } +} + +#if defined(ESP_PLATFORM) +// ESP-IDF's own log macros pre-format the entire line (color, level letter, timestamp, tag, +// message, color reset, newline - see esp_log_write()/LOG_FORMAT() in esp_log.h) before handing +// it to the installed vprintf_like_t as fmt+args, so this hook only needs one vsnprintf - no +// prefix building of its own (unlike log_generic() in log.cpp, which builds its own prefix). +// Replaces (does not chain through) the previously-installed vprintf: the whole point is +// removing the direct-to-stdout path, not adding a second consumer of it. +int log_queue_vprintf_hook(const char* format, va_list args) { + char buffer[LOG_QUEUE_MESSAGE_MAX_LENGTH]; + int written = vsnprintf(buffer, sizeof(buffer), format, args); + if (written > 0) { + size_t length = static_cast(written) < sizeof(buffer) ? static_cast(written) : sizeof(buffer) - 1; + log_queue_write(buffer, length); + } + return written; +} +#endif + +} // namespace + +extern "C" { + +void log_queue_init(void) { + if (g_queue.load(std::memory_order_relaxed) != nullptr) { + return; // already initialized + } + +#if defined(ESP_PLATFORM) + // Message storage: prefer PSRAM/external memory, fall back to internal automatically if + // unavailable (MemoryPolicy's documented semantics) - this is the bulk of the queue's memory + // (24 * 256 bytes), so it's the part worth offloading to PSRAM when there is any. + MemoryPolicy storage_policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + auto* queue_storage = static_cast(memory_alloc_with_policy(LOG_QUEUE_DEPTH * sizeof(LogQueueMessage), &storage_policy)); + if (queue_storage == nullptr) { + return; + } + + // The queue's own control block and the drain task's TCB are both small, fixed-size kernel + // objects (not the bulk data) - keep them in internal RAM like scheduler.cpp's + // APP_TASK_TCB_POLICY already does for app instance tasks. + MemoryPolicy internal_policy = { .required = MEMORY_CAPABILITY_INTERNAL, .desired = 0, .alignment = 0 }; + + auto* queue_struct = static_cast(memory_alloc_with_policy(sizeof(StaticQueue_t), &internal_policy)); + if (queue_struct == nullptr) { + memory_free(queue_storage); + return; + } + + QueueHandle_t queue = xQueueCreateStatic(LOG_QUEUE_DEPTH, sizeof(LogQueueMessage), queue_storage, queue_struct); + if (queue == nullptr) { + memory_free(queue_struct); + memory_free(queue_storage); + return; + } + + // Stack: same PSRAM-preferred/internal-fallback policy as the message storage above. + MemoryPolicy stack_policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + auto* stack_buffer = static_cast(memory_alloc_with_policy(DRAIN_TASK_STACK_DEPTH * sizeof(StackType_t), &stack_policy)); + if (stack_buffer == nullptr) { + vQueueDelete(queue); + memory_free(queue_struct); + memory_free(queue_storage); + return; + } + + auto* task_tcb = static_cast(memory_alloc_with_policy(sizeof(StaticTask_t), &internal_policy)); + if (task_tcb == nullptr) { + memory_free(stack_buffer); + vQueueDelete(queue); + memory_free(queue_struct); + memory_free(queue_storage); + return; + } + + TaskHandle_t task_handle = xTaskCreateStatic(drain_task_main, "log_drain", DRAIN_TASK_STACK_DEPTH, queue, tskIDLE_PRIORITY + 1, stack_buffer, task_tcb); + if (task_handle == nullptr) { + memory_free(task_tcb); + memory_free(stack_buffer); + vQueueDelete(queue); + memory_free(queue_struct); + memory_free(queue_storage); + return; + } +#else + QueueHandle_t queue = xQueueCreate(LOG_QUEUE_DEPTH, sizeof(LogQueueMessage)); + if (queue == nullptr) { + return; + } + + TaskHandle_t task_handle = nullptr; + if (xTaskCreate(drain_task_main, "log_drain", DRAIN_TASK_STACK_DEPTH, queue, tskIDLE_PRIORITY + 1, &task_handle) != pdPASS) { + vQueueDelete(queue); + return; + } +#endif + + g_queue.store(queue, std::memory_order_release); // published last + +#if defined(ESP_PLATFORM) + esp_log_set_vprintf(log_queue_vprintf_hook); +#endif +} + +void log_queue_write(const char* data, size_t length) { + if (data == nullptr || length == 0) { + return; + } + + QueueHandle_t queue = g_queue.load(std::memory_order_acquire); + if (queue == nullptr) { + write_real(data, length); // pre-init fallback + return; + } + + LogQueueMessage message; + size_t copy_length = length < sizeof(message.text) ? length : sizeof(message.text) - 1; + memcpy(message.text, data, copy_length); + message.length = static_cast(copy_length); + + xQueueSend(queue, &message, portMAX_DELAY); +} + +} // extern "C" diff --git a/TactilityKernel/source/paths.cpp b/TactilityKernel/source/paths.cpp index 3319c630..4da7e680 100644 --- a/TactilityKernel/source/paths.cpp +++ b/TactilityKernel/source/paths.cpp @@ -42,7 +42,6 @@ static error_t paths_get_data_root_path(char* out_path, size_t out_path_size) { extern "C" { error_t paths_get_data_path(char* out_path, size_t out_path_size) { -#ifdef ESP_PLATFORM char root[64]; error_t error = paths_get_data_root_path(root, sizeof(root)); if (error != ERROR_NONE) { @@ -53,14 +52,6 @@ error_t paths_get_data_path(char* out_path, size_t out_path_size) { return ERROR_BUFFER_OVERFLOW; } return ERROR_NONE; -#else - const char* fixed_path = "data"; - if (std::strlen(fixed_path) + 1 > out_path_size) { - return ERROR_BUFFER_OVERFLOW; - } - std::strcpy(out_path, fixed_path); - return ERROR_NONE; -#endif } } // extern "C" diff --git a/TactilityKernel/tests/source/paths_test.cpp b/TactilityKernel/tests/source/paths_test.cpp index e851dfe6..0c79fd5b 100644 --- a/TactilityKernel/tests/source/paths_test.cpp +++ b/TactilityKernel/tests/source/paths_test.cpp @@ -5,22 +5,22 @@ #include // The simulator target is never built with ESP_PLATFORM, so paths_get_data_path() -// always takes the fixed "data" path branch here, guarded by a buffer-size check. +// always takes the fixed "data" root, with "/tactility" appended, guarded by a buffer-size check. TEST_CASE("paths_get_data_path succeeds when the buffer exactly fits") { - char buffer[16] = { 0 }; + char buffer[32] = { 0 }; CHECK_EQ(paths_get_data_path(buffer, sizeof(buffer)), ERROR_NONE); - CHECK_EQ(std::strcmp(buffer, "data"), 0); + CHECK_EQ(std::strcmp(buffer, "data/tactility"), 0); } TEST_CASE("paths_get_data_path succeeds with a buffer sized to exactly fit the string and terminator") { - char buffer[5] = { 0 }; // strlen("data") + 1 + char buffer[15] = { 0 }; // strlen("data/tactility") + 1 CHECK_EQ(paths_get_data_path(buffer, sizeof(buffer)), ERROR_NONE); - CHECK_EQ(std::strcmp(buffer, "data"), 0); + CHECK_EQ(std::strcmp(buffer, "data/tactility"), 0); } TEST_CASE("paths_get_data_path reports a buffer overflow when the buffer is one byte too small") { - char buffer[4] = { 0 }; // strlen("data"), no room for the terminator + char buffer[14] = { 0 }; // strlen("data/tactility"), no room for the terminator CHECK_EQ(paths_get_data_path(buffer, sizeof(buffer)), ERROR_BUFFER_OVERFLOW); } diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index de535ee9..b5b36a8d 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -9,6 +9,7 @@ add_subdirectory(${CMAKE_SOURCE_DIR}/TactilityKernel/tests ${CMAKE_CURRENT_BINAR add_subdirectory(${CMAKE_SOURCE_DIR}/Tactility/Tests ${CMAKE_CURRENT_BINARY_DIR}/Tactility) add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/crypt-module/tests ${CMAKE_CURRENT_BINARY_DIR}/crypt-module) add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-module) +add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/app-posix-module/tests ${CMAKE_CURRENT_BINARY_DIR}/app-posix-module) add_subdirectory(${CMAKE_SOURCE_DIR}/Modules/http-module/tests ${CMAKE_CURRENT_BINARY_DIR}/http-module) add_custom_target(build-tests) @@ -18,4 +19,5 @@ add_dependencies(build-tests TactilityTests) add_dependencies(build-tests TactilityKernelTests) add_dependencies(build-tests CryptModuleTests) add_dependencies(build-tests AppModuleTests) +add_dependencies(build-tests AppPosixModuleTests) add_dependencies(build-tests HttpModuleTests) diff --git a/Tests/SdkIntegration/CMakeLists.txt b/Tests/SdkIntegration/CMakeLists.txt index 23669c5d..945da106 100644 --- a/Tests/SdkIntegration/CMakeLists.txt +++ b/Tests/SdkIntegration/CMakeLists.txt @@ -1,16 +1,13 @@ +# tactility-cmakelists-version: 1 cmake_minimum_required(VERSION 3.20) -include($ENV{IDF_PATH}/tools/cmake/project.cmake) - -if (DEFINED ENV{TACTILITY_SDK_PATH}) - set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH}) -else() - set(TACTILITY_SDK_PATH ../../release/TactilitySDK) - message(WARNING "TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}") +if (NOT DEFINED ENV{TACTILITY_SDK_PATH}) + message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set") endif() +get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE) include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH} ${TACTILITY_SDK_PATH}/Modules) -project(SdkTest) -tactility_project(SdkTest) +tactility_project_pre(tactility.sdktest) +project(tactility.sdktest) +tactility_project_post(tactility.sdktest) diff --git a/Tests/SdkIntegration/main/CMakeLists.txt b/Tests/SdkIntegration/main/CMakeLists.txt index 29dce186..075f19a1 100644 --- a/Tests/SdkIntegration/main/CMakeLists.txt +++ b/Tests/SdkIntegration/main/CMakeLists.txt @@ -1,7 +1,7 @@ -file(GLOB_RECURSE SOURCE_FILES Source/*.c) +if (NOT DEFINED TACTILITY_SDK_PATH) + get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_LIST_DIR}/..") +endif () +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") -idf_component_register( - SRCS ${SOURCE_FILES} - REQUIRES TactilitySDK - app-module crypt-module gps-module lvgl-module lvgl-window-manager-module service-module -) +file(GLOB_RECURSE SOURCE_FILES Source/*.c) +tactility_component_register(SRCS ${SOURCE_FILES} INCLUDE_DIRS include) \ No newline at end of file diff --git a/Tests/SdkIntegration/main/Source/main.c b/Tests/SdkIntegration/main/Source/main.c index 54db9bc6..a2795ee6 100644 --- a/Tests/SdkIntegration/main/Source/main.c +++ b/Tests/SdkIntegration/main/Source/main.c @@ -9,6 +9,8 @@ #include #include +#include + #include static void create_widgets(lv_obj_t* parent, void* userData) { @@ -16,7 +18,7 @@ static void create_widgets(lv_obj_t* parent, void* userData) { lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_t* label = lv_label_create(parent); - lv_label_set_text(label, "Hello, world!"); + lv_label_set_text(label, SDK_TEST_GREETING); lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); } diff --git a/Tests/SdkIntegration/main/include/greeting.h b/Tests/SdkIntegration/main/include/greeting.h new file mode 100644 index 00000000..803c2abb --- /dev/null +++ b/Tests/SdkIntegration/main/include/greeting.h @@ -0,0 +1,5 @@ +#pragma once + +// Only exists to prove tactility_component_register()'s INCLUDE_DIRS parameter resolves this +// directory on both platforms - see main/CMakeLists.txt. +#define SDK_TEST_GREETING "Hello, world!" diff --git a/Tests/SdkIntegration/manifest.properties b/Tests/SdkIntegration/manifest.properties index bd6454d7..2f57201b 100644 --- a/Tests/SdkIntegration/manifest.properties +++ b/Tests/SdkIntegration/manifest.properties @@ -1,7 +1,7 @@ manifest.version=0.2 target.sdk=0.0.0 -target.platforms=esp32,esp32s3,esp32c6,esp32p4 -app.id=one.tactility.sdktest +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 diff --git a/Tests/SdkIntegration/tactility.py b/Tests/SdkIntegration/tactility.py index 769de53a..f57d2fef 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 = "4.1.0" +ttbuild_version = "5.0.0" ttbuild_cdn = "https://cdn.tactilityproject.org" ttbuild_sdk_json_validity = 3600 # seconds ttport = 6666 @@ -20,6 +20,10 @@ verbose = False 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 +# easily take well over http_timeout_seconds on a slow SD card, so give it a lot more room. +install_timeout_seconds = 120 shell_color_red = "\033[91m" shell_color_orange = "\033[93m" @@ -183,24 +187,26 @@ def update_tool_json(): 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 + if not platform.startswith("posix"): + 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(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}/sdk/{sdkconfig_filename}", target_path): - exit_with_error(f"Failed to download sdkconfig file for {platform}") + if not platform.startswith("posix"): + sdkconfig_filename = f"sdkconfig.app.{platform}" + target_path = os.path.join(ttbuild_path, sdkconfig_filename) + if not download_file(f"{ttbuild_cdn}/sdk/{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: +def validate_environment(platforms): + if any(not platform.startswith("posix") for platform in platforms) and os.environ.get("IDF_PATH") is None: if sys.platform == "win32": exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via %IDF_PATH%\\export.ps1") else: @@ -311,6 +317,44 @@ def sdk_download_all(version, platforms): #endregion SDK download +#region CMakeLists scaffolding + +# Bump whenever CMAKELISTS_TEMPLATE below changes, so existing apps' generated CMakeLists.txt get +# regenerated on their next build instead of silently going stale. +CMAKELISTS_VERSION = 1 + +CMAKELISTS_TEMPLATE = """# tactility-cmakelists-version: %(version)d +cmake_minimum_required(VERSION 3.20) + +if (NOT DEFINED ENV{TACTILITY_SDK_PATH}) + message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set") +endif() + +get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE) +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") + +tactility_project_pre(%(app_id)s) +project(%(app_id)s) +tactility_project_post(%(app_id)s) +""" + +def cmakelists_version_marker(): + return f"# tactility-cmakelists-version: {CMAKELISTS_VERSION}" + +def ensure_cmakelists_up_to_date(manifest): + marker = cmakelists_version_marker() + if os.path.exists("CMakeLists.txt"): + with open("CMakeLists.txt", "r") as file: + first_line = file.readline().rstrip("\n") + if first_line == marker: + return + print(f"Updating CMakeLists.txt to {marker}") + content = CMAKELISTS_TEMPLATE % {"version": CMAKELISTS_VERSION, "app_id": manifest["app.id"]} + with open("CMakeLists.txt", "w") as file: + file.write(content) + +#endregion CMakeLists scaffolding + #region Building def get_cmake_path(platform): @@ -318,18 +362,65 @@ def get_cmake_path(platform): def find_elf_file(platform): 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(cmake_dir, file) + if not os.path.exists(cmake_dir): + return None + # 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 "*.app.elf". + suffix = ".so" if platform.startswith("posix") else ".app.elf" + for file in os.listdir(cmake_dir): + if file.endswith(suffix): + return os.path.join(cmake_dir, file) return None +def get_posix_build_env(sdk_dir): + # A dev shell may already have ESP-IDF's export.sh sourced; strip it so the SDK's plain-CMake + # top-level CMakeLists.txt takes the POSIX branch instead of the idf.py one. + env = os.environ.copy() + env.pop("ESP_IDF_VERSION", None) + env.pop("IDF_PATH", None) + env["TACTILITY_SDK_PATH"] = sdk_dir + return env + +def build_posix(version, platform, skip_build): + sdk_dir = get_sdk_dir(version, platform) + if verbose: + print(f"Using SDK at {sdk_dir}") + if skip_build: + return True + env = get_posix_build_env(sdk_dir) + cmake_path = get_cmake_path(platform) + print_status_busy(f"Building {platform}") + configure_command = ["cmake", "-S", ".", "-B", cmake_path, "-G", "Ninja"] + if verbose: + print(f"Running command: {' '.join(configure_command)}") + configure_result = subprocess.run(configure_command, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if configure_result.returncode != 0: + print(configure_result.stdout.decode("UTF-8"), end="") + print_status_error(f"Configuring {platform}") + return False + build_command = ["cmake", "--build", cmake_path] + if verbose: + print(f"Running command: {' '.join(build_command)}") + with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) as process: + build_output = wait_for_process(process) + if process.returncode == 0: + print_status_success(f"Building {platform}") + return True + else: + for line in build_output: + print(line, end="") + print_status_error(f"Building {platform}") + return False + def build_all(version, platforms, skip_build): for platform in platforms: + if platform.startswith("posix"): + if not build_posix(version, platform, skip_build): + return False # First build command must be "idf.py build", otherwise it fails to execute "idf.py elf" # We check if the ELF file exists and run the correct command # This can lead to code caching issues, so sometimes a clean build is required - if find_elf_file(platform) is None: + elif find_elf_file(platform) is None: if not build_first(version, platform, skip_build): return False else: @@ -368,7 +459,8 @@ def build_first(version, platform, skip_build): print(f"Using SDK at {sdk_dir}") os.environ["TACTILITY_SDK_PATH"] = sdk_dir sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}") - shutil.copy(sdkconfig_path, "sdkconfig") + if not platform.startswith("posix"): + shutil.copy(sdkconfig_path, "sdkconfig") elf_path = find_elf_file(platform) # Remove previous elf file: re-creation of the file is used to measure if the build succeeded, # as the actual build job will always fail due to technical issues with the elf cmake script @@ -444,7 +536,10 @@ def package_intermediate_binaries(target_path, platforms): if elf_path is None: print_error(f"ELF file not found for {platform}") return False - shutil.copy(elf_path, os.path.join(elf_dir, f"{platform}.elf")) + # 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}")) return True def package_intermediate_assets(target_path): @@ -463,12 +558,10 @@ def package_intermediate(platforms): package_intermediate_assets(target_path) return True -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_name(manifest): + return os.path.join("build", f"{manifest['app.id']}.app") -def package_all(platforms): +def package_all(manifest, platforms): status = f"Building package with {platforms}" print_status_busy(status) if not package_intermediate(platforms): @@ -476,7 +569,7 @@ def package_all(platforms): return False # Create build/something.app try: - tar_path = package_name(platforms) + tar_path = package_name(manifest) with tarfile.open(tar_path, mode="w", format=tarfile.USTAR_FORMAT) as tar: tar.add(os.path.join("build", "package-intermediate"), arcname="") print_status_success(status) @@ -492,10 +585,11 @@ def setup_environment(): os.makedirs(ttbuild_path, exist_ok=True) def build_action(manifest, platform_arg, skip_build): - # Environment validation - validate_environment() + ensure_cmakelists_up_to_date(manifest) platforms_to_build = get_manifest_target_platforms(manifest, platform_arg) - + # Environment validation + validate_environment(platforms_to_build) + if use_local_sdk: global local_base_path local_base_path = os.environ.get("TACTILITY_SDK_PATH") @@ -515,7 +609,7 @@ def build_action(manifest, platform_arg, skip_build): if not build_all(sdk_version, platforms_to_build, skip_build): # Environment validation return False if not skip_build: - if not package_all(platforms_to_build): + if not package_all(manifest, platforms_to_build): return False return True @@ -570,14 +664,14 @@ def run_action(manifest, ip): except requests.RequestException as e: print_status_error(f"Running request failed: {e}") -def install_action(ip, platforms): +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 - package_path = package_name(platforms) + package_path = package_name(manifest) # print(f"Installing {package_path} to {ip}") url = get_url(ip, "/app/install") try: @@ -586,7 +680,7 @@ def install_action(ip, platforms): files = { 'elf': file } - response = requests.put(url, files=files, timeout=http_timeout_seconds) + response = requests.put(url, files=files, timeout=install_timeout_seconds) if response.status_code != 200: print_status_error("Install failed") return False @@ -691,7 +785,7 @@ if __name__ == "__main__": if len(sys.argv) >= 4: platform = sys.argv[3] platforms_to_install = [platform] - install_action(sys.argv[2], platforms_to_install) + install_action(manifest, sys.argv[2], platforms_to_install) elif action_arg == "uninstall": if len(sys.argv) < 3: print_help() @@ -707,7 +801,7 @@ if __name__ == "__main__": platform = sys.argv[3] platforms_to_install = [platform] if build_action(manifest, platform, skip_build): - if install_action(sys.argv[2], platforms_to_install): + if install_action(manifest, sys.argv[2], platforms_to_install): run_action(manifest, sys.argv[2]) else: print_help()