From 7ea6a02ff7f9b79ca2af9fb6e1a233f1e3211505 Mon Sep 17 00:00:00 2001 From: Gavin Wightman Date: Sat, 25 Jul 2026 15:18:15 -0400 Subject: [PATCH] Fix macOS emulator runtime and controls --- .github/workflows/emulator-build.yml | 8 +- ext_components/cp0_lvgl/include/input_keys.h | 13 ++ .../cp0_lvgl/src/cp0_lvgl_app_runner.cpp | 52 ++++++- .../cp0_lvgl/src/cp0_process_runner.cpp | 15 ++ ext_components/cp0_lvgl/src/cp0_pty_posix.inc | 28 ++-- .../cp0_lvgl/src/cp0_sudo_coordinator.cpp | 2 +- .../cp0_lvgl/src/cp0_sudo_coordinator.hpp | 2 +- ext_components/cp0_lvgl/src/sdl/sdl_lvgl.c | 2 +- .../cp0_lvgl/src/sdl/sdl_lvgl_display.c | 2 +- .../cp0_lvgl/src/sdl/sdl_lvgl_keyboard.c | 18 ++- ext_components/cp0_lvgl/tests/run_tests.sh | 28 ++-- .../cp0_lvgl/tests/test_process_runner.cpp | 9 +- projects/APPLaunch/main/ui/ui_screensaver.cpp | 8 ++ .../CardputerZero-Emulator/CMakeLists.txt | 55 ++++++- projects/CardputerZero-Emulator/README.md | 74 ++++++++++ projects/CardputerZero-Emulator/src/main.cpp | 136 +++++++++++++++--- 16 files changed, 402 insertions(+), 50 deletions(-) create mode 100644 projects/CardputerZero-Emulator/README.md diff --git a/.github/workflows/emulator-build.yml b/.github/workflows/emulator-build.yml index 067e790b..023092c3 100644 --- a/.github/workflows/emulator-build.yml +++ b/.github/workflows/emulator-build.yml @@ -38,6 +38,8 @@ jobs: mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j$(sysctl -n hw.ncpu) + - name: Test + run: ctest --test-dir projects/CardputerZero-Emulator/build --output-on-failure - name: Package run: | cd projects/CardputerZero-Emulator @@ -46,7 +48,11 @@ jobs: cp -r build/apps dist/CardputerZero-Emulator/ cp -r build/assets dist/CardputerZero-Emulator/ cp -r build/images dist/CardputerZero-Emulator/ - cd dist && tar czf ../CardputerZero-Emulator-macOS.tar.gz CardputerZero-Emulator + cp -r build/APPLaunch dist/CardputerZero-Emulator/ + cd dist/CardputerZero-Emulator + SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy EMU_TEST_NAVIGATE_FRAME=10 EMU_TEST_QUIT_FRAME=20 ./cardputer-zero-emu + cd .. + tar czf ../CardputerZero-Emulator-macOS.tar.gz CardputerZero-Emulator - uses: actions/upload-artifact@v4 with: name: emulator-macos diff --git a/ext_components/cp0_lvgl/include/input_keys.h b/ext_components/cp0_lvgl/include/input_keys.h index 933483d5..588984aa 100644 --- a/ext_components/cp0_lvgl/include/input_keys.h +++ b/ext_components/cp0_lvgl/include/input_keys.h @@ -19,6 +19,7 @@ #define KEY_9 10 #define KEY_0 11 #define KEY_MINUS 12 +#define KEY_EQUAL 13 #define KEY_BACKSPACE 14 #define KEY_TAB 15 #define KEY_Q 16 @@ -31,6 +32,8 @@ #define KEY_I 23 #define KEY_O 24 #define KEY_P 25 +#define KEY_LEFTBRACE 26 +#define KEY_RIGHTBRACE 27 #define KEY_ENTER 28 #define KEY_LEFTCTRL 29 #define KEY_A 30 @@ -42,7 +45,11 @@ #define KEY_J 36 #define KEY_K 37 #define KEY_L 38 +#define KEY_SEMICOLON 39 +#define KEY_APOSTROPHE 40 +#define KEY_GRAVE 41 #define KEY_LEFTSHIFT 42 +#define KEY_BACKSLASH 43 #define KEY_RIGHTSHIFT 54 #define KEY_Z 44 #define KEY_X 45 @@ -51,7 +58,9 @@ #define KEY_B 48 #define KEY_N 49 #define KEY_M 50 +#define KEY_COMMA 51 #define KEY_DOT 52 +#define KEY_SLASH 53 #define KEY_SPACE 57 #define KEY_LEFTALT 56 #define KEY_CAPSLOCK 58 @@ -68,6 +77,8 @@ #define KEY_F11 87 #define KEY_F12 88 #define KEY_KPENTER 96 +#define KEY_RIGHTCTRL 97 +#define KEY_RIGHTALT 100 #define KEY_HOME 102 #define KEY_UP 103 #define KEY_PAGEUP 104 @@ -81,6 +92,8 @@ #define KEY_MUTE 113 #define KEY_VOLUMEDOWN 114 #define KEY_VOLUMEUP 115 +#define KEY_LEFTMETA 125 +#define KEY_RIGHTMETA 126 #define KEY_BRIGHTNESSDOWN 224 #define KEY_BRIGHTNESSUP 225 #define KEY_NEXT 407 diff --git a/ext_components/cp0_lvgl/src/cp0_lvgl_app_runner.cpp b/ext_components/cp0_lvgl/src/cp0_lvgl_app_runner.cpp index f99582c0..e8f6d232 100644 --- a/ext_components/cp0_lvgl/src/cp0_lvgl_app_runner.cpp +++ b/ext_components/cp0_lvgl/src/cp0_lvgl_app_runner.cpp @@ -14,15 +14,24 @@ #include "cp0_runner_shutdown.hpp" #include "hal_lvgl_bsp.h" +#include #include +#include #include #include +#if !defined(__APPLE__) #include +#endif #include namespace { +#if defined(__APPLE__) +std::condition_variable lvgl_wake; +size_t pending_wakes = 0; +#else sem_t lvgl_sem; +#endif std::mutex runner_mutex; cp0::RunnerLifetime runner_lifetime; bool semaphore_ready = false; @@ -30,7 +39,13 @@ bool semaphore_ready = false; void post_lvgl_semaphore() { std::lock_guard lock(runner_mutex); - if (semaphore_ready) sem_post(&lvgl_sem); + if (!semaphore_ready) return; +#if defined(__APPLE__) + ++pending_wakes; + lvgl_wake.notify_one(); +#else + sem_post(&lvgl_sem); +#endif } void resume_cb(void *) @@ -40,6 +55,13 @@ void resume_cb(void *) void wait_for_lvgl(uint32_t milliseconds) { +#if defined(__APPLE__) + std::unique_lock lock(runner_mutex); + lvgl_wake.wait_for(lock, std::chrono::milliseconds(milliseconds), [] { + return pending_wakes != 0 || !semaphore_ready; + }); + if (pending_wakes != 0) --pending_wakes; +#else struct timespec deadline; #if defined(__linux__) clock_gettime(CLOCK_MONOTONIC, &deadline); @@ -58,6 +80,23 @@ void wait_for_lvgl(uint32_t milliseconds) result = sem_timedwait(&lvgl_sem, &deadline); #endif } while (result != 0 && errno == EINTR); +#endif +} + +void wait_for_lvgl() +{ +#if defined(__APPLE__) + std::unique_lock lock(runner_mutex); + lvgl_wake.wait(lock, [] { + return pending_wakes != 0 || !semaphore_ready; + }); + if (pending_wakes != 0) --pending_wakes; +#else + int result; + do { + result = sem_wait(&lvgl_sem); + } while (result != 0 && errno == EINTR); +#endif } void stop_imu() noexcept @@ -124,10 +163,14 @@ int cp0_lvgl_run(Cp0LvglRunOptions options) std::fprintf(stderr, "cp0_lvgl: runner supports one run per process\n"); return 1; } +#if defined(__APPLE__) + pending_wakes = 0; +#else if (sem_init(&lvgl_sem, 0, 0) != 0) { runner_lifetime.initialization_failed(); return 1; } +#endif semaphore_ready = true; } @@ -153,7 +196,12 @@ int cp0_lvgl_run(Cp0LvglRunOptions options) deinit_input, deinit_wifi, stop_lora, deinit_battery, lv_deinit); std::lock_guard lock(runner_mutex); semaphore_ready = false; +#if defined(__APPLE__) + pending_wakes = 0; + lvgl_wake.notify_all(); +#else sem_destroy(&lvgl_sem); +#endif runner_lifetime.finish(); return teardown_failed; }; @@ -189,7 +237,7 @@ int cp0_lvgl_run(Cp0LvglRunOptions options) if (options.should_quit && options.should_quit()) break; if (milliseconds == LV_NO_TIMER_READY) - sem_wait(&lvgl_sem); + wait_for_lvgl(); else wait_for_lvgl(milliseconds); } diff --git a/ext_components/cp0_lvgl/src/cp0_process_runner.cpp b/ext_components/cp0_lvgl/src/cp0_process_runner.cpp index 8629ef17..f4fb0080 100644 --- a/ext_components/cp0_lvgl/src/cp0_process_runner.cpp +++ b/ext_components/cp0_lvgl/src/cp0_process_runner.cpp @@ -80,11 +80,21 @@ Result run(std::vector argv, const std::string *input, Output outpu int count = 0; getgrouplist(user_name.c_str(), gid, nullptr, &count); if (count > 0) { +#if defined(__APPLE__) + std::vector native_groups(count); + if (getgrouplist(user_name.c_str(), static_cast(gid), + native_groups.data(), &count) < 0) { + result.exit_code = -EINVAL; return result; + } + native_groups.resize(count); + groups.assign(native_groups.begin(), native_groups.end()); +#else groups.resize(count); if (getgrouplist(user_name.c_str(), gid, groups.data(), &count) < 0) { result.exit_code = -EINVAL; return result; } groups.resize(count); +#endif } } int in[2] = {-1, -1}, out[2] = {-1, -1}; @@ -212,7 +222,12 @@ Result run(std::vector argv, const std::string *input, Output outpu finish_input(); if (sigpipe_blocked) { if (!sigpipe_was_pending && sigpending(&pending) == 0 && sigismember(&pending, SIGPIPE)) { +#if defined(__APPLE__) + int consumed_signal = 0; + sigwait(&set, &consumed_signal); +#else timespec zero{0, 0}; sigtimedwait(&set, nullptr, &zero); +#endif } pthread_sigmask(SIG_SETMASK, &old, nullptr); } diff --git a/ext_components/cp0_lvgl/src/cp0_pty_posix.inc b/ext_components/cp0_lvgl/src/cp0_pty_posix.inc index a5d66e23..22b1fa10 100644 --- a/ext_components/cp0_lvgl/src/cp0_pty_posix.inc +++ b/ext_components/cp0_lvgl/src/cp0_pty_posix.inc @@ -24,8 +24,14 @@ #include #include +#if defined(__linux__) || defined(__APPLE__) +#define CP0_HAS_POSIX_PTY 1 +#endif + #if defined(__linux__) #include +#elif defined(__APPLE__) +#include #endif namespace { @@ -51,7 +57,7 @@ public: pty_handle_t open(const char *cmd, const char *const *args, int cols, int rows) { -#if defined(__linux__) +#if defined(CP0_HAS_POSIX_PTY) if (!cmd || !cmd[0]) return NULL; int master_fd = -1; @@ -113,7 +119,7 @@ public: int read(pty_handle_t pty, char *buf, size_t buf_size) { -#if defined(__linux__) +#if defined(CP0_HAS_POSIX_PTY) std::lock_guard lock(handles_mutex_); cp0_pty_handle *h = resolve(pty); if (!h || !buf || buf_size == 0) return -1; @@ -133,7 +139,7 @@ public: int write(pty_handle_t pty, const char *buf, size_t len) { -#if defined(__linux__) +#if defined(CP0_HAS_POSIX_PTY) std::lock_guard lock(handles_mutex_); cp0_pty_handle *h = resolve(pty); if (!h || !buf) return -1; @@ -148,7 +154,7 @@ public: int check_child(pty_handle_t pty, int *exit_status) { -#if defined(__linux__) +#if defined(CP0_HAS_POSIX_PTY) std::lock_guard lock(handles_mutex_); cp0_pty_handle *h = resolve(pty); if (!h) return -1; @@ -175,7 +181,7 @@ public: int resize(pty_handle_t pty, int cols, int rows) { -#if defined(__linux__) +#if defined(CP0_HAS_POSIX_PTY) std::lock_guard lock(handles_mutex_); cp0_pty_handle *h = resolve(pty); if (!h) return -1; @@ -193,7 +199,7 @@ public: bool close(pty_handle_t pty) { -#if defined(__linux__) +#if defined(CP0_HAS_POSIX_PTY) cp0_pty_handle *handle = nullptr; { std::lock_guard lock(handles_mutex_); @@ -212,7 +218,7 @@ public: void shutdown() noexcept { -#if defined(__linux__) +#if defined(CP0_HAS_POSIX_PTY) std::unordered_map handles; try { std::lock_guard lock(handles_mutex_); @@ -251,7 +257,7 @@ public: private: static void cleanup(cp0_pty_handle *handle) noexcept { -#if defined(__linux__) +#if defined(CP0_HAS_POSIX_PTY) if (!handle) return; if (!handle->child_reaped) { (void)kill(handle->child_pid, SIGKILL); @@ -334,6 +340,8 @@ private: (void)shell_result; (void)chdir_result; } +#else + (void)configured_user; #endif } @@ -382,7 +390,9 @@ private: std::unordered_map handles_; std::mutex handles_mutex_; +#if defined(CP0_HAS_POSIX_PTY) bool accepting_ = true; +#endif }; std::mutex g_pty_mutex; @@ -425,3 +435,5 @@ extern "C" void deinit_pty(void) noexcept } catch (...) { } } + +#undef CP0_HAS_POSIX_PTY diff --git a/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.cpp b/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.cpp index 53e01ed1..73ab586c 100644 --- a/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.cpp +++ b/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.cpp @@ -90,7 +90,7 @@ std::vector Coordinator::start_next_locked(int64_t now_ms) return actions; } -void Coordinator::terminal_locked(const std::shared_ptr &request, +void Coordinator::terminal_locked(std::shared_ptr request, cp0_sudo_result_t result, int exit_code) { if (!request || request->state == State::TERMINAL) return; diff --git a/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.hpp b/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.hpp index 9331aa58..b23a816a 100644 --- a/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.hpp +++ b/ext_components/cp0_lvgl/src/cp0_sudo_coordinator.hpp @@ -92,7 +92,7 @@ class Coordinator { }; std::vector start_next_locked(int64_t now_ms); std::vector promote_reservations_locked(int64_t now_ms); - void terminal_locked(const std::shared_ptr &request, + void terminal_locked(std::shared_ptr request, cp0_sudo_result_t result, int exit_code); void remember_terminal_locked(uint64_t id); diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.c b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.c index 88818ddc..f14e4f52 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.c +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl.c @@ -1,7 +1,7 @@ #include "hal_lvgl_bsp.h" #include "lvgl/lvgl.h" -#include +#include "input_keys.h" #include #include #include diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_display.c b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_display.c index f898b761..fea876ce 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_display.c +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_display.c @@ -8,7 +8,7 @@ #include "lvgl/src/drivers/sdl/lv_sdl_private.h" #include "lvgl/src/drivers/sdl/lv_sdl_window.h" -#include +#include "input_keys.h" #include #include #include diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_keyboard.c b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_keyboard.c index 400f0b1c..588b6bfb 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_keyboard.c +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_keyboard.c @@ -13,7 +13,7 @@ #include "lvgl/src/drivers/sdl/lv_sdl_private.h" #include "lvgl/src/drivers/sdl/lv_sdl_window.h" -#include +#include "input_keys.h" #include #include #include @@ -45,12 +45,19 @@ __attribute__((weak)) void ui_global_hint_on_key(const struct key_item *elm) } static cp0_keyboard_key_handler_t global_key_handler; +typedef int (*cp0_sdl_keyboard_filter_t)(const struct key_item *item); +static cp0_sdl_keyboard_filter_t keyboard_filter; void cp0_keyboard_set_global_key_handler(cp0_keyboard_key_handler_t handler) { global_key_handler = handler; } +void cp0_sdl_keyboard_set_filter(cp0_sdl_keyboard_filter_t filter) +{ + keyboard_filter = filter; +} + static uint32_t cp0_evdev_process_key(uint16_t code) { switch (code) { @@ -462,7 +469,14 @@ static void cp0_sdl_keyboard_read(lv_indev_t *indev, lv_indev_data_t *data) struct key_item *elm = cp0_keyboard_queue_pop(); if (elm) { - int swallowed = ui_screensaver_filter_key(elm); + int swallowed = keyboard_filter + ? keyboard_filter(elm) + : ui_screensaver_filter_key(elm); + if (getenv("EMU_DEBUG_INPUT") != NULL) + fprintf(stderr, + "[EMU] Keyboard filter key=%u state=%d handler=%s consumed=%d\n", + elm->key_code, elm->key_state, + keyboard_filter ? "app" : "fallback", swallowed); if (!swallowed) { lv_obj_t *root = lv_screen_active(); if (root != NULL) diff --git a/ext_components/cp0_lvgl/tests/run_tests.sh b/ext_components/cp0_lvgl/tests/run_tests.sh index b10d2e22..b3330d5b 100755 --- a/ext_components/cp0_lvgl/tests/run_tests.sh +++ b/ext_components/cp0_lvgl/tests/run_tests.sh @@ -3,7 +3,8 @@ set -eu root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) binary="${TMPDIR:-/tmp}/cp0_lvgl_test_async_utils.$$" -trap 'rm -f "$binary"' EXIT HUP INT TERM +init_plan_object="${TMPDIR:-/tmp}/cp0_lvgl_test_init_plan.$$" +trap 'rm -f "$binary" "$init_plan_object"' EXIT HUP INT TERM "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ -I"$root/src" "$root/tests/test_c_api_boundary.cpp" -o "$binary" @@ -70,9 +71,10 @@ trap 'rm -f "$binary"' EXIT HUP INT TERM "$root/tests/test_signal_registration.cpp" -o "$binary" "$binary" +"${CC:-cc}" -std=c11 -Wall -Wextra -Werror \ + -I"$root/src" -c "$root/src/cp0_init_plan.c" -o "$init_plan_object" "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror \ - -I"$root/src" \ - "$root/src/cp0_init_plan.c" \ + -I"$root/src" "$init_plan_object" \ "$root/tests/test_init_plan.cpp" -o "$binary" "$binary" @@ -195,9 +197,11 @@ trap 'rm -f "$binary"' EXIT HUP INT TERM -I"$root/src" "$root/tests/test_sudo_timer_callback_boundary.cpp" -o "$binary" "$binary" -"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ - -I"$root/src" "$root/tests/test_external_process_group.cpp" -o "$binary" -"$binary" +if [ "$(uname -s)" = "Linux" ]; then + "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ + -I"$root/src" "$root/tests/test_external_process_group.cpp" -o "$binary" + "$binary" +fi "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -pthread \ -I"$root/src" \ @@ -402,11 +406,13 @@ trap 'rm -f "$binary"' EXIT HUP INT TERM "$root/tests/test_process_runner.cpp" -o "$binary" "$binary" -"${CC:-cc}" -std=c11 -Wall -Wextra -Werror \ - -I"$root/include" -I"$root/src/cp0" \ - "$root/src/cp0/cp0_keyboard_keymap.c" \ - "$root/tests/test_keyboard_keymap.c" -lxkbcommon -o "$binary" -"$binary" +if [ "$(uname -s)" = "Linux" ]; then + "${CC:-cc}" -std=c11 -Wall -Wextra -Werror \ + -I"$root/include" -I"$root/src/cp0" \ + "$root/src/cp0/cp0_keyboard_keymap.c" \ + "$root/tests/test_keyboard_keymap.c" -lxkbcommon -o "$binary" + "$binary" +fi "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -pthread \ -I"$root/include" -I"$root/src" \ diff --git a/ext_components/cp0_lvgl/tests/test_process_runner.cpp b/ext_components/cp0_lvgl/tests/test_process_runner.cpp index 3147bbc4..58c177af 100644 --- a/ext_components/cp0_lvgl/tests/test_process_runner.cpp +++ b/ext_components/cp0_lvgl/tests/test_process_runner.cpp @@ -214,7 +214,14 @@ int main() &huge_input, {}, &cancel, 1000); assert(closed_stdin.exit_code == 0); assert(sigpending(&pending) == 0 && sigismember(&pending, SIGPIPE)); - timespec zero{0, 0}; assert(sigtimedwait(&pipe_set, nullptr, &zero) == SIGPIPE); +#if defined(__APPLE__) + int consumed_signal = 0; + assert(sigwait(&pipe_set, &consumed_signal) == 0); + assert(consumed_signal == SIGPIPE); +#else + timespec zero{0, 0}; + assert(sigtimedwait(&pipe_set, nullptr, &zero) == SIGPIPE); +#endif assert(pthread_sigmask(SIG_SETMASK, &old_set, nullptr) == 0); std::string secret_input = "secret\n"; diff --git a/projects/APPLaunch/main/ui/ui_screensaver.cpp b/projects/APPLaunch/main/ui/ui_screensaver.cpp index 3d11c369..d01bea76 100644 --- a/projects/APPLaunch/main/ui/ui_screensaver.cpp +++ b/projects/APPLaunch/main/ui/ui_screensaver.cpp @@ -82,6 +82,14 @@ void overlay_delete_cb(lv_event_t *event) noexcept int timeout_seconds() noexcept { try { +#if defined(HAL_PLATFORM_SDL) + if (const char *override_value = std::getenv("EMU_SCREENSAVER_TIMEOUT_SECONDS")) { + char *end = nullptr; + const long seconds = std::strtol(override_value, &end, 10); + if (end != override_value && *end == '\0' && seconds > 0 && seconds <= 3600) + return static_cast(seconds); + } +#endif bool succeeded = false; std::string response; cp0_signal_config_api({"GetInt", "dark_time", "30"}, diff --git a/projects/CardputerZero-Emulator/CMakeLists.txt b/projects/CardputerZero-Emulator/CMakeLists.txt index e3b58524..f68253a1 100644 --- a/projects/CardputerZero-Emulator/CMakeLists.txt +++ b/projects/CardputerZero-Emulator/CMakeLists.txt @@ -5,6 +5,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) +include(CTest) # Paths — same repo, sibling projects set(REPO_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/../..) @@ -104,6 +105,16 @@ endif() set(CMAKE_POSITION_INDEPENDENT_CODE ON) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/lib/lvgl ${CMAKE_CURRENT_BINARY_DIR}/lvgl) +# The native CardputerZero SDL runtime provides its own keyboard handler with +# key-up events, modifiers, and the cp0 keyboard event contract. Do not also +# link LVGL's basic SDL keyboard driver on macOS: ELF linkers previously hid +# the duplicate symbol with --allow-multiple-definition, while ld64 rejects it. +if(APPLE AND NOT EMSCRIPTEN) + get_target_property(LVGL_SRCS lvgl SOURCES) + list(FILTER LVGL_SRCS EXCLUDE REGEX ".*/src/drivers/sdl/lv_sdl_keyboard\\.c$") + set_property(TARGET lvgl PROPERTY SOURCES ${LVGL_SRCS}) +endif() + file(GLOB APPLAUNCH_LVGL_FREETYPE_SRCS CONFIGURE_DEPENDS ${APPLAUNCH_LVGL_OVERRIDE}/lv_freetype*.c ) @@ -307,10 +318,12 @@ endif() if(APPLE OR (UNIX AND NOT EMSCRIPTEN)) set_target_properties(cardputer-zero-emu PROPERTIES ENABLE_EXPORTS ON) endif() -if(UNIX AND NOT APPLE AND NOT WIN32) +if(UNIX AND NOT WIN32) target_sources(cardputer-zero-emu PRIVATE ${CP0_LVGL_SDL_SRCS}) target_compile_definitions(cardputer-zero-emu PRIVATE HAL_PLATFORM_SDL=1) - target_link_options(cardputer-zero-emu PRIVATE -Wl,--allow-multiple-definition) + if(NOT APPLE) + target_link_options(cardputer-zero-emu PRIVATE -Wl,--allow-multiple-definition) + endif() endif() target_include_directories(cardputer-zero-emu PRIVATE @@ -430,6 +443,44 @@ if(TARGET APPLaunch) ) endif() +if(APPLE AND BUILD_TESTING) + add_test( + NAME emulator-macos-runtime + COMMAND ${CMAKE_COMMAND} -E env + SDL_VIDEODRIVER=dummy + SDL_AUDIODRIVER=dummy + EMU_TEST_CLICK_FRAME=10 + EMU_TEST_NAVIGATE_FRAME=10 + EMU_TEST_RESET_FRAME=20 + EMU_TEST_QUIT_FRAME=40 + $ + ) + set_tests_properties(emulator-macos-runtime PROPERTIES + WORKING_DIRECTORY $ + PASS_REGULAR_EXPRESSION "\\[EMU\\] Host cp0 keyboard driver" + FAIL_REGULAR_EXPRESSION "TEST (click )?(failed|missed|did not)" + TIMEOUT 30 + ) + + add_test( + NAME emulator-macos-screensaver-wake + COMMAND ${CMAKE_COMMAND} -E env + SDL_VIDEODRIVER=dummy + SDL_AUDIODRIVER=dummy + EMU_DEBUG_INPUT=1 + EMU_SCREENSAVER_TIMEOUT_SECONDS=1 + EMU_TEST_NAVIGATE_FRAME=300 + EMU_TEST_QUIT_FRAME=350 + $ + ) + set_tests_properties(emulator-macos-screensaver-wake PROPERTIES + WORKING_DIRECTORY $ + PASS_REGULAR_EXPRESSION + "Keyboard filter key=106 state=1 handler=app consumed=1" + TIMEOUT 30 + ) +endif() + endif() # NOT EMSCRIPTEN # --------------------------------------------------------------------------- diff --git a/projects/CardputerZero-Emulator/README.md b/projects/CardputerZero-Emulator/README.md new file mode 100644 index 00000000..6d24d9a1 --- /dev/null +++ b/projects/CardputerZero-Emulator/README.md @@ -0,0 +1,74 @@ +# Cardputer Zero Emulator + +The Cardputer Zero Emulator runs the launcher UI on a desktop with the same +320×170 framebuffer used by the device. The native build includes a clickable +Cardputer skin, physical keyboard input, APPLaunch, and selected desktop +runtime services. + +## Build on macOS + +Install the build dependencies: + +```sh +brew install cmake sdl2 sdl2_image freetype pkg-config nlohmann-json +python3 -m pip install --user parse requests tqdm +``` + +From the repository root, initialize the SDK and LVGL sources: + +```sh +git submodule update --init --depth=1 SDK +git clone --depth 1 --branch v9.5.0 \ + https://github.com/lvgl/lvgl.git \ + projects/CardputerZero-Emulator/lib/lvgl +``` + +If `projects/CardputerZero-Emulator/lib/lvgl` already exists, do not clone it +again. Configure, build, and test: + +```sh +cmake -S projects/CardputerZero-Emulator \ + -B projects/CardputerZero-Emulator/build \ + -DCMAKE_BUILD_TYPE=Release +cmake --build projects/CardputerZero-Emulator/build --parallel +ctest --test-dir projects/CardputerZero-Emulator/build --output-on-failure +``` + +Run the emulator from its build directory so it can find the packaged apps and +assets: + +```sh +cd projects/CardputerZero-Emulator/build +./cardputer-zero-emu +``` + +## Controls + +- Use the computer keyboard, or click the keys and side buttons on the device. +- `Aa`, `fn`, `ctrl`, `alt`, and `SYM` are toggled when clicked. +- `ESC`, `HOME`, `TALK`, and `NEXT` are clickable side buttons. +- The first click or key press while the screensaver is active wakes the + display without also activating the selected control. +- Clicking the power switch opens the emulator reset prompt. + +## Display scaling + +The default 1280×840 window displays the 320×170 LCD at an exact 2× scale. The +window is resizable and SDL preserves the device aspect ratio. + +Set `EMU_WINDOW_SCALE` to choose the initial window size. Values from `0.5` to +`2.0` are supported: + +```sh +EMU_WINDOW_SCALE=0.5 ./cardputer-zero-emu # compact 640×420 window +EMU_WINDOW_SCALE=1.5 ./cardputer-zero-emu # larger 1920×1260 window +``` + +## Automated tests + +On macOS, CTest runs headless regressions that cover: + +- native runtime startup and reset; +- clickable on-screen controls; +- keyboard navigation; and +- waking APPLaunch from its screensaver. diff --git a/projects/CardputerZero-Emulator/src/main.cpp b/projects/CardputerZero-Emulator/src/main.cpp index fff680be..f948e1db 100644 --- a/projects/CardputerZero-Emulator/src/main.cpp +++ b/projects/CardputerZero-Emulator/src/main.cpp @@ -1,4 +1,5 @@ #include "lvgl/lvgl.h" +#include "keyboard_input.h" #include #include #include @@ -29,7 +30,9 @@ static constexpr int LCD_SW = 640; static constexpr int LCD_SH = 340; static constexpr int LCD_W = 320; static constexpr int LCD_H = 170; -static constexpr float SCALE = 0.5f; +static constexpr float DEFAULT_WINDOW_SCALE = 1.0f; +static constexpr float MIN_WINDOW_SCALE = 0.5f; +static constexpr float MAX_WINDOW_SCALE = 2.0f; struct KeyRect { int x, y, w, h; SDL_Keycode key; }; static KeyRect g_keys[4][11] = { @@ -53,6 +56,7 @@ static KeyRect g_keys[4][11] = { }; // ── Side buttons + POWER ──────────────────────────────────────── +static constexpr int SIDE_NEXT = 3; static constexpr int SIDE_POWER = 4; // index of POWER in g_side_keys static constexpr int NUM_SIDE_KEYS = 5; static KeyRect g_side_keys[NUM_SIDE_KEYS] = { @@ -109,7 +113,9 @@ static uint32_t *g_lcd_buf = nullptr; typedef void (*sdl_kbd_handler_fn)(SDL_Event *); typedef void *(*sdl_kbd_create_fn)(void); +typedef int (*keyboard_filter_fn)(const struct key_item *); static sdl_kbd_handler_fn g_kbd_handler = nullptr; +static keyboard_filter_fn g_keyboard_filter = nullptr; static float g_dpi_scale = 1.0f; // renderer_pixels / window_points @@ -118,17 +124,13 @@ static int g_side_pr = -1; // pressed side key index // Convert mouse window coords → skin coords (1280x840) static void mouse_to_skin(int mx, int my, int *sx, int *sy) { - int rw, rh; - SDL_GetRendererOutputSize(g_ren, &rw, &rh); - *sx = mx * SKIN_W / (rw > 0 ? rw : SKIN_W); - *sy = my * SKIN_H / (rh > 0 ? rh : SKIN_H); - // On Retina: rw=1280, mouse is in 640 logical → sx = mx*1280/1280 wrong - // Actually SDL mouse coords are always in window logical points - // We need: sx = mx * SKIN_W / window_w - int ww, wh; - SDL_GetWindowSize(g_win, &ww, &wh); - *sx = mx * SKIN_W / (ww > 0 ? ww : 1); - *sy = my * SKIN_H / (wh > 0 ? wh : 1); + // SDL_RenderSetLogicalSize filters mouse events into renderer-logical + // coordinates before they reach SDL_PollEvent. Scaling them by the window + // size again makes real mouse clicks miss, especially on Retina displays. + int logical_w = 0, logical_h = 0; + SDL_RenderGetLogicalSize(g_ren, &logical_w, &logical_h); + *sx = mx * SKIN_W / (logical_w > 0 ? logical_w : SKIN_W); + *sy = my * SKIN_H / (logical_h > 0 ? logical_h : SKIN_H); } static bool hit_key(int mx, int my, int *r, int *c) @@ -251,7 +253,7 @@ static void render() typedef void (*ui_init_fn)(void); typedef void (*ui_deinit_fn)(void); -#if !defined(_WIN32) && !defined(__APPLE__) +#if !defined(_WIN32) extern "C" { void init_lvgl_env(void); void init_lvgl_event(void); @@ -273,6 +275,7 @@ void init_lvgl_saved_settings(void); void init_camera(void); void init_input(void); void lv_sdl_keyboard_handler(SDL_Event *event); +void cp0_sdl_keyboard_set_filter(keyboard_filter_fn filter); } static void emu_init_cp0_runtime() @@ -353,6 +356,22 @@ static unsigned emu_test_frame(const char *name) return static_cast(frame); } +static float emu_window_scale() +{ + const char *value = getenv("EMU_WINDOW_SCALE"); + if (!value || !*value) return DEFAULT_WINDOW_SCALE; + + char *end = nullptr; + const float scale = strtof(value, &end); + if (*end != '\0' || !(scale >= MIN_WINDOW_SCALE && scale <= MAX_WINDOW_SCALE)) { + fprintf(stderr, + "[EMU] Ignoring invalid EMU_WINDOW_SCALE=%s (expected %.1f-%.1f)\n", + value, MIN_WINDOW_SCALE, MAX_WINDOW_SCALE); + return DEFAULT_WINDOW_SCALE; + } + return scale; +} + int main(int argc, char *argv[]) { set_exe_dir(); @@ -360,6 +379,11 @@ int main(int argc, char *argv[]) const unsigned test_reset_frame = emu_test_frame("EMU_TEST_RESET_FRAME"); const unsigned test_quit_frame = emu_test_frame("EMU_TEST_QUIT_FRAME"); const unsigned test_navigate_frame = emu_test_frame("EMU_TEST_NAVIGATE_FRAME"); + const unsigned test_click_frame = emu_test_frame("EMU_TEST_CLICK_FRAME"); + const bool debug_input = getenv("EMU_DEBUG_INPUT") != nullptr; + const float window_scale = emu_window_scale(); + const int win_w = static_cast(SKIN_W * window_scale); + const int win_h = static_cast(SKIN_H * window_scale); #ifdef EMU_STATIC_APP // Windows: app statically linked, no dlopen @@ -376,9 +400,13 @@ int main(int argc, char *argv[]) printf("========================================\n"); printf(" M5CardputerZero Emulator\n"); printf(" App : %s\n", app_path); - printf(" LCD : %dx%d Window: %dx%d\n", - LCD_W, LCD_H, (int)(SKIN_W*SCALE), (int)(SKIN_H*SCALE)); + printf(" LCD : %dx%d (displayed %dx%d) Window: %dx%d\n", + LCD_W, LCD_H, + static_cast(LCD_SW * window_scale), + static_cast(LCD_SH * window_scale), + win_w, win_h); printf(" Modifiers: click Aa/fn/ctrl to toggle\n"); + printf(" Resize the window to zoom; EMU_WINDOW_SCALE=0.5 restores compact mode\n"); printf("========================================\n"); if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) != 0) { @@ -387,15 +415,19 @@ int main(int argc, char *argv[]) } IMG_Init(IMG_INIT_PNG); - int win_w = (int)(SKIN_W * SCALE), win_h = (int)(SKIN_H * SCALE); SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "nearest"); g_win = SDL_CreateWindow("M5CardputerZero Emulator", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, - win_w, win_h, SDL_WINDOW_SHOWN | SDL_WINDOW_ALLOW_HIGHDPI); + win_w, win_h, + SDL_WINDOW_SHOWN | SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_RESIZABLE); if (!g_win) { fprintf(stderr, "window: %s\n", SDL_GetError()); return 1; } + SDL_SetWindowMinimumSize( + g_win, + static_cast(SKIN_W * MIN_WINDOW_SCALE), + static_cast(SKIN_H * MIN_WINDOW_SCALE)); g_ren = SDL_CreateRenderer(g_win, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC); if (!g_ren) @@ -405,6 +437,7 @@ int main(int argc, char *argv[]) return 1; } SDL_RenderSetLogicalSize(g_ren, SKIN_W, SKIN_H); + SDL_StartTextInput(); // On Retina, renderer output is 2x the window size int render_w = win_w, render_h = win_h; @@ -436,7 +469,7 @@ int main(int argc, char *argv[]) LV_DISPLAY_RENDER_MODE_PARTIAL); lv_display_set_color_format(disp, LV_COLOR_FORMAT_RGB565); -#if !defined(_WIN32) && !defined(__APPLE__) +#if !defined(_WIN32) emu_init_cp0_runtime(); g_kbd_handler = lv_sdl_keyboard_handler; #endif @@ -455,6 +488,9 @@ int main(int argc, char *argv[]) auto kbd_create = (sdl_kbd_create_fn)emu_dlsym(app, "lv_sdl_keyboard_create"); g_kbd_handler = (sdl_kbd_handler_fn)emu_dlsym(app, "lv_sdl_keyboard_handler"); + g_keyboard_filter = + (keyboard_filter_fn)emu_dlsym(app, "ui_screensaver_filter_key"); + cp0_sdl_keyboard_set_filter(g_keyboard_filter); if (!g_kbd_handler) g_kbd_handler = (sdl_kbd_handler_fn)emu_dlsym(RTLD_DEFAULT, "lv_sdl_keyboard_handler"); @@ -468,6 +504,8 @@ int main(int argc, char *argv[]) lv_indev_set_type(kb, LV_INDEV_TYPE_KEYPAD); printf("[EMU] Built-in keyboard driver\n"); } + if (g_keyboard_filter) + printf("[EMU] App input filter loaded\n"); ui_init_fn app_init = (ui_init_fn)emu_dlsym(app, "ui_init"); if (!app_init) { fprintf(stderr, "[EMU] ui_init missing\n"); return 1; } @@ -481,14 +519,41 @@ int main(int argc, char *argv[]) unsigned frame_count = 0; bool test_reset_done = false; bool test_navigate_done = false; + bool test_click_queued = false; + bool test_click_done = false; + bool test_failed = false; + static constexpr uint32_t TEST_MOUSE_ID = 0x43503054; while (true) { SDL_Event ev; while (SDL_PollEvent(&ev)) { if (ev.type == SDL_QUIT) goto done; if (ev.type == SDL_MOUSEBUTTONDOWN) { + if (g_keyboard_filter) { + struct key_item pointer_activity = {}; + pointer_activity.key_state = KBD_KEY_RELEASED; + const int consumed = g_keyboard_filter(&pointer_activity); + if (debug_input) + printf("[EMU] Pointer activity filter=%d raw=(%d,%d)\n", + consumed, ev.button.x, ev.button.y); + if (consumed) + continue; + } int r, c; int side = hit_side_key(ev.button.x, ev.button.y); + if (ev.button.which == TEST_MOUSE_ID) { + test_click_done = side == SIDE_NEXT; + test_failed = !test_click_done; + printf("[EMU] TEST click %s raw=(%d,%d) side=%d\n", + test_click_done ? "hit NEXT" : "missed NEXT", + ev.button.x, ev.button.y, side); + } + if (debug_input) { + int skin_x = 0, skin_y = 0; + mouse_to_skin(ev.button.x, ev.button.y, &skin_x, &skin_y); + printf("[EMU] Mouse down raw=(%d,%d) skin=(%d,%d) side=%d\n", + ev.button.x, ev.button.y, skin_x, skin_y, side); + } if (side == SIDE_POWER) { g_side_pr = side; const SDL_MessageBoxButtonData btns[] = { @@ -544,6 +609,10 @@ int main(int argc, char *argv[]) } else if (ev.type == SDL_KEYDOWN || ev.type == SDL_KEYUP || ev.type == SDL_TEXTINPUT) { + if (debug_input) + printf("[EMU] SDL input event type=%u sym=%d\n", + ev.type, + ev.type == SDL_TEXTINPUT ? 0 : ev.key.keysym.sym); if (g_kbd_handler) g_kbd_handler(&ev); } } @@ -558,6 +627,29 @@ int main(int argc, char *argv[]) inject_sdl_key(SDLK_RIGHT, false); test_navigate_done = true; } + if (!test_click_queued && test_click_frame && frame_count >= test_click_frame) { + SDL_Event down = {}; + down.type = SDL_MOUSEBUTTONDOWN; + down.button.windowID = SDL_GetWindowID(g_win); + down.button.which = TEST_MOUSE_ID; + down.button.button = SDL_BUTTON_LEFT; + down.button.state = SDL_PRESSED; + const float logical_x = + static_cast(g_side_keys[SIDE_NEXT].x + g_side_keys[SIDE_NEXT].w / 2); + const float logical_y = + static_cast(g_side_keys[SIDE_NEXT].y + g_side_keys[SIDE_NEXT].h / 2); + SDL_RenderLogicalToWindow( + g_ren, logical_x, logical_y, &down.button.x, &down.button.y); + SDL_Event up = down; + up.type = SDL_MOUSEBUTTONUP; + up.button.state = SDL_RELEASED; + if (SDL_PushEvent(&down) < 0 || SDL_PushEvent(&up) < 0) { + fprintf(stderr, "[EMU] TEST failed to queue mouse click: %s\n", SDL_GetError()); + test_failed = true; + } + printf("[EMU] TEST click queued at frame %u\n", frame_count); + test_click_queued = true; + } if (!test_reset_done && test_reset_frame && frame_count >= test_reset_frame) { printf("[EMU] TEST reset at frame %u\n", frame_count); memset(g_lcd_buf, 0, LCD_W * LCD_H * sizeof(uint32_t)); @@ -583,6 +675,10 @@ int main(int argc, char *argv[]) } done: + if (test_click_frame && !test_click_done) { + fprintf(stderr, "[EMU] TEST click did not reach NEXT\n"); + test_failed = true; + } #ifdef EMU_STATIC_APP ui_deinit(); printf("[EMU] App deinitialized\n"); @@ -594,13 +690,15 @@ int main(int argc, char *argv[]) #endif free(g_lcd_buf); #ifndef EMU_STATIC_APP + cp0_sdl_keyboard_set_filter(nullptr); emu_dlclose(app); #endif + SDL_StopTextInput(); SDL_DestroyTexture(g_lcd_tex); SDL_DestroyTexture(g_skin_tex); SDL_DestroyRenderer(g_ren); SDL_DestroyWindow(g_win); IMG_Quit(); SDL_Quit(); - return 0; + return test_failed ? 1 : 0; }