diff --git a/Buildscripts/TactilitySDK/CMakeLists.txt b/Buildscripts/TactilitySDK/CMakeLists.txt
index 3be1ba3ef..6fd65e2a8 100644
--- a/Buildscripts/TactilitySDK/CMakeLists.txt
+++ b/Buildscripts/TactilitySDK/CMakeLists.txt
@@ -2,12 +2,12 @@ idf_component_register(
INCLUDE_DIRS
"Libraries/TactilityC/include"
"Libraries/TactilityKernel/include"
- "Libraries/TactilityFreeRtos/include"
+ "Libraries/TactilityFreeRtos/Include"
"Libraries/lvgl/include"
"Libraries/minmea/include"
+ "Libraries/minitar/include"
"Modules/lvgl-module/include"
- # DRIVER_INCLUDE_DIRS_PLACEHOLDER
- REQUIRES esp_timer
+ REQUIRES esp_timer app-module crypt-module gps-module lvgl-module lvgl-window-manager-module service-module
)
# Regular and core features
@@ -15,8 +15,10 @@ add_prebuilt_library(TactilityC Libraries/TactilityC/binary/libTactilityC.a)
add_prebuilt_library(TactilityKernel Libraries/TactilityKernel/binary/libTactilityKernel.a)
add_prebuilt_library(lvgl Libraries/lvgl/binary/liblvgl.a)
add_prebuilt_library(minmea Libraries/minmea/binary/libminmea.a)
+add_prebuilt_library(minitar Libraries/minitar/binary/libminitar.a)
target_link_libraries(${COMPONENT_LIB} INTERFACE TactilityC)
target_link_libraries(${COMPONENT_LIB} INTERFACE TactilityKernel)
target_link_libraries(${COMPONENT_LIB} INTERFACE lvgl)
target_link_libraries(${COMPONENT_LIB} INTERFACE minmea)
+target_link_libraries(${COMPONENT_LIB} INTERFACE minitar)
diff --git a/Buildscripts/TactilitySDK/TactilitySDK.cmake b/Buildscripts/TactilitySDK/TactilitySDK.cmake
index 71af31dd7..fe60e8c7c 100644
--- a/Buildscripts/TactilitySDK/TactilitySDK.cmake
+++ b/Buildscripts/TactilitySDK/TactilitySDK.cmake
@@ -18,14 +18,18 @@ macro(tactility_project project_name)
endif()
set(EXTRA_COMPONENT_DIRS
- "Libraries/TactilityFreeRtos"
- "Modules"
- "Drivers"
+ "${TACTILITY_SDK_PATH}/Libraries/TactilityFreeRtos"
+ "${TACTILITY_SDK_PATH}/Modules"
)
set(COMPONENTS
TactilityFreeRtos
- # DRIVER_COMPONENTS_PLACEHOLDER
+ app-module
+ crypt-module
+ gps-module
+ lvgl-module
+ lvgl-window-manager-module
+ service-module
)
endmacro()
diff --git a/Buildscripts/release-sdk.py b/Buildscripts/release-sdk.py
index 338546621..c530a4520 100644
--- a/Buildscripts/release-sdk.py
+++ b/Buildscripts/release-sdk.py
@@ -111,43 +111,13 @@ def add_module(target_path, module_name):
cmakelists_content = create_module_cmakelists(module_name)
write_module_cmakelists(os.path.join(target_path, f"Modules/{module_name}/CMakeLists.txt"), cmakelists_content)
-def discover_all_drivers():
- """
- Discover all *-module directories under Drivers/ (not Modules/ - those are handled
- separately via add_module). Sorted for deterministic output across OS/filesystem order.
- """
- pattern = os.path.join('Drivers', '*-module')
- return sorted(
- os.path.basename(p) for p in glob.glob(pattern) if os.path.isdir(p)
- )
-
-def generate_tactility_sdk_cmake(target_path, available_drivers):
+def generate_tactility_sdk_cmake(target_path):
src = os.path.join('Buildscripts', 'TactilitySDK', 'TactilitySDK.cmake')
- with open(src) as f:
- content = f.read()
- placeholder = " # DRIVER_COMPONENTS_PLACEHOLDER"
- assert placeholder in content, \
- f"Placeholder '{placeholder.strip()}' not found in {src} - template drifted, generator needs updating"
- components = "\n".join(f" {d}" for d in available_drivers)
- new_content = content.replace(placeholder, components)
- assert placeholder not in new_content, \
- f"Placeholder '{placeholder.strip()}' still present after replacement in {src}"
- with open(os.path.join(target_path, 'TactilitySDK.cmake'), 'w') as f:
- f.write(new_content)
-
-def generate_tactility_sdk_top_cmakelists(target_path, available_drivers):
+ 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')
- with open(src) as f:
- content = f.read()
- placeholder = " # DRIVER_INCLUDE_DIRS_PLACEHOLDER"
- assert placeholder in content, \
- f"Placeholder '{placeholder.strip()}' not found in {src} - template drifted, generator needs updating"
- include_dirs = "\n".join(f' "Drivers/{d}/include"' for d in available_drivers)
- new_content = content.replace(placeholder, include_dirs)
- assert placeholder not in new_content, \
- f"Placeholder '{placeholder.strip()}' still present after replacement in {src}"
- with open(os.path.join(target_path, 'CMakeLists.txt'), 'w') as f:
- f.write(new_content)
+ shutil.copy2(src, os.path.join(target_path, 'CMakeLists.txt'))
def main():
if len(sys.argv) < 2:
@@ -167,7 +137,7 @@ def main():
{'src': 'TactilityC/CMakeLists.txt', 'dst': 'Libraries/TactilityC/'},
{'src': 'TactilityC/LICENSE*.*', 'dst': 'Libraries/TactilityC/'},
# TactilityFreeRtos
- {'src': 'TactilityFreeRtos/Include/**', 'dst': 'Libraries/TactilityFreeRtos/include/'},
+ {'src': 'TactilityFreeRtos/Include/**', 'dst': 'Libraries/TactilityFreeRtos/Include/'},
{'src': 'TactilityFreeRtos/CMakeLists.txt', 'dst': 'Libraries/TactilityFreeRtos/'},
{'src': 'TactilityFreeRtos/LICENSE*.*', 'dst': 'Libraries/TactilityFreeRtos/'},
# TactilityKernel
@@ -185,6 +155,10 @@ def main():
# elf_loader
{'src': 'Libraries/elf_loader/elf_loader.cmake', 'dst': 'Libraries/elf_loader/'},
{'src': 'Libraries/elf_loader/license.txt', 'dst': 'Libraries/elf_loader/'},
+ # minitar
+ {'src': 'build/esp-idf/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': 'build/esp-idf/minmea/libminmea.a', 'dst': 'Libraries/minmea/binary/'},
{'src': 'Libraries/minmea/Include/**', 'dst': 'Libraries/minmea/include/'},
@@ -197,21 +171,16 @@ def main():
map_copy(mappings, target_path)
# Modules
- add_module(target_path, "lvgl-module")
+ add_module(target_path, "app-module")
add_module(target_path, "crypt-module")
add_module(target_path, "gps-module")
+ add_module(target_path, "lvgl-module")
+ add_module(target_path, "lvgl-window-manager-module")
add_module(target_path, "service-module")
- # Drivers - only ones actually built for this target (chip-restricted drivers like
- # sc2356-module won't have a .a outside ESP32-P4)
- available_drivers = [d for d in discover_all_drivers() if driver_is_available(d)]
- for driver_name in available_drivers:
- add_driver(target_path, driver_name)
-
- # Final scripts - generated (not copied verbatim) so COMPONENTS/INCLUDE_DIRS only list
- # drivers actually available for this target
- generate_tactility_sdk_cmake(target_path, available_drivers)
- generate_tactility_sdk_top_cmakelists(target_path, available_drivers)
+ # Final scripts - copied verbatim
+ generate_tactility_sdk_cmake(target_path)
+ generate_tactility_sdk_top_cmakelists(target_path)
# Output ESP-IDF SDK version to file
esp_idf_version = os.environ.get("ESP_IDF_VERSION", "")
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 195f180a2..6c6152f73 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -100,6 +100,8 @@ if (NOT DEFINED ENV{ESP_IDF_VERSION})
add_subdirectory(Modules/crypt-module)
add_subdirectory(Modules/gps-module)
add_subdirectory(Modules/service-module)
+ add_subdirectory(Modules/app-module)
+ add_subdirectory(Modules/lvgl-window-manager-module)
add_subdirectory(Drivers/gps-generic-module)
add_subdirectory(Drivers/gps-meshtastic-module)
diff --git a/Devices/generic-esp32/devicetree.yaml b/Devices/generic-esp32/devicetree.yaml
index 577f8a6e3..6a9f8cbbe 100644
--- a/Devices/generic-esp32/devicetree.yaml
+++ b/Devices/generic-esp32/devicetree.yaml
@@ -1,10 +1,3 @@
dependencies:
- Platforms/platform-esp32
- # Add all driver modules because the generic devices are used to build the SDK
- - Drivers/bm8563-module
- - Drivers/bmi270-module
- - Drivers/mpu6886-module
- - Drivers/pi4ioe5v6408-module
- - Drivers/qmi8658-module
- - Drivers/rx8130ce-module
dts: generic,esp32.dts
diff --git a/Devices/generic-esp32c6/devicetree.yaml b/Devices/generic-esp32c6/devicetree.yaml
index 574c81953..3b5ab4d4c 100644
--- a/Devices/generic-esp32c6/devicetree.yaml
+++ b/Devices/generic-esp32c6/devicetree.yaml
@@ -1,10 +1,3 @@
dependencies:
- Platforms/platform-esp32
- # Add all driver modules because the generic devices are used to build the SDK
- - Drivers/bm8563-module
- - Drivers/bmi270-module
- - Drivers/mpu6886-module
- - Drivers/pi4ioe5v6408-module
- - Drivers/qmi8658-module
- - Drivers/rx8130ce-module
dts: generic,esp32c6.dts
diff --git a/Devices/generic-esp32p4/devicetree.yaml b/Devices/generic-esp32p4/devicetree.yaml
index c700c1ced..4c31671aa 100644
--- a/Devices/generic-esp32p4/devicetree.yaml
+++ b/Devices/generic-esp32p4/devicetree.yaml
@@ -1,11 +1,3 @@
dependencies:
- Platforms/platform-esp32
- # Add all driver modules because the generic devices are used to build the SDK
- - Drivers/bm8563-module
- - Drivers/bmi270-module
- - Drivers/mpu6886-module
- - Drivers/pi4ioe5v6408-module
- - Drivers/qmi8658-module
- - Drivers/rx8130ce-module
- - Drivers/sc2356-module
dts: generic,esp32p4.dts
diff --git a/Devices/generic-esp32s3/devicetree.yaml b/Devices/generic-esp32s3/devicetree.yaml
index 7dac4215e..1a525a8ad 100644
--- a/Devices/generic-esp32s3/devicetree.yaml
+++ b/Devices/generic-esp32s3/devicetree.yaml
@@ -1,10 +1,3 @@
dependencies:
- Platforms/platform-esp32
- # Add all driver modules because the generic devices are used to build the SDK
- - Drivers/bm8563-module
- - Drivers/bmi270-module
- - Drivers/mpu6886-module
- - Drivers/pi4ioe5v6408-module
- - Drivers/qmi8658-module
- - Drivers/rx8130ce-module
dts: generic,esp32s3.dts
diff --git a/Devices/lilygo-tdeck-plus/device.properties b/Devices/lilygo-tdeck-plus/device.properties
index 3aaf6fbba..594b5fc50 100644
--- a/Devices/lilygo-tdeck-plus/device.properties
+++ b/Devices/lilygo-tdeck-plus/device.properties
@@ -23,3 +23,8 @@ cdn.infoMessage=To put the device into bootloader mode:
1. Press the trackb
lvgl.colorDepth=16
sdkconfig.CONFIG_CODEC_DUMMY_SUPPORT=y
+
+# Fix error "PSRAM space not enough for the Flash instructions" on boot:
+sdkconfig.CONFIG_SPIRAM_FETCH_INSTRUCTIONS=n
+sdkconfig.CONFIG_SPIRAM_RODATA=n
+sdkconfig.CONFIG_SPIRAM_XIP_FROM_PSRAM=n
diff --git a/Devices/lilygo-tlora-pager/source/module.cpp b/Devices/lilygo-tlora-pager/source/module.cpp
index 4f4b27aa3..ca93edd98 100644
--- a/Devices/lilygo-tlora-pager/source/module.cpp
+++ b/Devices/lilygo-tlora-pager/source/module.cpp
@@ -17,12 +17,12 @@ static void on_boot_completed(struct SystemEvent* /*event*/, void* /*context*/)
}
static error_t start() {
- system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, on_boot_completed, nullptr);
+ system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, on_boot_completed, nullptr);
return ERROR_NONE;
}
static error_t stop() {
- system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, on_boot_completed);
+ system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, on_boot_completed);
return ERROR_NONE;
}
diff --git a/Devices/m5stack-tab5/Source/devices/devices_v1.cpp b/Devices/m5stack-tab5/Source/devices/devices_v1.cpp
index 280f2ef99..5ba6025f3 100644
--- a/Devices/m5stack-tab5/Source/devices/devices_v1.cpp
+++ b/Devices/m5stack-tab5/Source/devices/devices_v1.cpp
@@ -67,6 +67,11 @@ static void create_gt911_touch(Device* i2c0) {
// Reset is pulsed via io_expander0 (detect.cpp's pulse_display_reset_pins), not a direct SoC GPIO.
.pin_reset = GPIO_PIN_SPEC_NONE,
.pin_interrupt = GPIO_PIN_SPEC_NONE,
+ .reset_pulses = 0, // no-op: pin_reset is NONE, so reset_controller_pin() skips anyway
+ .x_offset = 0,
+ .y_offset = 0,
+ .x_scale = 1000,
+ .y_scale = 1000,
};
gt911_device.config = >911_config;
diff --git a/Devices/m5stack-tab5/Source/devices/tab5_keyboard.cpp b/Devices/m5stack-tab5/Source/devices/tab5_keyboard.cpp
index 3de918af2..25ef0d004 100644
--- a/Devices/m5stack-tab5/Source/devices/tab5_keyboard.cpp
+++ b/Devices/m5stack-tab5/Source/devices/tab5_keyboard.cpp
@@ -575,6 +575,7 @@ static error_t tab5_keyboard_read_key(Device* device, KeyboardKeyData* data) {
static const KeyboardApi tab5_keyboard_api = {
.read_key = tab5_keyboard_read_key,
+ .is_present = tab5_keyboard_is_attached,
};
// Defined in module.cpp - this driver is registered directly by m5stack-tab5's own module,
diff --git a/Documentation/ideas.md b/Documentation/ideas.md
index f92dbe67f..4d5b07a04 100644
--- a/Documentation/ideas.md
+++ b/Documentation/ideas.md
@@ -12,6 +12,22 @@
## Higher Priority
+- Devices with a keyboard attached should always highlight the first widget (~Cardputer navigation issue), same for LV_INDEV_TYPE_ENCODER being present
+- Make it more clear to end-users that an SD card is required to run Tactility
+- Move "# Fix error "PSRAM space not enough for the Flash instructions" on boot:" fix from T-Deck and others to device.py
+- Make it possible to override stack size for an app via config file (loaded at boot), and make it possible to set preferred memory location (e.g. internal/external)
+- Put task stacks in PSRAM when possible.
+- Wrap file operations like fopen/fclose with file_mutex
+- Add bold fonts for e-ink readability improvement
+- Split up Claude instructions: https://code.claude.com/docs/en/memory#import-additional-files
+ and add https://github.com/multica-ai/andrej-karpathy-skills/blob/main/CLAUDE.md
+- Move test projects to their relevant subproject
+- tt_alertdialog start() etc is broken as it can't fetch the app instance id. Fetch automatically via thread context?
+- Migrate Tactility/Paths.cpp functions to TactilityKernel
+- app_manager_find_manifest() should make a copy, not return a pointer.
+- Httpd.cpp: warn if running on same CPU core (or task) as UI/LVGL/window manager.
+- Improve Setup: Show "Step done" screen
+- Improve Setup: Add keyboard/keypad navigation explanation
- display.h API: get_backlight does not change ref counting, but it should
- bluetooth: various getters for child devices do not change ref counting, but they should
- Improve kernel_init.cpp (and other modules): create driver_ensure_added() and driver_ensure_destructed()
@@ -38,6 +54,9 @@
## Medium Priority
+- Consider moving certain drivers into separate modules: audio, bt, wifi, etc
+- Consider using https://github.com/Graphify-Labs/graphify
+- Consider implementing LVGL gridnav in apps https://lvgl.io/docs/open/9.3/details/auxiliary-modules/gridnav.html
- Implement a LED kernel driver (single colour and RGB, plain GPIO and PWM)
- Make USB host driver disabled by default, so it doesn't consume memory
- Filtering for apps in App Hub:
diff --git a/Firmware/CMakeLists.txt b/Firmware/CMakeLists.txt
index 5efbbefe6..ea82c6574 100644
--- a/Firmware/CMakeLists.txt
+++ b/Firmware/CMakeLists.txt
@@ -96,6 +96,8 @@ else ()
Tactility
TactilityFreeRtos
lvgl-module
+ lvgl-window-manager-module
+ app-module
crypt-module
gps-module
gps-generic-module
diff --git a/Libraries/QRCode/CMakeLists.txt b/Libraries/QRCode/CMakeLists.txt
index dc61e7d60..b36264cff 100644
--- a/Libraries/QRCode/CMakeLists.txt
+++ b/Libraries/QRCode/CMakeLists.txt
@@ -25,4 +25,9 @@ else()
target_include_directories(QRCode
PUBLIC src
)
+
+ # qrcode.h polyfills bool/true/false for pre-C23 compilers - on a host compiler that
+ # defaults to C23 (where bool is a keyword), that polyfill itself fails to compile. Pin to
+ # C11 for the simulator build only; ESP-IDF's own toolchain default is unaffected.
+ set_target_properties(QRCode PROPERTIES C_STANDARD 11 C_STANDARD_REQUIRED ON)
endif()
diff --git a/Modules/app-esp32-module/CMakeLists.txt b/Modules/app-esp32-module/CMakeLists.txt
new file mode 100644
index 000000000..f1ff7b929
--- /dev/null
+++ b/Modules/app-esp32-module/CMakeLists.txt
@@ -0,0 +1,11 @@
+cmake_minimum_required(VERSION 3.20)
+
+include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake")
+
+file(GLOB_RECURSE SOURCE_FILES "source/*.c*")
+
+tactility_add_module(app-esp32-module
+ SRCS ${SOURCE_FILES}
+ INCLUDE_DIRS include/
+ REQUIRES TactilityKernel app-module service-module elf_loader
+)
diff --git a/Modules/app-esp32-module/LICENSE-Apache-2.0.md b/Modules/app-esp32-module/LICENSE-Apache-2.0.md
new file mode 100644
index 000000000..f5f4b8b5e
--- /dev/null
+++ b/Modules/app-esp32-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-esp32-module/devicetree.yaml b/Modules/app-esp32-module/devicetree.yaml
new file mode 100644
index 000000000..4082c5863
--- /dev/null
+++ b/Modules/app-esp32-module/devicetree.yaml
@@ -0,0 +1,4 @@
+dependencies:
+ - TactilityKernel
+ - Modules/app-module
+ - Modules/service-module
diff --git a/Modules/app-esp32-module/include/app_esp32/module.h b/Modules/app-esp32-module/include/app_esp32/module.h
new file mode 100644
index 000000000..bc0b0443b
--- /dev/null
+++ b/Modules/app-esp32-module/include/app_esp32/module.h
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+extern struct Module app_esp32_module;
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-esp32-module/source/app_esp32_loader_service.cpp b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp
new file mode 100644
index 000000000..ab8009828
--- /dev/null
+++ b/Modules/app-esp32-module/source/app_esp32_loader_service.cpp
@@ -0,0 +1,160 @@
+// SPDX-License-Identifier: Apache-2.0
+#ifdef ESP_PLATFORM
+#include
+#endif
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+
+constexpr auto* TAG = "app_esp32_loader";
+
+namespace {
+
+/** load()-allocated state, passed back through run()/unload(). */
+struct Esp32AppRuntime {
+ esp_elf_t elf {};
+ uint8_t* file_data = nullptr;
+};
+
+error_t read_file(const char* path, uint8_t** out_data, size_t* out_size) {
+ FileMutex mutex;
+ file_mutex_get(&mutex, path);
+ file_mutex_lock(&mutex);
+
+ FILE* file = fopen(path, "rb");
+ if (file == nullptr) {
+ LOG_E(TAG, "Failed to open %s", path);
+ file_mutex_unlock(&mutex);
+ return ERROR_NOT_FOUND;
+ }
+
+ fseek(file, 0, SEEK_END);
+ long size = ftell(file);
+ fseek(file, 0, SEEK_SET);
+ if (size <= 0) {
+ fclose(file);
+ file_mutex_unlock(&mutex);
+ return ERROR_RESOURCE;
+ }
+
+ auto* data = static_cast(malloc(static_cast(size)));
+ if (data == nullptr) {
+ fclose(file);
+ file_mutex_unlock(&mutex);
+ return ERROR_OUT_OF_MEMORY;
+ }
+
+ size_t read = fread(data, 1, static_cast(size), file);
+ fclose(file);
+ file_mutex_unlock(&mutex);
+
+ if (read != static_cast(size)) {
+ free(data);
+ return ERROR_RESOURCE;
+ }
+
+ *out_data = data;
+ *out_size = static_cast(size);
+ return ERROR_NONE;
+}
+
+// location.location can be either an app's install directory or the .elf file directly; the
+// former resolves to the per-target binary at {dir}/elf/{CONFIG_IDF_TARGET}.elf.
+std::string resolve_elf_path(const std::string& path) {
+ if (path.ends_with(".elf")) {
+ return path;
+ }
+ return path + "/elf/" + CONFIG_IDF_TARGET + ".elf";
+}
+
+error_t api_load(AppLocation location, AppRuntime* out_runtime) {
+ if (location.type != APP_LOCATION_PATH) {
+ LOG_E(TAG, "Out of memory");
+ return ERROR_NOT_SUPPORTED;
+ }
+
+ LOG_I(TAG, "Loading %s", static_cast(location.location));
+
+ auto* runtime = new (std::nothrow) Esp32AppRuntime();
+ if (runtime == nullptr) {
+ LOG_E(TAG, "Out of memory");
+ return ERROR_OUT_OF_MEMORY;
+ }
+
+ auto elf_path = resolve_elf_path(static_cast(location.location));
+
+ size_t size = 0;
+ error_t read_result = read_file(elf_path.c_str(), &runtime->file_data, &size);
+ if (read_result != ERROR_NONE) {
+ LOG_E(TAG, "Failed to read file");
+ delete runtime;
+ return read_result;
+ }
+
+ if (esp_elf_init(&runtime->elf) != ESP_OK) {
+ free(runtime->file_data);
+ delete runtime;
+ LOG_E(TAG, "Failed to init elf");
+ return ERROR_RESOURCE;
+ }
+
+ if (esp_elf_relocate(&runtime->elf, runtime->file_data) != 0) {
+ // esp_elf_relocate() already frees elf->pdata/ptext itself on a relocation failure
+ free(runtime->file_data);
+ delete runtime;
+ LOG_E(TAG, "Failed to map elf");
+ return ERROR_RESOURCE;
+ }
+
+ *out_runtime = runtime;
+ return ERROR_NONE;
+}
+
+int32_t api_run(AppRuntime runtime_ptr, uint32_t /*app_instance_id*/, int argc, char* argv[]) {
+ auto* runtime = static_cast(runtime_ptr);
+ return esp_elf_request(&runtime->elf, 0, argc, argv);
+}
+
+void api_unload(AppRuntime runtime_ptr) {
+ auto* runtime = static_cast(runtime_ptr);
+ esp_elf_deinit(&runtime->elf);
+ free(runtime->file_data);
+ delete runtime;
+}
+
+AppLoaderApi loader_api = {
+ .load = api_load,
+ .run = api_run,
+ .unload = api_unload,
+};
+
+void* create_service(const ServiceManifest*) {
+ return &loader_api;
+}
+
+void destroy_service(const ServiceManifest*, void*) {
+}
+
+} // namespace
+
+ServiceManifest loader_service_manifest = {
+ .id = APP_LOADER_PATH_SERVICE_ID,
+ .create_service = create_service,
+ .destroy_service = destroy_service,
+ .on_start = nullptr,
+ .on_stop = nullptr,
+};
diff --git a/Modules/app-esp32-module/source/module.cpp b/Modules/app-esp32-module/source/module.cpp
new file mode 100644
index 000000000..cfd0e0160
--- /dev/null
+++ b/Modules/app-esp32-module/source/module.cpp
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: Apache-2.0
+#include
+
+#include
+
+#include
+#include
+
+extern "C" {
+
+extern ServiceManifest loader_service_manifest;
+
+static error_t start() {
+ return service_manager_add(&loader_service_manifest, /*auto_start=*/true);
+}
+
+static error_t stop() {
+ return service_manager_remove(loader_service_manifest.id);
+}
+
+Module app_esp32_module = {
+ .name = "app-esp32",
+ .start = start,
+ .stop = stop,
+ .drivers = nullptr,
+ .symbols = nullptr,
+ .internal = nullptr
+};
+
+}
diff --git a/Modules/app-module/CMakeLists.txt b/Modules/app-module/CMakeLists.txt
new file mode 100644
index 000000000..916ed7039
--- /dev/null
+++ b/Modules/app-module/CMakeLists.txt
@@ -0,0 +1,12 @@
+cmake_minimum_required(VERSION 3.20)
+
+include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake")
+
+file(GLOB_RECURSE SOURCE_FILES "source/*.c*")
+
+tactility_add_module(app-module
+ SRCS ${SOURCE_FILES}
+ PRIV_INCLUDE_DIRS private/
+ INCLUDE_DIRS include/
+ REQUIRES TactilityKernel service-module minitar
+)
diff --git a/Modules/app-module/LICENSE-Apache-2.0.md b/Modules/app-module/LICENSE-Apache-2.0.md
new file mode 100644
index 000000000..f5f4b8b5e
--- /dev/null
+++ b/Modules/app-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-module/devicetree.yaml b/Modules/app-module/devicetree.yaml
new file mode 100644
index 000000000..0bd5002d1
--- /dev/null
+++ b/Modules/app-module/devicetree.yaml
@@ -0,0 +1,3 @@
+dependencies:
+ - TactilityKernel
+ - Modules/service-module
diff --git a/Modules/app-module/include/app/event.h b/Modules/app-module/include/app/event.h
new file mode 100644
index 000000000..090d0ffc3
--- /dev/null
+++ b/Modules/app-module/include/app/event.h
@@ -0,0 +1,103 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+#include
+
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Identifies the kind of app-lifecycle event delivered through app_event_await(). */
+enum AppEventType {
+ APP_EVENT_RESULT, // struct AppResultEventData
+ APP_EVENT_CLOSE, // no data - terminate now, permanently
+};
+
+/** Data for APP_EVENT_RESULT. */
+struct AppResultEventData {
+ uint32_t launch_id;
+ /** The child app instance's own AppMainFn/AppLoaderApi::run() return value. By convention:
+ * 0 = Ok, 1 = Cancelled, 2 = Error. Apps that need to hand back more than this (e.g. picked
+ * text, a path) expose their own "get last result" getter instead - see e.g.
+ * tt::app::inputdialog::getLastText(). */
+ int32_t result;
+};
+
+struct AppEvent {
+ enum AppEventType type;
+ /** Stamped by app_event_emit(); any value passed in by the caller is ignored. */
+ uint64_t timestamp;
+ /** Valid only when type == APP_EVENT_RESULT. */
+ struct AppResultEventData result;
+};
+
+/**
+ * Number of events that can be queued per subscription before app_event_emit() starts
+ * returning ERROR_RESOURCE (dropping the newest event, preserving FIFO order of what's
+ * already queued). Deliberately generous: app-module's scheduler is the only emitter and it
+ * serializes app-lifecycle transitions, so a given app can't realistically receive events
+ * faster than the scheduler produces them one at a time.
+ */
+#define APP_EVENT_QUEUE_CAPACITY 4
+
+/**
+ * Caller-owned subscription node. Unlike TactilityKernel's system_event poll subscription
+ * (which coalesces to the latest value), this queues events by value (FIFO) since dropping an
+ * APP_EVENT_RESULT would be unacceptable.
+ * @warning Fields other than `app_instance_id` are for internal use only; do not read or write
+ * them directly.
+ */
+struct AppEventSubscription {
+ /** The app instance this subscription receives events for; set by the caller before app_event_subscribe(). */
+ uint32_t app_instance_id;
+
+ TaskHandle_t task;
+
+ struct AppEvent queue[APP_EVENT_QUEUE_CAPACITY];
+ uint8_t head;
+ uint8_t count;
+
+ struct AppEventSubscription* next;
+};
+
+/**
+ * Register a subscription for events addressed to @a sub->app_instance_id.
+ * @warning Does not work in ISR context.
+ * @param[in,out] sub subscription to register; caller sets @a sub->app_instance_id beforehand,
+ * owns the storage, and must keep it alive (and stationary) until unsubscribed
+ * @return ERROR_NONE on success
+ */
+error_t app_event_subscribe(struct AppEventSubscription* sub);
+
+/**
+ * Remove a previously registered subscription.
+ * @warning Does not work in ISR context.
+ * @return ERROR_NONE on success, ERROR_NOT_FOUND if no matching subscription exists
+ */
+error_t app_event_unsubscribe(struct AppEventSubscription* sub);
+
+/**
+ * Deliver @a event to every subscription registered for @a app_instance_id (normally exactly one).
+ * @warning Does not work in ISR context.
+ * @retval ERROR_NONE delivered to at least one subscription
+ * @retval ERROR_NOT_FOUND no subscription is registered for @a app_instance_id
+ * @retval ERROR_RESOURCE at least one matching subscription's queue was full; the event was
+ * dropped for that subscription (still delivered to any other matching subscription)
+ */
+error_t app_event_emit(uint32_t app_instance_id, const struct AppEvent* event);
+
+/**
+ * Pop the next event for @a sub, blocking up to @a timeout if the queue is currently empty.
+ * @retval ERROR_NONE @a out_event was filled
+ * @retval ERROR_TIMEOUT no event arrived before the timeout elapsed
+ */
+error_t app_event_await(struct AppEventSubscription* sub, struct AppEvent* out_event, TickType_t timeout);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-module/include/app/install.h b/Modules/app-module/include/app/install.h
new file mode 100644
index 000000000..d6819f072
--- /dev/null
+++ b/Modules/app-module/include/app/install.h
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Computes the install directory for @a app_id (does not check whether anything is actually
+ * installed there).
+ * @param[out] path always NULL-terminated on return, even on failure (empty string if
+ * @a path_size == 0 - nothing is written in that case; otherwise at least "" is written)
+ * @retval ERROR_NONE on success
+ * @retval ERROR_BUFFER_OVERFLOW @a path_size is too small to hold the path (including the
+ * NULL terminator)
+ * @retval ERROR_NOT_FOUND the app install location isn't available (e.g. no SD card)
+ */
+error_t app_get_install_path(const char* app_id, char* path, size_t path_size);
+
+/**
+ * Installs an app from a tarball at @a source_path: extracts it into the app install directory,
+ * parses the extracted manifest.properties (see app/metadata.h) to determine its id, then
+ * registers it with app_manager_add() as an AppLocation{APP_LOCATION_PATH, } app.
+ * If an app with the same id is already installed (via a previous app_install() call), it is
+ * uninstalled first - stopped if running, its old install directory removed - before the new
+ * one takes its place.
+ * @param[in] source_path path to a tar file containing the app (must have manifest.properties
+ * at its root)
+ * @retval ERROR_NONE on success
+ * @retval ERROR_NOT_FOUND @a source_path doesn't exist / can't be read
+ * @retval ERROR_INVALID_ARGUMENT the tarball has no valid manifest.properties at its root
+ */
+error_t app_install(const char* source_path);
+
+/**
+ * Uninstalls a previously app_install()-ed app: stops it if currently running, deletes its
+ * install directory, and unregisters it (app_manager_remove()).
+ * @param[in] app_id the id the app was installed under (AppMetadata::app_id)
+ * @retval ERROR_NONE on success
+ * @retval ERROR_NOT_FOUND no such app was installed via app_install()
+ */
+error_t app_uninstall(const char* app_id);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-module/include/app/instance.h b/Modules/app-module/include/app/instance.h
new file mode 100644
index 000000000..b8993d82f
--- /dev/null
+++ b/Modules/app-module/include/app/instance.h
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Identifies a running (or previously running) app instance. 0 is never a valid instance id. */
+typedef uint32_t AppInstanceId;
+
+/** Lifecycle state of a running (or previously running) app instance. Every app instance owns
+ * its own task for its entire lifetime - there is no "saved, task given up" state. */
+typedef enum {
+ APP_INSTANCE_STATE_STARTING,
+ APP_INSTANCE_STATE_ACTIVE,
+ APP_INSTANCE_STATE_STOPPING,
+ APP_INSTANCE_STATE_STOPPED,
+} AppInstanceState;
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-module/include/app/loader.h b/Modules/app-module/include/app/loader.h
new file mode 100644
index 000000000..1ca8d0336
--- /dev/null
+++ b/Modules/app-module/include/app/loader.h
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+#include
+#include
+#include "location.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** service-module id the AppLoaderApi implementation for AppManifest::location.type ==
+ * APP_LOCATION_MEMORY must register under. Implemented by app-module itself (source/app_internal_loader.cpp). */
+#define APP_LOADER_MEMORY_SERVICE_ID "app-loader-memory"
+
+/** service-module id the AppLoaderApi implementation for AppManifest::location.type ==
+ * APP_LOCATION_PATH must register under. Implemented by a platform module (e.g. app-esp32-module). */
+#define APP_LOADER_PATH_SERVICE_ID "app-loader-path"
+
+/**
+ * Entry point signature for an APP_LOCATION_MEMORY app: a function linked directly into this
+ * firmware binary. Called on the dedicated task app-module's scheduler spawns for this instance,
+ * blocking for the app's whole lifetime - same contract as an external app's main(), plus
+ * @a app_instance_id identifying this running instance (use it with
+ * app_event_subscribe()/window_manager_create()/app_manager_finish()/etc.).
+ * AppManifest::location.location holds this cast to void*.
+ */
+typedef int32_t (*AppMainFn)(uint32_t app_instance_id, int argc, char* argv[]);
+
+typedef void* AppRuntime;
+
+/**
+ * Pluggable mechanism for loading and executing an app.
+ */
+struct AppLoaderApi {
+ /**
+ * Prepares an app instance for execution (e.g. read + relocate its binary).
+ * @param[in] location the location to load the elf from
+ * @param[out] out_runtime opaque handle to whatever load() allocated; passed back to run()/unload()
+ */
+ error_t (*load)(struct AppLocation location, AppRuntime* out_runtime);
+
+ /**
+ * Blocking: runs the app to completion.
+ * @param[in] runtime handle produced by load()
+ * @param[in] app_instance_id the running instance's id
+ * @param[in] argc the amount of arguments in @a argv
+ * @param[in] argv the array of string pointers (can be NULL)
+ */
+ int32_t (*run)(AppRuntime runtime, uint32_t app_instance_id, int argc, char* argv[]);
+
+ /** Releases whatever load() allocated. Called after run() returns. */
+ void (*unload)(AppRuntime runtime);
+};
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-module/include/app/location.h b/Modules/app-module/include/app/location.h
new file mode 100644
index 000000000..eb8703ca0
--- /dev/null
+++ b/Modules/app-module/include/app/location.h
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+enum AppLocationType {
+ APP_LOCATION_MEMORY,
+ APP_LOCATION_PATH,
+};
+
+struct AppLocation {
+ enum AppLocationType type;
+ /** Meaning depends on `type`; see AppLocationType. */
+ void* location;
+};
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-module/include/app/manager.h b/Modules/app-module/include/app/manager.h
new file mode 100644
index 000000000..e35ba5db2
--- /dev/null
+++ b/Modules/app-module/include/app/manager.h
@@ -0,0 +1,153 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+#include
+
+#include
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Register an app manifest.
+ * @retval ERROR_INVALID_ARGUMENT a manifest with the same id is already registered
+ * @retval ERROR_NONE on success
+ */
+error_t app_manager_add(const struct AppManifest* manifest);
+
+/**
+ * Unregister a previously-added manifest.
+ * @retval ERROR_NOT_FOUND no manifest with this id is registered
+ * @retval ERROR_NONE on success
+ */
+error_t app_manager_remove(const char* id);
+
+/** @return the manifest, or NULL if not found. */
+const struct AppManifest* app_manager_find_manifest(const char* id);
+
+/**
+ * Calls `@a` visitor once for every registered manifest. Iteration order is unspecified.
+ * `@warning` `@a` visitor runs with app-module's internal registry lock held. Do not call any
+ * app_manager_*() function from inside `@a` visitor - copy out what you need and act on it after
+ * this call returns.
+ */
+typedef void (*AppManifestVisitorFn)(const struct AppManifest* manifest, void* context);
+void app_manager_for_each_manifest(AppManifestVisitorFn visitor, void* context);
+
+/**
+ * Starts a new instance of the app registered under @a id. Every app instance gets its own
+ * dedicated task for its entire lifetime - starting an app never asks any other app to give up
+ * its task, and multiple instances (of the same or different apps) can be Active at once.
+ * @param[in] id the manifest id to start
+ * @param[out] out_app_instance_id the id of the new app instance
+ * @retval ERROR_NOT_FOUND no manifest with this id is registered, or no AppLoaderApi is registered
+ * @retval ERROR_NONE on success
+ */
+error_t app_manager_start(const char* id, AppInstanceId* out_app_instance_id);
+
+/**
+ * Same as app_manager_start(), but also passes @a argc/@a argv to the new instance's own main
+ * function (see app/loader.h's AppMainFn) - modelled on a C program's main(argc, argv). For
+ * regular (non-modal) navigations that need to pass data to the target app (e.g. "show details
+ * for this app id") without expecting a result back.
+ * @param[in] argv @a argc strings; app-module makes its own deep copy before returning, so
+ * @a argv and the strings it points to may be freed/go out of scope immediately after this call
+ * returns (e.g. safe to pass a stack-local array of a caller's own std::string::c_str()s).
+ */
+error_t app_manager_start_with_parameters(const char* id, int argc, const char* const argv[], AppInstanceId* out_app_instance_id);
+
+/**
+ * Starts @a id as a modal child of @a parent_instance_id, for the purpose of receiving a
+ * result. The parent keeps running (window_manager's own multi-window stack handles burying its
+ * window while the child is shown).
+ *
+ * When the child's task exits, an APP_EVENT_RESULT is delivered to @a parent_instance_id -
+ * result is whatever the child's AppMainFn/AppLoaderApi::run() returned - unless
+ * @a parent_instance_id is 0, in which case no result is delivered (fire-and-forget, for
+ * callers with no app_instance_id of their own). The parent is then responsible for calling
+ * app_manager_stop() on the child's instance id to fully reap it. Children that need to hand
+ * back more than an int32_t (e.g. picked text, a path) expose their own "get last result"
+ * getter for the parent to call after receiving the event - see e.g.
+ * tt::app::inputdialog::getLastText().
+ * @param[in] argv @a argc strings; app-module makes its own deep copy before returning (same as
+ * app_manager_start_with_parameters()), so @a argv and the strings it points to may be
+ * freed/go out of scope immediately after this call returns.
+ * @retval ERROR_NOT_FOUND no manifest with this id is registered, or no AppLoaderApi is registered
+ * @retval ERROR_NONE on success
+ */
+error_t app_manager_start_for_result(const char* id, AppInstanceId parent_instance_id, int argc, const char* const argv[], AppInstanceId* out_app_instance_id);
+
+/**
+ * Stop an app instance permanently. Emits APP_EVENT_CLOSE and bound-waits for its task to exit
+ * if it was running.
+ * @warning Must not be called from the instance's own task (it bound-waits via thread_join(),
+ * which asserts against joining yourself) - an app closing itself must call app_manager_finish()
+ * instead, right before returning from its own AppMainFn/AppLoaderApi::run().
+ */
+error_t app_manager_stop(AppInstanceId app_instance_id);
+
+/**
+ * Called by an app instance, from its own task, right before it returns in response to
+ * APP_EVENT_CLOSE - whether that close was self-initiated (e.g. its own back button) or came
+ * from someone else. Marks this instance Stopped immediately (rather than waiting for its task
+ * to actually exit) so app_manager_get_state()/app_manager_get_topmost_instance_id() reflect the
+ * closure as soon as the app has decided to close, not just once its task has fully unwound.
+ * @warning Does not join or free this instance's own task/ledger entry (can't - this runs on
+ * that very task); those are cleaned up on a later app_manager_stop() call, same as any
+ * self-terminating instance.
+ */
+error_t app_manager_finish(AppInstanceId app_instance_id);
+
+/** @return the instance's current state, or APP_INSTANCE_STATE_STOPPED if the id is unknown. */
+AppInstanceState app_manager_get_state(AppInstanceId app_instance_id);
+
+/**
+ * @param[out] out_app_instance_id set to the instance id of the topmost currently-Active app -
+ * the most recently started of whichever instances are Active (a modal child launched via
+ * app_manager_start_for_result() stays Active alongside its parent while shown, so this
+ * correctly picks the child, not the parent, while a dialog is up).
+ * @retval ERROR_NOT_FOUND no app is Active
+ * @retval ERROR_NONE on success
+ */
+error_t app_manager_get_topmost_instance_id(AppInstanceId* out_app_instance_id);
+
+/**
+ * Same as app_manager_get_topmost_instance_id(), but resolves straight to the topmost app's
+ * manifest id string.
+ * @param[out] buffer always NULL-terminated on return, even on failure (empty string if
+ * @a buffer_size == 0 - nothing is written in that case; otherwise at least "" is written)
+ * @retval ERROR_NOT_FOUND no app is Active
+ * @retval ERROR_BUFFER_OVERFLOW @a buffer_size is too small to hold the id (including the NULL
+ * terminator)
+ * @retval ERROR_NONE on success
+ */
+error_t app_manager_get_topmost_app_id(char* buffer, size_t buffer_size);
+
+/**
+ * Registers @a path as a directory to scan for app manifests - each direct subdirectory of
+ * @a path is expected to hold a manifest.properties (see app/metadata.h), matching the layout
+ * app_install() creates ({install dir}/{app_id}/manifest.properties), though this is not
+ * install/uninstall - it only ever adds/removes manifest registrations, never touches files on
+ * disk or running instances. No-op if @a path is already registered. Does not scan immediately -
+ * call app_manager_install_path_scan() to do that.
+ * @retval ERROR_NONE on success
+ */
+error_t app_manager_install_path_add(const char* path);
+
+/**
+ * Scans every path registered via app_manager_install_path_add(): registers
+ * (app_manager_add()) any direct subdirectory with a valid manifest.properties that isn't
+ * already registered, and unregisters (app_manager_remove() only - does not stop it if running,
+ * does not delete anything) any manifest a previous scan registered whose directory has since
+ * disappeared. Safe to call repeatedly (e.g. after an SD card is mounted/unmounted).
+ */
+void app_manager_install_path_scan(void);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-module/include/app/manifest.h b/Modules/app-module/include/app/manifest.h
new file mode 100644
index 000000000..ec393d74a
--- /dev/null
+++ b/Modules/app-module/include/app/manifest.h
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include "location.h"
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Broad classification of an app, used for grouping/launcher presentation. */
+enum AppCategory {
+ APP_CATEGORY_SYSTEM,
+ APP_CATEGORY_SETTINGS,
+ APP_CATEGORY_USER,
+};
+
+/** Bit flags for AppManifest::flags. */
+enum AppManifestFlags {
+ /** Excluded from generic app-browsing UIs (AppList, Settings) - for apps only ever reached
+ * by direct navigation (modal dialogs, detail views that require parameters, wizard/
+ * bootstrap steps). */
+ APP_MANIFEST_FLAG_HIDDEN = 0b00000001,
+};
+
+/** Describes a registrable app. One manifest exists per app id. */
+struct AppManifest {
+ /** Unique app identifier. Should never be NULL. */
+ const char* id;
+ /** Human-readable name. Should never be NULL. */
+ const char* name;
+ enum AppCategory category;
+ struct AppLocation location;
+ /** Bitmask of AppManifestFlags. Most apps should leave this 0. */
+ uint8_t flags;
+};
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-module/include/app/metadata.h b/Modules/app-module/include/app/metadata.h
new file mode 100644
index 000000000..6cd039ec8
--- /dev/null
+++ b/Modules/app-module/include/app/metadata.h
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define APP_METADATA_TARGET_SDK_LENGTH 16
+#define APP_METADATA_APP_ID_LENGTH 32
+#define APP_METADATA_APP_NAME_LENGTH 32
+#define APP_METADATA_APP_VERSION_NAME_LENGTH 16
+
+struct AppMetadata {
+
+ /**
+ * The SDK version that was used to compile this app. (e.g. "0.6.0")
+ * Must be NULL-terminated.
+ */
+ char target_sdk[APP_METADATA_TARGET_SDK_LENGTH + 1];
+
+ /**
+ * The identifier by which the app is launched by the system and other apps.
+ * Must be NULL-terminated.
+ */
+ char app_id[APP_METADATA_APP_ID_LENGTH + 1];
+
+ /**
+ * The user-readable name of the app. Used in UI.
+ * Must be NULL-terminated.
+ */
+ char app_name[APP_METADATA_APP_NAME_LENGTH + 1];
+
+ /**
+ * The version as it is displayed to the user (e.g. "1.2.0")
+ * Must be NULL-terminated.
+ */
+ char app_version_name[APP_METADATA_APP_VERSION_NAME_LENGTH + 1];
+
+ /** The technical version (must be incremented with new releases of the app) */
+ uint64_t app_version_code;
+};
+
+/**
+ * Parses a manifest.properties file at @a path into @a out_metadata, auto-detecting the V1
+ * (sectioned, e.g. "[app]id=...") or V2 (flat dot-notation, e.g. "app.id=...") format from its
+ * first line.
+ * @retval ERROR_NONE on success
+ * @retval ERROR_NOT_FOUND the file doesn't exist / couldn't be opened
+ * @retval ERROR_INVALID_ARGUMENT the file isn't a valid manifest, or a field's value doesn't fit
+ * @a out_metadata's fixed-size buffers
+ */
+error_t app_metadata_parse(const char* path, struct AppMetadata* out_metadata);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-module/include/app/module.h b/Modules/app-module/include/app/module.h
new file mode 100644
index 000000000..d54dba82f
--- /dev/null
+++ b/Modules/app-module/include/app/module.h
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+extern struct Module app_module;
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-module/include/app/paths.h b/Modules/app-module/include/app/paths.h
new file mode 100644
index 000000000..0042c956b
--- /dev/null
+++ b/Modules/app-module/include/app/paths.h
@@ -0,0 +1,56 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#pragma once
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @brief Get the user data directory for an app. Survives OS upgrades. No trailing "/".
+ * @param[in] app_id non-null app id
+ * @param[out] out_path buffer to store the path
+ * @param[in] out_path_size size of the output buffer
+ * @retval ERROR_BUFFER_OVERFLOW if out_path_size is too small
+ * @retval ERROR_NONE on success
+ */
+error_t app_paths_get_user_data_directory(const char* app_id, char* out_path, size_t out_path_size);
+
+/**
+ * @brief Get a path within the user data directory for an app.
+ * @param[in] app_id non-null app id
+ * @param[in] child_path path without a "/" prefix
+ * @param[out] out_path buffer to store the path
+ * @param[in] out_path_size size of the output buffer
+ * @retval ERROR_BUFFER_OVERFLOW if out_path_size is too small
+ * @retval ERROR_NONE on success
+ */
+error_t app_paths_get_user_data_path(const char* app_id, const char* child_path, char* out_path, size_t out_path_size);
+
+/**
+ * @brief Get the assets directory for an app. Do not store configuration data here. No trailing "/".
+ * @param[in] app_id non-null app id
+ * @param[out] out_path buffer to store the path
+ * @param[in] out_path_size size of the output buffer
+ * @retval ERROR_BUFFER_OVERFLOW if out_path_size is too small
+ * @retval ERROR_NONE on success
+ */
+error_t app_paths_get_assets_directory(const char* app_id, char* out_path, size_t out_path_size);
+
+/**
+ * @brief Get a path within the assets directory for an app.
+ * @param[in] app_id non-null app id
+ * @param[in] child_path path without a "/" prefix
+ * @param[out] out_path buffer to store the path
+ * @param[in] out_path_size size of the output buffer
+ * @retval ERROR_BUFFER_OVERFLOW if out_path_size is too small
+ * @retval ERROR_NONE on success
+ */
+error_t app_paths_get_assets_path(const char* app_id, const char* child_path, char* out_path, size_t out_path_size);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-module/include/app/scheduler.h b/Modules/app-module/include/app/scheduler.h
new file mode 100644
index 000000000..163c44ec9
--- /dev/null
+++ b/Modules/app-module/include/app/scheduler.h
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @return the app_instance_id of whichever app instance's task is calling this (every app
+ * instance's task stashes it in its own thread-local storage when it starts), or 0 if called
+ * from a task that isn't a running app instance. An app's own main() typically calls this once,
+ * near the top, to learn its own instance id - see e.g. app_event_subscribe()/
+ * window_manager_create(), both of which need it.
+ */
+AppInstanceId app_scheduler_current_app_id(void);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/Modules/app-module/private/app/private/app_fs.h b/Modules/app-module/private/app/private/app_fs.h
new file mode 100644
index 000000000..d4dff2a14
--- /dev/null
+++ b/Modules/app-module/private/app/private/app_fs.h
@@ -0,0 +1,72 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+// Minimal filesystem helpers shared by app-module internals that need to look at on-disk app
+// directories (app_install.cpp, manager.cpp's install-path scan) - app-module may not depend
+// upward on Tactility::file, so this is a small local re-implementation (see
+// app_metadata_parsing.cpp for the same constraint applied to properties-file loading).
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+inline bool app_fs_is_directory(const std::string& path) {
+ struct stat result {};
+ FileMutex file_mutex;
+ file_mutex_get(&file_mutex, path.c_str());
+ file_mutex_lock(&file_mutex);
+ auto is_dir = stat(path.c_str(), &result) == 0 && S_ISDIR(result.st_mode);
+ file_mutex_unlock(&file_mutex);
+ return is_dir;
+}
+
+inline bool app_fs_is_file(const std::string& path) {
+ FileMutex file_mutex;
+ file_mutex_get(&file_mutex, path.c_str());
+ file_mutex_lock(&file_mutex);
+ struct stat result {};
+ auto retval = stat(path.c_str(), &result) == 0 && S_ISREG(result.st_mode);
+ file_mutex_unlock(&file_mutex);
+ return retval;
+}
+
+// Appends the full path of every direct subdirectory of @a path to @a out.
+// No-op (not an error) if @a path can't be opened.
+inline void app_fs_list_direct_subdirectories(const std::string& path, std::vector& out) {
+ // Collect child names while the directory lock is held, then release it before classifying
+ // each one with app_fs_is_directory() - that function looks up and locks a FileMutex too,
+ // and file_mutex_get() resolves a child path to the same registered mutex as its parent
+ // mount. Calling it while still holding the directory's own lock would be a nested
+ // acquisition of that same (possibly non-recursive) mutex, and could self-deadlock.
+ std::vector children;
+
+ FileMutex file_mutex;
+ file_mutex_get(&file_mutex, path.c_str());
+ file_mutex_lock(&file_mutex);
+ DIR* dir = opendir(path.c_str());
+ if (dir == nullptr) {
+ file_mutex_unlock(&file_mutex);
+ return;
+ }
+
+ struct dirent* entry;
+ while ((entry = readdir(dir)) != nullptr) {
+ if (std::strcmp(entry->d_name, ".") == 0 || std::strcmp(entry->d_name, "..") == 0) {
+ continue;
+ }
+ children.push_back(path + "/" + entry->d_name);
+ }
+
+ closedir(dir);
+ file_mutex_unlock(&file_mutex);
+
+ for (const auto& child_path : children) {
+ if (app_fs_is_directory(child_path)) {
+ out.push_back(child_path);
+ }
+ }
+}
diff --git a/Modules/app-module/private/app/private/app_ledger.h b/Modules/app-module/private/app/private/app_ledger.h
new file mode 100644
index 000000000..61f38d714
--- /dev/null
+++ b/Modules/app-module/private/app/private/app_ledger.h
@@ -0,0 +1,80 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+/**
+ * A dedicated (not the task's shared default FreeRTOS notification, which app_event.cpp's
+ * AppEventSubscription also uses - an unrelated event delivered to the same task could
+ * otherwise unblock a waiter early) completion signal for one app instance's task, given as the
+ * literal last action app_task_main() takes before vTaskDelete(). Heap-allocated with its own
+ * refcount (protected by app_ledger().mutex, not atomic) rather than owned by the ledger
+ * entry, since app_task_main() always erases that entry - and may run its exit path entirely -
+ * before app_scheduler_stop() ever looks for it: whichever side (the exiting task, or a
+ * concurrent app_scheduler_stop() that found the entry in time and is waiting on `semaphore`)
+ * finishes with it last is the one that deletes `semaphore` and frees this struct.
+ */
+struct AppCompletionSignal {
+ SemaphoreHandle_t semaphore;
+ /** Starts at 1, owned by app_task_main() until its own exit. app_scheduler_stop() takes an
+ * additional reference for as long as it's waiting on `semaphore`, if it finds the instance
+ * still running. Reaching 0 means deletion. */
+ int refcount = 1;
+};
+
+/** A registered/running app instance, as tracked internally by app-module. */
+struct AppInstanceRecord {
+ uint32_t id;
+ const AppManifest* manifest;
+ AppInstanceState state;
+ /** The FreeRTOS task currently executing AppLoaderApi::run() for this instance; NULL when
+ * not running. */
+ TaskHandle_t task;
+
+ /** 0 for a top-level launch (app_manager_start()). Non-zero for a modal child launched via
+ * app_manager_start_for_result() - the instance that receives this child's APP_EVENT_RESULT. */
+ uint32_t parent_id = 0;
+
+ /** This instance's completion signal - see AppCompletionSignal. Set once by
+ * app_scheduler_start(), never reassigned. */
+ AppCompletionSignal* completion = nullptr;
+};
+
+struct AppLedger {
+ std::unordered_map manifests;
+ std::unordered_map instances;
+ uint32_t next_instance_id = 1;
+ Mutex mutex {};
+
+ AppLedger() { mutex_construct(&mutex); }
+ ~AppLedger() { mutex_destruct(&mutex); }
+};
+
+inline AppLedger& app_ledger() {
+ static AppLedger ledger;
+ return ledger;
+}
+
+/** Frees a deep-copied argv previously built by app_manager_start_with_parameters()/
+ * app_manager_start_for_result() (see app_scheduler.cpp's TaskContext::argv) - each
+ * individually heap-allocated string, then the array itself. Safe to call with count == 0 /
+ * values == nullptr (no-op). */
+inline void app_ledger_free_arguments(int count, char** values) {
+ if (values == nullptr) {
+ return;
+ }
+ for (int i = 0; i < count; i++) {
+ delete[] values[i];
+ }
+ delete[] values;
+}
diff --git a/Modules/app-module/private/app/private/app_metadata_parsing_internal.h b/Modules/app-module/private/app/private/app_metadata_parsing_internal.h
new file mode 100644
index 000000000..f611b0a50
--- /dev/null
+++ b/Modules/app-module/private/app/private/app_metadata_parsing_internal.h
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: Apache-2.0
+#pragma once
+
+#include
+
+#include