stdio, keyboard, compact UI, package manifest, and more (#667)

This commit is contained in:
Ken Van Hoeylandt 2026-10-01 18:09:57 +02:00 committed by GitHub
parent 25bcb20c05
commit 5bac1ada82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 1697 additions and 745 deletions

View File

@ -24,6 +24,12 @@ macro(tactility_project_post project_name)
idf_build_set_property(COMPILE_OPTIONS "-fno-omit-frame-pointer" APPEND)
endif()
# ESP-IDF's own <sys/ioctl.h> (esp_libc/platform_include) is found before app-module's, which adds
# the terminal ioctls (TIOCGWINSZ) Tactility's ioctl() supports for app fds.
# C and C++ only: assembly sources can't include a C header.
idf_build_set_property(C_COMPILE_OPTIONS "SHELL:-include ${TACTILITY_SDK_PATH}/Modules/app-module/include/sys/ioctl.h" APPEND)
idf_build_set_property(CXX_COMPILE_OPTIONS "SHELL:-include ${TACTILITY_SDK_PATH}/Modules/app-module/include/sys/ioctl.h" APPEND)
include("${TACTILITY_SDK_PATH}/Libraries/elf_loader/elf_loader.cmake")
project_elf($project_name)

View File

@ -32,6 +32,12 @@ macro(tactility_project_post project_name)
${TACTILITY_SDK_PATH}/Libraries/FreeRTOS-Kernel/portable/ThirdParty/GCC/Posix/utils
)
target_compile_definitions(TactilitySDK INTERFACE LV_LVGL_H_INCLUDE_SIMPLE)
# An app's references to its own functions must bind to its own definitions, not to a same-named
# libc function already loaded in the Tactility process (e.g. an app shipping its own regcomp()).
# ESP32 apps get the same through -fvisibility=hidden. Mach-O binds within the image by default.
if (NOT APPLE)
target_link_options(TactilitySDK INTERFACE -Wl,-Bsymbolic)
endif ()
# ESP-IDF's project() auto-discovers the "main" component; plain CMake doesn't.
add_subdirectory(main)

View File

@ -63,7 +63,7 @@ if (DEFINED ENV{ESP_IDF_VERSION})
# Wraps newlib's reentrant syscall stubs, not the plain read()/write()/close() newlib itself.
# Implements as thin wrappers around them. newlib's own stdio (fflush()'s buffer-flush path in particular)
# calls these _r stubs directly, bypassing the plain names entirely.
# See Modules/app-module/source/stdio_wrap_esp32.cpp's own comment for the exact call chain.
# See Modules/app-esp32-module/source/stdio_wrap.cpp's own comment for the exact call chain.
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_read_r" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_write_r" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=_close_r" APPEND)
@ -82,6 +82,13 @@ if (DEFINED ENV{ESP_IDF_VERSION})
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=tcgetattr" APPEND)
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=tcsetattr" APPEND)
# elf_loader's full D-cache write-back must not run concurrently with a cache operation on the other core.
# See Modules/app-esp32-module/source/elf_cache.cpp.
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=esp_elf_arch_flush" APPEND)
# elf_loader's Xtensa relocation drops GLOB_DAT/JMP_SLOT addends. See Modules/app-esp32-module/source/elf_relocate.cpp.
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=esp_elf_arch_relocate" APPEND)
# exit() from an app ends only that app (see app_scheduler_exit_current()) instead of aborting.
idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=exit" APPEND)

View File

@ -17,31 +17,31 @@ static constexpr uint32_t KEYMAP_LC[80] = {
37, 49, 50, 51, 52, 53, 54, 55, 56, 0, // % 1 2 3 4 5 6 7 8
57, 48, 8, 91, 93, 43, 34, 39, 27, 0, // 9 0 BKSP [ ] + " ' EXIT
9, 113, 119, 101, 114, 116, 121, 117, 105, 0, // TAB q w e r t y u i
111, 112, 13, 40, 41, 45, 59, 58, 3, 0, // o p ENTER ( ) - ; : STOP
111, 112, 10, 40, 41, 45, 59, 58, 3, 0, // o p ENTER ( ) - ; : STOP
0, 97, 115, 100, 102, 103, 104, 106, 107, 0, // a s d f g h j k
108, 17, 35, 123, 125, 42, 44, 46, 2, 0, // l UP # { } * , . MENU
122, 120, 99, 118, 98, 32, 32, 110, 109, 0, // z x c v b n m
CODEPOINT_ARROW_LEFT, CODEPOINT_ARROW_DOWN, CODEPOINT_ARROW_RIGHT, 60, 62, 47, 92, 61, 13, 0, // LEFT DOWN RIGHT < > / \ = RUN
CODEPOINT_ARROW_LEFT, CODEPOINT_ARROW_DOWN, CODEPOINT_ARROW_RIGHT, 60, 62, 47, 92, 61, 10, 0, // LEFT DOWN RIGHT < > / \ = RUN
};
static constexpr uint32_t KEYMAP_UC[80] = {
37, 49, 50, 51, 52, 53, 54, 55, 56, 0, // % 1 2 3 4 5 6 7 8
57, 48, 8, 91, 93, 43, 34, 39, 27, 0, // 9 0 BKSP [ ] + " ' EXIT
9, 81, 87, 69, 82, 84, 89, 85, 73, 0, // TAB Q W E R T Y U I
79, 80, 13, 40, 41, 45, 59, 58, 3, 0, // O P ENTER ( ) - ; : STOP
79, 80, 10, 40, 41, 45, 59, 58, 3, 0, // O P ENTER ( ) - ; : STOP
0, 65, 83, 68, 70, 71, 72, 74, 75, 0, // A S D F G H J K
76, 17, 35, 123, 125, 42, 44, 46, 2, 0, // L UP # { } * , . MENU
90, 88, 67, 86, 66, 32, 32, 78, 77, 0, // Z X C V B N M
CODEPOINT_ARROW_LEFT, CODEPOINT_ARROW_DOWN, CODEPOINT_ARROW_RIGHT, 60, 62, 47, 92, 61, 13, 0, // LEFT DOWN RIGHT < > / \ = RUN
CODEPOINT_ARROW_LEFT, CODEPOINT_ARROW_DOWN, CODEPOINT_ARROW_RIGHT, 60, 62, 47, 92, 61, 10, 0, // LEFT DOWN RIGHT < > / \ = RUN
};
static constexpr uint32_t KEYMAP_SY[80] = {
37, 49, 50, 51, 52, 53, 54, 55, 56, 0, // % 1 2 3 4 5 6 7 8
57, 48, 8, 91, 93, 43, 34, 39, 27, 0, // 9 0 BKSP [ ] + " ' EXIT
9, 113, 119, 101, 114, 116, 121, 117, 105, 0, // TAB q w e r t y u i
111, 112, 13, 40, 41, 45, 59, 58, 3, 0, // o p ENTER ( ) - ; : STOP
111, 112, 10, 40, 41, 45, 59, 58, 3, 0, // o p ENTER ( ) - ; : STOP
0, 97, 115, 100, 102, 103, 104, 106, 107, 0, // a s d f g h j k
108, 17, 35, 123, 125, 42, 44, 46, 2, 0, // l UP # { } * , . MENU
122, 120, 99, 118, 98, 32, 32, 110, 109, 0, // z x c v b n m
CODEPOINT_ARROW_LEFT, CODEPOINT_ARROW_DOWN, CODEPOINT_ARROW_RIGHT, 60, 62, 47, 92, 61, 13, 0, // LEFT DOWN RIGHT < > / \ = RUN
CODEPOINT_ARROW_LEFT, CODEPOINT_ARROW_DOWN, CODEPOINT_ARROW_RIGHT, 60, 62, 47, 92, 61, 10, 0, // LEFT DOWN RIGHT < > / \ = RUN
};
static Tca8418Config cl32_keyboard_config {};

View File

@ -88,19 +88,19 @@
keymap-lc = [
113 119 101 114 116 121 117 105 111 112 // q w e r t y u i o p
97 115 100 102 103 104 106 107 108 8 // a s d f g h j k l BACKSPACE
0 122 120 99 118 98 110 109 36 13 // z x c v b n m $ ENTER
0 122 120 99 118 98 110 109 36 10 // z x c v b n m $ ENTER
0 0 0 0 0 11 48 32 0 9 // PREV 0 SPC NEXT
];
keymap-uc = [
81 87 69 82 84 89 85 73 79 80 // Q W E R T Y U I O P
65 83 68 70 71 72 74 75 76 8 // A S D F G H J K L BACKSPACE
0 90 88 67 86 66 78 77 36 13 // Z X C V B N M $ ENTER
0 90 88 67 86 66 78 77 36 10 // Z X C V B N M $ ENTER
0 0 0 0 0 11 48 32 0 9 // PREV 0 SPC NEXT
];
keymap-sy = [
49 50 51 52 53 54 55 56 57 48 // 1 2 3 4 5 6 7 8 9 0
64 35 43 45 42 47 40 41 95 8 // @ # + - * / ( ) _ BACKSPACE
0 33 63 59 58 39 34 44 46 13 // ! ? ; : ' " , . ENTER
0 33 63 59 58 39 34 44 46 10 // ! ? ; : ' " , . ENTER
0 0 0 0 0 11 48 32 0 9 // PREV 0 SPC NEXT
];
shift-row = <2>;

View File

@ -60,19 +60,19 @@
keymap-lc = [
113 119 101 114 116 121 117 105 111 112 // q w e r t y u i o p
97 115 100 102 103 104 106 107 108 8 // a s d f g h j k l BACKSPACE
0 122 120 99 118 98 110 109 36 13 // z x c v b n m $ ENTER
0 122 120 99 118 98 110 109 36 10 // z x c v b n m $ ENTER
0 0 0 0 0 11 48 32 0 9 // PREV 0 SPC NEXT
];
keymap-uc = [
81 87 69 82 84 89 85 73 79 80 // Q W E R T Y U I O P
65 83 68 70 71 72 74 75 76 8 // A S D F G H J K L BACKSPACE
0 90 88 67 86 66 78 77 36 13 // Z X C V B N M $ ENTER
0 90 88 67 86 66 78 77 36 10 // Z X C V B N M $ ENTER
0 0 0 0 0 11 48 32 0 9 // PREV 0 SPC NEXT
];
keymap-sy = [
49 50 51 52 53 54 55 56 57 48 // 1 2 3 4 5 6 7 8 9 0
64 35 43 45 42 47 40 41 95 8 // @ # + - * / ( ) _ BACKSPACE
0 33 63 59 58 39 34 44 46 13 // ! ? ; : ' " , . ENTER
0 33 63 59 58 39 34 44 46 10 // ! ? ; : ' " , . ENTER
0 0 0 0 0 11 48 32 0 9 // PREV 0 SPC NEXT
];
shift-row = <2>;

View File

@ -1,7 +0,0 @@
file(GLOB_RECURSE SOURCE_FILES source/*.c*)
idf_component_register(
SRCS ${SOURCE_FILES}
INCLUDE_DIRS "source"
REQUIRES TactilityKernel lvgl-module gpio-encoder-module
)

View File

@ -21,3 +21,4 @@ display.dpi=227
lvgl.colorDepth=16
lvgl.dpi=150
lvgl.uiDensity=compact

View File

@ -58,21 +58,21 @@
columns = <10>;
keymap-lc = [
113 119 101 114 116 121 117 105 111 112 // q w e r t y u i o p
97 115 100 102 103 104 106 107 108 13 // a s d f g h j k l ENTER
97 115 100 102 103 104 106 107 108 10 // a s d f g h j k l ENTER
0 122 120 99 118 98 110 109 0 8 // z x c v b n m BACKSPACE
32 0 0 0 0 0 0 0 0 0 // SPC
];
keymap-uc = [
81 87 69 82 84 89 85 73 79 80 // Q W E R T Y U I O P
65 83 68 70 71 72 74 75 76 13 // A S D F G H J K L ENTER
65 83 68 70 71 72 74 75 76 10 // A S D F G H J K L ENTER
0 90 88 67 86 66 78 77 0 8 // Z X C V B N M BACKSPACE
32 0 0 0 0 0 0 0 0 0 // SPC
];
keymap-sy = [
49 50 51 52 53 54 55 56 57 48 // 1 2 3 4 5 6 7 8 9 0
46 47 43 45 61 58 39 34 64 9 // . / + - = : ' " @ TAB
46 47 43 45 61 58 39 34 64 27 // . / + - = : ' " @ ESC
0 95 36 59 63 33 44 46 0 8 // _ $ ; ? ! , . BACKSPACE
32 0 0 0 0 0 0 0 0 0 // SPC
9 0 0 0 0 0 0 0 0 0 // TAB
];
shift-row = <2>;
shift-col = <8>;

View File

@ -8,3 +8,4 @@ dependencies:
- Drivers/drv2605-module
- Drivers/gpio-encoder-module
dts: lilygo,tlora-pager.dts
dts-only: true

View File

@ -1,35 +0,0 @@
#include <lvgl/lvgl.h>
#include <tactility/module.h>
#include <tactility/system_event.h>
constexpr auto* TAG = "T-Lora Pager";
extern "C" {
static void on_boot_completed(struct SystemEvent* /*event*/, void* /*context*/) {
// The kernel gpio_encoder device is already started by kernel_init(); this just
// registers it as an LVGL input device, which requires LVGL to be up first.
lvgl_lock();
lvgl_unlock();
}
static error_t start() {
system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, on_boot_completed, nullptr);
return ERROR_NONE;
}
static error_t stop() {
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, on_boot_completed);
return ERROR_NONE;
}
Module lilygo_tlora_pager_module = {
.name = "lilygo-tlora-pager",
.start = start,
.stop = stop,
.drivers = nullptr,
.symbols = nullptr,
.internal = nullptr
};
}

View File

@ -11,7 +11,8 @@
- CrashDiagnostics shouldn't show a QR when there's no callstack
- Move USB host task stacks to SPIRAM when available: esp32_usbhost*.cpp
- Add bold fonts for e-ink readability improvement
- Improve Setup: Add keyboard/keypad navigation explanation
- Improve Setup: Add keyboard/keypad/encoder navigation explanation.
e.g. encoder long press on t-lora pager to get out of textarea
- Drivers/audio-codec-module is not a module. Move it somewhere else. Or make it an actual module.
- Improve SPI kernel driver (implement read, write, transactions)
- TCA9534 keyboards should use interrupts

View File

@ -54,7 +54,7 @@ static const CardputerKeyDef cardputer_key_map[CARDPUTER_ROWS][CARDPUTER_COLS] =
{ K('\t', '\t'), K('q', 'Q'), K('w', 'W'), K('e', 'E'), K('r', 'R'), K('t', 'T'), K('y', 'Y'),
K('u', 'U'), K('i', 'I'), K('o', 'O'), K('p', 'P'), K('[', '{'), K(']', '}'), K('\\', '|') },
{ { CARDPUTER_KEY_FN, 0, 0 }, { CARDPUTER_KEY_SHIFT, 0, 0 }, K('a', 'A'), K('s', 'S'), K('d', 'D'), K('f', 'F'), K('g', 'G'),
K('h', 'H'), K('j', 'J'), K('k', 'K'), K('l', 'L'), K(';', ':'), K('\'', '"'), K('\r', '\r') },
K('h', 'H'), K('j', 'J'), K('k', 'K'), K('l', 'L'), K(';', ':'), K('\'', '"'), K('\n', '\n') },
{ { CARDPUTER_KEY_CTRL, 0, 0 }, { CARDPUTER_KEY_OPT, 0, 0 }, { CARDPUTER_KEY_ALT, 0, 0 }, K('z', 'Z'), K('x', 'X'), K('c', 'C'), K('v', 'V'),
K('b', 'B'), K('n', 'N'), K('m', 'M'), K(',', '<'), K('.', '>'), K('/', '?'), K(' ', ' ') },
};

View File

@ -8,4 +8,6 @@ tactility_add_module(app-esp32-module
SRCS ${SOURCE_FILES}
INCLUDE_DIRS include/
REQUIRES TactilityKernel app-module service-module elf_loader
# The libc wraps in source/stdio_wrap.cpp are only reached through -Wl,--wrap=, never by direct reference
WHOLE_ARCHIVE
)

View File

@ -150,6 +150,10 @@ error_t api_load(AppLocation location, AppRuntime* out_runtime) {
return ERROR_RESOURCE;
}
// Relocation copied everything it needs (segments, symbol names) out of the file buffer
free(runtime->file_data);
runtime->file_data = nullptr;
*out_runtime = runtime;
return ERROR_NONE;
}

View File

