From 81b88922f7af41b519e7f250beddf16d3032cf43 Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Thu, 16 Jul 2026 13:21:47 +0530 Subject: [PATCH 01/15] system/nxpkg: network sync, install hardening, and CLI completion. Fill in most of what the initial nxpkg slice deferred: network sync and artifact acquisition over plain HTTP (verified via SHA-256), an install rewrite that supports network sources and reclaims state left by an interrupted previous install, and wiring update/remove/rollback/ available into the CLI. Harden the package path against untrusted input along the way: bounded memory-safety helpers and explicit size limits throughout, storage read/write hardened against partially-written files, path-traversal rejection in manifest name/version before they reach the filesystem, and a fix for a lost-update race on the shared installed-packages database (two concurrent installs of different packages could otherwise silently clobber each other's recorded state). Add an optional manifest icon field for the nxstore GUI frontend to consume, raise PKG_INDEX_MAX now that the in-memory index is heap-allocated, and document the repository layout and local server setup in system/nxpkg/README.txt. Also fixes a real build break: pkg_runtime_compat() unconditionally referenced CONFIG_ARCH_BOARD, a Kconfig string symbol with no default clause under ARCH_BOARD_CUSTOM, so it is left entirely undefined rather than defined-but-empty on a custom board - falls back to CONFIG_ARCH_BOARD_CUSTOM_NAME instead. Routes pkg_error()/pkg_info() through syslog rather than stdio, since neither is visible to a supervisor with no attached console. Assisted-by: Claude:claude-sonnet-5 Signed-off-by: aviralgarg05 --- system/nxpkg/CMakeLists.txt | 1 + system/nxpkg/Kconfig | 9 + system/nxpkg/Makefile | 1 + system/nxpkg/README.txt | 175 +++++++++ system/nxpkg/pkg.h | 154 +++++++- system/nxpkg/pkg_compat.c | 9 + system/nxpkg/pkg_install.c | 726 ++++++++++++++++++++++++++++++++---- system/nxpkg/pkg_log.c | 24 +- system/nxpkg/pkg_main.c | 107 ++++-- system/nxpkg/pkg_manifest.c | 71 ++++ system/nxpkg/pkg_metadata.c | 237 ++++++++++-- system/nxpkg/pkg_repo.c | 612 ++++++++++++++++++++++++++++++ system/nxpkg/pkg_store.c | 316 ++++++++++++++-- 13 files changed, 2259 insertions(+), 183 deletions(-) create mode 100644 system/nxpkg/README.txt create mode 100644 system/nxpkg/pkg_repo.c diff --git a/system/nxpkg/CMakeLists.txt b/system/nxpkg/CMakeLists.txt index 8ac3e358532..66a57e1ef3a 100644 --- a/system/nxpkg/CMakeLists.txt +++ b/system/nxpkg/CMakeLists.txt @@ -38,6 +38,7 @@ if(CONFIG_SYSTEM_NXPKG) pkg_log.c pkg_manifest.c pkg_metadata.c + pkg_repo.c pkg_store.c pkg_txn.c) endif() diff --git a/system/nxpkg/Kconfig b/system/nxpkg/Kconfig index afc7fa32670..196e7bdfd67 100644 --- a/system/nxpkg/Kconfig +++ b/system/nxpkg/Kconfig @@ -29,4 +29,13 @@ config SYSTEM_NXPKG_STACKSIZE int "'nxpkg' stack size" default 16384 +config SYSTEM_NXPKG_ROOT + string "'nxpkg' storage root" + default "/tmp/nxpkg" + ---help--- + Base directory used by nxpkg for its local index, installed + metadata, temporary downloads, and package payload storage. + Boards with external storage can point this to a persistent + location such as /mnt/sdcard/nxpkg. + endif diff --git a/system/nxpkg/Makefile b/system/nxpkg/Makefile index 757f9fc896e..da3e935cf63 100644 --- a/system/nxpkg/Makefile +++ b/system/nxpkg/Makefile @@ -29,6 +29,7 @@ MODULE = $(CONFIG_SYSTEM_NXPKG) CSRCS = pkg_compat.c pkg_hash.c pkg_install.c pkg_log.c pkg_manifest.c CSRCS += pkg_metadata.c pkg_store.c pkg_txn.c +CSRCS += pkg_repo.c MAINSRC = pkg_main.c include $(APPDIR)/Application.mk diff --git a/system/nxpkg/README.txt b/system/nxpkg/README.txt new file mode 100644 index 00000000000..c3a48ee80d9 --- /dev/null +++ b/system/nxpkg/README.txt @@ -0,0 +1,175 @@ +nxpkg - a package manager for NuttX application images +======================================================== + + nxpkg installs, updates, uninstalls, and rolls back standalone loadable + ELF applications (MODULE=m in an app's Makefile - see games/NXDoom or + examples/calculator for real ports) from a package repository served + over HTTP, onto local storage (an SD card on this board's config). + system/nxstore is an LVGL touchscreen front-end for nxpkg; this + document covers the repository/server side, which nxstore and the + nxpkg CLI both consume identically. + + +Repository layout +================== + + A repository is nothing more than a directory of static files: + + / + index.json - the package catalog + artifacts////// + icons////.bin - optional, see below + + index.json is a single JSON object with one "packages" array. Each + entry is one installable version of one package: + + { + "packages": [ + { + "name": "calc", + "version": "6", + "arch": "xtensa", + "compat": "esp32s3-touch-lcd-7", + "artifact": "artifacts/xtensa/esp32s3/esp32s3-touch-lcd-7/calc/6/calc", + "sha256": "<64 hex chars>", + "type": "elf", + "description": "Simple 4-function calculator", + "category": "Utilities", + "icon": "icons/xtensa/esp32s3/esp32s3-touch-lcd-7/calc.bin" + } + ] + } + + "artifact" and "icon" (both optional except "artifact") are resolved + relative to the repository's own base URL (the URL index.json itself + was fetched from, with the filename stripped) unless they're already + a full http(s):// URL - see pkg_resolve_artifact_source()/ + pkg_resolve_icon_source() in pkg_repo.c. That means the whole + directory tree above can be moved, mirrored, or served from a + completely different host without editing a single path in + index.json, as long as the *relative* structure between index.json + and artifacts/icons/ stays the same. + + "arch"/"compat" must match this board's CONFIG_ARCH ("xtensa") and + CONFIG_ARCH_BOARD ("esp32s3-touch-lcd-7") exactly - see + pkg_compat_check() in pkg_compat.c - so one repository can host + packages for several different boards side by side; each board only + ever installs the entries that match its own identity. + + +What actually serves this - any static file host works +======================================================== + + pkg_repo_fetch_url() (pkg_repo.c) does a plain HTTP GET with no auth, + no custom headers, and no server-side logic required - it just needs + a 2xx response. This means literally any static file server works: + nginx or Apache with a DocumentRoot pointed at the repo directory, a + one-line Python http.server, GitHub Pages, S3/R2/Cloudflare Pages, or + a NAS's built-in web server. There is nothing nxpkg-specific to + install on the server side. + + For local development/testing (what this project actually used while + building and testing every package in this series on real hardware): + + cd /path/to/repo-root + python3 -m http.server 8000 + + That's the entire "server setup." Confirm it's reachable from your + own machine first: + + curl -sI http://:8000/index.json + + sha256 verification (pkg_hash_file_sha256(), checked against the + manifest's "sha256" field before an artifact is ever activated) + already protects payload integrity in transit even over plain HTTP. + Only confidentiality/availability would need HTTPS on top of this if + that matters for a given deployment - nothing in the protocol assumes + HTTP specifically, an https:// URL works exactly the same way. + + +Populating a repository: tools/export_pkg_repo.py +================================================== + + Building the JSON by hand is error-prone (sha256 digests, exact + relative paths); tools/export_pkg_repo.py does it for you from a + built binary: + + python3 tools/export_pkg_repo.py \ + --arch xtensa --chip esp32s3 --compat esp32s3-touch-lcd-7 \ + --package "calc:6:elf:/path/to/apps/bin/calc:Simple 4-function calculator:Utilities" \ + /path/to/repo-root + + --package can be repeated to export several packages/versions in one + call. The colon-separated spec is: + + :::[::[:]] + + is the built artifact on your machine (e.g. apps/bin/calc + after a normal `make`). is optional: any image Pillow can open + (PNG, JPEG, ...) - the tool resizes it to a fixed 48x48 and encodes it + into the small raw RGB565 format system/nxstore's icon rendering + expects (see nxstore_load_icon() in nxstore_main.c for the exact + on-wire layout). Re-running the script is idempotent: it loads + whatever index.json already exists in the target directory, merges in + the packages you just passed (matched on name+version+arch+compat+ + type), and rewrites the file - existing unrelated entries are left + alone. + + This board's FAT-formatted SD card only supports short (8.3) file/ + directory names with no long-name extension - any path component + (package name, artifact leaf filename) over 8 characters before its + extension fails to install with errno 22, not a clear error message. + Keep names short (this is why this series' packages are named "calc", + "brick", "cgol" rather than "calculator", "brickmatch", "conway") - + this is a board/filesystem constraint, not an export_pkg_repo.py or + nxpkg one, and would not apply to a board with a different storage + filesystem. + + +Pointing the board at your repository +====================================== + + Two ways to get an index synced onto the board's local storage, + useful in different situations: + + 1. One-shot from NSH, useful for iterating on a package during + development (this is what building and testing every package in + this series actually looked like): + + nxpkg sync http://:8000/index.json + nxpkg install + nxpkg update # re-check for/install a newer version + nxpkg list # show what's installed, current vs previous + nxpkg rollback # swap back to the previous version + nxpkg remove + + 2. Automatically at boot, via system/nxstore: the repo URL nxstore + syncs from at startup is currently a single hardcoded string in + board_nxstore_autostart() (boards/xtensa/esp32s3/ + esp32s3-touch-lcd-7/src/esp32s3_bringup.c) - change that one line + to point at a different host, or a public one, and every boot + re-syncs the catalog automatically (falling back to whatever was + last synced to local storage if the network/server is unreachable + at boot - nxstore never blocks the app list on a failed sync). + + Development-network note: if your development machine's IP address + changes (switching Wi-Fi networks, a new DHCP lease, a hotspot), + re-run `nxpkg sync` with the new address - the board and the machine + serving the repository just need to be able to reach each other over + IP; nothing else about the setup changes. + + +Concurrency note +================= + + nxpkg protects a single package's own install/update/rollback against + running twice at once (a per-package lock file), and separately + protects the shared installed-packages database against being + corrupted by two *different* packages' installs racing each other + (see pkg_install_acquire_installed_lock() in pkg_install.c) - so it is + safe to have, for example, system/nxstore's own background install + worker and a directly-invoked `nxpkg install` running at the same + time for different packages. Two operations on the *same* package + name at the same time will have the second one fail with -EBUSY + (surfaced as "package has an install/update in progress") rather than + either one silently clobbering the other's work. diff --git a/system/nxpkg/pkg.h b/system/nxpkg/pkg.h index 39a4bf0c21d..ff515962ab9 100644 --- a/system/nxpkg/pkg.h +++ b/system/nxpkg/pkg.h @@ -27,30 +27,150 @@ * Included Files ****************************************************************************/ +#include + #include #include #include #include +#include + +#include /**************************************************************************** * Pre-processor Definitions ****************************************************************************/ -#define PKG_REPO_DIR "/etc/nxpkg" -#define PKG_REPO_INDEX "/etc/nxpkg/index.json" -#define PKG_REPO_INSTALLED "/var/lib/nxpkg/installed.json" -#define PKG_STORE_DIR "/var/lib/nxpkg/pkgs" -#define PKG_TMP_DIR "/var/cache/nxpkg" -#define PKG_TMP_PKG_DIR "/var/cache/nxpkg/pkg" +#define PKG_ROOT_DIR CONFIG_SYSTEM_NXPKG_ROOT +#define PKG_REPO_DIR PKG_ROOT_DIR +#define PKG_REPO_INDEX PKG_ROOT_DIR "/index.jsn" +#define PKG_REPO_SOURCE PKG_ROOT_DIR "/repo.url" +#define PKG_REPO_INSTALLED PKG_ROOT_DIR "/instpkg.jsn" +#define PKG_STORE_DIR PKG_ROOT_DIR "/pkgs" +#define PKG_TMP_DIR PKG_ROOT_DIR "/tmp" +#define PKG_TMP_PKG_DIR PKG_ROOT_DIR "/tmp/pkg" #define PKG_NAME_MAX 63 #define PKG_VERSION_MAX 31 #define PKG_ARCH_MAX 31 #define PKG_COMPAT_MAX 63 +#define PKG_DESCRIPTION_MAX 127 +#define PKG_CATEGORY_MAX 31 #define PKG_HASH_HEX_LEN 64 -#define PKG_INDEX_MAX 32 +/* Each manifest slot is ~1.7KB (dominated by PKG_LAUNCH_ARGS_MAX slots). + * This used to be 6 to fit inside a static internal-DRAM array, but the + * catalog outgrew that once it held every real GSoC package (calc, the + * four games, NXDoom, and the original examples) - system/nxstore/ + * nxstore_main.c now heap-allocates its struct pkg_index_s g_index via + * pkg_zalloc(), which draws from the PSRAM-backed user heap on this board + * (CONFIG_ESP32S3_SPIRAM_USER_HEAP) instead of internal DRAM, so this can + * grow without the same pressure. Still not unbounded: keep it sized for + * "a real catalog", not arbitrary attacker-controlled growth. + */ + +#define PKG_INDEX_MAX 16 #define PKG_INSTALLED_MAX 16 #define PKG_INSTALLED_VERSIONS_MAX 8 +#define PKG_LAUNCH_ARGS_MAX 8 +#define PKG_LAUNCH_ARG_MAX 127 + +/* Caps against a malicious/compromised HTTP server: without these, an + * oversized response can exhaust SD-card space (downloads) or force an + * unbounded single heap allocation sized directly off attacker-controlled + * content (pkg_store_read_text). Text/metadata files (index.jsn, + * instpkg.jsn) are always small; artifact downloads cover the largest + * real payloads seen in practice (a multi-MB WAD, a ~1MB game ELF) with + * generous headroom. + */ + +#define PKG_TEXT_MAX_SIZE (256 * 1024) +#define PKG_DOWNLOAD_MAX_SIZE (32 * 1024 * 1024) + +/* nxpkg is a one-shot CLI, not a daemon, so a lock file older than this + * cannot belong to a still-running install under normal use (even a full + * multi-MB artifact over a slow link finishes well within this window) - + * it can only be left over from a process that was killed or a device + * that lost power mid-install. Reclaiming it is what makes install/ + * update/rollback usable again after the crash/power-loss scenarios this + * target is prone to, instead of failing with EBUSY forever. + */ + +#define PKG_LOCK_STALE_SECONDS (600) + +static inline void *pkg_malloc(size_t size) +{ + void *ptr = malloc(size); + + if (ptr == NULL) + { + ptr = kmm_malloc(size); + } + + return ptr; +} + +static inline void *pkg_zalloc(size_t size) +{ + void *ptr = calloc(1, size); + + if (ptr == NULL) + { + ptr = kmm_zalloc(size); + } + + return ptr; +} + +static inline void *pkg_realloc(void *ptr, size_t size) +{ + if (ptr == NULL) + { + return pkg_malloc(size); + } + + if (size == 0) + { + if (kmm_heapmember(ptr)) + { + kmm_free(ptr); + } + else + { + free(ptr); + } + + return NULL; + } + + if (kmm_heapmember(ptr)) + { + return kmm_realloc(ptr, size); + } + + return realloc(ptr, size); +} + +static inline void pkg_free(void *ptr) +{ + if (ptr == NULL) + { + return; + } + + if (kmm_heapmember(ptr)) + { + kmm_free(ptr); + } + else + { + free(ptr); + } +} + +static inline FAR char *pkg_path_alloc(void) +{ + return pkg_malloc(PATH_MAX); +} /**************************************************************************** * Public Types @@ -83,7 +203,12 @@ struct pkg_manifest_s char compat[PKG_COMPAT_MAX + 1]; char artifact[PATH_MAX]; char sha256[PKG_HASH_HEX_LEN + 1]; + char launch_args[PKG_LAUNCH_ARGS_MAX][PKG_LAUNCH_ARG_MAX + 1]; + char description[PKG_DESCRIPTION_MAX + 1]; + char category[PKG_CATEGORY_MAX + 1]; + char icon[PATH_MAX]; enum pkg_payload_type_e type; + size_t launch_argc; }; struct pkg_index_s @@ -124,6 +249,7 @@ int pkg_store_ensure_package_root(FAR const char *name); int pkg_store_ensure_version_dir(FAR const char *name, FAR const char *version); int pkg_store_format_index_path(FAR char *buffer, size_t size); +int pkg_store_format_repo_source_path(FAR char *buffer, size_t size); int pkg_store_format_installed_path(FAR char *buffer, size_t size); int pkg_store_format_package_root(FAR char *buffer, size_t size, FAR const char *name); @@ -152,6 +278,8 @@ int pkg_store_read_text(FAR const char *path, FAR char **buffer); int pkg_store_write_text_atomic(FAR const char *path, FAR const char *text); int pkg_store_copy_file(FAR const char *src, FAR const char *dest); int pkg_store_remove_file(FAR const char *path); +int pkg_store_remove_version_dir(FAR const char *name, + FAR const char *version); const char *pkg_runtime_arch(void); const char *pkg_runtime_compat(void); @@ -160,6 +288,8 @@ int pkg_compat_check(FAR const struct pkg_manifest_s *manifest); int pkg_hash_file_sha256(FAR const char *path, FAR char digest[PKG_HASH_HEX_LEN + 1]); +int pkg_metadata_load_index_path(FAR const char *path, + FAR struct pkg_index_s *index); int pkg_metadata_load_index(FAR struct pkg_index_s *index); FAR const struct pkg_manifest_s * pkg_metadata_find_latest(FAR const struct pkg_index_s *index, @@ -178,7 +308,17 @@ const char *pkg_txn_state_str(enum pkg_txn_state_e state); int pkg_txn_write_state(FAR const char *name, enum pkg_txn_state_e state); int pkg_txn_clear_state(FAR const char *name); +bool pkg_source_is_url(FAR const char *source); +int pkg_resolve_artifact_source(FAR char *buffer, size_t size, + FAR const struct pkg_manifest_s *manifest); +int pkg_resolve_icon_source(FAR char *buffer, size_t size, + FAR const struct pkg_manifest_s *manifest); +int pkg_acquire_source(FAR const char *source, FAR const char *dest); +int pkg_sync(FAR const char *source); int pkg_install(FAR const char *name); +int pkg_uninstall(FAR const char *name); +int pkg_rollback(FAR const char *name); +int pkg_available(FAR FILE *stream); int pkg_list(FAR FILE *stream); void pkg_error(FAR const char *fmt, ...); diff --git a/system/nxpkg/pkg_compat.c b/system/nxpkg/pkg_compat.c index 301fb07aacf..b701a1d8a09 100644 --- a/system/nxpkg/pkg_compat.c +++ b/system/nxpkg/pkg_compat.c @@ -40,7 +40,16 @@ const char *pkg_runtime_arch(void) const char *pkg_runtime_compat(void) { +#ifdef CONFIG_ARCH_BOARD return CONFIG_ARCH_BOARD; +#elif defined(CONFIG_ARCH_BOARD_CUSTOM_NAME) + if (CONFIG_ARCH_BOARD_CUSTOM_NAME[0] != '\0') + { + return CONFIG_ARCH_BOARD_CUSTOM_NAME; + } +#endif + + return ""; } int pkg_compat_check(FAR const struct pkg_manifest_s *manifest) diff --git a/system/nxpkg/pkg_install.c b/system/nxpkg/pkg_install.c index c4098200191..3048342696e 100644 --- a/system/nxpkg/pkg_install.c +++ b/system/nxpkg/pkg_install.c @@ -27,8 +27,11 @@ #include #include #include +#include #include #include +#include +#include #include #include "pkg.h" @@ -37,30 +40,37 @@ * Private Functions ****************************************************************************/ -static int pkg_install_resolve_artifact(FAR char *buffer, size_t size, - FAR const struct pkg_manifest_s - *manifest) +/**************************************************************************** + * Name: pkg_install_reclaim_stale_lock + * + * Description: + * Remove "path" if it is old enough that it cannot belong to a + * still-running install (see PKG_LOCK_STALE_SECONDS). Best-effort: any + * stat()/unlink() failure just falls through to the normal -EBUSY + * result, since a lock we can't inspect should be treated as held. + * + ****************************************************************************/ + +static void pkg_install_reclaim_stale_lock(FAR const char *path) { - int ret; + struct stat st; + time_t now; - if (manifest->artifact[0] == '/') + if (stat(path, &st) < 0) { - ret = snprintf(buffer, size, "%s", manifest->artifact); - if (ret < 0) - { - return ret; - } - - return (size_t)ret >= size ? -ENAMETOOLONG : 0; + return; } - ret = snprintf(buffer, size, "%s/%s", PKG_REPO_DIR, manifest->artifact); - if (ret < 0) + now = time(NULL); + if (now < st.st_mtime || + (now - st.st_mtime) < PKG_LOCK_STALE_SECONDS) { - return ret; + return; } - return (size_t)ret >= size ? -ENAMETOOLONG : 0; + pkg_error("reclaiming stale lock '%s' (age %ld s)", + path, (long)(now - st.st_mtime)); + unlink(path); } static int pkg_install_acquire_lock(FAR const char *name, FAR char *path, @@ -82,6 +92,12 @@ static int pkg_install_acquire_lock(FAR const char *name, FAR char *path, } fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); + if (fd < 0 && errno == EEXIST) + { + pkg_install_reclaim_stale_lock(path); + fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); + } + if (fd < 0) { return errno == EEXIST ? -EBUSY : -errno; @@ -91,6 +107,82 @@ static int pkg_install_acquire_lock(FAR const char *name, FAR char *path, return 0; } +/**************************************************************************** + * Name: pkg_install_acquire_installed_lock + * + * Description: + * The per-package lock above (pkg_install_acquire_lock()) only ever + * protects one package's own version directory/manifest - it says + * nothing about the single shared installed-packages database + * (instpkg.jsn) that every install/uninstall/rollback reads, modifies + * in memory, and writes back as a whole. Two of those operations for + * *different* packages (their own per-package locks don't conflict) + * can each load a snapshot of that shared file, add/change their own + * entry, and save - and whichever one saves last wins, silently + * discarding whatever the other one had just added. This is exactly + * what "a previously-installed package vanishes from `nxpkg list` + * with no error" looks like, without needing any corrupted file or + * non-atomic write at all: the write path is already atomic + * (pkg_store_write_text_atomic() writes to a temp file, fsyncs, then + * renames), so the *file* is always internally consistent - it just + * might be a consistent snapshot that's missing an entry a concurrent + * operation already believed it had durably saved. + * + * A separate, single global lock file (independent of any specific + * package's own lock) closes that window: acquire it right before + * pkg_metadata_load_installed() and hold it until immediately after + * pkg_metadata_save_installed(), so that whole read-modify-write + * sequence is atomic with respect to every other caller of this + * function, regardless of which package(s) they're touching. + * + * Blocking (bounded retry loop) rather than instant-EBUSY like the + * per-package lock: the critical section this protects is a handful + * of local SD-card JSON operations with no network I/O in it, so a + * real holder releases it within milliseconds - a caller should wait + * that out rather than fail an otherwise-healthy install/uninstall/ + * rollback just because another one's shared-db update was in + * flight at the same instant. + * + ****************************************************************************/ + +static int pkg_install_acquire_installed_lock(FAR char *path, size_t size) +{ + int fd; + int ret; + int tries; + + ret = snprintf(path, size, PKG_ROOT_DIR "/instpkg.lk"); + if (ret < 0) + { + return ret; + } + + if ((size_t)ret >= size) + { + return -ENAMETOOLONG; + } + + for (tries = 0; tries < 100; tries++) + { + fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); + if (fd >= 0) + { + close(fd); + return 0; + } + + if (errno != EEXIST) + { + return -errno; + } + + pkg_install_reclaim_stale_lock(path); + usleep(20 * 1000); + } + + return -EBUSY; +} + static bool pkg_install_has_version( FAR const struct pkg_installed_entry_s *entry, FAR const char *version) @@ -108,6 +200,46 @@ static bool pkg_install_has_version( return false; } +static int pkg_install_prune_oldest_version( + FAR struct pkg_installed_entry_s *entry) +{ + size_t victim = entry->version_count; + size_t i; + + /* Versions are appended in install order, so the lowest index that + * isn't the active ("current") or rollback ("previous") version is the + * oldest one safe to drop. Without this, a package updated more than + * PKG_INSTALLED_VERSIONS_MAX times becomes permanently un-installable + * (pkg_install_add_version would just fail forever). + */ + + for (i = 0; i < entry->version_count; i++) + { + if (strcmp(entry->versions[i], entry->current) != 0 && + strcmp(entry->versions[i], entry->previous) != 0) + { + victim = i; + break; + } + } + + if (victim == entry->version_count) + { + return -E2BIG; + } + + pkg_store_remove_version_dir(entry->name, entry->versions[victim]); + + for (i = victim; i + 1 < entry->version_count; i++) + { + memcpy(entry->versions[i], entry->versions[i + 1], + sizeof(entry->versions[i])); + } + + entry->version_count--; + return 0; +} + static int pkg_install_add_version(FAR struct pkg_installed_entry_s *entry, FAR const char *version) { @@ -120,7 +252,11 @@ static int pkg_install_add_version(FAR struct pkg_installed_entry_s *entry, if (entry->version_count >= PKG_INSTALLED_VERSIONS_MAX) { - return -E2BIG; + ret = pkg_install_prune_oldest_version(entry); + if (ret < 0) + { + return ret; + } } ret = snprintf(entry->versions[entry->version_count], @@ -201,7 +337,7 @@ static int pkg_install_update_installed(FAR struct pkg_installed_db_s *db, static int pkg_install_write_pointers( FAR const struct pkg_installed_db_s *db, - FAR const struct pkg_manifest_s *manifest) + FAR const char *name) { FAR struct pkg_installed_entry_s *entry; char current[PATH_MAX]; @@ -209,33 +345,35 @@ static int pkg_install_write_pointers( int ret; entry = pkg_metadata_find_installed((FAR struct pkg_installed_db_s *)db, - manifest->name); + name); if (entry == NULL) { - return -ENOENT; + ret = -ENOENT; + goto out; } - ret = pkg_store_format_current_path(current, sizeof(current), - manifest->name); + ret = pkg_store_format_current_path(current, PATH_MAX, name); if (ret < 0) { - return ret; + goto out; } - ret = pkg_store_format_previous_path(previous, sizeof(previous), - manifest->name); + ret = pkg_store_format_previous_path(previous, PATH_MAX, name); if (ret < 0) { - return ret; + goto out; } ret = pkg_store_write_text_atomic(current, entry->current); if (ret < 0) { - return ret; + goto out; } - return pkg_store_write_text_atomic(previous, entry->previous); + ret = pkg_store_write_text_atomic(previous, entry->previous); + +out: + return ret; } /**************************************************************************** @@ -247,29 +385,65 @@ int pkg_install(FAR const char *name) FAR struct pkg_index_s *index; FAR struct pkg_installed_db_s *installed; FAR const struct pkg_manifest_s *manifest; - char source[PATH_MAX]; - char tmp[PATH_MAX] = ""; - char payload[PATH_MAX]; - char manifest_path[PATH_MAX]; - char lock[PATH_MAX] = ""; + FAR char *source; + FAR char *tmp; + FAR char *payload; + FAR char *manifest_path; + FAR char *lock; + FAR char *installed_lock; + FAR const char *artifact; char digest[PKG_HASH_HEX_LEN + 1]; + bool staged_to_tmp; + bool version_dir_created; + bool installed_lock_held; int ret; - index = malloc(sizeof(*index)); - installed = malloc(sizeof(*installed)); - if (index == NULL || installed == NULL) + index = pkg_zalloc(sizeof(*index)); + installed = pkg_zalloc(sizeof(*installed)); + source = pkg_path_alloc(); + tmp = pkg_path_alloc(); + payload = pkg_path_alloc(); + manifest_path = pkg_path_alloc(); + lock = pkg_path_alloc(); + installed_lock = pkg_path_alloc(); + if (index == NULL || installed == NULL || source == NULL || tmp == NULL || + payload == NULL || manifest_path == NULL || lock == NULL || + installed_lock == NULL) { - free(index); - free(installed); + pkg_free(index); + pkg_free(installed); + pkg_free(source); + pkg_free(tmp); + pkg_free(payload); + pkg_free(manifest_path); + pkg_free(lock); + pkg_free(installed_lock); pkg_error("unable to allocate package metadata buffers"); return EXIT_FAILURE; } + source[0] = '\0'; + tmp[0] = '\0'; + payload[0] = '\0'; + manifest_path[0] = '\0'; + lock[0] = '\0'; + installed_lock[0] = '\0'; + installed_lock_held = false; + artifact = NULL; + staged_to_tmp = false; + version_dir_created = false; + ret = pkg_store_prepare_layout(); if (ret < 0) { - free(index); - free(installed); + pkg_free(index); + pkg_free(installed); + pkg_free(source); + pkg_free(tmp); + pkg_free(payload); + pkg_free(manifest_path); + pkg_free(lock); + pkg_free(installed_lock); pkg_error("unable to prepare package layout: %d", ret); return EXIT_FAILURE; } @@ -277,8 +451,14 @@ int pkg_install(FAR const char *name) ret = pkg_metadata_load_index(index); if (ret < 0) { - free(index); - free(installed); + pkg_free(index); + pkg_free(installed); + pkg_free(source); + pkg_free(tmp); + pkg_free(payload); + pkg_free(manifest_path); + pkg_free(lock); + pkg_free(installed_lock); pkg_error("unable to load local index metadata: %d", ret); return EXIT_FAILURE; } @@ -286,22 +466,44 @@ int pkg_install(FAR const char *name) manifest = pkg_metadata_find_latest(index, name); if (manifest == NULL) { - free(index); - free(installed); + pkg_free(index); + pkg_free(installed); + pkg_free(source); + pkg_free(tmp); + pkg_free(payload); + pkg_free(manifest_path); + pkg_free(lock); + pkg_free(installed_lock); pkg_error("package '%s' not found in local index", name); return EXIT_FAILURE; } - ret = pkg_install_resolve_artifact(source, sizeof(source), manifest); + ret = pkg_resolve_artifact_source(source, PATH_MAX, manifest); if (ret < 0) { - pkg_error("artifact path for '%s' is too long", name); + pkg_free(index); + pkg_free(installed); + pkg_free(source); + pkg_free(tmp); + pkg_free(payload); + pkg_free(manifest_path); + pkg_free(lock); + pkg_free(installed_lock); + pkg_error("unable to resolve artifact source for '%s': %d", name, ret); return EXIT_FAILURE; } - ret = pkg_install_acquire_lock(name, lock, sizeof(lock)); + ret = pkg_install_acquire_lock(name, lock, PATH_MAX); if (ret < 0) { + pkg_free(index); + pkg_free(installed); + pkg_free(source); + pkg_free(tmp); + pkg_free(payload); + pkg_free(manifest_path); + pkg_free(lock); + pkg_free(installed_lock); pkg_error("unable to acquire package lock for '%s': %d", name, ret); return EXIT_FAILURE; } @@ -309,123 +511,174 @@ int pkg_install(FAR const char *name) ret = pkg_txn_write_state(name, PKG_TXN_FETCHING); if (ret < 0) { + pkg_error("txn state fetching failed: %d", ret); goto errout; } - ret = pkg_store_format_download_path(tmp, sizeof(tmp), manifest->name, - manifest->version); - if (ret < 0) + if (pkg_source_is_url(source)) { - goto errout; - } + ret = pkg_store_format_download_path(tmp, PATH_MAX, manifest->name, + manifest->version); + if (ret < 0) + { + pkg_error("download path format failed: %d", ret); + goto errout; + } - ret = pkg_store_copy_file(source, tmp); - if (ret < 0) + ret = pkg_acquire_source(source, tmp); + if (ret < 0) + { + pkg_error("acquire source failed: %d", ret); + goto errout; + } + + artifact = tmp; + staged_to_tmp = true; + } + else { - goto errout; + artifact = source; } - ret = pkg_hash_file_sha256(tmp, digest); + ret = pkg_hash_file_sha256(artifact, digest); if (ret < 0) { + pkg_error("sha256 failed: %d", ret); goto errout; } if (strcasecmp(digest, manifest->sha256) != 0) { ret = -EILSEQ; + pkg_error("sha256 mismatch: %d", ret); goto errout; } ret = pkg_txn_write_state(name, PKG_TXN_VERIFIED); if (ret < 0) { + pkg_error("txn state verified failed: %d", ret); goto errout; } ret = pkg_store_ensure_version_dir(manifest->name, manifest->version); if (ret < 0) { + pkg_error("ensure version dir failed: %d", ret); goto errout; } - ret = pkg_store_format_payload_path(payload, sizeof(payload), + version_dir_created = true; + + ret = pkg_store_format_payload_path(payload, PATH_MAX, manifest->name, manifest->version, manifest->artifact); if (ret < 0) { + pkg_error("payload path format failed: %d", ret); goto errout; } - ret = pkg_store_copy_file(tmp, payload); + ret = pkg_store_copy_file(artifact, payload); if (ret < 0) { + pkg_error("copy payload failed: %d", ret); goto errout; } - ret = pkg_store_format_manifest_path(manifest_path, sizeof(manifest_path), + if (manifest->type == PKG_PAYLOAD_ELF && + chmod(payload, 0755) < 0 && errno != ENOSYS) + { + ret = -errno; + pkg_error("mark payload executable failed: %d", ret); + goto errout; + } + + ret = pkg_store_format_manifest_path(manifest_path, PATH_MAX, manifest->name, manifest->version); if (ret < 0) { + pkg_error("manifest path format failed: %d", ret); goto errout; } ret = pkg_metadata_write_manifest(manifest_path, manifest); if (ret < 0) { + pkg_error("write manifest failed: %d", ret); goto errout; } ret = pkg_txn_write_state(name, PKG_TXN_STAGED); if (ret < 0) { + pkg_error("txn state staged failed: %d", ret); goto errout; } ret = pkg_compat_check(manifest); if (ret < 0) { + pkg_error("compat check failed: %d", ret); goto errout; } ret = pkg_txn_write_state(name, PKG_TXN_COMPAT_OK); if (ret < 0) { + pkg_error("txn state compat_ok failed: %d", ret); + goto errout; + } + + ret = pkg_install_acquire_installed_lock(installed_lock, PATH_MAX); + if (ret < 0) + { + pkg_error("unable to acquire installed-db lock: %d", ret); goto errout; } + installed_lock_held = true; + ret = pkg_metadata_load_installed(installed); if (ret < 0) { + pkg_error("load installed metadata failed: %d", ret); goto errout; } ret = pkg_install_update_installed(installed, manifest); if (ret < 0) { + pkg_error("update installed metadata failed: %d", ret); goto errout; } - ret = pkg_install_write_pointers(installed, manifest); + ret = pkg_install_write_pointers(installed, manifest->name); if (ret < 0) { + pkg_error("write current/previous pointers failed: %d", ret); goto errout; } ret = pkg_metadata_save_installed(installed); if (ret < 0) { + pkg_error("save installed metadata failed: %d", ret); goto errout; } + pkg_store_remove_file(installed_lock); + installed_lock_held = false; + ret = pkg_txn_write_state(name, PKG_TXN_ACTIVATED); if (ret < 0) { + pkg_error("txn state activated failed: %d", ret); goto errout; } pkg_txn_write_state(name, PKG_TXN_CLEANUP); - if (tmp[0] != '\0') + if (staged_to_tmp && tmp[0] != '\0') { pkg_store_remove_file(tmp); } @@ -437,27 +690,67 @@ int pkg_install(FAR const char *name) } pkg_info("installed %s version %s", manifest->name, manifest->version); - free(index); - free(installed); + pkg_free(index); + pkg_free(installed); + pkg_free(source); + pkg_free(tmp); + pkg_free(payload); + pkg_free(manifest_path); + pkg_free(lock); + pkg_free(installed_lock); return EXIT_SUCCESS; errout: pkg_txn_write_state(name, PKG_TXN_FAILED); - if (tmp[0] != '\0') + if (staged_to_tmp && tmp[0] != '\0') { pkg_store_remove_file(tmp); } + /* Reclaim whatever was staged into the version directory (payload, + * manifest.jsn) before this failure - otherwise every failure past + * this point leaves a permanently orphaned, never-activated version + * directory with no way to reclaim it short of "remove". + */ + + if (version_dir_created) + { + pkg_store_remove_version_dir(manifest->name, manifest->version); + } + pkg_txn_clear_state(name); if (lock[0] != '\0') { pkg_store_remove_file(lock); } - free(index); - free(installed); + if (installed_lock_held) + { + pkg_store_remove_file(installed_lock); + } + + pkg_free(index); + pkg_free(installed); + pkg_free(source); + pkg_free(tmp); + pkg_free(payload); + pkg_free(manifest_path); + pkg_free(lock); + pkg_free(installed_lock); pkg_error("install failed for '%s': %d", name, ret); - return EXIT_FAILURE; + + /* Propagate the real negative errno (rather than the constant + * EXIT_FAILURE) here specifically, since every meaningful pipeline + * failure - network/download, sha256 mismatch (-EILSEQ), wrong + * arch/board (-ENOEXEC/-EXDEV from pkg_compat_check) - funnels through + * this handler. nxstore calls pkg_install() directly (not through a + * shell) and can use this to show a differentiated message instead of + * one generic "install failed" string; any nonzero value (this is + * always < 0) still satisfies the plain success/failure contract for + * callers that only check for zero. + */ + + return ret; } int pkg_list(FAR FILE *stream) @@ -465,7 +758,7 @@ int pkg_list(FAR FILE *stream) FAR struct pkg_installed_db_s *db; int ret; - db = malloc(sizeof(*db)); + db = pkg_zalloc(sizeof(*db)); if (db == NULL) { pkg_error("unable to allocate installed metadata buffer"); @@ -475,7 +768,7 @@ int pkg_list(FAR FILE *stream) ret = pkg_store_prepare_layout(); if (ret < 0) { - free(db); + pkg_free(db); pkg_error("unable to prepare package layout: %d", ret); return EXIT_FAILURE; } @@ -483,7 +776,7 @@ int pkg_list(FAR FILE *stream) ret = pkg_metadata_load_installed(db); if (ret < 0) { - free(db); + pkg_free(db); pkg_error("unable to load installed metadata: %d", ret); return EXIT_FAILURE; } @@ -491,11 +784,300 @@ int pkg_list(FAR FILE *stream) ret = pkg_metadata_print_installed(stream, db); if (ret < 0) { - free(db); + pkg_free(db); pkg_error("unable to print installed metadata: %d", ret); return EXIT_FAILURE; } - free(db); + pkg_free(db); + return EXIT_SUCCESS; +} + +/**************************************************************************** + * Name: pkg_uninstall + * + * Description: + * Remove every installed version of "name": their version directories + * (payload + manifest.jsn), the current/previous pointer files, any + * leftover txn.tx, the entry in the shared installed-packages database, + * and finally the now-empty package root directory. Refuses to run + * while an install/update for the same package is in flight (a live + * lock.lk), since removing the store out from under it would corrupt + * whatever it's mid-writing. + * + ****************************************************************************/ + +int pkg_uninstall(FAR const char *name) +{ + FAR struct pkg_installed_db_s *db; + FAR struct pkg_installed_entry_s *entry; + char path[PATH_MAX]; + char installed_lock[PATH_MAX]; + size_t index; + size_t i; + int ret; + + if (name == NULL || name[0] == '\0') + { + pkg_error("remove requires a package name"); + return EXIT_FAILURE; + } + + db = pkg_zalloc(sizeof(*db)); + if (db == NULL) + { + pkg_error("unable to allocate installed metadata buffer"); + return EXIT_FAILURE; + } + + ret = pkg_store_prepare_layout(); + if (ret < 0) + { + pkg_free(db); + pkg_error("unable to prepare package layout: %d", ret); + return EXIT_FAILURE; + } + + ret = pkg_install_acquire_installed_lock(installed_lock, + sizeof(installed_lock)); + if (ret < 0) + { + pkg_free(db); + pkg_error("unable to acquire installed-db lock: %d", ret); + return EXIT_FAILURE; + } + + ret = pkg_metadata_load_installed(db); + if (ret < 0) + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("unable to load installed metadata: %d", ret); + return EXIT_FAILURE; + } + + entry = pkg_metadata_find_installed(db, name); + if (entry == NULL) + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("package '%s' is not installed", name); + return EXIT_FAILURE; + } + + if (pkg_store_format_lock_path(path, sizeof(path), name) == 0 && + access(path, F_OK) == 0) + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("package '%s' has an install/update in progress", name); + return EXIT_FAILURE; + } + + for (i = 0; i < entry->version_count; i++) + { + pkg_store_remove_version_dir(name, entry->versions[i]); + } + + if (pkg_store_format_txn_path(path, sizeof(path), name) == 0) + { + pkg_store_remove_file(path); + } + + /* Drop this entry from the in-memory db (shift the tail down over it) + * and persist before touching any more of the on-disk layout. + */ + + index = (size_t)(entry - db->entries); + for (i = index; i + 1 < db->count; i++) + { + db->entries[i] = db->entries[i + 1]; + } + + db->count--; + + ret = pkg_metadata_save_installed(db); + pkg_store_remove_file(installed_lock); + if (ret < 0) + { + pkg_free(db); + pkg_error("unable to save installed metadata: %d", ret); + return EXIT_FAILURE; + } + + if (pkg_store_format_current_path(path, sizeof(path), name) == 0) + { + pkg_store_remove_file(path); + } + + if (pkg_store_format_previous_path(path, sizeof(path), name) == 0) + { + pkg_store_remove_file(path); + } + + if (pkg_store_format_package_root(path, sizeof(path), name) == 0) + { + rmdir(path); + } + + pkg_info("removed %s", name); + pkg_free(db); + return EXIT_SUCCESS; +} + +/**************************************************************************** + * Name: pkg_rollback + * + * Description: + * Swap "name"'s current and previous installed versions. The swap (as + * opposed to just clearing "previous") lets a second rollback undo the + * first. Verifies the rollback target's version directory still + * exists on disk before committing any state change, and refuses to + * run while an install/update for the same package is in flight. + * + ****************************************************************************/ + +int pkg_rollback(FAR const char *name) +{ + FAR struct pkg_installed_db_s *db; + FAR struct pkg_installed_entry_s *entry; + char version_path[PATH_MAX]; + char lock_path[PATH_MAX]; + char installed_lock[PATH_MAX]; + char swap[PKG_VERSION_MAX + 1]; + struct stat st; + int ret; + + if (name == NULL || name[0] == '\0') + { + pkg_error("rollback requires a package name"); + return EXIT_FAILURE; + } + + db = pkg_zalloc(sizeof(*db)); + if (db == NULL) + { + pkg_error("unable to allocate installed metadata buffer"); + return EXIT_FAILURE; + } + + ret = pkg_store_prepare_layout(); + if (ret < 0) + { + pkg_free(db); + pkg_error("unable to prepare package layout: %d", ret); + return EXIT_FAILURE; + } + + ret = pkg_install_acquire_installed_lock(installed_lock, + sizeof(installed_lock)); + if (ret < 0) + { + pkg_free(db); + pkg_error("unable to acquire installed-db lock: %d", ret); + return EXIT_FAILURE; + } + + ret = pkg_metadata_load_installed(db); + if (ret < 0) + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("unable to load installed metadata: %d", ret); + return EXIT_FAILURE; + } + + entry = pkg_metadata_find_installed(db, name); + if (entry == NULL) + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("package '%s' is not installed", name); + return EXIT_FAILURE; + } + + if (entry->previous[0] == '\0') + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("package '%s' has no previous version to roll back to", + name); + return EXIT_FAILURE; + } + + if (pkg_store_format_lock_path(lock_path, sizeof(lock_path), name) == 0 && + access(lock_path, F_OK) == 0) + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("package '%s' has an install/update in progress", name); + return EXIT_FAILURE; + } + + ret = pkg_store_format_version_path(version_path, sizeof(version_path), + name, entry->previous); + if (ret < 0 || stat(version_path, &st) < 0) + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("rollback target version '%s' is missing on disk", + entry->previous); + return EXIT_FAILURE; + } + + ret = snprintf(swap, sizeof(swap), "%s", entry->current); + if (ret < 0 || (size_t)ret >= sizeof(swap)) + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("current version string too long to swap"); + return EXIT_FAILURE; + } + + ret = snprintf(entry->current, sizeof(entry->current), "%s", + entry->previous); + if (ret < 0 || (size_t)ret >= sizeof(entry->current)) + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("unable to update current version"); + return EXIT_FAILURE; + } + + ret = snprintf(entry->previous, sizeof(entry->previous), "%s", swap); + if (ret < 0 || (size_t)ret >= sizeof(entry->previous)) + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("unable to update previous version"); + return EXIT_FAILURE; + } + + /* Persist the pointer-file swap and the db entry together, in that + * order, before releasing anything - a crash between these two writes + * still leaves "current" resolving to the (now rolled-back-to) version + * that's actually on disk, which is the side that must win. + */ + + ret = pkg_install_write_pointers(db, name); + if (ret < 0) + { + pkg_store_remove_file(installed_lock); + pkg_free(db); + pkg_error("unable to write current/previous pointers: %d", ret); + return EXIT_FAILURE; + } + + ret = pkg_metadata_save_installed(db); + pkg_store_remove_file(installed_lock); + if (ret < 0) + { + pkg_free(db); + pkg_error("unable to save installed metadata: %d", ret); + return EXIT_FAILURE; + } + + pkg_info("rolled back %s to version %s", name, entry->current); + pkg_free(db); return EXIT_SUCCESS; } diff --git a/system/nxpkg/pkg_log.c b/system/nxpkg/pkg_log.c index bed06b13ffb..658f6b64f68 100644 --- a/system/nxpkg/pkg_log.c +++ b/system/nxpkg/pkg_log.c @@ -26,6 +26,8 @@ #include #include +#include +#include #include "pkg.h" @@ -33,13 +35,19 @@ * Private Functions ****************************************************************************/ -static void pkg_vlog(FAR FILE *stream, FAR const char *level, - FAR const char *fmt, va_list ap) +static void pkg_vlog(FAR const char *level, FAR const char *fmt, va_list ap) { - fprintf(stream, "nxpkg: %s: ", level); - vfprintf(stream, fmt, ap); - fputc('\n', stream); - fflush(stream); + char message[256]; + int ret; + + ret = vsnprintf(message, sizeof(message), fmt, ap); + if (ret < 0) + { + return; + } + + syslog(strcmp(level, "error") == 0 ? LOG_ERR : LOG_INFO, + "nxpkg: %s: %s", level, message); } /**************************************************************************** @@ -51,7 +59,7 @@ void pkg_error(FAR const char *fmt, ...) va_list ap; va_start(ap, fmt); - pkg_vlog(stderr, "error", fmt, ap); + pkg_vlog("error", fmt, ap); va_end(ap); } @@ -60,6 +68,6 @@ void pkg_info(FAR const char *fmt, ...) va_list ap; va_start(ap, fmt); - pkg_vlog(stdout, "info", fmt, ap); + pkg_vlog("info", fmt, ap); va_end(ap); } diff --git a/system/nxpkg/pkg_main.c b/system/nxpkg/pkg_main.c index c049592c5b6..f20137f9ec5 100644 --- a/system/nxpkg/pkg_main.c +++ b/system/nxpkg/pkg_main.c @@ -29,8 +29,18 @@ #include #include +#include + #include "pkg.h" +/**************************************************************************** + * Pre-processor Definitions + ****************************************************************************/ + +#define PKG_USAGE \ + "Usage: %s " \ + "[args]\n" + /**************************************************************************** * Public Functions ****************************************************************************/ @@ -38,12 +48,15 @@ int main(int argc, FAR char *argv[]) { FAR const char *cmd; + cJSON_Hooks hooks; + + hooks.malloc_fn = malloc; + hooks.free_fn = free; + cJSON_InitHooks(&hooks); if (argc < 2) { - fprintf(stderr, - "Usage: %s [args]\n", - argv[0]); + fprintf(stderr, PKG_USAGE, argv[0]); return EXIT_FAILURE; } @@ -52,9 +65,7 @@ int main(int argc, FAR char *argv[]) if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0 || strcmp(cmd, "-h") == 0) { - fprintf(stdout, - "Usage: %s [args]\n", - argv[0]); + fprintf(stdout, PKG_USAGE, argv[0]); return EXIT_SUCCESS; } @@ -63,19 +74,59 @@ int main(int argc, FAR char *argv[]) if (argc != 3) { pkg_error("install expects exactly one package name"); - fprintf(stderr, - "Usage: %s [args]\n", - argv[0]); + fprintf(stderr, PKG_USAGE, argv[0]); return EXIT_FAILURE; } - return pkg_install(argv[2]); + /* pkg_install() returns a real negative errno on the meaningful + * pipeline failures (nxstore uses that directly), not just + * EXIT_SUCCESS/EXIT_FAILURE - normalize to a plain 0/1 shell exit + * status here. + */ + + return pkg_install(argv[2]) == 0 ? EXIT_SUCCESS : EXIT_FAILURE; } + /* "update" is a package name resolving to whatever version is latest + * in the local index - pkg_install() already handles the "already + * installed at a different version" transition transparently via + * pkg_install_update_installed(), so no separate code path is needed. + */ + if (strcmp(cmd, "update") == 0) { - pkg_error("'update' is not implemented yet in the current unit"); - return EXIT_FAILURE; + if (argc != 3) + { + pkg_error("update expects exactly one package name"); + fprintf(stderr, PKG_USAGE, argv[0]); + return EXIT_FAILURE; + } + + return pkg_install(argv[2]) == 0 ? EXIT_SUCCESS : EXIT_FAILURE; + } + + if (strcmp(cmd, "remove") == 0 || strcmp(cmd, "uninstall") == 0) + { + if (argc != 3) + { + pkg_error("remove expects exactly one package name"); + fprintf(stderr, PKG_USAGE, argv[0]); + return EXIT_FAILURE; + } + + return pkg_uninstall(argv[2]); + } + + if (strcmp(cmd, "rollback") == 0) + { + if (argc != 3) + { + pkg_error("rollback expects exactly one package name"); + fprintf(stderr, PKG_USAGE, argv[0]); + return EXIT_FAILURE; + } + + return pkg_rollback(argv[2]); } if (strcmp(cmd, "list") == 0) @@ -83,24 +134,38 @@ int main(int argc, FAR char *argv[]) if (argc != 2) { pkg_error("list does not take additional arguments"); - fprintf(stderr, - "Usage: %s [args]\n", - argv[0]); + fprintf(stderr, PKG_USAGE, argv[0]); return EXIT_FAILURE; } return pkg_list(stdout); } - if (strcmp(cmd, "rollback") == 0) + if (strcmp(cmd, "available") == 0) { - pkg_error("'rollback' is not implemented yet in the current unit"); - return EXIT_FAILURE; + if (argc != 2) + { + pkg_error("available does not take additional arguments"); + fprintf(stderr, PKG_USAGE, argv[0]); + return EXIT_FAILURE; + } + + return pkg_available(stdout); + } + + if (strcmp(cmd, "sync") == 0) + { + if (argc != 3) + { + pkg_error("sync expects exactly one index source"); + fprintf(stderr, PKG_USAGE, argv[0]); + return EXIT_FAILURE; + } + + return pkg_sync(argv[2]); } fprintf(stderr, "ERROR: Unknown subcommand '%s'\n", cmd); - fprintf(stderr, - "Usage: %s [args]\n", - argv[0]); + fprintf(stderr, PKG_USAGE, argv[0]); return EXIT_FAILURE; } diff --git a/system/nxpkg/pkg_manifest.c b/system/nxpkg/pkg_manifest.c index 3e7b56a2637..4c09803851e 100644 --- a/system/nxpkg/pkg_manifest.c +++ b/system/nxpkg/pkg_manifest.c @@ -74,6 +74,49 @@ static bool pkg_validate_hex(FAR const char *value) return true; } +/**************************************************************************** + * Name: pkg_validate_path_component + * + * Description: + * Reject any value that could escape the intended directory when spliced + * into a filesystem path (pkg_store.c's PKG_STORE_DIR "/%s/%s/..." + * formatters). This is required for "name" and "version" specifically, + * since both come straight from an untrusted, network-fetched + * index.json and are used unsanitized to build install paths - a + * version of "../../evil" would otherwise let a malicious index write + * or delete files outside the package store entirely. + * + ****************************************************************************/ + +static bool pkg_validate_path_component(FAR const char *value) +{ + FAR const char *p; + + if (pkg_validate_required(value) < 0) + { + return false; + } + + /* Reject a leading '.' outright: blocks ".", "..", and any + * "../"-prefixed traversal in one check. + */ + + if (value[0] == '.') + { + return false; + } + + for (p = value; *p != '\0'; p++) + { + if (*p == '/' || *p == '\\') + { + return false; + } + } + + return true; +} + /**************************************************************************** * Public Functions ****************************************************************************/ @@ -95,6 +138,8 @@ const char *pkg_manifest_type_str(enum pkg_payload_type_e type) int pkg_manifest_validate(FAR const struct pkg_manifest_s *manifest) { + size_t i; + if (manifest == NULL) { return -EINVAL; @@ -110,6 +155,19 @@ int pkg_manifest_validate(FAR const struct pkg_manifest_s *manifest) return -EINVAL; } + /* "name" and "version" get spliced unsanitized into on-disk paths + * (pkg_store.c) - they must not contain path separators or traversal + * sequences. "artifact" is validated separately in pkg_repo.c, where + * it's legitimately allowed to be a relative repo path (just not an + * absolute one or one that escapes the repo root). + */ + + if (!pkg_validate_path_component(manifest->name) || + !pkg_validate_path_component(manifest->version)) + { + return -EINVAL; + } + if (strlen(manifest->sha256) != PKG_HASH_HEX_LEN) { return -EINVAL; @@ -126,6 +184,19 @@ int pkg_manifest_validate(FAR const struct pkg_manifest_s *manifest) return -EINVAL; } + if (manifest->launch_argc > PKG_LAUNCH_ARGS_MAX) + { + return -EINVAL; + } + + for (i = 0; i < manifest->launch_argc; i++) + { + if (pkg_validate_required(manifest->launch_args[i]) < 0) + { + return -EINVAL; + } + } + return 0; } diff --git a/system/nxpkg/pkg_metadata.c b/system/nxpkg/pkg_metadata.c index aadb4692e58..38907f59fd0 100644 --- a/system/nxpkg/pkg_metadata.c +++ b/system/nxpkg/pkg_metadata.c @@ -100,6 +100,54 @@ static FAR cJSON *pkg_metadata_packages_array(FAR cJSON *root) return cJSON_GetObjectItemCaseSensitive(root, "packages"); } +static int pkg_metadata_parse_launch_args( + FAR cJSON *item, FAR struct pkg_manifest_s *manifest) +{ + FAR cJSON *field; + FAR cJSON *arg; + size_t argc = 0; + FAR const char *value; + int ret; + + field = cJSON_GetObjectItemCaseSensitive(item, "launch_args"); + if (field == NULL) + { + manifest->launch_argc = 0; + return 0; + } + + if (!cJSON_IsArray(field)) + { + return -EINVAL; + } + + cJSON_ArrayForEach(arg, field) + { + if (argc >= PKG_LAUNCH_ARGS_MAX) + { + return -E2BIG; + } + + value = cJSON_GetStringValue(arg); + if (value == NULL) + { + return -EINVAL; + } + + ret = pkg_copy_string(manifest->launch_args[argc], + sizeof(manifest->launch_args[argc]), value); + if (ret < 0) + { + return ret; + } + + argc++; + } + + manifest->launch_argc = argc; + return 0; +} + static int pkg_metadata_parse_manifest(FAR cJSON *item, FAR struct pkg_manifest_s *manifest) { @@ -170,6 +218,39 @@ static int pkg_metadata_parse_manifest(FAR cJSON *item, return -EINVAL; } + /* description/category/icon are optional and purely for UI display; + * missing fields just leave the manifest's copy empty. + */ + + field = cJSON_GetObjectItemCaseSensitive(item, "description"); + value = cJSON_GetStringValue(field); + if (value != NULL) + { + pkg_copy_string(manifest->description, sizeof(manifest->description), + value); + } + + field = cJSON_GetObjectItemCaseSensitive(item, "category"); + value = cJSON_GetStringValue(field); + if (value != NULL) + { + pkg_copy_string(manifest->category, sizeof(manifest->category), + value); + } + + field = cJSON_GetObjectItemCaseSensitive(item, "icon"); + value = cJSON_GetStringValue(field); + if (value != NULL) + { + pkg_copy_string(manifest->icon, sizeof(manifest->icon), value); + } + + ret = pkg_metadata_parse_launch_args(item, manifest); + if (ret < 0) + { + return ret; + } + return pkg_manifest_validate(manifest); } @@ -395,6 +476,8 @@ static FAR cJSON *pkg_metadata_manifest_to_json( FAR const struct pkg_manifest_s *manifest) { FAR cJSON *root; + FAR cJSON *launch_args; + size_t i; root = cJSON_CreateObject(); if (root == NULL) @@ -410,51 +493,55 @@ static FAR cJSON *pkg_metadata_manifest_to_json( cJSON_AddStringToObject(root, "sha256", manifest->sha256); cJSON_AddStringToObject(root, "type", pkg_manifest_type_str(manifest->type)); - return root; -} -/**************************************************************************** - * Public Functions - ****************************************************************************/ - -int pkg_metadata_load_index(FAR struct pkg_index_s *index) -{ - FAR cJSON *root; - FAR cJSON *packages; - FAR cJSON *item; - FAR char *text; - char path[PATH_MAX]; - size_t count = 0; - size_t textlen; - int ret; - - if (index == NULL) + if (manifest->description[0] != '\0') { - return -EINVAL; + cJSON_AddStringToObject(root, "description", manifest->description); } - memset(index, 0, sizeof(*index)); - - ret = pkg_store_format_index_path(path, sizeof(path)); - if (ret < 0) + if (manifest->category[0] != '\0') { - return ret; + cJSON_AddStringToObject(root, "category", manifest->category); } - pkg_info("loading index from %s", path); - - ret = pkg_store_read_text(path, &text); - if (ret < 0) + if (manifest->launch_argc > 0) { - return ret; + launch_args = cJSON_AddArrayToObject(root, "launch_args"); + if (launch_args == NULL) + { + cJSON_Delete(root); + return NULL; + } + + for (i = 0; i < manifest->launch_argc; i++) + { + FAR cJSON *arg; + + arg = cJSON_CreateString(manifest->launch_args[i]); + if (arg == NULL) + { + cJSON_Delete(root); + return NULL; + } + + cJSON_AddItemToArray(launch_args, arg); + } } - textlen = strlen(text); - pkg_info("index read complete (%zu bytes)", textlen); + return root; +} + +static int pkg_metadata_parse_index_text(FAR const char *text, + FAR struct pkg_index_s *index) +{ + FAR cJSON *root; + FAR cJSON *packages; + FAR cJSON *item; + size_t count = 0; + int ret; root = cJSON_Parse(text); pkg_info("cJSON_Parse returned %s", root != NULL ? "success" : "failure"); - free(text); if (root == NULL) { return -EINVAL; @@ -471,15 +558,27 @@ int pkg_metadata_load_index(FAR struct pkg_index_s *index) { if (count >= PKG_INDEX_MAX) { - cJSON_Delete(root); - return -E2BIG; + /* Keep what's already parsed rather than discarding the whole + * index: a catalog that's grown past PKG_INDEX_MAX shouldn't + * make every other package unavailable too. + */ + + pkg_error("index has more than %d packages, truncating", + PKG_INDEX_MAX); + break; } ret = pkg_metadata_parse_manifest(item, &index->manifests[count]); if (ret < 0) { - cJSON_Delete(root); - return ret; + /* Skip a malformed entry instead of discarding the entire + * index: one bad/malicious package definition shouldn't make + * every other, otherwise-valid package unavailable too. + */ + + pkg_error("skipping malformed package entry %zu: %d", count, + ret); + continue; } pkg_info("parsed manifest %s %s", @@ -493,6 +592,54 @@ int pkg_metadata_load_index(FAR struct pkg_index_s *index) return 0; } +/**************************************************************************** + * Public Functions + ****************************************************************************/ + +int pkg_metadata_load_index_path(FAR const char *path, + FAR struct pkg_index_s *index) +{ + FAR char *text; + size_t textlen; + int ret; + + if (path == NULL || index == NULL) + { + return -EINVAL; + } + + memset(index, 0, sizeof(*index)); + + pkg_info("loading index from %s", path); + + ret = pkg_store_read_text(path, &text); + if (ret < 0) + { + return ret; + } + + textlen = strlen(text); + pkg_info("index read complete (%zu bytes)", textlen); + + ret = pkg_metadata_parse_index_text(text, index); + pkg_free(text); + return ret; +} + +int pkg_metadata_load_index(FAR struct pkg_index_s *index) +{ + char path[PATH_MAX]; + int ret; + + ret = pkg_store_format_index_path(path, sizeof(path)); + if (ret < 0) + { + return ret; + } + + return pkg_metadata_load_index_path(path, index); +} + FAR const struct pkg_manifest_s * pkg_metadata_find_latest(FAR const struct pkg_index_s *index, FAR const char *name) @@ -573,7 +720,7 @@ int pkg_metadata_load_installed(FAR struct pkg_installed_db_s *db) } root = cJSON_Parse(text); - free(text); + pkg_free(text); if (root == NULL) { return -EINVAL; @@ -590,15 +737,23 @@ int pkg_metadata_load_installed(FAR struct pkg_installed_db_s *db) { if (count >= PKG_INSTALLED_MAX) { - cJSON_Delete(root); - return -E2BIG; + pkg_error("installed db has more than %d entries, truncating", + PKG_INSTALLED_MAX); + break; } ret = pkg_metadata_parse_installed_entry(item, &db->entries[count]); if (ret < 0) { - cJSON_Delete(root); - return ret; + /* A single corrupted entry (plausible after a crash mid-write, + * despite the atomic-write mechanism) must not make every + * other installed package look uninstalled - that would drive + * needless reinstalls for everything else. + */ + + pkg_error("skipping malformed installed entry %zu: %d", count, + ret); + continue; } count++; diff --git a/system/nxpkg/pkg_repo.c b/system/nxpkg/pkg_repo.c new file mode 100644 index 00000000000..6ea294ca86a --- /dev/null +++ b/system/nxpkg/pkg_repo.c @@ -0,0 +1,612 @@ +/**************************************************************************** + * apps/system/nxpkg/pkg_repo.c + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef CONFIG_NETUTILS_WEBCLIENT +# include "netutils/webclient.h" +#endif + +#include "pkg.h" + +/**************************************************************************** + * Pre-processor Definitions + ****************************************************************************/ + +/* Each webclient_perform() read/sink cycle costs a TCP receive plus an + * SD-card write call; at the old 512-byte size, a sub-1MB file like + * nxdoom's ~940KB ELF took ~1900 round trips and, in practice, close + * to two minutes to install - easily read as "stuck" with only a bare + * spinner for feedback. 4KB cuts that to ~230 round trips and lines + * up with typical SD card erase-block granularity, which also reduces + * write amplification. Still small enough to be a safe stack-local + * buffer against the 16KB (CLI) / 16KB (nxstore install worker) task + * stacks that call into this. + */ + +#define PKG_REPO_FETCH_BUFFER_SIZE 4096 + +/**************************************************************************** + * Private Types + ****************************************************************************/ + +#ifdef CONFIG_NETUTILS_WEBCLIENT +struct pkg_fetch_context_s +{ + int fd; + size_t total; +}; +#endif + +/**************************************************************************** + * Private Functions + ****************************************************************************/ + +static int pkg_repo_copy_string(FAR char *buffer, size_t size, + FAR const char *value) +{ + int ret; + + ret = snprintf(buffer, size, "%s", value); + if (ret < 0) + { + return ret; + } + + return (size_t)ret >= size ? -ENAMETOOLONG : 0; +} + +static int pkg_repo_source_base(FAR char *buffer, size_t size, + FAR const char *source) +{ + FAR const char *slash; + size_t length; + + slash = strrchr(source, '/'); + if (slash == NULL) + { + return -EINVAL; + } + + length = (size_t)(slash - source); + if (length == 0) + { + length = 1; + } + + if (length >= size) + { + return -ENAMETOOLONG; + } + + memcpy(buffer, source, length); + buffer[length] = '\0'; + return 0; +} + +/**************************************************************************** + * Name: pkg_validate_artifact_relative + * + * Description: + * manifest->artifact is repo-relative content and gets spliced into a + * local filesystem path (or used to build a URL) unsanitized. Reject + * absolute paths outright - allowing them let a malicious index turn + * any local file with a known/predictable hash into an "installed" + * package, including making it executable - and reject any ".." path + * segment that would let the artifact escape the repo mirror directory. + * + ****************************************************************************/ + +static bool pkg_validate_artifact_relative(FAR const char *value) +{ + FAR const char *p; + + if (value == NULL || value[0] == '\0' || value[0] == '/') + { + return false; + } + + p = value; + while ((p = strstr(p, "..")) != NULL) + { + bool at_start = p == value || *(p - 1) == '/'; + bool at_end = p[2] == '\0' || p[2] == '/'; + + if (at_start && at_end) + { + return false; + } + + p++; + } + + return true; +} + +static int pkg_repo_read_source(FAR char *buffer, size_t size) +{ + char path[PATH_MAX]; + FAR char *text; + size_t length; + int ret; + + ret = pkg_store_format_repo_source_path(path, sizeof(path)); + if (ret < 0) + { + return ret; + } + + ret = pkg_store_read_text(path, &text); + if (ret < 0) + { + return ret; + } + + length = strlen(text); + while (length > 0 && isspace((unsigned char)text[length - 1])) + { + text[--length] = '\0'; + } + + ret = pkg_repo_copy_string(buffer, size, text); + pkg_free(text); + return ret; +} + +#ifdef CONFIG_NETUTILS_WEBCLIENT +static int pkg_repo_sink(FAR char **buffer, int offset, int datend, + FAR int *buflen, FAR void *arg) +{ + FAR struct pkg_fetch_context_s *ctx; + size_t remaining; + FAR char *cursor; + + UNUSED(buffer); + UNUSED(buflen); + + ctx = arg; + cursor = &(*buffer)[offset]; + remaining = (size_t)(datend - offset); + + /* Cap total downloaded bytes: an unbounded/malicious response could + * otherwise exhaust all SD-card space. Checked before writing more so + * the on-disk file never exceeds the cap even mid-chunk. + */ + + if (remaining > 0 && + (ctx->total > PKG_DOWNLOAD_MAX_SIZE || + remaining > PKG_DOWNLOAD_MAX_SIZE - ctx->total)) + { + return -EFBIG; + } + + ctx->total += remaining; + + while (remaining > 0) + { + ssize_t nwritten; + + nwritten = write(ctx->fd, cursor, remaining); + if (nwritten < 0) + { + if (errno == EINTR) + { + continue; + } + + return -errno; + } + + cursor += nwritten; + remaining -= (size_t)nwritten; + } + + return 0; +} + +static int pkg_repo_fetch_url(FAR const char *url, FAR const char *dest) +{ + struct pkg_fetch_context_s fetch; + struct webclient_context client; + char reason[64]; + char buffer[PKG_REPO_FETCH_BUFFER_SIZE]; + int ret; + + fetch.fd = open(dest, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fetch.fd < 0) + { + return -errno; + } + + fetch.total = 0; + + webclient_set_defaults(&client); + client.method = "GET"; + client.url = url; + client.buffer = buffer; + client.buflen = sizeof(buffer); + client.sink_callback = pkg_repo_sink; + client.sink_callback_arg = &fetch; + client.http_reason = reason; + client.http_reason_len = sizeof(reason); + + ret = webclient_perform(&client); + if (ret < 0) + { + close(fetch.fd); + unlink(dest); + return ret; + } + + if (client.http_status / 100 != 2) + { + close(fetch.fd); + unlink(dest); + return -EPROTO; + } + + if (close(fetch.fd) < 0) + { + unlink(dest); + return -errno; + } + + return 0; +} +#endif + +static int pkg_resolve_relative_source(FAR char *buffer, size_t size, + FAR const char *relative) +{ + char source[PATH_MAX]; + char base[PATH_MAX]; + int ret; + + if (buffer == NULL || relative == NULL || relative[0] == '\0') + { + return -EINVAL; + } + + if (pkg_source_is_url(relative)) + { + return pkg_repo_copy_string(buffer, size, relative); + } + + if (!pkg_validate_artifact_relative(relative)) + { + return -EINVAL; + } + + ret = pkg_repo_read_source(source, sizeof(source)); + if (ret >= 0) + { + ret = pkg_repo_source_base(base, sizeof(base), source); + if (ret < 0) + { + return ret; + } + + ret = snprintf(buffer, size, "%s/%s", base, relative); + if (ret < 0) + { + return ret; + } + + return (size_t)ret >= size ? -ENAMETOOLONG : 0; + } + + ret = snprintf(buffer, size, "%s/%s", PKG_REPO_DIR, relative); + if (ret < 0) + { + return ret; + } + + return (size_t)ret >= size ? -ENAMETOOLONG : 0; +} + +/**************************************************************************** + * Public Functions + ****************************************************************************/ + +bool pkg_source_is_url(FAR const char *source) +{ + if (source == NULL) + { + return false; + } + + return strncasecmp(source, "http://", 7) == 0 || + strncasecmp(source, "https://", 8) == 0; +} + +int pkg_resolve_artifact_source(FAR char *buffer, size_t size, + FAR const struct pkg_manifest_s *manifest) +{ + if (manifest == NULL) + { + return -EINVAL; + } + + return pkg_resolve_relative_source(buffer, size, manifest->artifact); +} + +int pkg_resolve_icon_source(FAR char *buffer, size_t size, + FAR const struct pkg_manifest_s *manifest) +{ + if (manifest == NULL) + { + return -EINVAL; + } + + return pkg_resolve_relative_source(buffer, size, manifest->icon); +} + +int pkg_acquire_source(FAR const char *source, FAR const char *dest) +{ + if (source == NULL || dest == NULL) + { + return -EINVAL; + } + + if (pkg_source_is_url(source)) + { +#ifdef CONFIG_NETUTILS_WEBCLIENT + return pkg_repo_fetch_url(source, dest); +#else + return -ENOSYS; +#endif + } + + return pkg_store_copy_file(source, dest); +} + +int pkg_sync(FAR const char *source) +{ + FAR struct pkg_index_s *index; + FAR char *text = NULL; + FAR char *tmp; + FAR char *index_path; + FAR char *source_path; + int ret; + + if (source == NULL || source[0] == '\0') + { + pkg_error("sync requires a non-empty index source"); + return EXIT_FAILURE; + } + + index = pkg_zalloc(sizeof(*index)); + tmp = pkg_path_alloc(); + index_path = pkg_path_alloc(); + source_path = pkg_path_alloc(); + if (index == NULL || tmp == NULL || index_path == NULL || + source_path == NULL) + { + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + pkg_free(source_path); + pkg_error("unable to allocate index metadata buffer"); + return EXIT_FAILURE; + } + + ret = pkg_store_prepare_layout(); + if (ret < 0) + { + pkg_error("unable to prepare package layout: %d", ret); + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + pkg_free(source_path); + return EXIT_FAILURE; + } + + ret = snprintf(tmp, PATH_MAX, "%s/idxsync.jsn", PKG_TMP_DIR); + if (ret < 0 || (size_t)ret >= PATH_MAX) + { + pkg_error("temporary sync path is too long"); + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + pkg_free(source_path); + return EXIT_FAILURE; + } + + ret = pkg_acquire_source(source, tmp); + if (ret < 0) + { + pkg_error("unable to fetch index source '%s': %d", source, ret); + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + pkg_free(source_path); + return EXIT_FAILURE; + } + + ret = pkg_metadata_load_index_path(tmp, index); + if (ret < 0) + { + pkg_store_remove_file(tmp); + pkg_error("downloaded index is invalid: %d", ret); + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + pkg_free(source_path); + return EXIT_FAILURE; + } + + ret = pkg_store_read_text(tmp, &text); + if (ret < 0) + { + pkg_store_remove_file(tmp); + pkg_error("unable to read fetched index: %d", ret); + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + pkg_free(source_path); + return EXIT_FAILURE; + } + + ret = pkg_store_format_index_path(index_path, PATH_MAX); + if (ret < 0) + { + pkg_store_remove_file(tmp); + pkg_free(text); + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + pkg_free(source_path); + pkg_error("unable to resolve local index path: %d", ret); + return EXIT_FAILURE; + } + + ret = pkg_store_write_text_atomic(index_path, text); + if (ret < 0) + { + pkg_store_remove_file(tmp); + pkg_free(text); + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + pkg_free(source_path); + pkg_error("unable to write local index: %d", ret); + return EXIT_FAILURE; + } + + ret = pkg_store_format_repo_source_path(source_path, PATH_MAX); + if (ret < 0) + { + pkg_store_remove_file(tmp); + pkg_free(text); + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + pkg_free(source_path); + pkg_error("unable to resolve repository source path: %d", ret); + return EXIT_FAILURE; + } + + ret = pkg_store_write_text_atomic(source_path, source); + if (ret < 0) + { + pkg_store_remove_file(tmp); + pkg_free(text); + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + pkg_free(source_path); + pkg_error("unable to save repository source: %d", ret); + return EXIT_FAILURE; + } + + pkg_store_remove_file(tmp); + pkg_free(text); + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + pkg_free(source_path); + pkg_info("synced package index from %s", source); + return EXIT_SUCCESS; +} + +int pkg_available(FAR FILE *stream) +{ + FAR struct pkg_index_s *index; + FAR const char *arch; + FAR const char *compat; + size_t i; + int ret; + + if (stream == NULL) + { + return EXIT_FAILURE; + } + + index = pkg_zalloc(sizeof(*index)); + if (index == NULL) + { + pkg_error("unable to allocate index metadata buffer"); + return EXIT_FAILURE; + } + + ret = pkg_store_prepare_layout(); + if (ret < 0) + { + pkg_free(index); + pkg_error("unable to prepare package layout: %d", ret); + return EXIT_FAILURE; + } + + ret = pkg_metadata_load_index(index); + if (ret < 0) + { + pkg_free(index); + pkg_error("unable to load package index: %d", ret); + return EXIT_FAILURE; + } + + arch = pkg_runtime_arch(); + compat = pkg_runtime_compat(); + + for (i = 0; i < index->count; i++) + { + FAR const struct pkg_manifest_s *manifest = &index->manifests[i]; + FAR const struct pkg_manifest_s *latest; + + if (strcmp(manifest->arch, arch) != 0 || + strcmp(manifest->compat, compat) != 0) + { + continue; + } + + latest = pkg_metadata_find_latest(index, manifest->name); + if (latest != manifest) + { + continue; + } + + fprintf(stream, + "%s version=%s type=%s arch=%s compat=%s artifact=%s\n", + manifest->name, + manifest->version, + pkg_manifest_type_str(manifest->type), + manifest->arch, + manifest->compat, + manifest->artifact); + } + + pkg_free(index); + return EXIT_SUCCESS; +} diff --git a/system/nxpkg/pkg_store.c b/system/nxpkg/pkg_store.c index 0b23b054ca3..56966f4287a 100644 --- a/system/nxpkg/pkg_store.c +++ b/system/nxpkg/pkg_store.c @@ -24,6 +24,7 @@ * Included Files ****************************************************************************/ +#include #include #include #include @@ -228,6 +229,11 @@ int pkg_store_format_index_path(FAR char *buffer, size_t size) return pkg_store_format(buffer, size, "%s", PKG_REPO_INDEX, ""); } +int pkg_store_format_repo_source_path(FAR char *buffer, size_t size) +{ + return pkg_store_format(buffer, size, "%s", PKG_REPO_SOURCE, ""); +} + int pkg_store_format_installed_path(FAR char *buffer, size_t size) { return pkg_store_format(buffer, size, "%s", PKG_REPO_INSTALLED, ""); @@ -266,21 +272,52 @@ int pkg_store_format_previous_path(FAR char *buffer, size_t size, int pkg_store_format_txn_path(FAR char *buffer, size_t size, FAR const char *name) { - return pkg_store_format(buffer, size, PKG_STORE_DIR "/%s/.txn", name, ""); + return pkg_store_format(buffer, size, PKG_STORE_DIR "/%s/txn.tx", name, + ""); } int pkg_store_format_lock_path(FAR char *buffer, size_t size, FAR const char *name) { - return pkg_store_format(buffer, size, PKG_STORE_DIR "/%s/.lock", name, ""); + return pkg_store_format(buffer, size, PKG_STORE_DIR "/%s/lock.lk", name, + ""); } int pkg_store_format_download_path(FAR char *buffer, size_t size, FAR const char *name, FAR const char *version) { - return pkg_store_format(buffer, size, PKG_TMP_PKG_DIR "/%s-%s.npkg", name, - version); + int ret; + + UNUSED(name); + UNUSED(version); + + /* This used to be "PKG_TMP_PKG_DIR/name-version.pkg", which breaks on + * this SD card's short-name-only FAT mount as soon as name+version + * exceeds the 8.3 8-character base-name limit - e.g. "nxdoom-1" (8 + * chars) fits and installs fine, but "nxdoom-10" or "nxdoom-9.1" (9+ + * chars) fails the open(O_CREAT) in pkg_repo_fetch_url() with + * -EINVAL, surfacing as "acquire source failed: -22" for any + * multi-character version - independent of name/version length here, + * unlike pkg_store_make_tmp_path()'s already-FAT-safe scheme. The + * pid is small, bounded, and unique per concurrently running install + * (each `nxpkg install` is its own process with its own per-name + * lock), so it can't collide the way a single fixed name would if + * two different packages were being installed at once. + */ + + ret = snprintf(buffer, size, PKG_TMP_PKG_DIR "/dl%d.pkg", (int)getpid()); + if (ret < 0) + { + return ret; + } + + if ((size_t)ret >= size) + { + return -ENAMETOOLONG; + } + + return 0; } int pkg_store_format_payload_path(FAR char *buffer, size_t size, @@ -311,16 +348,18 @@ int pkg_store_format_manifest_path(FAR char *buffer, size_t size, FAR const char *name, FAR const char *version) { - return pkg_store_format(buffer, size, PKG_STORE_DIR "/%s/%s/manifest.json", + return pkg_store_format(buffer, size, PKG_STORE_DIR "/%s/%s/manifest.jsn", name, version); } int pkg_store_read_text(FAR const char *path, FAR char **buffer) { - FAR FILE *stream; FAR char *data; - long length; + struct stat st; + size_t length; size_t nread; + size_t total; + int fd; if (buffer == NULL) { @@ -329,70 +368,167 @@ int pkg_store_read_text(FAR const char *path, FAR char **buffer) *buffer = NULL; - stream = fopen(path, "rb"); - if (stream == NULL) + fd = open(path, O_RDONLY); + if (fd < 0) { return errno == ENOENT ? -ENOENT : -errno; } - if (fseek(stream, 0, SEEK_END) < 0) + if (fstat(fd, &st) < 0) { - fclose(stream); + close(fd); return -errno; } - length = ftell(stream); - if (length < 0) + if (!S_ISREG(st.st_mode)) { - fclose(stream); - return -errno; + close(fd); + return -EINVAL; } - if (fseek(stream, 0, SEEK_SET) < 0) + /* Reject anything unreasonably large before the size is trusted for an + * allocation: guards both against a malicious/oversized text file (this + * path is used for the network-fetched index.jsn) and against + * "length + 1" wrapping if st_size were ever attacker-influenced up to + * SIZE_MAX. + */ + + if (st.st_size < 0 || st.st_size > (off_t)PKG_TEXT_MAX_SIZE) { - fclose(stream); - return -errno; + close(fd); + return -EFBIG; } - data = malloc((size_t)length + 1); + length = (size_t)st.st_size; + data = pkg_malloc((size_t)length + 1); if (data == NULL) { - fclose(stream); + close(fd); return -ENOMEM; } - nread = fread(data, 1, (size_t)length, stream); - if (nread != (size_t)length) + total = 0; + while (total < length) { - int err = ferror(stream); + ssize_t ret; + + ret = read(fd, data + total, length - total); + if (ret < 0) + { + if (errno == EINTR) + { + continue; + } + + close(fd); + pkg_free(data); + return -errno; + } + + if (ret == 0) + { + break; + } - fclose(stream); - free(data); - return err ? -EIO : -EINVAL; + total += (size_t)ret; } - fclose(stream); + nread = total; + close(fd); + + if (nread != length) + { + pkg_free(data); + return -EINVAL; + } data[length] = '\0'; *buffer = data; return 0; } +#ifndef CONFIG_PSEUDOFS_FILE +/**************************************************************************** + * Name: pkg_store_make_tmp_path + * + * Description: + * Derive a staging path for an atomic write/copy to "path", under a + * short-name-compatible extension instead of appending ".tmp" (which + * would produce a second '.' in the final path component and break on + * FAT filesystems without long file name support). + * + ****************************************************************************/ + +static int pkg_store_make_tmp_path(FAR char *tmp, size_t size, + FAR const char *path) +{ + FAR char *dot; + FAR char *slash; + int ret; + + ret = snprintf(tmp, size, "%s", path); + if (ret < 0) + { + return ret; + } + + if ((size_t)ret >= size) + { + return -ENAMETOOLONG; + } + + slash = strrchr(tmp, '/'); + dot = strrchr(slash != NULL ? slash : tmp, '.'); + if (dot != NULL) + { + *dot = '\0'; + } + + if (strlcat(tmp, ".tm", size) >= size) + { + return -ENAMETOOLONG; + } + + return 0; +} +#endif + int pkg_store_write_text_atomic(FAR const char *path, FAR const char *text) { - char tmp[PATH_MAX]; +#ifdef CONFIG_PSEUDOFS_FILE int fd; int ret; - ret = snprintf(tmp, sizeof(tmp), "%s.tmp", path); + fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (fd < 0) + { + return -errno; + } + + ret = pkg_store_write_all(fd, text, strlen(text)); if (ret < 0) { + close(fd); + unlink(path); return ret; } - if ((size_t)ret >= sizeof(tmp)) + if (close(fd) < 0) { - return -ENAMETOOLONG; + unlink(path); + return -errno; + } + + return 0; +#else + char tmp[PATH_MAX]; + int fd; + int ret; + + ret = pkg_store_make_tmp_path(tmp, sizeof(tmp), path); + if (ret < 0) + { + return ret; } fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644); @@ -409,6 +545,22 @@ int pkg_store_write_text_atomic(FAR const char *path, FAR const char *text) return ret; } + /* Force the write through to disk before renaming. nxpkg writes + * several small, unrelated files back-to-back during install (per- + * package txn state, then the shared installed-packages database); + * without an explicit sync here, the FAT driver's single shared + * sector cache can still hold a not-yet-committed buffer for this + * file when the very next atomic write starts touching a different + * file, corrupting one or both. + */ + + if (fsync(fd) < 0) + { + close(fd); + unlink(tmp); + return -errno; + } + if (close(fd) < 0) { unlink(tmp); @@ -422,6 +574,7 @@ int pkg_store_write_text_atomic(FAR const char *path, FAR const char *text) } return 0; +#endif } int pkg_store_copy_file(FAR const char *src, FAR const char *dest) @@ -430,6 +583,20 @@ int pkg_store_copy_file(FAR const char *src, FAR const char *dest) int outfd; int ret; char buffer[512]; +#ifndef CONFIG_PSEUDOFS_FILE + char tmp[PATH_MAX]; + FAR const char *outpath; + + ret = pkg_store_make_tmp_path(tmp, sizeof(tmp), dest); + if (ret < 0) + { + return ret; + } + + outpath = tmp; +#else + FAR const char *outpath = dest; +#endif infd = open(src, O_RDONLY); if (infd < 0) @@ -437,7 +604,7 @@ int pkg_store_copy_file(FAR const char *src, FAR const char *dest) return -errno; } - outfd = open(dest, O_WRONLY | O_CREAT | O_TRUNC, 0644); + outfd = open(outpath, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (outfd < 0) { ret = -errno; @@ -475,18 +642,43 @@ int pkg_store_copy_file(FAR const char *src, FAR const char *dest) close(infd); +#ifndef CONFIG_PSEUDOFS_FILE + /* Force the payload through to disk before renaming - this is the + * largest write in the whole install pipeline (WAD/game-ELF-sized + * payloads), so a hard power-loss here is the scenario the atomic + * temp+rename is specifically protecting against. + */ + + if (fsync(outfd) < 0) + { + ret = -errno; + close(outfd); + unlink(outpath); + return ret; + } +#endif + if (close(outfd) < 0) { - unlink(dest); + unlink(outpath); return -errno; } +#ifndef CONFIG_PSEUDOFS_FILE + if (rename(outpath, dest) < 0) + { + ret = -errno; + unlink(outpath); + return ret; + } +#endif + return 0; errout: close(infd); close(outfd); - unlink(dest); + unlink(outpath); return ret; } @@ -499,3 +691,59 @@ int pkg_store_remove_file(FAR const char *path) return 0; } + +int pkg_store_remove_version_dir(FAR const char *name, + FAR const char *version) +{ + char path[PATH_MAX]; + char entry_path[PATH_MAX]; + FAR DIR *dir; + FAR struct dirent *ent; + int ret; + + /* Generic directory-content removal (rather than unlinking the payload + * and manifest.jsn by their known names) so this same helper works both + * to reclaim a partially staged version directory after a failed + * install (pkg_install.c) and to prune/remove a fully-installed + * version, without needing to already know that version's artifact + * filename. Best-effort throughout: this runs from error/cleanup + * paths where a still-failing removal shouldn't itself abort the + * caller. + */ + + ret = pkg_store_format_version_path(path, sizeof(path), name, version); + if (ret < 0) + { + return ret; + } + + dir = opendir(path); + if (dir == NULL) + { + return errno == ENOENT ? 0 : -errno; + } + + while ((ent = readdir(dir)) != NULL) + { + if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) + { + continue; + } + + ret = snprintf(entry_path, sizeof(entry_path), "%s/%s", path, + ent->d_name); + if (ret > 0 && (size_t)ret < sizeof(entry_path)) + { + unlink(entry_path); + } + } + + closedir(dir); + + if (rmdir(path) < 0) + { + return errno == ENOENT ? 0 : -errno; + } + + return 0; +} From 455af497caa728886edbe5a799118ef083104133 Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Wed, 22 Jul 2026 17:59:47 +0530 Subject: [PATCH 02/15] system/nxpkg: address lifecycle review findings. Fix real correctness/security gaps found during review, on top of the network sync and CLI completion work: - pkg_install(): commit the installed database before writing the current/previous pointer files, and before advancing transaction state past ACTIVATED. Those are recovery bookkeeping and convenience mirrors respectively - once the installed database itself is durably committed, a failure to refresh either one must not trigger the failure-cleanup path, which could otherwise delete a payload the database now legitimately points at. Also stop treating an existing version directory as newly created when reinstalling an already-installed version, so a failed reinstall can't delete a working install. - pkg_uninstall()/pkg_rollback(): acquire the per-package install lock in addition to the installed-db lock, drop the entry from the authoritative database first, and only then remove payload files - a crash between those two steps can leave orphaned files, which are reclaimable, but never leaves the database pointing at a payload that's already gone. - pkg_sync(): stage each sync to a PID-qualified temp filename instead of a single fixed name, so concurrent sync calls can no longer race on the same staging file. Return real negative errno codes instead of EXIT_SUCCESS/EXIT_FAILURE, matching the rest of this API; pkg_main.c maps that back to a process exit code at the CLI boundary. - pkg_metadata_parse_installed_entry(): validate that name/current/ previous/every entry in versions[] are safe path components, and that current (and previous, if set) actually appear in versions[] - a corrupted or tampered installed-packages database can no longer reference a nonexistent version or smuggle a path-traversal sequence through a field this code already trusted implicitly. - pkg_store_write_all()/pkg_repo_sink(): treat a zero-byte write() as -EIO instead of looping on it silently. - Removed the malloc()-falls-back-to-kmm_malloc() logic in pkg_malloc/ pkg_zalloc/pkg_realloc/pkg_free entirely - it required tracking which allocator owned a given pointer via kmm_heapmember(), which is specific to this target's flat, single-heap memory model and not a sound general application API. These are now plain wrappers around malloc/calloc/realloc/free. - Added pkg_metadata_load_manifest_path(), so a caller (e.g. the nxstore GUI frontend, launching an installed package) can load the manifest actually recorded for a specific installed version, instead of combining a rollback-selected version with whatever the current catalog happens to describe for that package name - those can disagree after a rollback if the catalog has since moved on. - Storage root default changed from /tmp/nxpkg to /var/lib/nxpkg, following the conventional persistent application-data location instead of a path whose own name suggests non-persistent storage. A board without persistent storage mounted at /var, or that wants a different location (e.g. an SD card), still overrides this via CONFIG_SYSTEM_NXPKG_ROOT as before. - Moved system/nxpkg/README.txt into the companion NuttX documentation PR rather than shipping user-facing documentation as an in-tree README. Assisted-by: Claude:claude-sonnet-5 Signed-off-by: aviralgarg05 --- system/nxpkg/Kconfig | 8 +- system/nxpkg/README.txt | 175 ------------------------------------ system/nxpkg/pkg.h | 72 ++------------- system/nxpkg/pkg_install.c | 151 ++++++++++++++++++++----------- system/nxpkg/pkg_main.c | 2 +- system/nxpkg/pkg_manifest.c | 10 +-- system/nxpkg/pkg_metadata.c | 58 ++++++++++++ system/nxpkg/pkg_repo.c | 37 +++++--- system/nxpkg/pkg_store.c | 5 ++ 9 files changed, 205 insertions(+), 313 deletions(-) delete mode 100644 system/nxpkg/README.txt diff --git a/system/nxpkg/Kconfig b/system/nxpkg/Kconfig index 196e7bdfd67..2f08ea6230c 100644 --- a/system/nxpkg/Kconfig +++ b/system/nxpkg/Kconfig @@ -31,11 +31,13 @@ config SYSTEM_NXPKG_STACKSIZE config SYSTEM_NXPKG_ROOT string "'nxpkg' storage root" - default "/tmp/nxpkg" + default "/var/lib/nxpkg" ---help--- Base directory used by nxpkg for its local index, installed metadata, temporary downloads, and package payload storage. - Boards with external storage can point this to a persistent - location such as /mnt/sdcard/nxpkg. + The default follows the conventional persistent application-data + location. A board must mount persistent storage at /var or set + this to another persistent location, such as /mnt/sdcard/nxpkg, + if packages need to survive a reset. endif diff --git a/system/nxpkg/README.txt b/system/nxpkg/README.txt deleted file mode 100644 index c3a48ee80d9..00000000000 --- a/system/nxpkg/README.txt +++ /dev/null @@ -1,175 +0,0 @@ -nxpkg - a package manager for NuttX application images -======================================================== - - nxpkg installs, updates, uninstalls, and rolls back standalone loadable - ELF applications (MODULE=m in an app's Makefile - see games/NXDoom or - examples/calculator for real ports) from a package repository served - over HTTP, onto local storage (an SD card on this board's config). - system/nxstore is an LVGL touchscreen front-end for nxpkg; this - document covers the repository/server side, which nxstore and the - nxpkg CLI both consume identically. - - -Repository layout -================== - - A repository is nothing more than a directory of static files: - - / - index.json - the package catalog - artifacts////// - icons////.bin - optional, see below - - index.json is a single JSON object with one "packages" array. Each - entry is one installable version of one package: - - { - "packages": [ - { - "name": "calc", - "version": "6", - "arch": "xtensa", - "compat": "esp32s3-touch-lcd-7", - "artifact": "artifacts/xtensa/esp32s3/esp32s3-touch-lcd-7/calc/6/calc", - "sha256": "<64 hex chars>", - "type": "elf", - "description": "Simple 4-function calculator", - "category": "Utilities", - "icon": "icons/xtensa/esp32s3/esp32s3-touch-lcd-7/calc.bin" - } - ] - } - - "artifact" and "icon" (both optional except "artifact") are resolved - relative to the repository's own base URL (the URL index.json itself - was fetched from, with the filename stripped) unless they're already - a full http(s):// URL - see pkg_resolve_artifact_source()/ - pkg_resolve_icon_source() in pkg_repo.c. That means the whole - directory tree above can be moved, mirrored, or served from a - completely different host without editing a single path in - index.json, as long as the *relative* structure between index.json - and artifacts/icons/ stays the same. - - "arch"/"compat" must match this board's CONFIG_ARCH ("xtensa") and - CONFIG_ARCH_BOARD ("esp32s3-touch-lcd-7") exactly - see - pkg_compat_check() in pkg_compat.c - so one repository can host - packages for several different boards side by side; each board only - ever installs the entries that match its own identity. - - -What actually serves this - any static file host works -======================================================== - - pkg_repo_fetch_url() (pkg_repo.c) does a plain HTTP GET with no auth, - no custom headers, and no server-side logic required - it just needs - a 2xx response. This means literally any static file server works: - nginx or Apache with a DocumentRoot pointed at the repo directory, a - one-line Python http.server, GitHub Pages, S3/R2/Cloudflare Pages, or - a NAS's built-in web server. There is nothing nxpkg-specific to - install on the server side. - - For local development/testing (what this project actually used while - building and testing every package in this series on real hardware): - - cd /path/to/repo-root - python3 -m http.server 8000 - - That's the entire "server setup." Confirm it's reachable from your - own machine first: - - curl -sI http://:8000/index.json - - sha256 verification (pkg_hash_file_sha256(), checked against the - manifest's "sha256" field before an artifact is ever activated) - already protects payload integrity in transit even over plain HTTP. - Only confidentiality/availability would need HTTPS on top of this if - that matters for a given deployment - nothing in the protocol assumes - HTTP specifically, an https:// URL works exactly the same way. - - -Populating a repository: tools/export_pkg_repo.py -================================================== - - Building the JSON by hand is error-prone (sha256 digests, exact - relative paths); tools/export_pkg_repo.py does it for you from a - built binary: - - python3 tools/export_pkg_repo.py \ - --arch xtensa --chip esp32s3 --compat esp32s3-touch-lcd-7 \ - --package "calc:6:elf:/path/to/apps/bin/calc:Simple 4-function calculator:Utilities" \ - /path/to/repo-root - - --package can be repeated to export several packages/versions in one - call. The colon-separated spec is: - - :::[::[:]] - - is the built artifact on your machine (e.g. apps/bin/calc - after a normal `make`). is optional: any image Pillow can open - (PNG, JPEG, ...) - the tool resizes it to a fixed 48x48 and encodes it - into the small raw RGB565 format system/nxstore's icon rendering - expects (see nxstore_load_icon() in nxstore_main.c for the exact - on-wire layout). Re-running the script is idempotent: it loads - whatever index.json already exists in the target directory, merges in - the packages you just passed (matched on name+version+arch+compat+ - type), and rewrites the file - existing unrelated entries are left - alone. - - This board's FAT-formatted SD card only supports short (8.3) file/ - directory names with no long-name extension - any path component - (package name, artifact leaf filename) over 8 characters before its - extension fails to install with errno 22, not a clear error message. - Keep names short (this is why this series' packages are named "calc", - "brick", "cgol" rather than "calculator", "brickmatch", "conway") - - this is a board/filesystem constraint, not an export_pkg_repo.py or - nxpkg one, and would not apply to a board with a different storage - filesystem. - - -Pointing the board at your repository -====================================== - - Two ways to get an index synced onto the board's local storage, - useful in different situations: - - 1. One-shot from NSH, useful for iterating on a package during - development (this is what building and testing every package in - this series actually looked like): - - nxpkg sync http://:8000/index.json - nxpkg install - nxpkg update # re-check for/install a newer version - nxpkg list # show what's installed, current vs previous - nxpkg rollback # swap back to the previous version - nxpkg remove - - 2. Automatically at boot, via system/nxstore: the repo URL nxstore - syncs from at startup is currently a single hardcoded string in - board_nxstore_autostart() (boards/xtensa/esp32s3/ - esp32s3-touch-lcd-7/src/esp32s3_bringup.c) - change that one line - to point at a different host, or a public one, and every boot - re-syncs the catalog automatically (falling back to whatever was - last synced to local storage if the network/server is unreachable - at boot - nxstore never blocks the app list on a failed sync). - - Development-network note: if your development machine's IP address - changes (switching Wi-Fi networks, a new DHCP lease, a hotspot), - re-run `nxpkg sync` with the new address - the board and the machine - serving the repository just need to be able to reach each other over - IP; nothing else about the setup changes. - - -Concurrency note -================= - - nxpkg protects a single package's own install/update/rollback against - running twice at once (a per-package lock file), and separately - protects the shared installed-packages database against being - corrupted by two *different* packages' installs racing each other - (see pkg_install_acquire_installed_lock() in pkg_install.c) - so it is - safe to have, for example, system/nxstore's own background install - worker and a directly-invoked `nxpkg install` running at the same - time for different packages. Two operations on the *same* package - name at the same time will have the second one fail with -EBUSY - (surfaced as "package has an install/update in progress") rather than - either one silently clobbering the other's work. diff --git a/system/nxpkg/pkg.h b/system/nxpkg/pkg.h index ff515962ab9..8f62e901169 100644 --- a/system/nxpkg/pkg.h +++ b/system/nxpkg/pkg.h @@ -35,8 +35,6 @@ #include #include -#include - /**************************************************************************** * Pre-processor Definitions ****************************************************************************/ @@ -58,14 +56,9 @@ #define PKG_CATEGORY_MAX 31 #define PKG_HASH_HEX_LEN 64 /* Each manifest slot is ~1.7KB (dominated by PKG_LAUNCH_ARGS_MAX slots). - * This used to be 6 to fit inside a static internal-DRAM array, but the - * catalog outgrew that once it held every real GSoC package (calc, the - * four games, NXDoom, and the original examples) - system/nxstore/ - * nxstore_main.c now heap-allocates its struct pkg_index_s g_index via - * pkg_zalloc(), which draws from the PSRAM-backed user heap on this board - * (CONFIG_ESP32S3_SPIRAM_USER_HEAP) instead of internal DRAM, so this can - * grow without the same pressure. Still not unbounded: keep it sized for - * "a real catalog", not arbitrary attacker-controlled growth. + * Keep the catalog bounded so repository-provided metadata cannot cause + * unbounded memory use. Callers should allocate struct pkg_index_s from + * the application heap rather than placing it on a small task stack. */ #define PKG_INDEX_MAX 16 @@ -99,72 +92,22 @@ static inline void *pkg_malloc(size_t size) { - void *ptr = malloc(size); - - if (ptr == NULL) - { - ptr = kmm_malloc(size); - } - - return ptr; + return malloc(size); } static inline void *pkg_zalloc(size_t size) { - void *ptr = calloc(1, size); - - if (ptr == NULL) - { - ptr = kmm_zalloc(size); - } - - return ptr; + return calloc(1, size); } static inline void *pkg_realloc(void *ptr, size_t size) { - if (ptr == NULL) - { - return pkg_malloc(size); - } - - if (size == 0) - { - if (kmm_heapmember(ptr)) - { - kmm_free(ptr); - } - else - { - free(ptr); - } - - return NULL; - } - - if (kmm_heapmember(ptr)) - { - return kmm_realloc(ptr, size); - } - return realloc(ptr, size); } static inline void pkg_free(void *ptr) { - if (ptr == NULL) - { - return; - } - - if (kmm_heapmember(ptr)) - { - kmm_free(ptr); - } - else - { - free(ptr); - } + free(ptr); } static inline FAR char *pkg_path_alloc(void) @@ -241,6 +184,7 @@ struct pkg_installed_db_s const char *pkg_manifest_type_str(enum pkg_payload_type_e type); int pkg_manifest_validate(FAR const struct pkg_manifest_s *manifest); +bool pkg_validate_path_component(FAR const char *value); int pkg_manifest_parse_type(FAR const char *value, FAR enum pkg_payload_type_e *type); @@ -291,6 +235,8 @@ int pkg_hash_file_sha256(FAR const char *path, int pkg_metadata_load_index_path(FAR const char *path, FAR struct pkg_index_s *index); int pkg_metadata_load_index(FAR struct pkg_index_s *index); +int pkg_metadata_load_manifest_path(FAR const char *path, + FAR struct pkg_manifest_s *manifest); FAR const struct pkg_manifest_s * pkg_metadata_find_latest(FAR const struct pkg_index_s *index, FAR const char *name); diff --git a/system/nxpkg/pkg_install.c b/system/nxpkg/pkg_install.c index 3048342696e..9456a94671d 100644 --- a/system/nxpkg/pkg_install.c +++ b/system/nxpkg/pkg_install.c @@ -561,6 +561,29 @@ int pkg_install(FAR const char *name) goto errout; } + ret = pkg_store_format_version_path(payload, PATH_MAX, manifest->name, + manifest->version); + if (ret < 0) + { + pkg_error("version path format failed: %d", ret); + goto errout; + } + + if (access(payload, F_OK) == 0) + { + version_dir_created = false; + } + else if (errno == ENOENT) + { + version_dir_created = true; + } + else + { + ret = -errno; + pkg_error("unable to inspect version directory: %d", ret); + goto errout; + } + ret = pkg_store_ensure_version_dir(manifest->name, manifest->version); if (ret < 0) { @@ -568,8 +591,6 @@ int pkg_install(FAR const char *name) goto errout; } - version_dir_created = true; - ret = pkg_store_format_payload_path(payload, PATH_MAX, manifest->name, manifest->version, manifest->artifact); @@ -653,18 +674,22 @@ int pkg_install(FAR const char *name) goto errout; } - ret = pkg_install_write_pointers(installed, manifest->name); + ret = pkg_metadata_save_installed(installed); if (ret < 0) { - pkg_error("write current/previous pointers failed: %d", ret); + pkg_error("save installed metadata failed: %d", ret); goto errout; } - ret = pkg_metadata_save_installed(installed); + ret = pkg_install_write_pointers(installed, manifest->name); if (ret < 0) { - pkg_error("save installed metadata failed: %d", ret); - goto errout; + /* The installed database is authoritative. The pointer files are + * convenience mirrors and can be reconstructed from it, so failure + * to refresh one must not roll back a durably committed install. + */ + + pkg_error("unable to refresh current/previous pointers: %d", ret); } pkg_store_remove_file(installed_lock); @@ -673,8 +698,13 @@ int pkg_install(FAR const char *name) ret = pkg_txn_write_state(name, PKG_TXN_ACTIVATED); if (ret < 0) { + /* The installed database has already been committed. Do not enter + * the failure cleanup path here: it could remove a payload referenced + * by that database. Transaction state is recovery bookkeeping and + * can be cleared below. + */ + pkg_error("txn state activated failed: %d", ret); - goto errout; } pkg_txn_write_state(name, PKG_TXN_CLEANUP); @@ -811,15 +841,17 @@ int pkg_uninstall(FAR const char *name) { FAR struct pkg_installed_db_s *db; FAR struct pkg_installed_entry_s *entry; + struct pkg_installed_entry_s removed; char path[PATH_MAX]; + char package_lock[PATH_MAX]; char installed_lock[PATH_MAX]; size_t index; size_t i; int ret; - if (name == NULL || name[0] == '\0') + if (!pkg_validate_path_component(name)) { - pkg_error("remove requires a package name"); + pkg_error("remove requires a valid package name"); return EXIT_FAILURE; } @@ -838,10 +870,19 @@ int pkg_uninstall(FAR const char *name) return EXIT_FAILURE; } + ret = pkg_install_acquire_lock(name, package_lock, sizeof(package_lock)); + if (ret < 0) + { + pkg_free(db); + pkg_error("unable to acquire package lock for '%s': %d", name, ret); + return EXIT_FAILURE; + } + ret = pkg_install_acquire_installed_lock(installed_lock, sizeof(installed_lock)); if (ret < 0) { + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("unable to acquire installed-db lock: %d", ret); return EXIT_FAILURE; @@ -851,6 +892,7 @@ int pkg_uninstall(FAR const char *name) if (ret < 0) { pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("unable to load installed metadata: %d", ret); return EXIT_FAILURE; @@ -860,34 +902,18 @@ int pkg_uninstall(FAR const char *name) if (entry == NULL) { pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("package '%s' is not installed", name); return EXIT_FAILURE; } - if (pkg_store_format_lock_path(path, sizeof(path), name) == 0 && - access(path, F_OK) == 0) - { - pkg_store_remove_file(installed_lock); - pkg_free(db); - pkg_error("package '%s' has an install/update in progress", name); - return EXIT_FAILURE; - } - - for (i = 0; i < entry->version_count; i++) - { - pkg_store_remove_version_dir(name, entry->versions[i]); - } - - if (pkg_store_format_txn_path(path, sizeof(path), name) == 0) - { - pkg_store_remove_file(path); - } - - /* Drop this entry from the in-memory db (shift the tail down over it) - * and persist before touching any more of the on-disk layout. + /* Drop this entry from the authoritative database before removing its + * payloads. A power loss can then leave reclaimable orphan files, but + * never a database entry that points at a payload already deleted. */ + removed = *entry; index = (size_t)(entry - db->entries); for (i = index; i + 1 < db->count; i++) { @@ -900,11 +926,22 @@ int pkg_uninstall(FAR const char *name) pkg_store_remove_file(installed_lock); if (ret < 0) { + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("unable to save installed metadata: %d", ret); return EXIT_FAILURE; } + for (i = 0; i < removed.version_count; i++) + { + pkg_store_remove_version_dir(name, removed.versions[i]); + } + + if (pkg_store_format_txn_path(path, sizeof(path), name) == 0) + { + pkg_store_remove_file(path); + } + if (pkg_store_format_current_path(path, sizeof(path), name) == 0) { pkg_store_remove_file(path); @@ -915,6 +952,8 @@ int pkg_uninstall(FAR const char *name) pkg_store_remove_file(path); } + pkg_store_remove_file(package_lock); + if (pkg_store_format_package_root(path, sizeof(path), name) == 0) { rmdir(path); @@ -942,15 +981,15 @@ int pkg_rollback(FAR const char *name) FAR struct pkg_installed_db_s *db; FAR struct pkg_installed_entry_s *entry; char version_path[PATH_MAX]; - char lock_path[PATH_MAX]; + char package_lock[PATH_MAX]; char installed_lock[PATH_MAX]; char swap[PKG_VERSION_MAX + 1]; struct stat st; int ret; - if (name == NULL || name[0] == '\0') + if (!pkg_validate_path_component(name)) { - pkg_error("rollback requires a package name"); + pkg_error("rollback requires a valid package name"); return EXIT_FAILURE; } @@ -969,10 +1008,19 @@ int pkg_rollback(FAR const char *name) return EXIT_FAILURE; } + ret = pkg_install_acquire_lock(name, package_lock, sizeof(package_lock)); + if (ret < 0) + { + pkg_free(db); + pkg_error("unable to acquire package lock for '%s': %d", name, ret); + return EXIT_FAILURE; + } + ret = pkg_install_acquire_installed_lock(installed_lock, sizeof(installed_lock)); if (ret < 0) { + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("unable to acquire installed-db lock: %d", ret); return EXIT_FAILURE; @@ -982,6 +1030,7 @@ int pkg_rollback(FAR const char *name) if (ret < 0) { pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("unable to load installed metadata: %d", ret); return EXIT_FAILURE; @@ -991,6 +1040,7 @@ int pkg_rollback(FAR const char *name) if (entry == NULL) { pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("package '%s' is not installed", name); return EXIT_FAILURE; @@ -999,26 +1049,19 @@ int pkg_rollback(FAR const char *name) if (entry->previous[0] == '\0') { pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("package '%s' has no previous version to roll back to", name); return EXIT_FAILURE; } - if (pkg_store_format_lock_path(lock_path, sizeof(lock_path), name) == 0 && - access(lock_path, F_OK) == 0) - { - pkg_store_remove_file(installed_lock); - pkg_free(db); - pkg_error("package '%s' has an install/update in progress", name); - return EXIT_FAILURE; - } - ret = pkg_store_format_version_path(version_path, sizeof(version_path), name, entry->previous); if (ret < 0 || stat(version_path, &st) < 0) { pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("rollback target version '%s' is missing on disk", entry->previous); @@ -1029,6 +1072,7 @@ int pkg_rollback(FAR const char *name) if (ret < 0 || (size_t)ret >= sizeof(swap)) { pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("current version string too long to swap"); return EXIT_FAILURE; @@ -1039,6 +1083,7 @@ int pkg_rollback(FAR const char *name) if (ret < 0 || (size_t)ret >= sizeof(entry->current)) { pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("unable to update current version"); return EXIT_FAILURE; @@ -1048,33 +1093,33 @@ int pkg_rollback(FAR const char *name) if (ret < 0 || (size_t)ret >= sizeof(entry->previous)) { pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); pkg_free(db); pkg_error("unable to update previous version"); return EXIT_FAILURE; } - /* Persist the pointer-file swap and the db entry together, in that - * order, before releasing anything - a crash between these two writes - * still leaves "current" resolving to the (now rolled-back-to) version - * that's actually on disk, which is the side that must win. + /* The installed database is authoritative, so commit it first. Refresh + * the current/previous pointer files afterwards as convenience mirrors; + * they can be reconstructed from the database if either write fails. */ - ret = pkg_install_write_pointers(db, name); + ret = pkg_metadata_save_installed(db); if (ret < 0) { pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); pkg_free(db); - pkg_error("unable to write current/previous pointers: %d", ret); + pkg_error("unable to save installed metadata: %d", ret); return EXIT_FAILURE; } - ret = pkg_metadata_save_installed(db); + ret = pkg_install_write_pointers(db, name); pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); if (ret < 0) { - pkg_free(db); - pkg_error("unable to save installed metadata: %d", ret); - return EXIT_FAILURE; + pkg_error("unable to refresh current/previous pointers: %d", ret); } pkg_info("rolled back %s to version %s", name, entry->current); diff --git a/system/nxpkg/pkg_main.c b/system/nxpkg/pkg_main.c index f20137f9ec5..40dfb710746 100644 --- a/system/nxpkg/pkg_main.c +++ b/system/nxpkg/pkg_main.c @@ -162,7 +162,7 @@ int main(int argc, FAR char *argv[]) return EXIT_FAILURE; } - return pkg_sync(argv[2]); + return pkg_sync(argv[2]) == 0 ? EXIT_SUCCESS : EXIT_FAILURE; } fprintf(stderr, "ERROR: Unknown subcommand '%s'\n", cmd); diff --git a/system/nxpkg/pkg_manifest.c b/system/nxpkg/pkg_manifest.c index 4c09803851e..dca17546c21 100644 --- a/system/nxpkg/pkg_manifest.c +++ b/system/nxpkg/pkg_manifest.c @@ -74,6 +74,10 @@ static bool pkg_validate_hex(FAR const char *value) return true; } +/**************************************************************************** + * Public Functions + ****************************************************************************/ + /**************************************************************************** * Name: pkg_validate_path_component * @@ -88,7 +92,7 @@ static bool pkg_validate_hex(FAR const char *value) * ****************************************************************************/ -static bool pkg_validate_path_component(FAR const char *value) +bool pkg_validate_path_component(FAR const char *value) { FAR const char *p; @@ -117,10 +121,6 @@ static bool pkg_validate_path_component(FAR const char *value) return true; } -/**************************************************************************** - * Public Functions - ****************************************************************************/ - const char *pkg_manifest_type_str(enum pkg_payload_type_e type) { switch (type) diff --git a/system/nxpkg/pkg_metadata.c b/system/nxpkg/pkg_metadata.c index 38907f59fd0..c28b217adb8 100644 --- a/system/nxpkg/pkg_metadata.c +++ b/system/nxpkg/pkg_metadata.c @@ -302,6 +302,9 @@ static int pkg_metadata_parse_installed_entry( { FAR cJSON *field; FAR const char *value; + bool current_found = false; + bool previous_found = false; + size_t i; int ret; memset(entry, 0, sizeof(*entry)); @@ -366,6 +369,31 @@ static int pkg_metadata_parse_installed_entry( return ret; } + if (!pkg_validate_path_component(entry->name) || + !pkg_validate_path_component(entry->current) || + (entry->previous[0] != '\0' && + !pkg_validate_path_component(entry->previous))) + { + return -EINVAL; + } + + for (i = 0; i < entry->version_count; i++) + { + if (!pkg_validate_path_component(entry->versions[i])) + { + return -EINVAL; + } + + current_found |= strcmp(entry->versions[i], entry->current) == 0; + previous_found |= strcmp(entry->versions[i], entry->previous) == 0; + } + + if (!current_found || + (entry->previous[0] != '\0' && !previous_found)) + { + return -EINVAL; + } + return 0; } @@ -640,6 +668,36 @@ int pkg_metadata_load_index(FAR struct pkg_index_s *index) return pkg_metadata_load_index_path(path, index); } +int pkg_metadata_load_manifest_path(FAR const char *path, + FAR struct pkg_manifest_s *manifest) +{ + FAR cJSON *root; + FAR char *text; + int ret; + + if (path == NULL || manifest == NULL) + { + return -EINVAL; + } + + ret = pkg_store_read_text(path, &text); + if (ret < 0) + { + return ret; + } + + root = cJSON_Parse(text); + pkg_free(text); + if (root == NULL) + { + return -EINVAL; + } + + ret = pkg_metadata_parse_manifest(root, manifest); + cJSON_Delete(root); + return ret; +} + FAR const struct pkg_manifest_s * pkg_metadata_find_latest(FAR const struct pkg_index_s *index, FAR const char *name) diff --git a/system/nxpkg/pkg_repo.c b/system/nxpkg/pkg_repo.c index 6ea294ca86a..64d63403564 100644 --- a/system/nxpkg/pkg_repo.c +++ b/system/nxpkg/pkg_repo.c @@ -229,6 +229,11 @@ static int pkg_repo_sink(FAR char **buffer, int offset, int datend, return -errno; } + if (nwritten == 0) + { + return -EIO; + } + cursor += nwritten; remaining -= (size_t)nwritten; } @@ -404,7 +409,7 @@ int pkg_sync(FAR const char *source) if (source == NULL || source[0] == '\0') { pkg_error("sync requires a non-empty index source"); - return EXIT_FAILURE; + return -EINVAL; } index = pkg_zalloc(sizeof(*index)); @@ -419,7 +424,7 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_error("unable to allocate index metadata buffer"); - return EXIT_FAILURE; + return -ENOMEM; } ret = pkg_store_prepare_layout(); @@ -430,10 +435,16 @@ int pkg_sync(FAR const char *source) pkg_free(tmp); pkg_free(index_path); pkg_free(source_path); - return EXIT_FAILURE; + return ret; } - ret = snprintf(tmp, PATH_MAX, "%s/idxsync.jsn", PKG_TMP_DIR); + /* Each CLI invocation has its own PID. Use it in the staging name so a + * manual sync cannot truncate the file being validated by nxstore (or a + * second shell). Keep the leaf short for short-name-only FAT mounts. + */ + + ret = snprintf(tmp, PATH_MAX, "%s/s%u.jsn", PKG_TMP_DIR, + (unsigned int)getpid()); if (ret < 0 || (size_t)ret >= PATH_MAX) { pkg_error("temporary sync path is too long"); @@ -441,7 +452,7 @@ int pkg_sync(FAR const char *source) pkg_free(tmp); pkg_free(index_path); pkg_free(source_path); - return EXIT_FAILURE; + return -ENAMETOOLONG; } ret = pkg_acquire_source(source, tmp); @@ -452,7 +463,7 @@ int pkg_sync(FAR const char *source) pkg_free(tmp); pkg_free(index_path); pkg_free(source_path); - return EXIT_FAILURE; + return ret; } ret = pkg_metadata_load_index_path(tmp, index); @@ -464,7 +475,7 @@ int pkg_sync(FAR const char *source) pkg_free(tmp); pkg_free(index_path); pkg_free(source_path); - return EXIT_FAILURE; + return ret; } ret = pkg_store_read_text(tmp, &text); @@ -476,7 +487,7 @@ int pkg_sync(FAR const char *source) pkg_free(tmp); pkg_free(index_path); pkg_free(source_path); - return EXIT_FAILURE; + return ret; } ret = pkg_store_format_index_path(index_path, PATH_MAX); @@ -489,7 +500,7 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_error("unable to resolve local index path: %d", ret); - return EXIT_FAILURE; + return ret; } ret = pkg_store_write_text_atomic(index_path, text); @@ -502,7 +513,7 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_error("unable to write local index: %d", ret); - return EXIT_FAILURE; + return ret; } ret = pkg_store_format_repo_source_path(source_path, PATH_MAX); @@ -515,7 +526,7 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_error("unable to resolve repository source path: %d", ret); - return EXIT_FAILURE; + return ret; } ret = pkg_store_write_text_atomic(source_path, source); @@ -528,7 +539,7 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_error("unable to save repository source: %d", ret); - return EXIT_FAILURE; + return ret; } pkg_store_remove_file(tmp); @@ -538,7 +549,7 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_info("synced package index from %s", source); - return EXIT_SUCCESS; + return 0; } int pkg_available(FAR FILE *stream) diff --git a/system/nxpkg/pkg_store.c b/system/nxpkg/pkg_store.c index 56966f4287a..c5ad84bcb0f 100644 --- a/system/nxpkg/pkg_store.c +++ b/system/nxpkg/pkg_store.c @@ -146,6 +146,11 @@ static int pkg_store_write_all(int fd, FAR const char *buffer, size_t length) return -errno; } + if (ret == 0) + { + return -EIO; + } + offset += (size_t)ret; } From 8571175bb998a5d953ff9443d1e3061780a07fdf Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Thu, 23 Jul 2026 16:06:58 +0530 Subject: [PATCH 03/15] system/nxpkg: fix concurrency and versioning bugs found in review. - pkg_sync(): index.jsn and repo.url were updated as two independent atomic writes with nothing serializing the pair against a second, concurrent pkg_sync() call - each write stayed internally consistent, but the pair didn't, so one sync's index could end up on disk next to a different sync's source URL. Add a dedicated sync lock (pkg_repo_acquire_sync_lock(), mirroring the existing installed- db lock's blocking-retry-with-stale-reclaim pattern) around the whole read-fetch-write sequence. Also renew the lock's mtime as data actually arrives (pkg_repo_sink(), via a new renew_lock_path field threaded through pkg_acquire_source()) rather than only stamping it once at acquire time - a lock acquired once and then measured against a fixed 10-minute staleness window could otherwise be reclaimed mid-download on a large-enough file over a slow-enough link, even though the download was still genuinely in progress. pkg_reclaim_stale_lock() (renamed from pkg_install_reclaim_stale_lock, made public) is shared between both lock kinds rather than duplicated. - pkg_install_prune_oldest_version(): deleted the pruned version's on-disk payload directory before the updated installed database was even durably saved. If pkg_metadata_save_installed() subsequently failed, the payload was already gone but the last successfully-saved instpkg.jsn could still list that version as installed. The victim version is now handed back to the caller (threaded through pkg_install_add_version()/pkg_install_update_installed()) so pkg_install() can defer the actual directory removal until after the save succeeds. - pkg_metadata_version_token_cmp(): two version tokens with equal numeric prefixes (e.g. "1a" and "1b", both parsing as 1) compared as equal instead of falling back to a lexical comparison of what follows the number, contradicting this function's own documented behavior and silently treating genuinely different versions as the same one. Compare the non-numeric remainder lexically when the numeric prefixes match instead of falling through. Assisted-by: Claude:claude-sonnet-5 Signed-off-by: aviralgarg05 --- system/nxpkg/pkg.h | 4 +- system/nxpkg/pkg_install.c | 124 +++++++++++++++++++----------- system/nxpkg/pkg_metadata.c | 39 +++++++--- system/nxpkg/pkg_repo.c | 149 ++++++++++++++++++++++++++++++++---- 4 files changed, 246 insertions(+), 70 deletions(-) diff --git a/system/nxpkg/pkg.h b/system/nxpkg/pkg.h index 8f62e901169..28c8c19a805 100644 --- a/system/nxpkg/pkg.h +++ b/system/nxpkg/pkg.h @@ -259,7 +259,9 @@ int pkg_resolve_artifact_source(FAR char *buffer, size_t size, FAR const struct pkg_manifest_s *manifest); int pkg_resolve_icon_source(FAR char *buffer, size_t size, FAR const struct pkg_manifest_s *manifest); -int pkg_acquire_source(FAR const char *source, FAR const char *dest); +int pkg_acquire_source(FAR const char *source, FAR const char *dest, + FAR const char *renew_lock_path); +void pkg_reclaim_stale_lock(FAR const char *path); int pkg_sync(FAR const char *source); int pkg_install(FAR const char *name); int pkg_uninstall(FAR const char *name); diff --git a/system/nxpkg/pkg_install.c b/system/nxpkg/pkg_install.c index 9456a94671d..856b5e19f67 100644 --- a/system/nxpkg/pkg_install.c +++ b/system/nxpkg/pkg_install.c @@ -40,39 +40,6 @@ * Private Functions ****************************************************************************/ -/**************************************************************************** - * Name: pkg_install_reclaim_stale_lock - * - * Description: - * Remove "path" if it is old enough that it cannot belong to a - * still-running install (see PKG_LOCK_STALE_SECONDS). Best-effort: any - * stat()/unlink() failure just falls through to the normal -EBUSY - * result, since a lock we can't inspect should be treated as held. - * - ****************************************************************************/ - -static void pkg_install_reclaim_stale_lock(FAR const char *path) -{ - struct stat st; - time_t now; - - if (stat(path, &st) < 0) - { - return; - } - - now = time(NULL); - if (now < st.st_mtime || - (now - st.st_mtime) < PKG_LOCK_STALE_SECONDS) - { - return; - } - - pkg_error("reclaiming stale lock '%s' (age %ld s)", - path, (long)(now - st.st_mtime)); - unlink(path); -} - static int pkg_install_acquire_lock(FAR const char *name, FAR char *path, size_t size) { @@ -94,7 +61,7 @@ static int pkg_install_acquire_lock(FAR const char *name, FAR char *path, fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); if (fd < 0 && errno == EEXIST) { - pkg_install_reclaim_stale_lock(path); + pkg_reclaim_stale_lock(path); fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); } @@ -176,7 +143,7 @@ static int pkg_install_acquire_installed_lock(FAR char *path, size_t size) return -errno; } - pkg_install_reclaim_stale_lock(path); + pkg_reclaim_stale_lock(path); usleep(20 * 1000); } @@ -201,7 +168,8 @@ static bool pkg_install_has_version( } static int pkg_install_prune_oldest_version( - FAR struct pkg_installed_entry_s *entry) + FAR struct pkg_installed_entry_s *entry, + FAR char *pruned_version, size_t pruned_version_size) { size_t victim = entry->version_count; size_t i; @@ -228,7 +196,19 @@ static int pkg_install_prune_oldest_version( return -E2BIG; } - pkg_store_remove_version_dir(entry->name, entry->versions[victim]); + /* Deleting the pruned version's on-disk directory here, before this + * in-memory db update is even durably saved, left a real inconsistency + * window: if pkg_metadata_save_installed() subsequently failed (full + * SD card, I/O error), the payload was already gone but the last + * successfully-saved instpkg.jsn could still list that version as + * installed. Hand the victim's version string back to the caller + * instead, so it can defer the actual directory removal until after + * the save succeeds - mirroring how this file already treats the + * installed db as authoritative everywhere else. + */ + + snprintf(pruned_version, pruned_version_size, "%s", + entry->versions[victim]); for (i = victim; i + 1 < entry->version_count; i++) { @@ -241,7 +221,9 @@ static int pkg_install_prune_oldest_version( } static int pkg_install_add_version(FAR struct pkg_installed_entry_s *entry, - FAR const char *version) + FAR const char *version, + FAR char *pruned_version, + size_t pruned_version_size) { int ret; @@ -252,7 +234,8 @@ static int pkg_install_add_version(FAR struct pkg_installed_entry_s *entry, if (entry->version_count >= PKG_INSTALLED_VERSIONS_MAX) { - ret = pkg_install_prune_oldest_version(entry); + ret = pkg_install_prune_oldest_version(entry, pruned_version, + pruned_version_size); if (ret < 0) { return ret; @@ -278,7 +261,9 @@ static int pkg_install_add_version(FAR struct pkg_installed_entry_s *entry, static int pkg_install_update_installed(FAR struct pkg_installed_db_s *db, FAR const struct pkg_manifest_s - *manifest) + *manifest, + FAR char *pruned_version, + size_t pruned_version_size) { FAR struct pkg_installed_entry_s *entry; int ret; @@ -332,7 +317,8 @@ static int pkg_install_update_installed(FAR struct pkg_installed_db_s *db, } entry->type = manifest->type; - return pkg_install_add_version(entry, manifest->version); + return pkg_install_add_version(entry, manifest->version, pruned_version, + pruned_version_size); } static int pkg_install_write_pointers( @@ -380,6 +366,42 @@ static int pkg_install_write_pointers( * Public Functions ****************************************************************************/ +/**************************************************************************** + * Name: pkg_reclaim_stale_lock + * + * Description: + * Remove "path" if it is old enough that it cannot belong to a still- + * running holder (see PKG_LOCK_STALE_SECONDS). Best-effort: any + * stat()/unlink() failure just falls through to the normal -EBUSY + * result, since a lock we can't inspect should be treated as held. + * Generic across every lock file this package uses (per-package + * install locks, the shared installed-db lock, pkg_repo.c's sync + * lock) - not install-specific despite living in this file. + * + ****************************************************************************/ + +void pkg_reclaim_stale_lock(FAR const char *path) +{ + struct stat st; + time_t now; + + if (stat(path, &st) < 0) + { + return; + } + + now = time(NULL); + if (now < st.st_mtime || + (now - st.st_mtime) < PKG_LOCK_STALE_SECONDS) + { + return; + } + + pkg_error("reclaiming stale lock '%s' (age %ld s)", + path, (long)(now - st.st_mtime)); + unlink(path); +} + int pkg_install(FAR const char *name) { FAR struct pkg_index_s *index; @@ -393,11 +415,14 @@ int pkg_install(FAR const char *name) FAR char *installed_lock; FAR const char *artifact; char digest[PKG_HASH_HEX_LEN + 1]; + char pruned_version[PKG_VERSION_MAX + 1]; bool staged_to_tmp; bool version_dir_created; bool installed_lock_held; int ret; + pruned_version[0] = '\0'; + index = pkg_zalloc(sizeof(*index)); installed = pkg_zalloc(sizeof(*installed)); source = pkg_path_alloc(); @@ -525,7 +550,7 @@ int pkg_install(FAR const char *name) goto errout; } - ret = pkg_acquire_source(source, tmp); + ret = pkg_acquire_source(source, tmp, lock); if (ret < 0) { pkg_error("acquire source failed: %d", ret); @@ -667,7 +692,8 @@ int pkg_install(FAR const char *name) goto errout; } - ret = pkg_install_update_installed(installed, manifest); + ret = pkg_install_update_installed(installed, manifest, pruned_version, + sizeof(pruned_version)); if (ret < 0) { pkg_error("update installed metadata failed: %d", ret); @@ -681,6 +707,18 @@ int pkg_install(FAR const char *name) goto errout; } + /* Only remove the pruned version's payload directory now that the db + * update naming it gone is durably saved - see + * pkg_install_prune_oldest_version()'s comment for why doing this + * before the save could leave a saved db entry pointing at an + * already-deleted version if the save had failed instead. + */ + + if (pruned_version[0] != '\0') + { + pkg_store_remove_version_dir(manifest->name, pruned_version); + } + ret = pkg_install_write_pointers(installed, manifest->name); if (ret < 0) { diff --git a/system/nxpkg/pkg_metadata.c b/system/nxpkg/pkg_metadata.c index c28b217adb8..62658f798dc 100644 --- a/system/nxpkg/pkg_metadata.c +++ b/system/nxpkg/pkg_metadata.c @@ -404,6 +404,9 @@ static int pkg_metadata_version_token_cmp(FAR const char *lhs, long rightnum; FAR char *leftend; FAR char *rightend; + FAR const char *cmpleft; + FAR const char *cmpright; + int ret; leftnum = strtol(lhs, &leftend, 10); rightnum = strtol(rhs, &rightend, 10); @@ -419,22 +422,36 @@ static int pkg_metadata_version_token_cmp(FAR const char *lhs, { return 1; } + + /* Equal numeric prefixes don't make the tokens equal if what + * follows the number differs - e.g. "1a" and "1b" both parse as + * 1, but are documented to compare lexically by their non-numeric + * component. Falling through to "equal" here (as this used to) + * silently treated any such pair as the same version, which is + * exactly backwards for tokens whose whole point is to be + * distinguishable. Compare what's left after the numeric prefix + * - leftend/rightend - lexically instead of falling through. + */ + + cmpleft = leftend; + cmpright = rightend; } else { - int ret; + cmpleft = lhs; + cmpright = rhs; + } - ret = pkg_string_cmp(lhs, PKG_VERSION_MAX + 1, - rhs, PKG_VERSION_MAX + 1); - if (ret < 0) - { - return -1; - } + ret = pkg_string_cmp(cmpleft, PKG_VERSION_MAX + 1, + cmpright, PKG_VERSION_MAX + 1); + if (ret < 0) + { + return -1; + } - if (ret > 0) - { - return 1; - } + if (ret > 0) + { + return 1; } return 0; diff --git a/system/nxpkg/pkg_repo.c b/system/nxpkg/pkg_repo.c index 64d63403564..c41894f1097 100644 --- a/system/nxpkg/pkg_repo.c +++ b/system/nxpkg/pkg_repo.c @@ -32,6 +32,8 @@ #include #include #include +#include +#include #include @@ -67,6 +69,15 @@ struct pkg_fetch_context_s { int fd; size_t total; + + /* Optional path to a lock file whose mtime should be refreshed as data + * arrives - see pkg_repo_sink()'s comment on why a lock acquired once + * up front isn't enough for a download that can run past the stale- + * lock timeout on its own. NULL if there's nothing to renew (e.g. a + * plain local-file copy, which is fast enough not to need it). + */ + + FAR const char *renew_lock_path; }; #endif @@ -238,10 +249,28 @@ static int pkg_repo_sink(FAR char **buffer, int offset, int datend, remaining -= (size_t)nwritten; } + /* The lock this download is running under was only ever stamped once, + * at acquire time - PKG_LOCK_STALE_SECONDS then measures from that + * single timestamp regardless of how long the download actually + * takes, so a large-enough file over a slow-enough link can still be + * genuinely mid-transfer when another install for the same package + * decides the lock looks stale and reclaims it out from under this + * one. Touching it here means its age reflects time since the last + * byte actually arrived instead of total operation time - best- + * effort: a failed touch just means this one chunk didn't renew it, + * not that the download itself should fail. + */ + + if (ctx->renew_lock_path != NULL) + { + utime(ctx->renew_lock_path, NULL); + } + return 0; } -static int pkg_repo_fetch_url(FAR const char *url, FAR const char *dest) +static int pkg_repo_fetch_url(FAR const char *url, FAR const char *dest, + FAR const char *renew_lock_path) { struct pkg_fetch_context_s fetch; struct webclient_context client; @@ -256,6 +285,7 @@ static int pkg_repo_fetch_url(FAR const char *url, FAR const char *dest) } fetch.total = 0; + fetch.renew_lock_path = renew_lock_path; webclient_set_defaults(&client); client.method = "GET"; @@ -378,7 +408,8 @@ int pkg_resolve_icon_source(FAR char *buffer, size_t size, return pkg_resolve_relative_source(buffer, size, manifest->icon); } -int pkg_acquire_source(FAR const char *source, FAR const char *dest) +int pkg_acquire_source(FAR const char *source, FAR const char *dest, + FAR const char *renew_lock_path) { if (source == NULL || dest == NULL) { @@ -388,7 +419,7 @@ int pkg_acquire_source(FAR const char *source, FAR const char *dest) if (pkg_source_is_url(source)) { #ifdef CONFIG_NETUTILS_WEBCLIENT - return pkg_repo_fetch_url(source, dest); + return pkg_repo_fetch_url(source, dest, renew_lock_path); #else return -ENOSYS; #endif @@ -397,6 +428,64 @@ int pkg_acquire_source(FAR const char *source, FAR const char *dest) return pkg_store_copy_file(source, dest); } +/**************************************************************************** + * Name: pkg_repo_acquire_sync_lock + * + * Description: + * pkg_sync() below updates index.jsn and repo.url as two separate + * atomic writes with nothing serializing the pair against a second, + * concurrent pkg_sync() call (e.g. two different sources, or a manual + * sync racing nxstore's own retry loop). Each individual write stays + * internally consistent, but the *pair* doesn't: one sync's index.jsn + * can end up on disk next to a different sync's repo.url, and the two + * syncs' temp/staging files (already PID-qualified against each + * other) can still be committed out of order relative to one another. + * A single lock file around the whole read-fetch-write sequence + * closes that, mirroring pkg_install.c's installed-db lock: blocking + * with a bounded retry rather than instant -EBUSY, since this + * critical section already includes the network fetch and a slow + * sync should make a second one wait rather than fail outright. + * + ****************************************************************************/ + +static int pkg_repo_acquire_sync_lock(FAR char *path, size_t size) +{ + int fd; + int ret; + int tries; + + ret = snprintf(path, size, PKG_ROOT_DIR "/sync.lk"); + if (ret < 0) + { + return ret; + } + + if ((size_t)ret >= size) + { + return -ENAMETOOLONG; + } + + for (tries = 0; tries < 100; tries++) + { + fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); + if (fd >= 0) + { + close(fd); + return 0; + } + + if (errno != EEXIST) + { + return -errno; + } + + pkg_reclaim_stale_lock(path); + usleep(20 * 1000); + } + + return -EBUSY; +} + int pkg_sync(FAR const char *source) { FAR struct pkg_index_s *index; @@ -404,6 +493,8 @@ int pkg_sync(FAR const char *source) FAR char *tmp; FAR char *index_path; FAR char *source_path; + FAR char *lock; + bool lock_held = false; int ret; if (source == NULL || source[0] == '\0') @@ -412,6 +503,23 @@ int pkg_sync(FAR const char *source) return -EINVAL; } + lock = pkg_path_alloc(); + if (lock == NULL) + { + pkg_error("unable to allocate sync lock path buffer"); + return -ENOMEM; + } + + ret = pkg_repo_acquire_sync_lock(lock, PATH_MAX); + if (ret < 0) + { + pkg_error("unable to acquire sync lock: %d", ret); + pkg_free(lock); + return ret; + } + + lock_held = true; + index = pkg_zalloc(sizeof(*index)); tmp = pkg_path_alloc(); index_path = pkg_path_alloc(); @@ -424,7 +532,8 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_error("unable to allocate index metadata buffer"); - return -ENOMEM; + ret = -ENOMEM; + goto out; } ret = pkg_store_prepare_layout(); @@ -435,7 +544,7 @@ int pkg_sync(FAR const char *source) pkg_free(tmp); pkg_free(index_path); pkg_free(source_path); - return ret; + goto out; } /* Each CLI invocation has its own PID. Use it in the staging name so a @@ -452,10 +561,11 @@ int pkg_sync(FAR const char *source) pkg_free(tmp); pkg_free(index_path); pkg_free(source_path); - return -ENAMETOOLONG; + ret = -ENAMETOOLONG; + goto out; } - ret = pkg_acquire_source(source, tmp); + ret = pkg_acquire_source(source, tmp, lock); if (ret < 0) { pkg_error("unable to fetch index source '%s': %d", source, ret); @@ -463,7 +573,7 @@ int pkg_sync(FAR const char *source) pkg_free(tmp); pkg_free(index_path); pkg_free(source_path); - return ret; + goto out; } ret = pkg_metadata_load_index_path(tmp, index); @@ -475,7 +585,7 @@ int pkg_sync(FAR const char *source) pkg_free(tmp); pkg_free(index_path); pkg_free(source_path); - return ret; + goto out; } ret = pkg_store_read_text(tmp, &text); @@ -487,7 +597,7 @@ int pkg_sync(FAR const char *source) pkg_free(tmp); pkg_free(index_path); pkg_free(source_path); - return ret; + goto out; } ret = pkg_store_format_index_path(index_path, PATH_MAX); @@ -500,7 +610,7 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_error("unable to resolve local index path: %d", ret); - return ret; + goto out; } ret = pkg_store_write_text_atomic(index_path, text); @@ -513,7 +623,7 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_error("unable to write local index: %d", ret); - return ret; + goto out; } ret = pkg_store_format_repo_source_path(source_path, PATH_MAX); @@ -526,7 +636,7 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_error("unable to resolve repository source path: %d", ret); - return ret; + goto out; } ret = pkg_store_write_text_atomic(source_path, source); @@ -539,7 +649,7 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_error("unable to save repository source: %d", ret); - return ret; + goto out; } pkg_store_remove_file(tmp); @@ -549,7 +659,16 @@ int pkg_sync(FAR const char *source) pkg_free(index_path); pkg_free(source_path); pkg_info("synced package index from %s", source); - return 0; + ret = 0; + +out: + if (lock_held) + { + unlink(lock); + } + + pkg_free(lock); + return ret; } int pkg_available(FAR FILE *stream) From 38c608326a5c71f48669fd25f05acb7af5a9cff5 Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Thu, 23 Jul 2026 20:43:22 +0530 Subject: [PATCH 04/15] system/nxpkg: Address follow-up review findings. Shorten the installed-database lock description and centralize pkg_sync() cleanup, as requested in review. Replace repeated protocol literals with named constants and preserve errno before cleanup calls can overwrite it. Store the synchronized catalog source in the catalog itself so the catalog and its relative-artifact base are committed atomically. Continue reading the former sidecar format for upgrade compatibility, and normalize array-form catalogs before adding the private source field. Compare numeric version prefixes without strtol() overflow and retain lexical comparison of suffixes. Assisted-by: Codex:gpt-5 Signed-off-by: aviralgarg05 --- system/nxpkg/pkg_install.c | 33 +---- system/nxpkg/pkg_metadata.c | 63 +++++++--- system/nxpkg/pkg_repo.c | 244 ++++++++++++++++++++---------------- system/nxpkg/pkg_store.c | 30 +++-- 4 files changed, 204 insertions(+), 166 deletions(-) diff --git a/system/nxpkg/pkg_install.c b/system/nxpkg/pkg_install.c index 856b5e19f67..cef908c5106 100644 --- a/system/nxpkg/pkg_install.c +++ b/system/nxpkg/pkg_install.c @@ -78,37 +78,8 @@ static int pkg_install_acquire_lock(FAR const char *name, FAR char *path, * Name: pkg_install_acquire_installed_lock * * Description: - * The per-package lock above (pkg_install_acquire_lock()) only ever - * protects one package's own version directory/manifest - it says - * nothing about the single shared installed-packages database - * (instpkg.jsn) that every install/uninstall/rollback reads, modifies - * in memory, and writes back as a whole. Two of those operations for - * *different* packages (their own per-package locks don't conflict) - * can each load a snapshot of that shared file, add/change their own - * entry, and save - and whichever one saves last wins, silently - * discarding whatever the other one had just added. This is exactly - * what "a previously-installed package vanishes from `nxpkg list` - * with no error" looks like, without needing any corrupted file or - * non-atomic write at all: the write path is already atomic - * (pkg_store_write_text_atomic() writes to a temp file, fsyncs, then - * renames), so the *file* is always internally consistent - it just - * might be a consistent snapshot that's missing an entry a concurrent - * operation already believed it had durably saved. - * - * A separate, single global lock file (independent of any specific - * package's own lock) closes that window: acquire it right before - * pkg_metadata_load_installed() and hold it until immediately after - * pkg_metadata_save_installed(), so that whole read-modify-write - * sequence is atomic with respect to every other caller of this - * function, regardless of which package(s) they're touching. - * - * Blocking (bounded retry loop) rather than instant-EBUSY like the - * per-package lock: the critical section this protects is a handful - * of local SD-card JSON operations with no network I/O in it, so a - * real holder releases it within milliseconds - a caller should wait - * that out rather than fail an otherwise-healthy install/uninstall/ - * rollback just because another one's shared-db update was in - * flight at the same instant. + * Acquire the global installed-database lock. This serializes the + * read-modify-write sequence used by install, uninstall, and rollback. * ****************************************************************************/ diff --git a/system/nxpkg/pkg_metadata.c b/system/nxpkg/pkg_metadata.c index 62658f798dc..951b4772bf8 100644 --- a/system/nxpkg/pkg_metadata.c +++ b/system/nxpkg/pkg_metadata.c @@ -24,6 +24,7 @@ * Included Files ****************************************************************************/ +#include #include #include #include @@ -400,41 +401,63 @@ static int pkg_metadata_parse_installed_entry( static int pkg_metadata_version_token_cmp(FAR const char *lhs, FAR const char *rhs) { - long leftnum; - long rightnum; - FAR char *leftend; - FAR char *rightend; FAR const char *cmpleft; FAR const char *cmpright; + FAR const char *leftdigits; + FAR const char *rightdigits; + size_t leftlen; + size_t rightlen; int ret; - leftnum = strtol(lhs, &leftend, 10); - rightnum = strtol(rhs, &rightend, 10); + leftdigits = lhs; + rightdigits = rhs; + while (isdigit((unsigned char)*leftdigits)) + { + leftdigits++; + } + + while (isdigit((unsigned char)*rightdigits)) + { + rightdigits++; + } - if (leftend != lhs && rightend != rhs) + if (leftdigits != lhs && rightdigits != rhs) { - if (leftnum < rightnum) + while (*lhs == '0' && lhs + 1 < leftdigits) + { + lhs++; + } + + while (*rhs == '0' && rhs + 1 < rightdigits) + { + rhs++; + } + + leftlen = (size_t)(leftdigits - lhs); + rightlen = (size_t)(rightdigits - rhs); + if (leftlen < rightlen) { return -1; } - if (leftnum > rightnum) + if (leftlen > rightlen) { return 1; } - /* Equal numeric prefixes don't make the tokens equal if what - * follows the number differs - e.g. "1a" and "1b" both parse as - * 1, but are documented to compare lexically by their non-numeric - * component. Falling through to "equal" here (as this used to) - * silently treated any such pair as the same version, which is - * exactly backwards for tokens whose whole point is to be - * distinguishable. Compare what's left after the numeric prefix - * - leftend/rightend - lexically instead of falling through. - */ + ret = memcmp(lhs, rhs, leftlen); + if (ret < 0) + { + return -1; + } + + if (ret > 0) + { + return 1; + } - cmpleft = leftend; - cmpright = rightend; + cmpleft = leftdigits; + cmpright = rightdigits; } else { diff --git a/system/nxpkg/pkg_repo.c b/system/nxpkg/pkg_repo.c index c41894f1097..3cd201e8e02 100644 --- a/system/nxpkg/pkg_repo.c +++ b/system/nxpkg/pkg_repo.c @@ -37,6 +37,8 @@ #include +#include + #ifdef CONFIG_NETUTILS_WEBCLIENT # include "netutils/webclient.h" #endif @@ -59,6 +61,9 @@ */ #define PKG_REPO_FETCH_BUFFER_SIZE 4096 +#define PKG_REPO_HTTP "http://" +#define PKG_REPO_HTTPS "https://" +#define PKG_REPO_SOURCE_KEY "_nxpkg_source" /**************************************************************************** * Private Types @@ -108,7 +113,7 @@ static int pkg_repo_source_base(FAR char *buffer, size_t size, slash = strrchr(source, '/'); if (slash == NULL) { - return -EINVAL; + return pkg_repo_copy_string(buffer, size, "."); } length = (size_t)(slash - source); @@ -168,12 +173,14 @@ static bool pkg_validate_artifact_relative(FAR const char *value) static int pkg_repo_read_source(FAR char *buffer, size_t size) { + FAR cJSON *root; + FAR cJSON *source; + FAR char *text = NULL; char path[PATH_MAX]; - FAR char *text; size_t length; int ret; - ret = pkg_store_format_repo_source_path(path, sizeof(path)); + ret = pkg_store_format_index_path(path, sizeof(path)); if (ret < 0) { return ret; @@ -185,17 +192,113 @@ static int pkg_repo_read_source(FAR char *buffer, size_t size) return ret; } - length = strlen(text); - while (length > 0 && isspace((unsigned char)text[length - 1])) + root = cJSON_Parse(text); + pkg_free(text); + if (root == NULL) { - text[--length] = '\0'; + return -EINVAL; } - ret = pkg_repo_copy_string(buffer, size, text); - pkg_free(text); + source = cJSON_GetObjectItemCaseSensitive(root, PKG_REPO_SOURCE_KEY); + if (!cJSON_IsString(source) || source->valuestring == NULL) + { + cJSON_Delete(root); + + /* Read the sidecar written by older nxpkg versions once, so an + * existing cached catalog remains usable until the next sync. + */ + + ret = pkg_store_format_repo_source_path(path, sizeof(path)); + if (ret < 0) + { + return ret; + } + + ret = pkg_store_read_text(path, &text); + if (ret < 0) + { + return ret; + } + + length = strlen(text); + while (length > 0 && isspace((unsigned char)text[length - 1])) + { + text[--length] = '\0'; + } + + ret = pkg_repo_copy_string(buffer, size, text); + pkg_free(text); + return ret; + } + + ret = pkg_repo_copy_string(buffer, size, source->valuestring); + cJSON_Delete(root); return ret; } +static int pkg_repo_attach_source(FAR char **text, + FAR const char *source_value) +{ + FAR cJSON *root; + FAR cJSON *wrapper; + FAR char *updated; + + root = cJSON_Parse(*text); + if (root == NULL) + { + return -EINVAL; + } + + if (cJSON_IsArray(root)) + { + wrapper = cJSON_CreateObject(); + if (wrapper == NULL) + { + cJSON_Delete(root); + return -ENOMEM; + } + + cJSON_AddItemToObject(wrapper, "packages", root); + if (cJSON_GetObjectItemCaseSensitive(wrapper, "packages") != root) + { + cJSON_Delete(root); + cJSON_Delete(wrapper); + return -ENOMEM; + } + + root = wrapper; + } + else if (!cJSON_IsObject(root)) + { + cJSON_Delete(root); + return -EINVAL; + } + + while (cJSON_GetObjectItemCaseSensitive(root, + PKG_REPO_SOURCE_KEY) != NULL) + { + cJSON_DeleteItemFromObjectCaseSensitive(root, PKG_REPO_SOURCE_KEY); + } + + if (cJSON_AddStringToObject(root, PKG_REPO_SOURCE_KEY, + source_value) == NULL) + { + cJSON_Delete(root); + return -ENOMEM; + } + + updated = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + if (updated == NULL) + { + return -ENOMEM; + } + + pkg_free(*text); + *text = updated; + return 0; +} + #ifdef CONFIG_NETUTILS_WEBCLIENT static int pkg_repo_sink(FAR char **buffer, int offset, int datend, FAR int *buflen, FAR void *arg) @@ -312,10 +415,12 @@ static int pkg_repo_fetch_url(FAR const char *url, FAR const char *dest, return -EPROTO; } - if (close(fetch.fd) < 0) + ret = close(fetch.fd); + if (ret < 0) { + ret = -errno; unlink(dest); - return -errno; + return ret; } return 0; @@ -382,8 +487,8 @@ bool pkg_source_is_url(FAR const char *source) return false; } - return strncasecmp(source, "http://", 7) == 0 || - strncasecmp(source, "https://", 8) == 0; + return strncasecmp(source, PKG_REPO_HTTP, strlen(PKG_REPO_HTTP)) == 0 || + strncasecmp(source, PKG_REPO_HTTPS, strlen(PKG_REPO_HTTPS)) == 0; } int pkg_resolve_artifact_source(FAR char *buffer, size_t size, @@ -432,19 +537,8 @@ int pkg_acquire_source(FAR const char *source, FAR const char *dest, * Name: pkg_repo_acquire_sync_lock * * Description: - * pkg_sync() below updates index.jsn and repo.url as two separate - * atomic writes with nothing serializing the pair against a second, - * concurrent pkg_sync() call (e.g. two different sources, or a manual - * sync racing nxstore's own retry loop). Each individual write stays - * internally consistent, but the *pair* doesn't: one sync's index.jsn - * can end up on disk next to a different sync's repo.url, and the two - * syncs' temp/staging files (already PID-qualified against each - * other) can still be committed out of order relative to one another. - * A single lock file around the whole read-fetch-write sequence - * closes that, mirroring pkg_install.c's installed-db lock: blocking - * with a bounded retry rather than instant -EBUSY, since this - * critical section already includes the network fetch and a slow - * sync should make a second one wait rather than fail outright. + * Serialize catalog synchronization so concurrent downloads cannot + * commit out of order. * ****************************************************************************/ @@ -488,13 +582,12 @@ static int pkg_repo_acquire_sync_lock(FAR char *path, size_t size) int pkg_sync(FAR const char *source) { - FAR struct pkg_index_s *index; + FAR struct pkg_index_s *index = NULL; FAR char *text = NULL; - FAR char *tmp; - FAR char *index_path; - FAR char *source_path; + FAR char *tmp = NULL; + FAR char *index_path = NULL; FAR char *lock; - bool lock_held = false; + bool remove_tmp = false; int ret; if (source == NULL || source[0] == '\0') @@ -518,19 +611,11 @@ int pkg_sync(FAR const char *source) return ret; } - lock_held = true; - index = pkg_zalloc(sizeof(*index)); tmp = pkg_path_alloc(); index_path = pkg_path_alloc(); - source_path = pkg_path_alloc(); - if (index == NULL || tmp == NULL || index_path == NULL || - source_path == NULL) + if (index == NULL || tmp == NULL || index_path == NULL) { - pkg_free(index); - pkg_free(tmp); - pkg_free(index_path); - pkg_free(source_path); pkg_error("unable to allocate index metadata buffer"); ret = -ENOMEM; goto out; @@ -540,10 +625,6 @@ int pkg_sync(FAR const char *source) if (ret < 0) { pkg_error("unable to prepare package layout: %d", ret); - pkg_free(index); - pkg_free(tmp); - pkg_free(index_path); - pkg_free(source_path); goto out; } @@ -557,10 +638,6 @@ int pkg_sync(FAR const char *source) if (ret < 0 || (size_t)ret >= PATH_MAX) { pkg_error("temporary sync path is too long"); - pkg_free(index); - pkg_free(tmp); - pkg_free(index_path); - pkg_free(source_path); ret = -ENAMETOOLONG; goto out; } @@ -569,104 +646,61 @@ int pkg_sync(FAR const char *source) if (ret < 0) { pkg_error("unable to fetch index source '%s': %d", source, ret); - pkg_free(index); - pkg_free(tmp); - pkg_free(index_path); - pkg_free(source_path); goto out; } + remove_tmp = true; ret = pkg_metadata_load_index_path(tmp, index); if (ret < 0) { - pkg_store_remove_file(tmp); pkg_error("downloaded index is invalid: %d", ret); - pkg_free(index); - pkg_free(tmp); - pkg_free(index_path); - pkg_free(source_path); goto out; } ret = pkg_store_read_text(tmp, &text); if (ret < 0) { - pkg_store_remove_file(tmp); pkg_error("unable to read fetched index: %d", ret); - pkg_free(index); - pkg_free(tmp); - pkg_free(index_path); - pkg_free(source_path); goto out; } - ret = pkg_store_format_index_path(index_path, PATH_MAX); - if (ret < 0) - { - pkg_store_remove_file(tmp); - pkg_free(text); - pkg_free(index); - pkg_free(tmp); - pkg_free(index_path); - pkg_free(source_path); - pkg_error("unable to resolve local index path: %d", ret); - goto out; - } + /* Commit the catalog and its source in one atomic file replacement. */ - ret = pkg_store_write_text_atomic(index_path, text); + ret = pkg_repo_attach_source(&text, source); if (ret < 0) { - pkg_store_remove_file(tmp); - pkg_free(text); - pkg_free(index); - pkg_free(tmp); - pkg_free(index_path); - pkg_free(source_path); - pkg_error("unable to write local index: %d", ret); + pkg_error("unable to record repository source: %d", ret); goto out; } - ret = pkg_store_format_repo_source_path(source_path, PATH_MAX); + ret = pkg_store_format_index_path(index_path, PATH_MAX); if (ret < 0) { - pkg_store_remove_file(tmp); - pkg_free(text); - pkg_free(index); - pkg_free(tmp); - pkg_free(index_path); - pkg_free(source_path); - pkg_error("unable to resolve repository source path: %d", ret); + pkg_error("unable to resolve local index path: %d", ret); goto out; } - ret = pkg_store_write_text_atomic(source_path, source); + ret = pkg_store_write_text_atomic(index_path, text); if (ret < 0) { - pkg_store_remove_file(tmp); - pkg_free(text); - pkg_free(index); - pkg_free(tmp); - pkg_free(index_path); - pkg_free(source_path); - pkg_error("unable to save repository source: %d", ret); + pkg_error("unable to write local index: %d", ret); goto out; } - pkg_store_remove_file(tmp); - pkg_free(text); - pkg_free(index); - pkg_free(tmp); - pkg_free(index_path); - pkg_free(source_path); pkg_info("synced package index from %s", source); ret = 0; out: - if (lock_held) + if (remove_tmp) { - unlink(lock); + pkg_store_remove_file(tmp); } + pkg_free(text); + pkg_free(index); + pkg_free(tmp); + pkg_free(index_path); + unlink(lock); pkg_free(lock); return ret; } diff --git a/system/nxpkg/pkg_store.c b/system/nxpkg/pkg_store.c index c5ad84bcb0f..562c0e3df30 100644 --- a/system/nxpkg/pkg_store.c +++ b/system/nxpkg/pkg_store.c @@ -518,10 +518,12 @@ int pkg_store_write_text_atomic(FAR const char *path, FAR const char *text) return ret; } - if (close(fd) < 0) + ret = close(fd); + if (ret < 0) { + ret = -errno; unlink(path); - return -errno; + return ret; } return 0; @@ -559,23 +561,29 @@ int pkg_store_write_text_atomic(FAR const char *path, FAR const char *text) * file, corrupting one or both. */ - if (fsync(fd) < 0) + ret = fsync(fd); + if (ret < 0) { + ret = -errno; close(fd); unlink(tmp); - return -errno; + return ret; } - if (close(fd) < 0) + ret = close(fd); + if (ret < 0) { + ret = -errno; unlink(tmp); - return -errno; + return ret; } - if (rename(tmp, path) < 0) + ret = rename(tmp, path); + if (ret < 0) { + ret = -errno; unlink(tmp); - return -errno; + return ret; } return 0; @@ -663,10 +671,12 @@ int pkg_store_copy_file(FAR const char *src, FAR const char *dest) } #endif - if (close(outfd) < 0) + ret = close(outfd); + if (ret < 0) { + ret = -errno; unlink(outpath); - return -errno; + return ret; } #ifndef CONFIG_PSEUDOFS_FILE From f363a8f4448c15a8b7bb5ff9f5f65be562dbf786 Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Thu, 23 Jul 2026 23:08:07 +0530 Subject: [PATCH 05/15] system/nxpkg: Protect live locks from filesystem clock skew. Record a per-boot token and owner PID in every package, installed-database, and synchronization lock. A contender now keeps a lock held while its recorded owner task is alive, regardless of unreliable FAT modification timestamps. Reclaim locks from exited owners or earlier boots immediately, while retaining timestamp-based migration handling for legacy empty lock files. This prevents a just-created live lock from being mistaken for a decades-old stale file on targets whose mounted filesystem clock does not match CLOCK_REALTIME. Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: aviralgarg05 --- system/nxpkg/pkg.h | 1 + system/nxpkg/pkg_install.c | 63 ++---------- system/nxpkg/pkg_repo.c | 10 +- system/nxpkg/pkg_store.c | 191 +++++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 61 deletions(-) diff --git a/system/nxpkg/pkg.h b/system/nxpkg/pkg.h index 28c8c19a805..8c1f6850a51 100644 --- a/system/nxpkg/pkg.h +++ b/system/nxpkg/pkg.h @@ -261,6 +261,7 @@ int pkg_resolve_icon_source(FAR char *buffer, size_t size, FAR const struct pkg_manifest_s *manifest); int pkg_acquire_source(FAR const char *source, FAR const char *dest, FAR const char *renew_lock_path); +int pkg_lock_create(FAR const char *path); void pkg_reclaim_stale_lock(FAR const char *path); int pkg_sync(FAR const char *source); int pkg_install(FAR const char *name); diff --git a/system/nxpkg/pkg_install.c b/system/nxpkg/pkg_install.c index cef908c5106..7b326cfe836 100644 --- a/system/nxpkg/pkg_install.c +++ b/system/nxpkg/pkg_install.c @@ -25,13 +25,11 @@ ****************************************************************************/ #include -#include #include #include #include #include #include -#include #include #include "pkg.h" @@ -43,7 +41,6 @@ static int pkg_install_acquire_lock(FAR const char *name, FAR char *path, size_t size) { - int fd; int ret; ret = pkg_store_ensure_package_root(name); @@ -58,20 +55,14 @@ static int pkg_install_acquire_lock(FAR const char *name, FAR char *path, return ret; } - fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); - if (fd < 0 && errno == EEXIST) + ret = pkg_lock_create(path); + if (ret == -EEXIST) { pkg_reclaim_stale_lock(path); - fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); + ret = pkg_lock_create(path); } - if (fd < 0) - { - return errno == EEXIST ? -EBUSY : -errno; - } - - close(fd); - return 0; + return ret == -EEXIST ? -EBUSY : ret; } /**************************************************************************** @@ -85,7 +76,6 @@ static int pkg_install_acquire_lock(FAR const char *name, FAR char *path, static int pkg_install_acquire_installed_lock(FAR char *path, size_t size) { - int fd; int ret; int tries; @@ -102,16 +92,15 @@ static int pkg_install_acquire_installed_lock(FAR char *path, size_t size) for (tries = 0; tries < 100; tries++) { - fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); - if (fd >= 0) + ret = pkg_lock_create(path); + if (ret == 0) { - close(fd); return 0; } - if (errno != EEXIST) + if (ret != -EEXIST) { - return -errno; + return ret; } pkg_reclaim_stale_lock(path); @@ -337,42 +326,6 @@ static int pkg_install_write_pointers( * Public Functions ****************************************************************************/ -/**************************************************************************** - * Name: pkg_reclaim_stale_lock - * - * Description: - * Remove "path" if it is old enough that it cannot belong to a still- - * running holder (see PKG_LOCK_STALE_SECONDS). Best-effort: any - * stat()/unlink() failure just falls through to the normal -EBUSY - * result, since a lock we can't inspect should be treated as held. - * Generic across every lock file this package uses (per-package - * install locks, the shared installed-db lock, pkg_repo.c's sync - * lock) - not install-specific despite living in this file. - * - ****************************************************************************/ - -void pkg_reclaim_stale_lock(FAR const char *path) -{ - struct stat st; - time_t now; - - if (stat(path, &st) < 0) - { - return; - } - - now = time(NULL); - if (now < st.st_mtime || - (now - st.st_mtime) < PKG_LOCK_STALE_SECONDS) - { - return; - } - - pkg_error("reclaiming stale lock '%s' (age %ld s)", - path, (long)(now - st.st_mtime)); - unlink(path); -} - int pkg_install(FAR const char *name) { FAR struct pkg_index_s *index; diff --git a/system/nxpkg/pkg_repo.c b/system/nxpkg/pkg_repo.c index 3cd201e8e02..2a4e0ef2775 100644 --- a/system/nxpkg/pkg_repo.c +++ b/system/nxpkg/pkg_repo.c @@ -544,7 +544,6 @@ int pkg_acquire_source(FAR const char *source, FAR const char *dest, static int pkg_repo_acquire_sync_lock(FAR char *path, size_t size) { - int fd; int ret; int tries; @@ -561,16 +560,15 @@ static int pkg_repo_acquire_sync_lock(FAR char *path, size_t size) for (tries = 0; tries < 100; tries++) { - fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); - if (fd >= 0) + ret = pkg_lock_create(path); + if (ret == 0) { - close(fd); return 0; } - if (errno != EEXIST) + if (ret != -EEXIST) { - return -errno; + return ret; } pkg_reclaim_stale_lock(path); diff --git a/system/nxpkg/pkg_store.c b/system/nxpkg/pkg_store.c index 562c0e3df30..c73b7b5831e 100644 --- a/system/nxpkg/pkg_store.c +++ b/system/nxpkg/pkg_store.c @@ -27,6 +27,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -36,10 +40,84 @@ #include "pkg.h" +/**************************************************************************** + * Pre-processor Definitions + ****************************************************************************/ + +#define PKG_LOCK_RECORD_MAGIC "NXPKG1" +#define PKG_LOCK_RECORD_SIZE 64 + +/**************************************************************************** + * Private Data + ****************************************************************************/ + +static pthread_once_t g_pkg_lock_boot_once = PTHREAD_ONCE_INIT; +static uint64_t g_pkg_lock_boot_id; + /**************************************************************************** * Private Functions ****************************************************************************/ +static void pkg_lock_init_boot_id(void) +{ + arc4random_buf(&g_pkg_lock_boot_id, sizeof(g_pkg_lock_boot_id)); + if (g_pkg_lock_boot_id == 0) + { + g_pkg_lock_boot_id = 1; + } +} + +static uint64_t pkg_lock_get_boot_id(void) +{ + if (pthread_once(&g_pkg_lock_boot_once, pkg_lock_init_boot_id) != 0) + { + return 1; + } + + return g_pkg_lock_boot_id; +} + +static int pkg_lock_read_owner(FAR const char *path, + FAR uint64_t *boot_id, + FAR pid_t *owner) +{ + char record[PKG_LOCK_RECORD_SIZE]; + unsigned long long parsed_boot; + long parsed_owner; + ssize_t nread; + int fd; + int ret; + + fd = open(path, O_RDONLY); + if (fd < 0) + { + return -errno; + } + + nread = read(fd, record, sizeof(record) - 1); + if (nread < 0) + { + ret = -errno; + close(fd); + return ret; + } + + close(fd); + record[nread] = '\0'; + + ret = sscanf(record, PKG_LOCK_RECORD_MAGIC " %llx %ld", + &parsed_boot, &parsed_owner); + if (ret != 2 || parsed_owner <= 0 || + (long)(pid_t)parsed_owner != parsed_owner) + { + return -EINVAL; + } + + *boot_id = (uint64_t)parsed_boot; + *owner = (pid_t)parsed_owner; + return 0; +} + static int pkg_store_format(FAR char *buffer, size_t size, FAR const char *fmt, FAR const char *name, @@ -194,6 +272,119 @@ int pkg_store_prepare_layout(void) return pkg_store_mkdirs(PKG_TMP_PKG_DIR); } +int pkg_lock_create(FAR const char *path) +{ + char record[PKG_LOCK_RECORD_SIZE]; + int fd; + int ret; + + fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); + if (fd < 0) + { + return -errno; + } + + ret = snprintf(record, sizeof(record), PKG_LOCK_RECORD_MAGIC + " %016" PRIx64 " %ld\n", + pkg_lock_get_boot_id(), (long)getpid()); + if (ret < 0 || (size_t)ret >= sizeof(record)) + { + ret = ret < 0 ? ret : -ENAMETOOLONG; + goto errout; + } + + ret = pkg_store_write_all(fd, record, (size_t)ret); + if (ret < 0) + { + goto errout; + } + + if (fsync(fd) < 0) + { + ret = -errno; + goto errout; + } + + if (close(fd) < 0) + { + ret = -errno; + unlink(path); + return ret; + } + + return 0; + +errout: + close(fd); + unlink(path); + return ret; +} + +void pkg_reclaim_stale_lock(FAR const char *path) +{ + struct stat st; + uint64_t boot_id; + pid_t owner; + time_t now; + int ret; + + ret = pkg_lock_read_owner(path, &boot_id, &owner); + if (ret == -EINVAL) + { + /* A creator may have completed open(O_EXCL) but not its first write. + * Give that very small window time to close before treating the file + * as a legacy timestamp-only lock. + */ + + usleep(20 * 1000); + ret = pkg_lock_read_owner(path, &boot_id, &owner); + } + + if (ret == 0) + { + if (boot_id != pkg_lock_get_boot_id()) + { + pkg_error("reclaiming lock from an earlier boot '%s'", path); + unlink(path); + return; + } + + if (kill(owner, 0) == 0 || errno == EPERM) + { + return; + } + + if (errno == ESRCH) + { + pkg_error("reclaiming lock from exited task %ld '%s'", + (long)owner, path); + unlink(path); + } + + return; + } + + /* Compatibility for empty lock files created by older nxpkg images. + * Their only ownership information is the filesystem timestamp. + */ + + if (stat(path, &st) < 0) + { + return; + } + + now = time(NULL); + if (now < st.st_mtime || + (now - st.st_mtime) < PKG_LOCK_STALE_SECONDS) + { + return; + } + + pkg_error("reclaiming legacy stale lock '%s' (age %ld s)", + path, (long)(now - st.st_mtime)); + unlink(path); +} + int pkg_store_ensure_package_root(FAR const char *name) { char path[PATH_MAX]; From 778bc0e64c9500caed94a47a3e74b4b47161f1bd Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Sat, 11 Jul 2026 19:40:21 +0530 Subject: [PATCH 06/15] system/nxstore: add LVGL app-store UI. Add nxstore, an LVGL-based frontend for nxpkg (system/nxpkg): lists packages from the local index, installs/launches the selected one, and supervises whatever it hands the screen to. Card-based app list (populate_app_list()): each row shows name, version, and description/status, install/launch state reflected via icon glyph and color (LV_SYMBOL_DOWNLOAD/PLAY, accent/success color), and a sliding-segment progress bar during install (no real byte-level progress is available from pkg_install(), so this reads as 'actively working' without fabricating a percentage). Explicit LV_STATE_PRESSED styling on every tappable row/button, since no LVGL theme is loaded and a tap would otherwise give no visual feedback at all. Supervisor screen (build_run_screen()/nxstore_enter_running_screen()): a launched app (e.g. a game that owns /dev/fb0 directly, not just another LVGL client) gets a dedicated screen with a name label and a Close button, confined to the border region the launched app's own scaled/centered framebuffer output never draws into, so switching back to it doesn't fight over pixels with whatever the app already put in the framebuffer. Close/reap handling (close_running_app_event_cb()/ nxstore_poll_running_app()): sends SIGTERM and polls waitpid(WNOHANG) for the launched pid, but explicitly also treats waitpid() returning ECHILD as 'already gone' rather than 'still running' - both the close-button handler and the passive per-loop poll independently race to reap the same child, so whichever one loses that race must not spin forever waiting for a wait() that can now never succeed. Requires the target app to install its own SIGTERM handler to exit cleanly (this is why there is no generic force-kill fallback here: an earlier version of this code called task_delete() when SIGTERM wasn't reaped quickly enough, which was found on real hardware to hang the entire board - not just the one task - when it landed mid framebuffer/heap access on this flat-memory build). Toast notifications (nxstore_toast()) provide a transient, unmissable confirmation for install/uninstall/launch outcomes and app-closed events, additive to the durable per-row subtitle text rather than a replacement for it. nxstore's own boot-time catalog sync waits (bounded, 15s) on g_wifi_dhcp_ret before attempting a network fetch, since Wi-Fi association completing doesn't imply DHCP has - an HTTP fetch attempted in that window fails with -ENETUNREACH even though the link itself is already up, indistinguishable from being genuinely offline without this wait. Assisted-by: Claude:claude-sonnet-5 Signed-off-by: aviralgarg05 --- system/nxstore/CMakeLists.txt | 35 + system/nxstore/Kconfig | 30 + system/nxstore/Make.defs | 25 + system/nxstore/Makefile | 32 + system/nxstore/README.txt | 100 ++ system/nxstore/nxstore_main.c | 1683 +++++++++++++++++++++++++++++++++ 6 files changed, 1905 insertions(+) create mode 100644 system/nxstore/CMakeLists.txt create mode 100644 system/nxstore/Kconfig create mode 100644 system/nxstore/Make.defs create mode 100644 system/nxstore/Makefile create mode 100644 system/nxstore/README.txt create mode 100644 system/nxstore/nxstore_main.c diff --git a/system/nxstore/CMakeLists.txt b/system/nxstore/CMakeLists.txt new file mode 100644 index 00000000000..38df90b3152 --- /dev/null +++ b/system/nxstore/CMakeLists.txt @@ -0,0 +1,35 @@ +# ############################################################################## +# apps/system/nxstore/CMakeLists.txt +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more contributor +# license agreements. See the NOTICE file distributed with this work for +# additional information regarding copyright ownership. The ASF licenses this +# file to you under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +# +# ############################################################################## + +if(CONFIG_SYSTEM_NXSTORE) + nuttx_add_application( + NAME + ${CONFIG_SYSTEM_NXSTORE_PROGNAME} + PRIORITY + ${CONFIG_SYSTEM_NXSTORE_PRIORITY} + STACKSIZE + ${CONFIG_SYSTEM_NXSTORE_STACKSIZE} + MODULE + ${CONFIG_SYSTEM_NXSTORE} + SRCS + nxstore_main.c) +endif() diff --git a/system/nxstore/Kconfig b/system/nxstore/Kconfig new file mode 100644 index 00000000000..a4cf381a19d --- /dev/null +++ b/system/nxstore/Kconfig @@ -0,0 +1,30 @@ +# +# For a description of the syntax of this configuration file, +# see the file kconfig-language.txt in the NuttX tools repository. +# + +config SYSTEM_NXSTORE + tristate "Nxstore LVGL App Store" + default n + depends on GRAPHICS_LVGL && SYSTEM_NXPKG + select NETUTILS_CJSON + ---help--- + Enable the Nxstore LVGL graphical application store frontend. + Lists packages from the local nxpkg repository, installs the + selected one via nxpkg, and launches it. + +if SYSTEM_NXSTORE + +config SYSTEM_NXSTORE_PROGNAME + string "Program name" + default "nxstore" + +config SYSTEM_NXSTORE_PRIORITY + int "nxstore task priority" + default 100 + +config SYSTEM_NXSTORE_STACKSIZE + int "nxstore stack size" + default 8192 + +endif diff --git a/system/nxstore/Make.defs b/system/nxstore/Make.defs new file mode 100644 index 00000000000..8ebe0c3028d --- /dev/null +++ b/system/nxstore/Make.defs @@ -0,0 +1,25 @@ +############################################################################ +# apps/system/nxstore/Make.defs +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +ifneq ($(CONFIG_SYSTEM_NXSTORE),) +CONFIGURED_APPS += $(APPDIR)/system/nxstore +endif diff --git a/system/nxstore/Makefile b/system/nxstore/Makefile new file mode 100644 index 00000000000..36f4330c1b9 --- /dev/null +++ b/system/nxstore/Makefile @@ -0,0 +1,32 @@ +############################################################################ +# apps/system/nxstore/Makefile +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +include $(APPDIR)/Make.defs + +PROGNAME = $(CONFIG_SYSTEM_NXSTORE_PROGNAME) +PRIORITY = $(CONFIG_SYSTEM_NXSTORE_PRIORITY) +STACKSIZE = $(CONFIG_SYSTEM_NXSTORE_STACKSIZE) +MODULE = $(CONFIG_SYSTEM_NXSTORE) + +MAINSRC = nxstore_main.c + +include $(APPDIR)/Application.mk diff --git a/system/nxstore/README.txt b/system/nxstore/README.txt new file mode 100644 index 00000000000..4d9811617c5 --- /dev/null +++ b/system/nxstore/README.txt @@ -0,0 +1,100 @@ +nxstore - an LVGL touchscreen app store for NuttX +================================================= + + nxstore is a graphical front-end for system/nxpkg. It lists the + packages available in a nxpkg repository, installs the one you tap via + nxpkg, launches it, and supervises it with an on-screen "Close" + control. It is the touchscreen equivalent of driving the nxpkg CLI by + hand; both consume the same repository, so the repository/server setup + (layout, how to serve it, populating it with tools/export_pkg_repo.py, + pointing the board at it) is documented once, in system/nxpkg's + README.txt, and is not repeated here. + + +Dependencies +============ + + Kconfig: SYSTEM_NXSTORE depends on GRAPHICS_LVGL and SYSTEM_NXPKG and + selects NETUTILS_CJSON. It needs a working framebuffer (/dev/fb0) and + a touch input device (/dev/input0) - the same graphics/input stack + examples/lvgldemo uses. NETUTILS_CJSON is used to parse the + repository index and package manifests. + + +On-device lifecycle +=================== + + 1. Browse - the app list is built from the repository index nxpkg + fetched. Installed packages show a launch action; + not-yet-installed ones show an install action. + 2. Install - tapping an uninstalled package runs the nxpkg install + flow (download -> sha256 verify -> transactional + install) on a background worker thread, with progress + shown as a toast. + 3. Launch - tapping an installed package spawns its payload + (posix_spawn) and switches to the "running" screen: a + thin bar across the top NXSTORE_BAR_HEIGHT pixels (see + include/system/nxstore_chrome.h) carrying the app name + and a Close button, with the rest of the framebuffer + left to the running app. + 4. Close - see "Closing a running app" below. + + Launched apps are given NXSTORE_LAUNCH_STACKSIZE (32 KiB) of stack - + posix_spawn's default (CONFIG_POSIX_SPAWN_DEFAULT_STACKSIZE, 2 KiB) is + far too small for a real LVGL/framebuffer app, and overflowing it does + not fail loudly (it corrupts adjacent heap, surfacing later as a crash + nowhere near the real cause). + + +Closing a running app: the cooperative-SIGTERM contract +======================================================= + + This is the one thing an app author MUST get right to be launchable + from nxstore. + + The Close button sends the running app SIGTERM and waits for it to + reap itself. It does NOT (and cannot safely) force-kill the task: + + - On a build with CONFIG_SIG_DEFAULT disabled (the default), NuttX + has no default action for SIGTERM at all. Delivery does nothing + unless the app installs its own handler with sigaction(). + - An earlier nxstore fell back to task_delete() when SIGTERM went + unreaped. On real hardware that force-kill landed while the app + was mid framebuffer/heap access and hung the *entire board* (not + just the task), requiring a physical power cycle. That fallback + was removed: there is no safe way to force-terminate an arbitrary + task from the outside here. + + So an nxstore-launchable app must cooperate: + + - Install an async-signal-safe SIGTERM handler that only sets a + volatile sig_atomic_t flag. + - Poll that flag from its own main loop and exit cleanly (freeing + the framebuffer, restoring input state, etc.) when it is set. + + Apps whose main loop can never block indefinitely (they run to + completion on their own, or fail fast at startup) do not strictly need + a handler, but any app that renders in a long-lived loop does. See + these in-tree examples for the exact pattern: + + - games/NXDoom (i_install_quit_signal / i_poll_quit_signal) + - examples/calculator + - games/cgol, games/brickmatch + + If SIGTERM is not reaped within the timeout, nxstore keeps the running + screen up rather than returning to the app list - switching back while + the app might still be alive and drawing into the framebuffer would + just reproduce the original hang, hidden. + + +Configuration +============= + + SYSTEM_NXSTORE_PROGNAME program/registration name (default nxstore) + SYSTEM_NXSTORE_PRIORITY task priority (default 100) + SYSTEM_NXSTORE_STACKSIZE nxstore's own stack (default 8192) + + NXSTORE_LAUNCH_STACKSIZE (the stack given to launched apps) and + NXSTORE_BAR_HEIGHT (the reserved top bar) are compile-time constants in + the source / include/system/nxstore_chrome.h rather than Kconfig + options. diff --git a/system/nxstore/nxstore_main.c b/system/nxstore/nxstore_main.c new file mode 100644 index 00000000000..6008b3eba3d --- /dev/null +++ b/system/nxstore/nxstore_main.c @@ -0,0 +1,1683 @@ +/**************************************************************************** + * apps/system/nxstore/nxstore_main.c + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../nxpkg/pkg.h" + +/**************************************************************************** + * Pre-processor Definitions + ****************************************************************************/ + +/* No cancellation mechanism exists here (forcibly killing a thread mid + * SD-card write risks corrupting more state than it fixes), so a hang + * can't be recovered from automatically. This threshold only controls + * when the UI starts telling the user something is stuck, rather than + * showing a spinner that just silently never stops. + * + * A legitimately large package (e.g. a game WAD) over a real Wi-Fi + * link has been observed taking up to ~90s - the previous 120s + * threshold meant that install could finish successfully without the + * UI ever having reassured the user it hadn't hung. 60s comfortably + * covers normal large installs while still catching a genuine stall + * well before someone gives up and walks away. + */ + +#define NXSTORE_INSTALL_WARN_SECONDS (60) + +/* Color palette, named rather than inlined as hex literals throughout, + * so the whole screen reads as one consistent visual system instead of + * ad hoc per-widget colors. + */ + +#define NXSTORE_COLOR_BG 0x0b0d10 /* Screen background */ +#define NXSTORE_COLOR_HEADER_BG 0x14171c /* Header bar surface */ +#define NXSTORE_COLOR_HEADER_LINE 0x22262e /* Header bottom divider */ +#define NXSTORE_COLOR_CARD_BG 0x1b1f26 /* Row card surface */ +#define NXSTORE_COLOR_CARD_BORDER 0x282d36 /* Row card border */ +#define NXSTORE_COLOR_TEXT 0xf2f4f7 /* Primary text */ +#define NXSTORE_COLOR_TEXT_MUTED 0x99a1ad /* Secondary/meta text */ +#define NXSTORE_COLOR_ACCENT 0x3d8bff /* "Install" affordance */ +#define NXSTORE_COLOR_SUCCESS 0x34c77b /* Installed / launch */ +#define NXSTORE_COLOR_WARNING 0xf5a623 /* In progress / slow */ +#define NXSTORE_COLOR_ERROR 0xf0554c /* Failed */ + +/**************************************************************************** + * Private Types + ****************************************************************************/ + +enum install_state_e +{ + INSTALL_STATE_IDLE = 0, + INSTALL_STATE_INSTALLING, + INSTALL_STATE_LAUNCHING, + INSTALL_STATE_DONE_OK, + INSTALL_STATE_INSTALL_FAILED, + INSTALL_STATE_LAUNCH_FAILED, +}; + +/* Tracks the one install/launch operation nxstore allows at a time. The + * worker thread only ever writes `state` - `_Atomic` (rather than a bare + * `volatile int`) gives that a real cross-thread happens-before guarantee + * instead of relying on "an int-sized store happens to be atomic on this + * target" as an unenforced assumption. Every LVGL object touch happens + * back on the main thread out of the polling loop in main(), since LVGL + * itself is not thread-safe. + */ + +struct install_ctx_s +{ + FAR const struct pkg_manifest_s *manifest; + lv_obj_t *btn; + lv_obj_t *label; + lv_obj_t *progress_bar; + char orig_text[192]; + _Atomic int state; + _Atomic int install_error; + pthread_t thread; + bool joinable; + time_t start_time; + bool warned_slow; + + /* Written by install_worker() (background thread) once nxstore_launch() + * returns, read by nxstore_poll_active_install() (main/LVGL thread) once + * it observes INSTALL_STATE_DONE_OK - safe without extra synchronization + * because `state`'s own _Atomic store/load already establishes the + * happens-before ordering between the two. + */ + + pid_t launched_pid; +}; + +/* Tracks the single external process nxstore has handed the screen to + * (e.g. nxdoom). Only ever touched from the main/LVGL thread - see + * nxstore_enter_running_screen(). + */ + +struct running_app_s +{ + pid_t pid; + char name[64]; + bool active; +}; + +/**************************************************************************** + * Private Data + ****************************************************************************/ + +static lv_obj_t *g_list; +static struct pkg_index_s g_index; +static struct install_ctx_s g_active; + +/* g_main_scr is the normal app-list screen (built once in + * build_app_store_ui()); g_run_scr is the supervisor screen shown while an + * external app owns the framebuffer directly - see build_run_screen(). + */ + +static lv_obj_t *g_main_scr; +static lv_obj_t *g_run_scr; +static lv_obj_t *g_run_label; +static struct running_app_s g_running; + +/**************************************************************************** + * Private Functions + ****************************************************************************/ + +static void nxstore_toast(bool is_error, FAR const char *fmt, ...); +static lv_obj_t *nxstore_progress_bar_start(lv_obj_t *card); +static void nxstore_progress_bar_stop(lv_obj_t *bar); +static void nxstore_enter_running_screen(FAR const char *name, pid_t pid); +static void close_running_app_event_cb(lv_event_t *e); +static void nxstore_poll_running_app(void); +static void build_run_screen(void); + +/**************************************************************************** + * Name: nxstore_install_error_str + * + * Description: + * Translate a pkg_install() failure code into a message a user can act + * on, instead of one generic "install failed" string for every cause + * (network down, bad checksum, wrong board, out of space, ...). + * + ****************************************************************************/ + +static FAR const char *nxstore_install_error_str(int err) +{ + switch (err) + { + case -EILSEQ: + return "checksum mismatch"; + + case -ENOEXEC: + return "wrong architecture for this device"; + + case -EXDEV: + return "not built for this board"; + + case -ENETUNREACH: + case -ENETDOWN: + case -ETIMEDOUT: + case -ECONNREFUSED: + case -EHOSTUNREACH: + case -EPROTO: + return "network error"; + + case -ENOSPC: + return "not enough storage space"; + + case -EFBIG: + return "download too large"; + + case -EBUSY: + return "another install is already in progress for this package"; + + case -EINVAL: + return "invalid or untrusted package data"; + + default: + return "install failed"; + } +} + +/**************************************************************************** + * Name: nxstore_toast + * + * Description: + * Transient bottom-aligned status banner, auto-dismissed after a couple + * seconds - a stronger, momentary "this just happened" signal than the + * durable per-row subtitle text, for actions (install/uninstall/launch + * failures) that benefit from an unmissable confirmation that a tap was + * registered and acted on. + * + ****************************************************************************/ + +static void nxstore_toast(bool is_error, FAR const char *fmt, ...) +{ + lv_obj_t *label; + char text[192]; + va_list ap; + + va_start(ap, fmt); + vsnprintf(text, sizeof(text), fmt, ap); + va_end(ap); + + label = lv_label_create(lv_screen_active()); + lv_label_set_text(label, text); + lv_obj_add_flag(label, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(label, lv_color_hex(0xffffff), 0); + lv_obj_set_style_bg_color(label, + lv_color_hex(is_error ? NXSTORE_COLOR_ERROR + : NXSTORE_COLOR_CARD_BG), + 0); + lv_obj_set_style_bg_opa(label, LV_OPA_90, 0); + lv_obj_set_style_radius(label, 10, 0); + lv_obj_set_style_pad_hor(label, 14, 0); + lv_obj_set_style_pad_ver(label, 8, 0); + lv_obj_set_style_border_width(label, 0, 0); + lv_obj_align(label, LV_ALIGN_BOTTOM_MID, 0, -14); + lv_obj_move_foreground(label); + + lv_obj_delete_delayed(label, 2500); +} + +/**************************************************************************** + * Name: nxstore_progress_bar_anim_cb + ****************************************************************************/ + +static void nxstore_progress_bar_anim_cb(void *var, int32_t v) +{ + lv_obj_t *bar = var; + int32_t end = v + 30 > 100 ? 100 : v + 30; + + lv_bar_set_start_value(bar, v, LV_ANIM_OFF); + lv_bar_set_value(bar, end, LV_ANIM_OFF); +} + +/**************************************************************************** + * Name: nxstore_progress_bar_start + * + * Description: + * pkg_install() has no byte-level progress callback, so real percentage + * progress isn't available - a sliding-segment bar (the standard + * hand-rolled "indeterminate" pattern, since LVGL's lv_bar has no built + * in indeterminate mode) reads far more clearly as "actively working" + * than the small corner spinner it replaces, without fabricating a fake + * percentage. Placed under the card's subtitle rather than over the + * chevron, so (unlike the spinner it replaces) it doesn't need to hide + * any other row content to make room for itself. + * + ****************************************************************************/ + +static lv_obj_t *nxstore_progress_bar_start(lv_obj_t *card) +{ + lv_obj_t *text_col = lv_obj_get_child(card, 1); + lv_obj_t *bar; + lv_anim_t a; + + if (text_col == NULL) + { + return NULL; + } + + bar = lv_bar_create(text_col); + lv_obj_set_size(bar, lv_pct(100), 5); + lv_obj_set_style_radius(bar, 3, LV_PART_MAIN); + lv_obj_set_style_radius(bar, 3, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(bar, lv_color_hex(NXSTORE_COLOR_CARD_BORDER), + LV_PART_MAIN); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_bg_color(bar, lv_color_hex(NXSTORE_COLOR_ACCENT), + LV_PART_INDICATOR); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_clear_flag(bar, LV_OBJ_FLAG_CLICKABLE); + lv_bar_set_mode(bar, LV_BAR_MODE_RANGE); + lv_bar_set_range(bar, 0, 100); + + lv_anim_init(&a); + lv_anim_set_var(&a, bar); + lv_anim_set_exec_cb(&a, nxstore_progress_bar_anim_cb); + lv_anim_set_values(&a, 0, 70); + lv_anim_set_time(&a, 900); + lv_anim_set_playback_time(&a, 900); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); + + return bar; +} + +/**************************************************************************** + * Name: nxstore_progress_bar_stop + ****************************************************************************/ + +static void nxstore_progress_bar_stop(lv_obj_t *bar) +{ + if (bar == NULL) + { + return; + } + + lv_anim_delete(bar, nxstore_progress_bar_anim_cb); + lv_obj_del(bar); +} + +/**************************************************************************** + * Name: nxstore_enter_running_screen + * + * Description: + * Switches to the supervisor screen and records the spawned child so + * nxstore_poll_running_app()/close_running_app_event_cb() can reap or + * force-close it. Must only be called from the LVGL/main thread - + * lv_screen_load() is not thread-safe. + * + ****************************************************************************/ + +static void nxstore_enter_running_screen(FAR const char *name, pid_t pid) +{ + g_running.pid = pid; + g_running.active = true; + snprintf(g_running.name, sizeof(g_running.name), "%s", name); + + lv_label_set_text(g_run_label, g_running.name); + lv_screen_load(g_run_scr); +} + +/**************************************************************************** + * Name: close_running_app_event_cb + * + * Description: + * Sends SIGTERM and waits for the app to reap itself. This board's + * build has CONFIG_SIG_DEFAULT disabled (sched/Kconfig, off by + * default), so NuttX has *no* default action registered for SIGTERM + * (see the CONFIG_SIG_SIGKILL_ACTION-gated table in + * sched/signal/sig_default.c) - delivery only does anything for an app + * that explicitly installs its own handler via sigaction(), which + * NXDoom now does (apps/games/NXDoom/src/i_system.c, + * i_install_quit_signal()/i_poll_quit_signal()). + * + * An earlier version of this function fell back to task_delete() + * (NuttX's forced-termination API) when SIGTERM didn't get a reap + * quickly enough. On real hardware that force-kill landed while + * NXDoom was mid framebuffer/heap access and hung the *entire board*, + * not just the one task - confirmed by the board going completely + * silent on the serial console, requiring a physical power cycle to + * recover. That fallback has been removed entirely: there is no safe + * way to force-terminate an arbitrary task from the outside on this + * system, so an app can only be closed if it cooperates. If SIGTERM + * doesn't get reaped within the timeout, this leaves the running + * screen up rather than pretending success - switching back to the + * app list while the app might secretly still be alive and drawing + * into the framebuffer underneath it would just reproduce the original + * header-bleed-through bug. + * + ****************************************************************************/ + +static void close_running_app_event_cb(lv_event_t *e) +{ + char name[64]; + int tries; + int status; + pid_t wret; + bool reaped = false; + bool gone = false; + + UNUSED(e); + + syslog(LOG_WARNING, "nxstore: close cb fired, active=%d pid=%d\n", + g_running.active, (int)g_running.pid); + + if (!g_running.active) + { + return; + } + + lv_label_set_text(g_run_label, "Closing..."); + lv_timer_handler(); + + snprintf(name, sizeof(name), "%s", g_running.name); + + if (kill(g_running.pid, SIGTERM) < 0 && errno == ESRCH) + { + /* Nothing to signal - the child is already gone (e.g. reaped by + * nxstore_poll_running_app()'s own waitpid() between this tap + * landing and this handler running). Fall straight through to + * cleanup instead of sending a signal to a stale pid and then + * looping on a waitpid() that can now never match. + */ + + syslog(LOG_WARNING, "nxstore: close pid %d already gone (ESRCH)\n", + (int)g_running.pid); + gone = true; + } + else + { + syslog(LOG_WARNING, "nxstore: close SIGTERM sent to %d\n", + (int)g_running.pid); + } + + for (tries = 0; tries < 40 && !reaped && !gone; tries++) + { + wret = waitpid(g_running.pid, &status, WNOHANG); + if (wret == g_running.pid) + { + reaped = true; + } + else if (wret < 0 && errno == ECHILD) + { + /* No such child left to wait for - it already exited and was + * reaped by someone else (again, most likely + * nxstore_poll_running_app()'s own concurrent waitpid()). + * This is not "still running, keep polling": there is + * nothing left to reap, ever, so stop and clean up now + * rather than spinning for the rest of the tries and leaving + * the user stuck on "Still closing" for an app that is + * already gone. + */ + + gone = true; + } + else + { + usleep(50 * 1000); + } + } + + syslog(LOG_WARNING, + "nxstore: close reaped=%d gone=%d after %d tries\n", + reaped, gone, tries); + + if (!reaped && !gone) + { + lv_label_set_text(g_run_label, "Still closing - try again"); + return; + } + + g_running.active = false; + lv_screen_load(g_main_scr); + + /* NXDoom just left pixels directly in /dev/fb0 that no LVGL object on + * the app-list screen naturally overlaps (its own header/list content + * doesn't cover the same area doom's viewport did) - lv_screen_load() + * marks the screen dirty, but that only actually reaches the physical + * framebuffer on LVGL's own schedule. Force it to flush right now + * instead of trusting a later lv_timer_handler() call gets to it + * before something else (a toast, another tap) does. + */ + + lv_refr_now(NULL); + + nxstore_toast(false, "%s closed", name); +} + +/**************************************************************************** + * Name: nxstore_poll_running_app + * + * Description: + * Called from the main LVGL loop. Catches an app that exits on its own + * (a plain CLI demo that just runs and returns, as opposed to nxdoom + * which has to be force-closed via close_running_app_event_cb) so the + * screen returns to the app list automatically instead of being left + * showing a dead app's last frame with no way back short of the Close + * button. + * + ****************************************************************************/ + +static void nxstore_poll_running_app(void) +{ + int status; + pid_t wret; + + if (!g_running.active) + { + return; + } + + wret = waitpid(g_running.pid, &status, WNOHANG); + if (wret == g_running.pid || (wret < 0 && errno == ECHILD)) + { + char name[64]; + + snprintf(name, sizeof(name), "%s", g_running.name); + g_running.active = false; + lv_screen_load(g_main_scr); + lv_refr_now(NULL); + nxstore_toast(false, "%s closed", name); + } +} + +/**************************************************************************** + * Name: build_run_screen + * + * Description: + * Builds the supervisor screen shown while an external app (nxdoom, + * etc.) owns /dev/fb0 directly. Confined to the top 36px, which is + * inside the border NXDoom's centered/scaled viewport never writes to + * (800x480 screen, 320x200 game buffer scaled x2 = 640x400, leaving an + * 80px left/right and 40px top/bottom margin) - so this bar coexists + * with whatever the child process draws into the rest of the frame + * buffer without either side clobbering the other. Built once and + * reused rather than rebuilt per launch, since nothing about it changes + * other than the label text. + * + ****************************************************************************/ + +static void build_run_screen(void) +{ + lv_obj_t *bar; + lv_obj_t *close_btn; + lv_obj_t *close_label; + + g_run_scr = lv_obj_create(NULL); + lv_obj_set_style_bg_color(g_run_scr, lv_color_hex(0x000000), 0); + lv_obj_set_style_border_width(g_run_scr, 0, 0); + lv_obj_clear_flag(g_run_scr, LV_OBJ_FLAG_SCROLLABLE); + + bar = lv_obj_create(g_run_scr); + lv_obj_set_size(bar, lv_pct(100), 36); + lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(bar, lv_color_hex(NXSTORE_COLOR_HEADER_BG), 0); + lv_obj_set_style_radius(bar, 0, 0); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_set_style_pad_hor(bar, 12, 0); + lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(bar, LV_OBJ_FLAG_CLICKABLE); + + g_run_label = lv_label_create(bar); + lv_label_set_text(g_run_label, "Running"); + lv_obj_set_style_text_font(g_run_label, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(g_run_label, lv_color_hex(NXSTORE_COLOR_TEXT), + 0); + lv_obj_align(g_run_label, LV_ALIGN_LEFT_MID, 0, 0); + + close_btn = lv_obj_create(bar); + lv_obj_set_size(close_btn, 68, 26); + lv_obj_align(close_btn, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_radius(close_btn, 8, 0); + lv_obj_set_style_bg_color(close_btn, lv_color_hex(NXSTORE_COLOR_ERROR), 0); + lv_obj_set_style_bg_color(close_btn, lv_color_hex(0xb03830), + LV_STATE_PRESSED); + lv_obj_set_style_transform_width(close_btn, -2, LV_STATE_PRESSED); + lv_obj_set_style_transform_height(close_btn, -2, LV_STATE_PRESSED); + lv_obj_set_style_border_width(close_btn, 0, 0); + lv_obj_clear_flag(close_btn, LV_OBJ_FLAG_SCROLLABLE); + + close_label = lv_label_create(close_btn); + lv_label_set_text(close_label, LV_SYMBOL_CLOSE " Close"); + lv_obj_set_style_text_font(close_label, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(close_label, lv_color_hex(0xffffff), 0); + lv_obj_clear_flag(close_label, LV_OBJ_FLAG_CLICKABLE); + lv_obj_center(close_label); + + lv_obj_add_event_cb(close_btn, close_running_app_event_cb, + LV_EVENT_CLICKED, NULL); +} + +/* posix_spawn()'s default stack size (CONFIG_POSIX_SPAWN_DEFAULT_STACKSIZE, + * used whenever the spawn attributes don't explicitly override it) is a + * mere 2048 bytes - nowhere near enough for a real app like nxdoom, which + * needs CONFIG_GAMES_NXDOOM_STACKSIZE=16384 just for itself. A package + * manifest has no field to carry its own required stack size, and there + * is no reliable way to recover an app's Makefile-configured stack size + * from its installed ELF file at spawn time - a generous fixed size for + * every nxstore-launched app is the only practical option here, and costs + * nothing at rest (stack is only reserved, not zero-filled/touched, until + * actually used). Getting this wrong doesn't fail loudly: the app + * silently overflows its stack into adjacent heap memory, corrupting + * whatever happens to live there - which on real hardware surfaced as a + * deterministic-looking crash deep inside an unrelated kernel semaphore + * function, nowhere near the actual bug. + */ + +#define NXSTORE_LAUNCH_STACKSIZE 32768 + +/**************************************************************************** + * Name: nxstore_launch + * + * Description: + * Resolve the just-installed package's current payload path and run it. + * Reuses nxpkg's own installed-package bookkeeping rather than guessing + * at paths. On success, *pid_out is set to the spawned child's pid so + * the caller can hand it to nxstore_enter_running_screen() for + * supervision (reap-on-exit / force-close). + * + ****************************************************************************/ + +static int nxstore_launch(FAR const struct pkg_manifest_s *manifest, + FAR pid_t *pid_out) +{ + FAR struct pkg_installed_db_s *db; + FAR struct pkg_installed_entry_s *entry; + posix_spawnattr_t attr; + char path[PATH_MAX]; + FAR char *argv[2]; + pid_t pid; + int ret; + + /* struct pkg_installed_db_s is ~8KB - too large for a plain stack + * local given this function is called from the main UI thread (only + * an 8KB task stack, CONFIG_SYSTEM_NXSTORE_STACKSIZE) as well as the + * install worker thread (16KB). Heap-allocate it instead. + */ + + db = pkg_zalloc(sizeof(*db)); + if (db == NULL) + { + pkg_error("nxstore: unable to allocate installed db buffer"); + return -ENOMEM; + } + + ret = pkg_metadata_load_installed(db); + if (ret < 0) + { + pkg_error("nxstore: failed to load installed db: %d", ret); + pkg_free(db); + return ret; + } + + entry = pkg_metadata_find_installed(db, manifest->name); + if (entry == NULL) + { + pkg_error("nxstore: %s not found in installed db", manifest->name); + pkg_free(db); + return -ENOENT; + } + + ret = pkg_store_format_payload_path(path, sizeof(path), manifest->name, + entry->current, manifest->artifact); + pkg_free(db); + if (ret < 0) + { + return ret; + } + + argv[0] = path; + argv[1] = NULL; + + posix_spawnattr_init(&attr); + posix_spawnattr_setstacksize(&attr, NXSTORE_LAUNCH_STACKSIZE); + + ret = posix_spawn(&pid, path, NULL, &attr, argv, NULL); + posix_spawnattr_destroy(&attr); + if (ret != 0) + { + pkg_error("nxstore: posix_spawn(%s) failed: %d", path, ret); + return -ret; + } + + if (pid_out != NULL) + { + *pid_out = pid; + } + + return 0; +} + +/**************************************************************************** + * Name: nxstore_is_installed + ****************************************************************************/ + +static bool nxstore_is_installed(FAR const struct pkg_manifest_s *manifest) +{ + FAR struct pkg_installed_db_s *db; + bool found; + + db = pkg_zalloc(sizeof(*db)); + if (db == NULL) + { + return false; + } + + if (pkg_metadata_load_installed(db) < 0) + { + pkg_free(db); + return false; + } + + found = pkg_metadata_find_installed(db, manifest->name) != NULL; + pkg_free(db); + return found; +} + +/**************************************************************************** + * Name: install_worker + * + * Description: + * Runs on its own thread so the LVGL loop keeps animating the progress + * bar while the (blocking, network- and SD-bound) install/launch calls + * run. + * + ****************************************************************************/ + +static FAR void *install_worker(FAR void *arg) +{ + FAR struct install_ctx_s *ctx = arg; + int ret; + + ret = pkg_install(ctx->manifest->name); + if (ret != 0) + { + ctx->install_error = ret; + ctx->state = INSTALL_STATE_INSTALL_FAILED; + return NULL; + } + + ctx->state = INSTALL_STATE_LAUNCHING; + + ret = nxstore_launch(ctx->manifest, &ctx->launched_pid); + ctx->state = ret == 0 ? INSTALL_STATE_DONE_OK : + INSTALL_STATE_LAUNCH_FAILED; + return NULL; +} + +/**************************************************************************** + * Name: nxstore_poll_active_install + * + * Description: + * Called from the main LVGL loop. Reflects g_active's worker-thread + * state onto the button/label/progress bar, and reaps the thread once + * it finishes. + * + ****************************************************************************/ + +static void nxstore_poll_active_install(void) +{ + char text[256]; + FAR const char *result_text; + + if (g_active.manifest == NULL) + { + return; + } + + if (g_active.state == INSTALL_STATE_INSTALLING || + g_active.state == INSTALL_STATE_LAUNCHING) + { + bool launching = g_active.state == INSTALL_STATE_LAUNCHING; + time_t elapsed = time(NULL) - g_active.start_time; + + if (!g_active.warned_slow && elapsed > NXSTORE_INSTALL_WARN_SECONDS) + { + g_active.warned_slow = true; + } + + if (g_active.label != NULL) + { + /* A larger package (e.g. a game WAD) can legitimately take a + * minute or more over Wi-Fi - a static "Installing..." with + * no further feedback for that whole stretch reads as + * frozen. A live elapsed-time counter costs nothing (no + * real byte-progress is threaded up from the download layer) + * but gives continuous reassurance that something is still + * happening, well before the "(taking a while)" threshold. + */ + + if (elapsed < 3) + { + snprintf(text, sizeof(text), "%s...", + launching ? "Launching" : "Installing"); + } + else if (g_active.warned_slow) + { + snprintf(text, sizeof(text), "%s... %lds (taking a while)", + launching ? "Launching" : "Installing", + (long)elapsed); + } + else + { + snprintf(text, sizeof(text), "%s... %lds", + launching ? "Launching" : "Installing", + (long)elapsed); + } + + lv_label_set_text(g_active.label, text); + } + + return; + } + + /* The title (name + version) was never touched by any of this - only + * the subtitle changes here - so unlike the old single-label design + * there's no need to reconstruct "name vVersion - ..." from scratch; + * each case only has to say what actually changed. + */ + + switch (g_active.state) + { + case INSTALL_STATE_DONE_OK: + if (g_active.manifest->description[0] != '\0') + { + snprintf(text, sizeof(text), "%s", + g_active.manifest->description); + } + else + { + snprintf(text, sizeof(text), "Installed - tap to launch"); + } + + result_text = text; + break; + + case INSTALL_STATE_INSTALL_FAILED: + snprintf(text, sizeof(text), "%s, tap to retry", + nxstore_install_error_str(g_active.install_error)); + result_text = text; + break; + + case INSTALL_STATE_LAUNCH_FAILED: + snprintf(text, sizeof(text), "Installed - launch failed, tap to " + "retry"); + result_text = text; + break; + + default: + return; + } + + if (g_active.joinable) + { + pthread_join(g_active.thread, NULL); + g_active.joinable = false; + } + + if (g_active.progress_bar != NULL) + { + nxstore_progress_bar_stop(g_active.progress_bar); + g_active.progress_bar = NULL; + } + + if (g_active.label != NULL) + { + lv_label_set_text(g_active.label, result_text); + } + + /* A fresh successful install started out with the "not installed" + * blue/download icon (set at populate_app_list() time) - flip it to + * the green/play "installed" affordance now that it's true, instead + * of leaving a stale icon until the next reboot repopulates the list. + */ + + if (g_active.state == INSTALL_STATE_DONE_OK && g_active.btn != NULL) + { + lv_obj_t *icon = lv_obj_get_child(g_active.btn, 0); + lv_obj_t *icon_label = icon != NULL ? lv_obj_get_child(icon, 0) : NULL; + + if (icon != NULL) + { + lv_obj_set_style_bg_color(icon, + lv_color_hex(NXSTORE_COLOR_SUCCESS), 0); + } + + if (icon_label != NULL) + { + lv_label_set_text(icon_label, LV_SYMBOL_PLAY); + } + } + + if (g_active.btn != NULL) + { + lv_obj_clear_state(g_active.btn, LV_STATE_DISABLED); + } + + switch (g_active.state) + { + case INSTALL_STATE_DONE_OK: + + /* No "installed" toast here - the screen switch to the running + * app itself is the confirmation, and a toast created just + * before lv_screen_load() would be created on the screen that's + * about to be hidden and never actually seen. + */ + + nxstore_enter_running_screen(g_active.manifest->name, + g_active.launched_pid); + break; + + case INSTALL_STATE_INSTALL_FAILED: + case INSTALL_STATE_LAUNCH_FAILED: + nxstore_toast(true, "%s: %s", g_active.manifest->name, result_text); + break; + + default: + break; + } + + memset(&g_active, 0, sizeof(g_active)); +} + +/**************************************************************************** + * Name: nxstore_card_subtitle + * + * Description: + * Card children are [icon][text_col][chevron]; text_col's are + * [title][subtitle] - see populate_app_list(). Centralizes that + * layout knowledge in one place rather than repeating the child + * indices at every call site. + * + ****************************************************************************/ + +static lv_obj_t *nxstore_card_subtitle(lv_obj_t *card) +{ + lv_obj_t *text_col = lv_obj_get_child(card, 1); + + return text_col != NULL ? lv_obj_get_child(text_col, 1) : NULL; +} + +/**************************************************************************** + * Name: uninstall_btn_event_cb + * + * Description: + * Long-press on an installed row removes it via nxpkg's "remove". + * Long-press (rather than a second confirmation tap) is used + * specifically to avoid the ambiguity of whether this LVGL version + * also fires LV_EVENT_CLICKED on release after a long-press - a + * two-step "tap again to confirm" scheme risks the same physical + * gesture both arming and immediately confirming itself. A sustained + * long-press is already a deliberate, hard-to-trigger-by-accident + * gesture on its own. + * + ****************************************************************************/ + +static void uninstall_btn_event_cb(lv_event_t *e) +{ + FAR const struct pkg_manifest_s *manifest = lv_event_get_user_data(e); + lv_obj_t *card; + lv_obj_t *subtitle; + char text[256]; + int ret; + + if (manifest == NULL || !nxstore_is_installed(manifest)) + { + return; + } + + card = lv_event_get_target(e); + + /* Reuses the same LV_STATE_DISABLED reentrancy guard as + * install/launch: this must not run concurrently with itself, or + * with an in-flight install/launch for this same row. + */ + + if (lv_obj_has_state(card, LV_STATE_DISABLED)) + { + return; + } + + subtitle = nxstore_card_subtitle(card); + + lv_obj_add_state(card, LV_STATE_DISABLED); + if (subtitle != NULL) + { + lv_label_set_text(subtitle, "Removing..."); + lv_timer_handler(); + } + + ret = pkg_uninstall(manifest->name); + if (subtitle != NULL) + { + if (ret == EXIT_SUCCESS) + { + lv_obj_t *icon; + lv_obj_t *icon_label; + + snprintf(text, sizeof(text), "Removed - tap to reinstall"); + + /* Icon reverts to the "not installed" affordance now that + * it genuinely isn't. + */ + + icon = lv_obj_get_child(card, 0); + icon_label = icon != NULL ? lv_obj_get_child(icon, 0) : NULL; + + if (icon != NULL) + { + lv_obj_set_style_bg_color(icon, + lv_color_hex(NXSTORE_COLOR_ACCENT), + 0); + } + + if (icon_label != NULL) + { + lv_label_set_text(icon_label, LV_SYMBOL_DOWNLOAD); + } + } + else + { + snprintf(text, sizeof(text), + "Remove failed - long-press to retry"); + } + + lv_label_set_text(subtitle, text); + } + + nxstore_toast(ret != EXIT_SUCCESS, "%s %s", manifest->name, + ret == EXIT_SUCCESS ? "removed" : "failed to remove"); + + lv_obj_clear_state(card, LV_STATE_DISABLED); +} + +/**************************************************************************** + * Name: install_btn_event_cb + * + * Description: + * Tapped list entry: launch directly if already installed, otherwise + * kick off an async install+launch and show a progress bar while it + * runs. Long-press (uninstall_btn_event_cb) removes an installed + * entry. + * + ****************************************************************************/ + +static void install_btn_event_cb(lv_event_t *e) +{ + FAR const struct pkg_manifest_s *manifest = lv_event_get_user_data(e); + lv_event_code_t code = lv_event_get_code(e); + lv_obj_t *card; + lv_obj_t *subtitle; + pthread_attr_t attr; + + if (code != LV_EVENT_CLICKED || manifest == NULL) + { + return; + } + + card = lv_event_get_target(e); + subtitle = nxstore_card_subtitle(card); + + if (nxstore_is_installed(manifest)) + { + char orig[192]; + char text[256]; + pid_t pid = 0; + int ret; + + /* This doesn't touch g_active (the single install-worker slot) at + * all, so it's safe to run even while an unrelated install is in + * progress elsewhere in the list. What it does need is its own + * reentrancy guard: a rapid double-tap on this exact button, while + * the first tap's blocking nxstore_launch() call is still + * resolving, must not spawn the target process twice. + */ + + if (lv_obj_has_state(card, LV_STATE_DISABLED)) + { + return; + } + + lv_obj_add_state(card, LV_STATE_DISABLED); + + orig[0] = '\0'; + if (subtitle != NULL) + { + snprintf(orig, sizeof(orig), "%s", lv_label_get_text(subtitle)); + lv_label_set_text(subtitle, "Launching..."); + lv_timer_handler(); + } + + ret = nxstore_launch(manifest, &pid); + if (subtitle != NULL) + { + lv_label_set_text(subtitle, orig); + } + + lv_obj_clear_state(card, LV_STATE_DISABLED); + + if (ret == 0) + { + /* No "launched" toast here, same reasoning as the install + * path - the screen switch itself is the confirmation. + */ + + nxstore_enter_running_screen(manifest->name, pid); + } + else + { + if (subtitle != NULL) + { + snprintf(text, sizeof(text), + "%s - launch failed, tap to retry", orig); + lv_label_set_text(subtitle, text); + } + + nxstore_toast(true, "%s failed to launch", manifest->name); + } + + return; + } + + if (g_active.manifest != NULL) + { + /* Only one *install* can run at a time (single worker slot); + * launching an already-installed app above isn't gated by this. + */ + + return; + } + + memset(&g_active, 0, sizeof(g_active)); + g_active.manifest = manifest; + g_active.btn = card; + g_active.label = subtitle; + g_active.state = INSTALL_STATE_INSTALLING; + g_active.start_time = time(NULL); + + if (subtitle != NULL) + { + snprintf(g_active.orig_text, sizeof(g_active.orig_text), "%s", + lv_label_get_text(subtitle)); + } + + lv_obj_add_state(card, LV_STATE_DISABLED); + + if (subtitle != NULL) + { + lv_label_set_text(subtitle, "Installing..."); + } + + g_active.progress_bar = nxstore_progress_bar_start(card); + + pthread_attr_init(&attr); + pthread_attr_setstacksize(&attr, 16384); + + if (pthread_create(&g_active.thread, &attr, install_worker, + &g_active) != 0) + { + pkg_error("nxstore: failed to spawn install worker"); + nxstore_progress_bar_stop(g_active.progress_bar); + + lv_obj_clear_state(card, LV_STATE_DISABLED); + if (subtitle != NULL) + { + lv_label_set_text(subtitle, "Install failed"); + } + + nxstore_toast(true, "%s failed to start install", manifest->name); + + memset(&g_active, 0, sizeof(g_active)); + pthread_attr_destroy(&attr); + return; + } + + g_active.joinable = true; + pthread_attr_destroy(&attr); +} + +/**************************************************************************** + * Name: populate_app_list + * + * Description: + * Build one LVGL list entry per manifest already loaded into g_index. + * + ****************************************************************************/ + +static void populate_app_list(void) +{ + char seen_names[PKG_INDEX_MAX][PKG_NAME_MAX + 1]; + size_t seen_count = 0; + size_t i; + + for (i = 0; i < g_index.count; i++) + { + FAR const struct pkg_manifest_s *manifest; + bool installed; + bool dup = false; + size_t j; + lv_obj_t *card; + lv_obj_t *icon; + lv_obj_t *icon_label; + lv_obj_t *text_col; + lv_obj_t *title_label; + lv_obj_t *subtitle_label; + lv_obj_t *chevron; + char title_text[96]; + char subtitle_text[192]; + + /* The index can list multiple versions of the same package as + * separate entries (that's exactly how `nxpkg update` finds a + * newer version to install) - without this, every version in the + * index got its own row ("nxdoom" showing up 3 times). One card + * per unique name; pkg_metadata_find_latest() (already used by + * pkg_install.c for bare-name installs/updates, see pkg.h) picks + * the actual newest version rather than just whichever entry + * happened to appear first in the index. + */ + + for (j = 0; j < seen_count; j++) + { + if (strcmp(seen_names[j], g_index.manifests[i].name) == 0) + { + dup = true; + break; + } + } + + if (dup) + { + continue; + } + + snprintf(seen_names[seen_count], sizeof(seen_names[seen_count]), "%s", + g_index.manifests[i].name); + seen_count++; + + manifest = pkg_metadata_find_latest(&g_index, + g_index.manifests[i].name); + if (manifest == NULL) + { + continue; + } + + installed = nxstore_is_installed(manifest); + + /* Card: one flex row [icon circle][title+subtitle column][chevron], + * styled as a distinct surface (rounded corners, subtle border) + * rather than a bare list row - this is what actually reads as + * "a real app" per entry instead of a plain text menu. + */ + + card = lv_obj_create(g_list); + lv_obj_set_size(card, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_style_radius(card, 14, 0); + lv_obj_set_style_bg_color(card, lv_color_hex(NXSTORE_COLOR_CARD_BG), + 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, + lv_color_hex(NXSTORE_COLOR_CARD_BORDER), + 0); + lv_obj_set_style_pad_all(card, 12, 0); + lv_obj_set_style_pad_column(card, 12, 0); + + /* No LVGL theme is loaded (this file styles every widget itself), + * so without an explicit LV_STATE_PRESSED variant a tap gives no + * visual feedback at all - this is what actually confirms to the + * user that a touch registered, before any install/launch state + * change has had a chance to show up elsewhere on the row. + */ + + lv_obj_set_style_bg_color(card, + lv_color_hex(NXSTORE_COLOR_CARD_BORDER), + LV_STATE_PRESSED); + lv_obj_set_style_border_color(card, lv_color_hex(NXSTORE_COLOR_ACCENT), + LV_STATE_PRESSED); + lv_obj_set_style_transform_width(card, -3, LV_STATE_PRESSED); + lv_obj_set_style_transform_height(card, -3, LV_STATE_PRESSED); + + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); + + /* Icon circle: color + symbol double as the installed/not-installed + * indicator, so status is legible at a glance without reading text. + */ + + icon = lv_obj_create(card); + lv_obj_set_size(icon, 44, 44); + lv_obj_set_style_radius(icon, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(icon, + lv_color_hex(installed + ? NXSTORE_COLOR_SUCCESS + : NXSTORE_COLOR_ACCENT), 0); + lv_obj_set_style_border_width(icon, 0, 0); + lv_obj_set_style_pad_all(icon, 0, 0); + lv_obj_clear_flag(icon, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE); + + icon_label = lv_label_create(icon); + lv_label_set_text(icon_label, + installed ? LV_SYMBOL_PLAY : LV_SYMBOL_DOWNLOAD); + lv_obj_set_style_text_color(icon_label, lv_color_hex(0xffffff), 0); + lv_obj_center(icon_label); + + /* Text column: title never gets overwritten by install/launch + * status (unlike the previous single-label design) - only the + * subtitle line changes, so which package the progress bar below + * it belongs to stays legible the whole time. + */ + + text_col = lv_obj_create(card); + lv_obj_set_style_bg_opa(text_col, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(text_col, 0, 0); + lv_obj_set_style_pad_all(text_col, 0, 0); + lv_obj_set_style_pad_row(text_col, 2, 0); + lv_obj_set_flex_flow(text_col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_grow(text_col, 1); + lv_obj_set_height(text_col, LV_SIZE_CONTENT); + lv_obj_clear_flag(text_col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(text_col, LV_OBJ_FLAG_CLICKABLE); + + title_label = lv_label_create(text_col); + snprintf(title_text, sizeof(title_text), "%s v%s", + manifest->name, manifest->version); + lv_label_set_text(title_label, title_text); + lv_obj_set_style_text_font(title_label, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(title_label, + lv_color_hex(NXSTORE_COLOR_TEXT), 0); + + subtitle_label = lv_label_create(text_col); + if (manifest->description[0] != '\0') + { + snprintf(subtitle_text, sizeof(subtitle_text), "%s", + manifest->description); + } + else + { + snprintf(subtitle_text, sizeof(subtitle_text), + installed ? "Installed - tap to launch" : + "Tap to install"); + } + + lv_label_set_text(subtitle_label, subtitle_text); + lv_obj_set_style_text_font(subtitle_label, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(subtitle_label, + lv_color_hex(NXSTORE_COLOR_TEXT_MUTED), 0); + lv_label_set_long_mode(subtitle_label, LV_LABEL_LONG_WRAP); + lv_obj_set_width(subtitle_label, lv_pct(100)); + + /* Chevron: a plain tap-affordance hint, decorative only - not + * touched again after creation. Unlike the old spinner design, + * the install-progress bar lives in text_col below the subtitle + * rather than over this, so nothing needs to hide it during an + * active install. + */ + + chevron = lv_label_create(card); + lv_label_set_text(chevron, LV_SYMBOL_RIGHT); + lv_obj_set_style_text_color(chevron, + lv_color_hex(NXSTORE_COLOR_TEXT_MUTED), 0); + + lv_obj_add_event_cb(card, install_btn_event_cb, LV_EVENT_CLICKED, + (void *)manifest); + lv_obj_add_event_cb(card, uninstall_btn_event_cb, + LV_EVENT_LONG_PRESSED, (void *)manifest); + } +} + +/**************************************************************************** + * Name: build_app_store_ui + ****************************************************************************/ + +static void build_app_store_ui(FAR const char *repo_url) +{ + lv_obj_t *scr = lv_scr_act(); + lv_obj_t *header; + lv_obj_t *title; + lv_obj_t *subtitle; + lv_obj_t *status; + int ret; + + g_main_scr = scr; + lv_obj_set_style_bg_color(scr, lv_color_hex(NXSTORE_COLOR_BG), 0); + + /* The indev-level scroll_limit/scroll_throw settings in main() only + * raise the bar for a scroll gesture to *start* - they don't stop one + * from happening once that bar is cleared. Without also locking the + * screen object itself down (examples/lvgldemo/lvgldemo.c does this + * for its own screen; this file previously only cleared SCROLLABLE on + * the list, not on scr), an accepted gesture scrolls the whole + * screen's content, which looks exactly like "the display steps down" + * on a tap and silently misaligns every row's real position from + * where it was drawn when the user aimed - explaining reports of + * tapping one entry and a different one installing. + */ + + lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLL_CHAIN); + lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLL_ELASTIC); + lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLL_MOMENTUM); + lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLL_ON_FOCUS); + lv_obj_set_scroll_dir(scr, LV_DIR_NONE); + lv_obj_set_scrollbar_mode(scr, LV_SCROLLBAR_MODE_OFF); + + /* Screen is a flex column [header][list]: header gets a fixed pixel + * height, list gets flex_grow to take all remaining vertical space - + * this resizes correctly regardless of screen resolution, unlike + * doing height arithmetic on lv_pct() values (which encode percentage + * specially and cannot be combined with plain pixel math). + */ + + lv_obj_set_flex_flow(scr, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(scr, 0, 0); + lv_obj_set_style_pad_row(scr, 0, 0); + + /* Header bar: a distinct surface (not just a label floating on the + * background) with a bottom divider, giving the screen an actual + * top-level structure instead of a title line directly above a list. + */ + + header = lv_obj_create(scr); + lv_obj_set_size(header, lv_pct(100), 64); + lv_obj_set_style_bg_color(header, + lv_color_hex(NXSTORE_COLOR_HEADER_BG), 0); + lv_obj_set_style_radius(header, 0, 0); + lv_obj_set_style_border_width(header, 2, 0); + lv_obj_set_style_border_side(header, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_color(header, + lv_color_hex(NXSTORE_COLOR_HEADER_LINE), 0); + lv_obj_set_style_pad_all(header, 0, 0); + lv_obj_clear_flag(header, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(header, LV_OBJ_FLAG_CLICKABLE); + + title = lv_label_create(header); + lv_label_set_text(title, "App Store"); + lv_obj_set_style_text_font(title, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(title, lv_color_hex(NXSTORE_COLOR_TEXT), 0); + lv_obj_align(title, LV_ALIGN_LEFT_MID, 16, -10); + + subtitle = lv_label_create(header); + lv_label_set_text(subtitle, "NuttX package manager"); + lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(subtitle, + lv_color_hex(NXSTORE_COLOR_TEXT_MUTED), 0); + lv_obj_align(subtitle, LV_ALIGN_LEFT_MID, 16, 12); + + g_list = lv_list_create(scr); + lv_obj_set_width(g_list, lv_pct(100)); + lv_obj_set_flex_grow(g_list, 1); + lv_obj_set_style_bg_color(g_list, lv_color_hex(NXSTORE_COLOR_BG), 0); + lv_obj_set_style_border_width(g_list, 0, 0); + lv_obj_set_style_pad_all(g_list, 12, 0); + lv_obj_set_style_pad_row(g_list, 10, 0); + + /* The list needs to scroll once there are more rows than fit on + * screen - it was previously locked down entirely (matching scr + * above) because a noisy touch driver could turn a tap into an + * accidental scroll drag. That protection actually lives at the + * indev level (lv_indev_set_scroll_limit(255)/scroll_throw(0) in + * main() - a real drag has to travel much further than any touch + * jitter before a scroll starts at all), so it's safe to leave this + * SCROLLABLE and still get that protection; only vertical dragging is + * allowed, momentum/elastic overscroll stay off so a fast swipe can't + * bounce past the last row on this same noisy touch driver, and + * SCROLL_CHAIN stays off since there's nothing above this to chain + * into (scr itself is not scrollable). + */ + + lv_obj_clear_flag(g_list, LV_OBJ_FLAG_SCROLL_CHAIN); + lv_obj_clear_flag(g_list, LV_OBJ_FLAG_SCROLL_ELASTIC); + lv_obj_clear_flag(g_list, LV_OBJ_FLAG_SCROLL_MOMENTUM); + lv_obj_clear_flag(g_list, LV_OBJ_FLAG_SCROLL_ON_FOCUS); + lv_obj_set_scroll_dir(g_list, LV_DIR_VER); + lv_obj_set_scrollbar_mode(g_list, LV_SCROLLBAR_MODE_AUTO); + + /* If a repository URL was supplied, download the package listing from the + * server over Wi-Fi before showing it. This is the "fetch the catalog + * from a hosted server" step of the store flow. Falls back to whatever + * local index already exists if the download fails (e.g. offline). + */ + + if (repo_url != NULL && repo_url[0] != '\0') + { + lv_obj_t *spinner; + int wait_ticks; +#ifdef CONFIG_ESPRESSIF_WIFI + extern volatile int g_wifi_dhcp_ret; +#endif + + status = lv_label_create(scr); + lv_obj_add_flag(status, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_label_set_text(status, "Waiting for network..."); + lv_obj_align(status, LV_ALIGN_CENTER, 0, -30); + lv_obj_set_style_text_color(status, + lv_color_hex(NXSTORE_COLOR_WARNING), 0); + + spinner = lv_spinner_create(scr); + lv_obj_add_flag(spinner, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_set_size(spinner, 40, 40); + lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 20); + lv_obj_set_style_arc_color(spinner, lv_color_hex(NXSTORE_COLOR_ACCENT), + LV_PART_INDICATOR); + + /* nxstore starts as soon as LCD/touchscreen bring-up finishes, + * but Wi-Fi association + DHCP run on their own background task + * (see wapi_board_autoconnect_task) that can easily still be in + * progress at this point. Without this wait, the very first + * sync attempt on a cold boot would race the network coming up + * and fail every time, even though the board ends up connected + * moments later - the exact "Server download failed" report + * that motivated adding this. Bounded (15s) rather than + * indefinite so a genuinely offline board still falls through + * to the cached-listing path instead of hanging here forever. + */ + +#ifdef CONFIG_ESPRESSIF_WIFI + /* IFF_RUNNING alone only means L2 association completed - DHCP + * (a separate step afterward in wapi_board_autoconnect_task) + * can still be in flight, and an HTTP fetch attempted before it + * finishes fails with -ENETUNREACH (no route yet) even though + * the link itself is up. g_wifi_dhcp_ret is set exactly once, + * the moment DHCP finishes (success or failure) - wait for that + * rather than inferring readiness from interface flags/IP, + * which can be ambiguous (e.g. a non-DHCP placeholder address). + */ + + for (wait_ticks = 0; wait_ticks < 250; wait_ticks++) + { + /* -424242 must match WIFI_DIAG_NOT_ATTEMPTED in + * esp32s3_bringup.c - a #define there, not a linkable + * symbol, so it can't be shared directly across this + * flat build's separate compilation units. + */ + + if (g_wifi_dhcp_ret != -424242) + { + break; + } + + lv_timer_handler(); + usleep(100 * 1000); + } +#endif + + lv_label_set_text(status, "Downloading listing from server..."); + + /* Yield once so the "Downloading..." label and spinner are painted + * before the blocking HTTP fetch below. + */ + + lv_timer_handler(); + + ret = pkg_sync(repo_url); + lv_obj_del(spinner); + if (ret != 0) + { + lv_label_set_text(status, "Server download failed.\n" + "Showing last cached listing."); + lv_obj_set_style_text_color(status, + lv_color_hex(NXSTORE_COLOR_WARNING), + 0); + lv_obj_align(status, LV_ALIGN_CENTER, 0, 0); + lv_timer_handler(); + } + else + { + lv_obj_del(status); + } + } + + ret = pkg_metadata_load_index(&g_index); + if (ret < 0) + { + status = lv_label_create(scr); + lv_obj_add_flag(status, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_label_set_text(status, "No package index available.\n" + "Connect Wi-Fi and pass a repo URL,\n" + "or run 'nxpkg sync ' first."); + lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(status); + lv_obj_set_style_text_color(status, lv_color_hex(NXSTORE_COLOR_ERROR), + 0); + return; + } + + if (g_index.count == 0) + { + /* The index parsed fine but has zero usable entries (empty + * catalog, or every entry was for a different arch/board and got + * filtered out) - distinct from the "couldn't load an index at + * all" case above, which needs a different message. + */ + + status = lv_label_create(scr); + lv_obj_add_flag(status, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_label_set_text(status, "No packages available for this device."); + lv_obj_center(status); + lv_obj_set_style_text_color(status, + lv_color_hex(NXSTORE_COLOR_WARNING), 0); + return; + } + + populate_app_list(); +} + +/**************************************************************************** + * Public Functions + ****************************************************************************/ + +int main(int argc, FAR char *argv[]) +{ + lv_nuttx_dsc_t info; + lv_nuttx_result_t result; + FAR const char *repo_url = NULL; + + /* Optional first argument: a repository URL (http://host:port/index.json) + * to download the package listing from over Wi-Fi. With no argument, + * nxstore just shows the locally-synced index. + */ + + if (argc > 1 && argv[1] != NULL && argv[1][0] != '\0') + { + repo_url = argv[1]; + } + + if (lv_is_initialized()) + { + printf("nxstore: LVGL already initialized! aborting.\n"); + return -1; + } + + lv_init(); + + lv_nuttx_dsc_init(&info); + info.fb_path = CONFIG_EXAMPLES_LVGLDEMO_FBDEVPATH; + info.input_path = CONFIG_EXAMPLES_LVGLDEMO_INPUT_DEVPATH; + lv_nuttx_init(&info, &result); + + if (result.disp == NULL) + { + printf("nxstore: lv_nuttx_init failure!\n"); + return 1; + } + + /* Same touch-drift mitigation as examples/lvgldemo/lvgldemo.c: require + * a large drag before scroll starts and kill momentum, so this board's + * noisy touch driver can't turn a tap into an accidental scroll. The + * list itself already clears LV_OBJ_FLAG_SCROLLABLE, but that's a + * single-widget workaround - this covers the indev (and therefore the + * whole screen, including anything added outside the list) the same + * way the reference app does. + */ + + if (result.indev != NULL) + { + lv_indev_set_scroll_limit(result.indev, 255); + lv_indev_set_scroll_throw(result.indev, 0); + } + + build_app_store_ui(repo_url); + build_run_screen(); + + while (1) + { + uint32_t idle; + + idle = lv_timer_handler(); + + nxstore_poll_active_install(); + nxstore_poll_running_app(); + + idle = idle ? idle : 1; + usleep(idle * 1000); + } + + lv_nuttx_deinit(&result); + lv_deinit(); + return 0; +} From 3d8bbb8630113e8140b0a65c5f0c67775cac9da5 Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Thu, 23 Jul 2026 16:13:21 +0530 Subject: [PATCH 07/15] system/nxstore: address review findings and fix hardware-found bugs. Review/hardening findings from the companion nxpkg PRs: - Own framebuffer/input device paths instead of borrowing CONFIG_EXAMPLES_LVGLDEMO_FBDEVPATH/INPUT_DEVPATH from an unrelated example app: new CONFIG_SYSTEM_NXSTORE_FBDEVPATH/INPUT_DEVPATH Kconfig string options (defaulting to /dev/fb0 and /dev/input0). - g_index moves from a static struct to a heap-allocated FAR struct pkg_index_s * (pkg_zalloc()), with a "Not enough memory to load the catalog." UI fallback if the allocation fails. - nxstore_launch() now loads the specific installed version's manifest via pkg_metadata_load_manifest_path() instead of combining a rollback-selected version with whatever the current catalog entry happens to describe for that name - those can disagree after a rollback if the catalog has since moved on. Also passes through the installed manifest's launch_args/launch_argc (previously always argv[1] = NULL). - title_text buffer sized PKG_NAME_MAX + PKG_VERSION_MAX + 5 instead of a fixed 96, fixing a real compiler truncation warning. - README.txt moved to the companion NuttX documentation PR rather than shipping user-facing documentation as an in-tree README. Bugs found bringing this up on real hardware: - LV_EVENT_LONG_PRESSED could fire for a touch that was actually driving a scroll of the app list: if a drag starts slowly enough that the long-press timer (400ms) elapses before the finger crosses the scroll-lock distance, LVGL hasn't committed the gesture to scrolling yet and still delivers the long-press event, silently uninstalling whatever card the touch happened to land on. Ignore the long-press if this input device is currently attributed to scrolling any object (lv_indev_get_scroll_obj()). - Tapping an installed-app card to launch it, while a different install was in progress elsewhere in the list (or another app was already running), was allowed through unconditionally - install_worker() auto-launches its own package once done, and letting a second launch through independently meant whichever one finished last silently overwrote g_running, leaving the other process alive, unsupervised, and drawing into the same shared framebuffer with no way to close it from this UI again. Both the direct-launch and fresh-install paths in install_btn_event_cb() now refuse (with a toast) if g_running.active or g_active.manifest indicate another app is already running or about to be. - nxstore_is_installed() only checked the package *name*, not which version was actually on disk - an older installed version showed as plain "Installed" identically to a current one, and tapping it silently launched the stale payload with no update indication or action. Add nxstore_is_up_to_date() (compares the installed version against the catalog's latest manifest) and a third status_bar color (in addition to not-installed/up-to-date) plus a "Update available - tap to update" subtitle for the mismatch case; tapping such a card now goes through the install path (which fetches and auto-launches the newly installed version) instead of the direct-launch path. - lv_indev_set_scroll_limit() was set to 255 (copied from examples/ lvgldemo/lvgldemo.c, whose own touch-drift mitigation this file reused), requiring a nearly-full-screen drag before a touch was even recognized as a scroll gesture. Unlike that demo, this screen's app list is scrolled constantly, and a threshold that large read as broken/laggy scrolling rather than drift protection. Lowered to 20, enough to reject typical touch-driver jitter while still recognizing a real scroll almost immediately; momentum stays off (scroll_throw 0), which is what the dual-launch/scroll-lock fixes above actually depend on, not the gesture-start threshold. Also adds nxstore_load_icon(): best-effort loads and caches a package's optional icon (manifest->icon) as a raw RGB565 image LVGL can render with no decoder (this board has no PNG/JPEG decode capability), falling back to the existing colored-circle-plus-glyph rendering on any failure (no icon set, download failed, corrupt/ oversized file) so a bad icon never blocks a package from being listed or installed. Assisted-by: Claude:claude-sonnet-5 Signed-off-by: aviralgarg05 --- system/nxstore/Kconfig | 11 + system/nxstore/README.txt | 100 ------- system/nxstore/nxstore_main.c | 483 ++++++++++++++++++++++++++++------ 3 files changed, 412 insertions(+), 182 deletions(-) delete mode 100644 system/nxstore/README.txt diff --git a/system/nxstore/Kconfig b/system/nxstore/Kconfig index a4cf381a19d..230d84250e2 100644 --- a/system/nxstore/Kconfig +++ b/system/nxstore/Kconfig @@ -8,6 +8,9 @@ config SYSTEM_NXSTORE default n depends on GRAPHICS_LVGL && SYSTEM_NXPKG select NETUTILS_CJSON + select SCHED_WAITPID + select LV_FONT_MONTSERRAT_12 + select LV_FONT_MONTSERRAT_20 ---help--- Enable the Nxstore LVGL graphical application store frontend. Lists packages from the local nxpkg repository, installs the @@ -27,4 +30,12 @@ config SYSTEM_NXSTORE_STACKSIZE int "nxstore stack size" default 8192 +config SYSTEM_NXSTORE_FBDEVPATH + string "Framebuffer device path" + default "/dev/fb0" + +config SYSTEM_NXSTORE_INPUT_DEVPATH + string "Input device path" + default "/dev/input0" + endif diff --git a/system/nxstore/README.txt b/system/nxstore/README.txt deleted file mode 100644 index 4d9811617c5..00000000000 --- a/system/nxstore/README.txt +++ /dev/null @@ -1,100 +0,0 @@ -nxstore - an LVGL touchscreen app store for NuttX -================================================= - - nxstore is a graphical front-end for system/nxpkg. It lists the - packages available in a nxpkg repository, installs the one you tap via - nxpkg, launches it, and supervises it with an on-screen "Close" - control. It is the touchscreen equivalent of driving the nxpkg CLI by - hand; both consume the same repository, so the repository/server setup - (layout, how to serve it, populating it with tools/export_pkg_repo.py, - pointing the board at it) is documented once, in system/nxpkg's - README.txt, and is not repeated here. - - -Dependencies -============ - - Kconfig: SYSTEM_NXSTORE depends on GRAPHICS_LVGL and SYSTEM_NXPKG and - selects NETUTILS_CJSON. It needs a working framebuffer (/dev/fb0) and - a touch input device (/dev/input0) - the same graphics/input stack - examples/lvgldemo uses. NETUTILS_CJSON is used to parse the - repository index and package manifests. - - -On-device lifecycle -=================== - - 1. Browse - the app list is built from the repository index nxpkg - fetched. Installed packages show a launch action; - not-yet-installed ones show an install action. - 2. Install - tapping an uninstalled package runs the nxpkg install - flow (download -> sha256 verify -> transactional - install) on a background worker thread, with progress - shown as a toast. - 3. Launch - tapping an installed package spawns its payload - (posix_spawn) and switches to the "running" screen: a - thin bar across the top NXSTORE_BAR_HEIGHT pixels (see - include/system/nxstore_chrome.h) carrying the app name - and a Close button, with the rest of the framebuffer - left to the running app. - 4. Close - see "Closing a running app" below. - - Launched apps are given NXSTORE_LAUNCH_STACKSIZE (32 KiB) of stack - - posix_spawn's default (CONFIG_POSIX_SPAWN_DEFAULT_STACKSIZE, 2 KiB) is - far too small for a real LVGL/framebuffer app, and overflowing it does - not fail loudly (it corrupts adjacent heap, surfacing later as a crash - nowhere near the real cause). - - -Closing a running app: the cooperative-SIGTERM contract -======================================================= - - This is the one thing an app author MUST get right to be launchable - from nxstore. - - The Close button sends the running app SIGTERM and waits for it to - reap itself. It does NOT (and cannot safely) force-kill the task: - - - On a build with CONFIG_SIG_DEFAULT disabled (the default), NuttX - has no default action for SIGTERM at all. Delivery does nothing - unless the app installs its own handler with sigaction(). - - An earlier nxstore fell back to task_delete() when SIGTERM went - unreaped. On real hardware that force-kill landed while the app - was mid framebuffer/heap access and hung the *entire board* (not - just the task), requiring a physical power cycle. That fallback - was removed: there is no safe way to force-terminate an arbitrary - task from the outside here. - - So an nxstore-launchable app must cooperate: - - - Install an async-signal-safe SIGTERM handler that only sets a - volatile sig_atomic_t flag. - - Poll that flag from its own main loop and exit cleanly (freeing - the framebuffer, restoring input state, etc.) when it is set. - - Apps whose main loop can never block indefinitely (they run to - completion on their own, or fail fast at startup) do not strictly need - a handler, but any app that renders in a long-lived loop does. See - these in-tree examples for the exact pattern: - - - games/NXDoom (i_install_quit_signal / i_poll_quit_signal) - - examples/calculator - - games/cgol, games/brickmatch - - If SIGTERM is not reaped within the timeout, nxstore keeps the running - screen up rather than returning to the app list - switching back while - the app might still be alive and drawing into the framebuffer would - just reproduce the original hang, hidden. - - -Configuration -============= - - SYSTEM_NXSTORE_PROGNAME program/registration name (default nxstore) - SYSTEM_NXSTORE_PRIORITY task priority (default 100) - SYSTEM_NXSTORE_STACKSIZE nxstore's own stack (default 8192) - - NXSTORE_LAUNCH_STACKSIZE (the stack given to launched apps) and - NXSTORE_BAR_HEIGHT (the reserved top bar) are compile-time constants in - the source / include/system/nxstore_chrome.h rather than Kconfig - options. diff --git a/system/nxstore/nxstore_main.c b/system/nxstore/nxstore_main.c index 6008b3eba3d..039754af30b 100644 --- a/system/nxstore/nxstore_main.c +++ b/system/nxstore/nxstore_main.c @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -34,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -41,6 +43,8 @@ #include +#include + #include "../nxpkg/pkg.h" /**************************************************************************** @@ -80,6 +84,14 @@ #define NXSTORE_COLOR_WARNING 0xf5a623 /* In progress / slow */ #define NXSTORE_COLOR_ERROR 0xf0554c /* Failed */ +/* Sanity bound on a package icon's declared width/height (see + * nxstore_load_icon()) - purely a defense against a garbage or + * malicious icon file driving an oversized allocation/render, not a + * real design constraint (icons are shown well under this size). + */ + +#define NXSTORE_ICON_MAX_DIM 128 + /**************************************************************************** * Private Types ****************************************************************************/ @@ -144,7 +156,16 @@ struct running_app_s ****************************************************************************/ static lv_obj_t *g_list; -static struct pkg_index_s g_index; + +/* Heap-allocated (nxstore_main()'s first-run allocation in + * build_app_store_ui()) rather than a plain static struct - each manifest + * slot is ~1.7KB and PKG_INDEX_MAX has grown past what's comfortable to + * carve out of internal DRAM as a fixed BSS array (see the comment on + * PKG_INDEX_MAX in pkg.h). calloc()/pkg_zalloc() on this board's tasks + * draws from the PSRAM-backed user heap instead. + */ + +static FAR struct pkg_index_s *g_index; static struct install_ctx_s g_active; /* g_main_scr is the normal app-list screen (built once in @@ -164,7 +185,7 @@ static struct running_app_s g_running; static void nxstore_toast(bool is_error, FAR const char *fmt, ...); static lv_obj_t *nxstore_progress_bar_start(lv_obj_t *card); static void nxstore_progress_bar_stop(lv_obj_t *bar); -static void nxstore_enter_running_screen(FAR const char *name, pid_t pid); +static void nxstore_enter_running_screen(FAR const char *name); static void close_running_app_event_cb(lv_event_t *e); static void nxstore_poll_running_app(void); static void build_run_screen(void); @@ -344,21 +365,36 @@ static void nxstore_progress_bar_stop(lv_obj_t *bar) * Name: nxstore_enter_running_screen * * Description: - * Switches to the supervisor screen and records the spawned child so - * nxstore_poll_running_app()/close_running_app_event_cb() can reap or - * force-close it. Must only be called from the LVGL/main thread - - * lv_screen_load() is not thread-safe. + * Switches to the supervisor screen and forces that switch to actually + * reach the physical framebuffer before returning. Deliberately does + * NOT record a pid or set g_running.active - callers that can control + * spawn order (the direct-launch path in install_btn_event_cb()) must + * call this *before* nxstore_launch(), then set g_running.pid/active + * themselves only once the spawn actually succeeds. Getting this + * backwards (spawn first, switch screens after) leaves a real window, + * observed on hardware, where the newly-spawned app starts drawing + * into the framebuffer immediately while this task's own pending + * screen switch only reaches the display whenever its LVGL loop next + * happens to run - which could be starved indefinitely by a busy-looping + * child, leaving the old app-list screen visibly stuck underneath/around + * whatever the child draws for as long as it keeps running. + * + * The async install-then-launch path (nxstore_poll_active_install(), + * reacting to a background thread that already called nxstore_launch() + * itself) can't avoid that ordering - LVGL calls aren't safe off the + * main thread, so the screen switch can only happen after the worker + * reports done - but forcing the flush here still shrinks that window + * to one polling tick instead of leaving it open indefinitely. * ****************************************************************************/ -static void nxstore_enter_running_screen(FAR const char *name, pid_t pid) +static void nxstore_enter_running_screen(FAR const char *name) { - g_running.pid = pid; - g_running.active = true; snprintf(g_running.name, sizeof(g_running.name), "%s", name); lv_label_set_text(g_run_label, g_running.name); lv_screen_load(g_run_scr); + lv_refr_now(NULL); } /**************************************************************************** @@ -552,7 +588,7 @@ static void build_run_screen(void) lv_obj_clear_flag(g_run_scr, LV_OBJ_FLAG_SCROLLABLE); bar = lv_obj_create(g_run_scr); - lv_obj_set_size(bar, lv_pct(100), 36); + lv_obj_set_size(bar, lv_pct(100), NXSTORE_BAR_HEIGHT); lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0); lv_obj_set_style_bg_color(bar, lv_color_hex(NXSTORE_COLOR_HEADER_BG), 0); lv_obj_set_style_radius(bar, 0, 0); @@ -717,6 +753,45 @@ static bool nxstore_is_installed(FAR const struct pkg_manifest_s *manifest) return found; } +/**************************************************************************** + * Name: nxstore_is_up_to_date + * + * Description: + * nxstore_is_installed() only checks the package *name*, not which + * version is actually on disk. If an older version is installed and + * the catalog's latest manifest for that name is newer, that made a + * card read as plain "Installed" with no way to tell the two apart - + * tapping it launched whatever old version was actually on disk, with + * no update indication or action at all. Returns true only when the + * installed version string exactly matches manifest->version. + * + ****************************************************************************/ + +static bool nxstore_is_up_to_date(FAR const struct pkg_manifest_s *manifest) +{ + FAR struct pkg_installed_db_s *db; + FAR struct pkg_installed_entry_s *entry; + bool up_to_date; + + db = pkg_zalloc(sizeof(*db)); + if (db == NULL) + { + return false; + } + + if (pkg_metadata_load_installed(db) < 0) + { + pkg_free(db); + return false; + } + + entry = pkg_metadata_find_installed(db, manifest->name); + up_to_date = entry != NULL && + strcmp(entry->current, manifest->version) == 0; + pkg_free(db); + return up_to_date; +} + /**************************************************************************** * Name: install_worker * @@ -870,26 +945,23 @@ static void nxstore_poll_active_install(void) } /* A fresh successful install started out with the "not installed" - * blue/download icon (set at populate_app_list() time) - flip it to - * the green/play "installed" affordance now that it's true, instead - * of leaving a stale icon until the next reboot repopulates the list. + * blue status bar (set at populate_app_list() time) - flip it to the + * green "installed" affordance now that it's true, instead of leaving + * it stale until the next reboot repopulates the list. The icon + * itself (child 0) is never state-colored - see populate_app_list()'s + * own comment on why a real app icon can't double as that indicator - + * status_bar (child 1) is the only thing that needs flipping here. */ if (g_active.state == INSTALL_STATE_DONE_OK && g_active.btn != NULL) { - lv_obj_t *icon = lv_obj_get_child(g_active.btn, 0); - lv_obj_t *icon_label = icon != NULL ? lv_obj_get_child(icon, 0) : NULL; + lv_obj_t *status_bar = lv_obj_get_child(g_active.btn, 1); - if (icon != NULL) + if (status_bar != NULL) { - lv_obj_set_style_bg_color(icon, + lv_obj_set_style_bg_color(status_bar, lv_color_hex(NXSTORE_COLOR_SUCCESS), 0); } - - if (icon_label != NULL) - { - lv_label_set_text(icon_label, LV_SYMBOL_PLAY); - } } if (g_active.btn != NULL) @@ -907,8 +979,9 @@ static void nxstore_poll_active_install(void) * about to be hidden and never actually seen. */ - nxstore_enter_running_screen(g_active.manifest->name, - g_active.launched_pid); + g_running.pid = g_active.launched_pid; + g_running.active = true; + nxstore_enter_running_screen(g_active.manifest->name); break; case INSTALL_STATE_INSTALL_FAILED: @@ -969,6 +1042,21 @@ static void uninstall_btn_event_cb(lv_event_t *e) return; } + /* LV_EVENT_LONG_PRESSED can fire for a touch that is actually driving + * a scroll of the enclosing list: if the drag starts slowly enough + * that the long-press timer (400ms) elapses before the finger has + * moved past the scroll-lock distance, LVGL hasn't committed the + * gesture to scrolling yet and still delivers the long-press event. + * This is what reads to a user as "it just happens when scrolling", + * not a deliberate long-press. Ignore the event if this input + * device is currently attributed to scrolling any object. + */ + + if (lv_indev_get_scroll_obj(lv_indev_active()) != NULL) + { + return; + } + card = lv_event_get_target(e); /* Reuses the same LV_STATE_DISABLED reentrancy guard as @@ -995,29 +1083,24 @@ static void uninstall_btn_event_cb(lv_event_t *e) { if (ret == EXIT_SUCCESS) { - lv_obj_t *icon; - lv_obj_t *icon_label; + lv_obj_t *status_bar; snprintf(text, sizeof(text), "Removed - tap to reinstall"); - /* Icon reverts to the "not installed" affordance now that - * it genuinely isn't. + /* status_bar (child 1) reverts to the "not installed" + * affordance now that it genuinely isn't - the icon itself + * (child 0) is never state-colored, same reasoning as the + * install success path above. */ - icon = lv_obj_get_child(card, 0); - icon_label = icon != NULL ? lv_obj_get_child(icon, 0) : NULL; + status_bar = lv_obj_get_child(card, 1); - if (icon != NULL) + if (status_bar != NULL) { - lv_obj_set_style_bg_color(icon, + lv_obj_set_style_bg_color(status_bar, lv_color_hex(NXSTORE_COLOR_ACCENT), 0); } - - if (icon_label != NULL) - { - lv_label_set_text(icon_label, LV_SYMBOL_DOWNLOAD); - } } else { @@ -1038,10 +1121,13 @@ static void uninstall_btn_event_cb(lv_event_t *e) * Name: install_btn_event_cb * * Description: - * Tapped list entry: launch directly if already installed, otherwise - * kick off an async install+launch and show a progress bar while it - * runs. Long-press (uninstall_btn_event_cb) removes an installed - * entry. + * Tapped list entry: launch directly if already installed *and* + * up-to-date, otherwise kick off an async install+launch (this + * fetches whatever version the catalog currently advertises and + * launches it once done, which for an already-installed-but-outdated + * package is exactly the "update" action) and show a progress bar + * while it runs. Long-press (uninstall_btn_event_cb) removes an + * installed entry. * ****************************************************************************/ @@ -1061,21 +1147,37 @@ static void install_btn_event_cb(lv_event_t *e) card = lv_event_get_target(e); subtitle = nxstore_card_subtitle(card); - if (nxstore_is_installed(manifest)) + if (nxstore_is_installed(manifest) && nxstore_is_up_to_date(manifest)) { char orig[192]; char text[256]; pid_t pid = 0; int ret; - /* This doesn't touch g_active (the single install-worker slot) at - * all, so it's safe to run even while an unrelated install is in - * progress elsewhere in the list. What it does need is its own - * reentrancy guard: a rapid double-tap on this exact button, while - * the first tap's blocking nxstore_launch() call is still - * resolving, must not spawn the target process twice. + /* This board's display is a single shared framebuffer that only + * one app can own at a time, and g_running is nxstore's only + * record of who that is. A rapid double-tap on this exact button + * is guarded below, but that alone isn't enough: this path used + * to run unconditionally even while another app was already + * running (g_running.active), or while an unrelated install + * elsewhere in the list was about to auto-launch its own package + * once done (install_worker() -> nxstore_launch() runs on its own + * thread and g_active.manifest stays non-NULL for the whole + * install+launch+settle window - see nxstore_poll_active_install() + * - so checking it here closes the same race as checking + * g_running.active, without needing to know which state the + * install is currently in). Letting either through overwrote + * g_running with whichever launch finished last, leaving the + * other process alive, unsupervised, and drawing into the same + * framebuffer with no way to close it from this UI again. */ + if (g_running.active || g_active.manifest != NULL) + { + nxstore_toast(true, "Close the running app first"); + return; + } + if (lv_obj_has_state(card, LV_STATE_DISABLED)) { return; @@ -1091,6 +1193,14 @@ static void install_btn_event_cb(lv_event_t *e) lv_timer_handler(); } + /* Switch to (and force-flush) the running screen *before* spawning + * - see nxstore_enter_running_screen()'s own comment for why doing + * it the other way around left the old app-list screen visibly + * stuck under/around whatever the newly-launched app draws. + */ + + nxstore_enter_running_screen(manifest->name); + ret = nxstore_launch(manifest, &pid); if (subtitle != NULL) { @@ -1101,14 +1211,15 @@ static void install_btn_event_cb(lv_event_t *e) if (ret == 0) { - /* No "launched" toast here, same reasoning as the install - * path - the screen switch itself is the confirmation. - */ - - nxstore_enter_running_screen(manifest->name, pid); + g_running.pid = pid; + g_running.active = true; } else { + g_running.active = false; + lv_screen_load(g_main_scr); + lv_refr_now(NULL); + if (subtitle != NULL) { snprintf(text, sizeof(text), @@ -1124,10 +1235,20 @@ static void install_btn_event_cb(lv_event_t *e) if (g_active.manifest != NULL) { - /* Only one *install* can run at a time (single worker slot); - * launching an already-installed app above isn't gated by this. + /* Only one *install* can run at a time (single worker slot). */ + + return; + } + + if (g_running.active) + { + /* This install will auto-launch its package once done (see + * install_worker()) - starting it while another app already owns + * the framebuffer would just reproduce the dual-launch race + * guarded against above, once this install finishes. */ + nxstore_toast(true, "Close the running app first"); return; } @@ -1179,6 +1300,127 @@ static void install_btn_event_cb(lv_event_t *e) pthread_attr_destroy(&attr); } +/**************************************************************************** + * Name: nxstore_load_icon + * + * Description: + * Best-effort load of a package's optional icon (manifest->icon) into + * an lv_image_dsc_t, downloading and caching it under PKG_ROOT_DIR + * first if it isn't already cached. The icon file format is a raw, + * uncompressed image matching lv_image_header_t's own 12-byte layout + * (magic/cf/flags, then w/h, then stride/reserved, all little-endian) + * followed immediately by RGB565 pixel data - deliberately the + * simplest thing LVGL can render directly with no decoder, since this + * board has no PNG/JPEG decode capability wired up. tools/ + * export_pkg_repo.py's --icon option writes this exact format. + * + * Every failure mode (no icon set, download failed, corrupt/oversized + * file) just returns NULL - callers fall back to the plain colored + * circle + symbol glyph, so a bad icon never blocks the app from being + * listed or installed. + * + * On success, both the returned descriptor and its data pointer are + * heap-allocated and intentionally never freed - the descriptor is + * handed straight to a permanent lv_image row widget (which keeps a + * pointer to it, not a copy) that lives for nxstore's whole runtime, + * so there's no point it's ever safe to free at. Must be heap, not a + * caller's stack local: it has to outlive the populate_app_list() loop + * iteration that creates it. + * + ****************************************************************************/ + +static FAR lv_image_dsc_t * +nxstore_load_icon(FAR const struct pkg_manifest_s *manifest) +{ + char cache_path[PATH_MAX]; + char source[PATH_MAX]; + FAR lv_image_dsc_t *dsc; + FAR uint8_t *buf; + struct stat st; + int fd; + ssize_t nread; + uint16_t w; + uint16_t h; + uint16_t stride; + + if (manifest->icon[0] == '\0') + { + return NULL; + } + + snprintf(cache_path, sizeof(cache_path), PKG_ROOT_DIR "/icons/%s.bin", + manifest->name); + + if (stat(cache_path, &st) < 0) + { + mkdir(PKG_ROOT_DIR "/icons", 0755); + + if (pkg_resolve_icon_source(source, sizeof(source), manifest) < 0 || + pkg_acquire_source(source, cache_path, NULL) < 0 || + stat(cache_path, &st) < 0) + { + return NULL; + } + } + + if (st.st_size <= 12 || st.st_size > 64 * 1024) + { + return NULL; + } + + buf = pkg_malloc((size_t)st.st_size); + if (buf == NULL) + { + return NULL; + } + + fd = open(cache_path, O_RDONLY); + if (fd < 0) + { + pkg_free(buf); + return NULL; + } + + nread = read(fd, buf, (size_t)st.st_size); + close(fd); + + if (nread != st.st_size || buf[0] != LV_IMAGE_HEADER_MAGIC) + { + pkg_free(buf); + return NULL; + } + + w = (uint16_t)(buf[4] | (buf[5] << 8)); + h = (uint16_t)(buf[6] | (buf[7] << 8)); + stride = (uint16_t)(buf[8] | (buf[9] << 8)); + + if (w == 0 || h == 0 || w > NXSTORE_ICON_MAX_DIM || + h > NXSTORE_ICON_MAX_DIM || stride != w * 2 || + 12 + (size_t)stride * h > (size_t)nread) + { + pkg_free(buf); + return NULL; + } + + dsc = pkg_zalloc(sizeof(*dsc)); + if (dsc == NULL) + { + pkg_free(buf); + return NULL; + } + + dsc->header.magic = buf[0]; + dsc->header.cf = buf[1]; + dsc->header.flags = (uint16_t)(buf[2] | (buf[3] << 8)); + dsc->header.w = w; + dsc->header.h = h; + dsc->header.stride = stride; + dsc->data_size = (uint32_t)stride * h; + dsc->data = buf + 12; + + return dsc; +} + /**************************************************************************** * Name: populate_app_list * @@ -1193,20 +1435,23 @@ static void populate_app_list(void) size_t seen_count = 0; size_t i; - for (i = 0; i < g_index.count; i++) + for (i = 0; i < g_index->count; i++) { FAR const struct pkg_manifest_s *manifest; bool installed; + bool up_to_date; bool dup = false; size_t j; lv_obj_t *card; lv_obj_t *icon; lv_obj_t *icon_label; + lv_obj_t *status_bar; lv_obj_t *text_col; lv_obj_t *title_label; lv_obj_t *subtitle_label; lv_obj_t *chevron; - char title_text[96]; + FAR lv_image_dsc_t *icon_dsc; + char title_text[PKG_NAME_MAX + PKG_VERSION_MAX + 5]; char subtitle_text[192]; /* The index can list multiple versions of the same package as @@ -1221,7 +1466,7 @@ static void populate_app_list(void) for (j = 0; j < seen_count; j++) { - if (strcmp(seen_names[j], g_index.manifests[i].name) == 0) + if (strcmp(seen_names[j], g_index->manifests[i].name) == 0) { dup = true; break; @@ -1234,17 +1479,19 @@ static void populate_app_list(void) } snprintf(seen_names[seen_count], sizeof(seen_names[seen_count]), "%s", - g_index.manifests[i].name); + g_index->manifests[i].name); seen_count++; - manifest = pkg_metadata_find_latest(&g_index, - g_index.manifests[i].name); + manifest = pkg_metadata_find_latest(g_index, + g_index->manifests[i].name); if (manifest == NULL) { continue; } installed = nxstore_is_installed(manifest); + up_to_date = !installed || nxstore_is_up_to_date(manifest); + icon_dsc = nxstore_load_icon(manifest); /* Card: one flex row [icon circle][title+subtitle column][chevron], * styled as a distinct surface (rounded corners, subtle border) @@ -1284,27 +1531,68 @@ static void populate_app_list(void) LV_FLEX_ALIGN_CENTER); lv_obj_clear_flag(card, LV_OBJ_FLAG_SCROLLABLE); - /* Icon circle: color + symbol double as the installed/not-installed - * indicator, so status is legible at a glance without reading text. + /* Icon circle: always shows the package's own art (icon_dsc) when + * available, on a neutral background - install state used to be + * conveyed by coloring this same circle, but that only worked for + * the plain-glyph fallback; a real (opaque, same-size) icon image + * just covered the ring color entirely, silently losing the only + * install-state indicator in the row. status_bar below is a + * dedicated indicator instead, so state stays legible regardless + * of whether a row has real art or just the fallback glyph. */ icon = lv_obj_create(card); lv_obj_set_size(icon, 44, 44); lv_obj_set_style_radius(icon, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(icon, - lv_color_hex(installed - ? NXSTORE_COLOR_SUCCESS - : NXSTORE_COLOR_ACCENT), 0); + lv_color_hex(NXSTORE_COLOR_CARD_BORDER), 0); lv_obj_set_style_border_width(icon, 0, 0); lv_obj_set_style_pad_all(icon, 0, 0); + lv_obj_set_style_clip_corner(icon, true, 0); lv_obj_clear_flag(icon, LV_OBJ_FLAG_SCROLLABLE); lv_obj_clear_flag(icon, LV_OBJ_FLAG_CLICKABLE); - icon_label = lv_label_create(icon); - lv_label_set_text(icon_label, - installed ? LV_SYMBOL_PLAY : LV_SYMBOL_DOWNLOAD); - lv_obj_set_style_text_color(icon_label, lv_color_hex(0xffffff), 0); - lv_obj_center(icon_label); + if (icon_dsc != NULL) + { + lv_obj_t *icon_img = lv_image_create(icon); + + lv_image_set_src(icon_img, icon_dsc); + lv_image_set_scale(icon_img, 200); + lv_obj_center(icon_img); + } + else + { + icon_label = lv_label_create(icon); + lv_label_set_text(icon_label, LV_SYMBOL_FILE); + lv_obj_set_style_text_color(icon_label, + lv_color_hex(NXSTORE_COLOR_TEXT_MUTED), + 0); + lv_obj_center(icon_label); + } + + /* Status bar: a small vertical stripe next to the icon, the only + * thing that carries installed/not-installed state now - legible + * at a glance without depending on the icon itself ever being + * colorable (a real app icon's artwork is whatever it is). A + * third color distinguishes "installed, but an older version than + * the catalog's latest" from a fully up-to-date install - without + * it, an outdated install looked identical to a current one and + * gave no hint that tapping would still launch the old payload. + */ + + status_bar = lv_obj_create(card); + lv_obj_set_size(status_bar, 6, 36); + lv_obj_set_style_radius(status_bar, 3, 0); + lv_obj_set_style_bg_color(status_bar, + lv_color_hex(!installed + ? NXSTORE_COLOR_ACCENT + : up_to_date + ? NXSTORE_COLOR_SUCCESS + : NXSTORE_COLOR_WARNING), 0); + lv_obj_set_style_border_width(status_bar, 0, 0); + lv_obj_set_style_pad_all(status_bar, 0, 0); + lv_obj_clear_flag(status_bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(status_bar, LV_OBJ_FLAG_CLICKABLE); /* Text column: title never gets overwritten by install/launch * status (unlike the previous single-label design) - only the @@ -1332,7 +1620,17 @@ static void populate_app_list(void) lv_color_hex(NXSTORE_COLOR_TEXT), 0); subtitle_label = lv_label_create(text_col); - if (manifest->description[0] != '\0') + if (installed && !up_to_date) + { + /* Takes priority over the static description - an available + * update is actionable state the user needs to see, and a + * fixed description text would never convey it. + */ + + snprintf(subtitle_text, sizeof(subtitle_text), + "Update available - tap to update"); + } + else if (manifest->description[0] != '\0') { snprintf(subtitle_text, sizeof(subtitle_text), "%s", manifest->description); @@ -1386,6 +1684,19 @@ static void build_app_store_ui(FAR const char *repo_url) g_main_scr = scr; lv_obj_set_style_bg_color(scr, lv_color_hex(NXSTORE_COLOR_BG), 0); + g_index = pkg_zalloc(sizeof(*g_index)); + if (g_index == NULL) + { + status = lv_label_create(scr); + lv_obj_add_flag(status, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_label_set_text(status, "Out of memory building catalog."); + lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(status); + lv_obj_set_style_text_color(status, lv_color_hex(NXSTORE_COLOR_ERROR), + 0); + return; + } + /* The indev-level scroll_limit/scroll_throw settings in main() only * raise the bar for a scroll gesture to *start* - they don't stop one * from happening once that bar is cleared. Without also locking the @@ -1572,7 +1883,7 @@ static void build_app_store_ui(FAR const char *repo_url) } } - ret = pkg_metadata_load_index(&g_index); + ret = pkg_metadata_load_index(g_index); if (ret < 0) { status = lv_label_create(scr); @@ -1587,7 +1898,7 @@ static void build_app_store_ui(FAR const char *repo_url) return; } - if (g_index.count == 0) + if (g_index->count == 0) { /* The index parsed fine but has zero usable entries (empty * catalog, or every entry was for a different arch/board and got @@ -1646,18 +1957,26 @@ int main(int argc, FAR char *argv[]) return 1; } - /* Same touch-drift mitigation as examples/lvgldemo/lvgldemo.c: require - * a large drag before scroll starts and kill momentum, so this board's - * noisy touch driver can't turn a tap into an accidental scroll. The - * list itself already clears LV_OBJ_FLAG_SCROLLABLE, but that's a - * single-widget workaround - this covers the indev (and therefore the - * whole screen, including anything added outside the list) the same - * way the reference app does. + /* Touch-drift mitigation: require a modest drag before scroll starts, + * so this board's noisy touch driver can't turn a tap into an + * accidental scroll. examples/lvgldemo/lvgldemo.c uses 255 for this + * same setting, which this file originally copied verbatim - but + * unlike that demo, this screen's list is something a user actually + * scrolls constantly, and requiring a drag that large before anything + * visibly responds reads as broken/laggy scrolling, not drift + * protection. 20px is enough to reject typical touch-driver jitter + * (single-digit pixels) while still recognizing a real scroll gesture + * almost immediately. Momentum stays off (scroll_throw 0) - see + * nxstore_enter_running_screen()'s and populate_app_list()'s own + * comments on why a coasting scroll misaligning row positions caused + * real "tapped one entry, a different one installed" reports; that + * risk is about *momentum* continuing after release, not about the + * gesture-start threshold, so it's independent of this value. */ if (result.indev != NULL) { - lv_indev_set_scroll_limit(result.indev, 255); + lv_indev_set_scroll_limit(result.indev, 20); lv_indev_set_scroll_throw(result.indev, 0); } From 7fb624df4c0b38d067dc8e5e71c2282f77c4c4a7 Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Thu, 23 Jul 2026 20:44:06 +0530 Subject: [PATCH 08/15] system/nxstore: Retry synchronization on network readiness. Remove the dependency on an ESP-specific DHCP global. Retry catalog synchronization for a bounded period only while the network stack reports readiness-related errors, allowing the same frontend to work with Wi-Fi, Ethernet, and other boards. Keep the LVGL timer serviced between attempts so the interface remains responsive while the network comes up. Assisted-by: Codex:gpt-5 Signed-off-by: aviralgarg05 --- system/nxstore/nxstore_main.c | 60 +++++++++++------------------------ 1 file changed, 18 insertions(+), 42 deletions(-) diff --git a/system/nxstore/nxstore_main.c b/system/nxstore/nxstore_main.c index 039754af30b..b5308e8101d 100644 --- a/system/nxstore/nxstore_main.c +++ b/system/nxstore/nxstore_main.c @@ -1797,10 +1797,7 @@ static void build_app_store_ui(FAR const char *repo_url) if (repo_url != NULL && repo_url[0] != '\0') { lv_obj_t *spinner; - int wait_ticks; -#ifdef CONFIG_ESPRESSIF_WIFI - extern volatile int g_wifi_dhcp_ret; -#endif + time_t retry_deadline; status = lv_label_create(scr); lv_obj_add_flag(status, LV_OBJ_FLAG_IGNORE_LAYOUT); @@ -1816,56 +1813,35 @@ static void build_app_store_ui(FAR const char *repo_url) lv_obj_set_style_arc_color(spinner, lv_color_hex(NXSTORE_COLOR_ACCENT), LV_PART_INDICATOR); - /* nxstore starts as soon as LCD/touchscreen bring-up finishes, - * but Wi-Fi association + DHCP run on their own background task - * (see wapi_board_autoconnect_task) that can easily still be in - * progress at this point. Without this wait, the very first - * sync attempt on a cold boot would race the network coming up - * and fail every time, even though the board ends up connected - * moments later - the exact "Server download failed" report - * that motivated adding this. Bounded (15s) rather than - * indefinite so a genuinely offline board still falls through - * to the cached-listing path instead of hanging here forever. + lv_label_set_text(status, "Downloading listing from server..."); + + /* Yield once so the "Downloading..." label and spinner are painted + * before the blocking HTTP fetch below. */ -#ifdef CONFIG_ESPRESSIF_WIFI - /* IFF_RUNNING alone only means L2 association completed - DHCP - * (a separate step afterward in wapi_board_autoconnect_task) - * can still be in flight, and an HTTP fetch attempted before it - * finishes fails with -ENETUNREACH (no route yet) even though - * the link itself is up. g_wifi_dhcp_ret is set exactly once, - * the moment DHCP finishes (success or failure) - wait for that - * rather than inferring readiness from interface flags/IP, - * which can be ambiguous (e.g. a non-DHCP placeholder address). + lv_timer_handler(); + + /* A cold boot can reach nxstore before DHCP has installed a route. + * Retry only readiness failures for up to 15 seconds. Checking the + * operation's result keeps nxstore independent of board-specific + * Wi-Fi state and works for Ethernet or other network interfaces too. */ - for (wait_ticks = 0; wait_ticks < 250; wait_ticks++) + retry_deadline = time(NULL) + 15; + do { - /* -424242 must match WIFI_DIAG_NOT_ATTEMPTED in - * esp32s3_bringup.c - a #define there, not a linkable - * symbol, so it can't be shared directly across this - * flat build's separate compilation units. - */ - - if (g_wifi_dhcp_ret != -424242) + ret = pkg_sync(repo_url); + if (ret != -ENETUNREACH && ret != -ENETDOWN && + ret != -EHOSTUNREACH) { break; } lv_timer_handler(); - usleep(100 * 1000); + usleep(250 * 1000); } -#endif - - lv_label_set_text(status, "Downloading listing from server..."); - - /* Yield once so the "Downloading..." label and spinner are painted - * before the blocking HTTP fetch below. - */ - - lv_timer_handler(); + while (time(NULL) < retry_deadline); - ret = pkg_sync(repo_url); lv_obj_del(spinner); if (ret != 0) { From ab5036fcabf7d16c6db0d1f4399df38f5e35bb26 Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Thu, 23 Jul 2026 20:51:45 +0530 Subject: [PATCH 09/15] system/nxstore: Honor installed launch metadata. Use nxstore-owned framebuffer and input configuration symbols instead of relying on an unrelated LVGL demo configuration. Load the manifest for the installed version before launching it, validate that it matches the installed database entry, and pass its recorded arguments to posix_spawn(). Check spawn-attribute setup errors as well. Add the shared supervisor-bar height header to the nxstore change itself so the branch builds independently and framebuffer applications can follow the required reserved-strip contract. Assisted-by: Codex:gpt-5 Signed-off-by: aviralgarg05 --- include/system/nxstore_chrome.h | 37 +++++++++++++++++++ system/nxstore/nxstore_main.c | 65 ++++++++++++++++++++++++++++----- 2 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 include/system/nxstore_chrome.h diff --git a/include/system/nxstore_chrome.h b/include/system/nxstore_chrome.h new file mode 100644 index 00000000000..0ebb1b0e48b --- /dev/null +++ b/include/system/nxstore_chrome.h @@ -0,0 +1,37 @@ +/**************************************************************************** + * apps/include/system/nxstore_chrome.h + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +#ifndef __APPS_INCLUDE_SYSTEM_NXSTORE_CHROME_H +#define __APPS_INCLUDE_SYSTEM_NXSTORE_CHROME_H + +/**************************************************************************** + * Pre-processor Definitions + ****************************************************************************/ + +/* Full-screen applications launched by nxstore share the framebuffer with + * its supervisor bar. They must leave this top strip untouched so the + * Close control remains visible and usable. + */ + +#define NXSTORE_BAR_HEIGHT 36 + +#endif /* __APPS_INCLUDE_SYSTEM_NXSTORE_CHROME_H */ diff --git a/system/nxstore/nxstore_main.c b/system/nxstore/nxstore_main.c index b5308e8101d..51a2547221f 100644 --- a/system/nxstore/nxstore_main.c +++ b/system/nxstore/nxstore_main.c @@ -662,9 +662,11 @@ static int nxstore_launch(FAR const struct pkg_manifest_s *manifest, { FAR struct pkg_installed_db_s *db; FAR struct pkg_installed_entry_s *entry; + FAR struct pkg_manifest_s *installed; posix_spawnattr_t attr; char path[PATH_MAX]; - FAR char *argv[2]; + FAR char *argv[PKG_LAUNCH_ARGS_MAX + 2]; + size_t i; pid_t pid; int ret; @@ -681,10 +683,18 @@ static int nxstore_launch(FAR const struct pkg_manifest_s *manifest, return -ENOMEM; } + installed = pkg_zalloc(sizeof(*installed)); + if (installed == NULL) + { + pkg_free(db); + return -ENOMEM; + } + ret = pkg_metadata_load_installed(db); if (ret < 0) { pkg_error("nxstore: failed to load installed db: %d", ret); + pkg_free(installed); pkg_free(db); return ret; } @@ -693,26 +703,63 @@ static int nxstore_launch(FAR const struct pkg_manifest_s *manifest, if (entry == NULL) { pkg_error("nxstore: %s not found in installed db", manifest->name); + pkg_free(installed); pkg_free(db); return -ENOENT; } - ret = pkg_store_format_payload_path(path, sizeof(path), manifest->name, - entry->current, manifest->artifact); + ret = pkg_store_format_manifest_path(path, sizeof(path), manifest->name, + entry->current); + if (ret >= 0) + { + ret = pkg_metadata_load_manifest_path(path, installed); + } + + if (ret >= 0 && + (strcmp(installed->name, manifest->name) != 0 || + strcmp(installed->version, entry->current) != 0)) + { + ret = -EINVAL; + } + + if (ret >= 0) + { + ret = pkg_store_format_payload_path(path, sizeof(path), + installed->name, + installed->version, + installed->artifact); + } + pkg_free(db); if (ret < 0) { + pkg_free(installed); return ret; } argv[0] = path; - argv[1] = NULL; + for (i = 0; i < installed->launch_argc; i++) + { + argv[i + 1] = installed->launch_args[i]; + } + + argv[i + 1] = NULL; + + ret = posix_spawnattr_init(&attr); + if (ret != 0) + { + pkg_free(installed); + return -ret; + } - posix_spawnattr_init(&attr); - posix_spawnattr_setstacksize(&attr, NXSTORE_LAUNCH_STACKSIZE); + ret = posix_spawnattr_setstacksize(&attr, NXSTORE_LAUNCH_STACKSIZE); + if (ret == 0) + { + ret = posix_spawn(&pid, path, NULL, &attr, argv, NULL); + } - ret = posix_spawn(&pid, path, NULL, &attr, argv, NULL); posix_spawnattr_destroy(&attr); + pkg_free(installed); if (ret != 0) { pkg_error("nxstore: posix_spawn(%s) failed: %d", path, ret); @@ -1923,8 +1970,8 @@ int main(int argc, FAR char *argv[]) lv_init(); lv_nuttx_dsc_init(&info); - info.fb_path = CONFIG_EXAMPLES_LVGLDEMO_FBDEVPATH; - info.input_path = CONFIG_EXAMPLES_LVGLDEMO_INPUT_DEVPATH; + info.fb_path = CONFIG_SYSTEM_NXSTORE_FBDEVPATH; + info.input_path = CONFIG_SYSTEM_NXSTORE_INPUT_DEVPATH; lv_nuttx_init(&info, &result); if (result.disp == NULL) From 63c540cbbbf649161ce0f988766cc81f881ee223 Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Thu, 23 Jul 2026 21:35:59 +0530 Subject: [PATCH 10/15] system/nxstore: Correct the scroll threshold comment. Keep the list behavior documentation consistent with the 20-pixel threshold used by the tested implementation. Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: aviralgarg05 --- system/nxstore/nxstore_main.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/nxstore/nxstore_main.c b/system/nxstore/nxstore_main.c index 51a2547221f..1d4a22efbe6 100644 --- a/system/nxstore/nxstore_main.c +++ b/system/nxstore/nxstore_main.c @@ -1818,7 +1818,7 @@ static void build_app_store_ui(FAR const char *repo_url) * screen - it was previously locked down entirely (matching scr * above) because a noisy touch driver could turn a tap into an * accidental scroll drag. That protection actually lives at the - * indev level (lv_indev_set_scroll_limit(255)/scroll_throw(0) in + * indev level (lv_indev_set_scroll_limit(20)/scroll_throw(0) in * main() - a real drag has to travel much further than any touch * jitter before a scroll starts at all), so it's safe to leave this * SCROLLABLE and still get that protection; only vertical dragging is From 79daf2c5a9bc927520aec49bd331c4617cb1aec0 Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Fri, 24 Jul 2026 15:02:32 +0530 Subject: [PATCH 11/15] system/nxstore: Harden package icon caching. Read cached icons completely, validate the RGB565 format and exact payload size, and discard corrupt cache entries so a later launch can retry acquisition. Key the local cache by package name and version so a catalog update cannot silently reuse an older icon. Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: aviralgarg05 --- system/nxstore/nxstore_main.c | 41 ++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/system/nxstore/nxstore_main.c b/system/nxstore/nxstore_main.c index 1d4a22efbe6..0e28706f257 100644 --- a/system/nxstore/nxstore_main.c +++ b/system/nxstore/nxstore_main.c @@ -1366,6 +1366,10 @@ static void install_btn_event_cb(lv_event_t *e) * circle + symbol glyph, so a bad icon never blocks the app from being * listed or installed. * + * Cache files include the package version so an updated package does not + * silently retain an older icon. Corrupt cache files are removed so a + * later launch can fetch them again. + * * On success, both the returned descriptor and its data pointer are * heap-allocated and intentionally never freed - the descriptor is * handed straight to a permanent lv_image row widget (which keeps a @@ -1386,6 +1390,7 @@ nxstore_load_icon(FAR const struct pkg_manifest_s *manifest) struct stat st; int fd; ssize_t nread; + size_t offset; uint16_t w; uint16_t h; uint16_t stride; @@ -1395,8 +1400,9 @@ nxstore_load_icon(FAR const struct pkg_manifest_s *manifest) return NULL; } - snprintf(cache_path, sizeof(cache_path), PKG_ROOT_DIR "/icons/%s.bin", - manifest->name); + snprintf(cache_path, sizeof(cache_path), + PKG_ROOT_DIR "/icons/%s-%s.bin", + manifest->name, manifest->version); if (stat(cache_path, &st) < 0) { @@ -1428,12 +1434,36 @@ nxstore_load_icon(FAR const struct pkg_manifest_s *manifest) return NULL; } - nread = read(fd, buf, (size_t)st.st_size); + offset = 0; + while (offset < (size_t)st.st_size) + { + nread = read(fd, buf + offset, (size_t)st.st_size - offset); + if (nread < 0) + { + if (errno == EINTR) + { + continue; + } + + break; + } + + if (nread == 0) + { + break; + } + + offset += nread; + } + close(fd); - if (nread != st.st_size || buf[0] != LV_IMAGE_HEADER_MAGIC) + if (offset != (size_t)st.st_size || + buf[0] != LV_IMAGE_HEADER_MAGIC || + buf[1] != LV_COLOR_FORMAT_RGB565) { pkg_free(buf); + unlink(cache_path); return NULL; } @@ -1443,9 +1473,10 @@ nxstore_load_icon(FAR const struct pkg_manifest_s *manifest) if (w == 0 || h == 0 || w > NXSTORE_ICON_MAX_DIM || h > NXSTORE_ICON_MAX_DIM || stride != w * 2 || - 12 + (size_t)stride * h > (size_t)nread) + 12 + (size_t)stride * h != (size_t)st.st_size) { pkg_free(buf); + unlink(cache_path); return NULL; } From 9372de597be607fee4a3b93ce062e4b0d313e829 Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Sun, 12 Jul 2026 16:01:18 +0530 Subject: [PATCH 12/15] examples/calculator: Add an LVGL touchscreen calculator. Add a calculator example that can run as either a built-in application or loadable module. It uses configurable framebuffer and touchscreen paths, reserves the nxstore supervisor bar, and exits cooperatively on SIGTERM. Validate display, touchscreen, and signal-handler setup before entering the LVGL loop. Reset static state on every invocation so built-in relaunches behave like fresh module loads. Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: aviralgarg05 --- examples/calculator/CMakeLists.txt | 37 ++ examples/calculator/Kconfig | 35 ++ examples/calculator/Make.defs | 25 ++ examples/calculator/Makefile | 32 ++ examples/calculator/calculator_main.c | 518 ++++++++++++++++++++++++++ 5 files changed, 647 insertions(+) create mode 100644 examples/calculator/CMakeLists.txt create mode 100644 examples/calculator/Kconfig create mode 100644 examples/calculator/Make.defs create mode 100644 examples/calculator/Makefile create mode 100644 examples/calculator/calculator_main.c diff --git a/examples/calculator/CMakeLists.txt b/examples/calculator/CMakeLists.txt new file mode 100644 index 00000000000..6f26ffea9cc --- /dev/null +++ b/examples/calculator/CMakeLists.txt @@ -0,0 +1,37 @@ +# ############################################################################## +# apps/examples/calculator/CMakeLists.txt +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more contributor +# license agreements. See the NOTICE file distributed with this work for +# additional information regarding copyright ownership. The ASF licenses this +# file to you under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. +# +# ############################################################################## + +if(CONFIG_EXAMPLES_CALCULATOR) + nuttx_add_application( + NAME + calc + PRIORITY + ${CONFIG_EXAMPLES_CALCULATOR_PRIORITY} + STACKSIZE + ${CONFIG_EXAMPLES_CALCULATOR_STACKSIZE} + MODULE + ${CONFIG_EXAMPLES_CALCULATOR} + DEPENDS + lvgl + SRCS + calculator_main.c) +endif() diff --git a/examples/calculator/Kconfig b/examples/calculator/Kconfig new file mode 100644 index 00000000000..6a86a2cd4c6 --- /dev/null +++ b/examples/calculator/Kconfig @@ -0,0 +1,35 @@ +# +# For a description of the syntax of this configuration file, +# see the file kconfig-language.txt in the NuttX tools repository. +# + +menuconfig EXAMPLES_CALCULATOR + tristate "Calculator" + default n + depends on GRAPHICS_LVGL && INPUT_TOUCHSCREEN + ---help--- + Enable the simple LVGL touchscreen calculator example. + +if EXAMPLES_CALCULATOR + +config EXAMPLES_CALCULATOR_PRIORITY + int "calculator task priority" + default 100 + +config EXAMPLES_CALCULATOR_STACKSIZE + int "calculator stack size" + default 16384 + +config EXAMPLES_CALCULATOR_INPUT_DEVPATH + string "Touchscreen device path" + default "/dev/input0" + ---help--- + The path to the touchscreen device. Default: "/dev/input0" + +config EXAMPLES_CALCULATOR_FBDEVPATH + string "Framebuffer device path" + default "/dev/fb0" + ---help--- + The path to the framebuffer device. Default: "/dev/fb0" + +endif # EXAMPLES_CALCULATOR diff --git a/examples/calculator/Make.defs b/examples/calculator/Make.defs new file mode 100644 index 00000000000..d9a1de44638 --- /dev/null +++ b/examples/calculator/Make.defs @@ -0,0 +1,25 @@ +############################################################################ +# apps/examples/calculator/Make.defs +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +ifneq ($(CONFIG_EXAMPLES_CALCULATOR),) +CONFIGURED_APPS += $(APPDIR)/examples/calculator +endif diff --git a/examples/calculator/Makefile b/examples/calculator/Makefile new file mode 100644 index 00000000000..cd19d62bb67 --- /dev/null +++ b/examples/calculator/Makefile @@ -0,0 +1,32 @@ +############################################################################ +# apps/examples/calculator/Makefile +# +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. The +# ASF licenses this file to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +############################################################################ + +include $(APPDIR)/Make.defs + +PROGNAME = calc +PRIORITY = $(CONFIG_EXAMPLES_CALCULATOR_PRIORITY) +STACKSIZE = $(CONFIG_EXAMPLES_CALCULATOR_STACKSIZE) +MODULE = $(CONFIG_EXAMPLES_CALCULATOR) + +MAINSRC = calculator_main.c + +include $(APPDIR)/Application.mk diff --git a/examples/calculator/calculator_main.c b/examples/calculator/calculator_main.c new file mode 100644 index 00000000000..167e86d3bc9 --- /dev/null +++ b/examples/calculator/calculator_main.c @@ -0,0 +1,518 @@ +/**************************************************************************** + * apps/examples/calculator/calculator_main.c + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +/**************************************************************************** + * Pre-processor Definitions + ****************************************************************************/ + +#undef NEED_BOARDINIT + +#if defined(CONFIG_BOARDCTL) && !defined(CONFIG_NSH_ARCHINIT) +# define NEED_BOARDINIT 1 +#endif + +#define CALC_DISPLAY_MAX 16 + +/**************************************************************************** + * Private Data + ****************************************************************************/ + +/* Set only by quit_signal_handler(), an async-signal-safe flag - the + * actual shutdown happens from the main loop, a known-safe point, + * rather than from the handler itself. See games/NXDoom's + * i_install_quit_signal()/i_poll_quit_signal() for the same pattern: + * an external supervisor (nxstore) has no in-app quit path to drive, + * so it can only ask this process to exit from the outside via + * SIGTERM. + */ + +static volatile sig_atomic_t g_quit_requested; + +static lv_obj_t *g_display; +static char g_display_buf[CALC_DISPLAY_MAX + 1] = "0"; +static double g_accumulator; +static char g_pending_op; +static bool g_start_new_entry = true; + +/**************************************************************************** + * Private Functions + ****************************************************************************/ + +static void quit_signal_handler(int signo) +{ + (void)signo; + g_quit_requested = 1; +} + +/* nxstore's own "Close" bar is confined to the top 36px of the display + * so it can coexist with full-screen children like this one - a + * convention that only works if the child app leaves that strip alone. + * This app's display/keypad fill the entire canvas, so nxstore's bar + * gets overdrawn as soon as this screen loads. Give the calculator its + * own close affordance instead: tapping it just sets the same quit flag + * SIGTERM does, so nxstore's nxstore_poll_running_app() sees the process + * exit on its own and returns to the app list automatically. + */ + +static void close_event_cb(lv_event_t *e) +{ + UNUSED(e); + g_quit_requested = 1; +} + +static void calc_update_display(void) +{ + lv_label_set_text(g_display, g_display_buf); +} + +static void calc_set_display_number(double value) +{ + snprintf(g_display_buf, sizeof(g_display_buf), "%.10g", value); + calc_update_display(); +} + +static double calc_apply(double a, double b, char op, bool *ok) +{ + *ok = true; + + switch (op) + { + case '+': + return a + b; + + case '-': + return a - b; + + case '*': + return a * b; + + case '/': + if (b == 0) + { + *ok = false; + return 0; + } + + return a / b; + + default: + return b; + } +} + +static void calc_clear(void) +{ + g_accumulator = 0; + g_pending_op = 0; + g_start_new_entry = true; + snprintf(g_display_buf, sizeof(g_display_buf), "0"); + calc_update_display(); +} + +static void calc_handle_digit(char digit) +{ + size_t len; + + if (g_start_new_entry) + { + g_display_buf[0] = '\0'; + g_start_new_entry = false; + } + + if (strcmp(g_display_buf, "0") == 0) + { + g_display_buf[0] = '\0'; + } + + len = strlen(g_display_buf); + if (len + 1 >= sizeof(g_display_buf)) + { + /* Display is already at capacity - drop the keypress rather than + * overflow g_display_buf or silently truncate mid-number. + */ + + return; + } + + g_display_buf[len] = digit; + g_display_buf[len + 1] = '\0'; + calc_update_display(); +} + +static void calc_handle_point(void) +{ + size_t len; + + if (g_start_new_entry) + { + snprintf(g_display_buf, sizeof(g_display_buf), "0"); + g_start_new_entry = false; + } + + if (strchr(g_display_buf, '.') != NULL) + { + return; + } + + len = strlen(g_display_buf); + if (len + 1 >= sizeof(g_display_buf)) + { + return; + } + + g_display_buf[len] = '.'; + g_display_buf[len + 1] = '\0'; + calc_update_display(); +} + +static void calc_handle_op(char op) +{ + double current = atof(g_display_buf); + bool ok; + + if (g_pending_op != 0) + { + g_accumulator = calc_apply(g_accumulator, current, g_pending_op, &ok); + if (!ok) + { + snprintf(g_display_buf, sizeof(g_display_buf), "Error"); + calc_update_display(); + g_pending_op = 0; + g_start_new_entry = true; + return; + } + } + else + { + g_accumulator = current; + } + + g_pending_op = op; + g_start_new_entry = true; + calc_set_display_number(g_accumulator); +} + +static void calc_handle_equals(void) +{ + double current = atof(g_display_buf); + bool ok; + + if (g_pending_op == 0) + { + g_accumulator = current; + } + else + { + g_accumulator = calc_apply(g_accumulator, current, g_pending_op, &ok); + if (!ok) + { + snprintf(g_display_buf, sizeof(g_display_buf), "Error"); + calc_update_display(); + g_pending_op = 0; + g_start_new_entry = true; + return; + } + } + + g_pending_op = 0; + g_start_new_entry = true; + calc_set_display_number(g_accumulator); +} + +static void button_event_cb(lv_event_t *e) +{ + FAR const char *code = lv_event_get_user_data(e); + + switch (code[0]) + { + case 'C': + calc_clear(); + break; + + case '.': + calc_handle_point(); + break; + + case '=': + calc_handle_equals(); + break; + + case '+': + case '-': + case '*': + case '/': + calc_handle_op(code[0]); + break; + + default: + calc_handle_digit(code[0]); + break; + } +} + +static lv_obj_t *make_button(lv_obj_t *parent, FAR const char *label, + FAR const char *code, uint32_t color) +{ + lv_obj_t *btn; + lv_obj_t *btn_label; + + btn = lv_obj_create(parent); + lv_obj_set_style_radius(btn, 10, 0); + lv_obj_set_style_bg_color(btn, lv_color_hex(color), 0); + lv_obj_set_style_bg_color(btn, lv_color_hex(0xffffff), + LV_STATE_PRESSED); + lv_obj_set_style_bg_opa(btn, LV_OPA_30, LV_STATE_PRESSED); + lv_obj_set_style_border_width(btn, 0, 0); + lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE); + + btn_label = lv_label_create(btn); + lv_label_set_text(btn_label, label); + lv_obj_set_style_text_color(btn_label, lv_color_hex(0xffffff), 0); + lv_obj_center(btn_label); + + lv_obj_add_event_cb(btn, button_event_cb, LV_EVENT_CLICKED, + (void *)code); + + return btn; +} + +/**************************************************************************** + * Public Functions + ****************************************************************************/ + +int main(int argc, FAR char *argv[]) +{ + lv_nuttx_dsc_t info; + lv_nuttx_result_t result; + lv_obj_t *screen; + lv_obj_t *grid; + lv_obj_t *close_btn; + lv_obj_t *close_label; + struct sigaction sa; + int32_t display_height; + int32_t display_width; + static const char *const keys[5][4] = + { + { "7", "8", "9", "/" }, + { "4", "5", "6", "*" }, + { "1", "2", "3", "-" }, + { "C", "0", ".", "+" }, + { "=", "=", "=", "=" }, + }; + + static const int32_t col_dsc[] = + { + LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), + LV_GRID_TEMPLATE_LAST + }; + + static const int32_t row_dsc[] = + { + LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), LV_GRID_FR(1), + LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST + }; + + g_quit_requested = 0; + g_display_buf[0] = '0'; + g_display_buf[1] = '\0'; + g_accumulator = 0; + g_pending_op = 0; + g_start_new_entry = true; + + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = quit_signal_handler; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGTERM, &sa, NULL) < 0) + { + LV_LOG_ERROR("calculator: failed to install SIGTERM handler"); + return 1; + } + + if (lv_is_initialized()) + { + LV_LOG_ERROR("LVGL already initialized! aborting."); + return -1; + } + +#ifdef NEED_BOARDINIT + boardctl(BOARDIOC_INIT, 0); +#endif + + lv_init(); + + lv_nuttx_dsc_init(&info); + info.fb_path = CONFIG_EXAMPLES_CALCULATOR_FBDEVPATH; + info.input_path = CONFIG_EXAMPLES_CALCULATOR_INPUT_DEVPATH; + + lv_nuttx_init(&info, &result); + + if (result.disp == NULL) + { + LV_LOG_ERROR("calculator: LVGL display init failed!"); + lv_nuttx_deinit(&result); + lv_deinit(); + return 1; + } + + if (result.indev == NULL) + { + LV_LOG_ERROR("calculator: touchscreen init failed!"); + lv_nuttx_deinit(&result); + lv_deinit(); + return 1; + } + + display_width = lv_display_get_horizontal_resolution(result.disp); + display_height = lv_display_get_vertical_resolution(result.disp); + if (display_width <= 0 || display_height <= NXSTORE_BAR_HEIGHT) + { + LV_LOG_ERROR("calculator: display is too small"); + lv_nuttx_deinit(&result); + lv_deinit(); + return 1; + } + + screen = lv_obj_create(NULL); + lv_obj_set_size(screen, display_width, + display_height - NXSTORE_BAR_HEIGHT); + lv_obj_set_pos(screen, 0, NXSTORE_BAR_HEIGHT); + lv_obj_set_style_bg_color(screen, lv_color_hex(0x101827), 0); + lv_obj_set_style_border_width(screen, 0, 0); + lv_obj_clear_flag(screen, LV_OBJ_FLAG_SCROLLABLE); + + g_display = lv_label_create(screen); + lv_obj_set_width(g_display, LV_PCT(90)); + lv_obj_set_style_text_font(g_display, &lv_font_montserrat_20, 0); + lv_obj_set_style_text_color(g_display, lv_color_hex(0xffffff), 0); + lv_obj_set_style_text_align(g_display, LV_TEXT_ALIGN_RIGHT, 0); + lv_label_set_text(g_display, g_display_buf); + lv_obj_align(g_display, LV_ALIGN_TOP_MID, 0, 48); + + close_btn = lv_obj_create(screen); + lv_obj_set_size(close_btn, 36, 36); + lv_obj_align(close_btn, LV_ALIGN_TOP_RIGHT, -8, 8); + lv_obj_set_style_radius(close_btn, 8, 0); + lv_obj_set_style_bg_color(close_btn, lv_color_hex(0xf0554c), 0); + lv_obj_set_style_bg_color(close_btn, lv_color_hex(0xb03830), + LV_STATE_PRESSED); + lv_obj_set_style_border_width(close_btn, 0, 0); + lv_obj_clear_flag(close_btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(close_btn, LV_OBJ_FLAG_CLICKABLE); + + close_label = lv_label_create(close_btn); + lv_label_set_text(close_label, LV_SYMBOL_CLOSE); + lv_obj_set_style_text_color(close_label, lv_color_hex(0xffffff), 0); + lv_obj_clear_flag(close_label, LV_OBJ_FLAG_CLICKABLE); + lv_obj_center(close_label); + + lv_obj_add_event_cb(close_btn, close_event_cb, LV_EVENT_CLICKED, NULL); + + grid = lv_obj_create(screen); + lv_obj_set_size(grid, LV_PCT(90), LV_PCT(70)); + lv_obj_align(grid, LV_ALIGN_BOTTOM_MID, 0, -16); + lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(grid, 0, 0); + lv_obj_set_style_pad_all(grid, 4, 0); + lv_obj_set_style_pad_gap(grid, 8, 0); + lv_obj_clear_flag(grid, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_grid_dsc_array(grid, col_dsc, row_dsc); + lv_obj_set_layout(grid, LV_LAYOUT_GRID); + + for (int row = 0; row < 5; row++) + { + for (int col = 0; col < 4; col++) + { + FAR const char *code = keys[row][col]; + uint32_t color = 0x2a3648; + lv_obj_t *btn; + + if (row == 4) + { + /* Single wide "=" button spans the whole row - only + * instantiate it once, on the first column. + */ + + if (col > 0) + { + continue; + } + } + + if (strchr("+-*/=", code[0]) != NULL) + { + color = 0x3d8bff; + } + else if (code[0] == 'C') + { + color = 0xf0554c; + } + + btn = make_button(grid, code, code, color); + if (row == 4) + { + lv_obj_set_grid_cell(btn, LV_GRID_ALIGN_STRETCH, 0, 4, + LV_GRID_ALIGN_STRETCH, row, 1); + } + else + { + lv_obj_set_grid_cell(btn, LV_GRID_ALIGN_STRETCH, col, 1, + LV_GRID_ALIGN_STRETCH, row, 1); + } + } + } + + lv_screen_load(screen); + + while (!g_quit_requested) + { + uint32_t idle; + + idle = lv_timer_handler(); + + /* Minimum sleep of 1ms */ + + idle = idle ? idle : 1; + usleep(idle * 1000); + } + + lv_nuttx_deinit(&result); + lv_deinit(); + + return 0; +} From d7b08fd4126dcb672259180db2b228f50edd00f1 Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Fri, 24 Jul 2026 01:40:21 +0530 Subject: [PATCH 13/15] games: Support supervised loadable game processes. Allow cgol, match4, and snake to be selected as loadable modules while preserving built-in builds in both Make and CMake. For cgol, use a safer 4 KiB default stack, fix the FBIO_UPDATE area object, add an optional top-row offset for launcher controls, and handle SIGTERM cooperatively. Validate physical and virtual framebuffer dimensions and release mapped or allocated buffers on every exit path. Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: aviralgarg05 --- games/cgol/Kconfig | 12 +++- games/cgol/cgol_main.c | 132 ++++++++++++++++++++++++++++++++++------- games/match4/Kconfig | 2 +- games/snake/Kconfig | 2 +- 4 files changed, 121 insertions(+), 27 deletions(-) diff --git a/games/cgol/Kconfig b/games/cgol/Kconfig index d07a7125163..3271b82ded6 100644 --- a/games/cgol/Kconfig +++ b/games/cgol/Kconfig @@ -4,7 +4,7 @@ # config GAMES_CGOL - bool "Conway's Game of Life" + tristate "Conway's Game of Life" depends on VIDEO_FB default n ---help--- @@ -25,7 +25,7 @@ config GAMES_CGOL_PRIORITY config GAMES_CGOL_STACKSIZE int "CGOL stack size" - default DEFAULT_TASK_STACKSIZE + default 4096 config GAMES_CGOL_FBDEV string "CGOL frame buffer device path" @@ -34,6 +34,14 @@ config GAMES_CGOL_FBDEV The frame buffer device path that will be used by default for rendering. Device path can still be selected from first command line argument. +config GAMES_CGOL_YOFFSET + int "Top framebuffer rows to reserve" + range 0 500000 + default 0 + ---help--- + Leave this many rows at the top of the framebuffer unchanged. This + allows a launcher or supervisor to reserve a status or control bar. + config GAMES_CGOL_DBLBUF bool "CGOL double buffered" default n diff --git a/games/cgol/cgol_main.c b/games/cgol/cgol_main.c index 88e26d2f728..70e7554f134 100644 --- a/games/cgol/cgol_main.c +++ b/games/cgol/cgol_main.c @@ -82,6 +82,7 @@ #include #include #include +#include #include #include #include @@ -174,6 +175,14 @@ struct fb_state_s struct fb_videoinfo_s vinfo; struct fb_planeinfo_s pinfo; unsigned int scale; + + /* Rows [0, abs_yoff) are reserved for a launcher or supervisor and are + * never cleared or drawn to. + */ + + unsigned int abs_yoff; + unsigned int usable_yres; + int fd; void *fb; /* Real frame buffer */ #ifdef CONFIG_GAMES_CGOL_DBLBUF @@ -190,10 +199,27 @@ typedef void (*cell_render_f)(const struct fb_state_s *, uint32_t x, * Private Data ****************************************************************************/ +/* Set only by quit_signal_handler(), an async-signal-safe flag - the + * actual shutdown happens from the main render loop, a known-safe + * point, rather than from the handler itself. cgol has no natural + * exit condition of its own (it renders forever), so without this, an + * external supervisor (nxstore) has no way to ask this process to + * stop - see games/NXDoom's i_install_quit_signal()/ + * i_poll_quit_signal() for the same pattern. + */ + +static volatile sig_atomic_t g_quit_requested; + /**************************************************************************** * Private Functions ****************************************************************************/ +static void quit_signal_handler(int signo) +{ + (void)signo; + g_quit_requested = 1; +} + /**************************************************************************** * Name: cgol_init * @@ -515,28 +541,35 @@ static void cgol_advance(unsigned int *map) static void cgol_render_update(const struct fb_state_s *state) { #ifdef CONFIG_FB_UPDATE - struct fb_area_s *full_screen; + struct fb_area_s playfield; + int err; - /* Create an area with the dimensions of the full screen for updating the - * frame buffer after render operations are complete. + /* Only the area below the configured offset is cleared or drawn to. + * Restrict the update area to match. */ - full_screen.x = 0; - full_screen.y = 0; - full_screen.w = fb_state.vinfo.xres; - full_screen.h = fb_state.vinfo.yres; + playfield.x = 0; + playfield.y = state->abs_yoff; + playfield.w = state->vinfo.xres; + playfield.h = state->usable_yres; #endif - /* If double buffering, copy the RAM buffer to the frame buffer */ + /* If double buffering, copy the RAM buffer to the frame buffer. Skip + * reserved rows at the top of both buffers because rambuf never clears + * or draws into them. + */ #ifdef CONFIG_GAMES_CGOL_DBLBUF - memcpy(state->fb, state->rambuf, state->pinfo.fblen); + memcpy((FAR uint8_t *)state->fb + state->pinfo.stride * state->abs_yoff, + (FAR uint8_t *)state->rambuf + + state->pinfo.stride * state->abs_yoff, + state->pinfo.stride * state->usable_yres); #endif /* If the frame buffer on this device needs explicit updates, do that */ #ifdef CONFIG_FB_UPDATE - err = ioctl(fb_state.fd, FBIO_UPDATE, (uintptr_t)&full_screen); + err = ioctl(state->fd, FBIO_UPDATE, (uintptr_t)&playfield); if (err < 0) { fprintf(stderr, "Couldn't update screen: %d\n", errno); @@ -562,14 +595,18 @@ static void cgol_render_clear(const struct fb_state_s *state) { /* TODO: we can do this more efficiently if we just blot out the pixels * used for live cells last frame. + * + * Rows [0, abs_yoff) are reserved. Every case below starts at abs_yoff + * and only clears usable_yres rows. */ switch (state->pinfo.bpp) { case 32: - for (uint32_t y = 0; y < state->pinfo.yres_virtual; y++) + for (uint32_t y = 0; y < state->usable_yres; y++) { - uint8_t *row = render_buf(state) + state->pinfo.stride * y; + uint8_t *row = render_buf(state) + + state->pinfo.stride * (y + state->abs_yoff); for (uint32_t x = 0; x < state->pinfo.xres_virtual; x++) { ((uint32_t *)(row))[x] = BG32; @@ -578,9 +615,10 @@ static void cgol_render_clear(const struct fb_state_s *state) break; case 24: - for (uint32_t y = 0; y < state->pinfo.yres_virtual; y++) + for (uint32_t y = 0; y < state->usable_yres; y++) { - uint8_t *row = render_buf(state) + state->pinfo.stride * y; + uint8_t *row = render_buf(state) + + state->pinfo.stride * (y + state->abs_yoff); for (uint32_t x = 0; x < state->pinfo.xres_virtual; x++) { *row++ = RGB24BLUE(BG24); @@ -592,9 +630,10 @@ static void cgol_render_clear(const struct fb_state_s *state) case 16: { - for (uint32_t y = 0; y < state->pinfo.yres_virtual; y++) + for (uint32_t y = 0; y < state->usable_yres; y++) { - uint8_t *row = render_buf(state) + state->pinfo.stride * y; + uint8_t *row = render_buf(state) + + state->pinfo.stride * (y + state->abs_yoff); for (uint32_t x = 0; x < state->pinfo.xres_virtual; x++) { ((uint16_t *)(row))[x] = BG16; @@ -604,7 +643,12 @@ static void cgol_render_clear(const struct fb_state_s *state) break; case 8: - memset(render_buf(state), BG8, state->pinfo.fblen); + for (uint32_t y = 0; y < state->usable_yres; y++) + { + uint8_t *row = render_buf(state) + + state->pinfo.stride * (y + state->abs_yoff); + memset(row, BG8, state->pinfo.xres_virtual); + } break; } } @@ -633,6 +677,10 @@ static void cgol_render_cell8(const struct fb_state_s *state, uint32_t x, x *= state->scale; y *= state->scale; + /* Shift down past the reserved rows */ + + y += state->abs_yoff; + /* Starting at the (x, y) pair, we draw `scale` cells in each direction */ for (uint8_t yy = 0; yy < state->scale; yy++) @@ -671,6 +719,10 @@ static void cgol_render_cell16(const struct fb_state_s *state, uint32_t x, x *= state->scale; y *= state->scale; + /* Shift down past the reserved rows */ + + y += state->abs_yoff; + /* Starting at the (x, y) pair, we draw `scale` cells in each direction */ for (uint8_t yy = 0; yy < state->scale; yy++) @@ -709,6 +761,10 @@ static void cgol_render_cell24(const struct fb_state_s *state, uint32_t x, x *= state->scale; y *= state->scale; + /* Shift down past the reserved rows */ + + y += state->abs_yoff; + /* Starting at the (x, y) pair, we draw `scale` cells in each direction */ for (uint8_t yy = 0; yy < state->scale; yy++) @@ -749,6 +805,10 @@ static void cgol_render_cell32(const struct fb_state_s *state, uint32_t x, x *= state->scale; y *= state->scale; + /* Shift down past the reserved rows */ + + y += state->abs_yoff; + /* Starting at the (x, y) pair, we draw `scale` cells in each direction */ for (uint8_t yy = 0; yy < state->scale; yy++) @@ -914,6 +974,17 @@ int main(int argc, FAR char *argv[]) unsigned int map[WORD_COUNT]; unsigned int yscale; unsigned int xscale; + struct sigaction sa; + + g_quit_requested = 0; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = quit_signal_handler; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGTERM, &sa, NULL) < 0) + { + fprintf(stderr, "Failed to install SIGTERM handler: %d\n", errno); + return EXIT_FAILURE; + } if (argc == 2) { @@ -949,16 +1020,27 @@ int main(int argc, FAR char *argv[]) return EXIT_FAILURE; } + /* Leave the configured top rows untouched and fold that into the minimum + * resolution check below. + */ + + fb_state.abs_yoff = CONFIG_GAMES_CGOL_YOFFSET; + /* If the frame buffer resolution is too small to support our game at its * lowest resolution (one pixel per cell), we can't play :( */ if (fb_state.vinfo.xres < CONFIG_GAMES_CGOL_MAPWIDTH || - fb_state.vinfo.yres < CONFIG_GAMES_CGOL_MAPHEIGHT) + fb_state.vinfo.yres < + CONFIG_GAMES_CGOL_MAPHEIGHT + fb_state.abs_yoff || + fb_state.pinfo.xres_virtual < CONFIG_GAMES_CGOL_MAPWIDTH || + fb_state.pinfo.yres_virtual < + CONFIG_GAMES_CGOL_MAPHEIGHT + fb_state.abs_yoff) { fprintf(stderr, - "Needed at least %u x %u px resolution, but got %u x %u", - CONFIG_GAMES_CGOL_MAPWIDTH, CONFIG_GAMES_CGOL_MAPHEIGHT, + "Needed at least %u x %u px resolution, but got %u x %u\n", + CONFIG_GAMES_CGOL_MAPWIDTH, + CONFIG_GAMES_CGOL_MAPHEIGHT + fb_state.abs_yoff, fb_state.vinfo.xres, fb_state.vinfo.yres); close(fb_state.fd); return EXIT_FAILURE; @@ -1001,6 +1083,7 @@ int main(int argc, FAR char *argv[]) if (fb_state.rambuf == NULL) { fprintf(stderr, "Couldn't allocate double buffer: %d\n", errno); + munmap(fb_state.fb, fb_state.pinfo.fblen); close(fb_state.fd); return EXIT_FAILURE; } @@ -1012,8 +1095,10 @@ int main(int argc, FAR char *argv[]) * This is selected by picking the minimum of the scale options. */ + fb_state.usable_yres = fb_state.pinfo.yres_virtual - fb_state.abs_yoff; + xscale = fb_state.pinfo.xres_virtual / CONFIG_GAMES_CGOL_MAPWIDTH; - yscale = fb_state.pinfo.yres_virtual / CONFIG_GAMES_CGOL_MAPHEIGHT; + yscale = fb_state.usable_yres / CONFIG_GAMES_CGOL_MAPHEIGHT; fb_state.scale = xscale < yscale ? xscale : yscale; /* Now we can seed the game with some random starting cells */ @@ -1024,9 +1109,9 @@ int main(int argc, FAR char *argv[]) cgol_render_clear(&fb_state); - /* Loop the game forever */ + /* Loop the game until asked to quit */ - for (; ; ) + while (!g_quit_requested) { /* Render the freshly calculated cells */ @@ -1052,6 +1137,7 @@ int main(int argc, FAR char *argv[]) #ifdef CONFIG_GAMES_CGOL_DBLBUF free(fb_state.rambuf); #endif + munmap(fb_state.fb, fb_state.pinfo.fblen); close(fb_state.fd); return EXIT_SUCCESS; } diff --git a/games/match4/Kconfig b/games/match4/Kconfig index cd916fd459e..9cb1dd801db 100644 --- a/games/match4/Kconfig +++ b/games/match4/Kconfig @@ -4,7 +4,7 @@ # config GAMES_MATCH4 - bool "Match 4 Game" + tristate "Match 4 Game" default n ---help--- Enable Match 4 game. diff --git a/games/snake/Kconfig b/games/snake/Kconfig index 35be6003512..7f76396a7a6 100644 --- a/games/snake/Kconfig +++ b/games/snake/Kconfig @@ -4,7 +4,7 @@ # config GAMES_SNAKE - bool "Snake Game" + tristate "Snake Game" default n ---help--- Enable Snake game. From 5e09f0b0dee79de2555b9f2af5b2502c3eed1cee Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Sun, 12 Jul 2026 20:52:43 +0530 Subject: [PATCH 14/15] games/brickmatch: Add touchscreen input and module support. Allow Brickmatch to build as either a built-in application or loadable module. Add configurable touchscreen swipe input with aligned samples, release handling, and explicit read-error propagation. Handle SIGTERM cooperatively, make the framebuffer top offset configurable for launcher controls, validate physical and virtual dimensions, and release input, framebuffer, and device resources on exit. Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: aviralgarg05 --- games/brickmatch/Kconfig | 36 +++++- games/brickmatch/bm_input_touch.h | 183 ++++++++++++++++++++++++++++++ games/brickmatch/bm_inputs.h | 3 + games/brickmatch/bm_main.c | 139 ++++++++++++++++++++--- 4 files changed, 344 insertions(+), 17 deletions(-) create mode 100644 games/brickmatch/bm_input_touch.h diff --git a/games/brickmatch/Kconfig b/games/brickmatch/Kconfig index 1567a4ebccf..e51be23fc72 100644 --- a/games/brickmatch/Kconfig +++ b/games/brickmatch/Kconfig @@ -4,7 +4,7 @@ # config GAMES_BRICKMATCH - bool "Brickmatch Game" + tristate "Brickmatch Game" default n ---help--- Enable Brickmatch games. Brickmatch is like a mix @@ -88,6 +88,10 @@ config GAMES_BRICKMATCH_USE_GESTURE config GAMES_BRICKMATCH_USE_GPIO bool "GPIO pins as Input" + +config GAMES_BRICKMATCH_USE_TOUCHSCREEN + bool "Touchscreen swipe as input" + depends on INPUT_TOUCHSCREEN endchoice if GAMES_BRICKMATCH_USE_GPIO @@ -118,4 +122,34 @@ config GAMES_BRICKMATCH_RIGHT_KEY_PATH endif #GAMES_BRICKMATCH_USE_GPIO +if GAMES_BRICKMATCH_USE_TOUCHSCREEN + +config GAMES_BRICKMATCH_TOUCH_DEVPATH + string "Touchscreen device path" + default "/dev/input0" + ---help--- + Path of the touchscreen device to read swipe gestures from. + +config GAMES_BRICKMATCH_TOUCH_THRESHOLD + int "Minimum swipe distance" + range 1 32767 + default 24 + ---help--- + Minimum movement in calibrated touchscreen coordinate units before + a drag is reported as a direction. + +endif #GAMES_BRICKMATCH_USE_TOUCHSCREEN + +if !GAMES_BRICKMATCH_USE_LED_MATRIX + +config GAMES_BRICKMATCH_YOFFSET + int "Top framebuffer rows to reserve" + range 0 500000 + default 0 + ---help--- + Leave this many rows at the top of the framebuffer unchanged. This + allows a launcher or supervisor to reserve a status or control bar. + +endif # !GAMES_BRICKMATCH_USE_LED_MATRIX + endif diff --git a/games/brickmatch/bm_input_touch.h b/games/brickmatch/bm_input_touch.h new file mode 100644 index 00000000000..09ea7183396 --- /dev/null +++ b/games/brickmatch/bm_input_touch.h @@ -0,0 +1,183 @@ +/**************************************************************************** + * apps/games/brickmatch/bm_input_touch.h + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. The + * ASF licenses this file to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + ****************************************************************************/ + +#ifndef __APPS_GAMES_BRICKMATCH_BM_INPUT_TOUCH_H +#define __APPS_GAMES_BRICKMATCH_BM_INPUT_TOUCH_H + +/**************************************************************************** + * Included Files + ****************************************************************************/ + +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include "bm_inputs.h" + +/**************************************************************************** + * Preprocessor Definitions + ****************************************************************************/ + +/* Tracks the in-progress drag so each dev_read_input() call only has to + * look at the latest sample, not replay history. Reset on TOUCH_UP so a + * finger lift always starts the next swipe's baseline fresh; re-baselined + * (rather than requiring a lift) after firing a direction, so a single + * continued drag can fire more than one swipe. + */ + +static bool g_bm_touch_active; +static int32_t g_bm_touch_origin_x; +static int32_t g_bm_touch_origin_y; + +/**************************************************************************** + * Name: dev_input_init + * + * Description: + * Initialize input method. + * + * Parameters: + * dev - Input state data + * + * Returned Value: + * Zero (OK) is returned on success. A negated errno value is returned on + * failure. + * + ****************************************************************************/ + +int dev_input_init(FAR struct input_state_s *dev) +{ + dev->fd_touch = open(CONFIG_GAMES_BRICKMATCH_TOUCH_DEVPATH, + O_RDONLY | O_NONBLOCK); + if (dev->fd_touch < 0) + { + int errcode = errno; + fprintf(stderr, "ERROR: Failed to open %s: %d\n", + CONFIG_GAMES_BRICKMATCH_TOUCH_DEVPATH, errcode); + return -errcode; + } + + g_bm_touch_active = false; + + return OK; +} + +/**************************************************************************** + * Name: dev_read_input + * + * Description: + * Read inputs and returns result in input state data. Interprets a + * drag/swipe gesture rather than an absolute tap position, since + * brickmatch's board layout has no natural on-screen buttons to tap - + * this mirrors how the gesture-sensor backend (bm_input_gesture.h) + * reports discrete directions, just derived from a touchscreen instead + * of a dedicated gesture sensor. + * + * Parameters: + * dev - Input state data + * + * Returned Value: + * Zero (OK) + * + ****************************************************************************/ + +int dev_read_input(FAR struct input_state_s *dev) +{ + struct touch_sample_s sample; + int32_t dx; + int32_t dy; + ssize_t nbytes; + + dev->dir = DIR_NONE; + + nbytes = read(dev->fd_touch, &sample, sizeof(sample)); + if (nbytes < 0) + { + if (errno == EAGAIN || errno == EWOULDBLOCK) + { + usleep(10000); + return OK; + } + + return -errno; + } + + if (nbytes != sizeof(sample) || sample.npoints < 1) + { + return OK; + } + + if (sample.point[0].flags & TOUCH_UP) + { + g_bm_touch_active = false; + return OK; + } + + if ((sample.point[0].flags & TOUCH_POS_VALID) == 0) + { + return OK; + } + + if (sample.point[0].flags & TOUCH_DOWN) + { + g_bm_touch_active = true; + g_bm_touch_origin_x = sample.point[0].x; + g_bm_touch_origin_y = sample.point[0].y; + return OK; + } + + if (!g_bm_touch_active) + { + return OK; + } + + dx = sample.point[0].x - g_bm_touch_origin_x; + dy = sample.point[0].y - g_bm_touch_origin_y; + + if (abs(dx) < CONFIG_GAMES_BRICKMATCH_TOUCH_THRESHOLD && + abs(dy) < CONFIG_GAMES_BRICKMATCH_TOUCH_THRESHOLD) + { + return OK; + } + + if (abs(dx) > abs(dy)) + { + dev->dir = dx > 0 ? DIR_RIGHT : DIR_LEFT; + } + else + { + dev->dir = dy > 0 ? DIR_DOWN : DIR_UP; + } + + g_bm_touch_origin_x = sample.point[0].x; + g_bm_touch_origin_y = sample.point[0].y; + + return OK; +} + +#endif /* __APPS_GAMES_BRICKMATCH_BM_INPUT_TOUCH_H */ diff --git a/games/brickmatch/bm_inputs.h b/games/brickmatch/bm_inputs.h index 9497b313599..f68c8047b67 100644 --- a/games/brickmatch/bm_inputs.h +++ b/games/brickmatch/bm_inputs.h @@ -64,6 +64,9 @@ struct input_state_s #ifdef CONFIG_GAMES_BRICKMATCH_USE_GPIO int fd_gpio; #endif +#ifdef CONFIG_GAMES_BRICKMATCH_USE_TOUCHSCREEN + int fd_touch; +#endif int dir; /* Direction to move the blocks */ }; diff --git a/games/brickmatch/bm_main.c b/games/brickmatch/bm_main.c index b9e63ca5d92..dc3e7bd50fb 100644 --- a/games/brickmatch/bm_main.c +++ b/games/brickmatch/bm_main.c @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -58,6 +59,10 @@ #include "bm_input_gpio.h" #endif +#ifdef CONFIG_GAMES_BRICKMATCH_USE_TOUCHSCREEN +#include "bm_input_touch.h" +#endif + /**************************************************************************** * Preprocessor Definitions ****************************************************************************/ @@ -93,6 +98,17 @@ struct screen_state_s struct fb_overlayinfo_s oinfo; #endif FAR void *fbmem; + +#ifndef CONFIG_GAMES_BRICKMATCH_USE_LED_MATRIX + /* Absolute placement of the game_screen_s board within the physical + * framebuffer. draw_rect() and update_screen() add these to every + * board-relative area, so the rest of the coordinate math stays in + * board-local coordinates that start at (0, 0). + */ + + int abs_xoff; + int abs_yoff; +#endif }; struct game_screen_s @@ -112,6 +128,19 @@ struct game_screen_s * Private Data ****************************************************************************/ +/* Set only by quit_signal_handler(), an async-signal-safe flag. Actual + * shutdown happens from the main loop so a launcher can ask either a + * built-in application or loadable module to stop cooperatively. + */ + +static volatile sig_atomic_t g_quit_requested; + +static void quit_signal_handler(int signo) +{ + (void)signo; + g_quit_requested = 1; +} + #ifndef CONFIG_GAMES_BRICKMATCH_USE_LED_MATRIX static const char g_default_fbdev[] = "/dev/fb0"; #else @@ -133,7 +162,7 @@ int prev_board[ROW][COL] = /* Colors used in the game plus Black */ -static const uint16_t pallete[NCOLORS + 1] = +static const uint16_t palette[NCOLORS + 1] = { RGB16_BLACK, RGB16_BLUE, @@ -173,13 +202,14 @@ static void draw_rect(FAR struct screen_state_s *state, int x; int y; - row = (FAR uint8_t *)state->fbmem + state->pinfo.stride * area->y; + row = (FAR uint8_t *)state->fbmem + + state->pinfo.stride * (area->y + state->abs_yoff); for (y = 0; y < area->h; y++) { - dest = ((FAR uint16_t *)row) + area->x; + dest = ((FAR uint16_t *)row) + area->x + state->abs_xoff; for (x = 0; x < area->w; x++) { - *dest++ = pallete[color]; + *dest++ = palette[color]; } row += state->pinfo.stride; @@ -204,11 +234,17 @@ static void draw_rect(FAR struct screen_state_s *state, void update_screen(FAR struct screen_state_s *state, FAR struct fb_area_s *area) { +#ifdef CONFIG_FB_UPDATE + struct fb_area_s abs_area; int ret; -#ifdef CONFIG_FB_UPDATE + abs_area.x = area->x + state->abs_xoff; + abs_area.y = area->y + state->abs_yoff; + abs_area.w = area->w; + abs_area.h = area->h; + ret = ioctl(state->fd_fb, FBIO_UPDATE, - (unsigned long)((uintptr_t)area)); + (unsigned long)((uintptr_t)&abs_area)); if (ret < 0) { int errcode = errno; @@ -410,7 +446,7 @@ void draw_board(FAR struct screen_state_s *state, { for (y = 1; y <= BOARDY_SIZE; y++) { - rgb_val = RGB16TO24(pallete[board[x][y]]); + rgb_val = RGB16TO24(palette[board[x][y]]); tmp = dim_color(rgb_val, 0.15); *bp++ = ws2812_gamma_correct(tmp); } @@ -990,7 +1026,23 @@ int main(int argc, FAR char *argv[]) struct input_state_s input; struct screen_state_s state; struct fb_area_s area; + struct sigaction sa; int ret; + int status = EXIT_SUCCESS; +#ifndef CONFIG_GAMES_BRICKMATCH_USE_LED_MATRIX + int usable_yres; +#endif + + g_quit_requested = 0; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = quit_signal_handler; + sigemptyset(&sa.sa_mask); + if (sigaction(SIGTERM, &sa, NULL) < 0) + { + fprintf(stderr, "ERROR: Failed to install SIGTERM handler: %d\n", + errno); + return EXIT_FAILURE; + } init_board(); @@ -1024,12 +1076,23 @@ int main(int argc, FAR char *argv[]) printf(" yres: %u\n", state.vinfo.yres); printf(" nplanes: %u\n", state.vinfo.nplanes); - /* Setup the screen information */ + /* Setup the screen information. The board is square and sized to the + * smaller display dimension after the configured top rows are reserved. + * Place that square below the reserved rows and horizontally center it. + */ + + if (state.vinfo.yres <= CONFIG_GAMES_BRICKMATCH_YOFFSET) + { + fprintf(stderr, "ERROR: Framebuffer is shorter than the y offset\n"); + close(state.fd_fb); + return EXIT_FAILURE; + } + + usable_yres = state.vinfo.yres - CONFIG_GAMES_BRICKMATCH_YOFFSET; + screen.xres = state.vinfo.xres > usable_yres ? + usable_yres : state.vinfo.xres; + screen.yres = screen.xres; - screen.xres = state.vinfo.xres > state.vinfo.yres ? - state.vinfo.yres : state.vinfo.xres; - screen.yres = state.vinfo.yres > state.vinfo.xres ? - state.vinfo.xres : state.vinfo.yres; screen.xoff = (screen.xres % NCOLORS) / 2; screen.yoff = (screen.yres % NCOLORS) / 2; screen.steps = 2; @@ -1037,6 +1100,9 @@ int main(int argc, FAR char *argv[]) screen.blklen = (screen.xres / NCOLORS); screen.ncolors = NCOLORS; + state.abs_xoff = (state.vinfo.xres - screen.xres) / 2; + state.abs_yoff = CONFIG_GAMES_BRICKMATCH_YOFFSET; + /* Get the display planeinfo */ ret = ioctl(state.fd_fb, FBIOGET_PLANEINFO, @@ -1057,6 +1123,14 @@ int main(int argc, FAR char *argv[]) printf(" display: %u\n", state.pinfo.display); printf(" bpp: %u\n", state.pinfo.bpp); + if (state.pinfo.xres_virtual < state.abs_xoff + screen.xres || + state.pinfo.yres_virtual < state.abs_yoff + screen.yres) + { + fprintf(stderr, "ERROR: Virtual framebuffer is too small\n"); + close(state.fd_fb); + return EXIT_FAILURE; + } + state.fbmem = mmap(NULL, state.pinfo.fblen, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FILE, state.fd_fb, 0); if (state.fbmem == MAP_FAILED) @@ -1088,15 +1162,37 @@ int main(int argc, FAR char *argv[]) draw_board(&state, &area, &screen); - dev_input_init(&input); + ret = dev_input_init(&input); + if (ret < 0) + { + fprintf(stderr, "ERROR: Failed to initialize input: %d\n", ret); +#ifndef CONFIG_GAMES_BRICKMATCH_USE_LED_MATRIX + munmap(state.fbmem, state.pinfo.fblen); +#else + free(state.fbmem); +#endif + close(state.fd_fb); + return EXIT_FAILURE; + } input.dir = DIR_NONE; - while (1) + while (!g_quit_requested) { - while (input.dir == DIR_NONE) + while (input.dir == DIR_NONE && !g_quit_requested) { ret = dev_read_input(&input); + if (ret < 0) + { + fprintf(stderr, "ERROR: Failed to read input: %d\n", ret); + status = EXIT_FAILURE; + g_quit_requested = 1; + } + } + + if (g_quit_requested) + { + break; } screen.dir = input.dir; @@ -1168,5 +1264,16 @@ int main(int argc, FAR char *argv[]) fill_edge(); } - return 0; +#ifndef CONFIG_GAMES_BRICKMATCH_USE_LED_MATRIX + munmap(state.fbmem, state.pinfo.fblen); +#else + free(state.fbmem); +#endif + +#ifdef CONFIG_GAMES_BRICKMATCH_USE_TOUCHSCREEN + close(input.fd_touch); +#endif + close(state.fd_fb); + + return status; } From de1a59a972a9d92394ab2bc4019bece7f1ad7bde Mon Sep 17 00:00:00 2001 From: aviralgarg05 Date: Tue, 14 Jul 2026 00:52:30 +0530 Subject: [PATCH 15/15] tools: Export package metadata and RGB565 icons. Keep the existing package specification syntax backward compatible, including source paths containing colons. Add separate name-keyed options for optional descriptions, categories, and icon source images. Convert icons to a fixed 48 by 48 little-endian RGB565 payload with an LVGL-compatible image header, and place them at package-level repository paths for nxstore caching. Assisted-by: OpenAI Codex:gpt-5.6-sol Signed-off-by: aviralgarg05 --- tools/export_pkg_repo.py | 122 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tools/export_pkg_repo.py b/tools/export_pkg_repo.py index 36d61143416..ce3f432b978 100644 --- a/tools/export_pkg_repo.py +++ b/tools/export_pkg_repo.py @@ -32,6 +32,7 @@ import hashlib import json import shutil +import struct from dataclasses import dataclass from pathlib import Path from typing import Dict, List @@ -41,6 +42,21 @@ "package must look like " ":::" ) +# Fixed square icon size nxstore renders app-list icons at (see +# nxstore_load_icon() in system/nxstore/nxstore_main.c) - small enough to +# stay a quick download over the board's WiFi, large enough to read as +# actual artwork rather than a favicon. +ICON_SIZE = 48 + +# Matches lv_image_header_t's own bit layout (LVGL v9, see +# graphics/lvgl/lvgl/src/draw/lv_image_dsc.h): magic/cf/flags packed into +# the first 4 bytes, then w/h, then stride/reserved - all little-endian, +# 12 bytes total, immediately followed by raw pixel data. This is +# deliberately the simplest format LVGL can render directly with no +# decoder, since the board has no PNG/JPEG decode capability wired up. +LV_IMAGE_HEADER_MAGIC = 0x19 +LV_COLOR_FORMAT_RGB565 = 0x12 + @dataclass class PackageSpec: @@ -88,6 +104,24 @@ def parse_package_spec(value: str) -> PackageSpec: ) +def parse_name_value(value: str) -> tuple: + parts = value.split("=", 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + raise argparse.ArgumentTypeError("value must look like =") + + return parts[0], parts[1] + + +def parse_icon_spec(value: str) -> tuple: + name, source = parse_name_value(value) + source_path = Path(source).expanduser().resolve() + if not source_path.is_file(): + msg = f"icon source does not exist: {source_path}" + raise argparse.ArgumentTypeError(msg) + + return name, source_path + + def artifact_relpath(arch: str, chip: str, compat: str, spec: PackageSpec) -> Path: # fmt: skip filename = spec.source.name @@ -96,6 +130,47 @@ def artifact_relpath(arch: str, chip: str, compat: str, return path +def icon_relpath(arch: str, chip: str, compat: str, name: str) -> Path: + # Keep one repository object per package. nxstore keys its local cache + # by package name and version, so a new catalog version fetches this + # object again without requiring versioned repository duplication. + return Path("icons") / arch / chip / compat / f"{name}.bin" + + +def encode_icon_rgb565(source: Path, size: int = ICON_SIZE) -> bytes: + """Convert an arbitrary source image into nxstore's raw icon format: + a 12-byte lv_image_header_t-compatible header (see the module + docstring comment above) followed by size*size RGB565 pixel data. + """ + + from PIL import Image + + with Image.open(source) as img: + img = img.convert("RGB").resize((size, size), Image.Resampling.LANCZOS) + pixels = list(img.getdata()) + + stride = size * 2 + header = struct.pack( + "> 3) + struct.pack_into(" tuple: return ( package["name"], @@ -171,6 +246,30 @@ def main() -> int: type=parse_package_spec, help=PACKAGE_SPEC_HELP, ) + parser.add_argument( + "--package-description", + action="append", + default=[], + type=parse_name_value, + metavar="NAME=TEXT", + help="Optional package description, keyed by package name", + ) + parser.add_argument( + "--package-category", + action="append", + default=[], + type=parse_name_value, + metavar="NAME=TEXT", + help="Optional package category, keyed by package name", + ) + parser.add_argument( + "--package-icon", + action="append", + default=[], + type=parse_icon_spec, + metavar="NAME=PATH", + help="Optional source image for a package icon", + ) args = parser.parse_args() repo_dir = args.repo_dir.expanduser().resolve() @@ -181,6 +280,9 @@ def main() -> int: for package in packages: packages_by_id[package_identity(package)] = package prefix = args.artifact_prefix.rstrip("/") + descriptions = dict(args.package_description) + categories = dict(args.package_category) + icons = dict(args.package_icon) for spec in args.package: relpath = artifact_relpath(args.arch, args.chip, args.compat, spec) @@ -201,6 +303,26 @@ def main() -> int: "sha256": sha256_file(destination), "type": spec.payload_type, } + + if spec.name in descriptions: + package["description"] = descriptions[spec.name] + + if spec.name in categories: + package["category"] = categories[spec.name] + + if spec.name in icons: + icon_bytes = encode_icon_rgb565(icons[spec.name]) + icon_dest = repo_dir / icon_relpath( + args.arch, args.chip, args.compat, spec.name + ) + icon_dest.parent.mkdir(parents=True, exist_ok=True) + icon_dest.write_bytes(icon_bytes) + + icon_rel = icon_relpath( + args.arch, args.chip, args.compat, spec.name + ).as_posix() + package["icon"] = f"{prefix}/{icon_rel}" if prefix else icon_rel + packages_by_id[package_identity(package)] = package packages = sorted(