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/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..2f08ea6230c 100644 --- a/system/nxpkg/Kconfig +++ b/system/nxpkg/Kconfig @@ -29,4 +29,15 @@ config SYSTEM_NXPKG_STACKSIZE int "'nxpkg' stack size" default 16384 +config SYSTEM_NXPKG_ROOT + string "'nxpkg' storage root" + default "/var/lib/nxpkg" + ---help--- + Base directory used by nxpkg for its local index, installed + metadata, temporary downloads, and package payload storage. + 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/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/pkg.h b/system/nxpkg/pkg.h index 39a4bf0c21d..8c1f6850a51 100644 --- a/system/nxpkg/pkg.h +++ b/system/nxpkg/pkg.h @@ -27,30 +27,93 @@ * Included Files ****************************************************************************/ +#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). + * 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 #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) +{ + return malloc(size); +} + +static inline void *pkg_zalloc(size_t size) +{ + return calloc(1, size); +} + +static inline void *pkg_realloc(void *ptr, size_t size) +{ + return realloc(ptr, size); +} + +static inline void pkg_free(void *ptr) +{ + free(ptr); +} + +static inline FAR char *pkg_path_alloc(void) +{ + return pkg_malloc(PATH_MAX); +} /**************************************************************************** * Public Types @@ -83,7 +146,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 @@ -116,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); @@ -124,6 +193,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 +222,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,7 +232,11 @@ 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); +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); @@ -178,7 +254,20 @@ 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, + 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); +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..7b326cfe836 100644 --- a/system/nxpkg/pkg_install.c +++ b/system/nxpkg/pkg_install.c @@ -25,10 +25,11 @@ ****************************************************************************/ #include -#include #include +#include #include #include +#include #include #include "pkg.h" @@ -37,58 +38,76 @@ * Private Functions ****************************************************************************/ -static int pkg_install_resolve_artifact(FAR char *buffer, size_t size, - FAR const struct pkg_manifest_s - *manifest) +static int pkg_install_acquire_lock(FAR const char *name, FAR char *path, + size_t size) { int ret; - if (manifest->artifact[0] == '/') + ret = pkg_store_ensure_package_root(name); + if (ret < 0) { - ret = snprintf(buffer, size, "%s", manifest->artifact); - if (ret < 0) - { - return ret; - } - - return (size_t)ret >= size ? -ENAMETOOLONG : 0; + return ret; } - ret = snprintf(buffer, size, "%s/%s", PKG_REPO_DIR, manifest->artifact); + ret = pkg_store_format_lock_path(path, size, name); if (ret < 0) { return ret; } - return (size_t)ret >= size ? -ENAMETOOLONG : 0; + ret = pkg_lock_create(path); + if (ret == -EEXIST) + { + pkg_reclaim_stale_lock(path); + ret = pkg_lock_create(path); + } + + return ret == -EEXIST ? -EBUSY : ret; } -static int pkg_install_acquire_lock(FAR const char *name, FAR char *path, - size_t size) +/**************************************************************************** + * Name: pkg_install_acquire_installed_lock + * + * Description: + * Acquire the global installed-database lock. This serializes the + * read-modify-write sequence used by install, uninstall, and rollback. + * + ****************************************************************************/ + +static int pkg_install_acquire_installed_lock(FAR char *path, size_t size) { - int fd; int ret; + int tries; - ret = pkg_store_ensure_package_root(name); + ret = snprintf(path, size, PKG_ROOT_DIR "/instpkg.lk"); if (ret < 0) { return ret; } - ret = pkg_store_format_lock_path(path, size, name); - if (ret < 0) + if ((size_t)ret >= size) { - return ret; + return -ENAMETOOLONG; } - fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0644); - if (fd < 0) + for (tries = 0; tries < 100; tries++) { - return errno == EEXIST ? -EBUSY : -errno; + ret = pkg_lock_create(path); + if (ret == 0) + { + return 0; + } + + if (ret != -EEXIST) + { + return ret; + } + + pkg_reclaim_stale_lock(path); + usleep(20 * 1000); } - close(fd); - return 0; + return -EBUSY; } static bool pkg_install_has_version( @@ -108,8 +127,63 @@ static bool pkg_install_has_version( return false; } +static int pkg_install_prune_oldest_version( + 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; + + /* 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; + } + + /* 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++) + { + 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) + FAR const char *version, + FAR char *pruned_version, + size_t pruned_version_size) { int ret; @@ -120,7 +194,12 @@ 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, pruned_version, + pruned_version_size); + if (ret < 0) + { + return ret; + } } ret = snprintf(entry->versions[entry->version_count], @@ -142,7 +221,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; @@ -196,12 +277,13 @@ 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( 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 +291,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 +331,68 @@ 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]; + char pruned_version[PKG_VERSION_MAX + 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) + pruned_version[0] = '\0'; + + 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 +400,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 +415,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 +460,217 @@ 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, lock); + 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_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) { + pkg_error("ensure version dir failed: %d", ret); goto errout; } - ret = pkg_store_format_payload_path(payload, sizeof(payload), + 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_metadata_load_installed(installed); + 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; } - ret = pkg_install_update_installed(installed, manifest); + 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_write_pointers(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); goto errout; } ret = pkg_metadata_save_installed(installed); if (ret < 0) { + pkg_error("save installed metadata failed: %d", ret); 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) + { + /* 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); + installed_lock_held = false; + ret = pkg_txn_write_state(name, PKG_TXN_ACTIVATED); if (ret < 0) { - goto errout; + /* 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); } 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 +682,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 +750,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 +760,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 +768,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 +776,315 @@ 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; + 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 (!pkg_validate_path_component(name)) + { + pkg_error("remove requires a valid 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_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; + } + + ret = pkg_metadata_load_installed(db); + 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; + } + + entry = pkg_metadata_find_installed(db, 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; + } + + /* 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++) + { + 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_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); + } + + if (pkg_store_format_previous_path(path, sizeof(path), name) == 0) + { + pkg_store_remove_file(path); + } + + pkg_store_remove_file(package_lock); + + 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 package_lock[PATH_MAX]; + char installed_lock[PATH_MAX]; + char swap[PKG_VERSION_MAX + 1]; + struct stat st; + int ret; + + if (!pkg_validate_path_component(name)) + { + pkg_error("rollback requires a valid 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_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; + } + + ret = pkg_metadata_load_installed(db); + 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; + } + + entry = pkg_metadata_find_installed(db, 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 (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; + } + + 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); + 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_store_remove_file(package_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_store_remove_file(package_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_store_remove_file(package_lock); + pkg_free(db); + pkg_error("unable to update previous version"); + return EXIT_FAILURE; + } + + /* 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_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 save installed metadata: %d", ret); + return EXIT_FAILURE; + } + + ret = pkg_install_write_pointers(db, name); + pkg_store_remove_file(installed_lock); + pkg_store_remove_file(package_lock); + if (ret < 0) + { + pkg_error("unable to refresh current/previous pointers: %d", ret); + } + + 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..40dfb710746 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]) == 0 ? EXIT_SUCCESS : EXIT_FAILURE; } 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..dca17546c21 100644 --- a/system/nxpkg/pkg_manifest.c +++ b/system/nxpkg/pkg_manifest.c @@ -78,6 +78,49 @@ static bool pkg_validate_hex(FAR const char *value) * Public Functions ****************************************************************************/ +/**************************************************************************** + * 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. + * + ****************************************************************************/ + +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; +} + const char *pkg_manifest_type_str(enum pkg_payload_type_e type) { switch (type) @@ -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..951b4772bf8 100644 --- a/system/nxpkg/pkg_metadata.c +++ b/system/nxpkg/pkg_metadata.c @@ -24,6 +24,7 @@ * Included Files ****************************************************************************/ +#include #include #include #include @@ -100,6 +101,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 +219,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); } @@ -221,6 +303,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)); @@ -285,38 +370,82 @@ 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; } 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; } - } - else - { - int ret; - ret = pkg_string_cmp(lhs, PKG_VERSION_MAX + 1, - rhs, PKG_VERSION_MAX + 1); + ret = memcmp(lhs, rhs, leftlen); if (ret < 0) { return -1; @@ -326,6 +455,26 @@ static int pkg_metadata_version_token_cmp(FAR const char *lhs, { return 1; } + + cmpleft = leftdigits; + cmpright = rightdigits; + } + else + { + cmpleft = lhs; + cmpright = rhs; + } + + ret = pkg_string_cmp(cmpleft, PKG_VERSION_MAX + 1, + cmpright, PKG_VERSION_MAX + 1); + if (ret < 0) + { + return -1; + } + + if (ret > 0) + { + return 1; } return 0; @@ -395,6 +544,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 +561,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 +626,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 +660,84 @@ 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); +} + +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) @@ -573,7 +818,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 +835,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..2a4e0ef2775 --- /dev/null +++ b/system/nxpkg/pkg_repo.c @@ -0,0 +1,774 @@ +/**************************************************************************** + * 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 +#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 +#define PKG_REPO_HTTP "http://" +#define PKG_REPO_HTTPS "https://" +#define PKG_REPO_SOURCE_KEY "_nxpkg_source" + +/**************************************************************************** + * Private Types + ****************************************************************************/ + +#ifdef CONFIG_NETUTILS_WEBCLIENT +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 + +/**************************************************************************** + * 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 pkg_repo_copy_string(buffer, size, "."); + } + + 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) +{ + FAR cJSON *root; + FAR cJSON *source; + FAR char *text = NULL; + char path[PATH_MAX]; + size_t length; + int ret; + + ret = pkg_store_format_index_path(path, sizeof(path)); + if (ret < 0) + { + return ret; + } + + ret = pkg_store_read_text(path, &text); + if (ret < 0) + { + return ret; + } + + root = cJSON_Parse(text); + pkg_free(text); + if (root == NULL) + { + return -EINVAL; + } + + 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) +{ + 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; + } + + if (nwritten == 0) + { + return -EIO; + } + + cursor += nwritten; + 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, + FAR const char *renew_lock_path) +{ + 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; + fetch.renew_lock_path = renew_lock_path; + + 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; + } + + ret = close(fetch.fd); + if (ret < 0) + { + ret = -errno; + unlink(dest); + return ret; + } + + 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, 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, + 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, + FAR const char *renew_lock_path) +{ + if (source == NULL || dest == NULL) + { + return -EINVAL; + } + + if (pkg_source_is_url(source)) + { +#ifdef CONFIG_NETUTILS_WEBCLIENT + return pkg_repo_fetch_url(source, dest, renew_lock_path); +#else + return -ENOSYS; +#endif + } + + return pkg_store_copy_file(source, dest); +} + +/**************************************************************************** + * Name: pkg_repo_acquire_sync_lock + * + * Description: + * Serialize catalog synchronization so concurrent downloads cannot + * commit out of order. + * + ****************************************************************************/ + +static int pkg_repo_acquire_sync_lock(FAR char *path, size_t size) +{ + 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++) + { + ret = pkg_lock_create(path); + if (ret == 0) + { + return 0; + } + + if (ret != -EEXIST) + { + return ret; + } + + pkg_reclaim_stale_lock(path); + usleep(20 * 1000); + } + + return -EBUSY; +} + +int pkg_sync(FAR const char *source) +{ + FAR struct pkg_index_s *index = NULL; + FAR char *text = NULL; + FAR char *tmp = NULL; + FAR char *index_path = NULL; + FAR char *lock; + bool remove_tmp = false; + int ret; + + if (source == NULL || source[0] == '\0') + { + pkg_error("sync requires a non-empty index 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; + } + + index = pkg_zalloc(sizeof(*index)); + tmp = pkg_path_alloc(); + index_path = pkg_path_alloc(); + if (index == NULL || tmp == NULL || index_path == NULL) + { + pkg_error("unable to allocate index metadata buffer"); + ret = -ENOMEM; + goto out; + } + + ret = pkg_store_prepare_layout(); + if (ret < 0) + { + pkg_error("unable to prepare package layout: %d", ret); + goto out; + } + + /* 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"); + ret = -ENAMETOOLONG; + goto out; + } + + ret = pkg_acquire_source(source, tmp, lock); + if (ret < 0) + { + pkg_error("unable to fetch index source '%s': %d", source, ret); + goto out; + } + + remove_tmp = true; + ret = pkg_metadata_load_index_path(tmp, index); + if (ret < 0) + { + pkg_error("downloaded index is invalid: %d", ret); + goto out; + } + + ret = pkg_store_read_text(tmp, &text); + if (ret < 0) + { + pkg_error("unable to read fetched index: %d", ret); + goto out; + } + + /* Commit the catalog and its source in one atomic file replacement. */ + + ret = pkg_repo_attach_source(&text, source); + if (ret < 0) + { + pkg_error("unable to record repository source: %d", ret); + goto out; + } + + ret = pkg_store_format_index_path(index_path, PATH_MAX); + if (ret < 0) + { + pkg_error("unable to resolve local index path: %d", ret); + goto out; + } + + ret = pkg_store_write_text_atomic(index_path, text); + if (ret < 0) + { + pkg_error("unable to write local index: %d", ret); + goto out; + } + + pkg_info("synced package index from %s", source); + ret = 0; + +out: + if (remove_tmp) + { + 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; +} + +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..c73b7b5831e 100644 --- a/system/nxpkg/pkg_store.c +++ b/system/nxpkg/pkg_store.c @@ -24,8 +24,13 @@ * Included Files ****************************************************************************/ +#include #include #include +#include +#include +#include +#include #include #include #include @@ -35,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, @@ -145,6 +224,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; } @@ -188,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]; @@ -228,6 +425,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 +468,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 +544,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 +564,169 @@ 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)) + ret = close(fd); + if (ret < 0) { - return -ENAMETOOLONG; + ret = -errno; + unlink(path); + return ret; + } + + 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,19 +743,42 @@ int pkg_store_write_text_atomic(FAR const char *path, FAR const char *text) return ret; } - if (close(fd) < 0) + /* 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. + */ + + ret = fsync(fd); + if (ret < 0) { + ret = -errno; + close(fd); unlink(tmp); - return -errno; + return ret; } - if (rename(tmp, path) < 0) + ret = close(fd); + if (ret < 0) { + ret = -errno; unlink(tmp); - return -errno; + return ret; + } + + ret = rename(tmp, path); + if (ret < 0) + { + ret = -errno; + unlink(tmp); + return ret; } return 0; +#endif } int pkg_store_copy_file(FAR const char *src, FAR const char *dest) @@ -430,6 +787,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 +808,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 +846,45 @@ int pkg_store_copy_file(FAR const char *src, FAR const char *dest) close(infd); - if (close(outfd) < 0) +#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) { - unlink(dest); - return -errno; + ret = -errno; + close(outfd); + unlink(outpath); + return ret; } +#endif + + ret = close(outfd); + if (ret < 0) + { + ret = -errno; + unlink(outpath); + return ret; + } + +#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 +897,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; +} 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..230d84250e2 --- /dev/null +++ b/system/nxstore/Kconfig @@ -0,0 +1,41 @@ +# +# 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 + 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 + 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 + +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/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/nxstore_main.c b/system/nxstore/nxstore_main.c new file mode 100644 index 00000000000..0e28706f257 --- /dev/null +++ b/system/nxstore/nxstore_main.c @@ -0,0 +1,2056 @@ +/**************************************************************************** + * 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 + +#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 */ + +/* 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 + ****************************************************************************/ + +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; + +/* 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 + * 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); +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 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) +{ + 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); +} + +/**************************************************************************** + * 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), 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); + 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; + FAR struct pkg_manifest_s *installed; + posix_spawnattr_t attr; + char path[PATH_MAX]; + FAR char *argv[PKG_LAUNCH_ARGS_MAX + 2]; + size_t i; + 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; + } + + 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; + } + + entry = pkg_metadata_find_installed(db, manifest->name); + 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_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; + 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; + } + + ret = posix_spawnattr_setstacksize(&attr, NXSTORE_LAUNCH_STACKSIZE); + if (ret == 0) + { + 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); + 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: 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 + * + * 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 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 *status_bar = lv_obj_get_child(g_active.btn, 1); + + if (status_bar != NULL) + { + lv_obj_set_style_bg_color(status_bar, + lv_color_hex(NXSTORE_COLOR_SUCCESS), 0); + } + } + + 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. + */ + + 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: + 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; + } + + /* 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 + * 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 *status_bar; + + snprintf(text, sizeof(text), "Removed - tap to reinstall"); + + /* 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. + */ + + status_bar = lv_obj_get_child(card, 1); + + if (status_bar != NULL) + { + lv_obj_set_style_bg_color(status_bar, + lv_color_hex(NXSTORE_COLOR_ACCENT), + 0); + } + } + 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 *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. + * + ****************************************************************************/ + +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) && nxstore_is_up_to_date(manifest)) + { + char orig[192]; + char text[256]; + pid_t pid = 0; + int ret; + + /* 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; + } + + 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(); + } + + /* 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) + { + lv_label_set_text(subtitle, orig); + } + + lv_obj_clear_state(card, LV_STATE_DISABLED); + + if (ret == 0) + { + 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), + "%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). */ + + 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; + } + + 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: 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. + * + * 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 + * 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; + size_t offset; + 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-%s.bin", + manifest->name, manifest->version); + + 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; + } + + 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 (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; + } + + 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)st.st_size) + { + pkg_free(buf); + unlink(cache_path); + 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 + * + * 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 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; + 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 + * 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); + 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) + * 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: 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(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); + + 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 + * 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 (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); + } + 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); + + 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 + * 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(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 + * 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; + time_t retry_deadline; + + 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); + + 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(); + + /* 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. + */ + + retry_deadline = time(NULL) + 15; + do + { + ret = pkg_sync(repo_url); + if (ret != -ENETUNREACH && ret != -ENETDOWN && + ret != -EHOSTUNREACH) + { + break; + } + + lv_timer_handler(); + usleep(250 * 1000); + } + while (time(NULL) < retry_deadline); + + 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_SYSTEM_NXSTORE_FBDEVPATH; + info.input_path = CONFIG_SYSTEM_NXSTORE_INPUT_DEVPATH; + lv_nuttx_init(&info, &result); + + if (result.disp == NULL) + { + printf("nxstore: lv_nuttx_init failure!\n"); + return 1; + } + + /* 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, 20); + 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; +}