@ -0,0 +1,70 @@
// SPDX-License-Identifier: Apache-2.0
#include <sdkconfig.h>
#ifdef CONFIG_ELF_LOADER_LOAD_PSRAM
#include <esp_ipc.h>
#include <freertos/FreeRTOS.h>
#include <atomic>
// elf_loader writes back the whole D-cache with the ROM's Cache_WriteBack_All(), which doesn't take
// esp_cache_msync()'s lock. A cache operation running at the same time on the other core (e.g. a display
// driver syncing a PSRAM framebuffer) then fails with "Dcache sync parameter configuration error".
// The write-back therefore runs in core 0's IPC task while core 1 is parked in its own IPC task. An IPC task
// only runs when its core is not in a critical section, which is where esp_cache_msync() does its work.
extern "C" {
void __real_esp_elf_arch_flush(void);
void Cache_WriteBack_All(void);
void spi_flash_disable_interrupts_caches_and_other_cpu(void);
void spi_flash_enable_interrupts_caches_and_other_cpu(void);
}
#if !CONFIG_FREERTOS_UNICORE && !CONFIG_IDF_TARGET_ESP32S31
namespace {
std::atomic<bool> other_core_parked { false };
std::atomic<bool> other_core_release { false };
void IRAM_ATTR park_core(void*) {
const uint32_t state = portSET_INTERRUPT_MASK_FROM_ISR();
other_core_parked.store(true, std::memory_order_release);
while (!other_core_release.load(std::memory_order_acquire)) {
}
portCLEAR_INTERRUPT_MASK_FROM_ISR(state);
}
void IRAM_ATTR write_back_with_core1_parked(void*) {
other_core_parked.store(false, std::memory_order_relaxed);
other_core_release.store(false, std::memory_order_relaxed);
esp_ipc_call(1, park_core, nullptr);
while (!other_core_parked.load(std::memory_order_acquire)) {
}
const uint32_t state = portSET_INTERRUPT_MASK_FROM_ISR();
Cache_WriteBack_All();
portCLEAR_INTERRUPT_MASK_FROM_ISR(state);
other_core_release.store(true, std::memory_order_release);
}
} // namespace
// IRAM: spi_flash_disable_interrupts_caches_and_other_cpu() returns here with the caches disabled
extern "C" void IRAM_ATTR __wrap_esp_elf_arch_flush(void) {
esp_ipc_call_blocking(0, write_back_with_core1_parked, nullptr);
// Same as elf_loader's own esp_elf_arch_flush() after its write-back
spi_flash_disable_interrupts_caches_and_other_cpu();
spi_flash_enable_interrupts_caches_and_other_cpu();
}
#else
extern "C" void IRAM_ATTR __wrap_esp_elf_arch_flush(void) {
__real_esp_elf_arch_flush();
}
#endif
#endif // CONFIG_ELF_LOADER_LOAD_PSRAM

View File

@ -0,0 +1,27 @@
// SPDX-License-Identifier: Apache-2.0
#include <sdkconfig.h>
#include <esp_elf.h>
#include <private/elf_platform.h>
// elf_loader's Xtensa relocation ignores the addend of R_XTENSA_GLOB_DAT/R_XTENSA_JMP_SLOT, which is
// S + A per the ABI. The compiler does emit addends there: newlib's ctype macros read (_ctype_ + 1)[c],
// which becomes "R_XTENSA_GLOB_DAT _ctype_ + 1". Without the addend every ctype lookup in an app reads
// the previous character's entry (e.g. isspace('!') is true).
extern "C" {
int __real_esp_elf_arch_relocate(esp_elf_t* elf, const elf32_rela_t* rela, const elf32_sym_t* sym, uint32_t addr);
int __wrap_esp_elf_arch_relocate(esp_elf_t* elf, const elf32_rela_t* rela, const elf32_sym_t* sym, uint32_t addr) {
#if CONFIG_IDF_TARGET_ARCH_XTENSA
constexpr uint32_t R_XTENSA_GLOB_DAT = 3;
constexpr uint32_t R_XTENSA_JMP_SLOT = 4;
const uint32_t type = ELF_R_TYPE(rela->info);
if (type == R_XTENSA_GLOB_DAT || type == R_XTENSA_JMP_SLOT) {
addr += rela->addend;
}
#endif
return __real_esp_elf_arch_relocate(elf, rela, sym, addr);
}
}

View File

@ -1,11 +1,17 @@
// SPDX-License-Identifier: Apache-2.0
#ifdef ESP_PLATFORM
#include <app/private/stdio_wrap.h>
// libc wraps (-Wl,--wrap=, see the top-level CMakeLists.txt) that route an app instance's calls to
// its own fds, cwd and exit(). App-instance behavior itself lives in app-module (app/libc.h).
#include <app/io.h>
#include <app/libc.h>
#include <app/scheduler.h>
#include <signal.h>
#include <sys/poll.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <termios.h>
#include <cerrno>
#include <cstdarg>
@ -39,10 +45,9 @@ int __wrap_ioctl(int fd, int request, ...) {
void* arg = va_arg(args, void*);
va_end(args);
struct winsize windowSize {};
if (tryAppWindowSize(fd, static_cast<unsigned long>(request), arg, &windowSize)) {
*static_cast<struct winsize*>(arg) = windowSize;
return 0;
int result;
if (app_libc_try_window_size(fd, static_cast<unsigned long>(request), arg, &result)) {
return result;
}
return __real_ioctl(fd, request, arg);
}
@ -52,11 +57,7 @@ int __real_chdir(const char* path);
char* __wrap_getcwd(char* buf, size_t size) {
char* result;
int err;
if (tryAppGetCwd(buf, size, &result, &err)) {
if (result == nullptr) {
errno = err;
}
if (app_libc_try_getcwd(buf, size, &result)) {
return result;
}
return __real_getcwd(buf, size);
@ -64,11 +65,7 @@ char* __wrap_getcwd(char* buf, size_t size) {
int __wrap_chdir(const char* path) {
int result;
int err;
if (tryAppChdir(path, &result, &err)) {
if (result != 0) {
errno = err;
}
if (app_libc_try_chdir(path, &result)) {
return result;
}
return __real_chdir(path);
@ -79,7 +76,7 @@ int __real__fstat_r(struct _reent* r, int fd, struct stat* st);
int __wrap__fstat_r(struct _reent* r, int fd, struct stat* st) {
int result;
if (tryAppFstat(fd, st, &result)) {
if (app_libc_try_fstat(fd, st, &result)) {
return result;
}
return __real__fstat_r(r, fd, st);
@ -89,7 +86,7 @@ int __real_poll(struct pollfd* fds, nfds_t nfds, int timeout);
int __wrap_poll(struct pollfd* fds, nfds_t nfds, int timeout) {
int result;
if (tryAppPoll(fds, nfds, timeout, __real_poll, &result)) {
if (app_libc_try_poll(fds, nfds, timeout, __real_poll, &result)) {
return result;
}
return __real_poll(fds, nfds, timeout);
@ -100,7 +97,7 @@ int __real_tcsetattr(int fd, int optional_actions, const struct termios* p);
int __wrap_tcgetattr(int fd, struct termios* p) {
int result;
if (tryAppTcgetattr(fd, p, &result)) {
if (app_libc_try_tcgetattr(fd, p, &result)) {
return result;
}
return __real_tcgetattr(fd, p);
@ -108,12 +105,32 @@ int __wrap_tcgetattr(int fd, struct termios* p) {
int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p) {
int result;
if (tryAppTcsetattr(fd, &result)) {
if (app_libc_try_tcsetattr(fd, p, &result)) {
return result;
}
return __real_tcsetattr(fd, optional_actions, p);
}
// ESP-IDF's newlib has no signal() of its own, so this is the only definition
_sig_func_ptr signal(int sig, _sig_func_ptr handler) {
AppLibcSignalHandler previous;
if (app_libc_try_signal(sig, handler, &previous)) {
return previous;
}
errno = ENOSYS;
return SIG_ERR;
}
// Replaces the libc kill(), which reaches a _kill_r stub that aborts the device on some libc builds
int kill(pid_t pid, int sig) {
int result;
if (app_libc_try_kill(static_cast<int>(pid), sig, &result)) {
return result;
}
errno = ENOSYS;
return -1;
}
// Called by an app, newlib's exit() would reach _exit(), which aborts the whole device
[[noreturn]] void __real_exit(int status);
@ -123,5 +140,3 @@ int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p) {
}
}
#endif // ESP_PLATFORM

View File

@ -14,10 +14,9 @@ tactility_add_module(app-module
)
# 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 where those are wrapped: via
# -Wl,--wrap= on ESP-IDF (see the top-level CMakeLists.txt), or via source/stdio_wrap_posix.cpp's own
# read/write/close and printf-family definitions on POSIX (dyld interpose on Apple, plain
# symbol overrides elsewhere - see that file for why POSIX doesn't use --wrap).
# than calling ::read/::write/::close() directly, since those are wrapped by the platform module:
# app-esp32-module via -Wl,--wrap= (the linker provides __real_*, see the top-level CMakeLists.txt),
# app-posix-module via its own read/write/close overrides (it defines __real_*, see its stdio_wrap.cpp).
tactility_get_module_name(app-module MODULE_NAME)
target_compile_definitions(${MODULE_NAME} PRIVATE TT_APP_IO_WRAPS_STDIO)

View File

@ -13,7 +13,7 @@
* instance's cwd.
*
* Matches POSIX getcwd()/chdir() semantics, and is what their real-syscall wraps
* (Modules/app-module/source/stdio_wrap.cpp) route to for a calling task that is an app instance.
* (the platform modules' stdio_wrap.cpp, via app/libc.h) route to for a calling task that is an app instance.
*/
#ifdef __cplusplus

View File

@ -47,7 +47,6 @@ struct AppFileOps {
struct AppFile {
const struct AppFileOps* ops;
void* object;
bool suppress_console_tee;
};
#ifdef __cplusplus

View File

@ -53,7 +53,6 @@ error_t app_io_ioctl(int fd, AppIoctlRequest request, void* arg);
/**
* Installs a custom AppFileOps at @a fd in the calling task's own app instance fd table.
* Unlike AppStreamBinding, writes are never teed to the real underlying fd.
* @warning Must be called from the app instance's own task.
* @retval ERROR_NOT_FOUND the calling task isn't a running app instance
* @retval ERROR_OUT_OF_RANGE @a fd is outside [0, APP_MAX_FDS)

View File

@ -0,0 +1,70 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <sys/poll.h>
#ifdef __cplusplus
extern "C" {
#endif
struct stat;
struct termios;
/**
* App-instance behavior for libc calls, used by the platform modules' libc wraps
* (app-esp32-module, app-posix-module).
*
* Each app_libc_try_*() function returns false when the call has nothing to do with an app instance
* (not called from an app's task, or not an app fd), so the caller falls through to the real libc
* function. When it returns true, the call was handled: *out_result holds the return value, and errno
* is set on failure. A closed app fd fails with EBADF, so it never reaches a real fd of the same number.
*/
/** Handles ioctl(fd, TIOCGWINSZ, struct winsize*) for an app fd. */
bool app_libc_try_window_size(int fd, unsigned long request, void* arg, int* out_result);
/** @param[out] out_result @a buf on success, NULL on failure */
bool app_libc_try_getcwd(char* buf, size_t size, char** out_result);
bool app_libc_try_chdir(const char* path, int* out_result);
/** App fds report as character devices, which also makes isatty() true for them. */
bool app_libc_try_fstat(int fd, struct stat* st, int* out_result);
/**
* App fd input is always raw and unechoed, and a written '\n' also returns the cursor.
* The input flags are per app instance: ICRNL (on by default, like a terminal) makes stdin read '\r' as '\n'.
*/
bool app_libc_try_tcgetattr(int fd, struct termios* t, int* out_result);
/** Stores the input flags (c_iflag, of which ICRNL is applied). Other settings are accepted without effect. */
bool app_libc_try_tcsetattr(int fd, const struct termios* t, int* out_result);
/** Signal numbers an app can register a handler for are 1 to APP_LIBC_SIGNAL_COUNT - 1. */
#define APP_LIBC_SIGNAL_COUNT 32
typedef void (*AppLibcSignalHandler)(int sig);
/**
* Records @a handler for @a sig in the calling app instance. Signals aren't delivered to apps yet,
* so the handler is never called. SIGKILL, SIGSTOP and out-of-range signals fail with EINVAL.
* @param[out] out_previous the previously registered handler, or SIG_ERR on failure
*/
bool app_libc_try_signal(int sig, AppLibcSignalHandler handler, AppLibcSignalHandler* out_previous);
/** Apps can't send signals: kill() from an app fails with ENOSYS rather than reaching the process. */
bool app_libc_try_kill(int pid, int sig, int* out_result);
typedef int (*AppLibcPollFunction)(struct pollfd* fds, nfds_t nfds, int timeout);
/**
* Handles poll() when @a fds contains an app fd. Other fds are checked with @a real_poll.
* Closed app fds report POLLNVAL.
*/
bool app_libc_try_poll(struct pollfd* fds, nfds_t nfds, int timeout, AppLibcPollFunction real_poll, int* out_result);
#ifdef __cplusplus
}
#endif

View File

@ -5,6 +5,7 @@
#include <tactility/error.h>
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
@ -59,6 +60,9 @@ struct PackageManifest {
*/
char requires_device_id[PACKAGE_MANIFEST_REQUIRES_DEVICE_ID_LENGTH + 1];
/** RAM in megabytes (1024 * 1024 bytes) the package needs to run ("requires.ram", e.g. "2M"). 0 means no requirement. */
uint8_t requires_ram;
/** How many AppManifest entries this package's manifest.properties declared. */
uint32_t app_manifest_count;
};
@ -86,6 +90,12 @@ struct AppManifestBinding {
*/
error_t app_package_manifest_parse(const char* path, struct PackageManifest* out_package, struct AppManifestBinding* out_bindings, size_t bindings_capacity);
/**
* @return true if this device can run the package: it is listed in requires_device_id (or that is empty),
* and its total RAM (internal plus external) is at least requires_ram megabytes
*/
bool app_package_manifest_is_compatible(const struct PackageManifest* const manifest);
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
// ESP-IDF only provides <sys/poll.h>. Apps include this through app-module's include path.
#pragma once
#include <sys/poll.h>

View File

@ -0,0 +1,16 @@
// SPDX-License-Identifier: Apache-2.0
// ESP-IDF's <sys/ioctl.h> has no terminal ioctls. Apps include this through app-module's include path.
// For an app fd, ioctl(fd, TIOCGWINSZ, &winsize) reports the terminal size (see app/libc.h).
#pragma once
#include_next <sys/ioctl.h>
#ifndef TIOCGWINSZ
#define TIOCGWINSZ 0x5413
struct winsize {
unsigned short ws_row;
unsigned short ws_col;
unsigned short ws_xpixel;
unsigned short ws_ypixel;
};
#endif

View File

@ -58,10 +58,9 @@ void app_fd_table_teardown(struct AppFdTable* table);
/**
* Installs {ops, object} at @a fd, closing whatever was previously there first (the null device
* counts as "previously there" for fds 0-2, so this doubles as their initial stdio binding).
* @param[in] suppress_console_tee see AppFile::suppress_console_tee
* @retval ERROR_OUT_OF_RANGE @a fd is outside [0, APP_MAX_FDS)
*/
error_t app_fd_table_bind(struct AppFdTable* table, int fd, const struct AppFileOps* ops, void* object, bool suppress_console_tee);
error_t app_fd_table_bind(struct AppFdTable* table, int fd, const struct AppFileOps* ops, void* object);
/**
* Installs {ops, object} at the lowest unused fd >= 3.

View File

@ -2,6 +2,7 @@
#pragma once
#include <app/instance.h>
#include <app/libc.h>
#include <app/manifest.h>
#include <app/package_manifest.h>
#include <app/private/fd_table.h>
@ -14,6 +15,7 @@
#include <tactility/freertos/task.h>
#include <stdint.h>
#include <termios.h>
#include <string>
#include <unordered_map>
#include <vector>
@ -71,6 +73,12 @@ struct AppInstanceRecord {
/** Inherited from the parent instance, like `env`. Mutable via app_dir_set_cwd() (app/dir.h).
* Always absolute. */
std::string cwd = "/";
/** Handlers registered via signal() (app/libc.h), indexed by signal number. Null means SIG_DFL. */
AppLibcSignalHandler signal_handlers[APP_LIBC_SIGNAL_COUNT] {};
/** Terminal input flags set via tcsetattr() (app/libc.h). ICRNL is applied when reading stdin. */
tcflag_t termios_iflag = ICRNL;
};
/** A registered installed package - see app_manager_add_package() (app/manager.h). */

View File

@ -1,57 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <cstddef>
#include <cstdio>
#include <sys/ioctl.h>
#include <sys/poll.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <termios.h>
// Neither platform's headers declare TIOCGWINSZ/struct winsize (ESP-IDF's sys/ioctl.h has no
// terminal ioctls at all; the guard is only for POSIX, where <sys/ioctl.h> already provides them).
#ifndef TIOCGWINSZ
#define TIOCGWINSZ 0x5413
struct winsize {
unsigned short ws_row;
unsigned short ws_col;
unsigned short ws_xpixel;
unsigned short ws_ypixel;
};
#endif
using PollFunction = int (*)(struct pollfd*, nfds_t, int);
/** @return true (with *out filled) if this was a window-size query on an app-owned fd */
bool tryAppWindowSize(int fd, unsigned long request, void* arg, struct winsize* out);
/** @return false if the caller isn't an app instance and should fall through to the real getcwd() */
bool tryAppGetCwd(char* buf, size_t size, char** out, int* outErrno);
/** @return false if the caller isn't an app instance and should fall through to the real chdir() */
bool tryAppChdir(const char* path, int* outResult, int* outErrno);
enum class AppFdState {
Bound,
/** An app fd that was already closed. Fails with EBADF rather than reaching a real fd of the same number. */
Closed,
/** Not an app fd. Falls through to the real syscall. */
NotAppFd,
};
AppFdState getAppFdState(int fd);
/**
* The tryApp*() fd helpers below set *outResult (and errno on failure) when they return true.
* @return false if @a fd isn't an app fd and the caller should fall through to the real call
*/
bool tryAppFstat(int fd, struct stat* st, int* outResult);
bool tryAppTcgetattr(int fd, struct termios* t, int* outResult);
bool tryAppTcsetattr(int fd, int* outResult);
/**
* Closed app fds report POLLNVAL.
* @return false if no fd in @a fds is an app fd and the caller should fall through to @a realPoll
*/
bool tryAppPoll(struct pollfd* fds, nfds_t nfds, int timeout, PollFunction realPoll, int* outResult);

View File

@ -71,7 +71,7 @@ void app_fd_table_teardown(AppFdTable* table) {
mutex_destruct(&table->mutex);
}
error_t app_fd_table_bind(AppFdTable* table, int fd, const AppFileOps* ops, void* object, bool suppress_console_tee) {
error_t app_fd_table_bind(AppFdTable* table, int fd, const AppFileOps* ops, void* object) {
if (!fd_in_range(fd)) {
return ERROR_OUT_OF_RANGE;
}
@ -81,7 +81,7 @@ error_t app_fd_table_bind(AppFdTable* table, int fd, const AppFileOps* ops, void
AppFdSlot& slot = table->slots[fd];
AppFile old = slot.file;
bool had_old = slot.in_use;
slot.file = { .ops = ops, .object = object, .suppress_console_tee = suppress_console_tee };
slot.file = { .ops = ops, .object = object };
slot.in_use = true;
slot.ever_used = true;
mutex_unlock(&table->mutex);

View File

@ -48,6 +48,20 @@ int real_close(int fd) { return ::close(fd); }
namespace {
// Whether the calling app instance has ICRNL set (see AppInstanceRecord::termios_iflag).
bool current_app_translates_cr() {
AppInstanceId app_id = app_scheduler_current_app_id();
if (app_id == 0) {
return false;
}
auto& ledger = app_ledger();
mutex_lock(&ledger.mutex);
auto iterator = ledger.instances.find(app_id);
bool result = (iterator != ledger.instances.end()) && (iterator->second.termios_iflag & ICRNL) != 0;
mutex_unlock(&ledger.mutex);
return result;
}
// NULL for a caller not running as an app instance (e.g. a kernel service task). @a fd is then
// a real underlying fd, handled by the caller falling through to the real syscall.
AppFdTable* current_app_fd_table() {
@ -75,6 +89,15 @@ ssize_t app_io_read(int fd, void* buffer, size_t size) {
if (file.ops->release != nullptr) {
file.ops->release(file.object);
}
// Like a terminal with ICRNL: the Enter key's '\r' reads as '\n'
if (fd == STDIN_FILENO && result > 0 && current_app_translates_cr()) {
auto* bytes = static_cast<char*>(buffer);
for (ssize_t i = 0; i < result; i++) {
if (bytes[i] == '\r') {
bytes[i] = '\n';
}
}
}
return result;
}
// @a fd isn't currently bound. If this table has never touched it either, it's a real
@ -96,17 +119,6 @@ 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 normally 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. An instance that bound its own fd
// (see app_io_bind_self()) opts out - it wants exclusive ownership, not a silent tap.
if (!file.suppress_console_tee) {
real_write(fd, buffer, size);
}
return result;
}
if (table != nullptr && app_fd_table_is_app_owned(table, fd)) {
@ -184,7 +196,7 @@ error_t app_io_bind_self(int fd, const AppFileOps* ops, void* object) {
if (table == nullptr) {
return ERROR_NOT_FOUND;
}
return app_fd_table_bind(table, fd, ops, object, /*suppress_console_tee=*/true);
return app_fd_table_bind(table, fd, ops, object);
}
} // extern "C"

View File

@ -0,0 +1,315 @@
// SPDX-License-Identifier: Apache-2.0
#include <app/libc.h>
#include <app/dir.h>
#include <app/io.h>
#include <app/private/ledger.h>
#include <app/scheduler.h>
#include <tactility/delay.h>
#include <tactility/paths.h>
#include <tactility/time.h>
#include <signal.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <termios.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
namespace {
enum class AppFdState {
Bound,
/** An app fd that was already closed. Fails with EBADF rather than reaching a real fd of the same number. */
Closed,
/** Not an app fd. Falls through to the real syscall. */
NotAppFd,
};
AppFdState get_app_fd_state(int fd) {
uint32_t bits;
switch (app_io_poll(fd, &bits)) {
case ERROR_NONE:
return AppFdState::Bound;
case ERROR_INVALID_STATE:
return AppFdState::Closed;
default:
return AppFdState::NotAppFd;
}
}
// Upper bound on wake latency while waiting on more than one fd: app streams can only be awaited one at a time.
constexpr TickType_t POLL_INTERVAL_TICKS = pdMS_TO_TICKS(10);
} // namespace
extern "C" {
bool app_libc_try_window_size(int fd, unsigned long request, void* arg, int* out_result) {
if (request != TIOCGWINSZ) {
return false;
}
const AppFdState state = get_app_fd_state(fd);
if (state == AppFdState::Closed) {
errno = EBADF;
*out_result = -1;
return true;
}
if (arg == nullptr || state == AppFdState::NotAppFd) {
return false;
}
AppWindowSize size {};
if (app_io_ioctl(fd, APP_IOCTL_GET_WINDOW_SIZE, &size) != ERROR_NONE) {
return false;
}
auto* out = static_cast<struct winsize*>(arg);
out->ws_row = size.rows;
out->ws_col = size.columns;
out->ws_xpixel = 0;
out->ws_ypixel = 0;
*out_result = 0;
return true;
}
// app_dir_get_cwd() distinguishes "not an app instance" (ERROR_NOT_FOUND, caller falls through to the
// real syscall) from "buffer too small" (ERROR_BUFFER_OVERFLOW, a real failure to report).
bool app_libc_try_getcwd(char* buf, size_t size, char** out_result) {
if (buf == nullptr) {
return false;
}
const error_t result = app_dir_get_cwd(buf, size);
if (result == ERROR_NONE) {
*out_result = buf;
return true;
}
if (result == ERROR_BUFFER_OVERFLOW) {
errno = ERANGE;
*out_result = nullptr;
return true;
}
return false; // ERROR_NOT_FOUND: not an app instance.
}
// app_dir_set_cwd() requires an already-absolute path and reports both "not an app instance" and
// "no such directory" as ERROR_NOT_FOUND, so app_dir_get_cwd() is used first as an unambiguous
// "is this an app instance" probe (it's needed anyway, to resolve a relative path).
bool app_libc_try_chdir(const char* path, int* out_result) {
if (path == nullptr || path[0] == '\0') {
return false;
}
char cwd[FILE_MAX_PATH_STRING_LENGTH];
if (app_dir_get_cwd(cwd, sizeof(cwd)) != ERROR_NONE) {
return false; // not an app instance
}
char resolved[FILE_MAX_PATH_STRING_LENGTH];
const int written = (path[0] == '/') ? snprintf(resolved, sizeof(resolved), "%s", path)
: (strcmp(cwd, "/") == 0) ? snprintf(resolved, sizeof(resolved), "/%s", path)
: snprintf(resolved, sizeof(resolved), "%s/%s", cwd, path);
if (written < 0 || static_cast<size_t>(written) >= sizeof(resolved)) {
errno = ENAMETOOLONG;
*out_result = -1;
return true;
}
if (app_dir_set_cwd(resolved) == ERROR_NONE) {
*out_result = 0;
} else {
errno = ENOENT;
*out_result = -1;
}
return true;
}
bool app_libc_try_fstat(int fd, struct stat* st, int* out_result) {
const AppFdState state = get_app_fd_state(fd);
if (state == AppFdState::NotAppFd) {
return false;
}
if (state == AppFdState::Closed) {
errno = EBADF;
*out_result = -1;
return true;
}
if (st == nullptr) {
errno = EFAULT;
*out_result = -1;
return true;
}
memset(st, 0, sizeof(*st));
st->st_mode = S_IFCHR | 0666;
*out_result = 0;
return true;
}
bool app_libc_try_tcgetattr(int fd, struct termios* t, int* out_result) {
const AppFdState state = get_app_fd_state(fd);
if (state == AppFdState::NotAppFd) {
return false;
}
if (state == AppFdState::Closed) {
errno = EBADF;
*out_result = -1;
return true;
}
if (t == nullptr) {
errno = EFAULT;
*out_result = -1;
return true;
}
tcflag_t iflag = ICRNL;
auto& ledger = app_ledger();
mutex_lock(&ledger.mutex);
auto iterator = ledger.instances.find(app_scheduler_current_app_id());
if (iterator != ledger.instances.end()) {
iflag = iterator->second.termios_iflag;
}
mutex_unlock(&ledger.mutex);
memset(t, 0, sizeof(*t));
t->c_iflag = iflag;
t->c_oflag = OPOST | ONLCR;
t->c_cflag = CS8 | CREAD;
t->c_cc[VMIN] = 1;
t->c_cc[VTIME] = 0;
*out_result = 0;
return true;
}
bool app_libc_try_tcsetattr(int fd, const struct termios* t, int* out_result) {
const AppFdState state = get_app_fd_state(fd);
if (state == AppFdState::NotAppFd) {
return false;
}
if (state == AppFdState::Closed) {
errno = EBADF;
*out_result = -1;
return true;
}
if (t == nullptr) {
errno = EFAULT;
*out_result = -1;
return true;
}
auto& ledger = app_ledger();
mutex_lock(&ledger.mutex);
auto iterator = ledger.instances.find(app_scheduler_current_app_id());
if (iterator != ledger.instances.end()) {
iterator->second.termios_iflag = t->c_iflag;
}
mutex_unlock(&ledger.mutex);
*out_result = 0;
return true;
}
bool app_libc_try_signal(int sig, AppLibcSignalHandler handler, AppLibcSignalHandler* out_previous) {
const AppInstanceId app_instance_id = app_scheduler_current_app_id();
if (app_instance_id == 0) {
return false;
}
if (sig <= 0 || sig >= APP_LIBC_SIGNAL_COUNT || sig == SIGKILL || sig == SIGSTOP) {
errno = EINVAL;
*out_previous = SIG_ERR;
return true;
}
auto& ledger = app_ledger();
mutex_lock(&ledger.mutex);
auto iterator = ledger.instances.find(app_instance_id);
if (iterator == ledger.instances.end()) {
mutex_unlock(&ledger.mutex);
return false;
}
AppLibcSignalHandler previous = iterator->second.signal_handlers[sig];
iterator->second.signal_handlers[sig] = handler;
mutex_unlock(&ledger.mutex);
*out_previous = previous;
return true;
}
bool app_libc_try_kill(int pid, int sig, int* out_result) {
if (app_scheduler_current_app_id() == 0) {
return false;
}
errno = ENOSYS;
*out_result = -1;
return true;
}
// Non-app fds in @a fds are checked with a zero timeout real poll() on every iteration.
bool app_libc_try_poll(struct pollfd* fds, nfds_t nfds, int timeout, AppLibcPollFunction real_poll, int* out_result) {
if (fds == nullptr) {
return false;
}
// A closed app fd is always ready (POLLNVAL), so the wait below always has a bound fd to await
int first_app_index = -1;
bool has_app_fd = false;
nfds_t active_count = 0;
for (nfds_t i = 0; i < nfds; i++) {
if (fds[i].fd < 0) {
continue;
}
active_count++;
const AppFdState state = get_app_fd_state(fds[i].fd);
has_app_fd = has_app_fd || state != AppFdState::NotAppFd;
if (first_app_index < 0 && state == AppFdState::Bound) {
first_app_index = static_cast<int>(i);
}
}
if (!has_app_fd) {
return false;
}
const TickType_t start = get_ticks();
const TickType_t timeout_ticks = (timeout < 0) ? portMAX_DELAY : pdMS_TO_TICKS(timeout);
while (true) {
int ready = 0;
for (nfds_t i = 0; i < nfds; i++) {
fds[i].revents = 0;
if (fds[i].fd < 0) {
continue;
}
uint32_t bits;
const error_t poll_result = app_io_poll(fds[i].fd, &bits);
if (poll_result == ERROR_NONE) {
if ((fds[i].events & POLLIN) && (bits & APP_FILE_READABLE)) {
fds[i].revents |= POLLIN;
}
if ((fds[i].events & POLLOUT) && (bits & APP_FILE_WRITABLE)) {
fds[i].revents |= POLLOUT;
}
} else if (poll_result == ERROR_INVALID_STATE) {
fds[i].revents = POLLNVAL;
} else {
real_poll(&fds[i], 1, 0);
}
if (fds[i].revents != 0) {
ready++;
}
}
if (ready > 0) {
*out_result = ready;
return true;
}
TickType_t remaining = (timeout < 0) ? portMAX_DELAY : get_timeout_remaining_ticks(timeout_ticks, start);
if (remaining == 0) {
*out_result = 0;
return true;
}
if (active_count > 1 && remaining > POLL_INTERVAL_TICKS) {
remaining = POLL_INTERVAL_TICKS;
}
const struct pollfd& first = fds[first_app_index];
if (first.events & POLLIN) {
app_io_await(first.fd, APP_FILE_WAIT_READABLE, remaining);
} else if (first.events & POLLOUT) {
app_io_await(first.fd, APP_FILE_WAIT_WRITABLE, remaining);
} else {
delay_ticks(remaining < POLL_INTERVAL_TICKS ? remaining : POLL_INTERVAL_TICKS);
}
}
}
} // extern "C"

View File

@ -78,6 +78,7 @@ static const ModuleSymbol SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(app_manifest_stack_size_is_valid),
// app/package_manifest
DEFINE_MODULE_SYMBOL(app_package_manifest_parse),
DEFINE_MODULE_SYMBOL(app_package_manifest_is_compatible),
// app/paths
DEFINE_MODULE_SYMBOL(app_paths_get_user_data_directory),
DEFINE_MODULE_SYMBOL(app_paths_get_user_data_path),

View File

@ -0,0 +1,46 @@
// SPDX-License-Identifier: Apache-2.0
#include <app/package_manifest.h>
#include <tactility/memory.h>
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#include <cstring>
namespace {
// requires_device_id is a comma-separated list without spaces (see app_package_manifest_is_valid_device_id_list())
bool is_device_listed(const char* device_ids, const char* device_id) {
const size_t device_id_length = strlen(device_id);
const char* item = device_ids;
while (*item != '\0') {
const char* comma = strchr(item, ',');
const size_t item_length = (comma != nullptr) ? static_cast<size_t>(comma - item) : strlen(item);
if (item_length == device_id_length && strncmp(item, device_id, item_length) == 0) {
return true;
}
if (comma == nullptr) {
break;
}
item = comma + 1;
}
return false;
}
} // namespace
extern "C" {
bool app_package_manifest_is_compatible(const struct PackageManifest* const manifest) {
if (manifest->requires_device_id[0] != '\0' && !is_device_listed(manifest->requires_device_id, CONFIG_TT_DEVICE_ID)) {
return false;
}
// Internal and external memory together, as an app's allocations can come from either
const uint64_t total_ram = static_cast<uint64_t>(memory_heap_total()) + memory_external_total();
const uint64_t required_ram = static_cast<uint64_t>(manifest->requires_ram) * 1024 * 1024;
return total_ram >= required_ram;
}
} // extern "C"

View File

@ -210,6 +210,21 @@ error_t package_manifest_parse_v3(const std::map<std::string, std::string>& prop
}
}
// requires.ram: whole megabytes with an "M" suffix, e.g. "2M"
auto ram_iterator = properties.find("requires.ram");
if (ram_iterator != properties.end()) {
const std::string& ram = ram_iterator->second;
uint32_t megabytes = 0;
const char* first = ram.data();
const char* last = first + ram.size();
const auto [end, error] = std::from_chars(first, last, megabytes);
if (error != std::errc {} || end != last - 1 || *end != 'M' || megabytes > UINT8_MAX) {
LOG_E(TAG, "Invalid requires.ram");
return ERROR_INVALID_ARGUMENT;
}
out_package.requires_ram = static_cast<uint8_t>(megabytes);
}
// app.<index>.* blocks: 0-indexed, contiguous - the first missing "app.<index>.id" ends the list.
size_t count = 0;
while (properties.contains(std::format("app.{}.id", count))) {

View File

@ -34,6 +34,11 @@ constexpr auto* TAG = "app_scheduler";
constexpr size_t APP_INSTANCE_ID_THREAD_SLOT_INDEX = 1;
AppInstanceId get_current_app_id() {
// No current task before the scheduler starts (e.g. newlib's stdio setup calling fstat()),
// where pvTaskGetThreadLocalStoragePointer(nullptr, ...) would dereference a null task.
if (xTaskGetCurrentTaskHandle() == nullptr) {
return 0;
}
return reinterpret_cast<uintptr_t>(pvTaskGetThreadLocalStoragePointer(nullptr, APP_INSTANCE_ID_THREAD_SLOT_INDEX));
}

View File

@ -1,239 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// Platform-independent helpers for the syscall wraps in stdio_wrap_esp32.cpp and stdio_wrap_posix.cpp.
#include <app/private/stdio_wrap.h>
#include <app/dir.h>
#include <app/io.h>
#include <tactility/delay.h>
#include <tactility/paths.h>
#include <tactility/time.h>
#include <cerrno>
#include <cstring>
bool tryAppWindowSize(int fd, unsigned long request, void* arg, struct winsize* out) {
if (request != TIOCGWINSZ || arg == nullptr) {
return false;
}
AppWindowSize size {};
if (app_io_ioctl(fd, APP_IOCTL_GET_WINDOW_SIZE, &size) != ERROR_NONE) {
return false;
}
out->ws_row = size.rows;
out->ws_col = size.columns;
out->ws_xpixel = 0;
out->ws_ypixel = 0;
return true;
}
// getcwd(): app_dir_get_cwd() distinguishes "not an app instance" (ERROR_NOT_FOUND, caller falls
// through to the real syscall) from "buffer too small" (ERROR_BUFFER_OVERFLOW, a real failure to
// report), so no separate probe is needed here.
bool tryAppGetCwd(char* buf, size_t size, char** out, int* outErrno) {
if (buf == nullptr) {
return false;
}
const error_t result = app_dir_get_cwd(buf, size);
if (result == ERROR_NONE) {
*out = buf;
return true;
}
if (result == ERROR_BUFFER_OVERFLOW) {
*out = nullptr;
*outErrno = ERANGE;
return true;
}
return false; // ERROR_NOT_FOUND: not an app instance.
}
// chdir(): app_dir_set_cwd() requires an already-absolute path and reports both "not an app
// instance" and "no such directory" as ERROR_NOT_FOUND, so app_dir_get_cwd() is used first as a
// cheap, unambiguous "is this an app instance" probe (it's needed anyway, to resolve a relative
// path) - only once that confirms an app instance is calling is app_dir_set_cwd()'s own result
// treated as a real success/failure to report, rather than a reason to fall through.
bool tryAppChdir(const char* path, int* outResult, int* outErrno) {
if (path == nullptr || path[0] == '\0') {
return false;
}
char cwd[FILE_MAX_PATH_STRING_LENGTH];
if (app_dir_get_cwd(cwd, sizeof(cwd)) != ERROR_NONE) {
return false; // not an app instance
}
char resolved[FILE_MAX_PATH_STRING_LENGTH];
const int written = (path[0] == '/') ? snprintf(resolved, sizeof(resolved), "%s", path)
: (strcmp(cwd, "/") == 0) ? snprintf(resolved, sizeof(resolved), "/%s", path)
: snprintf(resolved, sizeof(resolved), "%s/%s", cwd, path);
if (written < 0 || static_cast<size_t>(written) >= sizeof(resolved)) {
*outResult = -1;
*outErrno = ENAMETOOLONG;
return true;
}
if (app_dir_set_cwd(resolved) == ERROR_NONE) {
*outResult = 0;
} else {
*outResult = -1;
*outErrno = ENOENT;
}
return true;
}
AppFdState getAppFdState(int fd) {
uint32_t bits;
switch (app_io_poll(fd, &bits)) {
case ERROR_NONE:
return AppFdState::Bound;
case ERROR_INVALID_STATE:
return AppFdState::Closed;
default:
return AppFdState::NotAppFd;
}
}
// App fds report as character devices, which also makes isatty() true for them.
bool tryAppFstat(int fd, struct stat* st, int* outResult) {
const AppFdState state = getAppFdState(fd);
if (state == AppFdState::NotAppFd) {
return false;
}
if (state == AppFdState::Closed) {
errno = EBADF;
*outResult = -1;
return true;
}
if (st == nullptr) {
errno = EFAULT;
*outResult = -1;
return true;
}
memset(st, 0, sizeof(*st));
st->st_mode = S_IFCHR | 0666;
*outResult = 0;
return true;
}
// App fd input is always raw and unechoed, and a written '\n' also returns the cursor.
bool tryAppTcgetattr(int fd, struct termios* t, int* outResult) {
const AppFdState state = getAppFdState(fd);
if (state == AppFdState::NotAppFd) {
return false;
}
if (state == AppFdState::Closed) {
errno = EBADF;
*outResult = -1;
return true;
}
if (t == nullptr) {
errno = EFAULT;
*outResult = -1;
return true;
}
memset(t, 0, sizeof(*t));
t->c_oflag = OPOST | ONLCR;
t->c_cflag = CS8 | CREAD;
t->c_cc[VMIN] = 1;
t->c_cc[VTIME] = 0;
*outResult = 0;
return true;
}
// Accepts any settings without applying them.
bool tryAppTcsetattr(int fd, int* outResult) {
const AppFdState state = getAppFdState(fd);
if (state == AppFdState::NotAppFd) {
return false;
}
if (state == AppFdState::Closed) {
errno = EBADF;
*outResult = -1;
return true;
}
*outResult = 0;
return true;
}
namespace {
// Upper bound on wake latency while waiting on more than one fd: app streams can only be awaited one at a time.
constexpr TickType_t POLL_INTERVAL_TICKS = pdMS_TO_TICKS(10);
} // namespace
// Non-app fds in @a fds are checked with a zero timeout real poll() on every iteration.
bool tryAppPoll(struct pollfd* fds, nfds_t nfds, int timeout, PollFunction realPoll, int* outResult) {
if (fds == nullptr) {
return false;
}
// A closed app fd is always ready (POLLNVAL), so the wait below always has a bound fd to await
int firstAppIndex = -1;
bool hasAppFd = false;
nfds_t activeCount = 0;
for (nfds_t i = 0; i < nfds; i++) {
if (fds[i].fd < 0) {
continue;
}
activeCount++;
const AppFdState state = getAppFdState(fds[i].fd);
hasAppFd = hasAppFd || state != AppFdState::NotAppFd;
if (firstAppIndex < 0 && state == AppFdState::Bound) {
firstAppIndex = static_cast<int>(i);
}
}
if (!hasAppFd) {
return false;
}
const TickType_t start = get_ticks();
const TickType_t timeoutTicks = (timeout < 0) ? portMAX_DELAY : pdMS_TO_TICKS(timeout);
while (true) {
int ready = 0;
for (nfds_t i = 0; i < nfds; i++) {
fds[i].revents = 0;
if (fds[i].fd < 0) {
continue;
}
uint32_t bits;
const error_t pollResult = app_io_poll(fds[i].fd, &bits);
if (pollResult == ERROR_NONE) {
if ((fds[i].events & POLLIN) && (bits & APP_FILE_READABLE)) {
fds[i].revents |= POLLIN;
}
if ((fds[i].events & POLLOUT) && (bits & APP_FILE_WRITABLE)) {
fds[i].revents |= POLLOUT;
}
} else if (pollResult == ERROR_INVALID_STATE) {
fds[i].revents = POLLNVAL;
} else {
realPoll(&fds[i], 1, 0);
}
if (fds[i].revents != 0) {
ready++;
}
}
if (ready > 0) {
*outResult = ready;
return true;
}
TickType_t remaining = (timeout < 0) ? portMAX_DELAY : get_timeout_remaining_ticks(timeoutTicks, start);
if (remaining == 0) {
*outResult = 0;
return true;
}
if (activeCount > 1 && remaining > POLL_INTERVAL_TICKS) {
remaining = POLL_INTERVAL_TICKS;
}
const struct pollfd& first = fds[firstAppIndex];
if (first.events & POLLIN) {
app_io_await(first.fd, APP_FILE_WAIT_READABLE, remaining);
} else if (first.events & POLLOUT) {
app_io_await(first.fd, APP_FILE_WAIT_WRITABLE, remaining);
} else {
delay_ticks(remaining < POLL_INTERVAL_TICKS ? remaining : POLL_INTERVAL_TICKS);
}
}
}

View File

@ -217,7 +217,7 @@ error_t app_stream_subscribe(AppStream* stream, void* buffer, size_t buffer_capa
return ERROR_NOT_FOUND;
}
stream->producer_task = iterator->second.task;
error_t bind_result = app_fd_table_bind(&iterator->second.fd_table, producer_fd, &STREAM_OPS, stream, /*suppress_console_tee=*/false);
error_t bind_result = app_fd_table_bind(&iterator->second.fd_table, producer_fd, &STREAM_OPS, stream);
mutex_unlock(&ledger.mutex);
if (bind_result != ERROR_NONE) {
@ -242,7 +242,7 @@ error_t app_stream_bind_alias_fd(AppStream* stream, int alias_fd) {
mutex_unlock(&ledger.mutex);
return ERROR_NOT_FOUND;
}
error_t result = app_fd_table_bind(&iterator->second.fd_table, alias_fd, &STREAM_OPS, stream, /*suppress_console_tee=*/false);
error_t result = app_fd_table_bind(&iterator->second.fd_table, alias_fd, &STREAM_OPS, stream);
mutex_unlock(&ledger.mutex);
return result;
}

View File

@ -10,13 +10,14 @@ target_include_directories(AppModuleTests PRIVATE ${DOCTESTINC} ${CMAKE_CURRENT_
add_test(NAME AppModuleTests COMMAND AppModuleTests)
# No --wrap flags or stdio_wrap sources needed here: linking app-module below already brings
# both along (see its own CMakeLists.txt).
# app-posix-module provides the __real_read/__real_write/__real_close that app-module's io.cpp falls
# through to. Tests of the libc wraps themselves live in app-posix-module's own tests.
target_link_libraries(AppModuleTests PUBLIC
TactilityKernel
TactilityKernelCpp
app-module
app-posix-module
service-module
platform-posix
freertos_kernel

View File

@ -111,11 +111,6 @@ int32_t stream_writer_app_main(int, char*[]) {
return 7;
}
int32_t printf_stream_writer_app_main(int, char*[]) {
printf("loc");
return 7;
}
// Writes argv[0] to its own stdout, for the app_execute_with_streams() argv-delivery test.
int32_t argv_echo_app_main(int argc, char* argv[]) {
if (argc < 1) {
@ -346,57 +341,3 @@ TEST_CASE("app_execute_for_result_with_streams delivers both the stream data and
app_manager_remove("test.app.execute.parent_streams");
}
TEST_CASE("app_execute_for_result_with_streams pipes a child's plain printf() calls too") {
ensure_memory_loader_registered();
AppManifest parent_manifest { "test.app.execute.printf_parent", "Parent", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(location_app_main) } };
REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE);
uint32_t parent_id = 0;
AppStartContext parent_context;
REQUIRE_EQ(app_start_context_from_id("test.app.execute.printf_parent", &parent_context), ERROR_NONE);
REQUIRE_EQ(app_start_with_context(&parent_context, &parent_id), ERROR_NONE);
CHECK(wait_for_state(parent_id, APP_INSTANCE_STATE_ACTIVE, 1000));
TaskEventGroup parent_event_group {};
task_event_group_construct(&parent_event_group);
AppEventSubscription parent_sub {};
REQUIRE_EQ(app_event_subscribe_with_app_id(&parent_sub, &parent_event_group, parent_id), ERROR_NONE);
uint8_t storage[64];
AppStream child_stdout {};
AppStreamBinding binding { STDOUT_FILENO, &child_stdout, storage, sizeof(storage), &parent_event_group, {}, -1 };
AppLocation location { APP_LOCATION_MEMORY, reinterpret_cast<void*>(printf_stream_writer_app_main) };
uint32_t child_id = 0;
AppStartContext context = app_start_context_for_location(location);
app_start_context_set_streams(&context, &binding, 1);
app_start_context_set_parent(&context, parent_id);
REQUIRE_EQ(app_start_with_context(&context, &child_id), ERROR_NONE);
std::vector<uint8_t> received;
while (app_stream_await(&child_stdout, APP_FILE_WAIT_READABLE, pdMS_TO_TICKS(1000)) == ERROR_NONE) {
uint8_t chunk[16];
size_t n = app_stream_read(&child_stdout, chunk, sizeof(chunk));
if (n == 0) {
break; // EOF
}
received.insert(received.end(), chunk, chunk + n);
}
REQUIRE_EQ(received.size(), 3u);
CHECK_EQ(std::memcmp(received.data(), "loc", 3), 0);
REQUIRE_EQ(task_event_group_wait(&parent_event_group, parent_sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE);
AppEvent event {};
REQUIRE_EQ(app_event_poll(&parent_sub, &event), ERROR_NONE);
CHECK_EQ(event.type, APP_EVENT_RESULT);
CHECK_EQ(event.result.launch_id, child_id);
CHECK_EQ(event.result.result, 7);
app_stream_unsubscribe(&child_stdout);
app_event_unsubscribe(&parent_sub);
task_event_group_destruct(&parent_event_group);
app_manager_stop(child_id);
app_manager_stop(parent_id);
app_manager_remove("test.app.execute.printf_parent");
}

View File

@ -13,15 +13,10 @@
#include <tactility/delay.h>
#include <fcntl.h>
#include <poll.h>
#include <sys/stat.h>
#include <termios.h>
#include <unistd.h>
#include <atomic>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
@ -208,160 +203,8 @@ int32_t double_close_app_main(int, char*[]) {
return 0;
}
std::atomic<bool> g_posix_stdin_is_char_device { false };
std::atomic<int> g_posix_tcgetattr_result { -2 };
std::atomic<bool> g_posix_stdin_is_raw { false };
std::atomic<int> g_posix_tcsetattr_result { -2 };
std::atomic<int> g_posix_poll_before_write { -2 };
std::atomic<int> g_posix_poll_after_write { -2 };
std::atomic<bool> g_posix_poll_first_done { false };
std::atomic<bool> g_posix_null_buffers_efault { false };
int32_t posix_calls_app_main(int, char*[]) {
struct stat st {};
g_posix_stdin_is_char_device.store(fstat(STDIN_FILENO, &st) == 0 && S_ISCHR(st.st_mode), std::memory_order_release);
struct termios t {};
g_posix_tcgetattr_result.store(tcgetattr(STDIN_FILENO, &t), std::memory_order_release);
g_posix_stdin_is_raw.store((t.c_lflag & (ICANON | ECHO)) == 0, std::memory_order_release);
g_posix_tcsetattr_result.store(tcsetattr(STDIN_FILENO, TCSANOW, &t), std::memory_order_release);
struct stat* volatile null_stat = nullptr;
struct termios* volatile null_termios = nullptr;
const bool fstat_efault = fstat(STDIN_FILENO, null_stat) == -1 && errno == EFAULT;
const bool tcgetattr_efault = tcgetattr(STDIN_FILENO, null_termios) == -1 && errno == EFAULT;
g_posix_null_buffers_efault.store(fstat_efault && tcgetattr_efault, std::memory_order_release);
struct pollfd fds { STDIN_FILENO, POLLIN, 0 };
g_posix_poll_before_write.store(poll(&fds, 1, 50), std::memory_order_release);
g_posix_poll_first_done.store(true, std::memory_order_release);
g_posix_poll_after_write.store(poll(&fds, 1, 1000), std::memory_order_release);
return 0;
}
std::atomic<bool> g_closed_fstat_ebadf { false };
std::atomic<bool> g_closed_tcgetattr_ebadf { false };
std::atomic<bool> g_closed_tcsetattr_ebadf { false };
std::atomic<int> g_closed_poll_result { -2 };
std::atomic<int> g_closed_poll_revents { -2 };
// Must never reach the process's real fd 0 (the host terminal on the simulator).
int32_t closed_stdin_posix_calls_app_main(int, char*[]) {
app_io_close(STDIN_FILENO);
struct stat st {};
g_closed_fstat_ebadf.store(fstat(STDIN_FILENO, &st) == -1 && errno == EBADF, std::memory_order_release);
struct termios t {};
g_closed_tcgetattr_ebadf.store(tcgetattr(STDIN_FILENO, &t) == -1 && errno == EBADF, std::memory_order_release);
g_closed_tcsetattr_ebadf.store(tcsetattr(STDIN_FILENO, TCSANOW, &t) == -1 && errno == EBADF, std::memory_order_release);
struct pollfd fds { STDIN_FILENO, POLLIN, 0 };
g_closed_poll_result.store(poll(&fds, 1, 1000), std::memory_order_release);
g_closed_poll_revents.store(fds.revents, std::memory_order_release);
return 0;
}
std::atomic<bool> g_exit_before { false };
std::atomic<bool> g_exit_after { false };
int32_t exit_app_main(int, char*[]) {
g_exit_before.store(true, std::memory_order_release);
exit(42);
g_exit_after.store(true, std::memory_order_release);
return 0;
}
} // namespace
TEST_CASE("exit() in an app ends only that app instance") {
ensure_memory_loader_registered();
g_exit_before.store(false, std::memory_order_relaxed);
g_exit_after.store(false, std::memory_order_relaxed);
AppManifest manifest { "test.io.exit", "Exit", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(exit_app_main) } };
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
AppInstanceId instance_id = 0;
AppStartContext context;
REQUIRE_EQ(app_start_context_from_id("test.io.exit", &context), ERROR_NONE);
REQUIRE_EQ(app_start_with_context(&context, &instance_id), ERROR_NONE);
REQUIRE(wait_for_state(instance_id, APP_INSTANCE_STATE_STOPPED, 2000));
CHECK(g_exit_before.load(std::memory_order_acquire));
CHECK_FALSE(g_exit_after.load(std::memory_order_acquire));
app_manager_remove("test.io.exit");
}
TEST_CASE("fstat, termios and poll on a closed app stdin fail with EBADF/POLLNVAL instead of reaching the real fd") {
ensure_memory_loader_registered();
g_closed_fstat_ebadf.store(false, std::memory_order_relaxed);
g_closed_tcgetattr_ebadf.store(false, std::memory_order_relaxed);
g_closed_tcsetattr_ebadf.store(false, std::memory_order_relaxed);
g_closed_poll_result.store(-2, std::memory_order_relaxed);
g_closed_poll_revents.store(-2, std::memory_order_relaxed);
AppManifest manifest { "test.io.closed_posix_calls", "ClosedPosixCalls", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(closed_stdin_posix_calls_app_main) } };
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
AppInstanceId instance_id = 0;
AppStartContext context;
REQUIRE_EQ(app_start_context_from_id("test.io.closed_posix_calls", &context), ERROR_NONE);
REQUIRE_EQ(app_start_with_context(&context, &instance_id), ERROR_NONE);
REQUIRE(wait_for_state(instance_id, APP_INSTANCE_STATE_STOPPED, 2000));
CHECK(g_closed_fstat_ebadf.load(std::memory_order_acquire));
CHECK(g_closed_tcgetattr_ebadf.load(std::memory_order_acquire));
CHECK(g_closed_tcsetattr_ebadf.load(std::memory_order_acquire));
CHECK_EQ(g_closed_poll_result.load(std::memory_order_acquire), 1);
CHECK_EQ(g_closed_poll_revents.load(std::memory_order_acquire), POLLNVAL);
app_manager_remove("test.io.closed_posix_calls");
}
TEST_CASE("fstat, termios and poll on a bound app stdin report a raw character device that becomes readable") {
ensure_memory_loader_registered();
g_posix_stdin_is_char_device.store(false, std::memory_order_relaxed);
g_posix_tcgetattr_result.store(-2, std::memory_order_relaxed);
g_posix_stdin_is_raw.store(false, std::memory_order_relaxed);
g_posix_tcsetattr_result.store(-2, std::memory_order_relaxed);
g_posix_poll_before_write.store(-2, std::memory_order_relaxed);
g_posix_poll_after_write.store(-2, std::memory_order_relaxed);
g_posix_poll_first_done.store(false, std::memory_order_relaxed);
g_posix_null_buffers_efault.store(false, std::memory_order_relaxed);
AppManifest manifest { "test.io.posix_calls", "PosixCalls", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(posix_calls_app_main) } };
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
uint8_t storage[16];
AppStream child_stdin {};
AppStreamBinding binding { STDIN_FILENO, &child_stdin, storage, sizeof(storage), &event_group, {}, -1 };
AppInstanceId child_id = 0;
AppStartContext context;
REQUIRE_EQ(app_start_context_from_id("test.io.posix_calls", &context), ERROR_NONE);
app_start_context_set_streams(&context, &binding, 1);
REQUIRE_EQ(app_start_with_context(&context, &child_id), ERROR_NONE);
REQUIRE(wait_for_flag(g_posix_poll_first_done, 1000));
app_stream_write(&child_stdin, "x", 1);
REQUIRE(wait_for_state(child_id, APP_INSTANCE_STATE_STOPPED, 1000));
CHECK(g_posix_stdin_is_char_device.load(std::memory_order_acquire));
CHECK_EQ(g_posix_tcgetattr_result.load(std::memory_order_acquire), 0);
CHECK(g_posix_stdin_is_raw.load(std::memory_order_acquire));
CHECK_EQ(g_posix_tcsetattr_result.load(std::memory_order_acquire), 0);
CHECK(g_posix_null_buffers_efault.load(std::memory_order_acquire));
CHECK_EQ(g_posix_poll_before_write.load(std::memory_order_acquire), 0);
CHECK_EQ(g_posix_poll_after_write.load(std::memory_order_acquire), 1);
app_stream_unsubscribe(&child_stdin);
task_event_group_destruct(&event_group);
app_manager_remove("test.io.posix_calls");
}
TEST_CASE("an app's stdio fds default to the null device: write succeeds and discards, read reports EOF") {
ensure_memory_loader_registered();
g_stdio_write_result.store(-2, std::memory_order_relaxed);

View File

@ -0,0 +1,79 @@
// SPDX-License-Identifier: Apache-2.0
#include "doctest.h"
#include <app/package_manifest.h>
#include <app/private/package_manifest_parsing.h>
#include <cstring>
#include <map>
#include <string>
namespace {
std::map<std::string, std::string> minimal_v3_properties() {
return {
{ "manifest.version", "0.3" },
{ "id", "test.package" },
{ "version.name", "1.0.0" },
{ "version.code", "1" },
{ "target.sdk", "0.8.0" },
{ "app.0.id", "test.package.app" },
{ "app.0.name", "App" },
{ "app.0.binary", "app" },
};
}
error_t parse(const std::map<std::string, std::string>& properties, PackageManifest& package) {
AppManifestBinding bindings[1];
return package_manifest_parse_v3(properties, package, bindings, 1);
}
} // namespace
TEST_CASE("package manifest v3: requires.ram defaults to 0") {
PackageManifest package {};
REQUIRE_EQ(parse(minimal_v3_properties(), package), ERROR_NONE);
CHECK_EQ(package.requires_ram, 0);
}
TEST_CASE("package manifest v3: requires.ram parses whole megabytes with an M suffix") {
auto properties = minimal_v3_properties();
properties["requires.ram"] = "2M";
PackageManifest package {};
REQUIRE_EQ(parse(properties, package), ERROR_NONE);
CHECK_EQ(package.requires_ram, 2);
}
TEST_CASE("package manifest v3: an invalid requires.ram rejects the manifest") {
for (const char* value : { "2", "2m", "M", "256M", "-1M", "2MB", "" }) {
CAPTURE(value);
auto properties = minimal_v3_properties();
properties["requires.ram"] = value;
PackageManifest package {};
CHECK_EQ(parse(properties, package), ERROR_INVALID_ARGUMENT);
}
}
TEST_CASE("package manifest compatibility: requires_device_id must list this device, if set") {
PackageManifest package {};
CHECK(app_package_manifest_is_compatible(&package));
strcpy(package.requires_device_id, CONFIG_TT_DEVICE_ID);
CHECK(app_package_manifest_is_compatible(&package));
strcpy(package.requires_device_id, "other-device," CONFIG_TT_DEVICE_ID);
CHECK(app_package_manifest_is_compatible(&package));
strcpy(package.requires_device_id, "other-device");
CHECK_FALSE(app_package_manifest_is_compatible(&package));
// A listed id that only starts with this device's id is a different device
strcpy(package.requires_device_id, CONFIG_TT_DEVICE_ID "-v2");
CHECK_FALSE(app_package_manifest_is_compatible(&package));
}
TEST_CASE("package manifest compatibility: requires_ram must fit in total RAM") {
PackageManifest package {};
package.requires_ram = 1;
CHECK(app_package_manifest_is_compatible(&package));
}

View File

@ -7,8 +7,12 @@ file(GLOB_RECURSE SOURCE_FILES "source/*.c*")
tactility_add_module(app-posix-module
SRCS ${SOURCE_FILES}
INCLUDE_DIRS include/
PRIV_INCLUDE_DIRS private/
REQUIRES TactilityKernel app-module service-module
PRIV_REQUIRES ${CMAKE_DL_LIBS}
# The libc overrides in source/stdio_wrap*.cpp replace libc's own by name and are never referenced
# directly, and app-module's io.cpp calls back into their __real_read/__real_write/__real_close
WHOLE_ARCHIVE
)
# Baked in at compile time, mirroring ESP32's CONFIG_IDF_TARGET - names the installed-app binary

View File

@ -7,10 +7,13 @@
#include <sys/poll.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <app/libc.h>
#include <signal.h>
#include <termios.h>
// Implemented in stdio_wrap_posix.cpp, installed under the real names by stdio_wrap_elf.cpp or stdio_wrap_apple.cpp.
// Implemented in stdio_wrap.cpp, installed under the real names by stdio_wrap_elf.cpp or stdio_wrap_apple.cpp.
extern "C" {
ssize_t __wrap_read(int fd, void* buffer, size_t size);
ssize_t __wrap_write(int fd, const void* buffer, size_t size);
@ -22,6 +25,8 @@ int __wrap_fstat(int fd, struct stat* st);
int __wrap_poll(struct pollfd* fds, nfds_t nfds, int timeout);
int __wrap_tcgetattr(int fd, struct termios* p);
int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p);
AppLibcSignalHandler __wrap_signal(int sig, AppLibcSignalHandler handler);
int __wrap_kill(pid_t pid, int sig);
[[noreturn]] void __wrap_exit(int status);
int __wrap_vprintf(const char* format, va_list args);

View File

@ -1,13 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
#ifndef ESP_PLATFORM
// POSIX can't use -Wl,--wrap= like ESP32: --wrap doesn't reach a dlopen()ed app's own printf/write
// calls, so these wraps are installed under their real names instead - dyld interpose on Apple
// (stdio_wrap_apple.cpp), plain strong definitions elsewhere (stdio_wrap_elf.cpp).
#include <app/private/stdio_wrap.h>
#include <app/private/stdio_wrap_posix.h>
// libc wraps that route an app instance's calls to its own fds, cwd and exit(). App-instance behavior
// itself lives in app-module (app/libc.h). POSIX can't use -Wl,--wrap= like ESP32: --wrap doesn't reach
// a dlopen()ed app's own printf/write calls, so these wraps are installed under their real names instead
// - dyld interpose on Apple (stdio_wrap_apple.cpp), plain strong definitions elsewhere (stdio_wrap_elf.cpp).
// app-module's io.cpp falls through to the __real_read/__real_write/__real_close defined here.
#include <app_posix/stdio_wrap.h>
#include <app/io.h>
#include <app/libc.h>
#include <app/scheduler.h>
#include <cerrno>
@ -72,6 +73,16 @@ int __real_tcsetattr(int fd, int optional_actions, const struct termios* p) {
return real(fd, optional_actions, p);
}
AppLibcSignalHandler __real_signal(int sig, AppLibcSignalHandler handler) {
static auto real = reinterpret_cast<AppLibcSignalHandler (*)(int, AppLibcSignalHandler)>(dlsym(RTLD_NEXT, "signal"));
return real(sig, handler);
}
int __real_kill(pid_t pid, int sig) {
static auto real = reinterpret_cast<int (*)(pid_t, int)>(dlsym(RTLD_NEXT, "kill"));
return real(pid, sig);
}
[[noreturn]] void __real_exit(int status) {
static auto real = reinterpret_cast<void (*)(int)>(dlsym(RTLD_NEXT, "exit"));
real(status);
@ -96,21 +107,16 @@ int __wrap_ioctl(int fd, unsigned long request, ...) {
void* arg = va_arg(args, void*);
va_end(args);
struct winsize windowSize {};
if (tryAppWindowSize(fd, request, arg, &windowSize)) {
*static_cast<struct winsize*>(arg) = windowSize;
return 0;
int result;
if (app_libc_try_window_size(fd, request, arg, &result)) {
return result;
}
return __real_ioctl(fd, request, arg);
}
char* __wrap_getcwd(char* buf, size_t size) {
char* result;
int err;
if (tryAppGetCwd(buf, size, &result, &err)) {
if (result == nullptr) {
errno = err;
}
if (app_libc_try_getcwd(buf, size, &result)) {
return result;
}
return __real_getcwd(buf, size);
@ -118,11 +124,7 @@ char* __wrap_getcwd(char* buf, size_t size) {
int __wrap_chdir(const char* path) {
int result;
int err;
if (tryAppChdir(path, &result, &err)) {
if (result != 0) {
errno = err;
}
if (app_libc_try_chdir(path, &result)) {
return result;
}
return __real_chdir(path);
@ -130,7 +132,7 @@ int __wrap_chdir(const char* path) {
int __wrap_fstat(int fd, struct stat* st) {
int result;
if (tryAppFstat(fd, st, &result)) {
if (app_libc_try_fstat(fd, st, &result)) {
return result;
}
return __real_fstat(fd, st);
@ -138,7 +140,7 @@ int __wrap_fstat(int fd, struct stat* st) {
int __wrap_poll(struct pollfd* fds, nfds_t nfds, int timeout) {
int result;
if (tryAppPoll(fds, nfds, timeout, __real_poll, &result)) {
if (app_libc_try_poll(fds, nfds, timeout, __real_poll, &result)) {
return result;
}
return __real_poll(fds, nfds, timeout);
@ -146,7 +148,7 @@ int __wrap_poll(struct pollfd* fds, nfds_t nfds, int timeout) {
int __wrap_tcgetattr(int fd, struct termios* p) {
int result;
if (tryAppTcgetattr(fd, p, &result)) {
if (app_libc_try_tcgetattr(fd, p, &result)) {
return result;
}
return __real_tcgetattr(fd, p);
@ -154,12 +156,30 @@ int __wrap_tcgetattr(int fd, struct termios* p) {
int __wrap_tcsetattr(int fd, int optional_actions, const struct termios* p) {
int result;
if (tryAppTcsetattr(fd, &result)) {
if (app_libc_try_tcsetattr(fd, p, &result)) {
return result;
}
return __real_tcsetattr(fd, optional_actions, p);
}
// Called by an app, the real signal() would install a process-wide handler pointing into the app's binary
AppLibcSignalHandler __wrap_signal(int sig, AppLibcSignalHandler handler) {
AppLibcSignalHandler previous;
if (app_libc_try_signal(sig, handler, &previous)) {
return previous;
}
return __real_signal(sig, handler);
}
// Called by an app, the real kill() would signal the whole simulator (e.g. SIGSTOP)
int __wrap_kill(pid_t pid, int sig) {
int result;
if (app_libc_try_kill(pid, sig, &result)) {
return result;
}
return __real_kill(pid, sig);
}
// Called by an app, the real exit() would end the whole simulator
void __wrap_exit(int status) {
app_scheduler_exit_current(status);
@ -387,5 +407,3 @@ char* __wrap_fgets(char* buffer, int size, FILE* stream) {
}
// endregion
#endif // ESP_PLATFORM

View File

@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
#ifdef __APPLE__
#include <app/private/stdio_wrap_posix.h>
#include <app_posix/stdio_wrap.h>
#include <cstdlib>
#include <sys/ioctl.h>
@ -25,6 +25,8 @@ TT_DYLD_INTERPOSE(__wrap_poll, poll)
TT_DYLD_INTERPOSE(__wrap_tcgetattr, tcgetattr)
TT_DYLD_INTERPOSE(__wrap_tcsetattr, tcsetattr)
TT_DYLD_INTERPOSE(__wrap_exit, exit)
TT_DYLD_INTERPOSE(__wrap_signal, signal)
TT_DYLD_INTERPOSE(__wrap_kill, kill)
TT_DYLD_INTERPOSE(__wrap_vprintf, vprintf)
TT_DYLD_INTERPOSE(__wrap_printf, printf)

View File

@ -1,9 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
#if !defined(ESP_PLATFORM) && !defined(__APPLE__)
#ifndef __APPLE__
// Plain strong definitions: ELF gives the main executable's symbols priority process-wide,
// including for a dlopen()ed app's own calls.
#include <app/private/stdio_wrap_posix.h>
#include <app_posix/stdio_wrap.h>
extern "C" {
@ -51,6 +51,14 @@ int tcsetattr(int fd, int optional_actions, const struct termios* p) {
return __wrap_tcsetattr(fd, optional_actions, p);
}
AppLibcSignalHandler signal(int sig, AppLibcSignalHandler handler) {
return __wrap_signal(sig, handler);
}
int kill(pid_t pid, int sig) {
return __wrap_kill(pid, sig);
}
void exit(int status) {
__wrap_exit(status);
}

View File

@ -13,7 +13,7 @@ set_target_properties(app_posix_module_test_fixture PROPERTIES POSITION_INDEPEND
# Fixture: calls printf() with no app-module linkage of its own, so its call must resolve against
# the host process's own printf - proves a dlopen()ed app's stdio actually reaches app_io_write()
# rather than bypassing straight to libc (see stdio_wrap_posix.cpp's own comment for why that's not a
# rather than bypassing straight to libc (see ../source/stdio_wrap.cpp's own comment for why that's not a
# given on POSIX).
add_library(printf_fixture SHARED EXCLUDE_FROM_ALL ${CMAKE_CURRENT_LIST_DIR}/fixtures/printf_fixture.cpp)
set_target_properties(printf_fixture PROPERTIES POSITION_INDEPENDENT_CODE ON)

View File

@ -0,0 +1,415 @@
// SPDX-License-Identifier: Apache-2.0
// libc calls made by an app instance (fstat, termios, poll, printf, exit), routed by this module's
// libc wraps (../source/stdio_wrap.cpp) to the app instance's own fds and lifecycle.
#include "doctest.h"
#include <app/event.h>
#include <app/io.h>
#include <app/loader.h>
#include <app/manager.h>
#include <app/scheduler.h>
#include <app/start.h>
#include <app/stream.h>
#include <service/manager.h>
#include <tactility/delay.h>
#include <poll.h>
#include <signal.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <termios.h>
#include <unistd.h>
#include <atomic>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
extern ServiceManifest app_internal_loader_service_manifest;
namespace {
// See manager_test.cpp's own copy of this helper for why this checks the registry directly
// rather than a per-translation-unit static bool.
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;
}
bool wait_for_flag(std::atomic<bool>& flag, uint32_t timeout_ms) {
uint32_t waited = 0;
while (waited < timeout_ms) {
if (flag.load(std::memory_order_acquire)) {
return true;
}
delay_millis(10);
waited += 10;
}
return flag.load(std::memory_order_acquire);
}
int32_t location_app_main(int argc, char* argv[]) {
if (argc == 1) {
return static_cast<int32_t>(strtol(argv[0], nullptr, 10));
}
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
AppEventSubscription sub {};
app_event_subscribe(&sub, &event_group);
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
}
bool done = false;
AppEvent event {};
while (app_event_poll(&sub, &event) == ERROR_NONE) {
if (event.type == APP_EVENT_CLOSE) {
done = true;
break;
}
}
if (done) {
break;
}
}
app_event_unsubscribe(&sub);
task_event_group_destruct(&event_group);
return 0;
}
int32_t printf_stream_writer_app_main(int, char*[]) {
printf("loc");
return 7;
}
std::atomic<bool> g_posix_stdin_is_char_device { false };
std::atomic<int> g_posix_tcgetattr_result { -2 };
std::atomic<bool> g_posix_stdin_is_raw { false };
std::atomic<int> g_posix_tcsetattr_result { -2 };
std::atomic<int> g_posix_poll_before_write { -2 };
std::atomic<int> g_posix_poll_after_write { -2 };
std::atomic<bool> g_posix_poll_first_done { false };
std::atomic<bool> g_posix_null_buffers_efault { false };
int32_t posix_calls_app_main(int, char*[]) {
struct stat st {};
g_posix_stdin_is_char_device.store(fstat(STDIN_FILENO, &st) == 0 && S_ISCHR(st.st_mode), std::memory_order_release);
struct termios t {};
g_posix_tcgetattr_result.store(tcgetattr(STDIN_FILENO, &t), std::memory_order_release);
g_posix_stdin_is_raw.store((t.c_lflag & (ICANON | ECHO)) == 0, std::memory_order_release);
g_posix_tcsetattr_result.store(tcsetattr(STDIN_FILENO, TCSANOW, &t), std::memory_order_release);
struct stat* volatile null_stat = nullptr;
struct termios* volatile null_termios = nullptr;
const bool fstat_efault = fstat(STDIN_FILENO, null_stat) == -1 && errno == EFAULT;
const bool tcgetattr_efault = tcgetattr(STDIN_FILENO, null_termios) == -1 && errno == EFAULT;
g_posix_null_buffers_efault.store(fstat_efault && tcgetattr_efault, std::memory_order_release);
struct pollfd fds { STDIN_FILENO, POLLIN, 0 };
g_posix_poll_before_write.store(poll(&fds, 1, 50), std::memory_order_release);
g_posix_poll_first_done.store(true, std::memory_order_release);
g_posix_poll_after_write.store(poll(&fds, 1, 1000), std::memory_order_release);
return 0;
}
std::atomic<bool> g_closed_fstat_ebadf { false };
std::atomic<bool> g_closed_tcgetattr_ebadf { false };
std::atomic<bool> g_closed_tcsetattr_ebadf { false };
std::atomic<bool> g_closed_ioctl_ebadf { false };
std::atomic<int> g_closed_poll_result { -2 };
std::atomic<int> g_closed_poll_revents { -2 };
// Must never reach the process's real fd 0 (the host terminal on the simulator).
int32_t closed_stdin_posix_calls_app_main(int, char*[]) {
app_io_close(STDIN_FILENO);
struct stat st {};
g_closed_fstat_ebadf.store(fstat(STDIN_FILENO, &st) == -1 && errno == EBADF, std::memory_order_release);
struct termios t {};
g_closed_tcgetattr_ebadf.store(tcgetattr(STDIN_FILENO, &t) == -1 && errno == EBADF, std::memory_order_release);
g_closed_tcsetattr_ebadf.store(tcsetattr(STDIN_FILENO, TCSANOW, &t) == -1 && errno == EBADF, std::memory_order_release);
struct winsize ws {};
g_closed_ioctl_ebadf.store(ioctl(STDIN_FILENO, TIOCGWINSZ, &ws) == -1 && errno == EBADF, std::memory_order_release);
struct pollfd fds { STDIN_FILENO, POLLIN, 0 };
g_closed_poll_result.store(poll(&fds, 1, 1000), std::memory_order_release);
g_closed_poll_revents.store(fds.revents, std::memory_order_release);
return 0;
}
std::atomic<bool> g_exit_before { false };
std::atomic<bool> g_exit_after { false };
int32_t exit_app_main(int, char*[]) {
g_exit_before.store(true, std::memory_order_release);
exit(42);
g_exit_after.store(true, std::memory_order_release);
return 0;
}
void test_signal_handler(int) {
}
std::atomic<bool> g_signal_first_default { false };
std::atomic<bool> g_signal_returns_previous { false };
std::atomic<bool> g_signal_sigkill_rejected { false };
std::atomic<bool> g_kill_enosys { false };
int32_t signal_app_main(int, char*[]) {
g_signal_first_default.store(signal(SIGWINCH, test_signal_handler) == SIG_DFL, std::memory_order_release);
g_signal_returns_previous.store(signal(SIGWINCH, SIG_IGN) == test_signal_handler, std::memory_order_release);
g_signal_sigkill_rejected.store(signal(SIGKILL, test_signal_handler) == SIG_ERR && errno == EINVAL, std::memory_order_release);
// Would stop the whole test process if it reached the real kill()
g_kill_enosys.store(kill(0, SIGSTOP) == -1 && errno == ENOSYS, std::memory_order_release);
return 0;
}
std::atomic<bool> g_icrnl_default_on { false };
std::atomic<int> g_icrnl_first_read { -2 };
std::atomic<bool> g_icrnl_first_done { false };
std::atomic<int> g_icrnl_second_read { -2 };
int32_t icrnl_app_main(int, char*[]) {
struct termios t {};
tcgetattr(STDIN_FILENO, &t);
g_icrnl_default_on.store((t.c_iflag & ICRNL) != 0, std::memory_order_release);
char c = 0;
g_icrnl_first_read.store(read(STDIN_FILENO, &c, 1) == 1 ? c : -1, std::memory_order_release);
t.c_iflag &= ~ICRNL;
tcsetattr(STDIN_FILENO, TCSANOW, &t);
g_icrnl_first_done.store(true, std::memory_order_release);
g_icrnl_second_read.store(read(STDIN_FILENO, &c, 1) == 1 ? c : -1, std::memory_order_release);
return 0;
}
} // namespace
TEST_CASE("ICRNL is on by default and makes stdin read the Enter key's \\r as \\n, until an app clears it") {
ensure_memory_loader_registered();
g_icrnl_default_on.store(false, std::memory_order_relaxed);
g_icrnl_first_read.store(-2, std::memory_order_relaxed);
g_icrnl_first_done.store(false, std::memory_order_relaxed);
g_icrnl_second_read.store(-2, std::memory_order_relaxed);
AppManifest manifest { "test.libc.icrnl", "Icrnl", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(icrnl_app_main) } };
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
uint8_t storage[16];
AppStream child_stdin {};
AppStreamBinding binding { STDIN_FILENO, &child_stdin, storage, sizeof(storage), &event_group, {}, -1 };
AppInstanceId child_id = 0;
AppStartContext context;
REQUIRE_EQ(app_start_context_from_id("test.libc.icrnl", &context), ERROR_NONE);
app_start_context_set_streams(&context, &binding, 1);
REQUIRE_EQ(app_start_with_context(&context, &child_id), ERROR_NONE);
app_stream_write(&child_stdin, "\r", 1);
REQUIRE(wait_for_flag(g_icrnl_first_done, 1000));
app_stream_write(&child_stdin, "\r", 1);
REQUIRE(wait_for_state(child_id, APP_INSTANCE_STATE_STOPPED, 1000));
CHECK(g_icrnl_default_on.load(std::memory_order_acquire));
CHECK_EQ(g_icrnl_first_read.load(std::memory_order_acquire), '\n');
CHECK_EQ(g_icrnl_second_read.load(std::memory_order_acquire), '\r');
app_stream_unsubscribe(&child_stdin);
task_event_group_destruct(&event_group);
app_manager_remove("test.libc.icrnl");
}
TEST_CASE("signal() in an app records handlers per app instance, and kill() never reaches the process") {
ensure_memory_loader_registered();
g_signal_first_default.store(false, std::memory_order_relaxed);
g_signal_returns_previous.store(false, std::memory_order_relaxed);
g_signal_sigkill_rejected.store(false, std::memory_order_relaxed);
g_kill_enosys.store(false, std::memory_order_relaxed);
AppManifest manifest { "test.libc.signal", "Signal", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(signal_app_main) } };
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
AppInstanceId instance_id = 0;
AppStartContext context;
REQUIRE_EQ(app_start_context_from_id("test.libc.signal", &context), ERROR_NONE);
REQUIRE_EQ(app_start_with_context(&context, &instance_id), ERROR_NONE);
REQUIRE(wait_for_state(instance_id, APP_INSTANCE_STATE_STOPPED, 2000));
CHECK(g_signal_first_default.load(std::memory_order_acquire));
CHECK(g_signal_returns_previous.load(std::memory_order_acquire));
CHECK(g_signal_sigkill_rejected.load(std::memory_order_acquire));
CHECK(g_kill_enosys.load(std::memory_order_acquire));
app_manager_remove("test.libc.signal");
}
TEST_CASE("exit() in an app ends only that app instance") {
ensure_memory_loader_registered();
g_exit_before.store(false, std::memory_order_relaxed);
g_exit_after.store(false, std::memory_order_relaxed);
AppManifest manifest { "test.io.exit", "Exit", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(exit_app_main) } };
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
AppInstanceId instance_id = 0;
AppStartContext context;
REQUIRE_EQ(app_start_context_from_id("test.io.exit", &context), ERROR_NONE);
REQUIRE_EQ(app_start_with_context(&context, &instance_id), ERROR_NONE);
REQUIRE(wait_for_state(instance_id, APP_INSTANCE_STATE_STOPPED, 2000));
CHECK(g_exit_before.load(std::memory_order_acquire));
CHECK_FALSE(g_exit_after.load(std::memory_order_acquire));
app_manager_remove("test.io.exit");
}
TEST_CASE("fstat, ioctl, termios and poll on a closed app stdin fail with EBADF/POLLNVAL instead of reaching the real fd") {
ensure_memory_loader_registered();
g_closed_fstat_ebadf.store(false, std::memory_order_relaxed);
g_closed_tcgetattr_ebadf.store(false, std::memory_order_relaxed);
g_closed_tcsetattr_ebadf.store(false, std::memory_order_relaxed);
g_closed_ioctl_ebadf.store(false, std::memory_order_relaxed);
g_closed_poll_result.store(-2, std::memory_order_relaxed);
g_closed_poll_revents.store(-2, std::memory_order_relaxed);
AppManifest manifest { "test.io.closed_posix_calls", "ClosedPosixCalls", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(closed_stdin_posix_calls_app_main) } };
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
AppInstanceId instance_id = 0;
AppStartContext context;
REQUIRE_EQ(app_start_context_from_id("test.io.closed_posix_calls", &context), ERROR_NONE);
REQUIRE_EQ(app_start_with_context(&context, &instance_id), ERROR_NONE);
REQUIRE(wait_for_state(instance_id, APP_INSTANCE_STATE_STOPPED, 2000));
CHECK(g_closed_fstat_ebadf.load(std::memory_order_acquire));
CHECK(g_closed_tcgetattr_ebadf.load(std::memory_order_acquire));
CHECK(g_closed_tcsetattr_ebadf.load(std::memory_order_acquire));
CHECK(g_closed_ioctl_ebadf.load(std::memory_order_acquire));
CHECK_EQ(g_closed_poll_result.load(std::memory_order_acquire), 1);
CHECK_EQ(g_closed_poll_revents.load(std::memory_order_acquire), POLLNVAL);
app_manager_remove("test.io.closed_posix_calls");
}
TEST_CASE("fstat, termios and poll on a bound app stdin report a raw character device that becomes readable") {
ensure_memory_loader_registered();
g_posix_stdin_is_char_device.store(false, std::memory_order_relaxed);
g_posix_tcgetattr_result.store(-2, std::memory_order_relaxed);
g_posix_stdin_is_raw.store(false, std::memory_order_relaxed);
g_posix_tcsetattr_result.store(-2, std::memory_order_relaxed);
g_posix_poll_before_write.store(-2, std::memory_order_relaxed);
g_posix_poll_after_write.store(-2, std::memory_order_relaxed);
g_posix_poll_first_done.store(false, std::memory_order_relaxed);
g_posix_null_buffers_efault.store(false, std::memory_order_relaxed);
AppManifest manifest { "test.io.posix_calls", "PosixCalls", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(posix_calls_app_main) } };
REQUIRE_EQ(app_manager_add(&manifest), ERROR_NONE);
TaskEventGroup event_group {};
task_event_group_construct(&event_group);
uint8_t storage[16];
AppStream child_stdin {};
AppStreamBinding binding { STDIN_FILENO, &child_stdin, storage, sizeof(storage), &event_group, {}, -1 };
AppInstanceId child_id = 0;
AppStartContext context;
REQUIRE_EQ(app_start_context_from_id("test.io.posix_calls", &context), ERROR_NONE);
app_start_context_set_streams(&context, &binding, 1);
REQUIRE_EQ(app_start_with_context(&context, &child_id), ERROR_NONE);
REQUIRE(wait_for_flag(g_posix_poll_first_done, 1000));
app_stream_write(&child_stdin, "x", 1);
REQUIRE(wait_for_state(child_id, APP_INSTANCE_STATE_STOPPED, 1000));
CHECK(g_posix_stdin_is_char_device.load(std::memory_order_acquire));
CHECK_EQ(g_posix_tcgetattr_result.load(std::memory_order_acquire), 0);
CHECK(g_posix_stdin_is_raw.load(std::memory_order_acquire));
CHECK_EQ(g_posix_tcsetattr_result.load(std::memory_order_acquire), 0);
CHECK(g_posix_null_buffers_efault.load(std::memory_order_acquire));
CHECK_EQ(g_posix_poll_before_write.load(std::memory_order_acquire), 0);
CHECK_EQ(g_posix_poll_after_write.load(std::memory_order_acquire), 1);
app_stream_unsubscribe(&child_stdin);
task_event_group_destruct(&event_group);
app_manager_remove("test.io.posix_calls");
}
TEST_CASE("app_execute_for_result_with_streams pipes a child's plain printf() calls too") {
ensure_memory_loader_registered();
AppManifest parent_manifest { "test.app.execute.printf_parent", "Parent", APP_CATEGORY_USER, { APP_LOCATION_MEMORY, reinterpret_cast<void*>(location_app_main) } };
REQUIRE_EQ(app_manager_add(&parent_manifest), ERROR_NONE);
uint32_t parent_id = 0;
AppStartContext parent_context;
REQUIRE_EQ(app_start_context_from_id("test.app.execute.printf_parent", &parent_context), ERROR_NONE);
REQUIRE_EQ(app_start_with_context(&parent_context, &parent_id), ERROR_NONE);
CHECK(wait_for_state(parent_id, APP_INSTANCE_STATE_ACTIVE, 1000));
TaskEventGroup parent_event_group {};
task_event_group_construct(&parent_event_group);
AppEventSubscription parent_sub {};
REQUIRE_EQ(app_event_subscribe_with_app_id(&parent_sub, &parent_event_group, parent_id), ERROR_NONE);
uint8_t storage[64];
AppStream child_stdout {};
AppStreamBinding binding { STDOUT_FILENO, &child_stdout, storage, sizeof(storage), &parent_event_group, {}, -1 };
AppLocation location { APP_LOCATION_MEMORY, reinterpret_cast<void*>(printf_stream_writer_app_main) };
uint32_t child_id = 0;
AppStartContext context = app_start_context_for_location(location);
app_start_context_set_streams(&context, &binding, 1);
app_start_context_set_parent(&context, parent_id);
REQUIRE_EQ(app_start_with_context(&context, &child_id), ERROR_NONE);
std::vector<uint8_t> received;
while (app_stream_await(&child_stdout, APP_FILE_WAIT_READABLE, pdMS_TO_TICKS(1000)) == ERROR_NONE) {
uint8_t chunk[16];
size_t n = app_stream_read(&child_stdout, chunk, sizeof(chunk));
if (n == 0) {
break; // EOF
}
received.insert(received.end(), chunk, chunk + n);
}
REQUIRE_EQ(received.size(), 3u);
CHECK_EQ(std::memcmp(received.data(), "loc", 3), 0);
REQUIRE_EQ(task_event_group_wait(&parent_event_group, parent_sub.bit, false, nullptr, pdMS_TO_TICKS(2000)), ERROR_NONE);
AppEvent event {};
REQUIRE_EQ(app_event_poll(&parent_sub, &event), ERROR_NONE);
CHECK_EQ(event.type, APP_EVENT_RESULT);
CHECK_EQ(event.result.launch_id, child_id);
CHECK_EQ(event.result.result, 7);
app_stream_unsubscribe(&child_stdout);
app_event_unsubscribe(&parent_sub);
task_event_group_destruct(&parent_event_group);
app_manager_stop(child_id);
app_manager_stop(parent_id);
app_manager_remove("test.app.execute.printf_parent");
}

View File

@ -124,6 +124,7 @@ static const ModuleSymbol SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(getc),
DEFINE_MODULE_SYMBOL(putc),
DEFINE_MODULE_SYMBOL(putchar),
DEFINE_MODULE_SYMBOL(getchar),
DEFINE_MODULE_SYMBOL(puts),
DEFINE_MODULE_SYMBOL(printf),
DEFINE_MODULE_SYMBOL(sscanf),

View File

@ -8,6 +8,7 @@
#include <ctime>
#include <dirent.h>
#include <fcntl.h>
#include <signal.h>
#include <strings.h>
#include <sys/ioctl.h>
#include <sys/poll.h>
@ -41,6 +42,9 @@ static const ModuleSymbol SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(ftruncate),
DEFINE_MODULE_SYMBOL(fsync),
DEFINE_MODULE_SYMBOL(getuid),
// signal.h - handlers are recorded per app, see app/libc.h
DEFINE_MODULE_SYMBOL(signal),
DEFINE_MODULE_SYMBOL(kill),
// sys/ioctl.h
DEFINE_MODULE_SYMBOL(ioctl),
// sys/poll.h

View File

@ -15,6 +15,10 @@ struct AppHubEntry {
std::string appDescription;
std::string targetSdk;
std::vector<std::string> targetPlatforms;
/** Device ids the app is restricted to (manifest "requires.device.id"). Empty means unrestricted. */
std::vector<std::string> requiresDeviceId;
/** RAM in megabytes the app needs (manifest "requires.ram"). 0 means no requirement. */
int32_t requiresRam = 0;
std::string file;
};
@ -24,4 +28,7 @@ using AppHubEntryList = std::vector<AppHubEntry, OptExternalAllocator<AppHubEntr
bool parseJson(const std::string& filePath, AppHubEntryList& entries);
/** @return true if this device can install and run the entry's app (target platform, device and RAM requirements) */
bool isCompatible(const AppHubEntry& entry);
}

View File

@ -1,6 +1,7 @@
#pragma once
#include <cJSON.h>
#include <cmath>
#include <string>
#include <vector>
@ -32,6 +33,10 @@ public:
if (!readNumber(key, buffer)) {
return false;
}
if (buffer != std::trunc(buffer)) {
LOG_E(TAG, "%s is not an integer", key);
return false;
}
output = static_cast<int32_t>(buffer);
return true;
}

View File

@ -121,17 +121,8 @@ void showApps(Context* ctx) {
lv_obj_clean(ctx->contentWrapper);
ctx->mutex.lock();
if (parseJson(ctx->cachedAppsJsonFile, ctx->entries)) {
// An empty targetPlatforms list means the entry runs everywhere; otherwise it must name
// this build's own target to be installable here. The simulator isn't a real MCU target,
// so it has nothing to match against and skips this filter entirely.
std::erase_if(ctx->entries, [](const AppHubEntry& entry) {
#ifdef ESP_PLATFORM
return !entry.targetPlatforms.empty() &&
std::ranges::find(entry.targetPlatforms, std::string_view(CONFIG_IDF_TARGET)) == entry.targetPlatforms.end();
#else
(void)entry;
return false;
#endif
return !isCompatible(entry);
});
std::ranges::sort(ctx->entries, [](auto left, auto right) {

View File

@ -2,14 +2,35 @@
#include <Tactility/file/File.h>
#include <Tactility/json/Reader.h>
#include <app/package_manifest.h>
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#endif
#include <tactility/log.h>
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <string_view>
namespace tt::app::apphub {
constexpr auto* TAG = "AppHubJson";
static bool parseEntry(const cJSON* object, AppHubEntry& entry) {
const json::Reader reader(object);
// Optional: absent in apps.json files from before these fields existed
// Reset because parseJson() reuses entries from a previous parse
entry.requiresDeviceId.clear();
entry.requiresRam = 0;
if (cJSON_HasObjectItem(object, "requiresDeviceId") && !reader.readStringArray("requiresDeviceId", entry.requiresDeviceId)) {
return false;
}
if (cJSON_HasObjectItem(object, "requiresRam") && !reader.readInt32("requiresRam", entry.requiresRam)) {
return false;
}
return reader.readString("appId", entry.appId) &&
reader.readString("appVersionName", entry.appVersionName) &&
reader.readInt32("appVersionCode", entry.appVersionCode) &&
@ -57,4 +78,35 @@ bool parseJson(const std::string& filePath, AppHubEntryList& entries) {
return true;
}
bool isCompatible(const AppHubEntry& entry) {
// The simulator isn't a real MCU target, so it has no platform to match against
#ifdef ESP_PLATFORM
if (!entry.targetPlatforms.empty() &&
std::ranges::find(entry.targetPlatforms, std::string_view(CONFIG_IDF_TARGET)) == entry.targetPlatforms.end()) {
return false;
}
#endif
// Same device and RAM rules as for installed packages
PackageManifest package {};
std::string deviceIds;
for (const auto& deviceId : entry.requiresDeviceId) {
if (!deviceIds.empty()) {
deviceIds += ',';
}
deviceIds += deviceId;
}
if (deviceIds.size() >= sizeof(package.requires_device_id)) {
LOG_W(TAG, "%s: requiresDeviceId too long", entry.appId.c_str());
return false;
}
strcpy(package.requires_device_id, deviceIds.c_str());
if (entry.requiresRam < 0 || entry.requiresRam > UINT8_MAX) {
LOG_W(TAG, "%s: invalid requiresRam %d", entry.appId.c_str(), static_cast<int>(entry.requiresRam));
return false;
}
package.requires_ram = static_cast<uint8_t>(entry.requiresRam);
return app_package_manifest_is_compatible(&package);
}
}

View File

@ -1,6 +1,7 @@
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/package_manifest.h>
#include <app/scheduler.h>
#include <app/start.h>
@ -14,6 +15,7 @@
#include <algorithm>
#include <cstring>
#include <string>
#include <vector>
#include <lvgl/icons/shared.h>
@ -137,6 +139,17 @@ void collectManifest(const ::AppManifest* manifest, void* context) {
manifests->push_back(*manifest);
}
// Apps from installed packages this device can't run (wrong device, too little RAM)
void collectIncompatibleAppIds(const AppPackage* package, void* context) {
if (app_package_manifest_is_compatible(&package->package)) {
return;
}
auto* ids = static_cast<std::vector<std::string>*>(context);
for (size_t i = 0; i < package->app_id_count; i++) {
ids->emplace_back(package->app_ids[i]);
}
}
void populateList(lv_obj_t* list) {
// Captured before lv_obj_clean() deletes the currently focused button below: LVGL moves
// focus elsewhere as it leaves the group, and creating replacement buttons doesn't restore
@ -160,6 +173,11 @@ void populateList(lv_obj_t* list) {
std::vector<::AppManifest> collected;
app_manager_for_each_manifest(collectManifest, &collected);
std::vector<std::string> incompatibleIds;
app_manager_for_each_package(collectIncompatibleAppIds, &incompatibleIds);
std::erase_if(collected, [&](const ::AppManifest& manifest) {
return std::ranges::find(incompatibleIds, std::string(manifest.id)) != incompatibleIds.end();
});
std::ranges::sort(collected, [&](const ::AppManifest& a, const ::AppManifest& b) {
const bool aFavourite = Favourites::contains(favouriteIds, a.id);
const bool bFavourite = Favourites::contains(favouriteIds, b.id);

View File

@ -13,6 +13,8 @@
#include <tactility/check.h>
#include <TactilityCpp/Allocator.h>
#include <lvgl/widgets/toolbar.h>
#include <lvgl.h>
#include <algorithm>
@ -25,15 +27,18 @@ extern const ::AppManifest manifest;
namespace {
// Prefers PSRAM, like the other app list buffers
using PackageManifestList = std::vector<PackageManifest, OptExternalAllocator<PackageManifest>>;
struct Context {
uint32_t appInstanceId;
// Must outlive the widgets - button user-data points into this, not a createWidgets()-local vector.
std::vector<std::string> packageIds = {};
PackageManifestList packages = {};
};
void onPackagePressed(lv_event_t* e) {
auto* packageId = static_cast<char*>(lv_event_get_user_data(e));
apppackagedetails::start(packageId);
const auto* package = static_cast<const PackageManifest*>(lv_event_get_user_data(e));
apppackagedetails::start(package->id);
}
void onBackPressed(lv_event_t* event) {
@ -41,16 +46,19 @@ void onBackPressed(lv_event_t* event) {
app_event_emit_close(ctx->appInstanceId);
}
void createPackageWidget(const char* packageId, lv_obj_t* list) {
lv_obj_t* btn = lv_list_add_button(list, LVGL_ICON_SHARED_DEPLOYED_CODE, packageId);
void createPackageWidget(const PackageManifest* package, lv_obj_t* list) {
// A v2 package's single app shares its id, and its name is the nicer label. Otherwise show the package id.
AppManifest appManifest;
const char* label = (app_manager_find_manifest(package->id, &appManifest) == ERROR_NONE) ? appManifest.name : package->id;
lv_obj_t* btn = lv_list_add_button(list, LVGL_ICON_SHARED_DEPLOYED_CODE, label);
lv_obj_t* image = lv_obj_get_child(btn, 0);
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN);
lv_obj_add_event_cb(btn, &onPackagePressed, LV_EVENT_SHORT_CLICKED, const_cast<char*>(packageId));
lv_obj_add_event_cb(btn, &onPackagePressed, LV_EVENT_SHORT_CLICKED, const_cast<PackageManifest*>(package));
}
void collectPackageId(const ::AppPackage* pkg, void* context) {
auto* packageIds = static_cast<std::vector<std::string>*>(context);
packageIds->emplace_back(pkg->package.id);
void collectPackage(const ::AppPackage* pkg, void* context) {
auto* packages = static_cast<PackageManifestList*>(context);
packages->push_back(pkg->package);
}
void createWidgets(lv_obj_t* parent, void* userData) {
@ -70,15 +78,17 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_flex_grow(list, 1);
// createWidgets() can rerun for this same Context (window rebuild-on-remove).
ctx->packageIds.clear();
app_manager_for_each_package(collectPackageId, &ctx->packageIds);
std::ranges::sort(ctx->packageIds);
ctx->packages.clear();
app_manager_for_each_package(collectPackage, &ctx->packages);
std::ranges::sort(ctx->packages, [](const PackageManifest& left, const PackageManifest& right) {
return strcmp(left.id, right.id) < 0;
});
for (const auto& packageId : ctx->packageIds) {
createPackageWidget(packageId.c_str(), list);
for (const auto& package : ctx->packages) {
createPackageWidget(&package, list);
}
if (ctx->packageIds.empty()) {
if (ctx->packages.empty()) {
// lv_obj_align() is ignored for children of a flex-managed parent, so the empty-state
// label needs its own flex-growing wrapper to center within; the (empty) list is hidden
// rather than deleted so the wrapper can just take its place in the flex flow.

View File

@ -85,7 +85,7 @@ int32_t appMain(int argc, char* argv[]) {
if (ctx.resultCode == 0) {
// The parent captures this via an AppStream bound to our stdout (see startWithMode()) -
// see Modules/app-module/source/stdio_wrap.cpp for how printf() itself gets routed there on POSIX.
// see Modules/app-posix-module/source/stdio_wrap.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());
}

View File

@ -146,7 +146,7 @@ int32_t appMain(int argc, char* argv[]) {
if (ctx.resultCode == 0) {
// The caller captures this via an AppStream bound to our stdout (see start()); see
// Modules/app-module/source/stdio_wrap.cpp for how printf() itself gets routed there on POSIX.
// Modules/app-posix-module/source/stdio_wrap.cpp for how printf() itself gets routed there on POSIX.
printf("%s", ctx.resultText.c_str());
}
return ctx.resultCode;

View File

@ -118,6 +118,12 @@ void createWidgets(lv_obj_t* parent, void* userData) {
lv_obj_set_style_border_width(button_wrapper, 0, 0);
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
// Ensures showing selection state for keyboard/encoder devices
lv_obj_set_style_pad_all(button_wrapper, 0, LV_ALIGN_DEFAULT);
lv_obj_set_style_pad_column(button_wrapper, 12, LV_STATE_DEFAULT);
lv_obj_set_style_pad_ver(button_wrapper, 6, LV_STATE_DEFAULT);
lv_obj_set_style_pad_hor(button_wrapper, 6, LV_STATE_DEFAULT);
auto* yes_button = lv_button_create(button_wrapper);
auto* yes_label = lv_label_create(yes_button);
lv_label_set_text(yes_label, "Yes");

View File

@ -13,7 +13,7 @@ namespace {
// and cursor movement belong to the terminal itself and must never end up in a command's redirect
// target. sh_redir.c only ever swaps the stdout/stderr/stdin FILE*, never fd 1 itself, which is
// always piped to the terminal app running it. fwrite() would also miss this app's own stdio
// wrapping entirely (see stdio_wrap_posix.cpp), leaving nothing for that pipe to carry.
// wrapping entirely (see app-posix-module's stdio_wrap.cpp), leaving nothing for that pipe to carry.
void write(const char* text, size_t length) {
const char* bytes = text;
size_t remaining = length;

View File

@ -61,6 +61,11 @@ typedef struct {
*/
int pending_wrap;
/* Scroll region (DECSTBM, ESC[top;bottom r): line feeds, IND, RI, IL and DL only move rows
* scroll_top up to (not including) scroll_bottom. scroll_bottom 0 means the whole screen. */
int scroll_top;
int scroll_bottom;
} vterm_t;
static vterm_t *s_vterms = NULL;
@ -146,6 +151,64 @@ static void vterm_scroll(vterm_t *vt)
vt->cursor_y = rows - 1;
}
/* Exclusive end row of the scroll region, within the visible rows. */
static int region_bottom(const vterm_t *vt)
{
const int rows = effective_rows();
return (vt->scroll_bottom > 0 && vt->scroll_bottom < rows) ? vt->scroll_bottom : rows;
}
static void clear_rows(vterm_t *vt, int from, int to)
{
for (int y = from; y < to; y++) {
vterm_cell_t *row = &vt->cells[y * VTERM_COLS];
for (int x = 0; x < VTERM_COLS; x++) {
row[x].ch = ' ';
row[x].attr = VTERM_DEFAULT_ATTR;
}
}
}
/* Moves the scroll region's rows up by one and clears its last row. The whole screen scrolls
* through vterm_scroll(), so its top line still reaches the scrollback history. */
static void vterm_scroll_region_up(vterm_t *vt)
{
const int top = vt->scroll_top;
const int bottom = region_bottom(vt);
if (top == 0 && bottom == effective_rows()) {
vterm_scroll(vt);
return;
}
if (bottom - top > 1) {
memmove(&vt->cells[top * VTERM_COLS], &vt->cells[(top + 1) * VTERM_COLS],
(bottom - top - 1) * VTERM_COLS * sizeof(vterm_cell_t));
}
clear_rows(vt, bottom - 1, bottom);
}
/* Moves the scroll region's rows down by one and clears its first row. */
static void vterm_scroll_region_down(vterm_t *vt)
{
const int top = vt->scroll_top;
const int bottom = region_bottom(vt);
if (bottom - top > 1) {
memmove(&vt->cells[(top + 1) * VTERM_COLS], &vt->cells[top * VTERM_COLS],
(bottom - top - 1) * VTERM_COLS * sizeof(vterm_cell_t));
}
clear_rows(vt, top, top + 1);
}
/* Moves the cursor down one row. At the bottom of the scroll region, the region scrolls instead.
* Below the region, the cursor stops at the last row. */
static void vterm_line_feed(vterm_t *vt)
{
if (vt->cursor_y == region_bottom(vt) - 1) {
vterm_scroll_region_up(vt);
} else if (vt->cursor_y < effective_rows() - 1) {
vt->cursor_y++;
}
}
static void vterm_putchar_internal(vterm_t *vt, char c)
{
/*
@ -158,8 +221,7 @@ static void vterm_putchar_internal(vterm_t *vt, char c)
if (vt->pending_wrap && c >= 32 && c < 127) {
vt->pending_wrap = 0;
vt->cursor_x = 0;
vt->cursor_y++;
if (vt->cursor_y >= effective_rows()) vterm_scroll(vt);
vterm_line_feed(vt);
}
// Direct pointer access for speed
@ -169,8 +231,7 @@ static void vterm_putchar_internal(vterm_t *vt, char c)
case '\n':
vt->pending_wrap = 0;
vt->cursor_x = 0;
vt->cursor_y++;
if (vt->cursor_y >= effective_rows()) vterm_scroll(vt);
vterm_line_feed(vt);
break;
case '\r':
vt->pending_wrap = 0;
@ -360,30 +421,17 @@ static int vterm_handle_escape(vterm_t *vt, char c)
}
// Non-CSI escape sequences: ESC <letter>
if (c == 'D') {
// IND - Index: move cursor down, scroll if at bottom
if (vt->cursor_y >= effective_rows() - 1) {
vterm_scroll(vt);
} else {
vt->cursor_y++;
}
// IND - Index: move cursor down, scroll the region if at its bottom
vterm_line_feed(vt);
vt->escape_state = 0;
return 1;
}
if (c == 'M') {
// RI - Reverse Index: move cursor up, scroll down if at top
if (vt->cursor_y <= 0) {
// Scroll down: move lines 0..N-2 to 1..N-1
memmove(&vt->cells[VTERM_COLS], &vt->cells[0],
(effective_rows() - 1) * VTERM_COLS * sizeof(vterm_cell_t));
// Clear top line
vterm_cell_t *top_row = &vt->cells[0];
for (int x = 0; x < VTERM_COLS; x++) {
top_row[x].ch = ' ';
top_row[x].attr = VTERM_DEFAULT_ATTR;
}
} else {
// RI - Reverse Index: move cursor up, scroll the region down if at its top
if (vt->cursor_y == vt->scroll_top) {
vterm_scroll_region_down(vt);
} else if (vt->cursor_y > 0) {
vt->cursor_y--;
}
@ -393,11 +441,7 @@ static int vterm_handle_escape(vterm_t *vt, char c)
if (c == 'E') {
// NEL - Next Line: move to column 1 of next line, scroll if needed
vt->cursor_x = 0;
if (vt->cursor_y >= effective_rows() - 1) {
vterm_scroll(vt);
} else {
vt->cursor_y++;
}
vterm_line_feed(vt);
vt->escape_state = 0;
return 1;
@ -528,53 +572,66 @@ static int vterm_handle_escape(vterm_t *vt, char c)
break;
}
case 'L': {
// IL - Insert Lines: insert N blank lines at cursor row, scroll down
// IL - Insert Lines: insert N blank lines at the cursor row, within the scroll region
const int bottom = region_bottom(vt);
if (vt->cursor_y < vt->scroll_top || vt->cursor_y >= bottom) break;
int n = 1;
if (vt->escape_buf[0]) n = atoi(vt->escape_buf);
if (n < 1) n = 1;
if (n > VTERM_ROWS - vt->cursor_y) n = VTERM_ROWS - vt->cursor_y;
if (n > bottom - vt->cursor_y) n = bottom - vt->cursor_y;
// Move lines down
int lines_to_move = VTERM_ROWS - vt->cursor_y - n;
int lines_to_move = bottom - vt->cursor_y - n;
if (lines_to_move > 0) {
memmove(&vt->cells[(vt->cursor_y + n) * VTERM_COLS],
&vt->cells[vt->cursor_y * VTERM_COLS],
lines_to_move * VTERM_COLS * sizeof(vterm_cell_t));
}
// Clear inserted lines
for (int y = vt->cursor_y; y < vt->cursor_y + n; y++) {
vterm_cell_t *row = &vt->cells[y * VTERM_COLS];
for (int x = 0; x < VTERM_COLS; x++) {
row[x].ch = ' ';
row[x].attr = VTERM_DEFAULT_ATTR;
}
}
clear_rows(vt, vt->cursor_y, vt->cursor_y + n);
break;
}
case 'M': {
// DL - Delete Lines: delete N lines at cursor row, scroll up
// DL - Delete Lines: delete N lines at the cursor row, within the scroll region
const int bottom = region_bottom(vt);
if (vt->cursor_y < vt->scroll_top || vt->cursor_y >= bottom) break;
int n = 1;
if (vt->escape_buf[0]) n = atoi(vt->escape_buf);
if (n < 1) n = 1;
if (n > VTERM_ROWS - vt->cursor_y) n = VTERM_ROWS - vt->cursor_y;
if (n > bottom - vt->cursor_y) n = bottom - vt->cursor_y;
// Move lines up
int lines_to_move = VTERM_ROWS - vt->cursor_y - n;
int lines_to_move = bottom - vt->cursor_y - n;
if (lines_to_move > 0) {
memmove(&vt->cells[vt->cursor_y * VTERM_COLS],
&vt->cells[(vt->cursor_y + n) * VTERM_COLS],
lines_to_move * VTERM_COLS * sizeof(vterm_cell_t));
}
// Clear vacated lines at bottom
for (int y = VTERM_ROWS - n; y < VTERM_ROWS; y++) {
vterm_cell_t *row = &vt->cells[y * VTERM_COLS];
for (int x = 0; x < VTERM_COLS; x++) {
row[x].ch = ' ';
row[x].attr = VTERM_DEFAULT_ATTR;
}
clear_rows(vt, bottom - n, bottom);
break;
}
case 'r': {
// DECSTBM - Set Scroll Region: ESC[top;bottom r (1-based), no parameters is the whole screen
const int rows = effective_rows();
int top = 1, bottom = rows;
// Each parameter falls back to its own default when omitted, e.g. ESC[;4r
const char *separator = strchr(vt->escape_buf, ';');
if (vt->escape_buf[0] && vt->escape_buf[0] != ';') top = atoi(vt->escape_buf);
if (separator && separator[1]) bottom = atoi(separator + 1);
if (top < 1) top = 1;
if (bottom > rows || bottom < 1) bottom = rows;
if (top < bottom) {
vt->scroll_top = top - 1;
vt->scroll_bottom = (bottom == rows) ? 0 : bottom;
}
vt->cursor_x = 0;
vt->cursor_y = 0;
break;
}
case 'G': {
// CHA - Cursor Horizontal Absolute: move to column N (1-based) on the current row
int n = 1;
if (vt->escape_buf[0]) n = atoi(vt->escape_buf);
if (n < 1) n = 1;
vt->cursor_x = n - 1;
if (vt->cursor_x >= effective_cols()) vt->cursor_x = effective_cols() - 1;
break;
}
case 'n':
@ -783,12 +840,10 @@ void vterm_write(int vt_id, const char *data, size_t len)
*/
if (pending_wrap) {
pending_wrap = 0;
cx = 0; cy++;
if (cy >= effective_rows()) {
vt->cursor_x = cx; vt->cursor_y = cy;
vterm_scroll(vt);
cy = vt->cursor_y;
}
cx = 0;
vt->cursor_x = cx; vt->cursor_y = cy;
vterm_line_feed(vt);
cy = vt->cursor_y;
cursor_ptr = &cells_base[cy * VTERM_COLS + cx];
row_end = &cells_base[cy * VTERM_COLS + cols];
}

View File

@ -19,7 +19,8 @@ void __wrap_lv_obj_set_flex_flow(lv_obj_t* obj, lv_flex_flow_t flow) {
lv_obj_t* __wrap_lv_obj_create(lv_obj_t* parent) {
auto obj = __real_lv_obj_create(parent);
if (lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT) {
// Screens and display layers (a null parent, also used by LVGL's own display setup) must stay borderless and unpadded
if (parent != nullptr && lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT) {
lv_obj_set_style_pad_all(obj, 2, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(obj, 2, LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 3, LV_STATE_DEFAULT);

View File

@ -0,0 +1,84 @@
#include "doctest.h"
#include <Tactility/app/apphub/AppHubEntry.h>
#include <tactility/filesystem/fs.h>
#include <tactility/paths.h>
#include <cstdio>
#include <string>
using namespace tt::app::apphub;
namespace {
std::string writeAppsJson(const char* contents) {
char temp[FILE_MAX_PATH_STRING_LENGTH];
REQUIRE_EQ(paths_get_temp_path(temp, sizeof(temp)), ERROR_NONE);
REQUIRE_EQ(directory_make(temp, true), ERROR_NONE);
const std::string path = std::string(temp) + "/apphub_test_apps.json";
FILE* file = fopen(path.c_str(), "w");
REQUIRE_NE(file, nullptr);
fputs(contents, file);
fclose(file);
return path;
}
AppHubEntry entryWith(std::vector<std::string> requiresDeviceId, int32_t requiresRam) {
AppHubEntry entry {};
entry.requiresDeviceId = std::move(requiresDeviceId);
entry.requiresRam = requiresRam;
return entry;
}
} // namespace
TEST_CASE("app hub: requiresDeviceId and requiresRam are parsed, and optional") {
const auto path = writeAppsJson(R"({"apps": [
{"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app",
"requiresDeviceId": ["m5stack-tab5", "lilygo-tdeck"], "requiresRam": 2},
{"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"}
]})");
AppHubEntryList entries;
REQUIRE(parseJson(path, entries));
REQUIRE_EQ(entries.size(), 2u);
CHECK_EQ(entries[0].requiresDeviceId, std::vector<std::string> { "m5stack-tab5", "lilygo-tdeck" });
CHECK_EQ(entries[0].requiresRam, 2);
CHECK(entries[1].requiresDeviceId.empty());
CHECK_EQ(entries[1].requiresRam, 0);
remove(path.c_str());
}
TEST_CASE("app hub: reparsing into the same list resets optional requirements") {
AppHubEntryList entries;
const auto restricted = writeAppsJson(R"({"apps": [
{"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app",
"requiresDeviceId": ["m5stack-tab5"], "requiresRam": 2}
]})");
REQUIRE(parseJson(restricted, entries));
const auto unrestricted = writeAppsJson(R"({"apps": [
{"appId": "b", "appVersionName": "1", "appVersionCode": 1, "appName": "B", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "b-1.app"}
]})");
REQUIRE(parseJson(unrestricted, entries));
REQUIRE_EQ(entries.size(), 1u);
CHECK(entries[0].requiresDeviceId.empty());
CHECK_EQ(entries[0].requiresRam, 0);
remove(unrestricted.c_str());
}
TEST_CASE("app hub: a fractional requiresRam rejects the entry") {
const auto path = writeAppsJson(R"({"apps": [
{"appId": "a", "appVersionName": "1", "appVersionCode": 1, "appName": "A", "appDescription": "", "targetSdk": "0.8.0", "targetPlatforms": [], "file": "a-1.app",
"requiresRam": 1.5}
]})");
AppHubEntryList entries;
CHECK_FALSE(parseJson(path, entries));
remove(path.c_str());
}
TEST_CASE("app hub: isCompatible applies the device and RAM requirements") {
CHECK(isCompatible(entryWith({}, 0)));
CHECK(isCompatible(entryWith({ "other-device", CONFIG_TT_DEVICE_ID }, 1)));
CHECK_FALSE(isCompatible(entryWith({ "other-device" }, 0)));
CHECK_FALSE(isCompatible(entryWith({}, 256)));
}

View File

@ -0,0 +1,86 @@
#include "doctest.h"
extern "C" {
#include <Tactility/app/terminal/vterm/vterm.h>
}
#include <cstring>
#include <string>
namespace {
void write(const char* text) {
vterm_write(0, text, strlen(text));
}
char cell(int row, int col) {
return vterm_get_direct_buffer()[row * VTERM_COLS + col].ch;
}
std::string column0(int rows) {
std::string result;
for (int row = 0; row < rows; row++) {
result += cell(row, 0);
}
return result;
}
} // namespace
TEST_CASE("vterm: a scroll region limits line feeds and inserted lines to its rows") {
REQUIRE_EQ(vterm_init(), ERROR_NONE);
vterm_set_size_override(5, 10);
write("\x1B[2J\x1B[H1\r\n2\r\n3\r\n4\r\n5");
CHECK_EQ(column0(5), "12345");
// Rows 2-4 (1-based) scroll, rows 1 and 5 stay put
write("\x1B[2;4r");
write("\x1B[4;1H\n");
CHECK_EQ(column0(5), "134 5");
write("\x1B[2;1H\x1B[L");
CHECK_EQ(column0(5), "1 345");
write("\x1B[2;1H\x1B[M");
CHECK_EQ(column0(5), "134 5");
// A full clear keeps the scroll region
write("\x1B[2J\x1B[H1\r\n2\r\n3\r\n4\x1B[5;1H5");
CHECK_EQ(column0(5), "12345");
write("\x1B[4;1H\n");
CHECK_EQ(column0(5), "134 5");
// DECSTBM without parameters restores the whole screen as scroll region
write("\x1B[r\x1B[5;1H\n");
CHECK_EQ(column0(5), "34 5 ");
vterm_clear_size_override();
vterm_deinit();
}
TEST_CASE("vterm: an omitted scroll region top defaults to the first row") {
REQUIRE_EQ(vterm_init(), ERROR_NONE);
vterm_set_size_override(5, 10);
write("\x1B[2J\x1B[H1\r\n2\r\n3\r\n4\r\n5");
write("\x1B[;4r\x1B[4;1H\n");
CHECK_EQ(column0(5), "234 5");
vterm_clear_size_override();
vterm_deinit();
}
TEST_CASE("vterm: CHA moves the cursor to a column on the current row") {
REQUIRE_EQ(vterm_init(), ERROR_NONE);
vterm_set_size_override(5, 10);
write("\x1B[2J\x1B[3;2H\x1B[5G");
int col = -1, row = -1, visible = 0;
vterm_get_cursor(0, &col, &row, &visible);
CHECK_EQ(col, 4);
CHECK_EQ(row, 2);
vterm_clear_size_override();
vterm_deinit();
}

View File

@ -19,7 +19,7 @@ extern "C" {
* Subsystems like LVGL can translate these special codepoints to specific actions.
*/
typedef enum {
CODEPOINT_ENTER = '\r',
CODEPOINT_ENTER = 10,
CODEPOINT_ESCAPE = '\x1B',
CODEPOINT_BACKSPACE = '\b',
CODEPOINT_DELETE = '\x7F',
@ -58,7 +58,7 @@ struct KeyboardKeyData {
*
* Reported separately rather than folded into `key` because the two encodings collide: the C0
* control codes a terminal expects for Ctrl chords (Ctrl+C is 0x03, Ctrl+K is 0x0B, ...) overlap
* the four CodePoint values that remain in true C0 range (CODEPOINT_ENTER is Ctrl+M/13,
* the four CodePoint values that remain in true C0 range (CODEPOINT_ENTER is Ctrl+J/10,
* CODEPOINT_BACKSPACE is Ctrl+H/8, CODEPOINT_TAB is Ctrl+I/9, CODEPOINT_ESCAPE is Ctrl+[/27), so
* a single uint32_t cannot express both. Consumers that want control codes derive them here, e.g.
* `((key >= 'a' && key <= 'z') || (key >= 'A' && key <= 'Z')) ? (key & 0x1F) : key`

View File

@ -56,6 +56,12 @@ void memory_log_stats(void);
*/
size_t memory_heap_total(void);
/**
* @brief Total size of external memory (PSRAM), in bytes.
* @return the external memory's total size, or 0 if the device or platform has none
*/
size_t memory_external_total(void);
/**
* @brief Currently free space in the main heap, in bytes.
* @note See memory_heap_total()'s own note - the same system-wide-vs-process-heap caveat applies.

View File

@ -50,7 +50,7 @@ static const uint8_t hid_keycode_to_ascii[57][2] = {
{'z', 'Z'},
{'1', '!'}, {'2', '@'}, {'3', '#'}, {'4', '$'}, {'5', '%'},
{'6', '^'}, {'7', '&'}, {'8', '*'}, {'9', '('}, {'0', ')'},
{'\r', '\r'}, {0, 0}, {'\b', 0}, {'\t', '\t'}, {' ', ' '},
{'\n', '\n'}, {0, 0}, {'\b', 0}, {'\t', '\t'}, {' ', ' '},
{'-', '_'}, {'=', '+'}, {'[', '{'}, {']', '}'},
{'\\', '|'}, {'\\', '|'}, {';', ':'}, {'\'', '"'},
{'`', '~'}, {',', '<'}, {'.', '>'}, {'/', '?'},

View File

@ -44,6 +44,14 @@ size_t memory_heap_total() {
#endif
}
size_t memory_external_total() {
#ifdef ESP_PLATFORM
return heap_caps_get_total_size(MALLOC_CAP_SPIRAM);
#else
return 0;
#endif
}
size_t memory_heap_free() {
#ifdef ESP_PLATFORM
return heap_caps_get_free_size(MALLOC_CAP_INTERNAL);

View File

@ -189,6 +189,7 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(memory_free),
DEFINE_MODULE_SYMBOL(memory_heap_free),
DEFINE_MODULE_SYMBOL(memory_heap_total),
DEFINE_MODULE_SYMBOL(memory_external_total),
// drivers/gpio_controller
DEFINE_MODULE_SYMBOL(gpio_descriptor_acquire),
DEFINE_MODULE_SYMBOL(gpio_descriptor_release),